1
import { Injectable } from '@nestjs/common';
2
import { randomUUID } from 'node:crypto';
3
import { DatabaseService } from '../../../infrastructure/database/database.service';
4
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
5
import { ScopeResolutionService } from '../../organizations/scope-resolution.service';
6
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
7
import { assertAvailabilityRuleInput } from './menu-input-validation';
8
9
@Injectable()
10
export class MenuAvailabilityRuleService {
11
constructor(private readonly database:DatabaseService,private readonly outbox:OutboxService,private readonly scopes:ScopeResolutionService) {}
12
13
async create(input:{organizationId:string;productId:string;locationId:string;placeId?:string;operationalUnitId?:string;stationId?:string;channel?:string;startsAt?:string;endsAt?:string;available:boolean;reasonCode?:string;priority?:number;userId?:string}) {
14
assertAvailabilityRuleInput(input);
15
const startsAt = input.startsAt ? new Date(input.startsAt) : new Date();
16
const endsAt = input.endsAt ? new Date(input.endsAt) : null;
17
if (Number.isNaN(startsAt.getTime()) || (endsAt && Number.isNaN(endsAt.getTime())) || (endsAt && endsAt <= startsAt)) {
18
throw new DomainConflictError('menu.availability_window_invalid');
19
}
20
const scope=await this.scopes.resolve({organizationId:input.organizationId,locationId:input.locationId,placeId:input.placeId,operationalUnitId:input.operationalUnitId});
21
const owned=await this.database.query<{id:string}>(`select id from products where id=$1 and organization_id=$2 and status='active'`,[input.productId,input.organizationId]);
22
if(!owned[0]) throw new DomainConflictError('menu.product_or_location_forbidden');
23
if (input.stationId) {
24
const station = await this.database.query<{ id: string }>(
25
`select id from kitchen_stations where id=$1 and organization_id=$2 and location_id=$3 and status='active'`,
26
[input.stationId, input.organizationId, input.locationId],
27
);
28
if (!station[0]) throw new DomainConflictError('menu.station_scope_invalid');
29
}
30
const id=randomUUID();
31
await this.database.transaction(async(tx)=>{
32
await tx.query(`insert into menu_availability_rules(id,organization_id,product_id,location_id,place_id,operational_unit_id,station_id,channel,starts_at,ends_at,available,reason_code,priority,created_by) values($1,$2,$3,$4,$5,$6,$7,$8,$9::timestamptz,$10::timestamptz,$11,$12,$13,$14)`,[id,input.organizationId,input.productId,input.locationId,scope.placeId??null,scope.operationalUnitId??null,input.stationId??null,input.channel??null,startsAt.toISOString(),endsAt?.toISOString()??null,input.available,input.reasonCode??null,input.priority??0,input.userId??null]);
33
await this.outbox.append({organizationId:input.organizationId,aggregateType:'Product',aggregateId:input.productId,eventType:'MenuAvailabilityRuleCreated',payload:{ruleId:id,productId:input.productId,locationId:input.locationId,placeId:scope.placeId??null,operationalUnitId:scope.operationalUnitId??null,stationId:input.stationId??null,channel:input.channel??null,startsAt:startsAt.toISOString(),endsAt:endsAt?.toISOString()??null,available:input.available}},tx);
34
});
35
return {id};
36
}
37
}
38