This commit is contained in:
2026-09-19 22:19:35 +02:00
commit 231b3e006a
124 changed files with 13816 additions and 0 deletions
+157
View File
@@ -0,0 +1,157 @@
import {
Injectable,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { CreateCommandDto } from './dto/create-command.dto';
import { COMMAND_EXPIRY_HOURS, DEVICE_OFFLINE_THRESHOLD_MS } from '@kooltab/shared';
@Injectable()
export class CommandsService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
) {}
async create(venueId: string, dto: CreateCommandDto, user: any) {
if (!dto.paymentMethod) {
throw new BadRequestException({
code: 'payment_method_required',
message: 'Payment method must be explicitly selected',
});
}
const validMethods = ['cash', 'payconiq', 'payment_pending'];
if (!validMethods.includes(dto.paymentMethod)) {
throw new BadRequestException({
code: 'invalid_payment_method',
message: `Payment method must be one of: ${validMethods.join(', ')}`,
});
}
// Use transaction for consistency
const result = await this.prisma.$transaction(async (tx) => {
// Resolve tab ownership
const ownership = await tx.tabOwnership.findUnique({
where: { venueId_tabId: { venueId, tabId: dto.tabId } },
});
if (!ownership) {
throw new NotFoundException({
code: 'tab_not_owned',
message: 'Tab is not associated with any device',
});
}
const device = await tx.device.findUnique({ where: { id: ownership.deviceId } });
if (!device) {
throw new NotFoundException({
code: 'device_not_found',
message: 'Owning device not found',
});
}
const now = new Date();
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
const command = await tx.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId: device.id,
targetTabId: dto.tabId,
type: dto.type || 'close_tab',
expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod,
requestedBy: user.name || user.email,
status: 'pending',
expiresAt,
},
});
await tx.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'command_created',
details: JSON.stringify({
commandId: command.id,
tabId: dto.tabId,
type: dto.type,
paymentMethod: dto.paymentMethod,
deviceId: device.id,
}),
},
});
// Check if device is stale
const lastSeenAgo = now.getTime() - new Date(device.lastSeenAt).getTime();
const warning = lastSeenAgo > DEVICE_OFFLINE_THRESHOLD_MS
? { warning: 'Device has not been seen recently' }
: {};
return {
id: command.id,
status: command.status,
...warning,
};
});
return result;
}
async findAll(
venueId: string,
options: { status?: string; limit: number; offset: number },
) {
const where: any = { venueId };
if (options.status) {
where.status = options.status;
}
const [commands, total] = await Promise.all([
this.prisma.remoteCommand.findMany({
where,
select: {
id: true,
targetTabId: true,
type: true,
status: true,
paymentMethod: true,
requestedBy: true,
expectedVersion: true,
createdAt: true,
acknowledgedAt: true,
result: true,
deviceId: true,
},
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
}),
this.prisma.remoteCommand.count({ where }),
]);
return {
commands: commands.map((c) => ({
id: c.id,
tabId: c.targetTabId,
type: c.type,
status: c.status,
paymentMethod: c.paymentMethod,
requestedBy: c.requestedBy,
expectedVersion: c.expectedVersion,
createdAt: c.createdAt,
processedAt: c.acknowledgedAt,
result: c.result,
deviceId: c.deviceId,
})),
total,
};
}
}