feat: reminders, Excel customer import and security hardening

Features
- Payment reminders via Resend (candidates, send, history, test email)
- Bulk import of the customer list from Excel/CSV in the dashboard
- Dashboard i18n (en/nl) with language selector

Security
- Enforce venue isolation on all /venues/:venueId routes
- Require admin for planned-tab changes and pairing codes
- Rate-limit login and device registration; opt-in TRUST_PROXY
- Require JWT_SECRET in production; stop leaking internal errors
- Escape customer names in reminder emails

Sync and commands
- Remove tabs the terminal no longer reports; validate nested sync payload
- Return all pending commands on every sync (fixes missed-command race)
- First command ack wins; atomic registration code claim
- Reject commands for revoked devices, stale versions or duplicates
- Remind only unpaid balances, with a 7-day cooldown

Dashboard
- Share a single token refresh across concurrent 401s (random logouts)
- Keep the session alive for the refresh token lifetime

Deployment
- Replace `db push --accept-data-loss` with migrations (baselines legacy DBs)
- Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
This commit is contained in:
2026-10-07 23:18:37 +02:00
parent 58eb8ddb8e
commit 8b1e38cb4d
56 changed files with 3168 additions and 376 deletions
+11 -4
View File
@@ -9,7 +9,8 @@ import {
HttpStatus,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -29,7 +30,9 @@ export class DevicesController {
) {}
@Post('register')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({ summary: 'Register a new device' })
@ApiResponse({ status: 201, description: 'Device registered successfully' })
async register(@Body() dto: RegisterDeviceDto) {
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
}
@@ -37,13 +40,15 @@ export class DevicesController {
@Post(':deviceId/sync')
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
return this.devicesService.sync(req.user, dto);
}
@Post(':deviceId/commands/:commandId/ack')
@UseGuards(DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command' })
@ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' })
async ackCommand(
@Param('deviceId') deviceId: string,
@Param('commandId') commandId: string,
@@ -54,9 +59,11 @@ export class DevicesController {
}
@Get('generation-code')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Generate a registration code' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a registration code for pairing' })
@ApiResponse({ status: 200, description: 'Registration code generated' })
async generateCode(@Req() req: any) {
const venueId = req.user.venueId;
return this.authService.generateRegistrationCode(venueId);
+81 -87
View File
@@ -26,68 +26,75 @@ export class DevicesService {
}
const now = new Date();
const openTabs = dto.openTabs || [];
const openTabIds = openTabs.map((t) => t.id);
// Upsert snapshots and ownership
for (const tab of dto.openTabs || []) {
await this.prisma.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
},
create: {
deviceId: device.id,
tabId: tab.id,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
update: {
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
});
// Check if tab was owned by another device
const existingOwnership = await this.prisma.tabOwnership.findUnique({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
});
if (existingOwnership && existingOwnership.deviceId !== device.id) {
if (existingOwnership.lastReportedAt < now) {
await this.auditService.log(
device.venueId,
'device',
device.id,
'snapshot_conflict',
{ tabId: tab.id, previousDevice: existingOwnership.deviceId, newDevice: device.id },
);
}
}
await this.prisma.tabOwnership.upsert({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
create: {
tabId: tab.id,
venueId: device.venueId,
deviceId: device.id,
lastReportedAt: now,
},
update: {
deviceId: device.id,
lastReportedAt: now,
},
});
// 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 } },
});
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
'device',
device.id,
'snapshot_conflict',
{ tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id },
);
}
// Update lastSeenAt
await this.prisma.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
});
await this.prisma.$transaction(async (tx) => {
// The device reports its full set of open tabs: anything it no longer reports has been closed
await tx.tabOwnership.deleteMany({
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
await tx.openTabSnapshot.deleteMany({
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
for (const tab of openTabs) {
await tx.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
},
create: {
deviceId: device.id,
tabId: tab.id,
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
update: {
customerName: tab.customerName,
version: tab.version,
totalInCents: tab.totalInCents,
itemCount: tab.itemCount,
reportedAt: now,
},
});
await tx.tabOwnership.upsert({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
create: {
tabId: tab.id,
venueId: device.venueId,
deviceId: device.id,
lastReportedAt: now,
},
update: {
deviceId: device.id,
lastReportedAt: now,
},
});
}
await tx.device.update({
where: { id: device.id },
data: { lastSeenAt: now },
});
}, { timeout: 15000 });
// Expire old commands
await this.prisma.remoteCommand.updateMany({
@@ -99,33 +106,15 @@ export class DevicesService {
data: { status: 'expired' },
});
// Build cursor from timestamp
const cursorTimestamp = now.toISOString();
const tieBreaker = uuidv4().substring(0, 8);
const cursor = Buffer.from(`${cursorTimestamp}:${tieBreaker}`).toString('base64url');
// Build the Prisma where clause
const commandWhere: any = {
// The cursor is kept for client compatibility but no longer filters commands: every pending
// command is returned on each sync, so a command committed while a sync was in flight can never
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
const commandWhere = {
deviceId: device.id,
status: 'pending',
};
// Parse cursor from request if provided — skip filter if cursor is missing or invalid
if (dto.cursor) {
try {
const decoded = Buffer.from(dto.cursor, 'base64url').toString('utf-8');
const parts = decoded.split(':');
if (parts.length >= 1) {
const parsed = new Date(parts[0]);
if (!isNaN(parsed.getTime())) {
commandWhere.createdAt = { gt: parsed };
}
}
} catch {
// Invalid cursor — ignore filter, return all pending commands
}
}
// Find pending commands for this device
const commands = await this.prisma.remoteCommand.findMany({
where: commandWhere,
@@ -182,13 +171,13 @@ export class DevicesService {
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
}
// Idempotent: if already acknowledged with same status, just return ok
if (command.status === dto.status && command.acknowledgedAt) {
return { ok: true };
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
if (command.acknowledgedAt) {
return { ok: true, status: command.status };
}
await this.prisma.remoteCommand.update({
where: { id: commandId },
const updated = await this.prisma.remoteCommand.updateMany({
where: { id: commandId, acknowledgedAt: null },
data: {
status: dto.status,
result: dto.result || null,
@@ -196,6 +185,11 @@ export class DevicesService {
},
});
if (updated.count === 0) {
// A concurrent ack got there first
return { ok: true };
}
await this.auditService.log(
device.venueId,
'device',
+6 -1
View File
@@ -1,9 +1,11 @@
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID } from 'class-validator';
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
class OpenTabDto {
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ example: 'Jan' })
@@ -38,5 +40,8 @@ export class SyncDto {
@ApiProperty({ type: [OpenTabDto] })
@IsArray()
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => OpenTabDto)
openTabs: OpenTabDto[];
}