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
+17
View File
@@ -0,0 +1,17 @@
import { IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class CreateRecipeIngredientDto {
@IsInt()
productId!: number;
@IsNumber()
@Min(0.01)
quantity!: number;
@IsString()
unit!: string;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,28 @@
import {
IsArray,
IsOptional,
IsString,
ValidateNested,
ArrayMinSize,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CreateRecipeIngredientDto } from './create-recipe-ingredient.dto';
export class CreateRecipeDto {
@IsString()
name!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
instructions?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateRecipeIngredientDto)
ingredients!: CreateRecipeIngredientDto[];
}
+28
View File
@@ -0,0 +1,28 @@
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { CreateRecipeDto } from './dto/create-recipe.dto';
import { RecipesService } from './recipes.service';
@Controller('recipes')
export class RecipesController {
constructor(private readonly recipesService: RecipesService) {}
@Get()
findAll() {
return this.recipesService.findAll();
}
@Get(':id/inventory-preview')
getInventoryPreview(@Param('id', ParseIntPipe) id: number) {
return this.recipesService.getInventoryPreview(id);
}
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return this.recipesService.findOne(id);
}
@Post()
create(@Body() body: CreateRecipeDto) {
return this.recipesService.create(body);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { RecipesController } from './recipes.controller';
import { RecipesService } from './recipes.service';
@Module({
imports: [PrismaModule],
controllers: [RecipesController],
providers: [RecipesService],
})
export class RecipesModule {}
+189
View File
@@ -0,0 +1,189 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateRecipeDto } from './dto/create-recipe.dto';
@Injectable()
export class RecipesService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
return this.prisma.recipe.findMany({
include: {
ingredients: {
include: {
product: true,
},
},
},
orderBy: {
name: 'asc',
},
});
}
async findOne(id: number) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
include: {
ingredients: {
include: {
product: true,
},
orderBy: {
id: 'asc',
},
},
},
});
if (!recipe) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
return recipe;
}
async create(data: CreateRecipeDto) {
for (const ingredient of data.ingredients) {
const product = await this.prisma.product.findUnique({
where: { id: ingredient.productId },
});
if (!product) {
throw new NotFoundException(
`Product with id ${ingredient.productId} not found`,
);
}
}
return this.prisma.recipe.create({
data: {
name: data.name.trim(),
description: data.description?.trim() || null,
instructions: data.instructions?.trim() || null,
ingredients: {
create: data.ingredients.map((ingredient) => ({
productId: ingredient.productId,
quantity: new Prisma.Decimal(ingredient.quantity),
unit: ingredient.unit.trim(),
note: ingredient.note?.trim() || null,
})),
},
},
include: {
ingredients: {
include: {
product: true,
},
orderBy: {
id: 'asc',
},
},
},
});
}
async getInventoryPreview(id: number) {
const recipe = await this.prisma.recipe.findUnique({
where: { id },
include: {
ingredients: {
include: {
product: true,
},
orderBy: {
id: 'asc',
},
},
},
});
if (!recipe) {
throw new NotFoundException(`Recipe with id ${id} not found`);
}
const ingredientPreviews = await Promise.all(
recipe.ingredients.map(async (ingredient: typeof recipe.ingredients[0]) => {
const inventoryItems = await this.prisma.inventoryItem.findMany({
where: {
productId: ingredient.productId,
},
orderBy: {
createdAt: 'desc',
},
});
const sameUnitItems = inventoryItems.filter(
(item) => item.unit.trim().toLowerCase() === ingredient.unit.trim().toLowerCase(),
);
const availableQuantity = sameUnitItems.reduce(
(sum, item) => sum + Number(item.quantity),
0,
);
const requiredQuantity = Number(ingredient.quantity);
let status: 'enough' | 'missing' | 'unit_mismatch';
if (sameUnitItems.length > 0) {
status = availableQuantity >= requiredQuantity ? 'enough' : 'missing';
} else if (inventoryItems.length > 0) {
status = 'unit_mismatch';
} else {
status = 'missing';
}
return {
ingredientId: ingredient.id,
productId: ingredient.productId,
productName: ingredient.product.canonicalName || ingredient.product.name,
requiredQuantity,
requiredUnit: ingredient.unit,
note: ingredient.note,
availableQuantity,
availableUnit: sameUnitItems.length > 0 ? ingredient.unit : null,
matchingInventoryItems: sameUnitItems.map((item) => ({
id: item.id,
quantity: item.quantity,
unit: item.unit,
location: item.location,
})),
otherInventoryItems: inventoryItems
.filter((item) => item.unit.trim().toLowerCase() !== ingredient.unit.trim().toLowerCase())
.map((item) => ({
id: item.id,
quantity: item.quantity,
unit: item.unit,
location: item.location,
})),
status,
missingQuantity:
status === 'missing'
? Math.max(0, requiredQuantity - availableQuantity)
: 0,
};
}),
);
const summary = {
totalIngredients: ingredientPreviews.length,
enoughCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'enough').length,
missingCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'missing').length,
unitMismatchCount: ingredientPreviews.filter((i: typeof ingredientPreviews[0]) => i.status === 'unit_mismatch').length,
canCookExactly:
ingredientPreviews.every((i: typeof ingredientPreviews[0]) => i.status === 'enough'),
};
return {
recipe: {
id: recipe.id,
name: recipe.name,
description: recipe.description,
},
ingredients: ingredientPreviews,
summary,
};
}
}