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
+8 -2
View File
@@ -11,6 +11,7 @@ import { CommandExpiryJob } from '../jobs/command-expiry.job';
import { createdFieldTimes } from '../stock/product-fields';
import { checkConsistency } from './consistency';
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
import { PurgeTarget } from './dto/admin.dto';
/** The superadmin doing it. */
@@ -18,7 +19,8 @@ export type Actor = { id: string; email: string; name: string; venueId: string }
// What each reset scope changes on the dashboard
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
tabs: ['tabs'],
// Plans the Free Drinks customer again
tabs: ['tabs', 'planned-tabs'],
settlements: ['tabs'],
tabEvents: [],
plannedTabs: ['planned-tabs', 'reminders'],
@@ -92,7 +94,11 @@ export class AdminService {
}
async createVenue(name: string, actor: Actor) {
const venue = await this.prisma.venue.create({ data: { name: name.trim() } });
const venue = await this.prisma.$transaction(async (tx) => {
const created = await tx.venue.create({ data: { name: name.trim() } });
await ensureDefaultCustomers(tx, created.id);
return created;
});
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
return venue;
}
+3
View File
@@ -1,4 +1,5 @@
import { Prisma } from '@prisma/client';
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
type Tx = Prisma.TransactionClient;
@@ -73,6 +74,8 @@ export async function resetVenue(
await tx.tabReminder.deleteMany({ where });
counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
}
// The Free Drinks tab is always there: planned again, and opened on the next sync
if (has('tabs') || has('plannedTabs')) await ensureDefaultCustomers(tx, venueId);
if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
if (has('stock')) {
const { now, actor, stockValue } = options;
@@ -16,8 +16,9 @@ function createService({ lines = [] as unknown[] } = {}) {
updatedAt: new Date(),
});
const realtime = { notify: jest.fn() };
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any);
return { service, prisma, realtime };
const cashPayments = { notify: jest.fn() };
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
return { service, prisma, realtime, cashPayments };
}
const colaLine = {
@@ -27,7 +28,7 @@ const colaLine = {
describe('CommandsService.create', () => {
it('settles everything on the tab at once and records it as completed', async () => {
const { service, prisma, realtime } = createService({ lines: [colaLine] });
const { service, prisma, realtime, cashPayments } = createService({ lines: [colaLine] });
const result = await service.create('v1', dto, user);
@@ -43,6 +44,7 @@ describe('CommandsService.create', () => {
id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500,
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs');
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ paymentMethod: 'cash', amountInCents: 500 })], 'Staff');
});
it('rejects a tab that is not open', async () => {
+8 -2
View File
@@ -10,6 +10,7 @@ import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CreateCommandDto } from './dto/create-command.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
@Injectable()
export class CommandsService {
@@ -17,6 +18,7 @@ export class CommandsService {
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
/**
@@ -46,7 +48,7 @@ export class CommandsService {
}
const requestedBy = user.name || user.email;
const result = await this.prisma.$transaction(async (tx) => {
const { result, settlements } = await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
@@ -103,10 +105,14 @@ export class CommandsService {
},
});
return { id: command.id, status: command.status, amountInCents: settlement.amountInCents };
return {
result: { id: command.id, status: command.status, amountInCents: settlement.amountInCents },
settlements: ledger.settlements,
};
});
this.realtime.notify(venueId, 'commands', 'tabs');
this.cashPayments.notify(venueId, settlements, requestedBy);
return result;
}
+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,
};
}
@@ -0,0 +1,51 @@
import { CashPaymentNotifier } from './cash-payment-notifier';
import { createFakePrisma } from '../testing/fake-prisma';
const settlement = (paymentMethod: string, id = 's1') => ({
id, tabId: 't1', customerName: 'Jan', settledAt: new Date('2026-10-27T20:15:00Z'), paymentMethod, amountInCents: 500,
commandId: null,
lines: [{ id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, units: [] }],
});
function setup(emails: string[]) {
process.env.RESEND_API_KEY = 'test-key';
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', name: 'Café De Kroon', cashPaymentEmails: emails });
const render = jest.fn().mockResolvedValue({ subject: 'Cash', html: '<p>Cash</p>' });
const notifier = new CashPaymentNotifier(prisma as any, { render } as any);
const fetchMock = jest.fn().mockResolvedValue({ ok: true, text: async () => '' });
global.fetch = fetchMock as any;
return { notifier, render, fetchMock };
}
afterEach(() => {
delete process.env.RESEND_API_KEY;
});
describe('CashPaymentNotifier', () => {
it('emails every address about a cash payment, once per settlement', async () => {
const { notifier, render, fetchMock } = setup(['a@bar.be', 'b@bar.be']);
await notifier.send('v1', [settlement('cash')], 'Bar tablet');
expect(render).toHaveBeenCalledWith('cash-payment', expect.objectContaining({
customerName: 'Jan', venueName: 'Café De Kroon', settledBy: 'Bar tablet', hasItems: true,
items: [expect.objectContaining({ quantity: 2, productName: 'Cola' })],
}));
const [url, request] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.resend.com/emails/batch');
expect(request.headers['Idempotency-Key']).toBe('cash-payment-s1');
expect(JSON.parse(request.body).map((m: { to: string }) => m.to)).toEqual(['a@bar.be', 'b@bar.be']);
});
it('sends nothing for other payment methods or without addresses', async () => {
const withAddress = setup(['a@bar.be']);
withAddress.notifier.notify('v1', [settlement('payconiq')], 'Bar tablet');
await new Promise((resolve) => setImmediate(resolve));
expect(withAddress.fetchMock).not.toHaveBeenCalled();
const withoutAddress = setup([]);
await withoutAddress.notifier.send('v1', [settlement('cash')], 'Bar tablet');
expect(withoutAddress.fetchMock).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EmailTemplatesService } from './email-templates.service';
import { RecordedSettlement } from '../tabs/venue-ledger';
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
const formatEuro = (cents: number) => euro.format(cents / 100);
const dateTime = new Intl.DateTimeFormat('nl-BE', {
timeZone: 'Europe/Brussels', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
});
/**
* Emails the venue's cash payment addresses (set on the dashboard) about every tab paid in cash, on a terminal or on
* the dashboard. Runs after the settlement is stored and never fails it: a mail that can't be sent is only logged.
*/
@Injectable()
export class CashPaymentNotifier {
private readonly logger = new Logger(CashPaymentNotifier.name);
private readonly resendApiKey = process.env.RESEND_API_KEY || '';
private readonly fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
constructor(
private prisma: PrismaService,
private emailTemplates: EmailTemplatesService,
) {}
/** Sends in the background; [settlements] may hold other payment methods, which are skipped. */
notify(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
const cash = settlements.filter((s) => s.paymentMethod === 'cash');
if (cash.length === 0) return;
this.send(venueId, cash, settledBy).catch((error) => {
this.logger.error(`Cash payment email for venue ${venueId} failed: ${error?.message ?? error}`);
});
}
async send(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
const venue = await this.prisma.venue.findUnique({
where: { id: venueId },
select: { name: true, cashPaymentEmails: true },
});
const recipients = venue?.cashPaymentEmails ?? [];
if (recipients.length === 0) return;
if (!this.resendApiKey) {
this.logger.warn(`Not emailing ${settlements.length} cash payment(s) of venue ${venueId}: RESEND_API_KEY is not set`);
return;
}
for (const settlement of settlements) {
const items = settlement.lines.filter((line) => line.quantity > 0);
const email = await this.emailTemplates.render('cash-payment', {
venueName: venue!.name,
customerName: settlement.customerName,
amount: formatEuro(settlement.amountInCents),
settledAt: dateTime.format(settlement.settledAt),
settledBy,
hasItems: items.length > 0,
items: items.map((line) => ({
quantity: line.quantity,
productName: line.productName,
lineTotal: formatEuro(line.quantity * line.unitPriceInCents),
})),
});
const res = await fetch('https://api.resend.com/emails/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
// One settlement is emailed once, even when this request is retried
'Idempotency-Key': `cash-payment-${settlement.id}`,
},
body: JSON.stringify(recipients.map((to) => ({ from: this.fromEmail, to, ...email }))),
});
if (!res.ok) {
this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${await res.text()}`);
}
}
}
}
@@ -17,7 +17,7 @@ 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`). */
export const EMAIL_TEMPLATES = ['payment-reminder', 'test-email'] as const;
export const EMAIL_TEMPLATES = ['payment-reminder', 'test-email', 'cash-payment'] as const;
export type EmailTemplateName = (typeof EMAIL_TEMPLATES)[number];
export class EmailTemplateError extends Error {}
+3 -2
View File
@@ -1,9 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { EmailTemplatesService } from './email-templates.service';
import { CashPaymentNotifier } from './cash-payment-notifier';
@Global()
@Module({
providers: [EmailTemplatesService],
exports: [EmailTemplatesService],
providers: [EmailTemplatesService, CashPaymentNotifier],
exports: [EmailTemplatesService, CashPaymentNotifier],
})
export class EmailModule {}
@@ -0,0 +1,46 @@
import { DevicesService } from '../devices/devices.service';
import { SyncDto } from '../devices/dto/sync.dto';
import { resetVenue } from '../admin/venue-reset';
import { createFakePrisma } from '../testing/fake-prisma';
import { ensureDefaultCustomers, FREE_DRINKS_CUSTOMER } from './default-customers';
function setup() {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
const tablet = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
prisma.device.rows.push({ ...tablet });
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any, { notify: jest.fn() } as any);
const sync = () => service.sync(tablet, { deviceId: tablet.id, openTabs: [] } as SyncDto);
const reset = (scopes: ('tabs' | 'plannedTabs')[]) =>
resetVenue(prisma as any, 'v1', scopes, { stockValue: 0, actor: { id: 'u1', name: 'Admin' }, now: new Date() });
return { prisma, sync, reset };
}
const names = (result: { tabs: { customerName: string }[] }) => result.tabs.map((tab) => tab.customerName);
describe('ensureDefaultCustomers', () => {
it('gives a venue a Free Drinks tab, without email, on the next sync', async () => {
const { prisma, sync } = setup();
await ensureDefaultCustomers(prisma as any, 'v1');
expect(prisma.plannedTab.rows).toEqual([expect.objectContaining({ customerName: FREE_DRINKS_CUSTOMER, email: null })]);
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
// Opened once: a second sync keeps the one tab
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
});
it('brings the tab back after a reset of the tabs or the planned customers', async () => {
const { prisma, sync, reset } = setup();
await ensureDefaultCustomers(prisma as any, 'v1');
await sync();
await reset(['tabs']);
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
await reset(['plannedTabs']);
expect(prisma.plannedTab.rows).toHaveLength(1);
// Its tab was still open: kept, not opened a second time
expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]);
});
});
@@ -0,0 +1,21 @@
import { Prisma } from '@prisma/client';
import { customerNameKey } from './customer-name';
type Tx = Prisma.TransactionClient;
/** The tab free drinks are put on, so they still count in the stock and history. A normal customer without email. */
export const FREE_DRINKS_CUSTOMER = 'Free Drinks';
/**
* Plans the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose
* tab was wiped, is planned again: run when a venue is created or reset.
*/
export async function ensureDefaultCustomers(tx: Tx, venueId: string) {
const key = customerNameKey(FREE_DRINKS_CUSTOMER);
await tx.plannedTab.upsert({
where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
create: { venueId, customerName: FREE_DRINKS_CUSTOMER, customerNameKey: key },
// Opened again when its tab is gone; an open tab with this name is kept
update: { deliveredAt: null },
});
}
+90
View File
@@ -0,0 +1,90 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { tabSummary, VenueTabs } from './tab-state';
import { tabOrders } from './tab-orders';
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes));
let counter = 0;
function event(fields: Partial<TabEventDto> & { type: TabEventDto['type'] }): TabEventDto {
return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: at(0).toISOString(), ...fields } as TabEventDto;
}
const duvel = { lineId: 'duvel', productId: 'duvel', productName: 'Duvel', unitPriceInCents: 400 };
const cola = { lineId: 'cola', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 };
const beer = { lineId: 'beer', productId: 'beer', productName: 'Beer', unitPriceInCents: 300 };
function tabWith(changes: [minutes: number, fields: Partial<TabEventDto> & { type: TabEventDto['type'] }][]) {
const tabs = new VenueTabs([]);
tabs.apply(event({ type: 'tab_opened' }), at(0));
for (const [minutes, fields] of changes) tabs.apply(event(fields), at(minutes));
return tabs;
}
describe('tabOrders', () => {
it('shows the drinks of each order under the time it started', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
[1, { type: 'line_added', quantity: 1, orderId: 'o1', ...cola }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
[39, { type: 'line_added', quantity: 1, orderId: 'o2', ...beer }],
]);
expect(tabOrders(tabs.get('t1')!.lines)).toEqual([
{
orderId: 'o1', startedAt: at(0).toISOString(),
items: [
{ productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 },
{ productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 },
],
},
{
orderId: 'o2', startedAt: at(38).toISOString(),
items: [
{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 },
{ productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 },
],
},
]);
});
it('groups drinks from older app versions by time: a gap of over 5 minutes starts a new order', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 1, ...duvel }],
[4, { type: 'line_added', quantity: 1, ...cola }],
[20, { type: 'line_added', quantity: 1, ...beer }],
]);
const orders = tabOrders(tabs.get('t1')!.lines);
expect(orders.map((order) => [order.orderId, order.startedAt, order.items.map((item) => item.productName)])).toEqual([
[null, at(0).toISOString(), ['Duvel', 'Cola']],
[null, at(20).toISOString(), ['Beer']],
]);
});
it('takes a drink off the order it is removed from, and leaves the others', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
[40, { type: 'line_removed', quantity: 1, orderId: 'o1', ...duvel }],
]);
expect(tabOrders(tabs.get('t1')!.lines).map((order) => [order.orderId, order.items[0].quantity, order.items[0].removed]))
.toEqual([['o1', 1, 1], ['o2', 1, 0]]);
// The open tab only shows what is still on it
expect(tabSummary({ ...tabs.get('t1')!, updatedAt: at(40) }).orders.map((order) => order.items))
.toEqual([[{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }], [{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }]]);
});
it('still takes the latest drinks off for a removal without an order (older app versions)', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 1, orderId: 'o1', ...duvel }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
]);
const removal = event({ type: 'line_removed', quantity: 5, ...duvel });
expect(tabs.removable(removal)).toBe(2);
expect(tabs.removable({ ...removal, orderId: 'o1' })).toBe(1);
expect(tabs.removable({ ...removal, orderId: null })).toBe(0);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { isActive, Line, Unit } from './tab-state';
/** Drinks rung up without an order (older app versions) that follow each other this closely form one order. */
export const ORDER_GAP_MS = 5 * 60 * 1000;
export type OrderLine = { productName: string; quantity: number; unitPriceInCents: number; removed: number };
/** Drinks ordered together, as the terminal shows them under one "12:32 today" divider. */
export type TabOrder = {
// Null for drinks from older app versions, grouped by time instead
orderId: string | null;
// When its first drink was rung up
startedAt: string;
items: OrderLine[];
};
/**
* Splits a tab's (or settlement's) lines into its orders, oldest first. Within an order every line shows how many of
* its drinks are still on it and how many were taken off again.
*/
export function tabOrders(lines: Line[]): TabOrder[] {
const drinks = lines
.flatMap((line) => line.units.map((unit) => ({ line, unit })))
.sort((a, b) => a.unit.addedAt.localeCompare(b.unit.addedAt) || a.unit.id.localeCompare(b.unit.id));
const orders: { orderId: string | null; startedAt: string; units: Map<Line, Unit[]> }[] = [];
const byId = new Map<string, (typeof orders)[number]>();
let unordered: { order: (typeof orders)[number]; lastAt: number } | null = null;
for (const { line, unit } of drinks) {
let order: (typeof orders)[number];
if (unit.orderId) {
order = byId.get(unit.orderId) ?? { orderId: unit.orderId, startedAt: unit.addedAt, units: new Map() };
if (!byId.has(unit.orderId)) {
byId.set(unit.orderId, order);
orders.push(order);
}
} else {
const at = Date.parse(unit.addedAt);
if (!unordered || at - unordered.lastAt > ORDER_GAP_MS) {
unordered = { order: { orderId: null, startedAt: unit.addedAt, units: new Map() }, lastAt: at };
orders.push(unordered.order);
}
unordered.lastAt = at;
order = unordered.order;
}
order.units.set(line, [...(order.units.get(line) ?? []), unit]);
}
return orders
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
.map(({ orderId, startedAt, units }) => ({
orderId,
startedAt,
items: [...units].map(([line, lineUnits]) => ({
productName: line.productName,
quantity: lineUnits.filter(isActive).length,
unitPriceInCents: line.unitPriceInCents,
removed: lineUnits.filter((unit) => !isActive(unit)).length,
})),
}));
}
+25 -6
View File
@@ -1,5 +1,6 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { OpenTabDto } from '../devices/dto/sync.dto';
import { tabOrders } from './tab-orders';
/**
* The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and
@@ -7,8 +8,11 @@ import { OpenTabDto } from '../devices/dto/sync.dto';
* derived the same way on both sides (`<event id>:<index>`).
*/
/** One piece ever put on a line. */
export type Unit = { id: string; addedAt: string; removedAt: string | null };
/**
* One piece ever put on a line. [orderId] is the order it was rung up in on the terminal: drinks ordered together are
* shown together. Missing for drinks from older app versions.
*/
export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId?: string | null };
export type Line = {
id: string;
@@ -46,6 +50,16 @@ export const isActive = (unit: Unit) => unit.removedAt === null;
export const lineQuantity = (line: Line) => line.units.filter(isActive).length;
/**
* The drinks of [line] a line_removed event may take off: those of its order (null: drinks without one), or any when
* the event has no order (older app versions).
*/
function removableUnits(line: Line, event: { orderId?: string | null }): Unit[] {
const active = line.units.filter(isActive);
if (event.orderId === undefined) return active;
return active.filter((unit) => (unit.orderId ?? null) === event.orderId);
}
export class VenueTabs {
private readonly tabs: Map<string, TabState>;
/** Tabs to write back, and tabs to delete. */
@@ -110,8 +124,9 @@ export class VenueTabs {
tab.lines.push(line);
}
const addedAt = occurredAt.toISOString();
const orderId = event.orderId ?? null;
for (let index = 0; index < event.quantity!; index++) {
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null });
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null, orderId });
}
this.touch(tabId);
return null;
@@ -121,8 +136,8 @@ export class VenueTabs {
const tab = this.tabs.get(tabId);
const line = tab && findLine(tab, event);
if (!line) return null;
// The latest ones: the drinks that were just rung up by mistake
const active = line.units.filter(isActive);
// The latest ones of the order: the drinks that were just rung up by mistake
const active = removableUnits(line, event);
for (const unit of active.slice(Math.max(0, active.length - event.quantity!))) {
unit.removedAt = occurredAt.toISOString();
}
@@ -178,7 +193,7 @@ export class VenueTabs {
removable(event: TabEventDto): number {
const tab = event.tabId ? this.tabs.get(event.tabId) : undefined;
const line = tab && findLine(tab, event);
return line ? Math.min(event.quantity ?? 0, lineQuantity(line)) : 0;
return line ? Math.min(event.quantity ?? 0, removableUnits(line, event).length) : 0;
}
/** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */
@@ -311,6 +326,10 @@ export function tabSummary(tab: { id: string; customerName: string; version: num
totalInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0),
itemCount: lines.reduce((sum, line) => sum + line.quantity, 0),
items: lines,
// Only what is still on the tab: drinks taken off again are left out
orders: tabOrders(tab.lines as Line[])
.map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) }))
.filter((order) => order.items.length > 0),
updatedAt: tab.updatedAt,
};
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey } from '../planned-tabs/customer-name';
import { SettledLine, tabSummary } from './tab-state';
import { tabOrders } from './tab-orders';
import { VenueLedger } from './venue-ledger';
@Injectable()
@@ -72,7 +73,8 @@ export class TabsService {
return {
settlements: rows.map((row) => {
const items = (row.lines as unknown as SettledLine[]).map((line) => ({
const lines = row.lines as unknown as SettledLine[];
const items = lines.map((line) => ({
productName: line.productName,
quantity: line.quantity,
unitPriceInCents: line.unitPriceInCents,
@@ -88,6 +90,7 @@ export class TabsService {
amountInCents: row.amountInCents,
itemCount: items.reduce((count, item) => count + item.quantity, 0),
items,
orders: tabOrders(lines),
settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null,
onDashboard: row.deviceId === null,
};
+1
View File
@@ -11,6 +11,7 @@ type Row = Record<string, any>;
const COMPOUND_KEYS: Record<string, string[]> = {
venueId_id: ['venueId', 'id'],
venueId_productId: ['venueId', 'productId'],
venueId_customerNameKey: ['venueId', 'customerNameKey'],
};
function matches(row: Row, where: Row = {}): boolean {
@@ -0,0 +1,10 @@
import { ArrayMaxSize, IsArray, IsEmail } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdateCashPaymentEmailsDto {
@ApiProperty({ example: ['owner@example.com'], description: 'Who gets an email for every tab paid in cash' })
@IsArray()
@ArrayMaxSize(20)
@IsEmail({}, { each: true })
emails: string[];
}
+11 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Param, UseGuards, Post, Body, Delete, Req } from '@nestjs/common';
import { Controller, Get, Param, UseGuards, Post, Put, Body, Delete, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { VenuesService } from './venues.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -8,6 +8,7 @@ import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateCashPaymentEmailsDto } from './dto/update-cash-payment-emails.dto';
@ApiTags('Venues')
@Controller('venues')
@@ -32,6 +33,15 @@ export class VenuesController {
return this.venuesService.findOne(venueId);
}
@Put(':venueId/cash-payment-emails')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Roles('admin', 'manager')
@ApiOperation({ summary: 'Set who gets an email for every tab paid in cash' })
async updateCashPaymentEmails(@Param('venueId') venueId: string, @Body() dto: UpdateCashPaymentEmailsDto) {
return this.venuesService.updateCashPaymentEmails(venueId, dto.emails);
}
@Get(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
+12 -1
View File
@@ -16,4 +16,15 @@ export class VenuesService {
}
return venue;
}
}
/** Who gets an email for every tab paid in cash. Each address once, whatever its case. */
async updateCashPaymentEmails(venueId: string, emails: string[]) {
const unique = [...new Set(emails.map((email) => email.trim().toLowerCase()).filter(Boolean))];
const venue = await this.prisma.venue.update({
where: { id: venueId },
data: { cashPaymentEmails: unique },
select: { cashPaymentEmails: true },
});
return { emails: venue.cashPaymentEmails };
}
}