import { Injectable, NotFoundException, ConflictException, ForbiddenException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { SyncDto } from './dto/sync.dto'; import { AckCommandDto } from './dto/ack-command.dto'; @Injectable() export class DevicesService { constructor( private prisma: PrismaService, private auditService: AuditService, ) {} async sync(device: any, dto: SyncDto) { if (device.revokedAt) { throw new ForbiddenException({ code: 'device_revoked', message: 'Device has been revoked' }); } const now = new Date(); // 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, }, }); } // Update lastSeenAt await this.prisma.device.update({ where: { id: device.id }, data: { lastSeenAt: now }, }); // Expire old commands await this.prisma.remoteCommand.updateMany({ where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now }, }, 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'); // Parse cursor from request if provided let cursorCutoff = new Date(0); if (dto.cursor) { try { const decoded = Buffer.from(dto.cursor, 'base64url').toString('utf-8'); const parts = decoded.split(':'); if (parts.length >= 1) { cursorCutoff = new Date(parts[0]); } } catch { // Invalid cursor, return all pending commands } } // Find pending commands for this device const commands = await this.prisma.remoteCommand.findMany({ where: { deviceId: device.id, status: 'pending', createdAt: { gt: cursorCutoff }, }, select: { id: true, type: true, targetTabId: true, expectedVersion: true, paymentMethod: true, requestedBy: true, createdAt: true, status: true, }, orderBy: { createdAt: 'asc' }, }); return { cursor, commands: commands.map((c) => ({ id: c.id, type: c.type, tabId: c.targetTabId, expectedVersion: c.expectedVersion, paymentMethod: c.paymentMethod, requestedBy: c.requestedBy, createdAt: c.createdAt, })), }; } async ackCommand(device: any, commandId: string, dto: AckCommandDto) { const command = await this.prisma.remoteCommand.findUnique({ where: { id: commandId } }); if (!command) { throw new NotFoundException({ code: 'command_not_found', message: 'Command not found' }); } if (command.deviceId !== device.id) { throw new ForbiddenException({ code: 'command_not_for_device', message: 'Command does not belong to this device' }); } if (command.venueId !== device.venueId) { 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 }; } await this.prisma.remoteCommand.update({ where: { id: commandId }, data: { status: dto.status, result: dto.result || null, acknowledgedAt: new Date(), }, }); await this.auditService.log( device.venueId, 'device', device.id, 'command_acknowledged', { commandId, status: dto.status, result: dto.result }, ); return { ok: true }; } async revoke(venueId: string, deviceId: string) { const device = await this.prisma.device.findUnique({ where: { id: deviceId } }); if (!device) { throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' }); } if (device.venueId !== venueId) { throw new ForbiddenException({ code: 'venue_mismatch', message: 'Device belongs to a different venue' }); } if (device.revokedAt) { return { ok: true }; // Idempotent } await this.prisma.device.update({ where: { id: deviceId }, data: { revokedAt: new Date() }, }); // Expire pending commands await this.prisma.remoteCommand.updateMany({ where: { deviceId, status: 'pending' }, data: { status: 'expired' }, }); await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name }); return { ok: true }; } }