From 14a11074667d2332c43599de65547cd983fa72a5 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 9 May 2026 23:19:28 +0200 Subject: [PATCH] feat: add private product management endpoints for updating canonical names and merging products --- backend/src/products/products.controller.ts | 20 +++ backend/src/products/products.service.ts | 125 +++++++++++++----- flutter/lib/core/api/api_paths.dart | 2 + .../features/admin/data/admin_repository.dart | 21 +++ 4 files changed, 134 insertions(+), 34 deletions(-) diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 94c0b16c..e850e574 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -140,6 +140,26 @@ export class ProductsController { return this.productsService.createPending(body, req.user.id); } + // ── Privata produkter: rename & merge ────────────────────────────────────── + // Inloggade användare kan hantera sina egna privata produkter + + @Patch('private/:id/canonical-name') + updateCanonicalNamePrivate( + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdateCanonicalNameDto, + @Request() req: { user: { id: number } }, + ) { + return this.productsService.updateCanonicalNamePrivate(req.user.id, id, body.canonicalName); + } + + @Post('private/merge') + mergePrivate( + @Body() body: MergeProductsDto, + @Request() req: { user: { id: number } }, + ) { + return this.productsService.mergePrivate(req.user.id, body.sourceProductId, body.targetProductId); + } + @Roles('admin') @Post('merge') merge(@Body() body: MergeProductsDto) { diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index e3dd0e51..425ee570 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { normalizeName } from '../common/utils/normalize-name'; import { CreateProductDto } from './dto/create-product.dto'; @@ -201,15 +201,7 @@ export class ProductsService { async updateCanonicalName(id: number, canonicalName: string) { await this.findOne(id); - - const cleaned = canonicalName.trim(); - - return this.prisma.product.update({ - where: { id }, - data: { - canonicalName: cleaned, - }, - }); + return this._updateCanonicalNameCore(id, canonicalName); } async findDeleted() { @@ -268,31 +260,12 @@ export class ProductsService { throw new Error('sourceProductId och targetProductId kan inte vara samma'); } - const source = await this.findProductByIdOrThrow(sourceProductId, 'Source'); - const target = await this.findProductByIdOrThrow(targetProductId, 'Target'); + await Promise.all([ + this.findProductByIdOrThrow(sourceProductId, 'Source'), + this.findProductByIdOrThrow(targetProductId, 'Target'), + ]); - return this.prisma.$transaction(async (tx) => { - const movedInventoryCount = await tx.inventoryItem.updateMany({ - where: { productId: sourceProductId }, - data: { productId: targetProductId }, - }); - - const softDeletedSource = await tx.product.update({ - where: { id: sourceProductId }, - data: { - isActive: false, - deletedAt: new Date(), - }, - }); - - return { - message: 'Products merged successfully', - sourceProductId, - targetProductId, - movedInventoryCount: movedInventoryCount.count, - softDeletedSource, - }; - }); + return this._mergeCore(sourceProductId, targetProductId); } async previewMerge(sourceProductId: number, targetProductId: number) { @@ -496,4 +469,88 @@ export class ProductsService { setStatus(id: number, status: string) { return this.prisma.product.update({ where: { id }, data: { status } }); } + + // ── Privata produkter (användare kan hantera sina egna) ────────────────────── + // Hjälpfunktioner för att undvika kodduplicering mellan admin och user-scope + + private async _updateCanonicalNameCore(id: number, canonicalName: string) { + const cleaned = canonicalName.trim(); + return this.prisma.product.update({ + where: { id }, + data: { canonicalName: cleaned }, + }); + } + + private async _mergeCore(sourceProductId: number, targetProductId: number) { + return this.prisma.$transaction(async (tx) => { + const movedInventoryCount = await tx.inventoryItem.updateMany({ + where: { productId: sourceProductId }, + data: { productId: targetProductId }, + }); + + const softDeletedSource = await tx.product.update({ + where: { id: sourceProductId }, + data: { + isActive: false, + deletedAt: new Date(), + }, + }); + + return { + message: 'Produkter slagna ihop', + sourceProductId, + targetProductId, + movedInventoryCount: movedInventoryCount.count, + softDeletedSource, + }; + }); + } + + async updateCanonicalNamePrivate(userId: number, id: number, canonicalName: string) { + const product = await this.prisma.product.findUnique({ + where: { id }, + select: { ownerId: true, isPrivate: true }, + }); + + if (!product) { + throw new NotFoundException(`Privat produkt med id ${id} hittades inte`); + } + + if (!product.isPrivate || product.ownerId !== userId) { + throw new ForbiddenException('Du har inte behörighet att ändra denna produkt'); + } + + return this._updateCanonicalNameCore(id, canonicalName); + } + + async mergePrivate(userId: number, sourceProductId: number, targetProductId: number) { + if (sourceProductId === targetProductId) { + throw new Error('Du kan inte slå ihop en produkt med sig själv'); + } + + const [source, target] = await Promise.all([ + this.prisma.product.findUnique({ + where: { id: sourceProductId }, + select: { ownerId: true, isPrivate: true, id: true }, + }), + this.prisma.product.findUnique({ + where: { id: targetProductId }, + select: { ownerId: true, isPrivate: true, id: true }, + }), + ]); + + if (!source || !target) { + throw new NotFoundException('En eller båda produkterna hittades inte'); + } + + if (!source.isPrivate || !target.isPrivate) { + throw new ForbiddenException('Du kan bara slå ihop privata produkter'); + } + + if (source.ownerId !== userId || target.ownerId !== userId) { + throw new ForbiddenException('Du kan bara slå ihop dina egna produkter'); + } + + return this._mergeCore(sourceProductId, targetProductId); + } } \ No newline at end of file diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index dc2c0825..8c89ffdf 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -10,11 +10,13 @@ class ProductApiPaths { static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const deleted = '/products/deleted'; static const merge = '/products/merge'; + static const mergePrivate = '/products/private/merge'; static String mergePreview(int sourceProductId, int targetProductId) => '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; static String setStatus(int id) => '/products/$id/status'; static String update(int id) => '/products/$id'; static String canonicalName(int id) => '/products/$id/canonical-name'; + static String canonicalNamePrivate(int id) => '/products/private/$id/canonical-name'; static String remove(int id) => '/products/$id'; static String restore(int id) => '/products/$id/restore'; static const bulkUpdate = '/products/bulk-update'; diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index c9f17f45..0fdba6a0 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -191,12 +191,33 @@ class AdminRepository { Future restoreProduct(int productId) => _postVoid(ProductApiPaths.restore(productId)); + // ── Product canonical name updates ──────────────────────────────────────── + // Admin can update any product; users can only update their own private products + Future updateCanonicalName(int productId, String canonicalName) => _patchVoid( ProductApiPaths.canonicalName(productId), {'canonicalName': canonicalName.trim()}, ); + Future updateCanonicalNamePrivate(int productId, String canonicalName) => + _patchVoid( + ProductApiPaths.canonicalNamePrivate(productId), + {'canonicalName': canonicalName.trim()}, + ); + + // ── Product merging ──────────────────────────────────────────────────────── + // Admin can merge any products; users can only merge their own private products + + Future mergeProductsPrivate({ + required int sourceProductId, + required int targetProductId, + }) => + _postVoid(ProductApiPaths.mergePrivate, { + 'sourceProductId': sourceProductId, + 'targetProductId': targetProductId, + }); + /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. Future> createProduct(String name, {int? categoryId}) => _post>(