feat: add private product management endpoints for updating canonical names and merging products
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -191,12 +191,33 @@ class AdminRepository {
|
||||
Future<void> 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<void> updateCanonicalName(int productId, String canonicalName) =>
|
||||
_patchVoid(
|
||||
ProductApiPaths.canonicalName(productId),
|
||||
{'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?}`.
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||
_post<Map<String, dynamic>>(
|
||||
|
||||
Reference in New Issue
Block a user