import { Injectable } from '@nestjs/common';
import { DatabaseService, type DatabaseQueryClient } from '../../../infrastructure/database/database.service';
import { DomainConflictError } from '../../../common/errors/domain-conflict.error';
import { OutboxService } from '../../../infrastructure/outbox/outbox.service';
@Injectable()
export class KitchenService {
constructor(
private readonly database: DatabaseService,
private readonly outbox: OutboxService,
) {}
async start(input: {
organizationId: string;
locationId: string;
ticketId: string;
userId?: string;
expectedVersion?: number;
}) {
return this.transition(input, ['fired','recalled'], 'in_progress', 'KitchenTicketStarted');
}
async ready(input: {
organizationId: string;
locationId: string;
ticketId: string;
userId?: string;
expectedVersion?: number;
}) {
return this.transition(input, ['in_progress','fired'], 'ready', 'KitchenTicketReady');
}
async bump(input: {
organizationId: string;
locationId: string;
ticketId: string;
userId?: string;
expectedVersion?: number;
}) {
return this.transition(input, ['ready'], 'bumped', 'KitchenTicketBumped');
}
async recall(input: {
organizationId: string;
locationId: string;
ticketId: string;
userId?: string;
expectedVersion?: number;
}) {
return this.transition(input, ['bumped'], 'recalled', 'KitchenTicketRecalled');
}
async complete(input: {
organizationId: string;
locationId: string;
ticketId: string;
userId?: string;
expectedVersion?: number;
}) {
return this.transition(input, ['bumped','ready'], 'completed', 'KitchenTicketCompleted');
}
private async transition(
input: { organizationId: string; locationId: string; ticketId: string; userId?: string; expectedVersion?: number },
allowed: string[],
next: string,
eventType: string,
) {
this.assertValidInput(input);
const timestampColumn = {
in_progress: 'started_at',
ready: 'ready_at',
bumped: 'bumped_at',
recalled: 'recalled_at',
completed: 'completed_at',
}[next];
return this.database.transaction(async (tx: DatabaseQueryClient) => {
const rows = await tx.query<{ id: string; order_id: string; version: number }>(
`update kitchen_tickets
set status=$1,
${timestampColumn}=now(),
version=version+1
where id=$2
and organization_id=$3
and location_id=$4
and status=any($5::text[])
and ($6::bigint is null or version=$6)
returning id, order_id, version`,
[next, input.ticketId, input.organizationId, input.locationId, allowed, input.expectedVersion ?? null],
);
if (!rows[0]) {
throw new DomainConflictError('kitchen.transition_invalid', {
target: next,
});
}
await tx.query(
`insert into kitchen_ticket_events(
kitchen_ticket_id, event_type, actor_user_id
) values ($1,$2,$3)`,
[input.ticketId, eventType, input.userId ?? null],
);
await this.outbox.append({
organizationId: input.organizationId,
aggregateType: 'KitchenTicket',
aggregateId: input.ticketId,
eventType,
payload: {
ticketId: input.ticketId,
orderId: rows[0].order_id,
version: rows[0].version,
},
}, tx);
return { id: input.ticketId, status: next, version: rows[0].version };
});
}
private assertValidInput(input: { organizationId: string; locationId: string; ticketId: string; userId?: string; expectedVersion?: number }): void {
if (!input || typeof input !== 'object' ||
!this.isBoundedText(input.organizationId, 200) ||
!this.isBoundedText(input.locationId, 200) ||
!this.isBoundedText(input.ticketId, 200) ||
(input.userId !== undefined && !this.isBoundedText(input.userId, 200)) ||
(input.expectedVersion !== undefined && (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion < 0))) {
throw new DomainConflictError('kitchen.transition_input_invalid');
}
}
private isBoundedText(value: unknown, max: number): value is string {
return typeof value === 'string' && value.trim().length > 0 && value.length <= max;
}
}
