batman
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { DeviceAuthGuard } from '../auth/guards/device-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||
import { SyncDto } from './dto/sync.dto';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
|
||||
@ApiTags('Devices')
|
||||
@Controller('devices')
|
||||
export class DevicesController {
|
||||
constructor(
|
||||
private devicesService: DevicesService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new device' })
|
||||
async register(@Body() dto: RegisterDeviceDto) {
|
||||
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
||||
}
|
||||
|
||||
@Post(':deviceId/sync')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Sync device state with server' })
|
||||
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
|
||||
const device = req.device;
|
||||
return this.devicesService.sync(device, dto);
|
||||
}
|
||||
|
||||
@Post(':deviceId/commands/:commandId/ack')
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Acknowledge a command' })
|
||||
async ackCommand(
|
||||
@Param('deviceId') deviceId: string,
|
||||
@Param('commandId') commandId: string,
|
||||
@Body() dto: AckCommandDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
const device = req.device;
|
||||
return this.devicesService.ackCommand(device, commandId, dto);
|
||||
}
|
||||
|
||||
@Get('generation-code')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'Generate a registration code' })
|
||||
@ApiBearerAuth()
|
||||
async generateCode(@Req() req: any) {
|
||||
const venueId = req.user.venueId;
|
||||
return this.authService.generateRegistrationCode(venueId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
})
|
||||
export class DevicesModule {}
|
||||
@@ -0,0 +1,222 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsString, IsIn, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class AckCommandDto {
|
||||
@ApiProperty({ example: 'cmd-uuid' })
|
||||
@IsString()
|
||||
commandId: string;
|
||||
|
||||
@ApiProperty({ example: 'completed', enum: ['completed', 'failed', 'conflict', 'expired'] })
|
||||
@IsString()
|
||||
@IsIn(['completed', 'failed', 'conflict', 'expired'])
|
||||
status: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Tab closed successfully' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
result?: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsString, IsUUID, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDeviceDto {
|
||||
@ApiProperty({ example: 'uuid' })
|
||||
@IsUUID()
|
||||
venueId: string;
|
||||
|
||||
@ApiProperty({ example: 'Bar terminal 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 'ABC123' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
registrationCode: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
class OpenTabDto {
|
||||
@ApiProperty({ example: 'local-tab-uuid' })
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'Jan' })
|
||||
@IsString()
|
||||
customerName: string;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
version: number;
|
||||
|
||||
@ApiProperty({ example: 2450 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
totalInCents: number;
|
||||
|
||||
@ApiProperty({ example: 3 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export class SyncDto {
|
||||
@ApiProperty({ example: 'uuid' })
|
||||
@IsUUID()
|
||||
deviceId: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'cursor-value' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cursor?: string;
|
||||
|
||||
@ApiProperty({ type: [OpenTabDto] })
|
||||
@IsArray()
|
||||
openTabs: OpenTabDto[];
|
||||
}
|
||||
Reference in New Issue
Block a user