feat: add better tab view
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-28 00:37:16 +01:00
parent bca6e88138
commit ca3af671cf
37 changed files with 740 additions and 79 deletions
+18 -4
View File
@@ -4,7 +4,7 @@ import { SyncDto } from './dto/sync.dto';
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
import { StockService } from '../stock/stock.service';
type Device = { id: string; venueId: string; revokedAt: null; lastSeenAt: Date };
type Device = { id: string; venueId: string; name?: string; revokedAt: null; lastSeenAt: Date };
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
@@ -17,16 +17,17 @@ const at = (minutes: number) => new Date(Date.UTC(
function setup({ seeded = true } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
const tabletA: Device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
const tabletA: Device = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
const realtime = { notify: jest.fn() };
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any);
const cashPayments = { notify: jest.fn() };
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
return { prisma, service, realtime, sync, tabletA, tabletB };
return { prisma, service, realtime, cashPayments, sync, tabletA, tabletB };
}
/** Builds the events a terminal sends, with fresh ids. */
@@ -244,6 +245,19 @@ describe('DevicesService.sync: settlements feed', () => {
expect(second.settlementsCursor).toBe(first.settlementsCursor);
});
it('hands a settlement to the cash payment emails once, with the tablet that settled it', async () => {
const { sync, tabletA, cashPayments } = setup();
const settled = { ...events.settled('t1', 1, 2), paymentMethod: 'cash' };
const batch = [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled];
await sync(tabletA, { events: batch });
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ id: settled.id, paymentMethod: 'cash' })], 'Bar tablet');
// Resent because the acknowledgement was lost: already applied, so nothing new to email
await sync(tabletA, { events: batch });
expect(cashPayments.notify).toHaveBeenLastCalledWith('v1', [], 'Bar tablet');
});
it('starts an empty feed after the latest settlement', async () => {
const { sync, tabletA } = setup();
+3
View File
@@ -21,6 +21,7 @@ import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
import { applyPendingAdjustments } from '../stock/stock-adjustments';
import { applyProductEvent } from '../stock/product-events';
import { isProductEvent } from './dto/tab-event.dto';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
@Injectable()
export class DevicesService {
@@ -30,6 +31,7 @@ export class DevicesService {
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
async sync(device: any, dto: SyncDto) {
@@ -177,6 +179,7 @@ export class DevicesService {
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed, catalog } = synced;
this.cashPayments.notify(venueId, ledger.settlements, device.name);
return {
cursor,
commands: commands.map((c) => ({
+11
View File
@@ -118,6 +118,17 @@ export class TabEventDto {
@MaxLength(200)
productName?: string;
@ApiPropertyOptional({
example: 'order-uuid',
nullable: true,
description: 'line_added: the order the drinks are part of. line_removed: the order they are taken off, null for '
+ 'drinks without one. Missing from older app versions: line_removed then takes the latest drinks of the line',
})
@IsOptional()
@IsString()
@MaxLength(100)
orderId?: string | null;
@ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsInt()
+1
View File
@@ -92,6 +92,7 @@ export function tabEventRow(event: TabEventDto, venueId: string, deviceId: strin
productName: event.productName,
unitPriceInCents: event.unitPriceInCents,
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
orderId: event.orderId ?? null,
};
}