feat: change email template + add list of items

This commit is contained in:
2026-10-09 09:30:26 +02:00
parent 6f237a937a
commit 5246aeabc1
17 changed files with 474 additions and 55 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB;
+3
View File
@@ -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])
@@ -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);
});
});
+11 -2
View File
@@ -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 };
}
}
}
/** 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 }));
}
+28 -2
View File
@@ -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 {
@@ -15,19 +15,25 @@ function withTemplates(files: Record<string, string>) {
}
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('<strong>€12.50</strong>');
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<string, any>) => {
const { service, cleanup } = withTemplates({ 'test-email.html': `<title>T</title>${html}` });
try {
return (await service.render('test-email', variables)).html.replace('<title>T</title>', '');
} 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}}<p>Overzicht</p>{{#items}}{{productName}}{{/items}}{{/hasItems}}end';
expect(await render(template, { hasItems: true, items: [{ productName: 'Cola' }] })).toBe('<p>Overzicht</p>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: '<script>' }] })).toBe('&lt;script&gt;');
});
it('never treats values as template syntax', async () => {
const html = await render('{{#items}}{{productName}}{{/items}} {{customerName}}', {
customerName: '{{venueName}}',
venueName: 'secret',
items: [{ productName: '{{#items}}' }],
});
expect(html).toBe('{{#items}} {{venueName}}');
});
it('reports a block that is never closed', async () => {
await expect(render('{{#items}}{{productName}}', { items: [] })).rejects.toThrow(/never closed/);
});
it('reports a list printed as a plain value', async () => {
await expect(render('{{items}}', { items: [] })).rejects.toThrow(/is a list/);
});
it('reports unknown placeholders inside a list item', async () => {
await expect(render('{{#items}}{{prodcutName}}{{/items}}', { items: [{ productName: 'Cola' }] })).rejects.toThrow(
/unknown placeholders: prodcutName/,
);
});
});
});
+92 -12
View File
@@ -8,9 +8,12 @@ export interface RenderedEmail {
text?: string;
}
export type TemplateVariables = Record<string, string | number>;
/** A value, a condition for `{{#key}}...{{/key}}`, or a list repeated by that block. */
export type TemplateValue = string | number | boolean | TemplateVariables[];
export type TemplateVariables = { [key: string]: TemplateValue };
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
// `{{name}}`, `{{#name}}` (block start) or `{{/name}}` (block end)
const TOKEN = String.raw`\{\{\s*([#/]?)\s*([A-Za-z0-9_]+)\s*\}\}`;
const TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
/** Templates the API sends; each needs `<name>.html` (and optionally `<name>.txt`). */
@@ -83,22 +86,90 @@ export class EmailTemplatesService implements OnModuleInit {
variables: TemplateVariables,
encode: (value: string) => string,
): string {
const missing = new Set<string>();
const result = template.replace(PLACEHOLDER, (_, key: string) => {
if (!(key in variables)) {
missing.add(key);
return '';
}
return encode(String(variables[key]));
});
const problems: Problems = { unknown: new Set(), other: new Set() };
const result = this.renderBlock(template, [variables], encode, problems);
// Never send an email with a blank where a value should be
if (missing.size > 0) {
throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...missing].join(', ')}`);
if (problems.unknown.size > 0) {
throw new EmailTemplateError(`Email template "${name}" uses unknown placeholders: ${[...problems.unknown].join(', ')}`);
}
if (problems.other.size > 0) {
throw new EmailTemplateError(`Email template "${name}" has errors: ${[...problems.other].join('; ')}`);
}
return result;
}
/**
* Renders in a single pass so values are never parsed again: a customer called "{{venueName}}"
* stays literal text. `scopes` lists the variables visible here, innermost (current list item) first.
*/
private renderBlock(
template: string,
scopes: TemplateVariables[],
encode: (value: string) => string,
problems: Problems,
): string {
const token = new RegExp(TOKEN, 'g');
let output = '';
let position = 0;
let match: RegExpExecArray | null;
while ((match = token.exec(template))) {
const [whole, kind, key] = match;
output += template.slice(position, match.index);
position = match.index + whole.length;
if (kind === '/') {
problems.other.add(`{{/${key}}} has no matching {{#${key}}}`);
continue;
}
if (kind === '#') {
const close = new RegExp(String.raw`\{\{\s*/\s*${key}\s*\}\}`, 'g');
close.lastIndex = position;
const end = close.exec(template);
if (!end) {
problems.other.add(`{{#${key}}} is never closed with {{/${key}}}`);
break;
}
output += this.renderSection(key, template.slice(position, end.index), scopes, encode, problems);
position = end.index + end[0].length;
token.lastIndex = position;
continue;
}
const value = lookup(key, scopes);
if (value === undefined) {
problems.unknown.add(key);
} else if (Array.isArray(value)) {
problems.other.add(`{{${key}}} is a list; repeat it with {{#${key}}}...{{/${key}}}`);
} else {
output += encode(String(value));
}
}
return output + template.slice(position);
}
/** A list repeats the block once per item; any other value shows it once when set. */
private renderSection(
key: string,
body: string,
scopes: TemplateVariables[],
encode: (value: string) => string,
problems: Problems,
): string {
const value = lookup(key, scopes);
if (value === undefined) {
problems.unknown.add(key);
return '';
}
if (Array.isArray(value)) {
return value.map((item) => this.renderBlock(body, [item, ...scopes], encode, problems)).join('');
}
return value ? this.renderBlock(body, scopes, encode, problems) : '';
}
private async read(file: string): Promise<string | null> {
try {
return await fs.readFile(path.join(this.directory, file), 'utf8');
@@ -108,3 +179,12 @@ export class EmailTemplatesService implements OnModuleInit {
}
}
}
type Problems = { unknown: Set<string>; other: Set<string> };
function lookup(key: string, scopes: TemplateVariables[]): TemplateValue | undefined {
for (const scope of scopes) {
if (key in scope) return scope[key];
}
return undefined;
}
@@ -0,0 +1,63 @@
import { EmailTemplatesService } from '../email/email-templates.service';
import { RemindersService } from './reminders.service';
function createService(items: unknown) {
const prisma = {
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
openTabSnapshot: {
findMany: jest.fn().mockResolvedValue([
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
]),
},
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) },
tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() },
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
};
return new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
}
describe('RemindersService.sendReminders', () => {
const originalFetch = global.fetch;
let sentBodies: any[];
beforeEach(() => {
process.env.RESEND_API_KEY = 'test-key';
sentBodies = [];
global.fetch = jest.fn(async (_url: any, init: any) => {
sentBodies.push(JSON.parse(init.body));
return { ok: true, text: async () => '' } as Response;
}) as any;
});
afterEach(() => {
global.fetch = originalFetch;
delete process.env.RESEND_API_KEY;
});
it('includes the tab summary with Belgian amounts', async () => {
const service = createService([
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
]);
const result = await service.sendReminders('v1', 'admin@example.com');
expect(result.sent).toBe(1);
const [email] = sentBodies;
expect(email.to).toBe('bram@example.com');
expect(email.text).toMatch(/10x Cola\s+€\s25,00/);
expect(email.text).toMatch(/2x Chips\s+€\s4,00/);
expect(email.html).toContain('10x Cola');
expect(email.subject).toContain('De Kroon');
});
it('leaves the summary out when the terminal did not report lines', async () => {
const service = createService(null);
await service.sendReminders('v1', 'admin@example.com');
const [email] = sentBodies;
expect(email.text).not.toContain('10x');
expect(email.text).toMatch(/€\s29,00/);
});
});
+15 -1
View File
@@ -70,6 +70,8 @@ export class RemindersService {
email,
amountInCents: snap.totalInCents,
itemCount: snap.itemCount,
// Null when the terminal's app version doesn't report lines
items: (snap.items as TabLine[] | null) ?? null,
});
}
@@ -101,6 +103,13 @@ export class RemindersService {
venueName,
amount: formatEuro(candidate.amountInCents),
itemCount: candidate.itemCount,
hasItems: (candidate.items?.length ?? 0) > 0,
items: (candidate.items ?? []).map((item) => ({
quantity: item.quantity,
productName: item.productName,
unitPrice: formatEuro(item.unitPriceInCents),
lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
})),
});
const error = await this.sendEmail(candidate.email, email);
@@ -208,6 +217,11 @@ export class RemindersService {
}
}
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
function formatEuro(cents: number): string {
return `€${(cents / 100).toFixed(2)}`;
return euro.format(cents / 100);
}
+2
View File
@@ -40,6 +40,8 @@ export class TabsService {
version: snapshot.version,
totalInCents: snapshot.totalInCents,
itemCount: snapshot.itemCount,
// null: the terminal's app version doesn't report lines yet
items: snapshot.items ?? null,
deviceId: ownership.device.id,
deviceName: ownership.device.name,
deviceLastSeenAt: ownership.device.lastSeenAt,
+27 -2
View File
@@ -16,6 +16,25 @@ Write `{{name}}` to insert a value. Values are HTML-escaped in the `.html` file
and inserted as-is in the `.txt` file. A placeholder without a value stops the email from being sent,
so a typo never reaches a customer.
### Blocks
`{{#name}} ... {{/name}}` does one of two things, depending on the value:
- **A list** repeats the block once per item. Inside it you can use that item's placeholders
(and every placeholder from outside the block).
- **Anything else** shows the block only when the value is set (e.g. `true`), and hides it otherwise.
```html
{{#hasItems}}
<p>Overzicht van je poef:</p>
<table>
{{#items}}<tr><td>{{quantity}}x {{productName}}</td><td>{{lineTotal}}</td></tr>{{/items}}
</table>
{{/hasItems}}
```
Amounts are already formatted the Belgian way (`€ 25,00`).
### `payment-reminder`
Sent by "Send reminders" to customers with an unpaid balance.
@@ -24,8 +43,14 @@ Sent by "Send reminders" to customers with an unpaid balance.
|---|---|
| `{{customerName}}` | Jan Peeters |
| `{{venueName}}` | Café De Kroon |
| `{{amount}}` | €12.50 |
| `{{itemCount}}` | 3 |
| `{{amount}}` | € 29,00 |
| `{{itemCount}}` | 12 |
| `{{#hasItems}}…{{/hasItems}}` | Shown when the tab's lines are known. Terminals on an older app version don't report them. |
| `{{#items}}…{{/items}}` | Repeated for each line on the tab, with: |
| &nbsp;&nbsp;`{{quantity}}` | 10 |
| &nbsp;&nbsp;`{{productName}}` | Cola |
| &nbsp;&nbsp;`{{unitPrice}}` | € 2,50 |
| &nbsp;&nbsp;`{{lineTotal}}` | € 25,00 |
### `test-email`
@@ -2,12 +2,28 @@
<html>
<head>
<meta charset="utf-8">
<title>Payment reminder for your tab at {{venueName}}</title>
<title>Herinnering aan open poef bij {{venueName}}</title>
</head>
<body>
<p>Hi {{customerName}},</p>
<p>This is a friendly reminder that you have an open tab of <strong>{{amount}}</strong> at {{venueName}} (items: {{itemCount}}).</p>
<p>Please settle your tab at the bar at your earliest convenience.</p>
<p>Thank you!</p>
<p>Beste {{customerName}},</p>
<p>Dit is een kleine reminder om je poef van <strong>{{amount}}</strong> nog te betalen (items: {{itemCount}}).</p>
{{#hasItems}}
<p>Overzicht van je poef:</p>
<table cellpadding="4" cellspacing="0" style="border-collapse: collapse;">
{{#items}}
<tr>
<td>{{quantity}}x {{productName}}</td>
<td style="text-align: right; padding-left: 24px;">{{lineTotal}}</td>
</tr>
{{/items}}
<tr>
<td style="border-top: 1px solid #ccc;"><strong>Totaal</strong></td>
<td style="border-top: 1px solid #ccc; text-align: right; padding-left: 24px;"><strong>{{amount}}</strong></td>
</tr>
</table>
{{/hasItems}}
<p>Gelieve je poef zo spoedig mogelijk te betalen.</p>
<p>Met vriendelijke groet,</p>
<p>{{venueName}}</p>
</body>
</html>
+10 -5
View File
@@ -1,7 +1,12 @@
Hi {{customerName}},
Beste {{customerName}},
This is a friendly reminder that you have an open tab of {{amount}} at {{venueName}} (items: {{itemCount}}).
Dit is een vriendelijke herinnering om je openstaande poef van {{amount}} bij {{venueName}} (items: {{itemCount}}) te betalen.
{{#hasItems}}
Overzicht van je poef:
{{#items}}{{quantity}}x {{productName}} {{lineTotal}}
{{/items}}Totaal: {{amount}}
{{/hasItems}}
Gelieve je poef zo spoedig mogelijk te betalen.
Please settle your tab at the bar at your earliest convenience.
Thank you!
Met vriendelijke groet,
{{venueName}}