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);
|
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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>>(
|
||||||
|
|||||||
Reference in New Issue
Block a user