feat: make server own tabs
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-20 16:18:36 +02:00
parent 1e4438ec5a
commit 0ca9f4d16a
31 changed files with 2101 additions and 892 deletions
+37 -74
View File
@@ -5,12 +5,11 @@ import {
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CreateCommandDto } from './dto/create-command.dto';
import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltap/shared';
import { VenueLedger } from '../tabs/venue-ledger';
@Injectable()
export class CommandsService {
@@ -20,6 +19,11 @@ export class CommandsService {
private realtime: RealtimeService,
) {}
/**
* Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is
* settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the
* history.
*/
async create(venueId: string, dto: CreateCommandDto, user: any) {
if (!dto.paymentMethod) {
throw new BadRequestException({
@@ -36,72 +40,50 @@ export class CommandsService {
});
}
// Serializable so two simultaneous requests for the same tab can't both pass the pending check
const type = dto.type || 'close_tab';
if (type !== 'close_tab') {
throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` });
}
const requestedBy = user.name || user.email;
const result = await this.prisma.$transaction(async (tx) => {
// Resolve tab ownership
const ownership = await tx.tabOwnership.findUnique({
where: { venueId_tabId: { venueId, tabId: dto.tabId } },
});
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
if (!ownership) {
throw new NotFoundException({
code: 'tab_not_owned',
message: 'Tab is not associated with any device',
});
const tab = ledger.tabs.get(dto.tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
const device = await tx.device.findUnique({ where: { id: ownership.deviceId } });
if (!device) {
throw new NotFoundException({
code: 'device_not_found',
message: 'Owning device not found',
});
}
if (device.revokedAt) {
throw new ConflictException({
code: 'device_revoked',
message: 'The device owning this tab has been revoked',
});
}
const snapshot = await tx.openTabSnapshot.findUnique({
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
});
if (snapshot && snapshot.version !== dto.expectedVersion) {
if (tab.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const alreadyPending = await tx.remoteCommand.findFirst({
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
});
if (alreadyPending) {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
const id = uuidv4();
const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod });
if (!settlement) {
throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
}
const now = new Date();
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
await ledger.save(tx, now);
const command = await tx.remoteCommand.create({
data: {
id: uuidv4(),
id,
venueId,
deviceId: device.id,
deviceId: null,
targetTabId: dto.tabId,
customerName: snapshot?.customerName ?? null,
amountInCents: snapshot?.totalInCents ?? null,
type: dto.type || 'close_tab',
customerName: settlement.customerName,
amountInCents: settlement.amountInCents,
type,
expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod,
requestedBy: user.name || user.email,
status: 'pending',
expiresAt,
requestedBy,
status: 'completed',
acknowledgedAt: now,
expiresAt: now,
},
});
@@ -111,39 +93,20 @@ export class CommandsService {
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'command_created',
action: 'tab_settled',
details: JSON.stringify({
commandId: command.id,
tabId: dto.tabId,
type: dto.type,
paymentMethod: dto.paymentMethod,
deviceId: device.id,
amountInCents: settlement.amountInCents,
}),
},
});
// Check if device is stale
const lastSeenAgo = now.getTime() - new Date(device.lastSeenAt).getTime();
const warning = lastSeenAgo > DEVICE_OFFLINE_THRESHOLD_MS
? { warning: 'Device has not been seen recently' }
: {};
return {
id: command.id,
status: command.status,
...warning,
};
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
}
throw error;
return { id: command.id, status: command.status, amountInCents: settlement.amountInCents };
});
this.realtime.notify(venueId, 'commands');
this.realtime.notify(venueId, 'commands', 'tabs');
return result;
}