diff --git a/.env.example b/.env.example
index fb67a59..bf495fd 100644
--- a/.env.example
+++ b/.env.example
@@ -1,8 +1,7 @@
# KoolTap Remote Tab Control
-DATABASE_URL="file:./dev.db"
+DATABASE_URL="postgresql://kooltap:kooltap@localhost:5432/kooltap"
JWT_SECRET="dev-jwt-secret-change-in-production"
-JWT_REFRESH_SECRET="dev-refresh-secret-change-in-production"
PORT=3001
CORS_ORIGIN="http://localhost:3000"
diff --git a/.env.prod.example b/.env.prod.example
index dad54b4..287996a 100644
--- a/.env.prod.example
+++ b/.env.prod.example
@@ -4,9 +4,8 @@
# PostgreSQL
DB_PASSWORD=changeme
-# JWT secrets - generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
+# JWT secret - generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=changeme
-JWT_REFRESH_SECRET=changeme
# URLs
DASHBOARD_URL=http://localhost:3000
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0b5f35c..6a8f9e6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,41 +2,68 @@ name: CI
on:
push:
- branches: [main]
+ branches: [master]
pull_request:
- branches: [main]
+ branches: [master]
jobs:
build-and-test:
runs-on: ubuntu-latest
+ services:
+ postgres:
+ image: postgres:16-alpine
+ env:
+ POSTGRES_DB: kooltap
+ POSTGRES_USER: kooltap
+ POSTGRES_PASSWORD: kooltap
+ ports:
+ - 5432:5432
+ options: >-
+ --health-cmd "pg_isready -U kooltap"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 10
+
+ env:
+ DATABASE_URL: postgresql://kooltap:kooltap@localhost:5432/kooltap
+ JWT_SECRET: ci-secret
+
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v4
with:
- version: 8
+ version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- - run: pnpm install
+ - run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: cd apps/api && npx prisma generate
- - name: Lint
- run: pnpm lint
- continue-on-error: true
+ - name: Build shared package
+ run: pnpm --filter @kooltap/shared run build
- name: TypeScript check
run: pnpm typecheck
- continue-on-error: true
- - name: Run migrations
- run: cd apps/api && npx prisma migrate dev --name ci-init
+ # Uses the still-empty database as shadow database, so it must run before the migrations
+ - name: Check schema matches migrations
+ run: cd apps/api && npx prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url "$DATABASE_URL" --exit-code
+
+ - name: Apply migrations
+ run: cd apps/api && npx prisma migrate deploy
+
+ - name: Unit tests
+ run: pnpm test
+
+ - name: E2E tests
+ run: pnpm test:e2e
- name: Build
- run: pnpm build
\ No newline at end of file
+ run: pnpm build
diff --git a/.gitignore b/.gitignore
index 2e7e379..235dc66 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,6 @@ dist/
*.db
*.db-journal
coverage/
-.turbo/
\ No newline at end of file
+.turbo/
+*.tsbuildinfo
+.idea/
diff --git a/.idea/.gitignore b/.idea/.gitignore
deleted file mode 100644
index 30cf57e..0000000
--- a/.idea/.gitignore
+++ /dev/null
@@ -1,10 +0,0 @@
-# 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
diff --git a/.idea/kooltab_backoffice.iml b/.idea/kooltab_backoffice.iml
deleted file mode 100644
index c956989..0000000
--- a/.idea/kooltab_backoffice.iml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
deleted file mode 100644
index d4c3a9d..0000000
--- a/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
deleted file mode 100644
index 35eb1dd..0000000
--- a/.idea/vcs.xml
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index f0d837a..47d80c6 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ A production-quality remote tab control system for venues. Allows bar staff to m
## Architecture
- **Monorepo** managed with pnpm
-- **NestJS** backend API with Prisma ORM (SQLite for dev, PostgreSQL-ready)
+- **NestJS** backend API with Prisma ORM on PostgreSQL
- **Next.js** dashboard with App Router, TanStack Query, and shadcn/ui
- **Shared** TypeScript types package
@@ -95,4 +95,4 @@ docker compose up
## Production Notes
-See [docs/production.md](docs/production.md) for production deployment and SQLite → PostgreSQL migration guide.
\ No newline at end of file
+See [docs/production.md](docs/production.md) for production deployment.
\ No newline at end of file
diff --git a/apps/api/package.json b/apps/api/package.json
index e8e13f2..d8f611f 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -15,7 +15,6 @@
"db:migrate": "prisma migrate dev",
"db:seed": "ts-node prisma/seed.ts",
"db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts",
- "db:use-sqlite": "cp prisma/schema.sqlite.prisma prisma/schema.prisma",
"clean": "rimraf dist"
},
"dependencies": {
@@ -55,6 +54,7 @@
"@types/jest": "^29.5.11",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0",
+ "@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.7",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
diff --git a/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql b/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql
new file mode 100644
index 0000000..786ced1
--- /dev/null
+++ b/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql
@@ -0,0 +1,27 @@
+-- PlannedTab: match customers case-insensitively
+ALTER TABLE "PlannedTab" ADD COLUMN "customerNameKey" TEXT;
+
+UPDATE "PlannedTab" SET "customerNameKey" = lower(trim(regexp_replace("customerName", '\s+', ' ', 'g')));
+
+-- Names that only differ in case or spacing keep the oldest row as the match; later ones get a unique key
+UPDATE "PlannedTab" p SET "customerNameKey" = p."customerNameKey" || '#' || p."id"
+WHERE EXISTS (
+ SELECT 1 FROM "PlannedTab" o
+ WHERE o."venueId" = p."venueId"
+ AND o."customerNameKey" = p."customerNameKey"
+ AND (o."createdAt", o."id") < (p."createdAt", p."id")
+);
+
+ALTER TABLE "PlannedTab" ALTER COLUMN "customerNameKey" SET NOT NULL;
+
+DROP INDEX "PlannedTab_venueId_customerName_key";
+
+CREATE UNIQUE INDEX "PlannedTab_venueId_customerNameKey_key" ON "PlannedTab"("venueId", "customerNameKey");
+
+-- RemoteCommand: keep who the tab belonged to, the tab itself disappears once closed
+ALTER TABLE "RemoteCommand" ADD COLUMN "customerName" TEXT,
+ADD COLUMN "amountInCents" INTEGER;
+
+UPDATE "RemoteCommand" c SET "customerName" = s."customerName", "amountInCents" = s."totalInCents"
+FROM "OpenTabSnapshot" s
+WHERE s."deviceId" = c."deviceId" AND s."tabId" = c."targetTabId";
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index d8d9d65..1e56944 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -34,7 +34,7 @@ model Device {
revokedAt DateTime?
createdAt DateTime @default(now())
- venue Venue @relation(fields: [venueId], references: [id])
+ venue Venue @relation(fields: [venueId], references: [id])
tabSnapshots OpenTabSnapshot[]
tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[]
@@ -57,12 +57,12 @@ model ClientUser {
}
model RegistrationCode {
- id String @id @default(uuid())
+ id String @id @default(uuid())
venueId String
- code String @unique
+ code String @unique
expiresAt DateTime
usedAt DateTime?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@ -106,6 +106,8 @@ model RemoteCommand {
venueId String
deviceId String
targetTabId String
+ customerName String?
+ amountInCents Int?
type String @default("close_tab")
expectedVersion Int
paymentMethod String
@@ -138,44 +140,46 @@ model AuditLog {
}
model RefreshToken {
- id String @id @default(uuid())
+ id String @id @default(uuid())
userId String
- tokenHash String @unique
+ tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
- createdAt DateTime @default(now())
+ createdAt DateTime @default(now())
@@index([userId])
}
model PlannedTab {
- id String @id @default(uuid())
- venueId String
- customerName String
- email String?
- position Int @default(0)
- createdAt DateTime @default(now())
+ id String @id @default(uuid())
+ venueId String
+ customerName String
+ // Lowercased, whitespace-collapsed name: customers are matched on this everywhere
+ customerNameKey String
+ email String?
+ position Int @default(0)
+ createdAt DateTime @default(now())
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
- deliveredAt DateTime?
+ deliveredAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
- @@unique([venueId, customerName])
+ @@unique([venueId, customerNameKey])
@@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")
+ 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])
-}
\ No newline at end of file
+}
diff --git a/apps/api/prisma/schema.sqlite.prisma b/apps/api/prisma/schema.sqlite.prisma
deleted file mode 100644
index 27d7d2c..0000000
--- a/apps/api/prisma/schema.sqlite.prisma
+++ /dev/null
@@ -1,158 +0,0 @@
-datasource db {
- provider = "sqlite"
- url = env("DATABASE_URL")
-}
-
-generator client {
- provider = "prisma-client-js"
-}
-
-model Venue {
- id String @id @default(uuid())
- name String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-
- devices Device[]
- clientUsers ClientUser[]
- tabOwnerships TabOwnership[]
- remoteCommands RemoteCommand[]
- auditLogs AuditLog[]
- regCodes RegistrationCode[]
- plannedTabs PlannedTab[]
-}
-
-model Device {
- id String @id @default(uuid())
- venueId String @unique
- name String
- tokenHash String @unique
- tokenPrefix String
- tokenExpiresAt DateTime
- lastSeenAt DateTime @default(now())
- revokedAt DateTime?
- createdAt DateTime @default(now())
-
- venue Venue @relation(fields: [venueId], references: [id])
- tabSnapshots OpenTabSnapshot[]
- tabOwnerships TabOwnership[]
- remoteCommands RemoteCommand[]
-
- @@index([venueId, revokedAt])
-}
-
-model ClientUser {
- id String @id @default(uuid())
- venueId String
- email String @unique
- passwordHash String
- name String
- role String @default("viewer")
- createdAt DateTime @default(now())
-
- venue Venue @relation(fields: [venueId], references: [id])
-
- @@index([venueId])
-}
-
-model RegistrationCode {
- id String @id @default(uuid())
- venueId String
- code String @unique
- expiresAt DateTime
- usedAt DateTime?
- createdAt DateTime @default(now())
-
- venue Venue @relation(fields: [venueId], references: [id])
-
- @@index([venueId])
-}
-
-model OpenTabSnapshot {
- id String @id @default(uuid())
- deviceId String
- tabId String
- customerName String
- version Int
- totalInCents Int
- itemCount Int
- reportedAt DateTime
-
- device Device @relation(fields: [deviceId], references: [id])
-
- @@unique([deviceId, tabId])
- @@index([deviceId])
- @@index([tabId, reportedAt])
-}
-
-model TabOwnership {
- tabId String
- venueId String
- deviceId String
- lastReportedAt DateTime
-
- venue Venue @relation(fields: [venueId], references: [id])
- device Device @relation(fields: [deviceId], references: [id])
-
- @@id([venueId, tabId])
-}
-
-model RemoteCommand {
- id String @id @default(uuid())
- venueId String
- deviceId String
- targetTabId String
- type String @default("close_tab")
- expectedVersion Int
- paymentMethod String
- requestedBy String
- status String @default("pending")
- result String?
- acknowledgedAt DateTime?
- createdAt DateTime @default(now())
- expiresAt DateTime
-
- venue Venue @relation(fields: [venueId], references: [id])
- device Device @relation(fields: [deviceId], references: [id])
-
- @@index([deviceId, status, createdAt])
- @@index([venueId, status, createdAt])
-}
-
-model AuditLog {
- id String @id @default(uuid())
- venueId String
- actorType String
- actorId String
- action String
- details String?
- createdAt DateTime @default(now())
-
- venue Venue @relation(fields: [venueId], references: [id])
-
- @@index([venueId, createdAt])
-}
-
-model RefreshToken {
- id String @id @default(uuid())
- userId String
- tokenHash String @unique
- expiresAt DateTime
- revokedAt DateTime?
- createdAt DateTime @default(now())
-
- @@index([userId])
-}
-
-model PlannedTab {
- id String @id @default(uuid())
- venueId String
- customerName String
- position Int @default(0)
- createdAt DateTime @default(now())
-
- venue Venue @relation(fields: [venueId], references: [id])
-
- @@unique([venueId, customerName])
- @@index([venueId])
-}
\ No newline at end of file
diff --git a/apps/api/prisma/setup.js b/apps/api/prisma/setup.js
index c3dc9c8..a5734c8 100644
--- a/apps/api/prisma/setup.js
+++ b/apps/api/prisma/setup.js
@@ -1,60 +1,70 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
-const crypto = require('crypto');
const prisma = new PrismaClient();
-const VENUE_NAME = process.env.SETUP_VENUE_NAME || process.argv[2] || 'My Venue';
-const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || process.argv[3] || 'admin@example.com';
-const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || process.argv[4] || 'admin123';
+const args = process.argv.slice(2).filter((arg) => arg !== '--force');
+const FORCE = process.argv.includes('--force');
+const VENUE_NAME = process.env.SETUP_VENUE_NAME || args[0] || 'My Venue';
+const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || args[1];
+const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || args[2];
async function main() {
console.log('=== KoolTap Production Setup ===\n');
- // Wipe everything
- await prisma.auditLog.deleteMany();
- await prisma.remoteCommand.deleteMany();
- await prisma.openTabSnapshot.deleteMany();
- await prisma.tabOwnership.deleteMany();
- await prisma.refreshToken.deleteMany();
- await prisma.registrationCode.deleteMany();
- await prisma.device.deleteMany();
- await prisma.clientUser.deleteMany();
- await prisma.venue.deleteMany();
- console.log('✓ Cleared existing data');
+ const venues = await prisma.venue.count();
+ if (venues > 0 && !FORCE) {
+ console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`);
+ return;
+ }
+
+ if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
+ throw new Error('Usage: node prisma/setup.js "" [--force]');
+ }
+ if (ADMIN_PASSWORD.length < 10) {
+ throw new Error('The admin password must be at least 10 characters');
+ }
- // Create venue
const venueId = uuidv4();
- await prisma.venue.create({ data: { id: venueId, name: VENUE_NAME } });
- console.log(`✓ Created venue: ${VENUE_NAME}`);
-
- // Create admin user
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
- await prisma.clientUser.create({
- data: {
- id: uuidv4(),
- venueId,
- email: ADMIN_EMAIL,
- passwordHash: hash,
- name: 'Admin',
- role: 'admin',
- },
- });
- console.log(`✓ Created admin: ${ADMIN_EMAIL}`);
- console.log('\n=== Setup complete ===');
+ await prisma.$transaction(async (tx) => {
+ await tx.auditLog.deleteMany();
+ await tx.tabReminder.deleteMany();
+ await tx.plannedTab.deleteMany();
+ await tx.remoteCommand.deleteMany();
+ await tx.openTabSnapshot.deleteMany();
+ await tx.tabOwnership.deleteMany();
+ await tx.refreshToken.deleteMany();
+ await tx.registrationCode.deleteMany();
+ await tx.device.deleteMany();
+ await tx.clientUser.deleteMany();
+ await tx.venue.deleteMany();
+
+ await tx.venue.create({ data: { id: venueId, name: VENUE_NAME } });
+ await tx.clientUser.create({
+ data: {
+ id: uuidv4(),
+ venueId,
+ email: ADMIN_EMAIL,
+ passwordHash: hash,
+ name: 'Admin',
+ role: 'admin',
+ },
+ });
+ });
+
+ console.log('=== Setup complete ===');
console.log(` Venue: ${VENUE_NAME}`);
console.log(` Venue ID: ${venueId}`);
console.log(` Admin: ${ADMIN_EMAIL}`);
- console.log(` Password: ${ADMIN_PASSWORD}`);
- console.log('\nSave the Venue ID — the Flutter app needs it to find your venue.');
- console.log('Login at https://kooltap.com with the admin credentials above.');
-
- await prisma.$disconnect();
+ console.log('\nLog in to the dashboard with the admin credentials you provided.');
}
-main().catch((err) => {
- console.error('Setup failed:', err);
- process.exit(1);
-});
\ No newline at end of file
+main()
+ .catch((err) => {
+ console.error('Setup failed:', err.message ?? err);
+ process.exitCode = 1;
+ })
+ .finally(() => prisma.$disconnect());
diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts
index ced1668..2a51db3 100644
--- a/apps/api/src/auth/auth.module.ts
+++ b/apps/api/src/auth/auth.module.ts
@@ -12,7 +12,7 @@ import { getJwtSecret } from './jwt-secret';
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: getJwtSecret(),
- signOptions: { expiresIn: '24h' },
+ signOptions: { expiresIn: '15m' },
}),
],
controllers: [AuthController],
diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts
new file mode 100644
index 0000000..1fce3e8
--- /dev/null
+++ b/apps/api/src/auth/auth.service.spec.ts
@@ -0,0 +1,72 @@
+import * as crypto from 'crypto';
+import * as bcrypt from 'bcrypt';
+import { UnauthorizedException } from '@nestjs/common';
+import { JwtService } from '@nestjs/jwt';
+import { AuthService } from './auth.service';
+
+const hash = (token: string) => crypto.createHash('sha256').update(token).digest('hex');
+
+type StoredToken = { id: string; userId: string; tokenHash: string; expiresAt: Date; revokedAt: Date | null };
+
+function createService() {
+ const tokens: StoredToken[] = [];
+ const user = { id: 'u1', email: 'admin@example.com', name: 'Admin', role: 'admin', venueId: 'v1', passwordHash: bcrypt.hashSync('correct-password', 4) };
+ const prisma = {
+ clientUser: {
+ findUnique: jest.fn(async ({ where }: any) => (where.id === user.id || where.email === user.email ? user : null)),
+ },
+ refreshToken: {
+ create: jest.fn(async ({ data }: any) => {
+ tokens.push({ id: `rt${tokens.length}`, revokedAt: null, ...data });
+ }),
+ findUnique: jest.fn(async ({ where }: any) => tokens.find((t) => t.tokenHash === where.tokenHash) ?? null),
+ updateMany: jest.fn(async ({ where, data }: any) => {
+ const matches = tokens.filter((t) =>
+ (where.id === undefined || t.id === where.id)
+ && (where.tokenHash === undefined || t.tokenHash === where.tokenHash)
+ && (where.revokedAt !== null || t.revokedAt === null));
+ for (const t of matches) Object.assign(t, data);
+ return { count: matches.length };
+ }),
+ },
+ };
+ const service = new AuthService(prisma as any, new JwtService({ secret: 'test' }), { log: jest.fn() } as any, { notify: jest.fn() } as any);
+ return { service, tokens };
+}
+
+describe('AuthService', () => {
+ it('rejects a wrong password', async () => {
+ const { service } = createService();
+
+ await expect(service.login('admin@example.com', 'wrong-password')).rejects.toBeInstanceOf(UnauthorizedException);
+ });
+
+ it('rotates the refresh token on use', async () => {
+ const { service, tokens } = createService();
+ const { refreshToken } = await service.login('admin@example.com', 'correct-password');
+
+ const refreshed = await service.refresh(refreshToken);
+
+ expect(refreshed.refreshToken).not.toBe(refreshToken);
+ expect(tokens.find((t) => t.tokenHash === hash(refreshToken))?.revokedAt).toBeInstanceOf(Date);
+ });
+
+ it('accepts a refresh token only once, even for simultaneous requests', async () => {
+ const { service } = createService();
+ const { refreshToken } = await service.login('admin@example.com', 'correct-password');
+
+ const results = await Promise.allSettled([service.refresh(refreshToken), service.refresh(refreshToken)]);
+
+ expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
+ expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1);
+ });
+
+ it('rejects a refresh token after logout', async () => {
+ const { service } = createService();
+ const { refreshToken } = await service.login('admin@example.com', 'correct-password');
+
+ await service.logout(refreshToken);
+
+ await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException);
+ });
+});
diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts
index b51851b..c484514 100644
--- a/apps/api/src/auth/auth.service.ts
+++ b/apps/api/src/auth/auth.service.ts
@@ -64,6 +64,15 @@ export class AuthService {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
+ // Revoke first and atomically: of two requests with the same token, only one gets new tokens
+ const claimed = await this.prisma.refreshToken.updateMany({
+ where: { id: stored.id, revokedAt: null },
+ data: { revokedAt: new Date() },
+ });
+ if (claimed.count === 0) {
+ throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
+ }
+
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
@@ -76,11 +85,6 @@ export class AuthService {
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
- await this.prisma.refreshToken.update({
- where: { id: stored.id },
- data: { revokedAt: new Date() },
- });
-
await this.prisma.refreshToken.create({
data: {
userId: user.id,
diff --git a/apps/api/src/commands/commands.service.spec.ts b/apps/api/src/commands/commands.service.spec.ts
new file mode 100644
index 0000000..9e18930
--- /dev/null
+++ b/apps/api/src/commands/commands.service.spec.ts
@@ -0,0 +1,98 @@
+import { ConflictException, NotFoundException } from '@nestjs/common';
+import { Prisma } from '@prisma/client';
+import { CommandsService } from './commands.service';
+
+const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
+const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
+
+function createService({
+ ownership = { deviceId: 'd1' } as object | null,
+ device = { id: 'd1', revokedAt: null, lastSeenAt: new Date() } as object | null,
+ snapshot = { customerName: 'Jan', totalInCents: 2500, version: 3 } as object | null,
+ pending = null as object | null,
+} = {}) {
+ const tx = {
+ tabOwnership: { findUnique: jest.fn().mockResolvedValue(ownership) },
+ device: { findUnique: jest.fn().mockResolvedValue(device) },
+ openTabSnapshot: { findUnique: jest.fn().mockResolvedValue(snapshot) },
+ remoteCommand: {
+ findFirst: jest.fn().mockResolvedValue(pending),
+ create: jest.fn(async ({ data }) => data),
+ },
+ auditLog: { create: jest.fn() },
+ };
+ const prisma = { $transaction: jest.fn((fn: (t: typeof tx) => unknown) => Promise.resolve().then(() => fn(tx))) };
+ const realtime = { notify: jest.fn() };
+ const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any);
+ return { service, prisma, tx, realtime };
+}
+
+describe('CommandsService.create', () => {
+ it('queues a close command with the customer and amount it was sent for', async () => {
+ const { service, prisma, tx, realtime } = createService();
+
+ const result = await service.create('v1', dto, user);
+
+ expect(result.status).toBe('pending');
+ expect(tx.remoteCommand.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ venueId: 'v1',
+ deviceId: 'd1',
+ targetTabId: 't1',
+ customerName: 'Jan',
+ amountInCents: 2500,
+ expectedVersion: 3,
+ paymentMethod: 'cash',
+ requestedBy: 'Staff',
+ }),
+ });
+ expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { isolationLevel: 'Serializable' });
+ expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands');
+ });
+
+ it('rejects a tab no device owns', async () => {
+ const { service } = createService({ ownership: null });
+
+ await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(NotFoundException);
+ });
+
+ it('rejects a tab that changed since it was loaded', async () => {
+ const { service, tx } = createService({ snapshot: { customerName: 'Jan', totalInCents: 3000, version: 4 } });
+
+ await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'version_mismatch' } });
+ expect(tx.remoteCommand.create).not.toHaveBeenCalled();
+ });
+
+ it('rejects a tab whose device was revoked', async () => {
+ const { service } = createService({ device: { id: 'd1', revokedAt: new Date(), lastSeenAt: new Date() } });
+
+ await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'device_revoked' } });
+ });
+
+ it('rejects a second command while one is pending', async () => {
+ const { service, tx } = createService({ pending: { id: 'c0' } });
+
+ await expect(service.create('v1', dto, user)).rejects.toMatchObject({ response: { code: 'command_already_pending' } });
+ expect(tx.remoteCommand.create).not.toHaveBeenCalled();
+ });
+
+ it('reports a lost race between two simultaneous requests as already pending', async () => {
+ const { service, prisma } = createService();
+ prisma.$transaction.mockRejectedValue(
+ new Prisma.PrismaClientKnownRequestError('serialization failure', { code: 'P2034', clientVersion: 'test' }),
+ );
+
+ const error = await service.create('v1', dto, user).catch((e) => e);
+
+ expect(error).toBeInstanceOf(ConflictException);
+ expect(error.response.code).toBe('command_already_pending');
+ });
+
+ it('warns when the terminal has not been seen recently', async () => {
+ const { service } = createService({ device: { id: 'd1', revokedAt: null, lastSeenAt: new Date(Date.now() - 60 * 60 * 1000) } });
+
+ const result = await service.create('v1', dto, user);
+
+ expect(result).toHaveProperty('warning');
+ });
+});
diff --git a/apps/api/src/commands/commands.service.ts b/apps/api/src/commands/commands.service.ts
index bcbd778..57d65cf 100644
--- a/apps/api/src/commands/commands.service.ts
+++ b/apps/api/src/commands/commands.service.ts
@@ -5,6 +5,7 @@ import {
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
+import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
@@ -35,7 +36,7 @@ export class CommandsService {
});
}
- // Use transaction for consistency
+ // Serializable so two simultaneous requests for the same tab can't both pass the pending check
const result = await this.prisma.$transaction(async (tx) => {
// Resolve tab ownership
const ownership = await tx.tabOwnership.findUnique({
@@ -93,6 +94,8 @@ export class CommandsService {
venueId,
deviceId: device.id,
targetTabId: dto.tabId,
+ customerName: snapshot?.customerName ?? null,
+ amountInCents: snapshot?.totalInCents ?? null,
type: dto.type || 'close_tab',
expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod,
@@ -130,6 +133,14 @@ export class CommandsService {
status: command.status,
...warning,
};
+ }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }).catch((error) => {
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034') {
+ throw new ConflictException({
+ code: 'command_already_pending',
+ message: 'A command for this tab is already pending',
+ });
+ }
+ throw error;
});
this.realtime.notify(venueId, 'commands');
@@ -152,6 +163,8 @@ export class CommandsService {
select: {
id: true,
targetTabId: true,
+ customerName: true,
+ amountInCents: true,
type: true,
status: true,
paymentMethod: true,
@@ -173,6 +186,8 @@ export class CommandsService {
commands: commands.map((c) => ({
id: c.id,
tabId: c.targetTabId,
+ customerName: c.customerName,
+ amountInCents: c.amountInCents,
type: c.type,
status: c.status,
paymentMethod: c.paymentMethod,
diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts
index fc77847..ceeeb67 100644
--- a/apps/api/src/devices/devices.service.spec.ts
+++ b/apps/api/src/devices/devices.service.spec.ts
@@ -1,12 +1,12 @@
import { Prisma } from '@prisma/client';
import { DevicesService } from './devices.service';
-type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number };
+type Snapshot = { tabId: string; customerName: string; version: number; totalInCents: number; itemCount: number; items?: unknown };
function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
const tx = {
- tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() },
- openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() },
+ tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
+ openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() },
};
@@ -27,13 +27,13 @@ const device = { id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(
const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
describe('DevicesService.sync planned customers', () => {
- it('marks planned customers delivered once the device reports a tab with their name', async () => {
+ it('marks planned customers delivered once the device reports a tab with their name, ignoring case', async () => {
const { service, tx } = createService();
- await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', 'Piet')] });
+ await service.sync(device, { deviceId: 'd1', openTabs: [tab('t1', 'Jan'), tab('t2', ' piet Peeters')] });
expect(tx.plannedTab.updateMany).toHaveBeenCalledWith({
- where: { venueId: 'v1', deliveredAt: null, customerName: { in: ['Jan', 'Piet'] } },
+ where: { venueId: 'v1', deliveredAt: null, customerNameKey: { in: ['jan', 'piet peeters'] } },
data: { deliveredAt: expect.any(Date) },
});
});
@@ -125,3 +125,32 @@ describe('DevicesService.sync tab lines', () => {
expect(tx.openTabSnapshot.upsert.mock.calls[0][0].update.items).toBe(Prisma.JsonNull);
});
});
+
+describe('DevicesService.sync writes', () => {
+ const items = [{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }];
+ const jan = { id: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items };
+ // JSONB doesn't keep key order
+ const janSnapshot = { tabId: 't1', customerName: 'Jan', version: 2, totalInCents: 2500, itemCount: 10, items: [{ quantity: 10, productName: 'Cola', unitPriceInCents: 250 }] };
+
+ it('only bumps the report time of unchanged tabs', async () => {
+ const { service, tx } = createService({ previous: [janSnapshot] });
+
+ await service.sync(device, { deviceId: 'd1', openTabs: [jan] });
+
+ expect(tx.openTabSnapshot.upsert).not.toHaveBeenCalled();
+ expect(tx.tabOwnership.upsert).not.toHaveBeenCalled();
+ expect(tx.openTabSnapshot.updateMany).toHaveBeenCalledWith({
+ where: { deviceId: 'd1', tabId: { in: ['t1'] } },
+ data: { reportedAt: expect.any(Date) },
+ });
+ });
+
+ it('writes a tab whose lines changed', async () => {
+ const { service, tx } = createService({ previous: [janSnapshot] });
+
+ await service.sync(device, { deviceId: 'd1', openTabs: [{ ...jan, items: [{ ...items[0], quantity: 11 }] }] });
+
+ expect(tx.openTabSnapshot.upsert).toHaveBeenCalledTimes(1);
+ expect(tx.openTabSnapshot.updateMany).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts
index 0289232..ead85de 100644
--- a/apps/api/src/devices/devices.service.ts
+++ b/apps/api/src/devices/devices.service.ts
@@ -10,7 +10,8 @@ import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { Prisma } from '@prisma/client';
-import { OpenTabDto, SyncDto } from './dto/sync.dto';
+import { OpenTabDto, OpenTabItemDto, SyncDto } from './dto/sync.dto';
+import { customerNameKey } from '../planned-tabs/customer-name';
import { AckCommandDto } from './dto/ack-command.dto';
@Injectable()
@@ -36,22 +37,28 @@ export class DevicesService {
// Devices sync often, so dashboards are only told about what actually changed
const previous = await this.prisma.openTabSnapshot.findMany({
where: { deviceId: device.id },
- select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true },
+ select: { tabId: true, customerName: true, version: true, totalInCents: true, itemCount: true, items: true },
});
const previousById = new Map(previous.map((s) => [s.tabId, s]));
- const tabsChanged =
- previous.length !== openTabs.length ||
- openTabs.some((t) => {
- const p = previousById.get(t.id);
- return !p || p.customerName !== t.customerName || p.version !== t.version
- || p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount;
- });
- let plannedDelivered = 0;
// Ownership changes are detected up front so audit logging stays outside the transaction
const takenOver = await this.prisma.tabOwnership.findMany({
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } },
});
+ const takenOverIds = new Set(takenOver.map((o) => o.tabId));
+
+ // Only new or changed tabs are written; unchanged ones just get their report time bumped
+ const changedTabs = openTabs.filter((t) => {
+ const p = previousById.get(t.id);
+ return !p || takenOverIds.has(t.id) || p.customerName !== t.customerName || p.version !== t.version
+ || p.totalInCents !== t.totalInCents || p.itemCount !== t.itemCount
+ || !sameItems(p.items as StoredItem[] | null, t.items);
+ });
+ const changedIds = new Set(changedTabs.map((t) => t.id));
+ const unchangedIds = openTabIds.filter((id) => !changedIds.has(id));
+ const tabsChanged = previous.length !== openTabs.length || changedTabs.length > 0;
+ let plannedDelivered = 0;
+
for (const ownership of takenOver) {
await this.auditService.log(
device.venueId,
@@ -71,7 +78,18 @@ export class DevicesService {
where: { deviceId: device.id, tabId: { notIn: openTabIds } },
});
- for (const tab of openTabs) {
+ if (unchangedIds.length > 0) {
+ await tx.openTabSnapshot.updateMany({
+ where: { deviceId: device.id, tabId: { in: unchangedIds } },
+ data: { reportedAt: now },
+ });
+ await tx.tabOwnership.updateMany({
+ where: { venueId: device.venueId, deviceId: device.id, tabId: { in: unchangedIds } },
+ data: { lastReportedAt: now },
+ });
+ }
+
+ for (const tab of changedTabs) {
await tx.openTabSnapshot.upsert({
where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id },
@@ -111,14 +129,12 @@ export class DevicesService {
});
}
- // A planned customer counts as delivered once the device reports a tab with that exact name
- // (the terminal matches names exactly when creating tabs)
if (openTabs.length > 0) {
({ count: plannedDelivered } = await tx.plannedTab.updateMany({
where: {
venueId: device.venueId,
deliveredAt: null,
- customerName: { in: openTabs.map((t) => t.customerName) },
+ customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) },
},
data: { deliveredAt: now },
}));
@@ -279,8 +295,21 @@ export class DevicesService {
}
}
+type StoredItem = { productName: string; quantity: number; unitPriceInCents: number };
+
+function sameItems(stored: StoredItem[] | null, reported: OpenTabItemDto[] | undefined): boolean {
+ if (!stored || !reported) return !stored && !reported;
+ return stored.length === reported.length && stored.every((s, i) =>
+ s.productName === reported[i].productName
+ && s.quantity === reported[i].quantity
+ && s.unitPriceInCents === reported[i].unitPriceInCents);
+}
+
+function storedItems(items: OpenTabItemDto[]): StoredItem[] {
+ return items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
+}
+
/** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
function snapshotItems(tab: OpenTabDto) {
- if (!tab.items) return Prisma.JsonNull;
- return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
+ return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 7726550..3570778 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -39,20 +39,23 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
- const config = new DocumentBuilder()
- .setTitle('KoolTap Remote Tab Control API')
- .setDescription('API for managing remote tab control in venues')
- .setVersion('1.0')
- .addBearerAuth()
- .build();
+ const swagger = process.env.NODE_ENV !== 'production';
+ if (swagger) {
+ const config = new DocumentBuilder()
+ .setTitle('KoolTap Remote Tab Control API')
+ .setDescription('API for managing remote tab control in venues')
+ .setVersion('1.0')
+ .addBearerAuth()
+ .build();
- const document = SwaggerModule.createDocument(app, config);
- SwaggerModule.setup('api/docs', app, document);
+ const document = SwaggerModule.createDocument(app, config);
+ SwaggerModule.setup('api/docs', app, document);
+ }
const port = process.env.PORT || 3001;
await app.listen(port);
console.log(`API running on http://localhost:${port}`);
- console.log(`Swagger docs at http://localhost:${port}/api/docs`);
+ if (swagger) console.log(`Swagger docs at http://localhost:${port}/api/docs`);
}
bootstrap();
\ No newline at end of file
diff --git a/apps/api/src/planned-tabs/customer-name.ts b/apps/api/src/planned-tabs/customer-name.ts
new file mode 100644
index 0000000..d6eb519
--- /dev/null
+++ b/apps/api/src/planned-tabs/customer-name.ts
@@ -0,0 +1,8 @@
+export function normalizeCustomerName(name: string): string {
+ return name.trim().replace(/\s+/g, ' ');
+}
+
+/** The key customers are matched on: names differing only in case or spacing are the same customer. */
+export function customerNameKey(name: string): string {
+ return normalizeCustomerName(name).toLowerCase();
+}
diff --git a/apps/api/src/planned-tabs/planned-tabs.service.ts b/apps/api/src/planned-tabs/planned-tabs.service.ts
index 4c3bdca..84f6147 100644
--- a/apps/api/src/planned-tabs/planned-tabs.service.ts
+++ b/apps/api/src/planned-tabs/planned-tabs.service.ts
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException }
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { v4 as uuidv4 } from 'uuid';
+import { customerNameKey, normalizeCustomerName } from './customer-name';
@Injectable()
export class PlannedTabsService {
@@ -20,12 +21,12 @@ export class PlannedTabsService {
}
async create(venueId: string, customerName: string, email?: string) {
- const name = customerName.trim();
+ const name = normalizeCustomerName(customerName);
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
+ const key = customerNameKey(name);
- // Check for duplicate
const existing = await this.prisma.plannedTab.findUnique({
- where: { venueId_customerName: { venueId, customerName: name } },
+ where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
});
if (existing) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
@@ -42,6 +43,7 @@ export class PlannedTabsService {
id: uuidv4(),
venueId,
customerName: name,
+ customerNameKey: key,
email: email?.trim() || null,
position: (last?.position ?? -1) + 1,
},
@@ -53,14 +55,15 @@ export class PlannedTabsService {
}
async update(venueId: string, id: string, customerName: string, email?: string) {
- const name = customerName.trim();
+ const name = normalizeCustomerName(customerName);
if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' });
+ const key = customerNameKey(name);
const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } });
if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' });
const duplicate = await this.prisma.plannedTab.findUnique({
- where: { venueId_customerName: { venueId, customerName: name } },
+ where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
});
if (duplicate && duplicate.id !== id) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
@@ -68,7 +71,7 @@ export class PlannedTabsService {
const result = await this.prisma.plannedTab.update({
where: { id },
- data: { customerName: name, email: email?.trim() || null },
+ data: { customerName: name, customerNameKey: key, email: email?.trim() || null },
select: { id: true, customerName: true, email: true, position: true, createdAt: true },
});
@@ -85,16 +88,16 @@ export class PlannedTabsService {
async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') {
const incoming = new Map();
for (const entry of entries) {
- const customerName = entry.customerName.trim().replace(/\s+/g, ' ');
+ const customerName = normalizeCustomerName(entry.customerName);
if (!customerName) continue;
- const key = customerName.toLowerCase();
+ const key = customerNameKey(customerName);
const previous = incoming.get(key);
incoming.set(key, { customerName, email: entry.email || previous?.email });
}
const result = await 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]));
+ const existingByKey = new Map(existing.map((t) => [customerNameKey(t.customerName), t]));
let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
let created = 0;
@@ -106,7 +109,7 @@ export class PlannedTabsService {
const current = existingByKey.get(key);
if (!current) {
await tx.plannedTab.create({
- data: { id: uuidv4(), venueId, customerName: entry.customerName, email: entry.email ?? null, position: nextPosition++ },
+ data: { id: uuidv4(), venueId, customerName: entry.customerName, customerNameKey: key, email: entry.email ?? null, position: nextPosition++ },
});
created++;
} else if (entry.email && entry.email !== current.email) {
@@ -118,7 +121,7 @@ export class PlannedTabsService {
}
if (mode === 'replace') {
- const toRemove = existing.filter((t) => !incoming.has(t.customerName.toLowerCase())).map((t) => t.id);
+ const toRemove = existing.filter((t) => !incoming.has(customerNameKey(t.customerName))).map((t) => t.id);
if (toRemove.length > 0) {
removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count;
}
diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts
index 5906831..9e24c81 100644
--- a/apps/api/src/reminders/reminders.controller.ts
+++ b/apps/api/src/reminders/reminders.controller.ts
@@ -1,4 +1,4 @@
-import { Controller, Get, Post, Param, UseGuards, Req } from '@nestjs/common';
+import { Controller, Get, Post, Param, UseGuards } 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';
@@ -21,11 +21,14 @@ export class RemindersController {
}
@Post('send')
- @ApiOperation({ summary: 'Send payment reminders via Resend' })
+ @UseGuards(RolesGuard)
+ @Roles('admin')
+ @ApiOperation({ summary: 'Send payment reminders via Resend (admin only)' })
@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');
+ @ApiResponse({ status: 409, description: 'Reminders are already being sent' })
+ async send(@Param('venueId') venueId: string) {
+ return this.service.sendReminders(venueId);
}
@Get()
diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts
index 5251717..af514f4 100644
--- a/apps/api/src/reminders/reminders.service.spec.ts
+++ b/apps/api/src/reminders/reminders.service.spec.ts
@@ -1,7 +1,8 @@
+import { ConflictException } from '@nestjs/common';
import { EmailTemplatesService } from '../email/email-templates.service';
import { RemindersService } from './reminders.service';
-function createService(items: unknown) {
+function createService(items: unknown, { plannedName = 'Bram' } = {}) {
const prisma = {
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
openTabSnapshot: {
@@ -9,22 +10,23 @@ function createService(items: unknown) {
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
]),
},
- plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) },
- tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() },
+ plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
+ tabReminder: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn() },
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) },
};
- return new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
+ const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService());
+ return { service, prisma };
}
describe('RemindersService.sendReminders', () => {
const originalFetch = global.fetch;
- let sentBodies: any[];
+ let requests: { url: string; headers: Record; body: any }[];
beforeEach(() => {
process.env.RESEND_API_KEY = 'test-key';
- sentBodies = [];
- global.fetch = jest.fn(async (_url: any, init: any) => {
- sentBodies.push(JSON.parse(init.body));
+ requests = [];
+ global.fetch = jest.fn(async (url: any, init: any) => {
+ requests.push({ url, headers: init.headers, body: JSON.parse(init.body) });
return { ok: true, text: async () => '' } as Response;
}) as any;
});
@@ -35,15 +37,15 @@ describe('RemindersService.sendReminders', () => {
});
it('includes the tab summary with Belgian amounts', async () => {
- const service = createService([
+ const { service } = createService([
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 },
]);
- const result = await service.sendReminders('v1', 'admin@example.com');
+ const result = await service.sendReminders('v1');
expect(result.sent).toBe(1);
- const [email] = sentBodies;
+ const [email] = requests[0].body;
expect(email.to).toBe('bram@example.com');
expect(email.text).toMatch(/10x Cola\s+€\s25,00/);
expect(email.text).toMatch(/2x Chips\s+€\s4,00/);
@@ -52,12 +54,60 @@ describe('RemindersService.sendReminders', () => {
});
it('leaves the summary out when the terminal did not report lines', async () => {
- const service = createService(null);
+ const { service } = createService(null);
- await service.sendReminders('v1', 'admin@example.com');
+ await service.sendReminders('v1');
- const [email] = sentBodies;
+ const [email] = requests[0].body;
expect(email.text).not.toContain('10x');
expect(email.text).toMatch(/€\s29,00/);
});
+
+ it('sends one batch request with an idempotency key and records the reminders', async () => {
+ const { service, prisma } = createService(null);
+
+ await service.sendReminders('v1');
+
+ expect(requests).toHaveLength(1);
+ expect(requests[0].url).toBe('https://api.resend.com/emails/batch');
+ expect(requests[0].headers['Idempotency-Key']).toMatch(/^reminders-[0-9a-f]{64}$/);
+ expect(prisma.tabReminder.createMany).toHaveBeenCalledWith({
+ data: [expect.objectContaining({ tabId: 't1', email: 'bram@example.com', amountInCents: 2900 })],
+ });
+ });
+
+ it('matches planned customers regardless of case and spacing', async () => {
+ const { service } = createService(null, { plannedName: ' bRAM ' });
+
+ const { candidates } = await service.listCandidates('v1');
+
+ expect(candidates.map((c) => c.email)).toEqual(['bram@example.com']);
+ });
+
+ it('refuses a second send for the venue while one is in progress', async () => {
+ const { service } = createService(null);
+
+ const first = service.sendReminders('v1');
+ await expect(service.sendReminders('v1')).rejects.toBeInstanceOf(ConflictException);
+ await first;
+ await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 1 });
+ });
+
+ it('does not record reminders when Resend rejects the batch', async () => {
+ global.fetch = jest.fn(async () => ({ ok: false, text: async () => 'rate limited' }) as Response) as any;
+ const { service, prisma } = createService(null);
+
+ const result = await service.sendReminders('v1');
+
+ expect(result).toMatchObject({ sent: 0, total: 1, errors: [{ customerName: 'Bram', error: 'rate limited' }] });
+ expect(prisma.tabReminder.createMany).not.toHaveBeenCalled();
+ });
+
+ it('lets database errors surface instead of re-sending to everyone', async () => {
+ const { service, prisma } = createService(null);
+ prisma.tabReminder.findMany.mockRejectedValue(new Error('db down'));
+
+ await expect(service.sendReminders('v1')).rejects.toThrow('db down');
+ expect(requests).toHaveLength(0);
+ });
});
diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts
index 766b2e8..7f2a0c0 100644
--- a/apps/api/src/reminders/reminders.service.ts
+++ b/apps/api/src/reminders/reminders.service.ts
@@ -1,16 +1,31 @@
-import { Injectable, BadRequestException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
+import { Injectable, BadRequestException, ConflictException, NotFoundException, InternalServerErrorException } from '@nestjs/common';
+import * as crypto from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service';
+import { customerNameKey } from '../planned-tabs/customer-name';
// 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;
+// Resend accepts at most 100 emails per batch request
+const BATCH_SIZE = 100;
+
+type Candidate = {
+ tabId: string;
+ customerName: string;
+ email: string;
+ amountInCents: number;
+ itemCount: number;
+ items: TabLine[] | null;
+};
+
@Injectable()
export class RemindersService {
private resendApiKey: string;
private fromEmail: string;
+ private sendingVenues = new Set();
constructor(
private prisma: PrismaService,
@@ -21,7 +36,7 @@ export class RemindersService {
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap ';
}
- async listCandidates(venueId: string) {
+ async listCandidates(venueId: string): Promise<{ candidates: Candidate[] }> {
const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId },
});
@@ -32,37 +47,24 @@ export class RemindersService {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
- const latestByTab = new Map(snapshots.map((s) => [s.tabId, s]));
- const emailMap = new Map();
- 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
- }
+ const plannedTabs = await this.prisma.plannedTab.findMany({
+ where: { venueId, email: { not: null } },
+ select: { customerName: true, email: true },
+ });
+ const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!]));
- let sentTabIds = new Set();
- 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 alreadySent = await this.prisma.tabReminder.findMany({
+ where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
+ select: { tabId: true },
+ });
+ const sentTabIds = new Set(alreadySent.map((r) => r.tabId));
- const candidates = [];
- for (const snap of latestByTab.values()) {
+ const candidates: Candidate[] = [];
+ for (const snap of snapshots) {
if (snap.totalInCents <= 0) continue;
if (sentTabIds.has(snap.tabId)) continue;
- const email = emailMap.get(snap.customerName.toLowerCase());
+ const email = emailByKey.get(customerNameKey(snap.customerName));
if (!email) continue;
candidates.push({
tabId: snap.tabId,
@@ -78,13 +80,7 @@ export class RemindersService {
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' };
- }
-
+ async sendReminders(venueId: string) {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
@@ -92,64 +88,71 @@ export class RemindersService {
});
}
+ // A double click or a second admin must not email everyone twice
+ if (this.sendingVenues.has(venueId)) {
+ throw new ConflictException({ code: 'reminders_in_progress', message: 'Reminders are already being sent' });
+ }
+ this.sendingVenues.add(venueId);
+
+ try {
+ return await this.sendToCandidates(venueId);
+ } finally {
+ this.sendingVenues.delete(venueId);
+ }
+ }
+
+ private async sendToCandidates(venueId: string) {
+ const { candidates } = await this.listCandidates(venueId);
+
+ if (candidates.length === 0) {
+ return { sent: 0, total: 0 };
+ }
+
const venueName = await this.venueName(venueId);
let sent = 0;
const errors: { customerName: string; error: string }[] = [];
+ const rendered: { candidate: Candidate; email: RenderedEmail }[] = [];
for (const candidate of candidates) {
try {
- const email = await this.emailTemplates.render('payment-reminder', {
- customerName: candidate.customerName,
- venueName,
- amount: formatEuro(candidate.amountInCents),
- itemCount: candidate.itemCount,
- hasItems: (candidate.items?.length ?? 0) > 0,
- items: (candidate.items ?? []).map((item) => ({
- quantity: item.quantity,
- productName: item.productName,
- unitPrice: formatEuro(item.unitPriceInCents),
- lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
- })),
- });
-
- const error = await this.sendEmail(candidate.email, email);
- if (error) {
- errors.push({ customerName: candidate.customerName, error });
- continue;
- }
-
- await this.prisma.tabReminder.create({
- data: {
- venueId,
- tabId: candidate.tabId,
- customerName: candidate.customerName,
- email: candidate.email,
- amountInCents: candidate.amountInCents,
- },
- });
-
- sent++;
+ rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) });
} catch (err: any) {
errors.push({ customerName: candidate.customerName, error: err.message });
}
}
+ for (let i = 0; i < rendered.length; i += BATCH_SIZE) {
+ const batch = rendered.slice(i, i + BATCH_SIZE);
+ const error = await this.sendBatch(batch.map(({ candidate, email }) => ({ to: candidate.email, email })));
+ if (error) {
+ for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error });
+ continue;
+ }
+
+ await this.prisma.tabReminder.createMany({
+ data: batch.map(({ candidate }) => ({
+ venueId,
+ tabId: candidate.tabId,
+ customerName: candidate.customerName,
+ email: candidate.email,
+ amountInCents: candidate.amountInCents,
+ })),
+ });
+ sent += batch.length;
+ }
+
if (sent > 0) this.realtime.notify(venueId, 'reminders');
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: [] };
- }
+ const reminders = await this.prisma.tabReminder.findMany({
+ where: { venueId },
+ orderBy: { sentAt: 'desc' },
+ take: 100,
+ });
+ return { reminders };
}
async sendTestEmail(venueId: string, plannedTabId: string) {
@@ -186,7 +189,7 @@ export class RemindersService {
throw error;
}
- const error = await this.sendEmail(tab.email, email);
+ const error = await this.resend('/emails', { from: this.fromEmail, to: tab.email, ...email });
if (error) {
throw new BadRequestException({
code: 'email_failed',
@@ -197,15 +200,39 @@ export class RemindersService {
return { ok: true, email: tab.email, customerName: tab.customerName };
}
- /** Sends through Resend; returns Resend's error text, or null when sent. */
- private async sendEmail(to: string, email: RenderedEmail): Promise {
- const res = await fetch('https://api.resend.com/emails', {
+ private renderReminder(candidate: Candidate, venueName: string): Promise {
+ return this.emailTemplates.render('payment-reminder', {
+ customerName: candidate.customerName,
+ venueName,
+ amount: formatEuro(candidate.amountInCents),
+ itemCount: candidate.itemCount,
+ hasItems: (candidate.items?.length ?? 0) > 0,
+ items: (candidate.items ?? []).map((item) => ({
+ quantity: item.quantity,
+ productName: item.productName,
+ unitPrice: formatEuro(item.unitPriceInCents),
+ lineTotal: formatEuro(item.quantity * item.unitPriceInCents),
+ })),
+ });
+ }
+
+ /** One Resend batch request; returns Resend's error text, or null when sent. */
+ private sendBatch(messages: { to: string; email: RenderedEmail }[]): Promise {
+ const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email }));
+ // Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice
+ const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify(body)).digest('hex');
+ return this.resend('/emails/batch', body, idempotencyKey);
+ }
+
+ private async resend(path: string, body: unknown, idempotencyKey?: string): Promise {
+ const res = await fetch(`https://api.resend.com${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
- body: JSON.stringify({ from: this.fromEmail, to, ...email }),
+ body: JSON.stringify(body),
});
return res.ok ? null : await res.text();
@@ -224,4 +251,4 @@ const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR'
function formatEuro(cents: number): string {
return euro.format(cents / 100);
-}
\ No newline at end of file
+}
diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts
index 746b559..9587eee 100644
--- a/apps/api/src/tabs/tabs.service.ts
+++ b/apps/api/src/tabs/tabs.service.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
+import { customerNameKey } from '../planned-tabs/customer-name';
@Injectable()
export class TabsService {
@@ -15,7 +16,7 @@ export class TabsService {
},
});
- const snapshots = await this.prisma.openTabSnapshot.findMany({
+ const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
@@ -25,15 +26,16 @@ export class TabsService {
// Customer details (email for reminders) live on the planned customer with the same name
const planned = await this.prisma.plannedTab.findMany({
where: { venueId },
- select: { id: true, customerName: true, email: true },
+ select: { id: true, customerNameKey: true, email: true },
});
- const plannedByName = new Map(planned.map((p) => [p.customerName, p]));
+ const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p]));
const tabs = [];
for (const ownership of ownerships) {
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
if (snapshot) {
+ const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName));
tabs.push({
tabId: snapshot.tabId,
customerName: snapshot.customerName,
@@ -47,8 +49,8 @@ export class TabsService {
deviceLastSeenAt: ownership.device.lastSeenAt,
deviceRevokedAt: ownership.device.revokedAt,
lastReportedAt: snapshot.reportedAt,
- plannedTabId: plannedByName.get(snapshot.customerName)?.id ?? null,
- email: plannedByName.get(snapshot.customerName)?.email ?? null,
+ plannedTabId: plannedTab?.id ?? null,
+ email: plannedTab?.email ?? null,
});
}
}
diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts
index 19e9b68..f78bc02 100644
--- a/apps/api/test/app.e2e-spec.ts
+++ b/apps/api/test/app.e2e-spec.ts
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
-import * as request from 'supertest';
+import request = require('supertest');
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
@@ -42,7 +42,7 @@ describe('KoolTap API (e2e)', () => {
it('POST /api/v1/auth/login with invalid credentials should return 401', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/login')
- .send({ email: 'nonexistent@test.com', password: 'wrong' })
+ .send({ email: 'nonexistent@test.com', password: 'wrong-password' })
.expect(401);
});
@@ -50,8 +50,9 @@ describe('KoolTap API (e2e)', () => {
// First create a user via seed or directly
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash('Test123!', 10);
- const uuid = require('uuid').v4();
- const venueId = uuid;
+ const { v4: uuid } = require('uuid');
+ const venueId = uuid();
+ const email = `test-${venueId}@test.com`;
await prisma.venue.create({
data: { id: venueId, name: 'Test Venue' },
@@ -59,9 +60,9 @@ describe('KoolTap API (e2e)', () => {
await prisma.clientUser.create({
data: {
- id: uuid,
+ id: uuid(),
venueId,
- email: 'test@test.com',
+ email,
passwordHash: hash,
name: 'Test User',
role: 'admin',
@@ -70,12 +71,12 @@ describe('KoolTap API (e2e)', () => {
return request(app.getHttpServer())
.post('/api/v1/auth/login')
- .send({ email: 'test@test.com', password: 'Test123!' })
+ .send({ email, password: 'Test123!' })
.expect(200)
.expect((res) => {
expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).toBeDefined();
- expect(res.body.user.email).toBe('test@test.com');
+ expect(res.body.user.email).toBe(email);
});
});
});
@@ -83,21 +84,20 @@ describe('KoolTap API (e2e)', () => {
describe('Venue isolation', () => {
it('should not allow cross-venue access', async () => {
const bcrypt = require('bcrypt');
- const uuid = require('uuid').v4();
- const venueAId = uuid;
- const venueBId = uuid;
+ const { v4: uuid } = require('uuid');
+ const venueAId = uuid();
+ const venueBId = uuid();
+ const email = `venuea-${venueAId}@test.com`;
await prisma.venue.create({ data: { id: venueAId, name: 'Venue A' } });
await prisma.venue.create({ data: { id: venueBId, name: 'Venue B' } });
const hash = await bcrypt.hash('Pass123!', 10);
- const userId = uuid;
-
await prisma.clientUser.create({
data: {
- id: userId,
+ id: uuid(),
venueId: venueAId,
- email: 'venuea@test.com',
+ email,
passwordHash: hash,
name: 'Venue A User',
role: 'admin',
@@ -107,7 +107,7 @@ describe('KoolTap API (e2e)', () => {
// Login as Venue A user
const loginRes = await request(app.getHttpServer())
.post('/api/v1/auth/login')
- .send({ email: 'venuea@test.com', password: 'Pass123!' })
+ .send({ email, password: 'Pass123!' })
.expect(200);
const token = loginRes.body.accessToken;
@@ -116,11 +116,9 @@ describe('KoolTap API (e2e)', () => {
await request(app.getHttpServer())
.get(`/api/v1/venues/${venueBId}/tabs`)
.set('Authorization', `Bearer ${token}`)
- .expect(200) // Should return empty - venue scoping is by user venueId, not by URL param
+ .expect(403)
.expect((res) => {
- // The endpoint uses the user's venueId from JWT
- // But for cross-venue tests we need the endpoint to verify
- expect(Array.isArray(res.body.tabs)).toBe(true);
+ expect(res.body.error.code).toBe('venue_forbidden');
});
});
});
diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json
index ac432d9..fd8633b 100644
--- a/apps/api/test/jest-e2e.json
+++ b/apps/api/test/jest-e2e.json
@@ -2,6 +2,9 @@
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
+ "moduleNameMapper": {
+ "^@kooltap/shared$": "/../../../packages/shared/src"
+ },
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
diff --git a/apps/dashboard/src/app/dashboard/commands/page.tsx b/apps/dashboard/src/app/dashboard/commands/page.tsx
index bc71d8f..75c9b4b 100644
--- a/apps/dashboard/src/app/dashboard/commands/page.tsx
+++ b/apps/dashboard/src/app/dashboard/commands/page.tsx
@@ -1,9 +1,8 @@
'use client';
import { useQuery } from '@tanstack/react-query';
-import { useEffect, useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { api } from '@/lib/api/client';
+import { useState } from 'react';
+import { commandsApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
@@ -16,6 +15,8 @@ import { ErrorState } from '@/components/error-state';
import { Filter } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n';
+import { useSession } from '@/lib/session';
+import { formatCents } from '@/lib/utils';
const statusFilters = [
{ value: '', label: 'commands.all' },
@@ -29,40 +30,24 @@ const statusFilters = [
export default function CommandsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
- const { t, tn, relativeTime } = useLang();
- const router = useRouter();
- const [venueId, setVenueId] = useState(null);
+ const { t, tn, te, relativeTime } = useLang();
+ const { venueId } = useSession();
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
const limit = 20;
- 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 { data, isLoading, error } = useQuery({
queryKey: ['commands', venueId, statusFilter, page],
- queryFn: () =>
- api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
- enabled: !!venueId,
+ queryFn: () => commandsApi.list(venueId, { limit, offset: page * limit, status: statusFilter || undefined }),
refetchInterval: live ? false : 10000,
});
- const commands = data?.commands || [];
- const total = data?.total || 0;
+ const commands = data?.commands ?? [];
+ const total = data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
- if (!venueId) return ;
if (isLoading) return ;
- if (error) return ;
+ if (error) return ;
return (
@@ -93,7 +78,8 @@ export default function CommandsPage() {
- {t('commands.tab')}
+ {t('commands.customer')}
+ {t('commands.amount')}
{t('commands.requested')}
{t('commands.payment')}
{t('commands.created')}
@@ -103,9 +89,10 @@ export default function CommandsPage() {
- {commands.map((cmd: any) => (
+ {commands.map((cmd) => (
- {cmd.tabId.substring(0, 8)}
+ {cmd.customerName ?? {cmd.tabId.substring(0, 8)}}
+ {cmd.amountInCents != null ? formatCents(cmd.amountInCents) : '-'}
{cmd.requestedBy}
{relativeTime(cmd.createdAt)}
diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx
index 5403812..49720e8 100644
--- a/apps/dashboard/src/app/dashboard/layout.tsx
+++ b/apps/dashboard/src/app/dashboard/layout.tsx
@@ -18,9 +18,11 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn';
import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector';
-import { authApi, api } from '@/lib/api/client';
+import { authApi, venuesApi } from '@/lib/api/client';
import { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime';
+import { readSession, Session, SessionProvider } from '@/lib/session';
+import { LoadingState } from '@/components/loading-state';
const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
@@ -39,39 +41,22 @@ export default function DashboardLayout({
const router = useRouter();
const pathname = usePathname();
const [sidebarOpen, setSidebarOpen] = useState(false);
- const [user, setUser] = useState<{ name: string; email: string; role: string; venueId: string } | null>(null);
+ const [user, setUser] = useState(null);
const [venue, setVenue] = useState<{ name: string } | null>(null);
const { t } = useLang();
useEffect(() => {
- // Check auth
- const cookie = document.cookie
- .split('; ')
- .find((row) => row.startsWith('access_token='));
- if (!cookie) {
- router.push('/login');
+ const session = readSession();
+ if (!session) {
+ router.replace('/login');
return;
}
-
- // Decode JWT to get basic user info
- try {
- const payload = JSON.parse(atob(cookie.split('.')[1]));
- setUser({
- name: payload.email,
- email: payload.email,
- role: payload.role,
- venueId: payload.venueId,
- });
-
- // Fetch venue name
- api(`/venues/${payload.venueId}`).then((v: any) => {
- setVenue(v);
- }).catch(() => {});
- } catch {
- router.push('/login');
- }
+ setUser(session);
+ venuesApi.get(session.venueId).then(setVenue).catch(() => {});
}, [router]);
+ if (!user) return ;
+
function handleLogout() {
authApi.logout();
toast({ title: t('common.logged_out'), variant: 'default' });
@@ -79,6 +64,7 @@ export default function DashboardLayout({
}
return (
+
{/* Mobile sidebar toggle */}
@@ -118,7 +104,7 @@ export default function DashboardLayout({
const Icon = item.icon;
const active = pathname === item.href;
- if (item.adminOnly && user?.role !== 'admin') {
+ if (item.adminOnly && user.role !== 'admin') {
return null;
}
@@ -142,13 +128,12 @@ export default function DashboardLayout({
+
- {user && (
-
-
{user.name}
-
{user.role}
-
- )}
+
+
{user.email}
+
{t(`users.role.${user.role}`)}
+
+
);
}
diff --git a/apps/dashboard/src/app/dashboard/page.tsx b/apps/dashboard/src/app/dashboard/page.tsx
index 6ec6637..3631033 100644
--- a/apps/dashboard/src/app/dashboard/page.tsx
+++ b/apps/dashboard/src/app/dashboard/page.tsx
@@ -1,80 +1,67 @@
'use client';
import { useQuery } from '@tanstack/react-query';
-import { useEffect, useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { api } from '@/lib/api/client';
+import { commandsApi, devicesApi, tabsApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
-import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge';
-import { ClipboardList, Terminal, Monitor } from 'lucide-react';
+import { DeviceStatusBadge } from '@/components/device-status-badge';
+import { ClipboardList, Terminal, Monitor, Euro } from 'lucide-react';
import { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n';
+import { useSession } from '@/lib/session';
+import { formatCents } from '@/lib/utils';
export default function DashboardPage() {
// Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime();
const { t, relativeTime } = useLang();
- const router = useRouter();
- const [venueId, setVenueId] = useState
(null);
-
- 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 { venueId } = useSession();
const tabsQuery = useQuery({
queryKey: ['tabs', venueId],
- queryFn: () => api(`/venues/${venueId}/tabs`),
- enabled: !!venueId,
+ queryFn: () => tabsApi.list(venueId),
refetchInterval: live ? false : 10000,
});
const deviceQuery = useQuery({
queryKey: ['device', venueId],
- queryFn: () => api(`/venues/${venueId}/devices`),
- enabled: !!venueId,
+ queryFn: () => devicesApi.get(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000,
});
- const commandsQuery = useQuery({
+ const recentQuery = useQuery({
queryKey: ['commands', venueId, 'recent'],
- queryFn: () => api(`/venues/${venueId}/commands?limit=5`),
- enabled: !!venueId,
+ queryFn: () => commandsApi.list(venueId, { limit: 5 }),
refetchInterval: live ? false : 15000,
});
- if (!venueId) return ;
+ const pendingQuery = useQuery({
+ queryKey: ['commands', venueId, 'pending-count'],
+ queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 1 }),
+ refetchInterval: live ? false : 15000,
+ });
- const tabs = tabsQuery.data?.tabs || [];
+ const tabs = tabsQuery.data?.tabs ?? [];
const device = deviceQuery.data?.device;
- const commands = commandsQuery.data?.commands || [];
-
- const pendingCommands = commands.filter((c: any) => c.status === 'pending');
- const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
+ const commands = recentQuery.data?.commands ?? [];
+ const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
+ const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
return (
{isLoading ? (
-
- {Array.from({ length: 3 }).map((_, i) => (
+
+ {Array.from({ length: 4 }).map((_, i) => (
))}
) : (
-
+
{t('overview.open_tabs')}
@@ -82,14 +69,30 @@ export default function DashboardPage() {
{tabs.length}
+
+
+ {t('overview.outstanding')}
+
+
+ {formatCents(outstanding)}
+
{t('overview.device')}
- {device?.name || t('overview.not_connected')}
- {device && {t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}
}
+ {device ? (
+ <>
+
+ {device.name}
+
+
+ {t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}
+ >
+ ) : (
+ {t('overview.not_connected')}
+ )}
@@ -97,7 +100,7 @@ export default function DashboardPage() {
{t('overview.pending')}
- {pendingCommands.length}
+ {pendingQuery.data?.total ?? 0}
)}
@@ -109,13 +112,14 @@ export default function DashboardPage() {
{t('overview.no_commands')}
) : (
- {commands.map((cmd: any) => (
-
-
-
{cmd.tabId?.substring(0, 8)}
+ {commands.map((cmd) => (
+
+
+ {cmd.customerName ?? cmd.tabId.substring(0, 8)}
+ {cmd.amountInCents != null && {formatCents(cmd.amountInCents)}}
{cmd.requestedBy}
-
+
{relativeTime(cmd.createdAt)}
@@ -127,4 +131,4 @@ export default function DashboardPage() {
);
-}
\ No newline at end of file
+}
diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx
index 22c8d76..efc5662 100644
--- a/apps/dashboard/src/app/dashboard/reminders/page.tsx
+++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx
@@ -1,8 +1,7 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { useEffect, useState } from 'react';
-import { useRouter } from 'next/navigation';
+import { useState } from 'react';
import { remindersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -11,43 +10,34 @@ 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 } from '@/lib/utils';
import { useLang } from '@/lib/i18n';
+import { useSession } from '@/lib/session';
import { Send, History } from 'lucide-react';
export default function RemindersPage() {
- const router = useRouter();
const queryClient = useQueryClient();
- const { t, relativeTime } = useLang();
- const [venueId, setVenueId] = useState
(null);
+ const { t, te, relativeTime } = useLang();
+ const { venueId, role } = useSession();
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,
+ queryFn: () => remindersApi.candidates(venueId),
});
const historyQuery = useQuery({
queryKey: ['reminder-history', venueId],
- queryFn: () => remindersApi.history(venueId!),
- enabled: !!venueId && showHistory,
+ queryFn: () => remindersApi.history(venueId),
+ enabled: showHistory,
});
const sendMutation = useMutation({
- mutationFn: () => remindersApi.send(venueId!),
- onSuccess: (result: any) => {
+ mutationFn: () => remindersApi.send(venueId),
+ onSuccess: (result) => {
setSendDialogOpen(false);
if (result.errors && result.errors.length > 0) {
const firstError = result.errors[0];
@@ -60,15 +50,14 @@ export default function RemindersPage() {
queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] });
queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
},
- onError: (err: any) => {
- toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' });
+ onError: (err) => {
+ setSendDialogOpen(false);
+ toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
},
});
- if (!venueId) return ;
-
- const candidates = candidatesQuery.data?.candidates || [];
- const reminders = historyQuery.data?.reminders || [];
+ const candidates = candidatesQuery.data?.candidates ?? [];
+ const reminders = historyQuery.data?.reminders ?? [];
return (
@@ -106,13 +95,13 @@ export default function RemindersPage() {
- {reminders.map((r: any) => (
+ {reminders.map((r) => (
{r.customerName}
{r.email}
{formatCents(r.amountInCents)}
{relativeTime(r.sentAt)}
- {r.status === 'sent' ? t('reminders.status_sent') : r.status}
+ {r.status === 'sent' ? t('reminders.status_sent') : r.status}
))}
@@ -149,7 +138,7 @@ export default function RemindersPage() {
- {candidates.map((c: any) => (
+ {candidates.map((c) => (
{c.customerName}
{c.email}
@@ -161,10 +150,14 @@ export default function RemindersPage() {
-
+ {role === 'admin' ? (
+
+ ) : (
+ {t('reminders.admin_only')}
+ )}
>
)}
@@ -184,7 +177,7 @@ export default function RemindersPage() {
{t('reminders.confirm', { count: candidates.length })}
- {candidates.map((c: any) => (
+ {candidates.map((c) => (
-
{c.customerName}
({c.email})
diff --git a/apps/dashboard/src/app/dashboard/settings/page.tsx b/apps/dashboard/src/app/dashboard/settings/page.tsx
index 5905c30..275f54c 100644
--- a/apps/dashboard/src/app/dashboard/settings/page.tsx
+++ b/apps/dashboard/src/app/dashboard/settings/page.tsx
@@ -1,44 +1,32 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
-import { useRouter } from 'next/navigation';
-import { api, devicesApi } from '@/lib/api/client';
+import { devicesApi, venuesApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
-import { Badge } from '@/components/ui/badge';
+import { DeviceStatusBadge } from '@/components/device-status-badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n';
+import { useSession } from '@/lib/session';
import { Monitor, Copy, Plus } from 'lucide-react';
export default function SettingsPage() {
- const router = useRouter();
- const [venueId, setVenueId] = useState(null);
- const [userRole, setUserRole] = useState(null);
+ const { venueId, role } = useSession();
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false);
- const { t, relativeTime, dateTime, time } = useLang();
-
- 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);
- setUserRole(payload.role);
- }, [router]);
+ const { t, te, relativeTime, dateTime, time } = useLang();
const { data: venue } = useQuery({
queryKey: ['venue', venueId],
- queryFn: () => api(`/venues/${venueId}`),
- enabled: !!venueId,
+ queryFn: () => venuesApi.get(venueId),
});
const { data: deviceData } = useQuery({
queryKey: ['device', venueId],
- queryFn: () => devicesApi.get(venueId!),
- enabled: !!venueId,
+ queryFn: () => devicesApi.get(venueId),
// A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000,
});
@@ -50,8 +38,8 @@ export default function SettingsPage() {
try {
const result = await devicesApi.generateCode();
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
- } catch (err: any) {
- toast({ title: t('common.failed'), description: err.message, variant: 'destructive' });
+ } catch (err) {
+ toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally {
setGenerating(false);
}
@@ -91,8 +79,8 @@ export default function SettingsPage() {
{device ? (
-
- {t('settings.connected')}
+
+
{t('settings.since', { date: dateTime(device.createdAt) })}
) : (
@@ -100,7 +88,7 @@ export default function SettingsPage() {
{t('settings.pair_desc')}
)}
- {userRole === 'admin' && (
+ {role === 'admin' && (