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
+1
View File
@@ -0,0 +1 @@
DATABASE_URL="mysql://recipe_user:Imminent-Umpire-Undertook8-Crunchy@recipe-db:3306/recipe_app"
View File
View File
+27
View File
@@ -0,0 +1,27 @@
# Stage 1: Installera beroenden
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json ./
COPY prisma ./prisma
RUN npm install
# Stage 2: Bygg applikationen
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npx prisma generate
RUN npm run build
# Stage 3: Kör applikationen
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/dist ./dist
EXPOSE 8080
CMD ["node", "dist/main"]
View File
View File
View File
View File
View File
View File
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [InventoryController],
providers: [InventoryService],
imports: [PrismaModule],
})
export class InventoryModule {}
+4
View File
@@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}
+31
View File
@@ -0,0 +1,31 @@
{
"name": "recipe-api",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
"start": "node dist/main",
"start:dev": "nest start --watch",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@prisma/client": "^6.12.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.0",
"@types/node": "^22.15.29",
"prisma": "^6.12.0",
"typescript": "^5.4.5"
}
}
+85
View File
@@ -0,0 +1,85 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
model Product {
id Int @id @default(autoincrement())
name String
normalizedName String @unique
category String?
canonicalName String?
isActive Boolean @default(true)
deletedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
inventoryItems InventoryItem[]
recipeIngredients RecipeIngredient[]
}
model InventoryItem {
id Int @id @default(autoincrement())
productId Int
quantity Decimal @db.Decimal(10, 2)
unit String
brand String?
location String?
priority Int?
purchaseDate DateTime?
opened Boolean?
shelfNote String?
suitableFor String?
isOnSale Boolean?
priceLevel Int?
bestBeforeDate DateTime?
proteinType String?
isLeftover Boolean?
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?
instructions String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ingredients RecipeIngredient[]
}
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?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+18
View File
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { HealthModule } from './health/health.module';
import { PrismaModule } from './prisma/prisma.module';
import { ProductsModule } from './products/products.module';
import { InventoryModule } from './inventory/inventory.module';
import { RecipesModule } from './recipes/recipes.module';
@Module({
imports: [
HealthModule,
PrismaModule,
ProductsModule,
InventoryModule,
RecipesModule,
],
})
export class AppModule {}
@@ -0,0 +1,9 @@
export function normalizeName(value: string): string {
return value
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '');
}
+36
View File
@@ -0,0 +1,36 @@
import { Controller, Get } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Controller('health')
export class HealthController {
constructor(private readonly prisma: PrismaService) {}
@Get()
getHealth() {
return {
status: 'ok',
service: 'recipe-api',
timestamp: new Date().toISOString(),
};
}
@Get('db')
async getDatabaseHealth() {
try {
await this.prisma.$queryRaw`SELECT 1`;
return {
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString(),
};
} catch (error) {
return {
status: 'error',
database: 'not reachable',
message: error instanceof Error ? error.message : 'unknown error',
timestamp: new Date().toISOString(),
};
}
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}
@@ -0,0 +1,11 @@
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
export class ConsumeInventoryDto {
@IsNumber()
@Min(0.01)
amountUsed!: number;
@IsOptional()
@IsString()
comment?: string;
}
@@ -0,0 +1,70 @@
import {
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateInventoryDto {
@IsInt()
productId!: number;
@IsNumber()
@Min(0)
quantity!: number;
@IsString()
unit!: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsInt()
priority?: number;
@IsOptional()
purchaseDate?: string;
@IsOptional()
bestBeforeDate?: string;
@IsOptional()
@IsString()
brand?: string;
@IsOptional()
@IsBoolean()
opened?: boolean;
@IsOptional()
@IsString()
shelfNote?: string;
@IsOptional()
@IsString()
suitableFor?: string;
@IsOptional()
@IsBoolean()
isOnSale?: boolean;
@IsOptional()
@IsInt()
priceLevel?: number;
@IsOptional()
@IsString()
proteinType?: string;
@IsOptional()
@IsBoolean()
isLeftover?: boolean;
@IsOptional()
@IsString()
comment?: string;
}
@@ -0,0 +1,73 @@
import {
IsBoolean,
IsInt,
IsNumber,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateInventoryDto {
@IsOptional()
@IsInt()
productId?: number;
@IsOptional()
@IsNumber()
@Min(0)
quantity?: number;
@IsOptional()
@IsString()
unit?: string;
@IsOptional()
@IsString()
location?: string;
@IsOptional()
@IsInt()
priority?: number;
@IsOptional()
purchaseDate?: string;
@IsOptional()
bestBeforeDate?: string;
@IsOptional()
@IsString()
brand?: string;
@IsOptional()
@IsBoolean()
opened?: boolean;
@IsOptional()
@IsString()
shelfNote?: string;
@IsOptional()
@IsString()
suitableFor?: string;
@IsOptional()
@IsBoolean()
isOnSale?: boolean;
@IsOptional()
@IsInt()
priceLevel?: number;
@IsOptional()
@IsString()
proteinType?: string;
@IsOptional()
@IsBoolean()
isLeftover?: boolean;
@IsOptional()
@IsString()
comment?: string;
}
@@ -0,0 +1,58 @@
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CreateInventoryDto } from './dto/create-inventory.dto';
import { UpdateInventoryDto } from './dto/update-inventory.dto';
import { InventoryService } from './inventory.service';
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
@Controller('inventory')
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
@Post(':id/consume')
consume(
@Param('id', ParseIntPipe) id: number,
@Body() body: ConsumeInventoryDto,
) {
return this.inventoryService.consume(id, body);
}
@Get(':id/consumption-history')
findConsumptionHistory(@Param('id', ParseIntPipe) id: number) {
return this.inventoryService.findConsumptionHistory(id);
}
@Get()
findAll(
@Query('location') location?: string,
@Query('sort') sort?: string,
) {
return this.inventoryService.findAll({ location, sort });
}
@Get('expiring')
findExpiring() {
return this.inventoryService.findExpiring();
}
@Post()
create(@Body() body: CreateInventoryDto) {
return this.inventoryService.create(body);
}
@Patch(':id')
update(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdateInventoryDto,
) {
return this.inventoryService.update(id, body);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { InventoryController } from './inventory.controller';
import { InventoryService } from './inventory.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
controllers: [InventoryController],
providers: [InventoryService],
imports: [PrismaModule],
})
export class InventoryModule {}
+248
View File
@@ -0,0 +1,248 @@
import { ConsumeInventoryDto } from './dto/consume-inventory.dto';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateInventoryDto } from './dto/create-inventory.dto';
import { UpdateInventoryDto } from './dto/update-inventory.dto';
type InventoryQuery = {
location?: string;
sort?: string;
};
@Injectable()
export class InventoryService {
constructor(private prisma: PrismaService) {}
async findAll(query?: InventoryQuery) {
const where: Prisma.InventoryItemWhereInput = {};
const orderBy: Prisma.InventoryItemOrderByWithRelationInput[] = [];
if (query?.location) {
where.location = query.location;
}
if (query?.sort === 'bestBeforeAsc') {
orderBy.push({ bestBeforeDate: 'asc' });
} else if (query?.sort === 'bestBeforeDesc') {
orderBy.push({ bestBeforeDate: 'desc' });
} else if (query?.sort === 'purchaseDateAsc') {
orderBy.push({ purchaseDate: 'asc' });
} else if (query?.sort === 'purchaseDateDesc') {
orderBy.push({ purchaseDate: 'desc' });
} else {
orderBy.push({ createdAt: 'desc' });
}
return this.prisma.inventoryItem.findMany({
where,
include: {
product: true,
},
orderBy,
});
}
async consume(id: number, data: ConsumeInventoryDto) {
const existing = await this.prisma.inventoryItem.findUnique({
where: { id },
include: {
product: true,
},
});
if (!existing) {
throw new NotFoundException(`Inventory item with id ${id} not found`);
}
const currentQuantity = Number(existing.quantity);
const newQuantity = Math.max(0, currentQuantity - data.amountUsed);
return this.prisma.$transaction(async (tx) => {
const updatedItem = await tx.inventoryItem.update({
where: { id },
data: {
quantity: new Prisma.Decimal(newQuantity),
},
include: {
product: true,
},
});
await tx.inventoryConsumption.create({
data: {
inventoryItemId: id,
amountUsed: new Prisma.Decimal(data.amountUsed),
comment: data.comment?.trim() || null,
},
});
return updatedItem;
});
}
async findConsumptionHistory(id: number) {
const existing = await this.prisma.inventoryItem.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException(`Inventory item with id ${id} not found`);
}
return this.prisma.inventoryConsumption.findMany({
where: {
inventoryItemId: id,
},
orderBy: {
createdAt: 'desc',
},
});
}
async findExpiring() {
const now = new Date();
return this.prisma.inventoryItem.findMany({
where: {
bestBeforeDate: {
not: null,
gte: now,
},
},
include: {
product: true,
},
orderBy: [{ bestBeforeDate: 'asc' }, { createdAt: 'desc' }],
});
}
async create(data: CreateInventoryDto) {
const product = await this.prisma.product.findUnique({
where: { id: data.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
return this.prisma.inventoryItem.create({
data: {
...data,
quantity: new Prisma.Decimal(data.quantity),
location: data.location?.trim() || undefined,
brand: data.brand?.trim() || undefined,
suitableFor: data.suitableFor?.trim() || undefined,
comment: data.comment?.trim() || undefined,
purchaseDate: data.purchaseDate
? new Date(data.purchaseDate)
: undefined,
bestBeforeDate: data.bestBeforeDate
? new Date(data.bestBeforeDate)
: undefined,
},
include: {
product: true,
},
});
}
async update(id: number, data: UpdateInventoryDto) {
const existing = await this.prisma.inventoryItem.findUnique({
where: { id },
});
if (!existing) {
throw new NotFoundException(`Inventory item with id ${id} not found`);
}
if (typeof data.productId === 'number') {
const product = await this.prisma.product.findUnique({
where: { id: data.productId },
});
if (!product) {
throw new NotFoundException('Product not found');
}
}
const updateData: Prisma.InventoryItemUpdateInput = {};
if (typeof data.productId === 'number') {
updateData.product = {
connect: { id: data.productId },
};
}
if (typeof data.quantity === 'number') {
updateData.quantity = new Prisma.Decimal(data.quantity);
}
if (typeof data.unit === 'string') {
updateData.unit = data.unit.trim();
}
if (typeof data.location === 'string') {
updateData.location = data.location.trim();
}
if (typeof data.brand === 'string') {
updateData.brand = data.brand.trim();
}
if (typeof data.priority === 'number') {
updateData.priority = data.priority;
}
if (typeof data.purchaseDate === 'string') {
updateData.purchaseDate = data.purchaseDate
? new Date(data.purchaseDate)
: null;
}
if (typeof data.bestBeforeDate === 'string') {
updateData.bestBeforeDate = data.bestBeforeDate
? new Date(data.bestBeforeDate)
: null;
}
if (typeof data.opened === 'boolean') {
updateData.opened = data.opened;
}
if (typeof data.shelfNote === 'string') {
updateData.shelfNote = data.shelfNote.trim();
}
if (typeof data.suitableFor === 'string') {
updateData.suitableFor = data.suitableFor.trim();
}
if (typeof data.isOnSale === 'boolean') {
updateData.isOnSale = data.isOnSale;
}
if (typeof data.priceLevel === 'number') {
updateData.priceLevel = data.priceLevel;
}
if (typeof data.proteinType === 'string') {
updateData.proteinType = data.proteinType.trim();
}
if (typeof data.isLeftover === 'boolean') {
updateData.isLeftover = data.isLeftover;
}
if (typeof data.comment === 'string') {
updateData.comment = data.comment.trim();
}
return this.prisma.inventoryItem.update({
where: { id },
data: updateData,
include: {
product: true,
},
});
}
}
+19
View File
@@ -0,0 +1,19 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(8080, '0.0.0.0');
}
bootstrap();
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+48
View File
@@ -0,0 +1,48 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
private readonly logger = new Logger(PrismaService.name);
async onModuleInit() {
const maxAttempts = 10;
const delayMs = 3000;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
this.logger.log(
`Försöker ansluta till databasen (försök ${attempt}/${maxAttempts})...`,
);
await this.$connect();
this.logger.log('Databasanslutning etablerad.');
return;
} catch (error) {
const message =
error instanceof Error ? error.message : 'Okänt fel';
this.logger.warn(
`Databasanslutning misslyckades på försök ${attempt}/${maxAttempts}: ${message}`,
);
if (attempt === maxAttempts) {
this.logger.error(
'Kunde inte ansluta till databasen efter maximalt antal försök.',
);
throw error;
}
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
}
}
async onModuleDestroy() {
await this.$disconnect();
}
}
@@ -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,
};
}
}
+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,
};
}
}
View File
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}
View File