import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { DatabaseService } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
import {
assertCreateIngredientInput,
assertCreateProductInput,
assertIngredientAllergensInput,
assertSetAvailabilityInput,
assertSetPriceInput,
} from './menu-input-validation';
@Injectable()
export class MenuService {
constructor(
private readonly database:DatabaseService,
private readonly outbox:OutboxService,
) {}
async createIngredient(input:{
organizationId:string;name:string;baseUnitCode:string;sku?:string;
}) {
assertCreateIngredientInput(input);
return this.database.transaction(async(tx)=>{
const unit=await tx.query<{code:string}>(
`select code from units where code=$1`,
[input.baseUnitCode],
);
if(!unit[0]) throw new DomainConflictError('menu.unit_invalid');
const id=randomUUID();
await tx.query(
`insert into ingredients(id,organization_id,sku,base_unit_code,name)
values($1,$2,$3,$4,$5)`,
[id,input.organizationId,input.sku??null,input.baseUnitCode,input.name],
);
return {id};
});
}
async setIngredientAllergens(input:{
organizationId:string;
ingredientId:string;
allergens:Array<{code:string;relation:'contains'|'may_contain'}>;
userId?:string;
}) {
assertIngredientAllergensInput(input);
return this.database.transaction(async(tx)=>{
const ingredient=await tx.query<{id:string}>(
`select id from ingredients
where id=$1 and organization_id=$2
for update`,
[input.ingredientId,input.organizationId],
);
if(!ingredient[0]) throw new DomainConflictError('ingredient.not_found');
if(input.allergens.length) {
const codes=[...new Set(input.allergens.map(x=>x.code))];
const known=await tx.query<{code:string}>(
`select code from allergens where code=any($1::text[])`,
[codes],
);
if(known.length!==codes.length) throw new DomainConflictError('menu.allergen_invalid');
}
await tx.query(`delete from ingredient_allergens where ingredient_id=$1`,[input.ingredientId]);
for(const allergen of input.allergens) {
await tx.query(
`insert into ingredient_allergens(
ingredient_id,allergen_code,relation,source,updated_by
) values($1,$2,$3,'manual',$4)`,
[input.ingredientId,allergen.code,allergen.relation,input.userId??null],
);
}
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Ingredient',
aggregateId:input.ingredientId,
eventType:'IngredientAllergensChanged',
payload:{ingredientId:input.ingredientId},
},tx);
return {ingredientId:input.ingredientId,count:input.allergens.length};
},{lockTimeoutMs:3000,statementTimeoutMs:10000});
}
async createProduct(input:{
organizationId:string;name:string;categoryId?:string;recipeId?:string;sku?:string;productType?:string;
}) {
assertCreateProductInput(input);
return this.database.transaction(async(tx)=>{
if(input.categoryId) {
const category=await tx.query<{id:string}>(
`select id from menu_categories
where id=$1 and organization_id=$2`,
[input.categoryId,input.organizationId],
);
if(!category[0]) throw new DomainConflictError('menu.category_forbidden');
}
if(input.recipeId) {
const recipe=await tx.query<{id:string}>(
`select id from recipes
where id=$1 and organization_id=$2`,
[input.recipeId,input.organizationId],
);
if(!recipe[0]) throw new DomainConflictError('menu.recipe_forbidden');
}
const id=randomUUID();
await tx.query(
`insert into products(
id,organization_id,category_id,recipe_id,sku,name,product_type
) values($1,$2,$3,$4,$5,$6,$7)`,
[
id,input.organizationId,input.categoryId??null,input.recipeId??null,
input.sku??null,input.name,input.productType??'dish',
],
);
return {id};
});
}
async setPrice(input:{
organizationId:string;productId:string;locationId:string;
currencyCode:string;amountMinor:number;channel?:string;
}) {
assertSetPriceInput(input);
if(!Number.isSafeInteger(input.amountMinor)||input.amountMinor<0) {
throw new DomainConflictError('menu.price_invalid');
}
return this.database.transaction(async(tx)=>{
const owned=await tx.query<{id:string}>(
`select p.id
from products p
join locations l on l.id=$2
where p.id=$1 and p.organization_id=$3 and l.organization_id=$3
for update of p`,
[input.productId,input.locationId,input.organizationId],
);
if(!owned[0]) throw new DomainConflictError('menu.product_or_location_forbidden');
const id=randomUUID();
await tx.query(
`insert into product_prices(
id,product_id,location_id,channel,currency_code,amount_minor
) values($1,$2,$3,$4,$5,$6)`,
[id,input.productId,input.locationId,input.channel??'onsite',input.currencyCode,input.amountMinor],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Product',
aggregateId:input.productId,
eventType:'ProductPriceChanged',
payload:{productId:input.productId,locationId:input.locationId},
},tx);
return {id};
});
}
async setAvailability(input:{
organizationId:string;productId:string;locationId:string;
available:boolean;reasonCode?:string;userId?:string;
}) {
assertSetAvailabilityInput(input);
return this.database.transaction(async(tx)=>{
const rows=await tx.query<{id:string}>(
`select p.id
from products p
join locations l on l.id=$2
where p.id=$1 and p.organization_id=$3 and l.organization_id=$3
for update of p`,
[input.productId,input.locationId,input.organizationId],
);
if(!rows[0]) throw new DomainConflictError('menu.product_or_location_forbidden');
await tx.query(
`insert into menu_availability(
product_id,location_id,available,reason_code,updated_by
) values($1,$2,$3,$4,$5)
on conflict(product_id,location_id)
do update set available=excluded.available,
reason_code=excluded.reason_code,
updated_by=excluded.updated_by,
updated_at=now()`,
[
input.productId,input.locationId,input.available,
input.reasonCode??null,input.userId??null,
],
);
await this.outbox.append({
organizationId:input.organizationId,
aggregateType:'Product',
aggregateId:input.productId,
eventType:'MenuAvailabilityChanged',
payload:{
productId:input.productId,locationId:input.locationId,available:input.available,
},
},tx);
return {productId:input.productId,available:input.available};
});
}
}
