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:
Generated
+10
@@ -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
|
||||||
Generated
+8
@@ -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>
|
||||||
Generated
+8
@@ -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
@@ -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
@@ -37,8 +37,9 @@ COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
|||||||
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
|
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
|
||||||
RUN pnpm install --prod
|
RUN pnpm install --prod
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3001
|
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 ----
|
# ---- Production Dashboard image ----
|
||||||
FROM node:20-alpine AS dashboard
|
FROM node:20-alpine AS dashboard
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -20,6 +20,7 @@ model Venue {
|
|||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
regCodes RegistrationCode[]
|
regCodes RegistrationCode[]
|
||||||
plannedTabs PlannedTab[]
|
plannedTabs PlannedTab[]
|
||||||
|
tabReminders TabReminder[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
@@ -148,6 +149,7 @@ model PlannedTab {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
venueId String
|
venueId String
|
||||||
customerName String
|
customerName String
|
||||||
|
email String?
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@ -156,3 +158,19 @@ model PlannedTab {
|
|||||||
@@unique([venueId, customerName])
|
@@unique([venueId, customerName])
|
||||||
@@index([venueId])
|
@@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])
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { CommandsModule } from './commands/commands.module';
|
|||||||
import { AuditModule } from './audit/audit.module';
|
import { AuditModule } from './audit/audit.module';
|
||||||
import { JobsModule } from './jobs/jobs.module';
|
import { JobsModule } from './jobs/jobs.module';
|
||||||
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
|
||||||
|
import { RemindersModule } from './reminders/reminders.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -26,6 +27,7 @@ import { HealthController } from './health.controller';
|
|||||||
AuditModule,
|
AuditModule,
|
||||||
JobsModule,
|
JobsModule,
|
||||||
PlannedTabsModule,
|
PlannedTabsModule,
|
||||||
|
RemindersModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Post, Body, HttpCode, HttpStatus } from '@nestjs/common';
|
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 { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
|
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||||
|
|
||||||
@ApiTags('Authentication')
|
@ApiTags('Authentication')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -9,6 +11,7 @@ export class AuthController {
|
|||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Login with email and password' })
|
@ApiOperation({ summary: 'Login with email and password' })
|
||||||
async login(@Body() dto: LoginDto) {
|
async login(@Body() dto: LoginDto) {
|
||||||
@@ -18,15 +21,15 @@ export class AuthController {
|
|||||||
@Post('refresh')
|
@Post('refresh')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Refresh access token' })
|
@ApiOperation({ summary: 'Refresh access token' })
|
||||||
async refresh(@Body('refreshToken') refreshToken: string) {
|
async refresh(@Body() dto: RefreshTokenDto) {
|
||||||
return this.authService.refresh(refreshToken);
|
return this.authService.refresh(dto.refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
||||||
async logout(@Body('refreshToken') refreshToken: string) {
|
async logout(@Body() dto: RefreshTokenDto) {
|
||||||
await this.authService.logout(refreshToken);
|
await this.authService.logout(dto.refreshToken);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,13 @@ import { AuthController } from './auth.controller';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { DeviceStrategy } from './strategies/device.strategy';
|
import { DeviceStrategy } from './strategies/device.strategy';
|
||||||
|
import { getJwtSecret } from './jwt-secret';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
secret: getJwtSecret(),
|
||||||
signOptions: { expiresIn: '24h' },
|
signOptions: { expiresIn: '24h' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -129,33 +129,46 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
|
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
|
// Claim the code atomically so it can only ever be used once
|
||||||
const deviceId = uuidv4();
|
const claimed = await this.prisma.registrationCode.updateMany({
|
||||||
|
where: { id: code.id, usedAt: null },
|
||||||
// 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() },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.device.create({
|
|
||||||
data: {
|
|
||||||
id: deviceId,
|
|
||||||
venueId,
|
|
||||||
name,
|
|
||||||
tokenHash: hash,
|
|
||||||
tokenPrefix: prefix,
|
|
||||||
tokenExpiresAt: expiresAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.registrationCode.update({
|
|
||||||
where: { id: code.id },
|
|
||||||
data: { usedAt: new Date() },
|
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,
|
||||||
|
venueId,
|
||||||
|
name,
|
||||||
|
tokenHash: hash,
|
||||||
|
tokenPrefix: prefix,
|
||||||
|
tokenExpiresAt: expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { getJwtSecret } from '../jwt-secret';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
@@ -8,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
ignoreExpiration: false,
|
ignoreExpiration: false,
|
||||||
secretOrKey: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
secretOrKey: getJwtSecret(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,18 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} 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 { CommandsService } from './commands.service';
|
||||||
import { DevicesService } from '../devices/devices.service';
|
import { DevicesService } from '../devices/devices.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||||
import { CreateCommandDto } from './dto/create-command.dto';
|
import { CreateCommandDto } from './dto/create-command.dto';
|
||||||
|
|
||||||
@ApiTags('Commands')
|
@ApiTags('Commands')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||||
@Controller('venues/:venueId/commands')
|
@Controller('venues/:venueId/commands')
|
||||||
export class CommandsController {
|
export class CommandsController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -51,15 +52,16 @@ export class CommandsController {
|
|||||||
) {
|
) {
|
||||||
return this.commandsService.findAll(venueId, {
|
return this.commandsService.findAll(venueId, {
|
||||||
status,
|
status,
|
||||||
limit: limit ? parseInt(limit, 10) : 50,
|
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||||
offset: offset ? parseInt(offset, 10) : 0,
|
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('devices/:deviceId/revoke')
|
@Post('devices/:deviceId/revoke')
|
||||||
@UseGuards(RolesGuard)
|
@UseGuards(RolesGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@ApiOperation({ summary: 'Revoke a device' })
|
@ApiOperation({ summary: 'Revoke a device (admin only)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Device revoked' })
|
||||||
async revoke(
|
async revoke(
|
||||||
@Param('venueId') venueId: string,
|
@Param('venueId') venueId: string,
|
||||||
@Param('deviceId') deviceId: string,
|
@Param('deviceId') deviceId: string,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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 now = new Date();
|
||||||
const expiresAt = new Date(now.getTime() + COMMAND_EXPIRY_HOURS * 60 * 60 * 1000);
|
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';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
private readonly logger = new Logger(HttpExceptionFilter.name);
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost) {
|
catch(exception: unknown, host: ArgumentsHost) {
|
||||||
const ctx = host.switchToHttp();
|
const ctx = host.switchToHttp();
|
||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
@@ -31,8 +33,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
code = resp.code || this.statusToCode(status);
|
code = resp.code || this.statusToCode(status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else {
|
||||||
message = exception.message;
|
// 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({
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Req,
|
Req,
|
||||||
} from '@nestjs/common';
|
} 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 { DevicesService } from './devices.service';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
@@ -29,7 +30,9 @@ export class DevicesController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('register')
|
@Post('register')
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@ApiOperation({ summary: 'Register a new device' })
|
@ApiOperation({ summary: 'Register a new device' })
|
||||||
|
@ApiResponse({ status: 201, description: 'Device registered successfully' })
|
||||||
async register(@Body() dto: RegisterDeviceDto) {
|
async register(@Body() dto: RegisterDeviceDto) {
|
||||||
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
||||||
}
|
}
|
||||||
@@ -37,13 +40,15 @@ export class DevicesController {
|
|||||||
@Post(':deviceId/sync')
|
@Post(':deviceId/sync')
|
||||||
@UseGuards(DeviceAuthGuard)
|
@UseGuards(DeviceAuthGuard)
|
||||||
@ApiOperation({ summary: 'Sync device state with server' })
|
@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) {
|
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
|
||||||
return this.devicesService.sync(req.user, dto);
|
return this.devicesService.sync(req.user, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':deviceId/commands/:commandId/ack')
|
@Post(':deviceId/commands/:commandId/ack')
|
||||||
@UseGuards(DeviceAuthGuard)
|
@UseGuards(DeviceAuthGuard)
|
||||||
@ApiOperation({ summary: 'Acknowledge a command' })
|
@ApiOperation({ summary: 'Acknowledge a command result' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Command acknowledged' })
|
||||||
async ackCommand(
|
async ackCommand(
|
||||||
@Param('deviceId') deviceId: string,
|
@Param('deviceId') deviceId: string,
|
||||||
@Param('commandId') commandId: string,
|
@Param('commandId') commandId: string,
|
||||||
@@ -54,9 +59,11 @@ export class DevicesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('generation-code')
|
@Get('generation-code')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@ApiOperation({ summary: 'Generate a registration code' })
|
@Roles('admin')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({ summary: 'Generate a registration code for pairing' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Registration code generated' })
|
||||||
async generateCode(@Req() req: any) {
|
async generateCode(@Req() req: any) {
|
||||||
const venueId = req.user.venueId;
|
const venueId = req.user.venueId;
|
||||||
return this.authService.generateRegistrationCode(venueId);
|
return this.authService.generateRegistrationCode(venueId);
|
||||||
|
|||||||
@@ -26,68 +26,75 @@ export class DevicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const openTabs = dto.openTabs || [];
|
||||||
|
const openTabIds = openTabs.map((t) => t.id);
|
||||||
|
|
||||||
// Upsert snapshots and ownership
|
// Ownership changes are detected up front so audit logging stays outside the transaction
|
||||||
for (const tab of dto.openTabs || []) {
|
const takenOver = await this.prisma.tabOwnership.findMany({
|
||||||
await this.prisma.openTabSnapshot.upsert({
|
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
|
||||||
where: {
|
});
|
||||||
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
|
for (const ownership of takenOver) {
|
||||||
},
|
await this.auditService.log(
|
||||||
create: {
|
device.venueId,
|
||||||
deviceId: device.id,
|
'device',
|
||||||
tabId: tab.id,
|
device.id,
|
||||||
customerName: tab.customerName,
|
'snapshot_conflict',
|
||||||
version: tab.version,
|
{ tabId: ownership.tabId, previousDevice: ownership.deviceId, newDevice: device.id },
|
||||||
totalInCents: tab.totalInCents,
|
);
|
||||||
itemCount: tab.itemCount,
|
|
||||||
reportedAt: now,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
customerName: tab.customerName,
|
|
||||||
version: tab.version,
|
|
||||||
totalInCents: tab.totalInCents,
|
|
||||||
itemCount: tab.itemCount,
|
|
||||||
reportedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if tab was owned by another device
|
|
||||||
const existingOwnership = await this.prisma.tabOwnership.findUnique({
|
|
||||||
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingOwnership && existingOwnership.deviceId !== device.id) {
|
|
||||||
if (existingOwnership.lastReportedAt < now) {
|
|
||||||
await this.auditService.log(
|
|
||||||
device.venueId,
|
|
||||||
'device',
|
|
||||||
device.id,
|
|
||||||
'snapshot_conflict',
|
|
||||||
{ tabId: tab.id, previousDevice: existingOwnership.deviceId, newDevice: device.id },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.tabOwnership.upsert({
|
|
||||||
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
|
|
||||||
create: {
|
|
||||||
tabId: tab.id,
|
|
||||||
venueId: device.venueId,
|
|
||||||
deviceId: device.id,
|
|
||||||
lastReportedAt: now,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
deviceId: device.id,
|
|
||||||
lastReportedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update lastSeenAt
|
await this.prisma.$transaction(async (tx) => {
|
||||||
await this.prisma.device.update({
|
// The device reports its full set of open tabs: anything it no longer reports has been closed
|
||||||
where: { id: device.id },
|
await tx.tabOwnership.deleteMany({
|
||||||
data: { lastSeenAt: now },
|
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 },
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
deviceId: device.id,
|
||||||
|
tabId: tab.id,
|
||||||
|
customerName: tab.customerName,
|
||||||
|
version: tab.version,
|
||||||
|
totalInCents: tab.totalInCents,
|
||||||
|
itemCount: tab.itemCount,
|
||||||
|
reportedAt: now,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
customerName: tab.customerName,
|
||||||
|
version: tab.version,
|
||||||
|
totalInCents: tab.totalInCents,
|
||||||
|
itemCount: tab.itemCount,
|
||||||
|
reportedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.tabOwnership.upsert({
|
||||||
|
where: { venueId_tabId: { venueId: device.venueId, tabId: tab.id } },
|
||||||
|
create: {
|
||||||
|
tabId: tab.id,
|
||||||
|
venueId: device.venueId,
|
||||||
|
deviceId: device.id,
|
||||||
|
lastReportedAt: now,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
deviceId: device.id,
|
||||||
|
lastReportedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { lastSeenAt: now },
|
||||||
|
});
|
||||||
|
}, { timeout: 15000 });
|
||||||
|
|
||||||
// Expire old commands
|
// Expire old commands
|
||||||
await this.prisma.remoteCommand.updateMany({
|
await this.prisma.remoteCommand.updateMany({
|
||||||
@@ -99,33 +106,15 @@ export class DevicesService {
|
|||||||
data: { status: 'expired' },
|
data: { status: 'expired' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build cursor from timestamp
|
// The cursor is kept for client compatibility but no longer filters commands: every pending
|
||||||
const cursorTimestamp = now.toISOString();
|
// command is returned on each sync, so a command committed while a sync was in flight can never
|
||||||
const tieBreaker = uuidv4().substring(0, 8);
|
// be skipped. Devices dedupe by command id and re-ack commands they already processed.
|
||||||
const cursor = Buffer.from(`${cursorTimestamp}:${tieBreaker}`).toString('base64url');
|
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
|
||||||
|
const commandWhere = {
|
||||||
// Build the Prisma where clause
|
|
||||||
const commandWhere: any = {
|
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
status: 'pending',
|
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
|
// Find pending commands for this device
|
||||||
const commands = await this.prisma.remoteCommand.findMany({
|
const commands = await this.prisma.remoteCommand.findMany({
|
||||||
where: commandWhere,
|
where: commandWhere,
|
||||||
@@ -182,13 +171,13 @@ export class DevicesService {
|
|||||||
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
|
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotent: if already acknowledged with same status, just return ok
|
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
|
||||||
if (command.status === dto.status && command.acknowledgedAt) {
|
if (command.acknowledgedAt) {
|
||||||
return { ok: true };
|
return { ok: true, status: command.status };
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prisma.remoteCommand.update({
|
const updated = await this.prisma.remoteCommand.updateMany({
|
||||||
where: { id: commandId },
|
where: { id: commandId, acknowledgedAt: null },
|
||||||
data: {
|
data: {
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
result: dto.result || null,
|
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(
|
await this.auditService.log(
|
||||||
device.venueId,
|
device.venueId,
|
||||||
'device',
|
'device',
|
||||||
|
|||||||
@@ -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';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
class OpenTabDto {
|
class OpenTabDto {
|
||||||
@ApiProperty({ example: 'local-tab-uuid' })
|
@ApiProperty({ example: 'local-tab-uuid' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Jan' })
|
@ApiProperty({ example: 'Jan' })
|
||||||
@@ -38,5 +40,8 @@ export class SyncDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: [OpenTabDto] })
|
@ApiProperty({ type: [OpenTabDto] })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
@ArrayMaxSize(1000)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => OpenTabDto)
|
||||||
openTabs: OpenTabDto[];
|
openTabs: OpenTabDto[];
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get, HttpStatus, Res } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||||
|
import { SkipThrottle } from '@nestjs/throttler';
|
||||||
|
import { Response } from 'express';
|
||||||
import { PrismaService } from './prisma/prisma.service';
|
import { PrismaService } from './prisma/prisma.service';
|
||||||
|
|
||||||
@ApiTags('Health')
|
@ApiTags('Health')
|
||||||
|
@SkipThrottle()
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
export class HealthController {
|
export class HealthController {
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Health check' })
|
@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';
|
let dbStatus = 'ok';
|
||||||
try {
|
try {
|
||||||
await this.prisma.$queryRaw`SELECT 1`;
|
await this.prisma.$queryRaw`SELECT 1`;
|
||||||
} catch {
|
} catch {
|
||||||
dbStatus = 'error';
|
dbStatus = 'error';
|
||||||
|
res.status(HttpStatus.SERVICE_UNAVAILABLE);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: dbStatus === 'ok' ? 'ok' : 'degraded',
|
||||||
database: dbStatus,
|
database: dbStatus,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-1
@@ -2,15 +2,25 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
|
||||||
|
|
||||||
async function bootstrap() {
|
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');
|
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.use(helmet());
|
||||||
|
|
||||||
app.enableCors({
|
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 { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
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 { 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')
|
@ApiTags('Planned Tabs')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||||
@Controller('venues/:venueId/planned-tabs')
|
@Controller('venues/:venueId/planned-tabs')
|
||||||
export class PlannedTabsController {
|
export class PlannedTabsController {
|
||||||
constructor(private service: PlannedTabsService) {}
|
constructor(private service: PlannedTabsService) {}
|
||||||
@@ -17,18 +23,29 @@ export class PlannedTabsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
|
@Roles('admin')
|
||||||
@ApiOperation({ summary: 'Add a planned tab name' })
|
@ApiOperation({ summary: 'Add a planned tab name' })
|
||||||
async create(@Param('venueId') venueId: string, @Body() dto: { customerName: string }) {
|
async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) {
|
||||||
return this.service.create(venueId, dto.customerName);
|
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')
|
@Put(':id')
|
||||||
|
@Roles('admin')
|
||||||
@ApiOperation({ summary: 'Rename a planned tab' })
|
@ApiOperation({ summary: 'Rename a planned tab' })
|
||||||
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: { customerName: string }) {
|
async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) {
|
||||||
return this.service.update(venueId, id, dto.customerName);
|
return this.service.update(venueId, id, dto.customerName, dto.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@Roles('admin')
|
||||||
@ApiOperation({ summary: 'Remove a planned tab' })
|
@ApiOperation({ summary: 'Remove a planned tab' })
|
||||||
async remove(@Param('venueId') venueId: string, @Param('id') id: string) {
|
async remove(@Param('venueId') venueId: string, @Param('id') id: string) {
|
||||||
return this.service.remove(venueId, id);
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
@@ -10,14 +10,14 @@ export class PlannedTabsService {
|
|||||||
const tabs = await this.prisma.plannedTab.findMany({
|
const tabs = await this.prisma.plannedTab.findMany({
|
||||||
where: { venueId },
|
where: { venueId },
|
||||||
orderBy: { position: 'asc' },
|
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 };
|
return { plannedTabs: tabs };
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(venueId: string, customerName: string) {
|
async create(venueId: string, customerName: string, email?: string) {
|
||||||
const name = customerName.trim();
|
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
|
// Check for duplicate
|
||||||
const existing = await this.prisma.plannedTab.findUnique({
|
const existing = await this.prisma.plannedTab.findUnique({
|
||||||
@@ -38,30 +38,90 @@ export class PlannedTabsService {
|
|||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
venueId,
|
venueId,
|
||||||
customerName: name,
|
customerName: name,
|
||||||
|
email: email?.trim() || null,
|
||||||
position: (last?.position ?? -1) + 1,
|
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;
|
return tab;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(venueId: string, id: string, customerName: string) {
|
async update(venueId: string, id: string, customerName: string, email?: string) {
|
||||||
const name = customerName.trim();
|
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 } });
|
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
|
||||||
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
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({
|
const result = await this.prisma.plannedTab.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { customerName: name },
|
data: { customerName: name, email: email?.trim() || null },
|
||||||
select: { id: true, customerName: true, position: true, createdAt: true },
|
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
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) {
|
async remove(venueId: string, id: string) {
|
||||||
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
|
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
|
||||||
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
|
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 {}
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
@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>€${(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,10 +2,11 @@ import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||||
import { TabsService } from './tabs.service';
|
import { TabsService } from './tabs.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||||
|
|
||||||
@ApiTags('Tabs')
|
@ApiTags('Tabs')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||||
@Controller('venues/:venueId/tabs')
|
@Controller('venues/:venueId/tabs')
|
||||||
export class TabsController {
|
export class TabsController {
|
||||||
constructor(private tabsService: TabsService) {}
|
constructor(private tabsService: TabsService) {}
|
||||||
|
|||||||
@@ -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 = [];
|
const tabs = [];
|
||||||
for (const ownership of ownerships) {
|
for (const ownership of ownerships) {
|
||||||
const snapshot = await this.prisma.openTabSnapshot.findFirst({
|
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
|
||||||
where: { tabId: ownership.tabId, deviceId: ownership.deviceId },
|
|
||||||
orderBy: { reportedAt: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
tabs.push({
|
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;
|
||||||
|
}
|
||||||
@@ -4,8 +4,10 @@ import { VenuesService } from './venues.service';
|
|||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { RolesGuard } from '../common/guards/roles.guard';
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
import { Roles } from '../common/decorators/roles.decorator';
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
|
|
||||||
@ApiTags('Venues')
|
@ApiTags('Venues')
|
||||||
@Controller('venues')
|
@Controller('venues')
|
||||||
@@ -24,7 +26,7 @@ export class VenuesController {
|
|||||||
|
|
||||||
@Get(':venueId')
|
@Get(':venueId')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||||
@ApiOperation({ summary: 'Get venue by ID' })
|
@ApiOperation({ summary: 'Get venue by ID' })
|
||||||
async findOne(@Param('venueId') venueId: string) {
|
async findOne(@Param('venueId') venueId: string) {
|
||||||
return this.venuesService.findOne(venueId);
|
return this.venuesService.findOne(venueId);
|
||||||
@@ -32,7 +34,7 @@ export class VenuesController {
|
|||||||
|
|
||||||
@Get(':venueId/users')
|
@Get(':venueId/users')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@ApiOperation({ summary: 'List users for a venue' })
|
@ApiOperation({ summary: 'List users for a venue' })
|
||||||
async findUsers(@Param('venueId') venueId: string) {
|
async findUsers(@Param('venueId') venueId: string) {
|
||||||
@@ -46,19 +48,19 @@ export class VenuesController {
|
|||||||
|
|
||||||
@Post(':venueId/users')
|
@Post(':venueId/users')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard)
|
||||||
@Roles('admin')
|
@Roles('admin')
|
||||||
@ApiOperation({ summary: 'Create a new user for the venue' })
|
@ApiOperation({ summary: 'Create a new user for the venue' })
|
||||||
async createUser(
|
async createUser(
|
||||||
@Param('venueId') venueId: string,
|
@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);
|
return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':venueId/devices')
|
@Get(':venueId/devices')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||||
@ApiOperation({ summary: 'Get venue device info' })
|
@ApiOperation({ summary: 'Get venue device info' })
|
||||||
async findDevices(@Param('venueId') venueId: string) {
|
async findDevices(@Param('venueId') venueId: string) {
|
||||||
const device = await this.prisma.device.findFirst({
|
const device = await this.prisma.device.findFirst({
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-hook-form": "^7.49.0",
|
"react-hook-form": "^7.49.0",
|
||||||
"tailwind-merge": "^2.2.1",
|
"tailwind-merge": "^2.2.1",
|
||||||
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -12,18 +12,22 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
|
Bell,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
|
import { useLang } from '@/lib/i18n';
|
||||||
|
import { LanguageSelector } from '@/components/language-selector';
|
||||||
import { authApi, api } from '@/lib/api/client';
|
import { authApi, api } from '@/lib/api/client';
|
||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: '/dashboard', label: 'Overview', icon: LayoutDashboard, neededRole: 'viewer' },
|
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
|
||||||
{ href: '/dashboard/tabs', label: 'Open Tabs', icon: ClipboardList, neededRole: 'viewer' },
|
{ href: '/dashboard/tabs', key: 'nav.tabs', icon: ClipboardList, adminOnly: false },
|
||||||
{ href: '/dashboard/users', label: 'Users', icon: Monitor, neededRole: 'admin' },
|
{ href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, adminOnly: false },
|
||||||
{ href: '/dashboard/commands', label: 'Command History', icon: Terminal, neededRole: 'viewer' },
|
{ href: '/dashboard/users', key: 'nav.users', icon: Monitor, adminOnly: true },
|
||||||
{ href: '/dashboard/settings', label: 'Settings', icon: Settings, neededRole: 'viewer' },
|
{ href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, adminOnly: false },
|
||||||
|
{ href: '/dashboard/settings', key: 'nav.settings', icon: Settings, adminOnly: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -36,6 +40,7 @@ export default function DashboardLayout({
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
|
const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
|
||||||
const [venue, setVenue] = useState<{ name: string } | null>(null);
|
const [venue, setVenue] = useState<{ name: string } | null>(null);
|
||||||
|
const { t } = useLang();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check auth
|
// Check auth
|
||||||
@@ -111,7 +116,7 @@ export default function DashboardLayout({
|
|||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const active = pathname === item.href;
|
const active = pathname === item.href;
|
||||||
|
|
||||||
if (item.neededRole === 'admin' && user?.role !== 'admin') {
|
if (item.adminOnly && user?.role !== 'admin') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,13 +133,14 @@ export default function DashboardLayout({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.label}
|
{t(item.key)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="p-4 border-t">
|
<div className="p-4 border-t">
|
||||||
|
<LanguageSelector />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="mb-3 text-sm">
|
<div className="mb-3 text-sm">
|
||||||
<p className="font-medium truncate">{user.name}</p>
|
<p className="font-medium truncate">{user.name}</p>
|
||||||
@@ -147,7 +153,7 @@ export default function DashboardLayout({
|
|||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
<LogOut className="h-4 w-4" />
|
||||||
Logout
|
{t('nav.logout')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { PageHeader } from '@/components/page-header';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -17,7 +17,9 @@ import { LoadingState } from '@/components/loading-state';
|
|||||||
import { ErrorState } from '@/components/error-state';
|
import { ErrorState } from '@/components/error-state';
|
||||||
import { toast } from '@/components/ui/toast';
|
import { toast } from '@/components/ui/toast';
|
||||||
import { formatCents, formatRelativeTime } from '@/lib/utils';
|
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() {
|
export default function TabsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -28,7 +30,10 @@ export default function TabsPage() {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set());
|
||||||
const [plannedName, setPlannedName] = useState('');
|
const [plannedName, setPlannedName] = useState('');
|
||||||
|
const [plannedEmail, setPlannedEmail] = useState('');
|
||||||
const [userRole, setUserRole] = useState<string | null>(null);
|
const [userRole, setUserRole] = useState<string | null>(null);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const { t } = useLang();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
const cookie = document.cookie.split('; ').find(row => row.startsWith('access_token='));
|
||||||
@@ -97,8 +102,9 @@ export default function TabsPage() {
|
|||||||
async function addPlannedName() {
|
async function addPlannedName() {
|
||||||
if (!plannedName.trim()) return;
|
if (!plannedName.trim()) return;
|
||||||
try {
|
try {
|
||||||
await plannedTabsApi.create(venueId!, plannedName.trim());
|
await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined);
|
||||||
setPlannedName('');
|
setPlannedName('');
|
||||||
|
setPlannedEmail('');
|
||||||
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
|
||||||
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
|
toast({ title: 'Added', description: `"${plannedName.trim()}" added to planned tabs` });
|
||||||
} catch (err: any) {
|
} 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 (!venueId) return <LoadingState />;
|
||||||
if (isLoading) return <LoadingState />;
|
if (isLoading) return <LoadingState />;
|
||||||
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />;
|
if (error) return <ErrorState message={(error as any).message || 'Failed to load tabs'} />;
|
||||||
@@ -132,14 +148,21 @@ export default function TabsPage() {
|
|||||||
{/* Planned Customers */}
|
{/* Planned Customers */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> Planned Customers</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-xs text-muted-foreground mb-3">
|
<p className="text-xs text-muted-foreground mb-3">
|
||||||
These names are synced to the terminal. It will create tabs for these customers automatically.
|
These names are synced to the terminal. It will create tabs for these customers automatically.
|
||||||
</p>
|
</p>
|
||||||
{userRole === 'admin' && (
|
{userRole === 'admin' && (
|
||||||
<div className="flex gap-2 mb-3">
|
<div className="flex gap-2 mb-3 flex-wrap">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Add customer name..."
|
placeholder="Add customer name..."
|
||||||
value={plannedName}
|
value={plannedName}
|
||||||
@@ -147,6 +170,14 @@ export default function TabsPage() {
|
|||||||
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
|
onKeyDown={(e) => e.key === 'Enter' && addPlannedName()}
|
||||||
className="max-w-xs"
|
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()}>
|
<Button size="sm" onClick={addPlannedName} disabled={!plannedName.trim()}>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -159,8 +190,19 @@ export default function TabsPage() {
|
|||||||
{(plannedQuery.data?.plannedTabs || []).map((pt: any) => (
|
{(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">
|
<div key={pt.id} className="flex items-center gap-1 px-3 py-1.5 bg-secondary rounded-full text-sm">
|
||||||
{pt.customerName}
|
{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' && (
|
{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" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -194,7 +236,8 @@ export default function TabsPage() {
|
|||||||
<TableCell>{tab.version}</TableCell>
|
<TableCell>{tab.version}</TableCell>
|
||||||
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
|
<TableCell>{formatRelativeTime(tab.lastReportedAt)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -203,6 +246,15 @@ export default function TabsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{venueId && (
|
||||||
|
<ImportCustomersDialog
|
||||||
|
venueId={venueId}
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{closeDialog.tab && (
|
{closeDialog.tab && (
|
||||||
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { LanguageProvider } from '@/lib/i18n';
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
const [queryClient] = useState(
|
const [queryClient] = useState(
|
||||||
@@ -18,7 +19,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
<LanguageProvider>{children}</LanguageProvider>
|
||||||
</QueryClientProvider>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,9 @@ function getTokens() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTokens(accessToken: string, refreshToken: string) {
|
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`;
|
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';
|
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
|
const refreshToken = document.cookie
|
||||||
.split('; ')
|
.split('; ')
|
||||||
.find((row) => row.startsWith('refresh_token='))
|
.find((row) => row.startsWith('refresh_token='))
|
||||||
@@ -169,12 +183,29 @@ export const venuesApi = {
|
|||||||
// Planned Tabs API
|
// Planned Tabs API
|
||||||
export const plannedTabsApi = {
|
export const plannedTabsApi = {
|
||||||
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
|
list: (venueId: string) => api<{ plannedTabs: any[] }>(`/venues/${venueId}/planned-tabs`),
|
||||||
create: (venueId: string, customerName: string) =>
|
create: (venueId: string, customerName: string, email?: string) =>
|
||||||
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName }) }),
|
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
|
||||||
update: (venueId: string, id: string, customerName: string) =>
|
update: (venueId: string, id: string, customerName: string, email?: string) =>
|
||||||
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName }) }),
|
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'PUT', body: JSON.stringify({ customerName, email }) }),
|
||||||
remove: (venueId: string, id: string) =>
|
remove: (venueId: string, id: string) =>
|
||||||
api(`/venues/${venueId}/planned-tabs/${id}`, { method: 'DELETE' }),
|
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 };
|
export { setTokens, clearTokens };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -30,6 +30,10 @@ services:
|
|||||||
JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET}"
|
JWT_REFRESH_SECRET: "${JWT_REFRESH_SECRET}"
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
CORS_ORIGIN: "${DASHBOARD_URL:-http://localhost:3000}"
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+26
-2
@@ -18,7 +18,7 @@ Swagger UI: `http://localhost:3001/api/docs`
|
|||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### Health
|
### Health
|
||||||
`GET /health` - Server health check
|
`GET /health` - Server health check (503 when the database is unreachable)
|
||||||
|
|
||||||
### Auth
|
### Auth
|
||||||
`POST /auth/login` - Login with email/password
|
`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)
|
`POST /devices/:deviceId/commands/:commandId/ack` - Acknowledge command (device auth)
|
||||||
`GET /devices/generation-code` - Generate registration code (admin)
|
`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
|
### Tabs
|
||||||
`GET /venues/:venueId/tabs` - List open 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
|
### Commands
|
||||||
`POST /venues/:venueId/commands` - Create a command
|
`POST /venues/:venueId/commands` - Create a command
|
||||||
`GET /venues/:venueId/commands` - List command history
|
`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
|
## Error Format
|
||||||
|
|
||||||
|
|||||||
Generated
+534
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,5 +18,8 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.23.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+317
-20
@@ -6,7 +6,11 @@ settings:
|
|||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
||||||
.: {}
|
.:
|
||||||
|
devDependencies:
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.23.15
|
||||||
|
version: 4.23.15
|
||||||
|
|
||||||
apps/api:
|
apps/api:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -82,7 +86,7 @@ importers:
|
|||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^10.3.0
|
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':
|
'@nestjs/schematics':
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.2.3(chokidar@3.6.0)(typescript@5.9.3)
|
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)
|
version: 6.3.4(supports-color@8.1.1)
|
||||||
ts-jest:
|
ts-jest:
|
||||||
specifier: ^29.1.2
|
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:
|
ts-loader:
|
||||||
specifier: ^9.5.1
|
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:
|
apps/dashboard:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -188,6 +192,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.6.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:
|
zod:
|
||||||
specifier: ^3.22.4
|
specifier: ^3.22.4
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
@@ -209,7 +216,7 @@ importers:
|
|||||||
version: 8.5.28
|
version: 8.5.28
|
||||||
tailwindcss:
|
tailwindcss:
|
||||||
specifier: ^3.4.1
|
specifier: ^3.4.1
|
||||||
version: 3.4.19
|
version: 3.4.19(tsx@4.23.15)
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ^5.4.0
|
specifier: ^5.4.0
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
@@ -420,6 +427,162 @@ packages:
|
|||||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||||
engines: {node: '>=12'}
|
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':
|
'@floating-ui/core@1.8.0':
|
||||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||||
|
|
||||||
@@ -1998,6 +2161,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
esbuild@0.28.2:
|
||||||
|
resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
escalade@3.2.0:
|
escalade@3.2.0:
|
||||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -3620,6 +3788,11 @@ packages:
|
|||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
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:
|
type-detect@4.0.8:
|
||||||
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
|
resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -3797,6 +3970,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
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:
|
xtend@4.0.2:
|
||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
@@ -4064,6 +4243,84 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.9
|
'@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':
|
'@floating-ui/core@1.8.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@floating-ui/utils': 0.2.12
|
'@floating-ui/utils': 0.2.12
|
||||||
@@ -4314,7 +4571,7 @@ snapshots:
|
|||||||
|
|
||||||
'@microsoft/tsdoc@0.15.1': {}
|
'@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:
|
dependencies:
|
||||||
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
|
'@angular-devkit/core': 17.3.11(chokidar@3.6.0)
|
||||||
'@angular-devkit/schematics': 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
|
chokidar: 3.6.0
|
||||||
cli-table3: 0.6.5
|
cli-table3: 0.6.5
|
||||||
commander: 4.1.1
|
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
|
glob: 10.4.5
|
||||||
inquirer: 8.2.6
|
inquirer: 8.2.6
|
||||||
node-emoji: 1.11.0
|
node-emoji: 1.11.0
|
||||||
@@ -4333,7 +4590,7 @@ snapshots:
|
|||||||
tsconfig-paths: 4.2.0
|
tsconfig-paths: 4.2.0
|
||||||
tsconfig-paths-webpack-plugin: 4.2.0
|
tsconfig-paths-webpack-plugin: 4.2.0
|
||||||
typescript: 5.7.2
|
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
|
webpack-node-externals: 3.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@minify-html/node'
|
- '@minify-html/node'
|
||||||
@@ -5825,6 +6082,35 @@ snapshots:
|
|||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
hasown: 2.0.4
|
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: {}
|
escalade@3.2.0: {}
|
||||||
|
|
||||||
escape-html@1.0.3: {}
|
escape-html@1.0.3: {}
|
||||||
@@ -5985,7 +6271,7 @@ snapshots:
|
|||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
signal-exit: 4.1.0
|
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:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
@@ -6000,7 +6286,7 @@ snapshots:
|
|||||||
semver: 7.8.5
|
semver: 7.8.5
|
||||||
tapable: 2.3.3
|
tapable: 2.3.3
|
||||||
typescript: 5.7.2
|
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:
|
form-data@4.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7071,12 +7357,13 @@ snapshots:
|
|||||||
camelcase-css: 2.0.1
|
camelcase-css: 2.0.1
|
||||||
postcss: 8.5.28
|
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:
|
dependencies:
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.3
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
postcss: 8.5.28
|
postcss: 8.5.28
|
||||||
|
tsx: 4.23.15
|
||||||
|
|
||||||
postcss-nested@6.2.0(postcss@8.5.28):
|
postcss-nested@6.2.0(postcss@8.5.28):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7490,7 +7777,7 @@ snapshots:
|
|||||||
|
|
||||||
tailwind-merge@2.6.1: {}
|
tailwind-merge@2.6.1: {}
|
||||||
|
|
||||||
tailwindcss@3.4.19:
|
tailwindcss@3.4.19(tsx@4.23.15):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@alloc/quick-lru': 5.3.0
|
'@alloc/quick-lru': 5.3.0
|
||||||
arg: 5.0.2
|
arg: 5.0.2
|
||||||
@@ -7509,7 +7796,7 @@ snapshots:
|
|||||||
postcss: 8.5.28
|
postcss: 8.5.28
|
||||||
postcss-import: 15.1.0(postcss@8.5.28)
|
postcss-import: 15.1.0(postcss@8.5.28)
|
||||||
postcss-js: 4.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-nested: 6.2.0(postcss@8.5.28)
|
||||||
postcss-selector-parser: 6.1.4
|
postcss-selector-parser: 6.1.4
|
||||||
resolve: 1.22.12
|
resolve: 1.22.12
|
||||||
@@ -7529,14 +7816,15 @@ snapshots:
|
|||||||
mkdirp: 1.0.4
|
mkdirp: 1.0.4
|
||||||
yallist: 4.0.0
|
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:
|
dependencies:
|
||||||
'@jridgewell/trace-mapping': 0.3.31
|
'@jridgewell/trace-mapping': 0.3.31
|
||||||
jest-worker: 27.5.1
|
jest-worker: 27.5.1
|
||||||
schema-utils: 4.5.0
|
schema-utils: 4.5.0
|
||||||
terser: 5.51.2
|
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:
|
optionalDependencies:
|
||||||
|
esbuild: 0.28.2
|
||||||
postcss: 8.5.28
|
postcss: 8.5.28
|
||||||
uglify-js: 3.19.3
|
uglify-js: 3.19.3
|
||||||
|
|
||||||
@@ -7592,7 +7880,7 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
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:
|
dependencies:
|
||||||
bs-logger: 0.2.6
|
bs-logger: 0.2.6
|
||||||
fast-json-stable-stringify: 2.1.0
|
fast-json-stable-stringify: 2.1.0
|
||||||
@@ -7610,15 +7898,16 @@ snapshots:
|
|||||||
'@jest/transform': 29.7.0(supports-color@8.1.1)
|
'@jest/transform': 29.7.0(supports-color@8.1.1)
|
||||||
'@jest/types': 29.6.3
|
'@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)
|
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-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:
|
dependencies:
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
picomatch: 4.0.7
|
picomatch: 4.0.7
|
||||||
source-map: 0.7.6
|
source-map: 0.7.6
|
||||||
typescript: 5.9.3
|
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):
|
ts-node@10.9.2(@types/node@20.19.43)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7653,6 +7942,12 @@ snapshots:
|
|||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tsx@4.23.15:
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.28.2
|
||||||
|
optionalDependencies:
|
||||||
|
fsevents: 2.3.3
|
||||||
|
|
||||||
type-detect@4.0.8: {}
|
type-detect@4.0.8: {}
|
||||||
|
|
||||||
type-fest@0.21.3: {}
|
type-fest@0.21.3: {}
|
||||||
@@ -7748,7 +8043,7 @@ snapshots:
|
|||||||
|
|
||||||
webpack-sources@3.5.1: {}
|
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:
|
dependencies:
|
||||||
'@types/eslint-scope': 3.7.7
|
'@types/eslint-scope': 3.7.7
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -7770,7 +8065,7 @@ snapshots:
|
|||||||
neo-async: 2.6.2
|
neo-async: 2.6.2
|
||||||
schema-utils: 3.3.0
|
schema-utils: 3.3.0
|
||||||
tapable: 2.3.3
|
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
|
watchpack: 2.5.2
|
||||||
webpack-sources: 3.5.1
|
webpack-sources: 3.5.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -7827,6 +8122,8 @@ snapshots:
|
|||||||
imurmurhash: 0.1.4
|
imurmurhash: 0.1.4
|
||||||
signal-exit: 3.0.7
|
signal-exit: 3.0.7
|
||||||
|
|
||||||
|
xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz: {}
|
||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ allowBuilds:
|
|||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
bcrypt: true
|
bcrypt: true
|
||||||
|
esbuild: true
|
||||||
prisma: true
|
prisma: true
|
||||||
|
|||||||
+250
-167
@@ -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
|
* 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 API_URL = process.env.API_URL || 'http://localhost:3001/api/v1';
|
||||||
|
|
||||||
const args = process.argv.slice(2);
|
// ── State ────────────────────────────────────────────────
|
||||||
const command = args[0] || 'help';
|
let deviceId = '';
|
||||||
|
let token = '';
|
||||||
interface SyncResult {
|
let cursor: string | null = null;
|
||||||
cursor: string;
|
let venueId = '';
|
||||||
commands: Array<{
|
let registrationCode = '';
|
||||||
id: string;
|
const openTabs: Map<string, { customerName: string; version: number; totalInCents: number; itemCount: number }> = new Map();
|
||||||
type: string;
|
let pendingCommands: Array<{ id: string; type: string; tabId: string; expectedVersion: number; paymentMethod: string }> = [];
|
||||||
tabId: string;
|
let tabCounter = 0;
|
||||||
expectedVersion: number;
|
let plannedNames: string[] = [];
|
||||||
paymentMethod: string;
|
let plannedIndex = 0;
|
||||||
requestedBy: string;
|
|
||||||
createdAt: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// ── API helpers ──────────────────────────────────────────
|
||||||
async function api<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
async function api<T = any>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const { headers: customHeaders, ...rest } = options;
|
||||||
const res = await fetch(`${API_URL}${endpoint}`, {
|
const res = await fetch(`${API_URL}${endpoint}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(options.headers as Record<string, string>) },
|
...rest,
|
||||||
...options,
|
headers: { 'Content-Type': 'application/json', ...(customHeaders as Record<string, string>) },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.json().catch(() => null);
|
const body = await res.json().catch(() => null);
|
||||||
const msg = body?.error?.message || `HTTP ${res.status}`;
|
throw new Error(body?.error?.message || `HTTP ${res.status}`);
|
||||||
throw new Error(msg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function registerDevice() {
|
// ── Actions ──────────────────────────────────────────────
|
||||||
const venueId = args[1];
|
|
||||||
const name = args[2] || 'Simulated Terminal';
|
|
||||||
const code = args[3];
|
|
||||||
|
|
||||||
if (!venueId || !code) {
|
// The server treats openTabs as the complete set: tabs left out are considered closed
|
||||||
console.error('Usage: npx ts-node scripts/device-sync.ts register <venueId> <name> <registrationCode>');
|
function buildTabsPayload() {
|
||||||
process.exit(1);
|
return Array.from(openTabs.entries()).map(([id, t]) => ({
|
||||||
}
|
id, customerName: t.customerName, version: t.version, totalInCents: t.totalInCents, itemCount: t.itemCount,
|
||||||
|
}));
|
||||||
console.log(`Registering device "${name}" in venue ${venueId}...`);
|
|
||||||
const result = await api('/devices/register', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ venueId, name, registrationCode: code }),
|
|
||||||
});
|
|
||||||
|
|
||||||
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() {
|
async function fetchPlannedCustomers() {
|
||||||
const deviceId = args[1];
|
// Planned names come from the sync response — do a quick sync to refresh them
|
||||||
const token = args[2];
|
|
||||||
|
|
||||||
if (!deviceId || !token) {
|
if (!deviceId || !token) {
|
||||||
console.error('Usage: npx ts-node scripts/device-sync.ts sync <deviceId> <token> [cursor]');
|
console.log('\n ❌ Not registered yet.');
|
||||||
process.exit(1);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
// 1. Register
|
const result = await api<{ plannedTabNames?: string[] }>(`/devices/${deviceId}/sync`, {
|
||||||
const reg = await registerDevice();
|
method: 'POST',
|
||||||
const deviceId = reg.deviceId;
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
const token = reg.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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Sync with tabs
|
async function registerDevice() {
|
||||||
console.log('\n--- Sync 1: Upload tabs ---');
|
const name = 'Simulated Terminal';
|
||||||
const sync1 = await syncDevice();
|
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 }),
|
||||||
|
});
|
||||||
|
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.log(`\n ❌ Registration failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sync1.commands.length > 0) {
|
function addTab() {
|
||||||
// 3. Ack commands
|
tabCounter++;
|
||||||
for (const cmd of sync1.commands) {
|
const tabId = `tab-${Date.now()}-${tabCounter}`;
|
||||||
console.log(`\n--- Ack: ${cmd.id} ---`);
|
const fallbackNames = ['Jan', 'Piet', 'Kees', 'Anna', 'Maria', 'Lisa', 'Tom', 'Emma', 'Noah', 'Sophie'];
|
||||||
await ackCommand(deviceId, token, cmd.id, 'completed', `Tab ${cmd.tabId} closed successfully`);
|
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)}]`);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Sync again to see no pending commands
|
async function sync() {
|
||||||
console.log('\n--- Sync 2: Verify no pending commands ---');
|
if (!deviceId || !token) {
|
||||||
await syncDevice();
|
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✅ Demo complete! Your device is fully functional.');
|
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) {
|
} catch (err: any) {
|
||||||
console.error('❌ Demo failed:', err.message);
|
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() {
|
async function main() {
|
||||||
try {
|
console.clear();
|
||||||
switch (command) {
|
console.log('╔══════════════════════════════════════╗');
|
||||||
case 'register':
|
console.log('║ KoolTab Device Simulator ║');
|
||||||
await registerDevice();
|
console.log('╚══════════════════════════════════════╝');
|
||||||
break;
|
|
||||||
case 'sync':
|
|
||||||
await syncDevice();
|
|
||||||
break;
|
|
||||||
case 'ack':
|
|
||||||
await ackCommand();
|
|
||||||
break;
|
|
||||||
case 'demo':
|
|
||||||
await demo();
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
console.log(`
|
|
||||||
KoolTab Device Simulator
|
|
||||||
========================
|
|
||||||
|
|
||||||
Commands:
|
// Prompt for venue / code on first run
|
||||||
register <venueId> <name> <registrationCode> Register a new device
|
await setVenueAndCode();
|
||||||
sync <deviceId> <token> [cursor] Sync device state
|
|
||||||
ack <deviceId> <token> <commandId> [status] Acknowledge a command
|
|
||||||
demo <venueId> <registrationCode> Run full demo sequence
|
|
||||||
|
|
||||||
Examples:
|
printHelp();
|
||||||
npx ts-node scripts/device-sync.ts register <venueId> "Bar Terminal 1" ABC123
|
showStatus();
|
||||||
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
|
|
||||||
|
|
||||||
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) {
|
switch (key.name?.toLowerCase()) {
|
||||||
console.error('Error:', err.message);
|
case 'p':
|
||||||
process.exit(1);
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user