feat: add private product management endpoints for updating canonical names and merging products
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 23:19:28 +02:00
parent 8e276a34fe
commit 14a1107466
4 changed files with 134 additions and 34 deletions
@@ -140,6 +140,26 @@ export class ProductsController {
return this.productsService.createPending(body, req.user.id); 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') @Roles('admin')
@Post('merge') @Post('merge')
merge(@Body() body: MergeProductsDto) { merge(@Body() body: MergeProductsDto) {
+91 -34
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { normalizeName } from '../common/utils/normalize-name'; import { normalizeName } from '../common/utils/normalize-name';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
@@ -201,15 +201,7 @@ export class ProductsService {
async updateCanonicalName(id: number, canonicalName: string) { async updateCanonicalName(id: number, canonicalName: string) {
await this.findOne(id); await this.findOne(id);
return this._updateCanonicalNameCore(id, canonicalName);
const cleaned = canonicalName.trim();
return this.prisma.product.update({
where: { id },
data: {
canonicalName: cleaned,
},
});
} }
async findDeleted() { async findDeleted() {
@@ -268,31 +260,12 @@ export class ProductsService {
throw new Error('sourceProductId och targetProductId kan inte vara samma'); throw new Error('sourceProductId och targetProductId kan inte vara samma');
} }
const source = await this.findProductByIdOrThrow(sourceProductId, 'Source'); await Promise.all([
const target = await this.findProductByIdOrThrow(targetProductId, 'Target'); this.findProductByIdOrThrow(sourceProductId, 'Source'),
this.findProductByIdOrThrow(targetProductId, 'Target'),
]);
return this.prisma.$transaction(async (tx) => { return this._mergeCore(sourceProductId, targetProductId);
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,
};
});
} }
async previewMerge(sourceProductId: number, targetProductId: number) { async previewMerge(sourceProductId: number, targetProductId: number) {
@@ -496,4 +469,88 @@ export class ProductsService {
setStatus(id: number, status: string) { setStatus(id: number, status: string) {
return this.prisma.product.update({ where: { id }, data: { status } }); 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);
}
} }
+2
View File
@@ -10,11 +10,13 @@ class ProductApiPaths {
static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const aiCategorizeBulk = '/products/ai-categorize-bulk';
static const deleted = '/products/deleted'; static const deleted = '/products/deleted';
static const merge = '/products/merge'; static const merge = '/products/merge';
static const mergePrivate = '/products/private/merge';
static String mergePreview(int sourceProductId, int targetProductId) => static String mergePreview(int sourceProductId, int targetProductId) =>
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status'; static String setStatus(int id) => '/products/$id/status';
static String update(int id) => '/products/$id'; static String update(int id) => '/products/$id';
static String canonicalName(int id) => '/products/$id/canonical-name'; 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 remove(int id) => '/products/$id';
static String restore(int id) => '/products/$id/restore'; static String restore(int id) => '/products/$id/restore';
static const bulkUpdate = '/products/bulk-update'; static const bulkUpdate = '/products/bulk-update';
@@ -191,12 +191,33 @@ class AdminRepository {
Future<void> restoreProduct(int productId) => Future<void> restoreProduct(int productId) =>
_postVoid(ProductApiPaths.restore(productId)); _postVoid(ProductApiPaths.restore(productId));
// ── Product canonical name updates ────────────────────────────────────────
// Admin can update any product; users can only update their own private products
Future<void> updateCanonicalName(int productId, String canonicalName) => Future<void> updateCanonicalName(int productId, String canonicalName) =>
_patchVoid( _patchVoid(
ProductApiPaths.canonicalName(productId), ProductApiPaths.canonicalName(productId),
{'canonicalName': canonicalName.trim()}, {'canonicalName': canonicalName.trim()},
); );
Future<void> 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<void> 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?}`. /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
_post<Map<String, dynamic>>( _post<Map<String, dynamic>>(