Recipe-app main
This commit is contained in:
@@ -0,0 +1 @@
|
||||
DATABASE_URL="mysql://recipe_user:Imminent-Umpire-Undertook8-Crunchy@recipe-db:3306/recipe_app"
|
||||
@@ -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"]
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user