feat: enhance recipe ingredient model; add raw fields and optional properties for better ingredient handling
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-06 07:25:42 +02:00
parent 612fcddb47
commit e4f201ea36
9 changed files with 349 additions and 267 deletions
@@ -0,0 +1,9 @@
ALTER TABLE `RecipeIngredient`
MODIFY `productId` INTEGER NULL,
MODIFY `quantity` DECIMAL(10, 2) NULL,
MODIFY `unit` VARCHAR(191) NULL,
ADD COLUMN `rawName` VARCHAR(191) NOT NULL DEFAULT '',
ADD COLUMN `rawLine` TEXT NULL,
ADD COLUMN `matchConfidence` DOUBLE NULL,
ADD COLUMN `matchSource` VARCHAR(191) NULL,
ADD COLUMN `analysisStatus` VARCHAR(191) NULL;
+242 -237
View File
@@ -1,237 +1,242 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
firstName String?
lastName String?
passwordHash String
role String @default("user")
isPremium Boolean @default(false)
canShareRecipes Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userProducts UserProduct[]
ownedRecipes Recipe[] @relation("RecipeOwner")
sharedRecipes RecipeShare[]
ownedProducts Product[]
pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[]
}
model Product {
id Int @id @default(autoincrement())
name String
normalizedName String @unique
category String?
canonicalName String?
isActive Boolean @default(true)
status String @default("active")
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventoryItems InventoryItem[]
recipeIngredients RecipeIngredient[]
pantryItems PantryItem[]
receiptAliases ReceiptAlias[]
tags ProductTag[]
nutrition Nutrition?
ownerId Int
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
userProducts UserProduct[]
categoryId Int?
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false)
}
model Category {
id Int @id @default(autoincrement())
name String
parentId Int?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
products Product[]
@@unique([name, parentId])
@@index([parentId])
}
model UserProduct {
id Int @id @default(autoincrement())
userId Int
productId Int
note String? @db.Text
preferredBrand String?
preferredStore String?
isPrivate Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId])
@@index([userId])
@@index([productId])
}
model InventoryItem {
id Int @id @default(autoincrement())
productId Int
quantity Decimal @db.Decimal(10, 2)
unit String
brand String?
origin String?
receiptName String?
location String?
purchaseDate DateTime?
opened Boolean?
suitableFor String?
bestBeforeDate DateTime?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
consumptions InventoryConsumption[]
@@index([productId])
}
model InventoryConsumption {
id Int @id @default(autoincrement())
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id])
inventoryItemId Int
amountUsed Decimal @db.Decimal(10, 2)
comment String?
createdAt DateTime @default(now())
}
model Recipe {
id Int @id @default(autoincrement())
name String
description String? @db.Text
instructions String? @db.Text
imageUrl String?
servings Int?
isPublic Boolean @default(false)
ownerId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User? @relation("RecipeOwner", fields: [ownerId], references: [id], onDelete: SetNull)
ingredients RecipeIngredient[]
mealPlanEntries MealPlanEntry[]
shares RecipeShare[]
}
model RecipeShare {
recipeId Int
userId Int
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([recipeId, userId])
@@index([userId])
}
model RecipeIngredient {
id Int @id @default(autoincrement())
recipe Recipe @relation(fields: [recipeId], references: [id])
recipeId Int
product Product @relation(fields: [productId], references: [id])
productId Int
quantity Decimal @db.Decimal(10, 2)
unit String
note String?
alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PantryItem {
id Int @id @default(autoincrement())
userId Int
productId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, productId])
@@index([userId])
}
model ReceiptAlias {
id Int @id @default(autoincrement())
receiptName String // normaliserat kvittonamn (lowercase, trim)
ownerId Int?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
isGlobal Boolean @default(false)
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([receiptName, ownerId, isGlobal])
@@index([ownerId])
@@index([isGlobal])
}
model MealPlanEntry {
id Int @id @default(autoincrement())
userId Int
date DateTime @db.Date
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
recipeId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
servings Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Bara ett recept per dag och anvandare.
@@unique([userId, date])
@@index([userId])
@@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)
}
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
username String @unique
email String @unique
firstName String?
lastName String?
passwordHash String
role String @default("user")
isPremium Boolean @default(false)
canShareRecipes Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userProducts UserProduct[]
ownedRecipes Recipe[] @relation("RecipeOwner")
sharedRecipes RecipeShare[]
ownedProducts Product[]
pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[]
}
model Product {
id Int @id @default(autoincrement())
name String
normalizedName String @unique
category String?
canonicalName String?
isActive Boolean @default(true)
status String @default("active")
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventoryItems InventoryItem[]
recipeIngredients RecipeIngredient[]
pantryItems PantryItem[]
receiptAliases ReceiptAlias[]
tags ProductTag[]
nutrition Nutrition?
ownerId Int
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
userProducts UserProduct[]
categoryId Int?
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
isPrivate Boolean @default(false)
}
model Category {
id Int @id @default(autoincrement())
name String
parentId Int?
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
children Category[] @relation("CategoryTree")
products Product[]
@@unique([name, parentId])
@@index([parentId])
}
model UserProduct {
id Int @id @default(autoincrement())
userId Int
productId Int
note String? @db.Text
preferredBrand String?
preferredStore String?
isPrivate Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([userId, productId])
@@index([userId])
@@index([productId])
}
model InventoryItem {
id Int @id @default(autoincrement())
productId Int
quantity Decimal @db.Decimal(10, 2)
unit String
brand String?
origin String?
receiptName String?
location String?
purchaseDate DateTime?
opened Boolean?
suitableFor String?
bestBeforeDate DateTime?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
consumptions InventoryConsumption[]
@@index([productId])
}
model InventoryConsumption {
id Int @id @default(autoincrement())
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id])
inventoryItemId Int
amountUsed Decimal @db.Decimal(10, 2)
comment String?
createdAt DateTime @default(now())
}
model Recipe {
id Int @id @default(autoincrement())
name String
description String? @db.Text
instructions String? @db.Text
imageUrl String?
servings Int?
isPublic Boolean @default(false)
ownerId Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User? @relation("RecipeOwner", fields: [ownerId], references: [id], onDelete: SetNull)
ingredients RecipeIngredient[]
mealPlanEntries MealPlanEntry[]
shares RecipeShare[]
}
model RecipeShare {
recipeId Int
userId Int
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@id([recipeId, userId])
@@index([userId])
}
model RecipeIngredient {
id Int @id @default(autoincrement())
recipe Recipe @relation(fields: [recipeId], references: [id])
recipeId Int
product Product? @relation(fields: [productId], references: [id])
productId Int?
rawName String @default("")
rawLine String? @db.Text
quantity Decimal? @db.Decimal(10, 2)
unit String?
note String?
alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous")
matchConfidence Float?
matchSource String?
analysisStatus String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PantryItem {
id Int @id @default(autoincrement())
userId Int
productId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, productId])
@@index([userId])
}
model ReceiptAlias {
id Int @id @default(autoincrement())
receiptName String // normaliserat kvittonamn (lowercase, trim)
ownerId Int?
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
isGlobal Boolean @default(false)
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([receiptName, ownerId, isGlobal])
@@index([ownerId])
@@index([isGlobal])
}
model MealPlanEntry {
id Int @id @default(autoincrement())
userId Int
date DateTime @db.Date
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
recipeId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
servings Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Bara ett recept per dag och anvandare.
@@unique([userId, date])
@@index([userId])
@@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)
}
+25 -6
View File
@@ -12,20 +12,39 @@ import {
import { Type } from 'class-transformer';
class CreateRecipeIngredientDto {
@IsOptional()
@IsInt()
productId!: number;
@IsNumber()
@Min(0)
quantity!: number;
productId?: number;
@IsString()
unit!: string;
rawName!: string;
@IsOptional()
@IsString()
rawLine?: string;
@IsOptional()
@IsNumber()
@Min(0)
quantity?: number;
@IsOptional()
@IsString()
unit?: string;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsNumber()
@Min(0)
matchConfidence?: number;
@IsOptional()
@IsString()
matchSource?: string;
@IsOptional()
@IsArray()
@IsInt({ each: true })
+43 -8
View File
@@ -113,6 +113,24 @@ export class RecipesService {
const ingredientPreviews = await Promise.all(
recipe.ingredients.map(async (ingredient: any) => {
if (!ingredient.productId || !ingredient.product) {
return {
ingredientId: ingredient.id,
productId: null,
productName: ingredient.rawName || 'Okänd ingrediens',
requiredQuantity: Number(ingredient.quantity ?? 0),
requiredUnit: ingredient.unit || '',
note: ingredient.note,
availableQuantity: 0,
availableUnit: ingredient.unit || '',
matchingInventoryItems: [],
otherInventoryItems: [],
status: 'missing' as const,
fromPantry: false,
missingQuantity: Number(ingredient.quantity ?? 0),
};
}
// Täcks ingrediensen av pantry (inkl. alternativ)?
const coveredByPantry =
pantryProductIds.has(ingredient.productId) ||
@@ -313,7 +331,11 @@ export class RecipesService {
await this.assertAndClaimRecipeOwner(existingRecipe, userId);
// Validera att alla produkter är aktiva
await this.assertProductsActive(updateRecipeDto.ingredients.map((i) => i.productId));
await this.assertProductsActive(
updateRecipeDto.ingredients
.map((i) => i.productId)
.filter((id): id is number => typeof id === 'number'),
);
// Transaktionsblock: ta bort gamla + skapa nya ingredienser atomärt
const recipe = await this.prisma.$transaction(async (tx) => {
@@ -329,11 +351,15 @@ export class RecipesService {
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
ingredients: {
create: updateRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId,
quantity: ingredient.quantity,
unit: ingredient.unit,
productId: ingredient.productId ?? null,
rawName: ingredient.rawName,
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
note: ingredient.note || null,
alternativeProductIds: ingredient.alternativeProductIds ?? [],
matchConfidence: ingredient.matchConfidence ?? null,
matchSource: ingredient.matchSource ?? null,
})),
},
},
@@ -462,7 +488,11 @@ export class RecipesService {
async create(createRecipeDto: CreateRecipeDto, userId: number) {
// Validera att alla produkter är aktiva
await this.assertProductsActive(createRecipeDto.ingredients.map((i) => i.productId));
await this.assertProductsActive(
createRecipeDto.ingredients
.map((i) => i.productId)
.filter((id): id is number => typeof id === 'number'),
);
this.logger.log(
`[create] Incoming imageUrl from client: ${createRecipeDto.imageUrl ?? 'null'}`,
@@ -497,11 +527,15 @@ export class RecipesService {
isPublic: false,
ingredients: {
create: createRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId,
quantity: ingredient.quantity,
unit: ingredient.unit,
productId: ingredient.productId ?? null,
rawName: ingredient.rawName,
rawLine: ingredient.rawLine ?? null,
quantity: ingredient.quantity ?? null,
unit: ingredient.unit?.trim() ? ingredient.unit : null,
note: ingredient.note || null,
alternativeProductIds: ingredient.alternativeProductIds ?? [],
matchConfidence: ingredient.matchConfidence ?? null,
matchSource: ingredient.matchSource ?? null,
})),
},
},
@@ -745,6 +779,7 @@ Regler:
return {
rawName: ingredient.rawName,
rawLine: ingredient.rawName,
alternatives: ingredient.alternatives ?? [],
quantity: ingredient.quantity,
unit: ingredient.unit,
@@ -2,7 +2,7 @@ enum IngredientStatus { enough, missing, unitMismatch }
class IngredientPreview {
final int ingredientId;
final int productId;
final int? productId;
final String productName;
final double requiredQuantity;
final String requiredUnit;
@@ -14,7 +14,7 @@ class IngredientPreview {
const IngredientPreview({
required this.ingredientId,
required this.productId,
this.productId,
required this.productName,
required this.requiredQuantity,
required this.requiredUnit,
@@ -34,8 +34,8 @@ class IngredientPreview {
};
return IngredientPreview(
ingredientId: json['ingredientId'] as int,
productId: json['productId'] as int,
productName: json['productName'] as String,
productId: (json['productId'] as num?)?.toInt(),
productName: (json['productName'] as String?) ?? (json['rawName'] as String? ?? ''),
requiredQuantity: (json['requiredQuantity'] as num).toDouble(),
requiredUnit: json['requiredUnit'] as String? ?? '',
note: json['note'] as String?,
@@ -19,6 +19,7 @@ class IngredientSuggestion {
class ParsedIngredient {
final String rawName;
final String? rawLine;
final double quantity;
final String unit;
final String? note;
@@ -27,6 +28,7 @@ class ParsedIngredient {
const ParsedIngredient({
required this.rawName,
this.rawLine,
required this.quantity,
required this.unit,
this.note,
@@ -38,6 +40,7 @@ class ParsedIngredient {
final rawSuggestions = json['suggestions'] as List<dynamic>? ?? [];
return ParsedIngredient(
rawName: json['rawName'] as String? ?? '',
rawLine: json['rawLine'] as String?,
quantity: (json['quantity'] as num? ?? 0).toDouble(),
unit: json['unit'] as String? ?? '',
note: json['note'] as String?,
@@ -1,15 +1,19 @@
class RecipeIngredient {
final int id;
final int productId;
final String productName;
final int? productId;
final String? productName;
final String rawName;
final String? rawLine;
final double quantity;
final String unit;
final String? note;
const RecipeIngredient({
required this.id,
required this.productId,
required this.productName,
this.productId,
this.productName,
required this.rawName,
this.rawLine,
required this.quantity,
required this.unit,
this.note,
@@ -20,8 +24,10 @@ class RecipeIngredient {
final rawQty = json['quantity'];
return RecipeIngredient(
id: (json['id'] as num).toInt(),
productId: (json['productId'] as num).toInt(),
productName: product?['name'] as String? ?? '',
productId: (json['productId'] as num?)?.toInt(),
productName: product?['canonicalName'] as String? ?? product?['name'] as String?,
rawName: json['rawName'] as String? ?? '',
rawLine: json['rawLine'] as String?,
quantity: rawQty is num
? rawQty.toDouble()
: double.tryParse(rawQty?.toString() ?? '') ?? 0,
@@ -191,7 +191,6 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
for (var i = 0; i < _parsed!.ingredients.length; i++) {
if (!_included[i]) continue;
final productId = _selectedProductIds[i];
if (productId == null) continue;
final qty = double.tryParse(
_qtyControllers[i]!.text.trim().replaceAll(',', '.'),
) ??
@@ -207,9 +206,11 @@ class _CreateRecipeScreenState extends ConsumerState<CreateRecipeScreen> {
.toList()
: <int>[];
ingredients.add({
'productId': productId,
'quantity': qty,
'unit': unit,
'rawName': ing.rawName,
if ((ing.rawLine ?? '').trim().isNotEmpty) 'rawLine': ing.rawLine,
if (productId != null) 'productId': productId,
if (qty > 0) 'quantity': qty,
if (unit.isNotEmpty) 'unit': unit,
if (note.isNotEmpty) 'note': note,
if (alternativeProductIds.isNotEmpty)
'alternativeProductIds': alternativeProductIds,
@@ -396,6 +396,10 @@ class _RecipeBody extends StatelessWidget {
const SizedBox(height: 12),
...recipe.ingredients.map((ing) {
final qtyStr = ing.quantity == 0 ? '' : _fmtQty(ing.quantity);
final ingredientLabel = (ing.rawName.trim().isNotEmpty
? ing.rawName
: (ing.productName ?? '').trim())
.trim();
final measureParts = [
if (qtyStr.isNotEmpty) qtyStr,
if (ing.unit.isNotEmpty) ing.unit,
@@ -430,8 +434,8 @@ class _RecipeBody extends StatelessWidget {
Expanded(
child: Text(
ing.note != null
? '${ing.productName} (${ing.note})'
: ing.productName,
? '$ingredientLabel (${ing.note})'
: ingredientLabel,
style: theme.textTheme.bodyMedium,
),
),