Recipe-app main
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(191)
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsInt } from 'class-validator';
|
||||
|
||||
export class MergeProductsDto {
|
||||
@IsInt()
|
||||
sourceProductId!: number;
|
||||
|
||||
@IsInt()
|
||||
targetProductId!: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateCanonicalNameDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(191)
|
||||
canonicalName!: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(191)
|
||||
name?: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
import { ProductsService } from './products.service';
|
||||
import { MergeProductsDto } from './dto/merge-products.dto';
|
||||
import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto';
|
||||
|
||||
@Controller('products')
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.productsService.findAll();
|
||||
}
|
||||
|
||||
@Get('duplicates')
|
||||
findDuplicates() {
|
||||
return this.productsService.findDuplicateCandidates();
|
||||
}
|
||||
|
||||
@Get('merge-preview')
|
||||
previewMerge(
|
||||
@Query('sourceProductId', ParseIntPipe) sourceProductId: number,
|
||||
@Query('targetProductId', ParseIntPipe) targetProductId: number,
|
||||
) {
|
||||
return this.productsService.previewMerge(sourceProductId, targetProductId);
|
||||
}
|
||||
|
||||
@Post('backfill-canonical')
|
||||
backfillCanonical() {
|
||||
return this.productsService.backfillCanonicalNames();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.productsService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: CreateProductDto) {
|
||||
return this.productsService.create(body);
|
||||
}
|
||||
|
||||
@Post('merge')
|
||||
merge(@Body() body: MergeProductsDto) {
|
||||
return this.productsService.merge(body.sourceProductId, body.targetProductId);
|
||||
}
|
||||
|
||||
@Patch(':id/canonical-name')
|
||||
updateCanonicalName(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateCanonicalNameDto,
|
||||
) {
|
||||
return this.productsService.updateCanonicalName(id, body.canonicalName);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateProductDto,
|
||||
) {
|
||||
return this.productsService.update(id, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.productsService.remove(id);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
restore(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.productsService.restore(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { normalizeName } from '../common/utils/normalize-name';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { UpdateProductDto } from './dto/update-product.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
return this.prisma.product.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findDuplicateCandidates() {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
const grouped = new Map<string, typeof products>();
|
||||
|
||||
for (const product of products) {
|
||||
const key = product.normalizedName;
|
||||
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, []);
|
||||
}
|
||||
|
||||
grouped.get(key)!.push(product);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.filter(([, items]) => items.length > 1)
|
||||
.map(([normalizedName, items]) => ({
|
||||
normalizedName,
|
||||
count: items.length,
|
||||
products: items,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!product) {
|
||||
throw new NotFoundException(`Product with id ${id} not found`);
|
||||
}
|
||||
|
||||
return product;
|
||||
}
|
||||
|
||||
async create(data: CreateProductDto) {
|
||||
const name = data.name.trim();
|
||||
const normalizedName = normalizeName(name);
|
||||
|
||||
const existing = await this.prisma.product.findUnique({
|
||||
where: { normalizedName },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (!existing.isActive) {
|
||||
return this.prisma.product.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
name,
|
||||
canonicalName: name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.prisma.product.create({
|
||||
data: {
|
||||
name,
|
||||
normalizedName,
|
||||
canonicalName: name,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: number, data: UpdateProductDto) {
|
||||
await this.findOne(id);
|
||||
|
||||
const updateData: {
|
||||
name?: string;
|
||||
normalizedName?: string;
|
||||
canonicalName?: string;
|
||||
} = {};
|
||||
|
||||
if (typeof data.name === 'string') {
|
||||
const name = data.name.trim();
|
||||
const normalizedName = normalizeName(name);
|
||||
|
||||
const existing = await this.prisma.product.findUnique({
|
||||
where: { normalizedName },
|
||||
});
|
||||
|
||||
if (existing && existing.id !== id) {
|
||||
if (!existing.isActive) {
|
||||
return this.prisma.product.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
name,
|
||||
canonicalName: name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
updateData.name = name;
|
||||
updateData.normalizedName = normalizedName;
|
||||
updateData.canonicalName = name;
|
||||
}
|
||||
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
async updateCanonicalName(id: number, canonicalName: string) {
|
||||
await this.findOne(id);
|
||||
|
||||
const cleaned = canonicalName.trim();
|
||||
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
canonicalName: cleaned,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
await this.findOne(id);
|
||||
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isActive: false,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async restore(id: number) {
|
||||
const product = await this.findOne(id);
|
||||
|
||||
if (product.isActive) {
|
||||
return product;
|
||||
}
|
||||
|
||||
return this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async merge(sourceProductId: number, targetProductId: number) {
|
||||
if (sourceProductId === targetProductId) {
|
||||
throw new Error('sourceProductId och targetProductId kan inte vara samma');
|
||||
}
|
||||
|
||||
const source = await this.prisma.product.findUnique({
|
||||
where: { id: sourceProductId },
|
||||
});
|
||||
|
||||
if (!source) {
|
||||
throw new NotFoundException(
|
||||
`Source product with id ${sourceProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = await this.prisma.product.findUnique({
|
||||
where: { id: targetProductId },
|
||||
});
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException(
|
||||
`Target product with id ${targetProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async previewMerge(sourceProductId: number, targetProductId: number) {
|
||||
if (sourceProductId === targetProductId) {
|
||||
throw new Error('sourceProductId och targetProductId kan inte vara samma');
|
||||
}
|
||||
|
||||
const [source, target, sourceInventoryCount, targetInventoryCount] =
|
||||
await Promise.all([
|
||||
this.prisma.product.findUnique({
|
||||
where: { id: sourceProductId },
|
||||
}),
|
||||
this.prisma.product.findUnique({
|
||||
where: { id: targetProductId },
|
||||
}),
|
||||
this.prisma.inventoryItem.count({
|
||||
where: { productId: sourceProductId },
|
||||
}),
|
||||
this.prisma.inventoryItem.count({
|
||||
where: { productId: targetProductId },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!source) {
|
||||
throw new NotFoundException(
|
||||
`Source product with id ${sourceProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
throw new NotFoundException(
|
||||
`Target product with id ${targetProductId} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
source: {
|
||||
...source,
|
||||
inventoryCount: sourceInventoryCount,
|
||||
},
|
||||
target: {
|
||||
...target,
|
||||
inventoryCount: targetInventoryCount,
|
||||
},
|
||||
outcome: {
|
||||
inventoryItemsToMove: sourceInventoryCount,
|
||||
sourceWillBeSoftDeleted: true,
|
||||
targetWillRemainActive: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async backfillCanonicalNames() {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: {
|
||||
canonicalName: null,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await this.prisma.$transaction(
|
||||
products.map((product) =>
|
||||
this.prisma.product.update({
|
||||
where: { id: product.id },
|
||||
data: {
|
||||
canonicalName: product.name,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Canonical names backfilled successfully',
|
||||
updatedCount: results.length,
|
||||
products: results,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user