222 lines
6.2 KiB
TypeScript
222 lines
6.2 KiB
TypeScript
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 };
|
|
}
|
|
} |