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:
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user