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);
}
// ── 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) {
+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 { 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);
}
}