From a4ea9be7a1c024408aece5b2f797ab79232d21be Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 17 Apr 2026 18:11:06 +0200 Subject: [PATCH] feat: enhance product model with subcategory, brand, tags, and nutrition; update related DTOs and services --- NEXT_STEPS.md | 67 +++-- backend/prisma/schema.prisma | 33 +++ backend/src/products/dto/set-tags.dto.ts | 8 + .../src/products/dto/update-product.dto.ts | 10 + .../src/products/dto/upsert-nutrition.dto.ts | 38 +++ backend/src/products/products.controller.ts | 47 +++- backend/src/products/products.service.ts | 70 +++++- .../app/admin/products/EditProductForm.tsx | 228 +++++++++++++++++- frontend/app/admin/products/actions.ts | 22 ++ frontend/features/inventory/types.ts | 27 +++ 10 files changed, 517 insertions(+), 33 deletions(-) create mode 100644 backend/src/products/dto/set-tags.dto.ts create mode 100644 backend/src/products/dto/upsert-nutrition.dto.ts diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 650594dc..b800467c 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -16,22 +16,22 @@ | Matplanering (veckovy, inköpslista) | ✅ Klart | | Baslager (lista, lägg till, ta bort) | ✅ Klart | | Admin: Produkter (edit, merge, duplicate, restore) | ✅ Klart | -| Receptredigering (frontend UX) | ⚠️ Delvis | -| Receptbilder (upload UI) | ⚠️ Delvis | +| Receptredigering (frontend UX) | ✅ Klart | +| Receptbilder (upload URL) | ✅ Klart | | Portionsjustering | ❌ Saknas | | Produktkategorier — fast lista | ❌ Saknas | -| Receptlista — filtrering & kortvy | ❌ Saknas | +| Receptlista — filtrering & kortvy | ✅ Klart | | Matplan — inventariejämförelse | ❌ Saknas | -| Taggning av produkter | ❌ Saknas | +| Taggning av produkter | ⚠️ Delvis — kräver migration | +| Näringsvärden på produkter | ⚠️ Delvis — kräver migration | +| Autentisering (User-modell) | ❌ Saknas | +| Användarspecifika produkter (UserProduct) | ❌ Saknas — kräver auth | --- ## Prioriterade förbättringar -### 1. Receptredigering — verifiera och slutför frontend-flödet -Backend (`PATCH /api/recipes/:id`) är fullt implementerat och hanterar namn, beskrivning, instruktioner, `imageUrl` och ingredienser. Redigeringskoden i `app/recipes/[id]/RecipeDetailClient.tsx` finns men flödet för spara/avbryt behöver verifieras och eventuellt slutföras. `/recipes/[id]/edit/page.tsx` redirectar i dag tillbaka till detaljsidan — ta bort den omdirigering om redigering sker inline. - -### 2. Portionsjustering av recept +### 1. Portionsjustering av recept Recept lagras utan portionsangivelse. Lägg till ett `servings`-fält och låt användaren justera antal portioner i receptvyn — ingrediensmängderna räknas om proportionellt (t.ex. 4 → 6 pers: × 1,5). - **Databas:** `servings Int?` på `Recipe` i Prisma + migration - **Backend:** `servings` exponeras i `RecipeDto`, sätts vid create/update @@ -45,16 +45,7 @@ Veckovy och inköpslista fungerar. Nästa steg är att visa vilka ingredienser p ### 4. Produktkategorier — definiera en fast lista Kategorier skrivs in som fritext i admin. Byt till en dropdown med fördefinierade kategorier (t.ex. "Mejeri, ost & ägg", "Kött, chark & fågel", "Frukt & Grönt") för konsistent data och bättre gruppering i baslagervyn. -### 5. Receptbilder — upload-UI i frontend -Backend har `POST /api/recipes/:id/image` som tar emot en URL, laddar ner och optimerar bilden. `imageUrl` finns i databasen och formuläret i `write/WriteRecipePage.tsx` har redan ett `imageUrl`-fält. Saknas: ett upload-flöde eller URL-inmatning med förhandsgranskning i receptdetaljvyn (`app/recipes/[id]/RecipeDetailClient.tsx`). - -### 6. Filtrering och kortvy för receptlistan -Receptlistan (`app/recipes/RecipeGrid.tsx`) är en platt lista utan filter. Lägg till: -- Söka på namn — klientside -- Sortera på namn A–Ö eller senast tillagd — klientside -- Kortrutnät med receptbild, namn och eventuellt portionsantal (efter att #2 är klar) - -### 7. Utökad databas med taggning +### 5. Utökad databas med taggning Lägg till stöd för taggar, underkategorier och varumärke direkt på produkter. Möjliggör filtrering, sökning och rekommendationer baserade på taggar. **Schemaändringar (Prisma):** @@ -73,6 +64,46 @@ Lägg till stöd för taggar, underkategorier och varumärke direkt på produkte **Rekommenderade taggar:** `ekologisk`, `svensk`, `laktosfri`, `glutenfri`, `vegan`, `nötfri`, `säsong`, `rökt`, `premium`, `lamm`, `korv`, `färs`, m.fl. +### 6. Näringsvärden på produkter +Lägg till en `Nutrition`-modell kopplad till `Product` (one-to-one) med näringsvärden per 100g: kalorier, protein, fett, kolhydrater, salt, socker, fiber. Kan implementeras oberoende av autentisering. + +**Schemaändring:** +```prisma +model Nutrition { + id Int @id @default(autoincrement()) + calories Float? + protein Float? + fat Float? + carbohydrates Float? + salt Float? + sugar Float? + fiber Float? + product Product @relation(fields: [productId], references: [id]) + productId Int @unique +} +``` +- **Backend:** CRUD via produktendpoints, exponeras i `ProductDto` +- **Frontend:** Visa näringsvärden i produktdetalj och eventuellt i receptvyn (summerat per portion) + +### 7. Autentisering — User-modell +Förutsättning för användarspecifika produkter (punkt 10). Idag saknar hela appen autentisering — alla kan CRUD allt. + +**Scope:** JWT-baserad auth med `User`-modell (id, name, email, passwordHash). Berör: +- Backend: AuthModule med NestJS Guards, JWT-strategi, skyddade routes +- Frontend: Inloggningsflöde, token-hantering i API-anrop +- Databas: `User`-tabell + migration + +> ⚠️ Detta är ett stort projekt i sig. Överväg om appen verkligen behöver fler användare eller om enkel HTTP Basic Auth räcker som skydd. + +### 8. Användarspecifika produkter (UserProduct) +Låter en användare spara egna produktvarianter med eget namn (t.ex. "Mormors Prästost") kopplade till en standardprodukt — eller fristående utan koppling. Kräver att punkt 9 (auth) är på plats. + +> ⚠️ **Överlapp med InventoryItem:** `InventoryItem` lagrar redan productId, quantity, unit, brand, bestBeforeDate och är i princip en "användarens produkt i lager". Klargör skillnaden: +> - `InventoryItem` = vad som finns hemma just nu (lager) +> - `UserProduct` = ett eget produktkort/favorit som kan återanvändas utan att vara lager +> +> Om distinktionen inte är tydlig, riskerar `UserProduct` att duplicera `InventoryItem`-logiken. + --- ## Teknisk skuld och städning diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dbd84bae..c661aae8 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) } \ No newline at end of file diff --git a/backend/src/products/dto/set-tags.dto.ts b/backend/src/products/dto/set-tags.dto.ts new file mode 100644 index 00000000..31b2483a --- /dev/null +++ b/backend/src/products/dto/set-tags.dto.ts @@ -0,0 +1,8 @@ +import { IsArray, IsString, MaxLength } from 'class-validator'; + +export class SetTagsDto { + @IsArray() + @IsString({ each: true }) + @MaxLength(100, { each: true }) + tags!: string[]; +} diff --git a/backend/src/products/dto/update-product.dto.ts b/backend/src/products/dto/update-product.dto.ts index 974732b4..eaae600f 100644 --- a/backend/src/products/dto/update-product.dto.ts +++ b/backend/src/products/dto/update-product.dto.ts @@ -16,4 +16,14 @@ export class UpdateProductDto { @IsString() @MaxLength(191) category?: string; + + @IsOptional() + @IsString() + @MaxLength(191) + subcategory?: string; + + @IsOptional() + @IsString() + @MaxLength(191) + brand?: string; } diff --git a/backend/src/products/dto/upsert-nutrition.dto.ts b/backend/src/products/dto/upsert-nutrition.dto.ts new file mode 100644 index 00000000..ab0c702b --- /dev/null +++ b/backend/src/products/dto/upsert-nutrition.dto.ts @@ -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; +} diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 097cb922..0bb25f21 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -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') diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 474f7765..dfaee5d7 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -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' } }); + } } \ No newline at end of file diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx index d7da5402..3f3edc76 100644 --- a/frontend/app/admin/products/EditProductForm.tsx +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -2,12 +2,238 @@ import { useState, useTransition } from 'react'; import type { Product } from '../../../features/inventory/types'; -import { updateProduct, deleteProduct } from './actions'; +import { updateProduct, deleteProduct, setProductTags } from './actions'; type Props = { product: Product; }; +const CATEGORIES: Record = { + 'Bröd & Kakor': ['Bröd', 'Kakor & bullar', 'Bageriprodukter'], + 'Dryck': ['Kaffe & te', 'Juice & läsk', 'Vatten', 'Alkohol'], + 'Fisk & Skaldjur': ['Fisk', 'Skaldjur', 'Bläckfisk & kalmar', 'Rökt fisk'], + 'Frukt & Grönt': ['Frukt', 'Grönsaker', 'Bär', 'Rotfrukter', 'Kål'], + 'Fryst': ['Fryst frukt & grönt', 'Frysta färdigrätter', 'Fryst kött & fisk', 'Glass'], + 'Färdigmat': ['Färdigrätter', 'Snabbmat', 'Sallader & wrap'], + 'Glass, godis & snacks': ['Glass', 'Godis', 'Snacks'], + 'Kött, chark & fågel': ['Nötkött', 'Fläsk', 'Fågel', 'Charkuteri', 'Vilt'], + 'Mejeri, ost & ägg': ['Mjölk', 'Grädde', 'Ost', 'Yoghurt & fil', 'Smör & margarin', 'Ägg'], + 'Skafferi': ['Mjöl & bakning', 'Pasta & ris', 'Baljväxter', 'Nötter & frön', 'Socker & sötningsmedel', 'Kryddor & örter', 'Konserver & burkar'], + 'Vegetariskt': ['Vegetariska proteinkällor', 'Vegetariska färdigrätter', 'Vegetariska korvar & burgare'], + 'Övrigt': [], +}; + +const inputStyle: React.CSSProperties = { + padding: '0.5rem 0.75rem', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '1rem', + width: '100%', + boxSizing: 'border-box', +}; + +export default function EditProductForm({ product }: Props) { + const [isOpen, setIsOpen] = useState(false); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [selectedCategory, setSelectedCategory] = useState(product.category ?? ''); + const [tagInput, setTagInput] = useState( + product.tags?.map((pt) => pt.tag.name).join(', ') ?? '' + ); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setSuccess(false); + const formData = new FormData(e.currentTarget); + const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean); + startTransition(async () => { + try { + await updateProduct(formData); + await setProductTags(product.id, rawTags); + setSuccess(true); + setIsOpen(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Okänt fel'); + } + }); + } + + function handleDelete() { + if (!confirm(`Ta bort "${product.name}"? Detta är en mjukradering och kan återställas.`)) return; + setError(null); + setSuccess(false); + startTransition(async () => { + try { + await deleteProduct(product.id); + } catch (err) { + setError(err instanceof Error ? err.message : 'Okänt fel'); + } + }); + } + + const subcategories = CATEGORIES[selectedCategory] ?? []; + + return ( +
+
+ + {success && ✓ Sparat!} +
+ + {error &&
{error}
} + + {isOpen && ( +
+ + + + + + + + + {subcategories.length > 0 && ( + + )} + + + + + +
+ Normaliserat namn: {product.normalizedName} + Aktiv: {product.isActive ? 'Ja' : 'Nej'} +
+ +
+ + + +
+
+ )} +
+ ); +} + const inputStyle: React.CSSProperties = { padding: '0.5rem 0.75rem', border: '1px solid #ddd', diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts index e018b4bb..89710eb7 100644 --- a/frontend/app/admin/products/actions.ts +++ b/frontend/app/admin/products/actions.ts @@ -8,11 +8,15 @@ export async function updateProduct(formData: FormData) { const name = String(formData.get('name') || '').trim(); const canonicalName = String(formData.get('canonicalName') || '').trim(); const category = String(formData.get('category') || '').trim(); + const subcategory = String(formData.get('subcategory') || '').trim(); + const brand = String(formData.get('brand') || '').trim(); if (!name) throw new Error('Namn får inte vara tomt.'); if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.'); if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.'); if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.'); + if (subcategory.length > 100) throw new Error('Underkategori får inte vara längre än 100 tecken.'); + if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.'); const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'PATCH', @@ -21,6 +25,8 @@ export async function updateProduct(formData: FormData) { name: name || undefined, canonicalName: canonicalName || undefined, category: category || null, + subcategory: subcategory || null, + brand: brand || null, }), cache: 'no-store', }); @@ -33,6 +39,22 @@ export async function updateProduct(formData: FormData) { revalidatePath('/admin/products'); } +export async function setProductTags(productId: number, tags: string[]) { + const res = await fetch(`${API_BASE}/api/products/${productId}/tags`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tags }), + cache: 'no-store', + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Kunde inte uppdatera taggar: ${text}`); + } + + revalidatePath('/admin/products'); +} + export async function deleteProduct(id: number) { const res = await fetch(`${API_BASE}/api/products/${id}`, { method: 'DELETE', diff --git a/frontend/features/inventory/types.ts b/frontend/features/inventory/types.ts index 49c8fea6..397095cc 100644 --- a/frontend/features/inventory/types.ts +++ b/frontend/features/inventory/types.ts @@ -1,13 +1,40 @@ +export type Tag = { + id: number; + name: string; +}; + +export type ProductTag = { + productId: number; + tagId: number; + tag: Tag; +}; + +export type Nutrition = { + id: number; + productId: number; + calories: number | null; + protein: number | null; + fat: number | null; + carbohydrates: number | null; + salt: number | null; + sugar: number | null; + fiber: number | null; +}; + export type Product = { id: number; name: string; normalizedName: string; category: string | null; + subcategory: string | null; + brand: string | null; canonicalName: string | null; isActive: boolean; deletedAt: string | null; createdAt: string; updatedAt: string; + tags?: ProductTag[]; + nutrition?: Nutrition | null; }; export type InventoryItem = {