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
+28
View File
@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
@@ -54,6 +55,33 @@ export class CommandsService {
});
}
if (device.revokedAt) {
throw new ConflictException({
code: 'device_revoked',
message: 'The device owning this tab has been revoked',
});
}
const snapshot = await tx.openTabSnapshot.findUnique({
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
});
if (snapshot && snapshot.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const alreadyPending = await tx.remoteCommand.findFirst({
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
});
if (alreadyPending) {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
}
const now = new Date();
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);