Recipe-app main

This commit is contained in:
2026-04-09 09:14:39 +02:00
commit 962f4e4be5
10015 changed files with 2445177 additions and 0 deletions
@@ -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);
}
}
+9
View File
@@ -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 {}
+308
View File
@@ -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,
};
}
}