diff --git a/apps/api/prisma/migrations/20261009120000_tab_snapshot_items/migration.sql b/apps/api/prisma/migrations/20261009120000_tab_snapshot_items/migration.sql new file mode 100644 index 0000000..a7e1ebf --- /dev/null +++ b/apps/api/prisma/migrations/20261009120000_tab_snapshot_items/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f209ddc..d8d9d65 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -77,6 +77,9 @@ model OpenTabSnapshot { version Int totalInCents Int itemCount Int + // The tab's lines as reported by the terminal: [{ productName, quantity, unitPriceInCents }]. + // Null for terminals running an app version that doesn't report them. + items Json? reportedAt DateTime device Device @relation(fields: [deviceId], references: [id]) diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts index aa236db..fc77847 100644 --- a/apps/api/src/devices/devices.service.spec.ts +++ b/apps/api/src/devices/devices.service.spec.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { DevicesService } from './devices.service'; type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number }; @@ -101,3 +102,26 @@ describe('DevicesService.sync live update signals', () => { expect(realtime.notify).toHaveBeenCalledWith('v1', 'device'); }); }); + +describe('DevicesService.sync tab lines', () => { + const base = { id: 't1', customerName: 'Bram', version: 4, totalInCents: 2500, itemCount: 10 }; + + it('stores the lines a terminal reports', async () => { + const { service, tx } = createService(); + const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }]; + + await service.sync(device, { deviceId: 'd1', openTabs: [{ ...base, items }] }); + + const { create, update } = tx.openTabSnapshot.upsert.mock.calls[0][0]; + expect(create.items).toEqual(items); + expect(update.items).toEqual(items); + }); + + it('stores no lines for app versions that do not report them', async () => { + const { service, tx } = createService(); + + await service.sync(device, { deviceId: 'd1', openTabs: [base] }); + + expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull); + }); +}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index 1768093..0289232 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -9,7 +9,8 @@ import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service'; import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared'; -import { SyncDto } from './dto/sync.dto'; +import { Prisma } from '@prisma/client'; +import { OpenTabDto, SyncDto } from './dto/sync.dto'; import { AckCommandDto } from './dto/ack-command.dto'; @Injectable() @@ -82,6 +83,7 @@ export class DevicesService { version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, + items: snapshotItems(tab), reportedAt: now, }, update: { @@ -89,6 +91,7 @@ export class DevicesService { version: tab.version, totalInCents: tab.totalInCents, itemCount: tab.itemCount, + items: snapshotItems(tab), reportedAt: now, }, }); @@ -274,4 +277,10 @@ export class DevicesService { return { ok: true }; } -} \ No newline at end of file +} + +/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */ +function snapshotItems(tab: OpenTabDto) { + if (!tab.items) return Prisma.JsonNull; + return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents })); +} diff --git a/apps/api/src/devices/dto/sync.dto.ts b/apps/api/src/devices/dto/sync.dto.ts index 9cd3921..5b475d5 100644 --- a/apps/api/src/devices/dto/sync.dto.ts +++ b/apps/api/src/devices/dto/sync.dto.ts @@ -1,8 +1,26 @@ -import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator'; +import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty, MaxLength } from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -class OpenTabDto { +export class OpenTabItemDto { + @ApiProperty({ example: 'Cola' }) + @IsString() + @IsNotEmpty() + @MaxLength(200) + productName: string; + + @ApiProperty({ example: 10 }) + @IsInt() + @Min(0) + quantity: number; + + @ApiProperty({ example: 250 }) + @IsInt() + @Min(0) + unitPriceInCents: number; +} + +export class OpenTabDto { @ApiProperty({ example: 'local-tab-uuid' }) @IsString() @IsNotEmpty() @@ -26,6 +44,14 @@ class OpenTabDto { @IsInt() @Min(0) itemCount: number; + + @ApiPropertyOptional({ type: [OpenTabItemDto], description: 'Lines on the tab; omitted by older app versions' }) + @IsOptional() + @IsArray() + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => OpenTabItemDto) + items?: OpenTabItemDto[]; } export class SyncDto { diff --git a/apps/api/src/email/email-templates.service.spec.ts b/apps/api/src/email/email-templates.service.spec.ts index 45694b9..145287c 100644 --- a/apps/api/src/email/email-templates.service.spec.ts +++ b/apps/api/src/email/email-templates.service.spec.ts @@ -15,19 +15,25 @@ function withTemplates(files: Record) { } describe('EmailTemplatesService', () => { + // The shipped templates are meant to be edited, so these only check that every value is filled in it('renders the shipped payment reminder with a subject and a text version', async () => { const service = new EmailTemplatesService(); const email = await service.render('payment-reminder', { customerName: 'Jan', venueName: 'Café De Kroon', - amount: '€12.50', + amount: '€ 12,50', itemCount: 3, + hasItems: true, + items: [{ quantity: 3, productName: 'Cola', unitPrice: '€ 2,50', lineTotal: '€ 7,50' }], }); - expect(email.subject).toBe('Payment reminder for your tab at Café De Kroon'); - expect(email.html).toContain('€12.50'); - expect(email.text).toContain('Hi Jan,'); + expect(email.subject.length).toBeGreaterThan(0); + expect(email.html).toContain('€ 12,50'); + expect(email.html).toContain('Cola'); + expect(email.text).toContain('Cola'); + expect(email.html).toContain('Jan'); + expect(email.text).toContain('Jan'); expect(email.html).not.toContain('{{'); expect(email.text).not.toContain('{{'); }); @@ -35,7 +41,9 @@ describe('EmailTemplatesService', () => { it('renders the shipped test email', async () => { const email = await new EmailTemplatesService().render('test-email', { customerName: 'Jan', venueName: 'De Kroon' }); - expect(email.subject).toBe('Test email from De Kroon'); + expect(email.subject.length).toBeGreaterThan(0); + expect(email.html).toContain('Jan'); + expect(`${email.subject} ${email.html}`).not.toContain('{{'); }); it('escapes values in the HTML but not in the subject or text version', async () => { @@ -83,4 +91,59 @@ describe('EmailTemplatesService', () => { await expect(service.render('payment-reminder', {})).rejects.toThrow(/not found/); cleanup(); }); + + describe('blocks', () => { + const render = async (html: string, variables: Record) => { + const { service, cleanup } = withTemplates({ 'test-email.html': `T${html}` }); + try { + return (await service.render('test-email', variables)).html.replace('T', ''); + } finally { + cleanup(); + } + }; + + it('repeats a list block per item, with outer values still available', async () => { + const html = await render('{{#items}}[{{quantity}}x {{productName}} @ {{venueName}}]{{/items}}', { + venueName: 'Kroon', + items: [{ quantity: 10, productName: 'Cola' }, { quantity: 2, productName: 'Chips' }], + }); + + expect(html).toBe('[10x Cola @ Kroon][2x Chips @ Kroon]'); + }); + + it('shows a conditional block only when its value is set', async () => { + const template = '{{#hasItems}}

Overzicht

{{#items}}{{productName}}{{/items}}{{/hasItems}}end'; + + expect(await render(template, { hasItems: true, items: [{ productName: 'Cola' }] })).toBe('

Overzicht

Colaend'); + expect(await render(template, { hasItems: false, items: [] })).toBe('end'); + }); + + it('escapes values from list items', async () => { + expect(await render('{{#items}}{{productName}}{{/items}}', { items: [{ productName: '