feat: enhance product model with subcategory, brand, tags, and nutrition; update related DTOs and services

This commit is contained in:
Nils-Johan Gynther
2026-04-17 18:11:06 +02:00
parent a05d907608
commit a4ea9be7a1
10 changed files with 517 additions and 33 deletions
+33
View File
@@ -12,6 +12,8 @@ model Product {
name String
normalizedName String @unique
category String?
subcategory String?
brand String?
canonicalName String?
isActive Boolean @default(true)
deletedAt DateTime?
@@ -22,6 +24,8 @@ model Product {
recipeIngredients RecipeIngredient[]
pantryItems PantryItem[]
receiptAliases ReceiptAlias[]
tags ProductTag[]
nutrition Nutrition?
}
model InventoryItem {
@@ -115,4 +119,33 @@ model MealPlanEntry {
@@unique([date])
@@index([date])
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
products ProductTag[]
}
model ProductTag {
productId Int
tagId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([productId, tagId])
@@index([tagId])
}
model Nutrition {
id Int @id @default(autoincrement())
productId Int @unique
calories Float?
protein Float?
fat Float?
carbohydrates Float?
salt Float?
sugar Float?
fiber Float?
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
}
+8
View File
@@ -0,0 +1,8 @@
import { IsArray, IsString, MaxLength } from 'class-validator';
export class SetTagsDto {
@IsArray()
@IsString({ each: true })
@MaxLength(100, { each: true })
tags!: string[];
}
@@ -16,4 +16,14 @@ export class UpdateProductDto {
@IsString()
@MaxLength(191)
category?: string;
@IsOptional()
@IsString()
@MaxLength(191)
subcategory?: string;
@IsOptional()
@IsString()
@MaxLength(191)
brand?: string;
}
@@ -0,0 +1,38 @@
import { IsNumber, IsOptional, Min } from 'class-validator';
export class UpsertNutritionDto {
@IsOptional()
@IsNumber()
@Min(0)
calories?: number;
@IsOptional()
@IsNumber()
@Min(0)
protein?: number;
@IsOptional()
@IsNumber()
@Min(0)
fat?: number;
@IsOptional()
@IsNumber()
@Min(0)
carbohydrates?: number;
@IsOptional()
@IsNumber()
@Min(0)
salt?: number;
@IsOptional()
@IsNumber()
@Min(0)
sugar?: number;
@IsOptional()
@IsNumber()
@Min(0)
fiber?: number;
}
+37 -10
View File
@@ -7,6 +7,7 @@ import {
ParseIntPipe,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import { CreateProductDto } from './dto/create-product.dto';
@@ -14,25 +15,35 @@ 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';
import { SetTagsDto } from './dto/set-tags.dto';
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@Get()
findAll() {
return this.productsService.findAll();
findAll(
@Query('tag') tag?: string,
@Query('subcategory') subcategory?: string,
) {
return this.productsService.findAll({ tag, subcategory });
}
@Get('tags')
findAllTags() {
return this.productsService.findAllTags();
}
@Get('duplicates')
findDuplicates() {
return this.productsService.findDuplicateCandidates();
}
@Get('merge-preview')
@Get('merge-preview')
previewMerge(
@Query('sourceProductId', ParseIntPipe) sourceProductId: number,
@Query('targetProductId', ParseIntPipe) targetProductId: number,
@Query('sourceProductId', ParseIntPipe) sourceProductId: number,
@Query('targetProductId', ParseIntPipe) targetProductId: number,
) {
return this.productsService.previewMerge(sourceProductId, targetProductId);
}
@@ -59,10 +70,26 @@ export class ProductsController {
@Patch(':id/canonical-name')
updateCanonicalName(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateCanonicalNameDto,
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateCanonicalNameDto,
) {
return this.productsService.updateCanonicalName(id, body.canonicalName);
return this.productsService.updateCanonicalName(id, body.canonicalName);
}
@Put(':id/tags')
setTags(
@Param('id', ParseIntPipe) id: number,
@Body() body: SetTagsDto,
) {
return this.productsService.setTags(id, body.tags);
}
@Put(':id/nutrition')
upsertNutrition(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpsertNutritionDto,
) {
return this.productsService.upsertNutrition(id, body);
}
@Patch(':id')
+66 -4
View File
@@ -3,19 +3,26 @@ 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';
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
async findAll(filters?: { tag?: string; subcategory?: string }) {
return this.prisma.product.findMany({
where: {
isActive: true,
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
...(filters?.tag
? { tags: { some: { tag: { name: filters.tag } } } }
: {}),
},
orderBy: {
name: 'asc',
include: {
tags: { include: { tag: true } },
nutrition: true,
},
orderBy: { name: 'asc' },
});
}
@@ -105,6 +112,8 @@ export class ProductsService {
normalizedName?: string;
canonicalName?: string;
category?: string | null;
subcategory?: string | null;
brand?: string | null;
} = {};
if (typeof data.name === 'string') {
@@ -140,12 +149,21 @@ export class ProductsService {
}
if (typeof data.category === 'string') {
updateData.category = data.category.trim() || undefined;
updateData.category = data.category.trim() || null;
}
if (typeof data.subcategory === 'string') {
updateData.subcategory = data.subcategory.trim() || null;
}
if (typeof data.brand === 'string') {
updateData.brand = data.brand.trim() || null;
}
return this.prisma.product.update({
where: { id },
data: updateData,
include: { tags: { include: { tag: true } }, nutrition: true },
});
}
@@ -313,4 +331,48 @@ export class ProductsService {
products: results,
};
}
async setTags(productId: number, tagNames: string[]) {
await this.findOne(productId);
// Skapa taggar som inte finns och hämta ID för alla
const tags = await this.prisma.$transaction(
tagNames.map((name) =>
this.prisma.tag.upsert({
where: { name },
create: { name },
update: {},
}),
),
);
// Ersätt alla taggkopplingar för produkten
await this.prisma.productTag.deleteMany({ where: { productId } });
if (tags.length > 0) {
await this.prisma.productTag.createMany({
data: tags.map((tag) => ({ productId, tagId: tag.id })),
skipDuplicates: true,
});
}
return this.prisma.product.findUnique({
where: { id: productId },
include: { tags: { include: { tag: true } }, nutrition: true },
});
}
async upsertNutrition(productId: number, data: UpsertNutritionDto) {
await this.findOne(productId);
return this.prisma.nutrition.upsert({
where: { productId },
create: { productId, ...data },
update: { ...data },
});
}
async findAllTags() {
return this.prisma.tag.findMany({ orderBy: { name: 'asc' } });
}
}