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

This commit is contained in:
2026-10-19 23:23:36 +02:00
parent fa38ac7617
commit 874f68b4b1
11 changed files with 577 additions and 2 deletions
@@ -9,6 +9,7 @@ function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
tabEvent: { createMany: jest.fn() },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
@@ -154,3 +155,49 @@ describe('DevicesService.sync writes', () => {
expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
});
});
describe('DevicesService.sync tab events', () => {
const event = {
id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e',
type: 'line_added',
tabId: 't1',
customerName: 'Jan',
occurredAt: new Date().toISOString(),
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
};
it('stores events once and acknowledges them', async () => {
const { service, tx } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [event] });
expect(tx.tabEvent.createMany).toHaveBeenCalledWith({
data: [expect.objectContaining({ id: event.id, venueId: 'v1', deviceId: 'd1', quantityDelta: 2 })],
skipDuplicates: true,
});
expect(result.acknowledgedEventIds).toEqual([event.id]);
expect(result.rejectedEvents).toEqual([]);
});
it('still syncs when an event is invalid, and reports it', async () => {
const { service, tx } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [], events: [{ ...event, quantity: -1 }] });
expect(tx.tabEvent.createMany).not.toHaveBeenCalled();
expect(tx.device.update).toHaveBeenCalled();
expect(result.acknowledgedEventIds).toEqual([]);
expect(result.rejectedEvents).toEqual([{ id: event.id, reason: expect.stringMatching(/quantity/) }]);
});
it('works for terminals that send no events', async () => {
const { service, tx } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(tx.tabEvent.createMany).not.toHaveBeenCalled();
expect(result.acknowledgedEventIds).toEqual([]);
});
});
+19
View File
@@ -1,5 +1,6 @@
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
ForbiddenException,
@@ -13,9 +14,12 @@ import { Prisma } from '@prisma/client';
import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
import { parseTabEvents, tabEventRow } from './tab-events';
@Injectable()
export class DevicesService {
private readonly logger = new Logger(DevicesService.name);
constructor(
private prisma: PrismaService,
private auditService: AuditService,
@@ -31,6 +35,10 @@ export class DevicesService {
}
const now = new Date();
const events = parseTabEvents(dto.events ?? []);
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
@@ -140,6 +148,13 @@ export class DevicesService {
}));
}
if (events.accepted.length > 0) {
await tx.tabEvent.createMany({
data: events.accepted.map((event) => tabEventRow(event, device.venueId, device.id, now)),
skipDuplicates: true,
});
}
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
@@ -208,6 +223,10 @@ export class DevicesService {
createdAt: c.createdAt,
})),
plannedTabNames: plannedTabs.map((t) => t.customerName),
// Stored (or already stored earlier): the terminal can drop these from its outbox
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never stored: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
};
}
+7
View File
@@ -70,4 +70,11 @@ export class SyncDto {
@ValidateNested({ each: true })
@Type(() => OpenTabDto)
openTabs: OpenTabDto[];
// Validated one by one in the service: a bad event is rejected on its own instead of failing the sync
@ApiPropertyOptional({ description: 'TabEventDto[]: what happened on tabs since the last acknowledged sync' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
}
+82
View File
@@ -0,0 +1,82 @@
import {
IsString, IsIn, IsInt, Min, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
ArrayMaxSize, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OpenTabItemDto } from './sync.dto';
export const TAB_EVENT_TYPES = ['line_added', 'line_removed', 'settled'] as const;
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
const isLine = (event: TabEventDto) => event.type !== 'settled';
const isSettlement = (event: TabEventDto) => event.type === 'settled';
export class TabEventDto {
@ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' })
@IsUUID()
id: string;
@ApiProperty({ enum: TAB_EVENT_TYPES })
@IsIn(TAB_EVENT_TYPES)
type: TabEventType;
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
@IsNotEmpty()
tabId: string;
@ApiProperty({ example: 'Jan' })
@IsString()
@MaxLength(200)
customerName: string;
@ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' })
@IsISO8601({ strict: true })
occurredAt: string;
@ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsString()
@IsNotEmpty()
@MaxLength(200)
productName?: string;
@ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsInt()
@Min(0)
unitPriceInCents?: number;
@ApiPropertyOptional({ example: 2, description: 'line_added / line_removed: how many were added or removed, always positive' })
@ValidateIf(isLine)
@IsInt()
@Min(1)
quantity?: number;
@ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' })
@ValidateIf(isSettlement)
@IsInt()
@Min(0)
amountInCents?: number;
@ApiPropertyOptional({ enum: SETTLEMENT_PAYMENT_METHODS, description: 'settled' })
@ValidateIf(isSettlement)
@IsIn(SETTLEMENT_PAYMENT_METHODS)
paymentMethod?: string;
@ApiPropertyOptional({ description: 'settled: the remote close command that caused it, if any' })
@IsOptional()
@IsUUID()
commandId?: string;
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'settled: the lines that were settled' })
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => OpenTabItemDto)
items?: OpenTabItemDto[];
}
+106
View File
@@ -0,0 +1,106 @@
import { Prisma } from '@prisma/client';
import { parseTabEvents, tabEventRow } from './tab-events';
const now = new Date('2026-10-24T22:00:00.000Z');
const lineAdded = {
id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e',
type: 'line_added',
tabId: 'tab-1',
customerName: 'Jan',
occurredAt: '2026-10-24T21:41:03.120Z',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
};
const settled = {
id: '7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a',
type: 'settled',
tabId: 'tab-1',
customerName: 'Jan',
occurredAt: '2026-10-24T21:55:00.000Z',
amountInCents: 500,
paymentMethod: 'payconiq',
items: [{ productName: 'Cola', quantity: 2, unitPriceInCents: 250 }],
};
describe('parseTabEvents', () => {
it('accepts valid line and settlement events', () => {
const { accepted, rejected } = parseTabEvents([lineAdded, settled]);
expect(accepted.map((e) => e.id)).toEqual([lineAdded.id, settled.id]);
expect(rejected).toEqual([]);
});
it('rejects invalid events one by one, keeping their id', () => {
const { accepted, rejected } = parseTabEvents([
lineAdded,
{ ...lineAdded, id: '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', quantity: 0 },
{ ...settled, id: '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', paymentMethod: 'bitcoin' },
{ ...lineAdded, id: 'not-a-uuid' },
'nonsense',
]);
expect(accepted.map((e) => e.id)).toEqual([lineAdded.id]);
expect(rejected.map((r) => r.id)).toEqual([
'8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b',
'9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c',
'not-a-uuid',
null,
]);
expect(rejected[0].reason).toMatch(/quantity/);
});
it('ignores fields it does not know, so newer app versions keep working', () => {
const { accepted } = parseTabEvents([{ ...lineAdded, discountInCents: 50 }]);
expect(accepted).toHaveLength(1);
expect(accepted[0]).not.toHaveProperty('discountInCents');
});
it('rejects line events without a product', () => {
const { productName, ...withoutProduct } = lineAdded;
expect(parseTabEvents([withoutProduct]).rejected).toHaveLength(1);
});
});
describe('tabEventRow', () => {
it('stores removed lines as a negative quantity', () => {
const [event] = parseTabEvents([{ ...lineAdded, type: 'line_removed', quantity: 3 }]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({
venueId: 'v1',
deviceId: 'd1',
type: 'line_removed',
productName: 'Cola',
unitPriceInCents: 250,
quantityDelta: -3,
occurredAt: new Date(lineAdded.occurredAt),
});
});
it('stores a settlement with its amount, payment method and lines', () => {
const [event] = parseTabEvents([settled]).accepted;
const row = tabEventRow(event, 'v1', 'd1', now);
expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null });
expect(row.items).toEqual(settled.items);
expect(row.quantityDelta).toBeUndefined();
});
it('stores JSON null for a settlement without lines', () => {
const { items, ...withoutItems } = settled;
const [event] = parseTabEvents([withoutItems]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now).items).toBe(Prisma.JsonNull);
});
it('uses the receive time when the terminal clock runs ahead', () => {
const [event] = parseTabEvents([{ ...lineAdded, occurredAt: '2026-10-25T03:00:00.000Z' }]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now).occurredAt).toEqual(now);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, ValidationError } from 'class-validator';
import { Prisma } from '@prisma/client';
import { TabEventDto } from './dto/tab-event.dto';
// A terminal clock that runs ahead must not put sales in the future
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
export type RejectedTabEvent = { id: string | null; reason: string };
export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejected: RejectedTabEvent[] } {
const accepted: TabEventDto[] = [];
const rejected: RejectedTabEvent[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'event must be an object' });
continue;
}
const event = plainToInstance(TabEventDto, value);
// Unknown fields are ignored so a newer app version adding one never loses sales
const errors = validateSync(event, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(event);
}
return { accepted, rejected };
}
export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput {
const occurredAt = new Date(event.occurredAt);
const base = {
id: event.id,
venueId,
deviceId,
tabId: event.tabId,
customerName: event.customerName,
type: event.type,
occurredAt: occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt,
receivedAt: now,
};
if (event.type === 'settled') {
return {
...base,
amountInCents: event.amountInCents,
paymentMethod: event.paymentMethod,
commandId: event.commandId ?? null,
items: event.items
? event.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }))
: Prisma.JsonNull,
};
}
return {
...base,
productName: event.productName,
unitPriceInCents: event.unitPriceInCents,
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
};
}
function describe(errors: ValidationError[]): string {
return errors
.flatMap((error) => [
...Object.values(error.constraints ?? {}),
...(error.children?.length ? [`${error.property}: ${describe(error.children)}`] : []),
])
.join('; ');
}