feat: add realtime

This commit is contained in:
2026-10-08 04:11:44 +02:00
parent e5580767a5
commit b336580040
31 changed files with 756 additions and 131 deletions
+5 -1
View File
@@ -9,7 +9,7 @@ import {
HttpStatus,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
@@ -37,7 +37,10 @@ export class DevicesController {
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
}
// Authenticated by the device token; the till must never be rate-limited out by other
// traffic from the venue's shared IP
@Post(':deviceId/sync')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
@@ -46,6 +49,7 @@ export class DevicesController {
}
@Post(':deviceId/commands/:commandId/ack')
@SkipThrottle()
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' })
@@ -0,0 +1,103 @@
import { DevicesService } from './devices.service';
type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
};
const prisma = {
openTabSnapshot: { findMany: jest.fn().mockResolvedValue(previous) },
tabOwnership: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: jest.fn((fn: (t: typeof tx) => unknown) => fn(tx)),
remoteCommand: { updateMany: jest.fn().mockResolvedValue({ count: expired }), findMany: jest.fn().mockResolvedValue([]) },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Els' }]) },
};
const audit = { log: jest.fn() };
const realtime = { notify: jest.fn() };
const service = new DevicesService(prisma as any, audit as any, realtime as any);
return { service, prisma, tx, realtime };
}
const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
describe('DevicesService.sync planned customers', () => {
it('marks planned customers delivered once the device reports a tab with their name', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
data: { deliveredAt: expect.any(Date) },
});
});
it('only sends customers that have not been delivered yet', async () => {
const { service, prisma } = createService();
const result = await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(prisma.plannedTab.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { venueId: 'v1', deliveredAt: null } }),
);
expect(result.plannedTabNames).toEqual(['Els']);
});
it('does not mark anything delivered when the device reports no tabs', async () => {
const { service, tx } = createService();
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(tx.plannedTab.updateMany).not.toHaveBeenCalled();
});
});
describe('DevicesService.sync live update signals', () => {
const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 500, itemCount: 1 };
it('sends nothing when the sync changed nothing', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).not.toHaveBeenCalled();
});
it('signals "tabs" when a tab changed', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, version: 3, totalInCents: 900, itemCount: 2 }] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
});
it('signals "tabs" when a tab disappeared', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync(device, { deviceId: 'd1', openTabs: [] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs');
});
it('signals delivered customers and expired commands', async () => {
const { service, realtime } = createService({ previous: [janSnapshot], delivered: 1, expired: 2 });
await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'planned-tabs', 'commands');
});
it('signals "device" when a terminal comes back online', async () => {
const { service, realtime } = createService({ previous: [janSnapshot] });
await service.sync({ ...device, lastSeenAt: new Date(Date.now() - 10 * 60 * 1000) }, { deviceId: 'd1', openTabs: [jan] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
});
});
+46 -3
View File
@@ -7,6 +7,8 @@ import {
import { v4 as uuidv4 } from 'uuid';
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 { AckCommandDto } from './dto/ack-command.dto';
@@ -15,6 +17,7 @@ export class DevicesService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
) {}
async sync(device: any, dto: SyncDto) {
@@ -29,6 +32,21 @@ export class DevicesService {
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
// Devices sync often, so dashboards are only told about what actually changed
const previous = await this.prisma.openTabSnapshot.findMany({
where: { deviceId: device.id },
select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
});
const previousById = new Map(previous.map((s) => [s.tabId, s]));
const tabsChanged =
previous.length !== openTabs.length ||
openTabs.some((t) => {
const p = previousById.get(t.id);
return !p || p.customerName !== t.customerName || p.version !== t.version
|| p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
});
let plannedDelivered = 0;
// Ownership changes are detected up front so audit logging stays outside the transaction
const takenOver = await this.prisma.tabOwnership.findMany({
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
@@ -90,6 +108,19 @@ export class DevicesService {
});
}
// A planned customer counts as delivered once the device reports a tab with that exact name
// (the terminal matches names exactly when creating tabs)
if (openTabs.length > 0) {
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
where: {
venueId: device.venueId,
deliveredAt: null,
customerName: { in: openTabs.map((t) => t.customerName) },
},
data: { deliveredAt: now },
}));
}
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
@@ -97,7 +128,7 @@ export class DevicesService {
}, { timeout: 15000 });
// Expire old commands
await this.prisma.remoteCommand.updateMany({
const expired = await this.prisma.remoteCommand.updateMany({
where: {
deviceId: device.id,
status: 'pending',
@@ -131,13 +162,21 @@ export class DevicesService {
orderBy: { createdAt: 'asc' },
});
// Fetch planned tab names for this venue
// Only customers the terminal has not opened a tab for yet
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId: device.venueId },
where: { venueId: device.venueId, deliveredAt: null },
orderBy: { position: 'asc' },
select: { customerName: true },
});
const topics: RealtimeTopic[] = [];
if (tabsChanged) topics.push('tabs');
if (plannedDelivered > 0) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(device.venueId, ...topics);
return {
cursor,
commands: commands.map((c) => ({
@@ -198,6 +237,8 @@ export class DevicesService {
{ commandId, status: dto.status, result: dto.result },
);
this.realtime.notify(device.venueId, 'commands', 'tabs');
return { ok: true };
}
@@ -229,6 +270,8 @@ export class DevicesService {
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
this.realtime.notify(venueId, 'device', 'commands');
return { ok: true };
}
}