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:
2026-10-07 23:18:37 +02:00
parent 58eb8ddb8e
commit 8b1e38cb4d
56 changed files with 3168 additions and 376 deletions
+10
View File
@@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kooltab_backoffice.iml" filepath="$PROJECT_DIR$/.idea/kooltab_backoffice.iml" />
</modules>
</component>
</project>
Generated
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+2 -1
View File
@@ -37,8 +37,9 @@ COPY --from=builder /app/packages/shared/package.json ./packages/shared/
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
RUN pnpm install --prod
ENV NODE_ENV=production
EXPOSE 3001
CMD ["sh", "-c", "npx prisma db push --accept-data-loss && node dist/main.js"]
CMD ["sh", "-c", "node prisma/deploy.js && node dist/main.js"]
# ---- Production Dashboard image ----
FROM node:20-alpine AS dashboard
+55
View File
@@ -0,0 +1,55 @@
/**
* Applies database migrations on container start.
*
* Databases created before migrations were used (via `prisma db push`) have no
* `_prisma_migrations` table. Those are brought up to date with a non-destructive
* `db push` (which refuses to drop data) and then baselined, so every later deploy
* can use plain `prisma migrate deploy`.
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { PrismaClient } = require('@prisma/client');
function run(command) {
console.log(`[deploy] ${command}`);
execSync(command, { stdio: 'inherit' });
}
async function main() {
const prisma = new PrismaClient();
let hasMigrationsTable;
let hasTables;
try {
const [{ migrations, tables }] = await prisma.$queryRaw`
SELECT
to_regclass('public._prisma_migrations') IS NOT NULL AS migrations,
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
hasMigrationsTable = migrations;
hasTables = tables;
} finally {
await prisma.$disconnect();
}
if (!hasMigrationsTable && hasTables) {
console.log('[deploy] Existing database without migration history, baselining');
run('npx prisma db push --skip-generate');
const migrationsDir = path.join(__dirname, 'migrations');
const migrations = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const name of migrations) {
run(`npx prisma migrate resolve --applied ${name}`);
}
}
run('npx prisma migrate deploy');
}
main().catch((error) => {
console.error('[deploy] Migration failed:', error);
process.exit(1);
});
@@ -0,0 +1,25 @@
-- AlterTable
ALTER TABLE "PlannedTab" ADD COLUMN "email" TEXT;
-- CreateTable
CREATE TABLE "TabReminder" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"amountInCents" INTEGER NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL DEFAULT 'sent',
CONSTRAINT "TabReminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TabReminder_venueId_sentAt_idx" ON "TabReminder"("venueId", "sentAt");
-- CreateIndex
CREATE INDEX "TabReminder_venueId_tabId_idx" ON "TabReminder"("venueId", "tabId");
-- AddForeignKey
ALTER TABLE "TabReminder" ADD CONSTRAINT "TabReminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+18
View File
@@ -20,6 +20,7 @@ model Venue {
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
}
model Device {
@@ -148,6 +149,7 @@ model PlannedTab {
id String @id @default(uuid())
venueId String
customerName String
email String?
position Int @default(0)
createdAt DateTime @default(now())
@@ -156,3 +158,19 @@ model PlannedTab {
@@unique([venueId, customerName])
@@index([venueId])
}
model TabReminder {
id String @id @default(uuid())
venueId String
tabId String
customerName String
email String
amountInCents Int
sentAt DateTime @default(now())
status String @default("sent")
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, sentAt])
@@index([venueId, tabId])
}
+2
View File
@@ -11,6 +11,7 @@ import { CommandsModule } from './commands/commands.module';
import { AuditModule } from './audit/audit.module';
import { JobsModule } from './jobs/jobs.module';
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
import { RemindersModule } from './reminders/reminders.module';
import { HealthController } from './health.controller';
@Module({
@@ -26,6 +27,7 @@ import { HealthController } from './health.controller';
AuditModule,
JobsModule,
PlannedTabsModule,
RemindersModule,
],
controllers: [HealthController],
providers: [
+8 -5
View File
@@ -1,7 +1,9 @@
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
@ApiTags('Authentication')
@Controller('auth')
@@ -9,6 +11,7 @@ export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
async login(@Body() dto: LoginDto) {
@@ -18,15 +21,15 @@ export class AuthController {
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
async refresh(@Body('refreshToken') refreshToken: string) {
return this.authService.refresh(refreshToken);
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout and revoke refresh token' })
async logout(@Body('refreshToken') refreshToken: string) {
await this.authService.logout(refreshToken);
async logout(@Body() dto: RefreshTokenDto) {
await this.authService.logout(dto.refreshToken);
return { ok: true };
}
}
+2 -1
View File
@@ -5,12 +5,13 @@ import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { DeviceStrategy } from './strategies/device.strategy';
import { getJwtSecret } from './jwt-secret';
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
secret: getJwtSecret(),
signOptions: { expiresIn: '24h' },
}),
],
+27 -14
View File
@@ -129,18 +129,35 @@ export class AuthService {
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
const deviceId = uuidv4();
// One device per venue — revoke any existing device
const existing = await this.prisma.device.findFirst({ where: { venueId, revokedAt: null } });
if (existing) {
await this.prisma.device.update({
where: { id: existing.id },
data: { revokedAt: new Date() },
// Claim the code atomically so it can only ever be used once
const claimed = await this.prisma.registrationCode.updateMany({
where: { id: code.id, usedAt: null },
data: { usedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
let deviceId: string;
// One device per venue — update existing or create new
const existing = await this.prisma.device.findFirst({ where: { venueId } });
if (existing) {
deviceId = existing.id;
await this.prisma.device.update({
where: { id: existing.id },
data: {
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
revokedAt: null,
lastSeenAt: new Date(),
},
});
} else {
deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
@@ -151,11 +168,7 @@ export class AuthService {
tokenExpiresAt: expiresAt,
},
});
await this.prisma.registrationCode.update({
where: { id: code.id },
data: { usedAt: new Date() },
});
}
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
@@ -0,0 +1,8 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ example: 'refresh-token-value' })
@IsString()
refreshToken: string;
}
+20
View File
@@ -0,0 +1,20 @@
import { Logger } from '@nestjs/common';
const DEV_FALLBACK_SECRET = 'dev-jwt-secret-change-in-production';
let warned = false;
export function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (secret) {
return secret;
}
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET must be set in production');
}
if (!warned) {
new Logger('Auth').warn('JWT_SECRET is not set — using an insecure development secret');
warned = true;
}
return DEV_FALLBACK_SECRET;
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { getJwtSecret } from '../jwt-secret';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
@@ -8,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
secretOrKey: getJwtSecret(),
});
}
+7 -5
View File
@@ -10,17 +10,18 @@ import {
HttpStatus,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { CommandsService } from './commands.service';
import { DevicesService } from '../devices/devices.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { CreateCommandDto } from './dto/create-command.dto';
@ApiTags('Commands')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/commands')
export class CommandsController {
constructor(
@@ -51,15 +52,16 @@ export class CommandsController {
) {
return this.commandsService.findAll(venueId, {
status,
limit: limit ? parseInt(limit, 10) : 50,
offset: offset ? parseInt(offset, 10) : 0,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
@Post('devices/:deviceId/revoke')
@UseGuards(RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Revoke a device' })
@ApiOperation({ summary: 'Revoke a device (admin only)' })
@ApiResponse({ status: 200, description: 'Device revoked' })
async revoke(
@Param('venueId') venueId: string,
@Param('deviceId') deviceId: string,
+28
View File
@@ -2,6 +2,7 @@ import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
@@ -54,6 +55,33 @@ export class CommandsService {
});
}
if (device.revokedAt) {
throw new ConflictException({
code: 'device_revoked',
message: 'The device owning this tab has been revoked',
});
}
const snapshot = await tx.openTabSnapshot.findUnique({
where: { deviceId_tabId: { deviceId: device.id, tabId: dto.tabId } },
});
if (snapshot && snapshot.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const alreadyPending = await tx.remoteCommand.findFirst({
where: { venueId, targetTabId: dto.tabId, status: 'pending' },
});
if (alreadyPending) {
throw new ConflictException({
code: 'command_already_pending',
message: 'A command for this tab is already pending',
});
}
const now = new Date();
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
@@ -1,8 +1,10 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
@@ -31,8 +33,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
code = resp.code || this.statusToCode(status);
}
}
} else if (exception instanceof Error) {
message = exception.message;
} else {
// Never leak internal error details (e.g. database errors) to clients
const request = ctx.getRequest();
this.logger.error(
`Unhandled error on ${request?.method} ${request?.url}`,
exception instanceof Error ? exception.stack : String(exception),
);
}
response.status(status).json({
@@ -0,0 +1,28 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
function contextFor(params: Record<string, string>, user?: { venueId: string }): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
} as unknown as ExecutionContext;
}
describe('VenueAccessGuard', () => {
const guard = new VenueAccessGuard();
it('allows routes without a venueId param', () => {
expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true);
});
it('allows access to the user\'s own venue', () => {
expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true);
});
it('rejects access to another venue', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
});
it('rejects when no user is attached', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
});
});
@@ -0,0 +1,23 @@
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
/**
* Ensures the authenticated user can only access the venue they belong to.
* Must run after JwtAuthGuard so that `request.user` is populated.
*/
@Injectable()
export class VenueAccessGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const venueId = request.params?.venueId;
if (!venueId) {
return true;
}
if (!request.user || request.user.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
return true;
}
}
+11 -4
View File
@@ -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);
+45 -51
View File
@@ -26,10 +26,34 @@ 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({
// 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 },
);
}
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 },
},
@@ -51,24 +75,7 @@ export class DevicesService {
},
});
// 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({
await tx.tabOwnership.upsert({
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
create: {
tabId: tab.id,
@@ -83,11 +90,11 @@ export class DevicesService {
});
}
// Update lastSeenAt
await this.prisma.device.update({
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',
+6 -1
View File
@@ -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[];
}
+10 -4
View File
@@ -1,24 +1,30 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { SkipThrottle } from '@nestjs/throttler';
import { Response } from 'express';
import { PrismaService } from './prisma/prisma.service';
@ApiTags('Health')
@SkipThrottle()
@Controller('health')
export class HealthController {
constructor(private prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: 'Health check' })
async check() {
@ApiResponse({ status: 200, description: 'Server is healthy' })
@ApiResponse({ status: 503, description: 'Database is unreachable' })
async check(@Res({ passthrough: true }) res: Response) {
let dbStatus = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
} catch {
dbStatus = 'error';
res.status(HttpStatus.SERVICE_UNAVAILABLE);
}
return {
status: 'ok',
status: dbStatus === 'ok' ? 'ok' : 'degraded',
database: dbStatus,
timestamp: new Date().toISOString(),
};
+11 -1
View File
@@ -2,15 +2,25 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import helmet from 'helmet';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Bulk customer imports can exceed the 100kb default
app.useBodyParser('json', { limit: '1mb' });
app.setGlobalPrefix('api/v1');
// Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy.
if (process.env.TRUST_PROXY) {
const value = process.env.TRUST_PROXY;
app.set('trust proxy', /^\d+$/.test(value) ? Number(value) : value === 'true' ? true : value);
}
app.use(helmet());
app.enableCors({
@@ -0,0 +1,17 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreatePlannedTabDto {
@ApiProperty({ example: 'Jan' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
@@ -0,0 +1,33 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength, IsArray, ArrayMaxSize, ValidateNested, IsIn } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ImportPlannedTabEntryDto {
@ApiProperty({ example: 'Jan Peeters' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
export class ImportPlannedTabsDto {
@ApiProperty({ type: [ImportPlannedTabEntryDto] })
@IsArray()
@ArrayMaxSize(5000)
@ValidateNested({ each: true })
@Type(() => ImportPlannedTabEntryDto)
entries: ImportPlannedTabEntryDto[];
@ApiProperty({
enum: ['merge', 'replace'],
description: 'merge adds new names and updates emails; replace also removes names that are not in the import',
})
@IsIn(['merge', 'replace'])
mode: 'merge' | 'replace';
}
@@ -0,0 +1,17 @@
import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpdatePlannedTabDto {
@ApiProperty({ example: 'Jan' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
customerName: string;
@ApiPropertyOptional({ example: 'jan@example.com' })
@IsOptional()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value))
@IsEmail()
email?: string;
}
@@ -1,11 +1,17 @@
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { PlannedTabsService } from './planned-tabs.service';
import { CreatePlannedTabDto } from './dto/create-planned-tab.dto';
import { UpdatePlannedTabDto } from './dto/update-planned-tab.dto';
import { ImportPlannedTabsDto } from './dto/import-planned-tabs.dto';
@ApiTags('Planned Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Controller('venues/:venueId/planned-tabs')
export class PlannedTabsController {
constructor(private service: PlannedTabsService) {}
@@ -17,18 +23,29 @@ export class PlannedTabsController {
}
@Post()
@Roles('admin')
@ApiOperation({ summary: 'Add a planned tab name' })
async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) {
return this.service.create(venueId, dto.customerName);
async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) {
return this.service.create(venueId, dto.customerName, dto.email);
}
@Post('import')
@Roles('admin')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' })
async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) {
return this.service.import(venueId, dto.entries, dto.mode);
}
@Put(':id')
@Roles('admin')
@ApiOperation({ summary: 'Rename a planned tab' })
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: { customerName: string }) {
return this.service.update(venueId, id, dto.customerName);
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) {
return this.service.update(venueId, id, dto.customerName, dto.email);
}
@Delete(':id')
@Roles('admin')
@ApiOperation({ summary: 'Remove a planned tab' })
async remove(@Param('venueId') venueId: string, @Param('id') id: string) {
return this.service.remove(venueId, id);
@@ -0,0 +1,77 @@
import { PlannedTabsService } from './planned-tabs.service';
type Row = { id: string; venueId: string; customerName: string; email: string | null; position: number };
function createService(initial: Omit<Row, 'venueId'>[]) {
let rows: Row[] = initial.map((r) => ({ ...r, venueId: 'v1' }));
const tx = {
plannedTab: {
findMany: jest.fn(async () => [...rows].sort((a, b) => a.position - b.position)),
create: jest.fn(async ({ data }: { data: Row }) => {
rows.push(data);
return data;
}),
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Row> }) => {
const row = rows.find((r) => r.id === where.id)!;
Object.assign(row, data);
return row;
}),
deleteMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => {
const before = rows.length;
rows = rows.filter((r) => !where.id.in.includes(r.id));
return { count: before - rows.length };
}),
},
};
const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) };
const service = new PlannedTabsService(prisma as any);
return { service, rows: () => rows };
}
describe('PlannedTabsService.import', () => {
it('adds new names, keeps existing spelling and only fills in emails', async () => {
const { service, rows } = createService([
{ id: 'a', customerName: 'Jan Peeters', email: null, position: 0 },
{ id: 'b', customerName: 'Els', email: 'els@example.com', position: 1 },
]);
const result = await service.import('v1', [
{ customerName: ' jan peeters ', email: 'jan@example.com' },
{ customerName: 'Els' },
{ customerName: 'Piet' },
], 'merge');
expect(result).toEqual({ created: 1, updated: 1, unchanged: 1, removed: 0 });
expect(rows().find((r) => r.id === 'a')).toMatchObject({ customerName: 'Jan Peeters', email: 'jan@example.com' });
expect(rows().find((r) => r.id === 'b')?.email).toBe('els@example.com');
expect(rows().find((r) => r.customerName === 'Piet')?.position).toBe(2);
});
it('collapses duplicate names within the import', async () => {
const { service, rows } = createService([]);
const result = await service.import('v1', [
{ customerName: 'Piet', email: 'piet@example.com' },
{ customerName: 'PIET' },
], 'merge');
expect(result.created).toBe(1);
expect(rows()).toHaveLength(1);
expect(rows()[0].email).toBe('piet@example.com');
});
it('removes names missing from the import only in replace mode', async () => {
const initial = [
{ id: 'a', customerName: 'Jan', email: null, position: 0 },
{ id: 'b', customerName: 'Els', email: null, position: 1 },
];
const merge = createService(initial);
expect((await merge.service.import('v1', [{ customerName: 'Jan' }], 'merge')).removed).toBe(0);
expect(merge.rows()).toHaveLength(2);
const replace = createService(initial);
expect((await replace.service.import('v1', [{ customerName: 'jan' }], 'replace')).removed).toBe(1);
expect(replace.rows().map((r) => r.customerName)).toEqual(['Jan']);
});
});
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { v4 as uuidv4 } from 'uuid';
@@ -10,14 +10,14 @@ export class PlannedTabsService {
const tabs = await this.prisma.plannedTab.findMany({
where: { venueId },
orderBy: { position: 'asc' },
select: { id: true, customerName: true, position: true, createdAt: true },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
return { plannedTabs: tabs };
}
async create(venueId: string, customerName: string) {
async create(venueId: string, customerName: string, email?: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
// Check for duplicate
const existing = await this.prisma.plannedTab.findUnique({
@@ -38,30 +38,90 @@ export class PlannedTabsService {
id: uuidv4(),
venueId,
customerName: name,
email: email?.trim() || null,
position: (last?.position ?? -1) + 1,
},
select: { id: true, customerName: true, position: true, createdAt: true },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
return tab;
}
async update(venueId: string, id: string, customerName: string) {
async update(venueId: string, id: string, customerName: string, email?: string) {
const name = customerName.trim();
if (!name) throw new NotFoundException({ code: 'name_required', message: 'Customer name is required' });
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
const duplicate = await this.prisma.plannedTab.findUnique({
where: { venueId_customerName: { venueId, customerName: name } },
});
if (duplicate && duplicate.id !== id) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
const result = await this.prisma.plannedTab.update({
where: { id },
data: { customerName: name },
select: { id: true, customerName: true, position: true, createdAt: true },
data: { customerName: name, email: email?.trim() || null },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
return result;
}
/**
* Bulk import of the customer list (e.g. from the client's Excel file).
* Names match case-insensitively and existing spelling is kept, because the terminal matches
* planned names to tabs exactly and a respelled name would open a second tab.
* A missing email in the import never clears an existing one.
*/
async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') {
const incoming = new Map<string, { customerName: string; email?: string }>();
for (const entry of entries) {
const customerName = entry.customerName.trim().replace(/\s+/g, ' ');
if (!customerName) continue;
const key = customerName.toLowerCase();
const previous = incoming.get(key);
incoming.set(key, { customerName, email: entry.email || previous?.email });
}
return this.prisma.$transaction(async (tx) => {
const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
const existingByKey = new Map(existing.map((t) => [t.customerName.toLowerCase(), t]));
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
let created = 0;
let updated = 0;
let unchanged = 0;
let removed = 0;
for (const [key, entry] of incoming) {
const current = existingByKey.get(key);
if (!current) {
await tx.plannedTab.create({
data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ },
});
created++;
} else if (entry.email && entry.email !== current.email) {
await tx.plannedTab.update({ where: { id: current.id }, data: { email: entry.email } });
updated++;
} else {
unchanged++;
}
}
if (mode === 'replace') {
const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id);
if (toRemove.length > 0) {
removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count;
}
}
return { created, updated, unchanged, removed };
}, { timeout: 30000 });
}
async remove(venueId: string, id: string) {
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
@@ -0,0 +1,51 @@
import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { RemindersService } from './reminders.service';
@ApiTags('Reminders')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/reminders')
export class RemindersController {
constructor(private service: RemindersService) {}
@Get('candidates')
@ApiOperation({ summary: 'List tabs eligible for payment reminders' })
@ApiResponse({ status: 200, description: 'List of eligible tabs with customer email and amount' })
async listCandidates(@Param('venueId') venueId: string) {
return this.service.listCandidates(venueId);
}
@Post('send')
@ApiOperation({ summary: 'Send payment reminders via Resend' })
@ApiResponse({ status: 201, description: 'Reminders sent' })
@ApiResponse({ status: 400, description: 'Resend not configured' })
async send(@Param('venueId') venueId: string, @Req() req: any) {
return this.service.sendReminders(venueId, req.user?.email || 'unknown');
}
@Get()
@ApiOperation({ summary: 'Get reminder history' })
@ApiResponse({ status: 200, description: 'List of previously sent reminders' })
async history(@Param('venueId') venueId: string) {
return this.service.history(venueId);
}
@Post('test/:plannedTabId')
@UseGuards(RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' })
@ApiResponse({ status: 201, description: 'Test email sent' })
@ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' })
@ApiResponse({ status: 404, description: 'Planned customer not found' })
async sendTest(
@Param('venueId') venueId: string,
@Param('plannedTabId') plannedTabId: string,
) {
return this.service.sendTestEmail(venueId, plannedTabId);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { RemindersController } from './reminders.controller';
import { RemindersService } from './reminders.service';
@Module({
controllers: [RemindersController],
providers: [RemindersService],
})
export class RemindersModule {}
+204
View File
@@ -0,0 +1,204 @@
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
// once this many days have passed since their last reminder.
const REMINDER_COOLDOWN_DAYS = 7;
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@Injectable()
export class RemindersService {
private resendApiKey: string;
private fromEmail: string;
constructor(private prisma: PrismaService) {
this.resendApiKey = process.env.RESEND_API_KEY || '';
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTab <onboarding@resend.dev>';
}
async listCandidates(venueId: string) {
const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId },
});
// Only the owning device's snapshot is authoritative for a tab
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const latestByTab = new Map(snapshots.map((s) => [s.tabId, s]));
const emailMap = new Map<string, string>();
try {
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId, email: { not: null } },
select: { customerName: true, email: true },
});
for (const pt of plannedTabs) {
if (pt.email) emailMap.set(pt.customerName.toLowerCase(), pt.email);
}
} catch {
// email column may not exist yet if migration hasn't been applied
}
let sentTabIds = new Set<string>();
try {
const alreadySent = await this.prisma.tabReminder.findMany({
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
select: { tabId: true },
});
sentTabIds = new Set(alreadySent.map((r) => r.tabId));
} catch {
// Table may not exist yet if migration hasn't been applied
}
const candidates = [];
for (const snap of latestByTab.values()) {
if (snap.totalInCents <= 0) continue;
if (sentTabIds.has(snap.tabId)) continue;
const email = emailMap.get(snap.customerName.toLowerCase());
if (!email) continue;
candidates.push({
tabId: snap.tabId,
customerName: snap.customerName,
email,
amountInCents: snap.totalInCents,
itemCount: snap.itemCount,
});
}
return { candidates };
}
async sendReminders(venueId: string, requestorEmail: string) {
const { candidates } = await this.listCandidates(venueId);
if (candidates.length === 0) {
return { sent: 0, message: 'No candidates to remind' };
}
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
let sent = 0;
const errors: { customerName: string; error: string }[] = [];
for (const candidate of candidates) {
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
},
body: JSON.stringify({
from: this.fromEmail,
to: candidate.email,
subject: `Payment reminder for your tab at the bar`,
html: `<p>Hi ${escapeHtml(candidate.customerName)},</p>
<p>This is a friendly reminder that you have an open tab of <strong>&euro;${(candidate.amountInCents / 100).toFixed(2)}</strong> (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).</p>
<p>Please settle your tab at the bar at your earliest convenience.</p>
<p>Thank you!</p>`,
}),
});
if (!res.ok) {
const errBody = await res.text();
errors.push({ customerName: candidate.customerName, error: errBody });
continue;
}
await this.prisma.tabReminder.create({
data: {
venueId,
tabId: candidate.tabId,
customerName: candidate.customerName,
email: candidate.email,
amountInCents: candidate.amountInCents,
},
});
sent++;
} catch (err: any) {
errors.push({ customerName: candidate.customerName, error: err.message });
}
}
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
}
async history(venueId: string) {
try {
const reminders = await this.prisma.tabReminder.findMany({
where: { venueId },
orderBy: { sentAt: 'desc' },
take: 100,
});
return { reminders };
} catch {
return { reminders: [] };
}
}
async sendTestEmail(venueId: string, plannedTabId: string) {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
const tab = await this.prisma.plannedTab.findFirst({
where: { id: plannedTabId, venueId },
});
if (!tab) {
throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' });
}
if (!tab.email) {
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
}
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
},
body: JSON.stringify({
from: this.fromEmail,
to: tab.email,
subject: `Test email from KoolTab`,
html: `<p>Hi ${escapeHtml(tab.customerName)},</p>
<p>This is a test email from KoolTab to verify that email reminders are working correctly.</p>
<p>If you received this, the email configuration is working!</p>
<hr>
<p style="color: #888; font-size: 12px;">This is a test — no action needed.</p>`,
}),
});
if (!res.ok) {
const errBody = await res.text();
throw new BadRequestException({
code: 'email_failed',
message: `Failed to send test email: ${errBody}`,
});
}
return { ok: true, email: tab.email, customerName: tab.customerName };
}
}
+2 -1
View File
@@ -2,10 +2,11 @@ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { TabsService } from './tabs.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
@ApiTags('Tabs')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/tabs')
export class TabsController {
constructor(private tabsService: TabsService) {}
+8 -4
View File
@@ -15,12 +15,16 @@ export class TabsService {
},
});
const snapshots = await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const snapshotByKey = new Map(snapshots.map((s) => [`${s.deviceId}:${s.tabId}`, s]));
const tabs = [];
for (const ownership of ownerships) {
const snapshot = await this.prisma.openTabSnapshot.findFirst({
where: { tabId: ownership.tabId, deviceId: ownership.deviceId },
orderBy: { reportedAt: 'desc' },
});
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
if (snapshot) {
tabs.push({
@@ -0,0 +1,23 @@
import { IsString, IsEmail, IsIn, MinLength, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@MinLength(6)
password: string;
@ApiProperty({ example: 'John Doe' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ example: 'viewer', enum: ['admin', 'viewer'] })
@IsString()
@IsIn(['admin', 'viewer'])
role: string;
}
+7 -5
View File
@@ -4,8 +4,10 @@ import { VenuesService } from './venues.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { AuthService } from '../auth/auth.service';
import { PrismaService } from '../prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
@ApiTags('Venues')
@Controller('venues')
@@ -24,7 +26,7 @@ export class VenuesController {
@Get(':venueId')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@ApiOperation({ summary: 'Get venue by ID' })
async findOne(@Param('venueId') venueId: string) {
return this.venuesService.findOne(venueId);
@@ -32,7 +34,7 @@ export class VenuesController {
@Get(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'List users for a venue' })
async findUsers(@Param('venueId') venueId: string) {
@@ -46,19 +48,19 @@ export class VenuesController {
@Post(':venueId/users')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Create a new user for the venue' })
async createUser(
@Param('venueId') venueId: string,
@Body() dto: { email: string; password: string; name: string; role: string },
@Body() dto: CreateUserDto,
) {
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
}
@Get(':venueId/devices')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@ApiOperation({ summary: 'Get venue device info' })
async findDevices(@Param('venueId') venueId: string) {
const device = await this.prisma.device.findFirst({
+1
View File
@@ -32,6 +32,7 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.0",
"tailwind-merge": "^2.2.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.22.4"
},
"devDependencies": {
+14 -8
View File
@@ -12,18 +12,22 @@ import {
LogOut,
Menu,
X,
Bell,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector';
import { authApi, api } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
const navItems = [
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard, neededRole: 'viewer' },
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList, neededRole: 'viewer' },
{ href: '/dashboard/users', label: 'Users', icon: Monitor, neededRole: 'admin' },
{ href: '/dashboard/commands', label: 'Command History', icon: Terminal, neededRole: 'viewer' },
{ href: '/dashboard/settings', label: 'Settings', icon: Settings, neededRole: 'viewer' },
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
];
export default function DashboardLayout({
@@ -36,6 +40,7 @@ export default function DashboardLayout({
const [sidebarOpen, setSidebarOpen] = useState(false);
const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
const [venue, setVenue] = useState<{ name: string } | null>(null);
const { t } = useLang();
useEffect(() => {
// Check auth
@@ -111,7 +116,7 @@ export default function DashboardLayout({
const Icon = item.icon;
const active = pathname === item.href;
if (item.neededRole === 'admin' && user?.role !== 'admin') {
if (item.adminOnly && user?.role !== 'admin') {
return null;
}
@@ -128,13 +133,14 @@ export default function DashboardLayout({
)}
>
<Icon className="h-4 w-4" />
{item.label}
{t(item.key)}
</Link>
);
})}
</nav>
<div className="p-4 border-t">
<LanguageSelector />
{user && (
<div className="mb-3 text-sm">
<p className="font-medium truncate">{user.name}</p>
@@ -147,7 +153,7 @@ export default function DashboardLayout({
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
Logout
{t('nav.logout')}
</Button>
</div>
</div>
@@ -0,0 +1,216 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { remindersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
import { Send, History } from 'lucide-react';
export default function RemindersPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { t } = useLang();
const [venueId, setVenueId] = useState<string | null>(null);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = useState(false);
useEffect(() => {
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
if (!cookie) { router.push('/login'); return; }
const payload = JSON.parse(atob(cookie.split('.')[1]));
setVenueId(payload.venueId);
}, [router]);
const candidatesQuery = useQuery({
queryKey: ['reminder-candidates', venueId],
queryFn: () => remindersApi.candidates(venueId!),
enabled: !!venueId,
});
const historyQuery = useQuery({
queryKey: ['reminder-history', venueId],
queryFn: () => remindersApi.history(venueId!),
enabled: !!venueId && showHistory,
});
const sendMutation = useMutation({
mutationFn: () => remindersApi.send(venueId!),
onSuccess: (result: any) => {
setSendDialogOpen(false);
if (result.errors && result.errors.length > 0) {
const firstError = result.errors[0];
toast({ title: `${result.sent}/${result.total} sent`, description: `Error: ${firstError.error}`, variant: 'destructive' });
} else if (result.sent > 0) {
toast({ title: t('reminders.sent_title'), description: t('reminders.success', { sent: result.sent }), variant: 'success' });
} else {
toast({ title: t('reminders.sent_title'), description: t('reminders.zero_sent'), variant: 'destructive' });
}
queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] });
queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
},
onError: (err: any) => {
toast({ title: 'Failed', description: err.message || 'An error occurred', variant: 'destructive' });
},
});
if (!venueId) return <LoadingState />;
const candidates = candidatesQuery.data?.candidates || [];
const reminders = historyQuery.data?.reminders || [];
return (
<div>
<PageHeader
title={t('reminders.title')}
description={t('reminders.desc')}
actions={
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setShowHistory(!showHistory); if (!showHistory) queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); }}>
<History className="h-4 w-4 mr-2" />
{showHistory ? t('reminders.candidates') : t('reminders.history')}
</Button>
</div>
}
/>
{showHistory ? (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">{t('reminders.history')}</CardTitle>
</CardHeader>
<CardContent>
{reminders.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('reminders.history_empty')}</p>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('reminders.customer')}</TableHead>
<TableHead>{t('reminders.email')}</TableHead>
<TableHead>{t('reminders.amount')}</TableHead>
<TableHead>{t('reminders.sent_at')}</TableHead>
<TableHead>{t('reminders.status')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{reminders.map((r: any) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.customerName}</TableCell>
<TableCell>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</TableCell>
<TableCell>{formatRelativeTime(r.sentAt)}</TableCell>
<TableCell><Badge variant="success">{r.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
) : (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Send className="h-4 w-4" />
{t('reminders.candidates')}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4">
{t('reminders.candidates_desc')}
</p>
{candidatesQuery.isLoading ? (
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
) : candidatesQuery.error ? (
<p className="text-sm text-destructive">{t('common.error')}</p>
) : candidates.length === 0 ? (
<EmptyState title={t('reminders.no_candidates')} description={t('reminders.no_candidates_desc')} />
) : (
<>
<div className="rounded-md border mb-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('reminders.customer')}</TableHead>
<TableHead>{t('reminders.email')}</TableHead>
<TableHead>{t('reminders.amount')}</TableHead>
<TableHead>{t('tabs.items')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{candidates.map((c: any) => (
<TableRow key={c.tabId}>
<TableCell className="font-medium">{c.customerName}</TableCell>
<TableCell>{c.email}</TableCell>
<TableCell>{formatCents(c.amountInCents)}</TableCell>
<TableCell>{c.itemCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Button onClick={() => setSendDialogOpen(true)}>
<Send className="h-4 w-4 mr-2" />
{t('reminders.send')} ({candidates.length})
</Button>
</>
)}
</CardContent>
</Card>
)}
<Dialog open={sendDialogOpen} onOpenChange={setSendDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('reminders.send')}</DialogTitle>
<DialogDescription>
{t('reminders.result', { sent: 0, total: candidates.length })}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
This will send payment reminder emails to <strong>{candidates.length}</strong> customer(s) with open tabs.
</p>
<ul className="mt-3 space-y-1">
{candidates.map((c: any) => (
<li key={c.tabId} className="text-sm flex items-center gap-2">
<span className="font-medium">{c.customerName}</span>
<span className="text-muted-foreground">({c.email})</span>
<span className="ml-auto">{formatCents(c.amountInCents)}</span>
</li>
))}
</ul>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSendDialogOpen(false)} disabled={sendMutation.isPending}>
{t('common.cancel')}
</Button>
<Button onClick={() => sendMutation.mutate()} disabled={sendMutation.isPending}>
{sendMutation.isPending ? (
<>{t('reminders.sending')}</>
) : (
<><Send className="h-4 w-4 mr-2" />{t('reminders.send')}</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+58 -6
View File
@@ -3,7 +3,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { api, commandsApi, plannedTabsApi } from '@/lib/api/client';
import { api, commandsApi, plannedTabsApi, remindersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -17,7 +17,9 @@ import { LoadingState } from '@/components/loading-state';
import { ErrorState } from '@/components/error-state';
import { toast } from '@/components/ui/toast';
import { formatCents, formatRelativeTime } from '@/lib/utils';
import { RefreshCw, Plus, X, Users } from 'lucide-react';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload } from 'lucide-react';
import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n';
export default function TabsPage() {
const router = useRouter();
@@ -28,7 +30,10 @@ export default function TabsPage() {
const [creating, setCreating] = useState(false);
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
const [plannedName, setPlannedName] = useState('');
const [plannedEmail, setPlannedEmail] = useState('');
const [userRole, setUserRole] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const { t } = useLang();
useEffect(() => {
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
@@ -97,8 +102,9 @@ export default function TabsPage() {
async function addPlannedName() {
if (!plannedName.trim()) return;
try {
await plannedTabsApi.create(venueId!, plannedName.trim());
await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined);
setPlannedName('');
setPlannedEmail('');
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
} catch (err: any) {
@@ -115,6 +121,16 @@ export default function TabsPage() {
}
}
const sendTestMutation = useMutation({
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId),
onSuccess: (result) => {
toast({ title: 'Test email sent', description: `Sent to ${result.email}`, variant: 'success' });
},
onError: (err: any) => {
toast({ title: 'Test email failed', description: err.message || 'An error occurred', variant: 'destructive' });
},
});
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />;
@@ -132,14 +148,21 @@ export default function TabsPage() {
{/* Planned Customers */}
<Card className="mb-6">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
{userRole === 'admin' && (
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-2" />{t('import.button')}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-3">
These names are synced to the terminal. It will create tabs for these customers automatically.
</p>
{userRole === 'admin' && (
<div className="flex gap-2 mb-3">
<div className="flex gap-2 mb-3 flex-wrap">
<Input
placeholder="Add customer name..."
value={plannedName}
@@ -147,6 +170,14 @@ export default function TabsPage() {
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Input
placeholder="Email (optional)"
type="email"
value={plannedEmail}
onChange={(e) => setPlannedEmail(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
className="max-w-xs"
/>
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}>
<Plus className="h-4 w-4" />
</Button>
@@ -159,8 +190,19 @@ export default function TabsPage() {
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
{pt.customerName}
{pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
{userRole === 'admin' && pt.email && (
<button
onClick={() => sendTestMutation.mutate(pt.id)}
disabled={sendTestMutation.isPending}
className="text-muted-foreground hover:text-primary ml-0.5"
title="Send test email"
>
<Send className="h-3 w-3" />
</button>
)}
{userRole === 'admin' && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-1">
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5">
<X className="h-3 w-3" />
</button>
)}
@@ -194,7 +236,8 @@ export default function TabsPage() {
<TableCell>{tab.version}</TableCell>
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
<Button size="sm" onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
{/* Closing settles the tab on the terminal; a tab without a balance has nothing to settle */}
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => { setCloseDialog({ open: true, tab }); setPaymentMethod(''); }}>Close</Button>
</TableCell>
</TableRow>
))}
@@ -203,6 +246,15 @@ export default function TabsPage() {
</div>
)}
{venueId && (
<ImportCustomersDialog
venueId={venueId}
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
/>
)}
{closeDialog.tab && (
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
<DialogContent>
+2 -1
View File
@@ -2,6 +2,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { LanguageProvider } from '@/lib/i18n';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
@@ -18,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<LanguageProvider>{children}</LanguageProvider>
</QueryClientProvider>
);
}
@@ -0,0 +1,218 @@
'use client';
import { useMemo, useState } from 'react';
import { Upload } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast';
import { plannedTabsApi } from '@/lib/api/client';
import { useLang } from '@/lib/i18n';
import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import';
const PREVIEW_ROWS = 8;
const selectClassName =
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring';
interface Props {
venueId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
const { t } = useLang();
const [fileName, setFileName] = useState<string | null>(null);
const [sheet, setSheet] = useState<SheetData | null>(null);
const [mapping, setMapping] = useState<ColumnMapping | null>(null);
const [mode, setMode] = useState<'merge' | 'replace'>('merge');
const [submitting, setSubmitting] = useState(false);
const preview = useMemo(() => (sheet && mapping ? buildPreview(sheet, mapping) : null), [sheet, mapping]);
function reset() {
setFileName(null);
setSheet(null);
setMapping(null);
setMode('merge');
}
function handleOpenChange(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleFile(file: File | undefined) {
if (!file) return;
try {
const data = await readSheet(file);
if (data.rows.length === 0) {
toast({ title: t('import.failed'), description: t('import.empty_file'), variant: 'destructive' });
return;
}
setFileName(file.name);
setSheet(data);
setMapping(detectMapping(data));
} catch {
toast({ title: t('import.failed'), description: t('import.unreadable'), variant: 'destructive' });
}
}
async function submit() {
if (!preview || preview.entries.length === 0) return;
setSubmitting(true);
try {
const result = await plannedTabsApi.import(venueId, preview.entries, mode);
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
onImported();
handleOpenChange(false);
} catch (err: any) {
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' });
} finally {
setSubmitting(false);
}
}
const header = sheet && mapping?.hasHeader ? sheet.rows[0] : null;
const columnOptions = sheet
? Array.from({ length: sheet.columnCount }, (_, i) => ({
value: i,
label: header?.[i] ? `${columnLabel(i)} – ${header[i]}` : columnLabel(i),
}))
: [];
function columnSelect(value: number | null, onChange: (value: number | null) => void, optional: boolean) {
return (
<select
className={selectClassName}
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
>
{optional && <option value="">{t('import.column_none')}</option>}
{columnOptions.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('import.title')}</DialogTitle>
<DialogDescription>{t('import.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<label className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm cursor-pointer hover:bg-accent">
<Upload className="h-5 w-5 text-muted-foreground" />
<span>{fileName ?? t('import.choose_file')}</span>
<span className="text-xs text-muted-foreground">.xlsx, .xls, .csv</span>
<input
type="file"
accept=".xlsx,.xls,.csv"
className="sr-only"
onChange={(e) => {
handleFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</label>
{sheet && mapping && preview && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t('import.name_column')}</Label>
{columnSelect(mapping.nameColumn, (v) => setMapping({ ...mapping, nameColumn: v ?? 0 }), false)}
</div>
<div className="space-y-1">
<Label>{t('import.last_name_column')}</Label>
{columnSelect(mapping.lastNameColumn, (v) => setMapping({ ...mapping, lastNameColumn: v }), true)}
</div>
<div className="space-y-1">
<Label>{t('import.email_column')}</Label>
{columnSelect(mapping.emailColumn, (v) => setMapping({ ...mapping, emailColumn: v }), true)}
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={mapping.hasHeader}
onChange={(e) => setMapping({ ...mapping, hasHeader: e.target.checked })}
/>
{t('import.has_header')}
</label>
<div className="space-y-2">
<p className="text-sm">
{t('import.summary', {
count: preview.entries.length,
emails: preview.entries.filter((e) => e.email).length,
})}
</p>
{(preview.skippedRows > 0 || preview.invalidEmails > 0) && (
<p className="text-xs text-muted-foreground">
{t('import.warnings', { skipped: preview.skippedRows, invalid: preview.invalidEmails })}
</p>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('tabs.customer')}</TableHead>
<TableHead>{t('import.email')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{entry.customerName}</TableCell>
<TableCell className="text-muted-foreground">{entry.email ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{preview.entries.length > PREVIEW_ROWS && (
<p className="text-xs text-muted-foreground">
{t('import.more', { count: preview.entries.length - PREVIEW_ROWS })}
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-base font-semibold">{t('import.mode')}</Label>
<RadioGroup value={mode} onValueChange={(v) => setMode(v as 'merge' | 'replace')}>
<div className="flex items-start space-x-2">
<RadioGroupItem value="merge" id="import-merge" className="mt-0.5" />
<Label htmlFor="import-merge" className="font-normal">{t('import.mode_merge')}</Label>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem value="replace" id="import-replace" className="mt-0.5" />
<Label htmlFor="import-replace" className="font-normal">{t('import.mode_replace')}</Label>
</div>
</RadioGroup>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
{t('tabs.cancel')}
</Button>
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,27 @@
'use client';
import { useLang } from '@/lib/i18n';
import { Languages } from 'lucide-react';
export function LanguageSelector() {
const { lang, setLang, t } = useLang();
return (
<div className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground">
<Languages className="h-3 w-3" />
<span>{t('lang.label')}:</span>
<button
onClick={() => setLang('en')}
className={`px-1.5 py-0.5 rounded ${lang === 'en' ? 'bg-primary text-primary-foreground font-medium' : 'hover:text-foreground'}`}
>
EN
</button>
<button
onClick={() => setLang('nl')}
className={`px-1.5 py-0.5 rounded ${lang === 'nl' ? 'bg-primary text-primary-foreground font-medium' : 'hover:text-foreground'}`}
>
NL
</button>
</div>
);
}
+37 -6
View File
@@ -15,7 +15,9 @@ function getTokens() {
}
function setTokens(accessToken: string, refreshToken: string) {
document.cookie = `access_token=${accessToken}; path=/; max-age=${24 * 60 * 60}; SameSite=Lax`;
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an
// expired JWT triggers a 401 which is transparently refreshed below.
document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
}
@@ -24,7 +26,19 @@ function clearTokens() {
document.cookie = 'refresh_token=; path=/; max-age=0';
}
async function refreshAccessToken(): Promise<string | null> {
// Refresh tokens rotate on use, so concurrent 401s must share a single refresh request
let refreshInFlight: Promise<string | null> | null = null;
function refreshAccessToken(): Promise<string | null> {
if (!refreshInFlight) {
refreshInFlight = doRefreshAccessToken().finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
async function doRefreshAccessToken(): Promise<string | null> {
const refreshToken = document.cookie
.split('; ')
.find((row) => row.startsWith('refresh_token='))
@@ -169,12 +183,29 @@ export const venuesApi = {
// Planned Tabs API
export const plannedTabsApi = {
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
create: (venueId: string, customerName: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }),
update: (venueId: string, id: string, customerName: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }),
create: (venueId: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
update: (venueId: string, id: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }),
remove: (venueId: string, id: string) =>
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
import: (venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') =>
api<{ created: number; updated: number; unchanged: number; removed: number }>(`/venues/${venueId}/planned-tabs/import`, {
method: 'POST',
body: JSON.stringify({ entries, mode }),
}),
};
// Reminders API
export const remindersApi = {
candidates: (venueId: string) =>
api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
history: (venueId: string) =>
api<{ reminders: any[] }>(`/venues/${venueId}/reminders`),
sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
};
export { setTokens, clearTokens };
+122
View File
@@ -0,0 +1,122 @@
export interface SheetData {
rows: string[][];
columnCount: number;
}
export interface ColumnMapping {
hasHeader: boolean;
nameColumn: number;
lastNameColumn: number | null;
emailColumn: number | null;
}
export interface ImportEntry {
customerName: string;
email?: string;
}
export interface ImportPreview {
entries: ImportEntry[];
skippedRows: number;
invalidEmails: number;
}
const NAME_HEADERS = ['naam', 'name', 'volledige naam', 'full name', 'klant', 'klantnaam', 'customer', 'lid', 'member'];
const FIRST_NAME_HEADERS = ['voornaam', 'first name', 'firstname', 'given name'];
const LAST_NAME_HEADERS = ['achternaam', 'familienaam', 'last name', 'lastname', 'surname', 'family name'];
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function normalizeHeader(value: string) {
return value.toLowerCase().replace(/[_\-.:]/g, ' ').replace(/\s+/g, ' ').trim();
}
/** Reads the first sheet of an .xlsx, .xls or .csv file into trimmed string rows. */
export async function readSheet(file: File): Promise<SheetData> {
// Loaded on demand: the parser is large and only needed when an admin imports a file
const XLSX = await import('xlsx');
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const sheet = workbook.Sheets[workbook.SheetNames[0]];
if (!sheet) return { rows: [], columnCount: 0 };
const raw = XLSX.utils.sheet_to_json<unknown[]>(sheet, { header: 1, defval: '', raw: false, blankrows: false });
const rows = raw
.map((row) => row.map((cell) => String(cell ?? '').trim()))
.filter((row) => row.some((cell) => cell !== ''));
const columnCount = rows.reduce((max, row) => Math.max(max, row.length), 0);
return { rows, columnCount };
}
/** Guesses which columns hold the name(s) and email from the first row. */
export function detectMapping(sheet: SheetData): ColumnMapping {
const header = (sheet.rows[0] ?? []).map(normalizeHeader);
const find = (candidates: string[]) => {
const index = header.findIndex((h) => candidates.includes(h));
return index === -1 ? null : index;
};
const name = find(NAME_HEADERS);
const firstName = find(FIRST_NAME_HEADERS);
const lastName = find(LAST_NAME_HEADERS);
const emailHeaderIndex = header.findIndex((h) => h.includes('mail'));
const hasHeader = name !== null || firstName !== null || lastName !== null || emailHeaderIndex !== -1;
// Without a recognisable header, fall back to the column whose values look like email addresses
let email: number | null = emailHeaderIndex === -1 ? null : emailHeaderIndex;
if (email === null) {
const sample = sheet.rows.slice(hasHeader ? 1 : 0, 50);
for (let col = 0; col < sheet.columnCount && email === null; col++) {
const filled = sample.map((row) => row[col]).filter(Boolean);
if (filled.length > 0 && filled.filter((v) => EMAIL_PATTERN.test(v)).length / filled.length >= 0.5) {
email = col;
}
}
}
if (firstName !== null && lastName !== null) {
return { hasHeader, nameColumn: firstName, lastNameColumn: lastName, emailColumn: email };
}
return { hasHeader, nameColumn: name ?? firstName ?? lastName ?? 0, lastNameColumn: null, emailColumn: email };
}
export function buildPreview(sheet: SheetData, mapping: ColumnMapping): ImportPreview {
const dataRows = mapping.hasHeader ? sheet.rows.slice(1) : sheet.rows;
const entries: ImportEntry[] = [];
let skippedRows = 0;
let invalidEmails = 0;
for (const row of dataRows) {
const parts = [row[mapping.nameColumn]];
if (mapping.lastNameColumn !== null) parts.push(row[mapping.lastNameColumn]);
const customerName = parts.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim().slice(0, 100);
if (!customerName) {
skippedRows++;
continue;
}
let email: string | undefined;
if (mapping.emailColumn !== null) {
const value = (row[mapping.emailColumn] ?? '').trim();
if (value && EMAIL_PATTERN.test(value)) {
email = value;
} else if (value) {
invalidEmails++;
}
}
entries.push(email ? { customerName, email } : { customerName });
}
return { entries, skippedRows, invalidEmails };
}
/** Spreadsheet-style column label: 0 -> A, 26 -> AA. */
export function columnLabel(index: number): string {
let label = '';
let n = index;
do {
label = String.fromCharCode(65 + (n % 26)) + label;
n = Math.floor(n / 26) - 1;
} while (n >= 0);
return label;
}
+367
View File
@@ -0,0 +1,367 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Lang = 'en' | 'nl';
const translations: Record<Lang, Record<string, string>> = {
en: {
'nav.overview': 'Overview',
'nav.tabs': 'Open Tabs',
'nav.reminders': 'Reminders',
'nav.users': 'Users',
'nav.commands': 'Command History',
'nav.settings': 'Settings',
'nav.logout': 'Logout',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
'tabs.empty': 'No open tabs',
'tabs.empty_desc': 'Open tabs will appear here when the terminal syncs.',
'tabs.customer': 'Customer',
'tabs.amount': 'Amount',
'tabs.items': 'Items',
'tabs.version': 'Version',
'tabs.reported': 'Last Reported',
'tabs.close': 'Close',
'tabs.close_title': "Close {name}'s tab?",
'tabs.close_desc': 'Send a close request to the terminal.',
'tabs.payment': 'Payment method',
'tabs.cash': 'Cash',
'tabs.payconiq': 'Payconiq',
'tabs.pending_payment': 'Payment pending',
'tabs.select_payment': 'Please select a payment method',
'tabs.send': 'Send close request',
'tabs.sending': 'Sending...',
'tabs.cancel': 'Cancel',
'tabs.planned_title': 'Planned Customers',
'tabs.planned_desc': 'These names are synced to the terminal. It will create tabs for these customers automatically.',
'tabs.planned_add': 'Add customer name...',
'tabs.planned_empty': 'No planned customers. Add names above and they\'ll sync to the terminal.',
'import.button': 'Import from Excel',
'import.title': 'Import customers',
'import.description': 'Upload the customer list. Every name becomes a planned customer and gets a tab on the terminal.',
'import.choose_file': 'Choose a file',
'import.empty_file': 'The file contains no rows.',
'import.unreadable': 'This file could not be read. Use an .xlsx, .xls or .csv file.',
'import.name_column': 'Name (or first name)',
'import.last_name_column': 'Last name',
'import.email_column': 'Email',
'import.column_none': '— none —',
'import.has_header': 'First row contains column headers',
'import.summary': '{count} customers found, {emails} with an email address.',
'import.warnings': '{skipped} rows without a name skipped, {invalid} invalid email addresses ignored.',
'import.email': 'Email',
'import.more': 'and {count} more…',
'import.mode': 'Existing customers',
'import.mode_merge': 'Keep customers that are not in the file',
'import.mode_replace': 'Remove customers that are not in the file (their open tabs on the terminal are kept)',
'import.submit': 'Import {count} customers',
'import.importing': 'Importing...',
'import.done': 'Import complete',
'import.result': '{created} added, {updated} updated, {unchanged} unchanged, {removed} removed.',
'import.failed': 'Import failed',
'users.title': 'Users',
'users.count': '{count} user',
'users.count_plural': '{count} users',
'users.empty': 'No users',
'users.empty_desc': 'Add users who can manage tabs from this dashboard.',
'users.add': 'Add User',
'users.name': 'Name',
'users.email': 'Email',
'users.role': 'Role',
'users.created': 'Created',
'users.add_title': 'Add User',
'users.add_desc': 'Create a new user who can access the dashboard.',
'users.password': 'Password',
'users.creating': 'Creating...',
'users.create': 'Create User',
'commands.title': 'Command History',
'commands.count': '{count} command',
'commands.count_plural': '{count} commands',
'commands.empty': 'No commands found',
'commands.tab': 'Tab',
'commands.requested': 'Requested By',
'commands.payment': 'Payment',
'commands.created': 'Created',
'commands.status': 'Status',
'commands.processed': 'Processed',
'commands.result': 'Result',
'commands.all': 'All',
'commands.previous': 'Previous',
'commands.next': 'Next',
'commands.page': 'Page {page} of {total}',
'settings.title': 'Settings',
'settings.desc': 'Venue and device management',
'settings.venue': 'Venue',
'settings.device': 'Device',
'settings.paired': 'Paired as "{name}" — last seen {time}',
'settings.not_paired': 'No device paired yet',
'settings.connected': 'Connected',
'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Device',
'settings.repair': 'Re-pair Device',
'settings.pair_title': 'Pair a Device',
'settings.pair_instructions': 'Enter this code on the bar terminal to connect it to your venue.',
'settings.pair_code': 'Registration Code',
'settings.pair_expires': 'Expires at {time}',
'settings.pair_step1': 'Open the bar terminal app',
'settings.pair_step2': 'Select your venue from the list',
'settings.pair_step3': 'Enter this registration code',
'settings.pair_step4': 'Give the terminal a name',
'settings.pair_step5': 'Complete registration',
'settings.copy': 'Copy Code',
'settings.close': 'Close',
'settings.api': 'API',
'settings.base_url': 'Base URL',
'settings.swagger': 'Swagger',
'overview.title': 'Overview',
'overview.desc': 'Real-time overview of your venue',
'overview.open_tabs': 'Open Tabs',
'overview.device': 'Device',
'overview.pending': 'Pending Commands',
'overview.recent': 'Recent Commands',
'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected',
'overview.last_seen': 'Last seen {time}',
'common.refresh': 'Refresh',
'common.loading': 'Loading...',
'common.error': 'Something went wrong',
'common.confirm': 'Confirm',
'common.cancel': 'Cancel',
'common.processing': 'Processing...',
'common.copied': 'Copied',
'auth.login': 'Sign in',
'auth.logging_in': 'Signing in...',
'auth.email': 'Email',
'auth.password': 'Password',
'auth.invalid': 'Invalid email or password',
'lang.en': 'English',
'lang.nl': 'Nederlands',
'lang.label': 'Language',
'reminders.title': 'Payment Reminders',
'reminders.desc': 'Send payment reminders to customers with open tabs',
'reminders.candidates': 'Eligible for reminder',
'reminders.candidates_desc': 'These customers have open tabs and an email address on file. Reminders will be sent via Resend.',
'reminders.no_candidates': 'No eligible customers',
'reminders.no_candidates_desc': 'Make sure planned customers have email addresses set.',
'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...',
'reminders.result': 'Sent {sent} of {total} reminder(s)',
'reminders.sent_title': 'Reminders Sent',
'reminders.zero_sent': 'No reminders were sent. Check that Resend is configured.',
'reminders.success': 'Successfully sent {sent} reminder email(s)',
'reminders.history': 'Sent Reminders',
'reminders.history_empty': 'No reminders have been sent yet',
'reminders.customer': 'Customer',
'reminders.email': 'Email',
'reminders.amount': 'Amount',
'reminders.sent_at': 'Sent At',
'reminders.status': 'Status',
'tabs.email': 'Email (optional)',
'tabs.planned_email_hint': 'Add email to enable payment reminders',
},
nl: {
'nav.overview': 'Overzicht',
'nav.tabs': 'Open Tabs',
'nav.reminders': 'Herinneringen',
'nav.users': 'Gebruikers',
'nav.commands': 'Opdrachten',
'nav.settings': 'Instellingen',
'nav.logout': 'Uitloggen',
'tabs.title': 'Open Tabs',
'tabs.count': '{count} open tab',
'tabs.count_plural': '{count} open tabs',
'tabs.empty': 'Geen open tabs',
'tabs.empty_desc': 'Open tabs verschijnen hier wanneer de terminal synchroniseert.',
'tabs.customer': 'Klant',
'tabs.amount': 'Bedrag',
'tabs.items': 'Artikelen',
'tabs.version': 'Versie',
'tabs.reported': 'Laatst gemeld',
'tabs.close': 'Sluiten',
'tabs.close_title': 'Sluit {name}\'s tab?',
'tabs.close_desc': 'Stuur een sluitverzoek naar de terminal.',
'tabs.payment': 'Betaalmethode',
'tabs.cash': 'Contant',
'tabs.payconiq': 'Payconiq',
'tabs.pending_payment': 'Betaling in behandeling',
'tabs.select_payment': 'Selecteer een betaalmethode',
'tabs.send': 'Sluitverzoek versturen',
'tabs.sending': 'Bezig met verzenden...',
'tabs.cancel': 'Annuleren',
'tabs.planned_title': 'Geplande Klanten',
'tabs.planned_desc': 'Deze namen worden gesynchroniseerd met de terminal. Deze maakt automatisch tabs aan voor deze klanten.',
'tabs.planned_add': 'Klantnaam toevoegen...',
'tabs.planned_empty': 'Geen geplande klanten. Voeg namen toe en ze worden gesynchroniseerd met de terminal.',
'import.button': 'Importeren uit Excel',
'import.title': 'Klanten importeren',
'import.description': 'Upload de klantenlijst. Elke naam wordt een geplande klant en krijgt een rekening op de terminal.',
'import.choose_file': 'Kies een bestand',
'import.empty_file': 'Het bestand bevat geen rijen.',
'import.unreadable': 'Dit bestand kon niet gelezen worden. Gebruik een .xlsx-, .xls- of .csv-bestand.',
'import.name_column': 'Naam (of voornaam)',
'import.last_name_column': 'Achternaam',
'import.email_column': 'E-mail',
'import.column_none': '— geen —',
'import.has_header': 'De eerste rij bevat kolomnamen',
'import.summary': '{count} klanten gevonden, {emails} met een e-mailadres.',
'import.warnings': '{skipped} rijen zonder naam overgeslagen, {invalid} ongeldige e-mailadressen genegeerd.',
'import.email': 'E-mail',
'import.more': 'en nog {count}…',
'import.mode': 'Bestaande klanten',
'import.mode_merge': 'Klanten die niet in het bestand staan behouden',
'import.mode_replace': 'Klanten die niet in het bestand staan verwijderen (hun open rekeningen op de terminal blijven)',
'import.submit': '{count} klanten importeren',
'import.importing': 'Bezig met importeren...',
'import.done': 'Import voltooid',
'import.result': '{created} toegevoegd, {updated} bijgewerkt, {unchanged} ongewijzigd, {removed} verwijderd.',
'import.failed': 'Importeren mislukt',
'users.title': 'Gebruikers',
'users.count': '{count} gebruiker',
'users.count_plural': '{count} gebruikers',
'users.empty': 'Geen gebruikers',
'users.empty_desc': 'Voeg gebruikers toe die tabs kunnen beheren vanaf dit dashboard.',
'users.add': 'Gebruiker toevoegen',
'users.name': 'Naam',
'users.email': 'E-mail',
'users.role': 'Rol',
'users.created': 'Aangemaakt',
'users.add_title': 'Gebruiker toevoegen',
'users.add_desc': 'Maak een nieuwe gebruiker aan die toegang heeft tot het dashboard.',
'users.password': 'Wachtwoord',
'users.creating': 'Bezig...',
'users.create': 'Gebruiker aanmaken',
'commands.title': 'Opdrachtgeschiedenis',
'commands.count': '{count} opdracht',
'commands.count_plural': '{count} opdrachten',
'commands.empty': 'Geen opdrachten gevonden',
'commands.tab': 'Tab',
'commands.requested': 'Aangevraagd door',
'commands.payment': 'Betaling',
'commands.created': 'Aangemaakt',
'commands.status': 'Status',
'commands.processed': 'Verwerkt',
'commands.result': 'Resultaat',
'commands.all': 'Alle',
'commands.previous': 'Vorige',
'commands.next': 'Volgende',
'commands.page': 'Pagina {page} van {total}',
'settings.title': 'Instellingen',
'settings.desc': 'Locatie- en apparaatbeheer',
'settings.venue': 'Locatie',
'settings.device': 'Apparaat',
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
'settings.not_paired': 'Nog geen apparaat gekoppeld',
'settings.connected': 'Verbonden',
'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om tabs te ontvangen.',
'settings.pair': 'Apparaat koppelen',
'settings.repair': 'Opnieuw koppelen',
'settings.pair_title': 'Apparaat koppelen',
'settings.pair_instructions': 'Voer deze code in op de barterminal om deze te verbinden met uw locatie.',
'settings.pair_code': 'Registratiecode',
'settings.pair_expires': 'Verloopt om {time}',
'settings.pair_step1': 'Open de barterminal app',
'settings.pair_step2': 'Selecteer uw locatie uit de lijst',
'settings.pair_step3': 'Voer deze registratiecode in',
'settings.pair_step4': 'Geef de terminal een naam',
'settings.pair_step5': 'Voltooi de registratie',
'settings.copy': 'Code kopiëren',
'settings.close': 'Sluiten',
'settings.api': 'API',
'settings.base_url': 'Basis-URL',
'settings.swagger': 'Swagger',
'overview.title': 'Overzicht',
'overview.desc': 'Real-time overzicht van uw locatie',
'overview.open_tabs': 'Open Tabs',
'overview.device': 'Apparaat',
'overview.pending': 'Openstaande opdrachten',
'overview.recent': 'Recente opdrachten',
'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden',
'overview.last_seen': 'Laatst gezien {time}',
'common.refresh': 'Verversen',
'common.loading': 'Laden...',
'common.error': 'Er is iets misgegaan',
'common.confirm': 'Bevestigen',
'common.cancel': 'Annuleren',
'common.processing': 'Bezig...',
'common.copied': 'Gekopieerd',
'auth.login': 'Inloggen',
'auth.logging_in': 'Bezig met inloggen...',
'auth.email': 'E-mail',
'auth.password': 'Wachtwoord',
'auth.invalid': 'Ongeldig e-mailadres of wachtwoord',
'lang.en': 'English',
'lang.nl': 'Nederlands',
'lang.label': 'Taal',
'reminders.title': 'Betalingsherinneringen',
'reminders.desc': 'Stuur betalingsherinneringen naar klanten met openstaande tabs',
'reminders.candidates': 'In aanmerking voor herinnering',
'reminders.candidates_desc': 'Deze klanten hebben openstaande tabs en een e-mailadres. Herinneringen worden verzonden via Resend.',
'reminders.no_candidates': 'Geen geschikte klanten',
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
'reminders.send': 'Verstuur herinneringen',
'reminders.sending': 'Bezig met versturen...',
'reminders.result': '{sent} van {total} herinnering(en) verzonden',
'reminders.sent_title': 'Herinneringen verzonden',
'reminders.zero_sent': 'Geen herinneringen verzonden. Controleer of Resend is geconfigureerd.',
'reminders.success': '{sent} herinnering(en) succesvol verzonden',
'reminders.history': 'Verzonden herinneringen',
'reminders.history_empty': 'Nog geen herinneringen verzonden',
'reminders.customer': 'Klant',
'reminders.email': 'E-mail',
'reminders.amount': 'Bedrag',
'reminders.sent_at': 'Verzonden op',
'reminders.status': 'Status',
'tabs.email': 'E-mail (optioneel)',
'tabs.planned_email_hint': 'Voeg e-mail toe om betalingsherinneringen in te schakelen',
},
};
interface LangContextType {
lang: Lang;
setLang: (lang: Lang) => void;
t: (key: string, params?: Record<string, string | number>) => string;
}
const LangContext = createContext<LangContextType>({
lang: 'en',
setLang: () => {},
t: (key) => key,
});
export function LanguageProvider({ children }: { children: ReactNode }) {
const [lang, setLangState] = useState<Lang>('en');
useEffect(() => {
const stored = localStorage.getItem('kooltab-lang') as Lang | null;
if (stored === 'en' || stored === 'nl') setLangState(stored);
}, []);
function setLang(l: Lang) {
setLangState(l);
localStorage.setItem('kooltab-lang', l);
}
function t(key: string, params?: Record<string, string | number>): string {
let s = translations[lang][key];
if (!s) s = translations['en'][key] || key;
if (params) {
for (const [k, v] of Object.entries(params)) {
s = s.replace(`{${k}}`, String(v));
}
}
return s;
}
return (
<LangContext.Provider value={{ lang, setLang, t }}>
{children}
</LangContext.Provider>
);
}
export function useLang() {
return useContext(LangContext);
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -30,6 +30,10 @@ services:
JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET}"
PORT: 3001
CORS_ORIGIN: "${DASHBOARD_URL:-http://localhost:3000}"
RESEND_API_KEY: "${RESEND_API_KEY:-}"
RESEND_FROM_EMAIL: "${RESEND_FROM_EMAIL:-KoolTab <onboarding@resend.dev>}"
# The API sits behind a reverse proxy: trust one hop of X-Forwarded-For for per-client rate limiting
TRUST_PROXY: "${TRUST_PROXY:-1}"
depends_on:
postgres:
condition: service_healthy
+26 -2
View File
@@ -18,7 +18,7 @@ Swagger UI: `http://localhost:3001/api/docs`
## Endpoints
### Health
`GET /health` - Server health check
`GET /health` - Server health check (503 when the database is unreachable)
### Auth
`POST /auth/login` - Login with email/password
@@ -36,13 +36,37 @@ Swagger UI: `http://localhost:3001/api/docs`
`POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth)
`GET /devices/generation-code` - Generate registration code (admin)
Sync semantics:
- `openTabs` must be the device's **complete** list of open tabs. Tabs it no longer reports
(e.g. deleted on the terminal) are removed from the dashboard. Settling a tab on the terminal
keeps the tab open under the same name with a zero balance, so the name stays listed.
- Every sync returns **all** pending commands for the device; `cursor` is still returned but no longer
filters. Devices dedupe by command id and re-send the ack for commands they already processed.
- The first ack for a command wins; later acks are accepted as no-ops.
### Tabs
`GET /venues/:venueId/tabs` - List open tabs
### Planned Tabs (customer list)
`GET /venues/:venueId/planned-tabs` - List planned customers
`POST /venues/:venueId/planned-tabs` - Add a customer (admin)
`POST /venues/:venueId/planned-tabs/import` - Bulk import `{ entries: [{ customerName, email? }], mode: 'merge' | 'replace' }` (admin).
Names match case-insensitively and keep their existing spelling; `replace` also removes customers missing from the import.
`PUT /venues/:venueId/planned-tabs/:id` - Rename a customer / set email (admin)
`DELETE /venues/:venueId/planned-tabs/:id` - Remove a customer (admin)
### Reminders
`GET /venues/:venueId/reminders/candidates` - Customers with an unpaid balance and email, not reminded in the last 7 days
`POST /venues/:venueId/reminders/send` - Send payment reminders
`GET /venues/:venueId/reminders` - Reminder history
`POST /venues/:venueId/reminders/test/:plannedTabId` - Send a test email (admin)
### Commands
`POST /venues/:venueId/commands` - Create a command
`GET /venues/:venueId/commands` - List command history
`POST /venues/:venueId/devices/:deviceId/revoke` - Revoke device (admin)
`POST /venues/:venueId/commands/devices/:deviceId/revoke` - Revoke device (admin)
All `/venues/:venueId/...` routes return `403 venue_forbidden` unless the user belongs to that venue.
## Error Format
+534
View File
@@ -0,0 +1,534 @@
{
"name": "kooltab",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kooltab",
"devDependencies": {
"tsx": "^4.23.15"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/tsx": {
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
}
}
}
+3
View File
@@ -18,5 +18,8 @@
},
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"tsx": "^4.23.15"
}
}
+317 -20
View File
@@ -6,7 +6,11 @@ settings:
importers:
.: {}
.:
devDependencies:
tsx:
specifier: ^4.23.15
version: 4.23.15
apps/api:
dependencies:
@@ -82,7 +86,7 @@ importers:
devDependencies:
'@nestjs/cli':
specifier: ^10.3.0
version: 10.4.9(postcss@8.5.28)(uglify-js@3.19.3)
version: 10.4.9(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)
'@nestjs/schematics':
specifier: ^10.1.0
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
@@ -118,10 +122,10 @@ importers:
version: 6.3.4(supports-color@8.1.1)
ts-jest:
specifier: ^29.1.2
version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3)
version: 29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.28.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3)
ts-loader:
specifier: ^9.5.1
version: 9.6.2(typescript@5.9.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3))
version: 9.6.2(typescript@5.9.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3))
apps/dashboard:
dependencies:
@@ -188,6 +192,9 @@ importers:
tailwind-merge:
specifier: ^2.2.1
version: 2.6.1
xlsx:
specifier: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
version: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
zod:
specifier: ^3.22.4
version: 3.25.76
@@ -209,7 +216,7 @@ importers:
version: 8.5.28
tailwindcss:
specifier: ^3.4.1
version: 3.4.19
version: 3.4.19(tsx@4.23.15)
typescript:
specifier: ^5.4.0
version: 5.9.3
@@ -420,6 +427,162 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@esbuild/aix-ppc64@0.28.2':
resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.28.2':
resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.28.2':
resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.28.2':
resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.28.2':
resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.28.2':
resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.28.2':
resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.2':
resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.28.2':
resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.28.2':
resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.28.2':
resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.28.2':
resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.28.2':
resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.28.2':
resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.28.2':
resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.28.2':
resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.28.2':
resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.28.2':
resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.2':
resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.28.2':
resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.2':
resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.28.2':
resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.28.2':
resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.28.2':
resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.28.2':
resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.28.2':
resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@@ -1998,6 +2161,11 @@ packages:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
esbuild@0.28.2:
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@@ -3620,6 +3788,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.23.15:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
type-detect@4.0.8:
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
engines: {node: '>=4'}
@@ -3797,6 +3970,12 @@ packages:
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz:
resolution: {integrity: sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz}
version: 0.20.3
engines: {node: '>=0.8'}
hasBin: true
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -4064,6 +4243,84 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@esbuild/aix-ppc64@0.28.2':
optional: true
'@esbuild/android-arm64@0.28.2':
optional: true
'@esbuild/android-arm@0.28.2':
optional: true
'@esbuild/android-x64@0.28.2':
optional: true
'@esbuild/darwin-arm64@0.28.2':
optional: true
'@esbuild/darwin-x64@0.28.2':
optional: true
'@esbuild/freebsd-arm64@0.28.2':
optional: true
'@esbuild/freebsd-x64@0.28.2':
optional: true
'@esbuild/linux-arm64@0.28.2':
optional: true
'@esbuild/linux-arm@0.28.2':
optional: true
'@esbuild/linux-ia32@0.28.2':
optional: true
'@esbuild/linux-loong64@0.28.2':
optional: true
'@esbuild/linux-mips64el@0.28.2':
optional: true
'@esbuild/linux-ppc64@0.28.2':
optional: true
'@esbuild/linux-riscv64@0.28.2':
optional: true
'@esbuild/linux-s390x@0.28.2':
optional: true
'@esbuild/linux-x64@0.28.2':
optional: true
'@esbuild/netbsd-arm64@0.28.2':
optional: true
'@esbuild/netbsd-x64@0.28.2':
optional: true
'@esbuild/openbsd-arm64@0.28.2':
optional: true
'@esbuild/openbsd-x64@0.28.2':
optional: true
'@esbuild/openharmony-arm64@0.28.2':
optional: true
'@esbuild/sunos-x64@0.28.2':
optional: true
'@esbuild/win32-arm64@0.28.2':
optional: true
'@esbuild/win32-ia32@0.28.2':
optional: true
'@esbuild/win32-x64@0.28.2':
optional: true
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
@@ -4314,7 +4571,7 @@ snapshots:
'@microsoft/tsdoc@0.15.1': {}
'@nestjs/cli@10.4.9(postcss@8.5.28)(uglify-js@3.19.3)':
'@nestjs/cli@10.4.9(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)':
dependencies:
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
'@angular-devkit/schematics': 17.3.11(chokidar@3.6.0)
@@ -4324,7 +4581,7 @@ snapshots:
chokidar: 3.6.0
cli-table3: 0.6.5
commander: 4.1.1
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3))
fork-ts-checker-webpack-plugin: 9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3))
glob: 10.4.5
inquirer: 8.2.6
node-emoji: 1.11.0
@@ -4333,7 +4590,7 @@ snapshots:
tsconfig-paths: 4.2.0
tsconfig-paths-webpack-plugin: 4.2.0
typescript: 5.7.2
webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3)
webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)
webpack-node-externals: 3.0.0
transitivePeerDependencies:
- '@minify-html/node'
@@ -5825,6 +6082,35 @@ snapshots:
has-tostringtag: 1.0.2
hasown: 2.0.4
esbuild@0.28.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.2
'@esbuild/android-arm': 0.28.2
'@esbuild/android-arm64': 0.28.2
'@esbuild/android-x64': 0.28.2
'@esbuild/darwin-arm64': 0.28.2
'@esbuild/darwin-x64': 0.28.2
'@esbuild/freebsd-arm64': 0.28.2
'@esbuild/freebsd-x64': 0.28.2
'@esbuild/linux-arm': 0.28.2
'@esbuild/linux-arm64': 0.28.2
'@esbuild/linux-ia32': 0.28.2
'@esbuild/linux-loong64': 0.28.2
'@esbuild/linux-mips64el': 0.28.2
'@esbuild/linux-ppc64': 0.28.2
'@esbuild/linux-riscv64': 0.28.2
'@esbuild/linux-s390x': 0.28.2
'@esbuild/linux-x64': 0.28.2
'@esbuild/netbsd-arm64': 0.28.2
'@esbuild/netbsd-x64': 0.28.2
'@esbuild/openbsd-arm64': 0.28.2
'@esbuild/openbsd-x64': 0.28.2
'@esbuild/openharmony-arm64': 0.28.2
'@esbuild/sunos-x64': 0.28.2
'@esbuild/win32-arm64': 0.28.2
'@esbuild/win32-ia32': 0.28.2
'@esbuild/win32-x64': 0.28.2
escalade@3.2.0: {}
escape-html@1.0.3: {}
@@ -5985,7 +6271,7 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)):
fork-ts-checker-webpack-plugin@9.0.2(typescript@5.7.2)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)):
dependencies:
'@babel/code-frame': 7.29.7
chalk: 4.1.2
@@ -6000,7 +6286,7 @@ snapshots:
semver: 7.8.5
tapable: 2.3.3
typescript: 5.7.2
webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3)
webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)
form-data@4.0.6:
dependencies:
@@ -7071,12 +7357,13 @@ snapshots:
camelcase-css: 2.0.1
postcss: 8.5.28
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28):
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.28)(tsx@4.23.15):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.28
tsx: 4.23.15
postcss-nested@6.2.0(postcss@8.5.28):
dependencies:
@@ -7490,7 +7777,7 @@ snapshots:
tailwind-merge@2.6.1: {}
tailwindcss@3.4.19:
tailwindcss@3.4.19(tsx@4.23.15):
dependencies:
'@alloc/quick-lru': 5.3.0
arg: 5.0.2
@@ -7509,7 +7796,7 @@ snapshots:
postcss: 8.5.28
postcss-import: 15.1.0(postcss@8.5.28)
postcss-js: 4.1.0(postcss@8.5.28)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.28)(tsx@4.23.15)
postcss-nested: 6.2.0(postcss@8.5.28)
postcss-selector-parser: 6.1.4
resolve: 1.22.12
@@ -7529,14 +7816,15 @@ snapshots:
mkdirp: 1.0.4
yallist: 4.0.0
terser-webpack-plugin@5.6.1(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)):
terser-webpack-plugin@5.6.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.5.0
terser: 5.51.2
webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3)
webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)
optionalDependencies:
esbuild: 0.28.2
postcss: 8.5.28
uglify-js: 3.19.3
@@ -7592,7 +7880,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
ts-jest@29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3):
ts-jest@29.4.12(@babel/core@7.29.7(supports-color@8.1.1))(@jest/transform@29.7.0(supports-color@8.1.1))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(esbuild@0.28.2)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.43)(supports-color@8.1.1)(ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@@ -7610,15 +7898,16 @@ snapshots:
'@jest/transform': 29.7.0(supports-color@8.1.1)
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1)
esbuild: 0.28.2
jest-util: 29.7.0
ts-loader@9.6.2(typescript@5.9.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3)):
ts-loader@9.6.2(typescript@5.9.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)):
dependencies:
chalk: 4.1.2
picomatch: 4.0.7
source-map: 0.7.6
typescript: 5.9.3
webpack: 5.97.1(postcss@8.5.28)(uglify-js@3.19.3)
webpack: 5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)
ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3):
dependencies:
@@ -7653,6 +7942,12 @@ snapshots:
tslib@2.8.1: {}
tsx@4.23.15:
dependencies:
esbuild: 0.28.2
optionalDependencies:
fsevents: 2.3.3
type-detect@4.0.8: {}
type-fest@0.21.3: {}
@@ -7748,7 +8043,7 @@ snapshots:
webpack-sources@3.5.1: {}
webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3):
webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3):
dependencies:
'@types/eslint-scope': 3.7.7
'@types/estree': 1.0.9
@@ -7770,7 +8065,7 @@ snapshots:
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.3.3
terser-webpack-plugin: 5.6.1(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(postcss@8.5.28)(uglify-js@3.19.3))
terser-webpack-plugin: 5.6.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3)(webpack@5.97.1(esbuild@0.28.2)(postcss@8.5.28)(uglify-js@3.19.3))
watchpack: 2.5.2
webpack-sources: 3.5.1
transitivePeerDependencies:
@@ -7827,6 +8122,8 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 3.0.7
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
xtend@4.0.2: {}
y18n@5.0.8: {}
+1
View File
@@ -6,4 +6,5 @@ allowBuilds:
'@prisma/client': true
'@prisma/engines': true
bcrypt: true
esbuild: true
prisma: true
+254 -171
View File
@@ -1,206 +1,289 @@
/**
* KoolTab Device Simulator
* KoolTab Interactive Device Simulator
*
* An interactive CLI that simulates a Flutter bar terminal device.
* Keeps running until you press Q.
*
* A CLI tool that simulates a Flutter bar terminal device.
* Usage: npx ts-node scripts/device-sync.ts
*
* Steps:
* 1. Register a device (or paste an existing token)
* 2. Sync open tabs
* 3. Receive pending commands
* 4. Acknowledge commands
*/
import * as readline from 'readline';
const API_URL = process.env.API_URL || 'http://localhost:3001/api/v1';
const args = process.argv.slice(2);
const command = args[0] || 'help';
interface SyncResult {
cursor: string;
commands: Array<{
id: string;
type: string;
tabId: string;
expectedVersion: number;
paymentMethod: string;
requestedBy: string;
createdAt: string;
}>;
}
// ── State ────────────────────────────────────────────────
let deviceId = '';
let token = '';
let cursor: string | null = null;
let venueId = '';
let registrationCode = '';
const openTabs: Map<string, { customerName: string; version: number; totalInCents: number; itemCount: number }> = new Map();
let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = [];
let tabCounter = 0;
let plannedNames: string[] = [];
let plannedIndex = 0;
// ── API helpers ──────────────────────────────────────────
async function api<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
const { headers: customHeaders, ...rest } = options;
const res = await fetch(`${API_URL}${endpoint}`, {
headers: { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>) },
...options,
...rest,
headers: { 'Content-Type': 'application/json', ...(customHeaders as Record<string, string>) },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const msg = body?.error?.message || `HTTP ${res.status}`;
throw new Error(msg);
throw new Error(body?.error?.message || `HTTP ${res.status}`);
}
return res.json();
}
async function registerDevice() {
const venueId = args[1];
const name = args[2] || 'Simulated Terminal';
const code = args[3];
// ── Actions ──────────────────────────────────────────────
if (!venueId || !code) {
console.error('Usage: npx ts-node scripts/device-sync.ts register <venueId> <name> <registrationCode>');
process.exit(1);
// The server treats openTabs as the complete set: tabs left out are considered closed
function buildTabsPayload() {
return Array.from(openTabs.entries()).map(([id, t]) => ({
id, customerName: t.customerName, version: t.version, totalInCents: t.totalInCents, itemCount: t.itemCount,
}));
}
async function fetchPlannedCustomers() {
// Planned names come from the sync response — do a quick sync to refresh them
if (!deviceId || !token) {
console.log('\n ❌ Not registered yet.');
return;
}
try {
const result = await api<{ plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ deviceId, cursor, openTabs: buildTabsPayload() }),
});
if (result.plannedTabNames && result.plannedTabNames.length > 0) {
plannedNames = result.plannedTabNames;
plannedIndex = 0;
}
console.log(`\n 📋 Loaded ${plannedNames.length} planned customer(s) from server`);
if (plannedNames.length > 0) {
console.log(` ${plannedNames.join(', ')}`);
}
} catch {
console.log('\n ⚠️ Could not fetch planned customers (using fallback names)');
}
}
console.log(`Registering device "${name}" in venue ${venueId}...`);
async function registerDevice() {
const name = 'Simulated Terminal';
if (!registrationCode) {
console.log('\n ❌ No registration code set. Use P to pair first.');
return;
}
try {
const result = await api('/devices/register', {
method: 'POST',
body: JSON.stringify({ venueId, name, registrationCode: code }),
body: JSON.stringify({ venueId, name, registrationCode }),
});
console.log('Device registered!');
console.log(` Device ID: ${result.deviceId}`);
console.log(` Token: ${result.token}`);
console.log(` Expires: ${result.tokenExpiresAt}`);
console.log('\nSave this token for future sync operations.');
return result;
}
async function syncDevice() {
const deviceId = args[1];
const token = args[2];
if (!deviceId || !token) {
console.error('Usage: npx ts-node scripts/device-sync.ts sync <deviceId> <token> [cursor]');
process.exit(1);
}
const cursor = args[3];
const tabs = [
{ id: 'tab-' + Date.now(), customerName: 'Simulated Customer', version: 1, totalInCents: 1500, itemCount: 2 },
];
console.log(`Syncing device ${deviceId}...`);
const result = await api<SyncResult>(`/devices/${deviceId}/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ deviceId, cursor: cursor || null, openTabs: tabs }),
});
console.log('Sync complete!');
console.log(` New cursor: ${result.cursor}`);
console.log(` Commands received: ${result.commands.length}`);
for (const cmd of result.commands) {
console.log(` Command: ${cmd.id} - ${cmd.type} (tab: ${cmd.tabId}, payment: ${cmd.paymentMethod})`);
}
return result;
}
async function ackCommand() {
const deviceId = args[1];
const token = args[2];
const commandId = args[3];
const status = args[4] || 'completed';
const result = args[5] || 'Tab closed successfully';
if (!deviceId || !token || !commandId) {
console.error('Usage: npx ts-node scripts/device-sync.ts ack <deviceId> <token> <commandId> [status] [result]');
process.exit(1);
}
console.log(`Acknowledging command ${commandId} as ${status}...`);
const res = await api(`/devices/${deviceId}/commands/${commandId}/ack`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ commandId, status, result }),
});
console.log('Ack complete!');
console.log(` Result: ${JSON.stringify(res)}`);
}
// Interactive demo sequence
async function demo() {
const venueId = args[1];
const code = args[2];
if (!venueId || !code) {
console.error('Usage: npx ts-node scripts/device-sync.ts demo <venueId> <registrationCode>');
console.error('First, generate a registration code from the dashboard (Devices > Add Device).');
process.exit(1);
}
try {
// 1. Register
const reg = await registerDevice();
const deviceId = reg.deviceId;
const token = reg.token;
// 2. Sync with tabs
console.log('\n--- Sync 1: Upload tabs ---');
const sync1 = await syncDevice();
if (sync1.commands.length > 0) {
// 3. Ack commands
for (const cmd of sync1.commands) {
console.log(`\n--- Ack: ${cmd.id} ---`);
await ackCommand(deviceId, token, cmd.id, 'completed', `Tab ${cmd.tabId} closed successfully`);
}
// 4. Sync again to see no pending commands
console.log('\n--- Sync 2: Verify no pending commands ---');
await syncDevice();
}
console.log('\n✅ Demo complete! Your device is fully functional.');
deviceId = result.deviceId;
token = result.token;
console.log(`\n ✅ Registered as "${name}"`);
console.log(` 🆔 Device ID: ${deviceId}`);
console.log(` 🔑 Token: ${token.substring(0, 20)}...`);
await fetchPlannedCustomers();
} catch (err: any) {
console.error('❌ Demo failed:', err.message);
console.log(`\n ❌ Registration failed: ${err.message}`);
}
}
function addTab() {
tabCounter++;
const tabId = `tab-${Date.now()}-${tabCounter}`;
const fallbackNames = ['Jan', 'Piet', 'Kees', 'Anna', 'Maria', 'Lisa', 'Tom', 'Emma', 'Noah', 'Sophie'];
const names = plannedNames.length > 0 ? plannedNames : fallbackNames;
const name = names[plannedIndex % names.length];
plannedIndex++;
const items = Math.floor(Math.random() * 5) + 1;
const total = items * (Math.floor(Math.random() * 10) + 5) * 100;
openTabs.set(tabId, { customerName: name, version: 1, totalInCents: total, itemCount: items });
console.log(` 📋 Tab added: ${name} — €${(total / 100).toFixed(2)} (${items} items) [${tabId.substring(0, 8)}]`);
}
async function sync() {
if (!deviceId || !token) {
console.log('\n ❌ Not registered. Press P to pair first.');
return;
}
const tabsPayload = buildTabsPayload();
try {
const result = await api<{ cursor: string; commands: any[]; plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ deviceId, cursor, openTabs: tabsPayload }),
});
cursor = result.cursor;
pendingCommands = result.commands;
// Update planned customers from sync response
if (result.plannedTabNames && result.plannedTabNames.length > 0) {
plannedNames = result.plannedTabNames;
plannedIndex = 0;
}
console.log(`\n ✅ Synced — ${openTabs.size} tab(s), ${pendingCommands.length} command(s) pending, ${plannedNames.length} planned`);
for (const cmd of pendingCommands) {
console.log(` 📦 ${cmd.type} on ${cmd.tabId.substring(0, 8)} (${cmd.paymentMethod})`);
}
} catch (err: any) {
console.log(`\n ❌ Sync failed: ${err.message}`);
}
}
async function ackAll() {
if (pendingCommands.length === 0) {
console.log('\n ℹ️ No pending commands to acknowledge.');
return;
}
for (const cmd of pendingCommands) {
try {
const status = Math.random() > 0.2 ? 'completed' : 'failed';
await api(`/devices/${deviceId}/commands/${cmd.id}/ack`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ commandId: cmd.id, status, result: `Tab closed via simulator (${status})` }),
});
console.log(` ✅ Acked ${cmd.id.substring(0, 8)} as ${status}`);
// If completed, remove the tab from local state
if (status === 'completed') {
openTabs.delete(cmd.tabId);
console.log(` Removed tab ${cmd.tabId.substring(0, 8)} from local state`);
} else {
// Bump version on failed close
const tab = openTabs.get(cmd.tabId);
if (tab) {
tab.version++;
console.log(` Bumped version to ${tab.version}`);
}
}
} catch (err: any) {
console.log(` ❌ Ack failed for ${cmd.id.substring(0, 8)}: ${err.message}`);
}
}
pendingCommands = [];
}
function listTabs() {
if (openTabs.size === 0) {
console.log('\n ℹ️ No tabs. Press T to add one.');
return;
}
console.log(`\n 📋 Open Tabs (${openTabs.size}):`);
Array.from(openTabs.entries()).forEach(([id, t]) => {
console.log(` ${id.substring(0, 8)} — ${t.customerName} — €${(t.totalInCents / 100).toFixed(2)} (v${t.version})`);
});
}
function showStatus() {
console.log(`\n ── Status ──────────────────────`);
console.log(` Venue ID: ${venueId || '—'}`);
console.log(` Device ID: ${deviceId || '—'}`);
console.log(` Token: ${token ? token.substring(0, 20) + '...' : '—'}`);
console.log(` Cursor: ${cursor || '—'}`);
console.log(` Planned: ${plannedNames.length} customer(s)`);
console.log(` Tabs: ${openTabs.size}`);
console.log(` Pending: ${pendingCommands.length}`);
console.log(` ────────────────────────────────`);
}
async function setVenueAndCode() {
// Temporarily disable raw mode so readline works
if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(false);
const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
const v = await new Promise<string>((resolve) => rl.question(' Venue ID: ', resolve));
venueId = v.trim();
const c = await new Promise<string>((resolve) => rl.question(' Registration Code: ', resolve));
registrationCode = c.trim();
rl.close();
// Re-enable raw mode for keypress handling
if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(true);
if (venueId && c) {
await registerDevice();
}
}
// ── Interactive loop ─────────────────────────────────────
function printHelp() {
console.log(`
╔══════════════════════════════════════════╗
║ KoolTab Device Simulator ║
╠══════════════════════════════════════════╣
║ P — Pair / register device ║
║ T — Add tab (uses planned customers) ║
║ S — Sync with server ║
║ A — Acknowledge all pending commands ║
║ R — Refresh planned customers ║
║ L — List open tabs ║
║ H — Show this help ║
║ Q — Quit ║
╚══════════════════════════════════════════╝`);
}
async function main() {
try {
switch (command) {
case 'register':
await registerDevice();
break;
case 'sync':
await syncDevice();
break;
case 'ack':
await ackCommand();
break;
case 'demo':
await demo();
break;
default:
console.log(`
KoolTab Device Simulator
========================
console.clear();
console.log('╔══════════════════════════════════════╗');
console.log('║ KoolTab Device Simulator ║');
console.log('╚══════════════════════════════════════╝');
Commands:
register <venueId> <name> <registrationCode> Register a new device
sync <deviceId> <token> [cursor] Sync device state
ack <deviceId> <token> <commandId> [status] Acknowledge a command
demo <venueId> <registrationCode> Run full demo sequence
// Prompt for venue / code on first run
await setVenueAndCode();
Examples:
npx ts-node scripts/device-sync.ts register <venueId> "Bar Terminal 1" ABC123
npx ts-node scripts/device-sync.ts sync <deviceId> <token>
npx ts-node scripts/device-sync.ts ack <deviceId> <token> <commandId> completed
npx ts-node scripts/device-sync.ts demo <venueId> ABC123
printHelp();
showStatus();
Status values: completed, failed, conflict, expired
`);
readline.emitKeypressEvents(process.stdin);
if ((process.stdin as any).isTTY) (process.stdin as any).setRawMode(true);
process.stdin.resume();
process.stdin.on('keypress', async (_str: string, key: any) => {
if (key.ctrl && key.name === 'c') {
console.log('\n👋 Goodbye!');
process.exit(0);
}
} catch (err: any) {
console.error('Error:', err.message);
process.exit(1);
switch (key.name?.toLowerCase()) {
case 'p':
await setVenueAndCode();
showStatus();
break;
case 't':
addTab();
break;
case 'r':
await fetchPlannedCustomers();
showStatus();
break;
case 's':
await sync();
showStatus();
break;
case 'a':
await ackAll();
showStatus();
break;
case 'l':
listTabs();
break;
case 'h':
printHelp();
break;
case 'q':
console.log('\n👋 Goodbye!');
process.exit(0);
}
});
console.log('\nPress H for help, or any key above to get started.');
}
main();
main().catch((err) => {
console.error('Fatal:', err.message);
process.exit(1);
});