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 { AuditService } from '../../audit/audit.service';
export type StationKind = 'kitchen' | 'bar' | 'pass' | 'expedite';
@Injectable()
export class KitchenStationService {
constructor(
private readonly database: DatabaseService,
private readonly audit: AuditService,
) {}
async list(input: { organizationId: string; locationId: string }) {
this.assertScope(input);
return this.database.query(
`select id, code, display_name, station_kind, target_seconds, status, sort_order, version
from kitchen_stations
where organization_id=$1 and location_id=$2 and status in ('active','inactive')
order by sort_order, display_name, id`,
[input.organizationId, input.locationId],
);
}
async create(input: {
organizationId: string;
locationId: string;
code: string;
displayName: string;
targetSeconds?: number;
stationKind?: StationKind;
actorUserId?: string;
}) {
this.assertScope(input);
if (!this.isBoundedText(input.code, 100) || !this.isBoundedText(input.displayName, 300) ||
(input.targetSeconds !== undefined && (!Number.isSafeInteger(input.targetSeconds) || input.targetSeconds < 0 || input.targetSeconds > 86_400)) ||
(input.stationKind !== undefined && !this.isStationKind(input.stationKind)) ||
(input.actorUserId !== undefined && !this.isBoundedText(input.actorUserId, 200))) {
throw new DomainConflictError('kitchen.station_input_invalid');
}
const id = randomUUID();
return this.database.transaction(async (tx) => {
await tx.query(
`insert into kitchen_stations(
id, organization_id, location_id, code, display_name, target_seconds, station_kind
) values($1,$2,$3,$4,$5,$6,$7)`,
[
id,
input.organizationId,
input.locationId,
input.code,
input.displayName,
input.targetSeconds ?? null,
input.stationKind ?? 'kitchen',
],
);
await this.audit.record({
organizationId: input.organizationId,
actorUserId: input.actorUserId,
eventType: 'kitchen.station.created',
entityType: 'kitchen_station',
entityId: id,
metadata: {
locationId: input.locationId,
stationKind: input.stationKind ?? 'kitchen',
},
}, tx);
return { id };
});
}
async routeProduct(input: {
organizationId: string;
locationId: string;
productId: string;
stationId: string;
priority?: number;
expectedStationKind?: StationKind;
}) {
this.assertScope(input);
if (!this.isBoundedText(input.productId, 200) || !this.isBoundedText(input.stationId, 200) ||
(input.priority !== undefined && (!Number.isSafeInteger(input.priority) || input.priority < 0 || input.priority > 10_000)) ||
(input.expectedStationKind !== undefined && !this.isStationKind(input.expectedStationKind))) {
throw new DomainConflictError('kitchen.route_input_invalid');
}
const valid = await this.database.query<{ valid: boolean }>(
`select exists(
select 1
from products p
join kitchen_stations ks on ks.id=$2
where p.id=$1
and p.organization_id=$3
and ks.organization_id=$3
and ks.location_id=$4
and ($5::text is null or ks.station_kind=$5)
) as valid`,
[input.productId, input.stationId, input.organizationId, input.locationId, input.expectedStationKind ?? null],
);
if (!valid[0]?.valid) {
throw new DomainConflictError('kitchen.route_scope_invalid');
}
await this.database.query(
`insert into product_kitchen_routes(
product_id, kitchen_station_id, priority
) values($1,$2,$3)
on conflict(product_id,kitchen_station_id)
do update set priority=excluded.priority`,
[input.productId, input.stationId, input.priority ?? 0],
);
return { routed: true };
}
private assertScope(input: { organizationId: string; locationId: string }): void {
if (!input || typeof input !== 'object' || !this.isBoundedText(input.organizationId, 200) || !this.isBoundedText(input.locationId, 200)) {
throw new DomainConflictError('kitchen.scope_invalid');
}
}
private isStationKind(value: unknown): value is StationKind {
return value === 'kitchen' || value === 'bar' || value === 'pass' || value === 'expedite';
}
private isBoundedText(value: unknown, max: number): value is string {
return typeof value === 'string' && value.trim().length > 0 && value.length <= max;
}
}
