fix: alot
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-19 21:41:07 +02:00
parent df90cf8a18
commit fa38ac7617
70 changed files with 1389 additions and 1023 deletions
+1 -2
View File
@@ -1,8 +1,7 @@
# KoolTap Remote Tab Control # 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_SECRET="dev-jwt-secret-change-in-production"
JWT_REFRESH_SECRET="dev-refresh-secret-change-in-production"
PORT=3001 PORT=3001
CORS_ORIGIN="http://localhost:3000" CORS_ORIGIN="http://localhost:3000"
+1 -2
View File
@@ -4,9 +4,8 @@
# PostgreSQL # PostgreSQL
DB_PASSWORD=changeme 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_SECRET=changeme
JWT_REFRESH_SECRET=changeme
# URLs # URLs
DASHBOARD_URL=http://localhost:3000 DASHBOARD_URL=http://localhost:3000
+38 -11
View File
@@ -2,41 +2,68 @@ name: CI
on: on:
push: push:
branches: [main] branches: [master]
pull_request: pull_request:
branches: [main] branches: [master]
jobs: jobs:
build-and-test: build-and-test:
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: pnpm/action-setup@v2 - uses: pnpm/action-setup@v4
with: with:
version: 8 version: 9
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
cache: 'pnpm' cache: 'pnpm'
- run: pnpm install - run: pnpm install --frozen-lockfile
- name: Generate Prisma client - name: Generate Prisma client
run: cd apps/api && npx prisma generate run: cd apps/api && npx prisma generate
- name: Lint - name: Build shared package
run: pnpm lint run: pnpm --filter @kooltap/shared run build
continue-on-error: true
- name: TypeScript check - name: TypeScript check
run: pnpm typecheck run: pnpm typecheck
continue-on-error: true
- name: Run migrations # Uses the still-empty database as shadow database, so it must run before the migrations
run: cd apps/api && npx prisma migrate dev --name ci-init - 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 - name: Build
run: pnpm build run: pnpm build
+2
View File
@@ -7,3 +7,5 @@ dist/
*.db-journal *.db-journal
coverage/ coverage/
.turbo/ .turbo/
*.tsbuildinfo
.idea/
-10
View File
@@ -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
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/kooltab_backoffice.iml" filepath="$PROJECT_DIR$/.idea/kooltab_backoffice.iml" />
</modules>
</component>
</project>
Generated
-6
View File
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+2 -2
View File
@@ -5,7 +5,7 @@ A production-quality remote tab control system for venues. Allows bar staff to m
## Architecture ## Architecture
- **Monorepo** managed with pnpm - **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 - **Next.js** dashboard with App Router, TanStack Query, and shadcn/ui
- **Shared** TypeScript types package - **Shared** TypeScript types package
@@ -95,4 +95,4 @@ docker compose up
## Production Notes ## Production Notes
See [docs/production.md](docs/production.md) for production deployment and SQLite → PostgreSQL migration guide. See [docs/production.md](docs/production.md) for production deployment.
+1 -1
View File
@@ -15,7 +15,6 @@
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:seed": "ts-node prisma/seed.ts", "db:seed": "ts-node prisma/seed.ts",
"db:reset": "prisma migrate reset --force && 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" "clean": "rimraf dist"
}, },
"dependencies": { "dependencies": {
@@ -55,6 +54,7 @@
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/node": "^20.11.0", "@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0", "@types/passport-jwt": "^4.0.0",
"@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"jest": "^29.7.0", "jest": "^29.7.0",
"rimraf": "^5.0.5", "rimraf": "^5.0.5",
@@ -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";
+27 -23
View File
@@ -34,7 +34,7 @@ model Device {
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id]) venue Venue @relation(fields: [venueId], references: [id])
tabSnapshots OpenTabSnapshot[] tabSnapshots OpenTabSnapshot[]
tabOwnerships TabOwnership[] tabOwnerships TabOwnership[]
remoteCommands RemoteCommand[] remoteCommands RemoteCommand[]
@@ -57,12 +57,12 @@ model ClientUser {
} }
model RegistrationCode { model RegistrationCode {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String
code String @unique code String @unique
expiresAt DateTime expiresAt DateTime
usedAt DateTime? usedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id]) venue Venue @relation(fields: [venueId], references: [id])
@@ -106,6 +106,8 @@ model RemoteCommand {
venueId String venueId String
deviceId String deviceId String
targetTabId String targetTabId String
customerName String?
amountInCents Int?
type String @default("close_tab") type String @default("close_tab")
expectedVersion Int expectedVersion Int
paymentMethod String paymentMethod String
@@ -138,41 +140,43 @@ model AuditLog {
} }
model RefreshToken { model RefreshToken {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String
tokenHash String @unique tokenHash String @unique
expiresAt DateTime expiresAt DateTime
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
} }
model PlannedTab { model PlannedTab {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String
customerName String customerName String
email String? // Lowercased, whitespace-collapsed name: customers are matched on this everywhere
position Int @default(0) customerNameKey String
createdAt DateTime @default(now()) 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 // 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]) venue Venue @relation(fields: [venueId], references: [id])
@@unique([venueId, customerName]) @@unique([venueId, customerNameKey])
@@index([venueId]) @@index([venueId])
} }
model TabReminder { model TabReminder {
id String @id @default(uuid()) id String @id @default(uuid())
venueId String venueId String
tabId String tabId String
customerName String customerName String
email String email String
amountInCents Int amountInCents Int
sentAt DateTime @default(now()) sentAt DateTime @default(now())
status String @default("sent") status String @default("sent")
venue Venue @relation(fields: [venueId], references: [id]) venue Venue @relation(fields: [venueId], references: [id])
-158
View File
@@ -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])
}
+51 -41
View File
@@ -1,60 +1,70 @@
const { PrismaClient } = require('@prisma/client'); const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const VENUE_NAME = process.env.SETUP_VENUE_NAME || process.argv[2] || 'My Venue'; const args = process.argv.slice(2).filter((arg) => arg !== '--force');
const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || process.argv[3] || 'admin@example.com'; const FORCE = process.argv.includes('--force');
const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || process.argv[4] || 'admin123'; 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() { async function main() {
console.log('=== KoolTap Production Setup ===\n'); console.log('=== KoolTap Production Setup ===\n');
// Wipe everything const venues = await prisma.venue.count();
await prisma.auditLog.deleteMany(); if (venues > 0 && !FORCE) {
await prisma.remoteCommand.deleteMany(); console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`);
await prisma.openTabSnapshot.deleteMany(); return;
await prisma.tabOwnership.deleteMany(); }
await prisma.refreshToken.deleteMany();
await prisma.registrationCode.deleteMany(); if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
await prisma.device.deleteMany(); throw new Error('Usage: node prisma/setup.js "<venue name>" <admin email> <admin password> [--force]');
await prisma.clientUser.deleteMany(); }
await prisma.venue.deleteMany(); if (ADMIN_PASSWORD.length < 10) {
console.log('✓ Cleared existing data'); throw new Error('The admin password must be at least 10 characters');
}
// Create venue
const venueId = uuidv4(); 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); 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: ${VENUE_NAME}`);
console.log(` Venue ID: ${venueId}`); console.log(` Venue ID: ${venueId}`);
console.log(` Admin: ${ADMIN_EMAIL}`); console.log(` Admin: ${ADMIN_EMAIL}`);
console.log(` Password: ${ADMIN_PASSWORD}`); console.log('\nLog in to the dashboard with the admin credentials you provided.');
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();
} }
main().catch((err) => { main()
console.error('Setup failed:', err); .catch((err) => {
process.exit(1); console.error('Setup failed:', err.message ?? err);
}); process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+1 -1
View File
@@ -12,7 +12,7 @@ import { getJwtSecret } from './jwt-secret';
PassportModule.register({ defaultStrategy: 'jwt' }), PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({ JwtModule.register({
secret: getJwtSecret(), secret: getJwtSecret(),
signOptions: { expiresIn: '24h' }, signOptions: { expiresIn: '15m' },
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
+72
View File
@@ -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);
});
});
+9 -5
View File
@@ -64,6 +64,15 @@ export class AuthService {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' }); 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 } }); const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
if (!user) { if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' }); 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 newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); 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({ await this.prisma.refreshToken.create({
data: { data: {
userId: user.id, userId: user.id,
@@ -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');
});
});
+16 -1
View File
@@ -5,6 +5,7 @@ import {
ConflictException, ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.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) => { const result = await this.prisma.$transaction(async (tx) => {
// Resolve tab ownership // Resolve tab ownership
const ownership = await tx.tabOwnership.findUnique({ const ownership = await tx.tabOwnership.findUnique({
@@ -93,6 +94,8 @@ export class CommandsService {
venueId, venueId,
deviceId: device.id, deviceId: device.id,
targetTabId: dto.tabId, targetTabId: dto.tabId,
customerName: snapshot?.customerName ?? null,
amountInCents: snapshot?.totalInCents ?? null,
type: dto.type || 'close_tab', type: dto.type || 'close_tab',
expectedVersion: dto.expectedVersion, expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod, paymentMethod: dto.paymentMethod,
@@ -130,6 +133,14 @@ export class CommandsService {
status: command.status, status: command.status,
...warning, ...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'); this.realtime.notify(venueId, 'commands');
@@ -152,6 +163,8 @@ export class CommandsService {
select: { select: {
id: true, id: true,
targetTabId: true, targetTabId: true,
customerName: true,
amountInCents: true,
type: true, type: true,
status: true, status: true,
paymentMethod: true, paymentMethod: true,
@@ -173,6 +186,8 @@ export class CommandsService {
commands: commands.map((c) => ({ commands: commands.map((c) => ({
id: c.id, id: c.id,
tabId: c.targetTabId, tabId: c.targetTabId,
customerName: c.customerName,
amountInCents: c.amountInCents,
type: c.type, type: c.type,
status: c.status, status: c.status,
paymentMethod: c.paymentMethod, paymentMethod: c.paymentMethod,
+35 -6
View File
@@ -1,12 +1,12 @@
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { DevicesService } from './devices.service'; 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 } = {}) { function createService({ previous = [] as Snapshot[], delivered = 0, expired = 0 } = {}) {
const tx = { const tx = {
tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn() }, tabOwnership: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn() }, openTabSnapshot: { deleteMany: jest.fn(), upsert: jest.fn(), updateMany: jest.fn() },
plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) }, plannedTab: { updateMany: jest.fn().mockResolvedValue({ count: delivered }) },
device: { update: jest.fn() }, 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 }); const tab = (id: string, customerName: string) => ({ id, customerName, version: 1, totalInCents: 0, itemCount: 0 });
describe('DevicesService.sync planned customers', () => { 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(); 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({ 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) }, 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); 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();
});
});
+45 -16
View File
@@ -10,7 +10,8 @@ import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service'; import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared'; import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { Prisma } from '@prisma/client'; 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'; import { AckCommandDto } from './dto/ack-command.dto';
@Injectable() @Injectable()
@@ -36,22 +37,28 @@ export class DevicesService {
// Devices sync often, so dashboards are only told about what actually changed // Devices sync often, so dashboards are only told about what actually changed
const previous = await this.prisma.openTabSnapshot.findMany({ const previous = await this.prisma.openTabSnapshot.findMany({
where: { deviceId: device.id }, 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 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 // Ownership changes are detected up front so audit logging stays outside the transaction
const takenOver = await this.prisma.tabOwnership.findMany({ const takenOver = await this.prisma.tabOwnership.findMany({
where: { venueId: device.venueId, tabId: { in: openTabIds }, deviceId: { not: device.id } }, 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) { for (const ownership of takenOver) {
await this.auditService.log( await this.auditService.log(
device.venueId, device.venueId,
@@ -71,7 +78,18 @@ export class DevicesService {
where: { deviceId: device.id, tabId: { notIn: openTabIds } }, 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({ await tx.openTabSnapshot.upsert({
where: { where: {
deviceId_tabId: { deviceId: device.id, tabId: tab.id }, 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) { if (openTabs.length > 0) {
({ count: plannedDelivered } = await tx.plannedTab.updateMany({ ({ count: plannedDelivered } = await tx.plannedTab.updateMany({
where: { where: {
venueId: device.venueId, venueId: device.venueId,
deliveredAt: null, deliveredAt: null,
customerName: { in: openTabs.map((t) => t.customerName) }, customerNameKey: { in: openTabs.map((t) => customerNameKey(t.customerName)) },
}, },
data: { deliveredAt: now }, 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. */ /** The tab's lines in the stored shape, or JSON null when the terminal didn't report them. */
function snapshotItems(tab: OpenTabDto) { function snapshotItems(tab: OpenTabDto) {
if (!tab.items) return Prisma.JsonNull; return tab.items ? storedItems(tab.items) : Prisma.JsonNull;
return tab.items.map(({ productName, quantity, unitPriceInCents }) => ({ productName, quantity, unitPriceInCents }));
} }
+12 -9
View File
@@ -39,20 +39,23 @@ async function bootstrap() {
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor()); app.useGlobalInterceptors(new LoggingInterceptor());
const config = new DocumentBuilder() const swagger = process.env.NODE_ENV !== 'production';
.setTitle('KoolTap Remote Tab Control API') if (swagger) {
.setDescription('API for managing remote tab control in venues') const config = new DocumentBuilder()
.setVersion('1.0') .setTitle('KoolTap Remote Tab Control API')
.addBearerAuth() .setDescription('API for managing remote tab control in venues')
.build(); .setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document); SwaggerModule.setup('api/docs', app, document);
}
const port = process.env.PORT || 3001; const port = process.env.PORT || 3001;
await app.listen(port); await app.listen(port);
console.log(`API running on http://localhost:${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(); bootstrap();
@@ -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();
}
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, ConflictException, BadRequestException }
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { customerNameKey, normalizeCustomerName } from './customer-name';
@Injectable() @Injectable()
export class PlannedTabsService { export class PlannedTabsService {
@@ -20,12 +21,12 @@ export class PlannedTabsService {
} }
async create(venueId: string, customerName: string, email?: string) { 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' }); 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({ const existing = await this.prisma.plannedTab.findUnique({
where: { venueId_customerName: { venueId, customerName: name } }, where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
}); });
if (existing) { if (existing) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
@@ -42,6 +43,7 @@ export class PlannedTabsService {
id: uuidv4(), id: uuidv4(),
venueId, venueId,
customerName: name, customerName: name,
customerNameKey: key,
email: email?.trim() || null, email: email?.trim() || null,
position: (last?.position ?? -1) + 1, position: (last?.position ?? -1) + 1,
}, },
@@ -53,14 +55,15 @@ export class PlannedTabsService {
} }
async update(venueId: string, id: string, customerName: string, email?: string) { 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' }); 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 } }); 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({ const duplicate = await this.prisma.plannedTab.findUnique({
where: { venueId_customerName: { venueId, customerName: name } }, where: { venueId_customerNameKey: { venueId, customerNameKey: key } },
}); });
if (duplicate && duplicate.id !== id) { if (duplicate && duplicate.id !== id) {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); 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({ const result = await this.prisma.plannedTab.update({
where: { id }, 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 }, 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') { async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') {
const incoming = new Map<string, { customerName: string; email?: string }>(); const incoming = new Map<string, { customerName: string; email?: string }>();
for (const entry of entries) { for (const entry of entries) {
const customerName = entry.customerName.trim().replace(/\s+/g, ' '); const customerName = normalizeCustomerName(entry.customerName);
if (!customerName) continue; if (!customerName) continue;
const key = customerName.toLowerCase(); const key = customerNameKey(customerName);
const previous = incoming.get(key); const previous = incoming.get(key);
incoming.set(key, { customerName, email: entry.email || previous?.email }); incoming.set(key, { customerName, email: entry.email || previous?.email });
} }
const result = await this.prisma.$transaction(async (tx) => { const result = await this.prisma.$transaction(async (tx) => {
const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } }); 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 nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1;
let created = 0; let created = 0;
@@ -106,7 +109,7 @@ export class PlannedTabsService {
const current = existingByKey.get(key); const current = existingByKey.get(key);
if (!current) { if (!current) {
await tx.plannedTab.create({ 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++; created++;
} else if (entry.email && entry.email !== current.email) { } else if (entry.email && entry.email !== current.email) {
@@ -118,7 +121,7 @@ export class PlannedTabsService {
} }
if (mode === 'replace') { 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) { if (toRemove.length > 0) {
removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count; removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count;
} }
@@ -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 { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
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';
@@ -21,11 +21,14 @@ export class RemindersController {
} }
@Post('send') @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: 201, description: 'Reminders sent' })
@ApiResponse({ status: 400, description: 'Resend not configured' }) @ApiResponse({ status: 400, description: 'Resend not configured' })
async send(@Param('venueId') venueId: string, @Req() req: any) { @ApiResponse({ status: 409, description: 'Reminders are already being sent' })
return this.service.sendReminders(venueId, req.user?.email || 'unknown'); async send(@Param('venueId') venueId: string) {
return this.service.sendReminders(venueId);
} }
@Get() @Get()
@@ -1,7 +1,8 @@
import { ConflictException } from '@nestjs/common';
import { EmailTemplatesService } from '../email/email-templates.service'; import { EmailTemplatesService } from '../email/email-templates.service';
import { RemindersService } from './reminders.service'; import { RemindersService } from './reminders.service';
function createService(items: unknown) { function createService(items: unknown, { plannedName = 'Bram' } = {}) {
const prisma = { const prisma = {
tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) }, tabOwnership: { findMany: jest.fn().mockResolvedValue([{ tabId: 't1', deviceId: 'd1' }]) },
openTabSnapshot: { openTabSnapshot: {
@@ -9,22 +10,23 @@ function createService(items: unknown) {
{ tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items }, { tabId: 't1', deviceId: 'd1', customerName: 'Bram', totalInCents: 2900, itemCount: 12, items },
]), ]),
}, },
plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: 'Bram', email: 'bram@example.com' }]) }, plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) },
tabReminder: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn() }, tabReminder: { findMany: jest.fn().mockResolvedValue([]), createMany: jest.fn() },
venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) }, 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', () => { describe('RemindersService.sendReminders', () => {
const originalFetch = global.fetch; const originalFetch = global.fetch;
let sentBodies: any[]; let requests: { url: string; headers: Record<string, string>; body: any }[];
beforeEach(() => { beforeEach(() => {
process.env.RESEND_API_KEY = 'test-key'; process.env.RESEND_API_KEY = 'test-key';
sentBodies = []; requests = [];
global.fetch = jest.fn(async (_url: any, init: any) => { global.fetch = jest.fn(async (url: any, init: any) => {
sentBodies.push(JSON.parse(init.body)); requests.push({ url, headers: init.headers, body: JSON.parse(init.body) });
return { ok: true, text: async () => '' } as Response; return { ok: true, text: async () => '' } as Response;
}) as any; }) as any;
}); });
@@ -35,15 +37,15 @@ describe('RemindersService.sendReminders', () => {
}); });
it('includes the tab summary with Belgian amounts', async () => { it('includes the tab summary with Belgian amounts', async () => {
const service = createService([ const { service } = createService([
{ productName: 'Cola', quantity: 10, unitPriceInCents: 250 }, { productName: 'Cola', quantity: 10, unitPriceInCents: 250 },
{ productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, { 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); expect(result.sent).toBe(1);
const [email] = sentBodies; const [email] = requests[0].body;
expect(email.to).toBe('bram@example.com'); expect(email.to).toBe('bram@example.com');
expect(email.text).toMatch(/10x Cola\s+€\s25,00/); expect(email.text).toMatch(/10x Cola\s+€\s25,00/);
expect(email.text).toMatch(/2x Chips\s+€\s4,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 () => { 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).not.toContain('10x');
expect(email.text).toMatch(/€\s29,00/); 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);
});
}); });
+108 -81
View File
@@ -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 { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service'; import { RealtimeService } from '../realtime/realtime.service';
import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.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 // 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. // once this many days have passed since their last reminder.
const REMINDER_COOLDOWN_DAYS = 7; 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() @Injectable()
export class RemindersService { export class RemindersService {
private resendApiKey: string; private resendApiKey: string;
private fromEmail: string; private fromEmail: string;
private sendingVenues = new Set<string>();
constructor( constructor(
private prisma: PrismaService, private prisma: PrismaService,
@@ -21,7 +36,7 @@ export class RemindersService {
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>'; this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
} }
async listCandidates(venueId: string) { async listCandidates(venueId: string): Promise<{ candidates: Candidate[] }> {
const ownerships = await this.prisma.tabOwnership.findMany({ const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId }, where: { venueId },
}); });
@@ -32,37 +47,24 @@ export class RemindersService {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), 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>(); const plannedTabs = await this.prisma.plannedTab.findMany({
try { where: { venueId, email: { not: null } },
const plannedTabs = await this.prisma.plannedTab.findMany({ select: { customerName: true, email: true },
where: { venueId, email: { not: null } }, });
select: { customerName: true, email: true }, const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!]));
});
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>(); const alreadySent = await this.prisma.tabReminder.findMany({
try { where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
const alreadySent = await this.prisma.tabReminder.findMany({ select: { tabId: true },
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));
});
sentTabIds = new Set(alreadySent.map((r) => r.tabId));
} catch {
// Table may not exist yet if migration hasn't been applied
}
const candidates = []; const candidates: Candidate[] = [];
for (const snap of latestByTab.values()) { for (const snap of snapshots) {
if (snap.totalInCents <= 0) continue; if (snap.totalInCents <= 0) continue;
if (sentTabIds.has(snap.tabId)) continue; if (sentTabIds.has(snap.tabId)) continue;
const email = emailMap.get(snap.customerName.toLowerCase()); const email = emailByKey.get(customerNameKey(snap.customerName));
if (!email) continue; if (!email) continue;
candidates.push({ candidates.push({
tabId: snap.tabId, tabId: snap.tabId,
@@ -78,13 +80,7 @@ export class RemindersService {
return { candidates }; return { candidates };
} }
async sendReminders(venueId: string, requestorEmail: string) { async sendReminders(venueId: string) {
const { candidates } = await this.listCandidates(venueId);
if (candidates.length === 0) {
return { sent: 0, message: 'No candidates to remind' };
}
if (!this.resendApiKey) { if (!this.resendApiKey) {
throw new BadRequestException({ throw new BadRequestException({
code: 'resend_not_configured', 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); const venueName = await this.venueName(venueId);
let sent = 0; let sent = 0;
const errors: { customerName: string; error: string }[] = []; const errors: { customerName: string; error: string }[] = [];
const rendered: { candidate: Candidate; email: RenderedEmail }[] = [];
for (const candidate of candidates) { for (const candidate of candidates) {
try { try {
const email = await this.emailTemplates.render('payment-reminder', { rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) });
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++;
} catch (err: any) { } catch (err: any) {
errors.push({ customerName: candidate.customerName, error: err.message }); 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'); if (sent > 0) this.realtime.notify(venueId, 'reminders');
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined }; return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
} }
async history(venueId: string) { async history(venueId: string) {
try { const reminders = await this.prisma.tabReminder.findMany({
const reminders = await this.prisma.tabReminder.findMany({ where: { venueId },
where: { venueId }, orderBy: { sentAt: 'desc' },
orderBy: { sentAt: 'desc' }, take: 100,
take: 100, });
}); return { reminders };
return { reminders };
} catch {
return { reminders: [] };
}
} }
async sendTestEmail(venueId: string, plannedTabId: string) { async sendTestEmail(venueId: string, plannedTabId: string) {
@@ -186,7 +189,7 @@ export class RemindersService {
throw error; 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) { if (error) {
throw new BadRequestException({ throw new BadRequestException({
code: 'email_failed', code: 'email_failed',
@@ -197,15 +200,39 @@ export class RemindersService {
return { ok: true, email: tab.email, customerName: tab.customerName }; return { ok: true, email: tab.email, customerName: tab.customerName };
} }
/** Sends through Resend; returns Resend's error text, or null when sent. */ private renderReminder(candidate: Candidate, venueName: string): Promise<RenderedEmail> {
private async sendEmail(to: string, email: RenderedEmail): Promise<string | null> { return this.emailTemplates.render('payment-reminder', {
const res = await fetch('https://api.resend.com/emails', { 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<string | null> {
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<string | null> {
const res = await fetch(`https://api.resend.com${path}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`, 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(); return res.ok ? null : await res.text();
+7 -5
View File
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { customerNameKey } from '../planned-tabs/customer-name';
@Injectable() @Injectable()
export class TabsService { 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: { where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })), 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 // Customer details (email for reminders) live on the planned customer with the same name
const planned = await this.prisma.plannedTab.findMany({ const planned = await this.prisma.plannedTab.findMany({
where: { venueId }, 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 = []; const tabs = [];
for (const ownership of ownerships) { for (const ownership of ownerships) {
const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`); const snapshot = snapshotByKey.get(`${ownership.deviceId}:${ownership.tabId}`);
if (snapshot) { if (snapshot) {
const plannedTab = plannedByKey.get(customerNameKey(snapshot.customerName));
tabs.push({ tabs.push({
tabId: snapshot.tabId, tabId: snapshot.tabId,
customerName: snapshot.customerName, customerName: snapshot.customerName,
@@ -47,8 +49,8 @@ export class TabsService {
deviceLastSeenAt: ownership.device.lastSeenAt, deviceLastSeenAt: ownership.device.lastSeenAt,
deviceRevokedAt: ownership.device.revokedAt, deviceRevokedAt: ownership.device.revokedAt,
lastReportedAt: snapshot.reportedAt, lastReportedAt: snapshot.reportedAt,
plannedTabId: plannedByName.get(snapshot.customerName)?.id ?? null, plannedTabId: plannedTab?.id ?? null,
email: plannedByName.get(snapshot.customerName)?.email ?? null, email: plannedTab?.email ?? null,
}); });
} }
} }
+18 -20
View File
@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common'; import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest'; import request = require('supertest');
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; 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', () => { it('POST /api/v1/auth/login with invalid credentials should return 401', () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/api/v1/auth/login') .post('/api/v1/auth/login')
.send({ email: 'nonexistent@test.com', password: 'wrong' }) .send({ email: 'nonexistent@test.com', password: 'wrong-password' })
.expect(401); .expect(401);
}); });
@@ -50,8 +50,9 @@ describe('KoolTap API (e2e)', () => {
// First create a user via seed or directly // First create a user via seed or directly
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const hash = await bcrypt.hash('Test123!', 10); const hash = await bcrypt.hash('Test123!', 10);
const uuid = require('uuid').v4(); const { v4: uuid } = require('uuid');
const venueId = uuid; const venueId = uuid();
const email = `test-${venueId}@test.com`;
await prisma.venue.create({ await prisma.venue.create({
data: { id: venueId, name: 'Test Venue' }, data: { id: venueId, name: 'Test Venue' },
@@ -59,9 +60,9 @@ describe('KoolTap API (e2e)', () => {
await prisma.clientUser.create({ await prisma.clientUser.create({
data: { data: {
id: uuid, id: uuid(),
venueId, venueId,
email: 'test@test.com', email,
passwordHash: hash, passwordHash: hash,
name: 'Test User', name: 'Test User',
role: 'admin', role: 'admin',
@@ -70,12 +71,12 @@ describe('KoolTap API (e2e)', () => {
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/api/v1/auth/login') .post('/api/v1/auth/login')
.send({ email: 'test@test.com', password: 'Test123!' }) .send({ email, password: 'Test123!' })
.expect(200) .expect(200)
.expect((res) => { .expect((res) => {
expect(res.body.accessToken).toBeDefined(); expect(res.body.accessToken).toBeDefined();
expect(res.body.refreshToken).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', () => { describe('Venue isolation', () => {
it('should not allow cross-venue access', async () => { it('should not allow cross-venue access', async () => {
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const uuid = require('uuid').v4(); const { v4: uuid } = require('uuid');
const venueAId = uuid; const venueAId = uuid();
const venueBId = 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: venueAId, name: 'Venue A' } });
await prisma.venue.create({ data: { id: venueBId, name: 'Venue B' } }); await prisma.venue.create({ data: { id: venueBId, name: 'Venue B' } });
const hash = await bcrypt.hash('Pass123!', 10); const hash = await bcrypt.hash('Pass123!', 10);
const userId = uuid;
await prisma.clientUser.create({ await prisma.clientUser.create({
data: { data: {
id: userId, id: uuid(),
venueId: venueAId, venueId: venueAId,
email: 'venuea@test.com', email,
passwordHash: hash, passwordHash: hash,
name: 'Venue A User', name: 'Venue A User',
role: 'admin', role: 'admin',
@@ -107,7 +107,7 @@ describe('KoolTap API (e2e)', () => {
// Login as Venue A user // Login as Venue A user
const loginRes = await request(app.getHttpServer()) const loginRes = await request(app.getHttpServer())
.post('/api/v1/auth/login') .post('/api/v1/auth/login')
.send({ email: 'venuea@test.com', password: 'Pass123!' }) .send({ email, password: 'Pass123!' })
.expect(200); .expect(200);
const token = loginRes.body.accessToken; const token = loginRes.body.accessToken;
@@ -116,11 +116,9 @@ describe('KoolTap API (e2e)', () => {
await request(app.getHttpServer()) await request(app.getHttpServer())
.get(`/api/v1/venues/${venueBId}/tabs`) .get(`/api/v1/venues/${venueBId}/tabs`)
.set('Authorization', `Bearer ${token}`) .set('Authorization', `Bearer ${token}`)
.expect(200) // Should return empty - venue scoping is by user venueId, not by URL param .expect(403)
.expect((res) => { .expect((res) => {
// The endpoint uses the user's venueId from JWT expect(res.body.error.code).toBe('venue_forbidden');
// But for cross-venue tests we need the endpoint to verify
expect(Array.isArray(res.body.tabs)).toBe(true);
}); });
}); });
}); });
+3
View File
@@ -2,6 +2,9 @@
"moduleFileExtensions": ["js", "json", "ts"], "moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".", "rootDir": ".",
"testEnvironment": "node", "testEnvironment": "node",
"moduleNameMapper": {
"^@kooltap/shared$": "<rootDir>/../../../packages/shared/src"
},
"testRegex": ".e2e-spec.ts$", "testRegex": ".e2e-spec.ts$",
"transform": { "transform": {
"^.+\\.(t|j)s$": "ts-jest" "^.+\\.(t|j)s$": "ts-jest"
@@ -1,9 +1,8 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { commandsApi } from '@/lib/api/client';
import { api } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -16,6 +15,8 @@ import { ErrorState } from '@/components/error-state';
import { Filter } from 'lucide-react'; import { Filter } from 'lucide-react';
import { useRealtime } from '@/lib/realtime'; import { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { formatCents } from '@/lib/utils';
const statusFilters = [ const statusFilters = [
{ value: '', label: 'commands.all' }, { value: '', label: 'commands.all' },
@@ -29,40 +30,24 @@ const statusFilters = [
export default function CommandsPage() { export default function CommandsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down // Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime(); const { connected: live } = useRealtime();
const { t, tn, relativeTime } = useLang(); const { t, tn, te, relativeTime } = useLang();
const router = useRouter(); const { venueId } = useSession();
const [venueId, setVenueId] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const limit = 20; 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({ const { data, isLoading, error } = useQuery({
queryKey: ['commands', venueId, statusFilter, page], queryKey: ['commands', venueId, statusFilter, page],
queryFn: () => queryFn: () => commandsApi.list(venueId, { limit, offset: page * limit, status: statusFilter || undefined }),
api(`/venues/${venueId}/commands?limit=${limit}&offset=${page * limit}${statusFilter ? `&status=${statusFilter}` : ''}`),
enabled: !!venueId,
refetchInterval: live ? false : 10000, refetchInterval: live ? false : 10000,
}); });
const commands = data?.commands || []; const commands = data?.commands ?? [];
const total = data?.total || 0; const total = data?.total ?? 0;
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || t('commands.load_failed')} />; if (error) return <ErrorState message={te(error, 'commands.load_failed')} />;
return ( return (
<div> <div>
@@ -93,7 +78,8 @@ export default function CommandsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t('commands.tab')}</TableHead> <TableHead>{t('commands.customer')}</TableHead>
<TableHead>{t('commands.amount')}</TableHead>
<TableHead>{t('commands.requested')}</TableHead> <TableHead>{t('commands.requested')}</TableHead>
<TableHead>{t('commands.payment')}</TableHead> <TableHead>{t('commands.payment')}</TableHead>
<TableHead>{t('commands.created')}</TableHead> <TableHead>{t('commands.created')}</TableHead>
@@ -103,9 +89,10 @@ export default function CommandsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{commands.map((cmd: any) => ( {commands.map((cmd) => (
<TableRow key={cmd.id}> <TableRow key={cmd.id}>
<TableCell className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</TableCell> <TableCell className="font-medium">{cmd.customerName ?? <span className="font-mono text-xs">{cmd.tabId.substring(0, 8)}</span>}</TableCell>
<TableCell className="tabular-nums">{cmd.amountInCents != null ? formatCents(cmd.amountInCents) : '-'}</TableCell>
<TableCell>{cmd.requestedBy}</TableCell> <TableCell>{cmd.requestedBy}</TableCell>
<TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell> <TableCell><PaymentMethodBadge method={cmd.paymentMethod} /></TableCell>
<TableCell>{relativeTime(cmd.createdAt)}</TableCell> <TableCell>{relativeTime(cmd.createdAt)}</TableCell>
+19 -33
View File
@@ -18,9 +18,11 @@ import { Button } from '@/components/ui/button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { LanguageSelector } from '@/components/language-selector'; 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 { toast } from '@/components/ui/toast';
import { RealtimeProvider, useRealtime } from '@/lib/realtime'; import { RealtimeProvider, useRealtime } from '@/lib/realtime';
import { readSession, Session, SessionProvider } from '@/lib/session';
import { LoadingState } from '@/components/loading-state';
const navItems = [ const navItems = [
{ href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false }, { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, adminOnly: false },
@@ -39,39 +41,22 @@ export default function DashboardLayout({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
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<Session | null>(null);
const [venue, setVenue] = useState<{ name: string } | null>(null); const [venue, setVenue] = useState<{ name: string } | null>(null);
const { t } = useLang(); const { t } = useLang();
useEffect(() => { useEffect(() => {
// Check auth const session = readSession();
const cookie = document.cookie if (!session) {
.split('; ') router.replace('/login');
.find((row) => row.startsWith('access_token='));
if (!cookie) {
router.push('/login');
return; return;
} }
setUser(session);
// Decode JWT to get basic user info venuesApi.get(session.venueId).then(setVenue).catch(() => {});
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');
}
}, [router]); }, [router]);
if (!user) return <LoadingState />;
function handleLogout() { function handleLogout() {
authApi.logout(); authApi.logout();
toast({ title: t('common.logged_out'), variant: 'default' }); toast({ title: t('common.logged_out'), variant: 'default' });
@@ -79,6 +64,7 @@ export default function DashboardLayout({
} }
return ( return (
<SessionProvider session={user}>
<RealtimeProvider> <RealtimeProvider>
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Mobile sidebar toggle */} {/* Mobile sidebar toggle */}
@@ -118,7 +104,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.adminOnly && user?.role !== 'admin') { if (item.adminOnly && user.role !== 'admin') {
return null; return null;
} }
@@ -142,13 +128,12 @@ export default function DashboardLayout({
</nav> </nav>
<div className="p-4 border-t"> <div className="p-4 border-t">
<LiveIndicator />
<LanguageSelector /> <LanguageSelector />
{user && ( <div className="mb-3 text-sm">
<div className="mb-3 text-sm"> <p className="font-medium truncate">{user.email}</p>
<p className="font-medium truncate">{user.name}</p> <p className="text-muted-foreground">{t(`users.role.${user.role}`)}</p>
<p className="text-muted-foreground capitalize">{user.role}</p> </div>
</div>
)}
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start gap-3" className="w-full justify-start gap-3"
@@ -167,6 +152,7 @@ export default function DashboardLayout({
</main> </main>
</div> </div>
</RealtimeProvider> </RealtimeProvider>
</SessionProvider>
); );
} }
+47 -43
View File
@@ -1,80 +1,67 @@
'use client'; 'use client';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { commandsApi, devicesApi, tabsApi } from '@/lib/api/client';
import { useRouter } from 'next/navigation';
import { api } 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 { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { LoadingState } from '@/components/loading-state';
import { CommandStatusBadge } from '@/components/command-status-badge'; 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 { useRealtime } from '@/lib/realtime';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { formatCents } from '@/lib/utils';
export default function DashboardPage() { export default function DashboardPage() {
// Live updates make polling unnecessary; it only runs while the socket is down // Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime(); const { connected: live } = useRealtime();
const { t, relativeTime } = useLang(); const { t, relativeTime } = useLang();
const router = useRouter(); const { venueId } = useSession();
const [venueId, setVenueId] = useState<string | null>(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 tabsQuery = useQuery({ const tabsQuery = useQuery({
queryKey: ['tabs', venueId], queryKey: ['tabs', venueId],
queryFn: () => api(`/venues/${venueId}/tabs`), queryFn: () => tabsApi.list(venueId),
enabled: !!venueId,
refetchInterval: live ? false : 10000, refetchInterval: live ? false : 10000,
}); });
const deviceQuery = useQuery({ const deviceQuery = useQuery({
queryKey: ['device', venueId], queryKey: ['device', venueId],
queryFn: () => api(`/venues/${venueId}/devices`), queryFn: () => devicesApi.get(venueId),
enabled: !!venueId,
// A terminal going offline sends no event, so its status is always polled // A terminal going offline sends no event, so its status is always polled
refetchInterval: 30000, refetchInterval: 30000,
}); });
const commandsQuery = useQuery({ const recentQuery = useQuery({
queryKey: ['commands', venueId, 'recent'], queryKey: ['commands', venueId, 'recent'],
queryFn: () => api(`/venues/${venueId}/commands?limit=5`), queryFn: () => commandsApi.list(venueId, { limit: 5 }),
enabled: !!venueId,
refetchInterval: live ? false : 15000, refetchInterval: live ? false : 15000,
}); });
if (!venueId) return <LoadingState />; 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 device = deviceQuery.data?.device;
const commands = commandsQuery.data?.commands || []; const commands = recentQuery.data?.commands ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const pendingCommands = commands.filter((c: any) => c.status === 'pending'); const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || pendingQuery.isLoading;
const isLoading = tabsQuery.isLoading || deviceQuery.isLoading || commandsQuery.isLoading;
return ( return (
<div> <div>
<PageHeader title={t('overview.title')} description="" /> <PageHeader title={t('overview.title')} description="" />
{isLoading ? ( {isLoading ? (
<div className="grid gap-4 md:grid-cols-3 mb-8"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
{Array.from({ length: 3 }).map((_, i) => ( {Array.from({ length: 4 }).map((_, i) => (
<Card key={i}><CardHeader className="flex flex-row items-center justify-between pb-2"><Skeleton className="h-4 w-24" /><Skeleton className="h-4 w-4" /></CardHeader><CardContent><Skeleton className="h-8 w-16" /></CardContent></Card> <Card key={i}><CardHeader className="flex flex-row items-center justify-between pb-2"><Skeleton className="h-4 w-24" /><Skeleton className="h-4 w-4" /></CardHeader><CardContent><Skeleton className="h-8 w-16" /></CardContent></Card>
))} ))}
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-3 mb-8"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{t('overview.open_tabs')}</CardTitle> <CardTitle className="text-sm font-medium">{t('overview.open_tabs')}</CardTitle>
@@ -82,14 +69,30 @@ export default function DashboardPage() {
</CardHeader> </CardHeader>
<CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent> <CardContent><div className="text-2xl font-bold">{tabs.length}</div></CardContent>
</Card> </Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{t('overview.outstanding')}</CardTitle>
<Euro className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent><div className="text-2xl font-bold tabular-nums">{formatCents(outstanding)}</div></CardContent>
</Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{t('overview.device')}</CardTitle> <CardTitle className="text-sm font-medium">{t('overview.device')}</CardTitle>
<Monitor className="h-4 w-4 text-muted-foreground" /> <Monitor className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{device?.name || t('overview.not_connected')}</div> {device ? (
{device && <p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>} <>
<div className="flex items-center gap-2">
<span className="text-2xl font-bold truncate">{device.name}</span>
<DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
</div>
<p className="text-xs text-muted-foreground mt-1">{t('overview.last_seen', { time: relativeTime(device.lastSeenAt) })}</p>
</>
) : (
<div className="text-2xl font-bold">{t('overview.not_connected')}</div>
)}
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
@@ -97,7 +100,7 @@ export default function DashboardPage() {
<CardTitle className="text-sm font-medium">{t('overview.pending')}</CardTitle> <CardTitle className="text-sm font-medium">{t('overview.pending')}</CardTitle>
<Terminal className="h-4 w-4 text-muted-foreground" /> <Terminal className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent><div className="text-2xl font-bold">{pendingCommands.length}</div></CardContent> <CardContent><div className="text-2xl font-bold">{pendingQuery.data?.total ?? 0}</div></CardContent>
</Card> </Card>
</div> </div>
)} )}
@@ -109,13 +112,14 @@ export default function DashboardPage() {
<p className="text-sm text-muted-foreground">{t('overview.no_commands')}</p> <p className="text-sm text-muted-foreground">{t('overview.no_commands')}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{commands.map((cmd: any) => ( {commands.map((cmd) => (
<div key={cmd.id} className="flex items-center justify-between text-sm"> <div key={cmd.id} className="flex items-center justify-between gap-2 text-sm">
<div> <div className="min-w-0">
<span className="font-medium">{cmd.tabId?.substring(0, 8)}</span> <span className="font-medium">{cmd.customerName ?? cmd.tabId.substring(0, 8)}</span>
{cmd.amountInCents != null && <span className="ml-2 tabular-nums">{formatCents(cmd.amountInCents)}</span>}
<span className="text-muted-foreground ml-2">{cmd.requestedBy}</span> <span className="text-muted-foreground ml-2">{cmd.requestedBy}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 shrink-0">
<CommandStatusBadge status={cmd.status} /> <CommandStatusBadge status={cmd.status} />
<span className="text-muted-foreground">{relativeTime(cmd.createdAt)}</span> <span className="text-muted-foreground">{relativeTime(cmd.createdAt)}</span>
</div> </div>
@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { remindersApi } from '@/lib/api/client'; import { 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';
@@ -11,43 +10,34 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { EmptyState } from '@/components/empty-state'; import { EmptyState } from '@/components/empty-state';
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 } from '@/lib/utils'; import { formatCents } from '@/lib/utils';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { Send, History } from 'lucide-react'; import { Send, History } from 'lucide-react';
export default function RemindersPage() { export default function RemindersPage() {
const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { t, relativeTime } = useLang(); const { t, te, relativeTime } = useLang();
const [venueId, setVenueId] = useState<string | null>(null); const { venueId, role } = useSession();
const [sendDialogOpen, setSendDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [showHistory, setShowHistory] = 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({ const candidatesQuery = useQuery({
queryKey: ['reminder-candidates', venueId], queryKey: ['reminder-candidates', venueId],
queryFn: () => remindersApi.candidates(venueId!), queryFn: () => remindersApi.candidates(venueId),
enabled: !!venueId,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ['reminder-history', venueId], queryKey: ['reminder-history', venueId],
queryFn: () => remindersApi.history(venueId!), queryFn: () => remindersApi.history(venueId),
enabled: !!venueId && showHistory, enabled: showHistory,
}); });
const sendMutation = useMutation({ const sendMutation = useMutation({
mutationFn: () => remindersApi.send(venueId!), mutationFn: () => remindersApi.send(venueId),
onSuccess: (result: any) => { onSuccess: (result) => {
setSendDialogOpen(false); setSendDialogOpen(false);
if (result.errors && result.errors.length > 0) { if (result.errors && result.errors.length > 0) {
const firstError = result.errors[0]; const firstError = result.errors[0];
@@ -60,15 +50,14 @@ export default function RemindersPage() {
queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] }); queryClient.invalidateQueries({ queryKey: ['reminder-candidates'] });
queryClient.invalidateQueries({ queryKey: ['reminder-history'] }); queryClient.invalidateQueries({ queryKey: ['reminder-history'] });
}, },
onError: (err: any) => { onError: (err) => {
toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' }); setSendDialogOpen(false);
toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
}, },
}); });
if (!venueId) return <LoadingState />; const candidates = candidatesQuery.data?.candidates ?? [];
const reminders = historyQuery.data?.reminders ?? [];
const candidates = candidatesQuery.data?.candidates || [];
const reminders = historyQuery.data?.reminders || [];
return ( return (
<div> <div>
@@ -106,13 +95,13 @@ export default function RemindersPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{reminders.map((r: any) => ( {reminders.map((r) => (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell className="font-medium">{r.customerName}</TableCell> <TableCell className="font-medium">{r.customerName}</TableCell>
<TableCell>{r.email}</TableCell> <TableCell>{r.email}</TableCell>
<TableCell>{formatCents(r.amountInCents)}</TableCell> <TableCell>{formatCents(r.amountInCents)}</TableCell>
<TableCell>{relativeTime(r.sentAt)}</TableCell> <TableCell>{relativeTime(r.sentAt)}</TableCell>
<TableCell><Badge variant="success">{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell> <TableCell><Badge variant={r.status === 'sent' ? 'success' : 'secondary'}>{r.status === 'sent' ? t('reminders.status_sent') : r.status}</Badge></TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -149,7 +138,7 @@ export default function RemindersPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{candidates.map((c: any) => ( {candidates.map((c) => (
<TableRow key={c.tabId}> <TableRow key={c.tabId}>
<TableCell className="font-medium">{c.customerName}</TableCell> <TableCell className="font-medium">{c.customerName}</TableCell>
<TableCell>{c.email}</TableCell> <TableCell>{c.email}</TableCell>
@@ -161,10 +150,14 @@ export default function RemindersPage() {
</Table> </Table>
</div> </div>
<Button onClick={() => setSendDialogOpen(true)}> {role === 'admin' ? (
<Send className="h-4 w-4 mr-2" /> <Button onClick={() => setSendDialogOpen(true)}>
{t('reminders.send')} ({candidates.length}) <Send className="h-4 w-4 mr-2" />
</Button> {t('reminders.send')} ({candidates.length})
</Button>
) : (
<p className="text-sm text-muted-foreground">{t('reminders.admin_only')}</p>
)}
</> </>
)} )}
</CardContent> </CardContent>
@@ -184,7 +177,7 @@ export default function RemindersPage() {
{t('reminders.confirm', { count: candidates.length })} {t('reminders.confirm', { count: candidates.length })}
</p> </p>
<ul className="mt-3 space-y-1"> <ul className="mt-3 space-y-1">
{candidates.map((c: any) => ( {candidates.map((c) => (
<li key={c.tabId} className="text-sm flex items-center gap-2"> <li key={c.tabId} className="text-sm flex items-center gap-2">
<span className="font-medium">{c.customerName}</span> <span className="font-medium">{c.customerName}</span>
<span className="text-muted-foreground">({c.email})</span> <span className="text-muted-foreground">({c.email})</span>
@@ -1,44 +1,32 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation'; import { devicesApi, venuesApi } from '@/lib/api/client';
import { api, devicesApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from '@/components/ui/toast'; import { toast } from '@/components/ui/toast';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { Monitor, Copy, Plus } from 'lucide-react'; import { Monitor, Copy, Plus } from 'lucide-react';
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const { venueId, role } = useSession();
const [venueId, setVenueId] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null); const [regDialog, setRegDialog] = useState<{ open: boolean; code: string; expiresAt: string } | null>(null);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const { t, relativeTime, dateTime, time } = useLang(); const { t, te, 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 { data: venue } = useQuery({ const { data: venue } = useQuery({
queryKey: ['venue', venueId], queryKey: ['venue', venueId],
queryFn: () => api(`/venues/${venueId}`), queryFn: () => venuesApi.get(venueId),
enabled: !!venueId,
}); });
const { data: deviceData } = useQuery({ const { data: deviceData } = useQuery({
queryKey: ['device', venueId], queryKey: ['device', venueId],
queryFn: () => devicesApi.get(venueId!), queryFn: () => devicesApi.get(venueId),
enabled: !!venueId,
// A terminal going offline sends no event, so its status is always polled // A terminal going offline sends no event, so its status is always polled
refetchInterval: 15000, refetchInterval: 15000,
}); });
@@ -50,8 +38,8 @@ export default function SettingsPage() {
try { try {
const result = await devicesApi.generateCode(); const result = await devicesApi.generateCode();
setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt }); setRegDialog({ open: true, code: result.code, expiresAt: result.expiresAt });
} catch (err: any) { } catch (err) {
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' }); toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} finally { } finally {
setGenerating(false); setGenerating(false);
} }
@@ -91,8 +79,8 @@ export default function SettingsPage() {
<CardContent> <CardContent>
{device ? ( {device ? (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Monitor className="h-4 w-4 text-green-600" /> <Monitor className="h-4 w-4 text-muted-foreground" />
<Badge variant="success">{t('settings.connected')}</Badge> <DeviceStatusBadge lastSeenAt={device.lastSeenAt} />
<span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span> <span className="text-muted-foreground">{t('settings.since', { date: dateTime(device.createdAt) })}</span>
</div> </div>
) : ( ) : (
@@ -100,7 +88,7 @@ export default function SettingsPage() {
{t('settings.pair_desc')} {t('settings.pair_desc')}
</p> </p>
)} )}
{userRole === 'admin' && ( {role === 'admin' && (
<Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3"> <Button onClick={handleGenerateCode} disabled={generating} size="sm" className="mt-3">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
{generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')} {generating ? t('settings.generating') : device ? t('settings.repair') : t('settings.pair')}
+210 -161
View File
@@ -2,155 +2,177 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Fragment, useEffect, useState } from 'react'; import { Fragment, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { commandsApi, plannedTabsApi, remindersApi, tabsApi, type Tab, type TabLine } 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';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { EmptyState } from '@/components/empty-state'; import { EmptyState } from '@/components/empty-state';
import { LoadingState } from '@/components/loading-state'; 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 } from '@/lib/utils'; import { formatCents } from '@/lib/utils';
import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown } from 'lucide-react'; import { RefreshCw, Plus, X, Users, Mail, Send, Upload, ChevronRight, ChevronDown, Search } from 'lucide-react';
import { ImportCustomersDialog } from '@/components/import-customers-dialog'; import { ImportCustomersDialog } from '@/components/import-customers-dialog';
import { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useRealtime } from '@/lib/realtime'; import { useRealtime } from '@/lib/realtime';
import { useSession } from '@/lib/session';
type SortKey = 'name' | 'amount';
const comparators: Record<SortKey, (a: Tab, b: Tab) => number> = {
name: (a, b) => a.customerName.localeCompare(b.customerName, undefined, { sensitivity: 'base' }),
amount: (a, b) => b.totalInCents - a.totalInCents,
};
export default function TabsPage() { export default function TabsPage() {
// Live updates make polling unnecessary; it only runs while the socket is down // Live updates make polling unnecessary; it only runs while the socket is down
const { connected: live } = useRealtime(); const { connected: live } = useRealtime();
const router = useRouter(); const { venueId, role } = useSession();
const isAdmin = role === 'admin';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null); const [closeTab, setCloseTab] = useState<Tab | null>(null);
const [closeDialog, setCloseDialog] = useState<{ open: boolean; tab: any }>({ open: false, tab: null }); const [paymentMethod, setPaymentMethod] = useState('');
const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set()); const [expandedTabs, setExpandedTabs] = useState<Set<string>>(new Set());
const [paymentMethod, setPaymentMethod] = useState<string>(''); const [watchedCommandIds, setWatchedCommandIds] = useState<Set<string>>(new Set());
const [creating, setCreating] = useState(false); const [search, setSearch] = useState('');
const [pendingCommandIds, setPendingCommandIds] = useState<Set<string>>(new Set()); const [sort, setSort] = useState<SortKey>('name');
const [plannedName, setPlannedName] = useState(''); const [plannedName, setPlannedName] = useState('');
const [plannedEmail, setPlannedEmail] = useState(''); const [plannedEmail, setPlannedEmail] = useState('');
const [userRole, setUserRole] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const { t, tn, relativeTime } = useLang(); const { t, tn, te, relativeTime } = 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 { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['tabs', venueId], queryKey: ['tabs', venueId],
queryFn: () => api(`/venues/${venueId}/tabs`), queryFn: () => tabsApi.list(venueId),
enabled: !!venueId,
refetchInterval: live ? false : 10000, refetchInterval: live ? false : 10000,
}); });
const commandsQuery = useQuery({ const pendingQuery = useQuery({
queryKey: ['commands', venueId, 'pending'], queryKey: ['commands', venueId, 'pending'],
queryFn: () => api(`/venues/${venueId}/commands?status=pending`), queryFn: () => commandsApi.list(venueId, { status: 'pending', limit: 200 }),
enabled: !!venueId && pendingCommandIds.size > 0, refetchInterval: live ? false : 5000,
});
const closingTabIds = new Set((pendingQuery.data?.commands ?? []).map((c) => c.tabId));
// Only needed to report the outcome of close requests sent from this page
const resultsQuery = useQuery({
queryKey: ['commands', venueId, 'results'],
queryFn: () => commandsApi.list(venueId, { limit: 50 }),
enabled: watchedCommandIds.size > 0,
refetchInterval: live ? false : 3000, refetchInterval: live ? false : 3000,
}); });
const plannedQuery = useQuery({ const plannedQuery = useQuery({
queryKey: ['planned-tabs', venueId], queryKey: ['planned-tabs', venueId],
queryFn: () => plannedTabsApi.list(venueId!), queryFn: () => plannedTabsApi.list(venueId),
enabled: !!venueId,
refetchInterval: live ? false : 10000, refetchInterval: live ? false : 10000,
}); });
// Tiles only show customers the terminal has not opened a tab for yet // Tiles only show customers the terminal has not opened a tab for yet
const pendingPlanned = (plannedQuery.data?.plannedTabs || []).filter((pt: any) => !pt.deliveredAt); const pendingPlanned = (plannedQuery.data?.plannedTabs ?? []).filter((pt) => !pt.deliveredAt);
useEffect(() => { useEffect(() => {
if (commandsQuery.data?.commands) { const finished = (resultsQuery.data?.commands ?? []).filter((c) => watchedCommandIds.has(c.id) && c.status !== 'pending');
for (const cmd of commandsQuery.data.commands) { if (finished.length === 0) return;
if (cmd.status !== 'pending' && pendingCommandIds.has(cmd.id)) { for (const cmd of finished) {
const newSet = new Set(pendingCommandIds); const status = t(`status.${cmd.status}`);
newSet.delete(cmd.id); toast({
setPendingCommandIds(newSet); title: status,
const customerName = data?.tabs?.find((tab: any) => tab.tabId === cmd.tabId)?.customerName ?? cmd.tabId?.substring(0, 8); description: t('tabs.request_result', { name: cmd.customerName ?? cmd.tabId.substring(0, 8), status }),
toast({ title: t(`status.${cmd.status}`), description: t('tabs.request_result', { name: customerName, status: t(`status.${cmd.status}`) }), variant: cmd.status === 'completed' ? 'success' : 'destructive' }); variant: cmd.status === 'completed' ? 'success' : 'destructive',
queryClient.invalidateQueries({ queryKey: ['tabs'] }); });
queryClient.invalidateQueries({ queryKey: ['commands'] });
}
}
} }
}, [commandsQuery.data, pendingCommandIds, queryClient, data, t]); setWatchedCommandIds((current) => {
const next = new Set(current);
for (const cmd of finished) next.delete(cmd.id);
return next;
});
queryClient.invalidateQueries({ queryKey: ['tabs'] });
}, [resultsQuery.data, watchedCommandIds, queryClient, t]);
const createCommandMutation = useMutation({ const createCommandMutation = useMutation({
mutationFn: (data: { tabId: string; expectedVersion: number; paymentMethod: string }) => mutationFn: ({ tab, paymentMethod }: { tab: Tab; paymentMethod: string }) =>
commandsApi.create(venueId!, { ...data, type: 'close_tab' }), commandsApi.create(venueId, { tabId: tab.tabId, expectedVersion: tab.version, paymentMethod, type: 'close_tab' }),
onSuccess: (result) => { onSuccess: (result) => {
setPendingCommandIds(new Set([...pendingCommandIds, result.id])); setWatchedCommandIds((current) => new Set(current).add(result.id));
toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' }); toast({ title: t('tabs.request_sent'), description: result.warning || t('tabs.request_sent_desc'), variant: result.warning ? 'default' : 'success' });
queryClient.invalidateQueries({ queryKey: ['commands'] });
}, },
onError: (err: any) => { onError: (err) => {
toast({ title: t('common.failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' }); toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
queryClient.invalidateQueries({ queryKey: ['tabs'] });
},
onSettled: () => {
setCloseTab(null);
setPaymentMethod('');
queryClient.invalidateQueries({ queryKey: ['commands'] });
}, },
}); });
function openCloseDialog(tab: Tab) {
setPaymentMethod('');
setCloseTab(tab);
}
function confirmClose() { function confirmClose() {
if (!paymentMethod) return; if (!closeTab || !paymentMethod) return;
setCreating(true); createCommandMutation.mutate({ tab: closeTab, paymentMethod });
createCommandMutation.mutate({ tabId: closeDialog.tab.tabId, expectedVersion: closeDialog.tab.version, paymentMethod }, {
onSettled: () => { setCreating(false); setCloseDialog({ open: false, tab: null }); setPaymentMethod(''); },
});
} }
async function addPlannedName() { async function addPlannedName() {
if (!plannedName.trim()) return; const name = plannedName.trim();
if (!name) return;
try { try {
await plannedTabsApi.create(venueId!, plannedName.trim(), plannedEmail.trim() || undefined); await plannedTabsApi.create(venueId, name, plannedEmail.trim() || undefined);
setPlannedName(''); setPlannedName('');
setPlannedEmail(''); setPlannedEmail('');
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
toast({ title: t('planned.added'), description: t('planned.added_desc', { name: plannedName.trim() }) }); toast({ title: t('planned.added'), description: t('planned.added_desc', { name }) });
} catch (err: any) { } catch (err) {
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' }); toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} }
} }
async function removePlannedName(id: string) { async function removePlannedName(id: string) {
try { try {
await plannedTabsApi.remove(venueId!, id); await plannedTabsApi.remove(venueId, id);
queryClient.invalidateQueries({ queryKey: ['planned-tabs'] }); queryClient.invalidateQueries({ queryKey: ['planned-tabs'] });
} catch (err: any) { } catch (err) {
toast({ title: t('common.failed'), description: err.message, variant: 'destructive' }); toast({ title: t('common.failed'), description: te(err), variant: 'destructive' });
} }
} }
const sendTestMutation = useMutation({ const sendTestMutation = useMutation({
mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId!, plannedTabId), mutationFn: (plannedTabId: string) => remindersApi.sendTest(venueId, plannedTabId),
onSuccess: (result) => { onSuccess: (result) => {
toast({ title: t('tabs.test_sent'), description: t('tabs.test_sent_desc', { email: result.email }), variant: 'success' }); toast({ title: t('tabs.test_sent'), description: t('tabs.test_sent_desc', { email: result.email }), variant: 'success' });
}, },
onError: (err: any) => { onError: (err) => {
toast({ title: t('tabs.test_failed'), description: err.message || t('common.unknown_error'), variant: 'destructive' }); toast({ title: t('tabs.test_failed'), description: te(err), variant: 'destructive' });
}, },
}); });
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || t('tabs.load_failed')} />; if (error) return <ErrorState message={te(error, 'tabs.load_failed')} />;
const tabs = data?.tabs || []; const tabs = data?.tabs ?? [];
const outstanding = tabs.reduce((sum, tab) => sum + tab.totalInCents, 0);
const query = search.trim().toLowerCase();
const visibleTabs = tabs
.filter((tab) => !query || tab.customerName.toLowerCase().includes(query))
.sort(comparators[sort]);
return ( return (
<div> <div>
<PageHeader <PageHeader
title={t('tabs.title')} title={t('tabs.title')}
description={tn('tabs.count', tabs.length)} description={`${tn('tabs.count', tabs.length)} · ${t('tabs.outstanding', { amount: formatCents(outstanding) })}`}
actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>} actions={<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['tabs'] })}><RefreshCw className="h-4 w-4 mr-2" />{t('common.refresh')}</Button>}
/> />
@@ -159,7 +181,7 @@ export default function TabsPage() {
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle> <CardTitle className="text-sm flex items-center gap-2"><Users className="h-4 w-4" /> {t('planned.title')}</CardTitle>
{userRole === 'admin' && ( {isAdmin && (
<Button variant="outline" size="sm" onClick={() => setImportOpen(true)}> <Button variant="outline" size="sm" onClick={() => setImportOpen(true)}>
<Upload className="h-4 w-4 mr-2" />{t('import.button')} <Upload className="h-4 w-4 mr-2" />{t('import.button')}
</Button> </Button>
@@ -167,7 +189,7 @@ export default function TabsPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{userRole === 'admin' && ( {isAdmin && (
<div className="flex gap-2 mb-3 flex-wrap"> <div className="flex gap-2 mb-3 flex-wrap">
<Input <Input
placeholder={t('planned.name_placeholder')} placeholder={t('planned.name_placeholder')}
@@ -193,11 +215,11 @@ export default function TabsPage() {
{plannedQuery.data && pendingPlanned.length === 0 && ( {plannedQuery.data && pendingPlanned.length === 0 && (
<p className="text-sm text-muted-foreground">{t('planned.waiting_empty')}</p> <p className="text-sm text-muted-foreground">{t('planned.waiting_empty')}</p>
)} )}
{pendingPlanned.map((pt: any) => ( {pendingPlanned.map((pt) => (
<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" />} {pt.email && <Mail className="h-3 w-3 text-muted-foreground" />}
{userRole === 'admin' && pt.email && ( {isAdmin && pt.email && (
<button <button
onClick={() => sendTestMutation.mutate(pt.id)} onClick={() => sendTestMutation.mutate(pt.id)}
disabled={sendTestMutation.isPending} disabled={sendTestMutation.isPending}
@@ -208,7 +230,7 @@ export default function TabsPage() {
<Send className="h-3 w-3" /> <Send className="h-3 w-3" />
</button> </button>
)} )}
{userRole === 'admin' && ( {isAdmin && (
<button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5" aria-label={t('planned.remove', { name: pt.customerName })}> <button onClick={() => removePlannedName(pt.id)} className="text-muted-foreground hover:text-destructive ml-0.5" aria-label={t('planned.remove', { name: pt.customerName })}>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
@@ -222,105 +244,134 @@ export default function TabsPage() {
{tabs.length === 0 ? ( {tabs.length === 0 ? (
<EmptyState title={t('tabs.empty')} description={t('tabs.empty_desc')} /> <EmptyState title={t('tabs.empty')} description={t('tabs.empty_desc')} />
) : ( ) : (
<div className="rounded-md border"> <>
<Table> <div className="flex gap-2 mb-3 flex-wrap">
<TableHeader> <div className="relative flex-1 min-w-[12rem] max-w-sm">
<TableRow> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<TableHead>{t('tabs.customer')}</TableHead> <Input
<TableHead>{t('tabs.email')}</TableHead> type="search"
<TableHead>{t('tabs.amount')}</TableHead> placeholder={t('tabs.search_placeholder')}
<TableHead>{t('tabs.items')}</TableHead> value={search}
<TableHead>{t('tabs.version')}</TableHead> onChange={(e) => setSearch(e.target.value)}
<TableHead>{t('tabs.reported')}</TableHead> className="pl-8"
<TableHead className="text-right">{t('tabs.actions')}</TableHead> aria-label={t('tabs.search_placeholder')}
</TableRow> />
</TableHeader> </div>
<TableBody> <Select value={sort} onValueChange={(value) => setSort(value as SortKey)}>
{tabs.map((tab: any) => { <SelectTrigger className="w-44" aria-label={t('tabs.sort')}><SelectValue /></SelectTrigger>
const expanded = expandedTabs.has(tab.tabId); <SelectContent>
const toggle = () => setExpandedTabs((current) => { <SelectItem value="name">{t('tabs.sort_name')}</SelectItem>
const next = new Set(current); <SelectItem value="amount">{t('tabs.sort_amount')}</SelectItem>
if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId); </SelectContent>
return next; </Select>
}); </div>
return (
<Fragment key={tab.tabId}> <div className="rounded-md border">
<Table>
<TableHeader>
<TableRow> <TableRow>
<TableCell className="font-medium"> <TableHead>{t('tabs.customer')}</TableHead>
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={t(expanded ? 'tabs.hide_items' : 'tabs.show_items', { name: tab.customerName })}> <TableHead>{t('tabs.email')}</TableHead>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} <TableHead>{t('tabs.amount')}</TableHead>
{tab.customerName} <TableHead>{t('tabs.items')}</TableHead>
</button> <TableHead>{t('tabs.reported')}</TableHead>
</TableCell> <TableHead className="text-right">{t('tabs.actions')}</TableHead>
<TableCell>
{tab.email ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
<span className="truncate max-w-[200px]">{tab.email}</span>
{userRole === 'admin' && tab.plannedTabId && (
<button
onClick={() => sendTestMutation.mutate(tab.plannedTabId)}
disabled={sendTestMutation.isPending}
className="hover:text-primary"
title={t('tabs.send_test')}
aria-label={t('tabs.send_test')}
>
<Send className="h-3 w-3" />
</button>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell>{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{tab.version}</TableCell>
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
{/* 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(''); }}>{t('tabs.close')}</Button>
</TableCell>
</TableRow> </TableRow>
{expanded && ( </TableHeader>
<TableRow className="bg-muted/30 hover:bg-muted/30"> <TableBody>
<TableCell colSpan={7} className="py-3 pl-4 sm:pl-10"> {visibleTabs.length === 0 && (
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */} <TableRow>
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]"> <TableCell colSpan={6} className="py-6 text-center text-sm text-muted-foreground">
<TabLines items={tab.items} /> {t('tabs.no_matches', { query: search.trim() })}
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
</Fragment> {visibleTabs.map((tab) => {
); const expanded = expandedTabs.has(tab.tabId);
})} const toggle = () => setExpandedTabs((current) => {
</TableBody> const next = new Set(current);
</Table> if (next.has(tab.tabId)) next.delete(tab.tabId); else next.add(tab.tabId);
</div> return next;
});
return (
<Fragment key={tab.tabId}>
<TableRow>
<TableCell className="font-medium">
<button onClick={toggle} className="flex items-center gap-1 hover:text-primary" aria-expanded={expanded} aria-label={t(expanded ? 'tabs.hide_items' : 'tabs.show_items', { name: tab.customerName })}>
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
{tab.customerName}
</button>
</TableCell>
<TableCell>
{tab.email ? (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
<span className="truncate max-w-[200px]">{tab.email}</span>
{isAdmin && tab.plannedTabId && (
<button
onClick={() => sendTestMutation.mutate(tab.plannedTabId!)}
disabled={sendTestMutation.isPending}
className="hover:text-primary"
title={t('tabs.send_test')}
aria-label={t('tabs.send_test')}
>
<Send className="h-3 w-3" />
</button>
)}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="tabular-nums">{formatCents(tab.totalInCents)}</TableCell>
<TableCell>{tab.itemCount}</TableCell>
<TableCell>{relativeTime(tab.lastReportedAt)}</TableCell>
<TableCell className="text-right">
{closingTabIds.has(tab.tabId) ? (
<Badge variant="warning">{t('tabs.closing')}</Badge>
) : (
// Closing settles the tab on the terminal; a tab without a balance has nothing to settle
<Button size="sm" disabled={tab.totalInCents <= 0} onClick={() => openCloseDialog(tab)}>{t('tabs.close')}</Button>
)}
</TableCell>
</TableRow>
{expanded && (
<TableRow className="bg-muted/30 hover:bg-muted/30">
<TableCell colSpan={6} className="py-3 pl-4 sm:pl-10">
{/* Pinned to the visible area so lines stay readable while the table scrolls sideways on phones */}
<div className="sticky left-4 max-w-[min(28rem,calc(100vw-7rem))]">
<TabLines items={tab.items} />
</div>
</TableCell>
</TableRow>
)}
</Fragment>
);
})}
</TableBody>
</Table>
</div>
</>
)} )}
{venueId && ( <ImportCustomersDialog
<ImportCustomersDialog venueId={venueId}
venueId={venueId} open={importOpen}
open={importOpen} onOpenChange={setImportOpen}
onOpenChange={setImportOpen} onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })}
onImported={() => queryClient.invalidateQueries({ queryKey: ['planned-tabs'] })} />
/>
)}
{closeDialog.tab && ( {closeTab && (
<Dialog open={closeDialog.open} onOpenChange={(open) => !open && setCloseDialog({ open: false, tab: null })}> <Dialog open onOpenChange={(open) => !open && !createCommandMutation.isPending && setCloseTab(null)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('tabs.close_title', { name: closeDialog.tab.customerName })}</DialogTitle> <DialogTitle>{t('tabs.close_title', { name: closeTab.customerName })}</DialogTitle>
<DialogDescription>{t('tabs.close_desc')}</DialogDescription> <DialogDescription>{t('tabs.close_desc')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-4"> <div className="space-y-3 py-4">
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeDialog.tab.totalInCents)}</span></div> <div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.amount')}</span><span className="font-semibold">{formatCents(closeTab.totalInCents)}</span></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeDialog.tab.itemCount}</span></div> <div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.items')}</span><span>{closeTab.itemCount}</span></div>
<div className="rounded-md border p-3"><TabLines items={closeDialog.tab.items} /></div> <div className="rounded-md border p-3"><TabLines items={closeTab.items} /></div>
<div className="flex justify-between text-sm"><span className="text-muted-foreground">{t('tabs.version')}</span><span>{closeDialog.tab.version}</span></div>
<div className="space-y-3 pt-2"> <div className="space-y-3 pt-2">
<Label className="text-base font-semibold">{t('tabs.payment')}</Label> <Label className="text-base font-semibold">{t('tabs.payment')}</Label>
<RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}> <RadioGroup value={paymentMethod} onValueChange={setPaymentMethod}>
@@ -332,8 +383,8 @@ export default function TabsPage() {
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCloseDialog({ open: false, tab: null })} disabled={creating}>{t('common.cancel')}</Button> <Button variant="outline" onClick={() => setCloseTab(null)} disabled={createCommandMutation.isPending}>{t('common.cancel')}</Button>
<Button onClick={confirmClose} disabled={creating || !paymentMethod}>{creating ? t('tabs.sending') : t('tabs.send')}</Button> <Button onClick={confirmClose} disabled={createCommandMutation.isPending || !paymentMethod}>{createCommandMutation.isPending ? t('tabs.sending') : t('tabs.send')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -342,8 +393,6 @@ export default function TabsPage() {
); );
} }
type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
/** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */ /** The lines on a tab, e.g. "10× Cola · €2.50 each · €25.00". */
function TabLines({ items }: { items: TabLine[] | null | undefined }) { function TabLines({ items }: { items: TabLine[] | null | undefined }) {
const { t } = useLang(); const { t } = useLang();
+12 -26
View File
@@ -1,8 +1,7 @@
'use client'; 'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { usersApi } from '@/lib/api/client'; import { usersApi } from '@/lib/api/client';
import { PageHeader } from '@/components/page-header'; import { PageHeader } from '@/components/page-header';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
@@ -17,55 +16,42 @@ 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 { useLang } from '@/lib/i18n'; import { useLang } from '@/lib/i18n';
import { useSession } from '@/lib/session';
import { RefreshCw, Plus } from 'lucide-react'; import { RefreshCw, Plus } from 'lucide-react';
export default function UsersPage() { export default function UsersPage() {
const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [venueId, setVenueId] = useState<string | null>(null); const { venueId, role } = useSession();
const [userRole, setUserRole] = useState<string | null>(null);
const [addDialog, setAddDialog] = useState(false); const [addDialog, setAddDialog] = useState(false);
const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' }); const [form, setForm] = useState({ email: '', password: '', name: '', role: 'viewer' });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const { t, tn, relativeTime } = useLang(); const { t, tn, te, relativeTime } = 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 { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ['users', venueId], queryKey: ['users', venueId],
queryFn: () => usersApi.list(venueId!), queryFn: () => usersApi.list(venueId),
enabled: !!venueId,
}); });
const users = data?.users || []; const users = data?.users ?? [];
async function handleCreateUser() { async function handleCreateUser() {
if (!form.email || !form.password || !form.name) return; if (!form.email || !form.password || !form.name) return;
setSubmitting(true); setSubmitting(true);
try { try {
await usersApi.create(venueId!, form); await usersApi.create(venueId, form);
toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' }); toast({ title: t('users.created_toast'), description: t('users.created_toast_desc', { email: form.email, role: t(`users.role.${form.role}`) }), variant: 'success' });
setAddDialog(false); setAddDialog(false);
setForm({ email: '', password: '', name: '', role: 'viewer' }); setForm({ email: '', password: '', name: '', role: 'viewer' });
queryClient.invalidateQueries({ queryKey: ['users'] }); queryClient.invalidateQueries({ queryKey: ['users'] });
} catch (err: any) { } catch (err) {
toast({ title: t('users.create_failed'), description: err.message, variant: 'destructive' }); toast({ title: t('users.create_failed'), description: te(err), variant: 'destructive' });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
} }
if (!venueId) return <LoadingState />;
if (isLoading) return <LoadingState />; if (isLoading) return <LoadingState />;
if (error) return <ErrorState message={(error as any).message || t('users.load_failed')} />; if (error) return <ErrorState message={te(error, 'users.load_failed')} />;
return ( return (
<div> <div>
@@ -77,7 +63,7 @@ export default function UsersPage() {
<Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}> <Button variant="outline" size="sm" onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')} <RefreshCw className="h-4 w-4 mr-2" /> {t('common.refresh')}
</Button> </Button>
{userRole === 'admin' && ( {role === 'admin' && (
<Button size="sm" onClick={() => setAddDialog(true)}> <Button size="sm" onClick={() => setAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" /> {t('users.add')} <Plus className="h-4 w-4 mr-2" /> {t('users.add')}
</Button> </Button>
@@ -100,7 +86,7 @@ export default function UsersPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((u: any) => ( {users.map((u) => (
<TableRow key={u.id}> <TableRow key={u.id}>
<TableCell className="font-medium">{u.name}</TableCell> <TableCell className="font-medium">{u.name}</TableCell>
<TableCell>{u.email}</TableCell> <TableCell>{u.email}</TableCell>
+3 -3
View File
@@ -16,7 +16,7 @@ export default function LoginPage() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { t } = useLang(); const { t, te } = useLang();
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -27,8 +27,8 @@ export default function LoginPage() {
const result = await authApi.login(email, password); const result = await authApi.login(email, password);
setTokens(result.accessToken, result.refreshToken); setTokens(result.accessToken, result.refreshToken);
router.push('/dashboard'); router.push('/dashboard');
} catch (err: any) { } catch (err) {
setError(err.message || t('auth.invalid')); setError(te(err, 'auth.invalid'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
+7 -12
View File
@@ -1,20 +1,15 @@
'use client'; 'use client';
import { redirect } from 'next/navigation'; import { useEffect } from 'react';
import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation';
import { api } from '@/lib/api/client'; import { readSession } from '@/lib/session';
export default function Home() { export default function Home() {
const router = useRouter();
useEffect(() => { useEffect(() => {
const cookie = document.cookie router.replace(readSession() ? '/dashboard' : '/login');
.split('; ') }, [router]);
.find((row) => row.startsWith('access_token='));
if (cookie) {
redirect('/dashboard');
} else {
redirect('/login');
}
}, []);
return null; return null;
} }
+5 -1
View File
@@ -3,6 +3,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'; import { LanguageProvider } from '@/lib/i18n';
import { Toaster } from '@/components/ui/toast';
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(
@@ -19,7 +20,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<LanguageProvider>{children}</LanguageProvider> <LanguageProvider>
{children}
<Toaster />
</LanguageProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
@@ -25,7 +25,7 @@ interface Props {
} }
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) { export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
const { t } = useLang(); const { t, te } = useLang();
const [fileName, setFileName] = useState<string | null>(null); const [fileName, setFileName] = useState<string | null>(null);
const [sheet, setSheet] = useState<SheetData | null>(null); const [sheet, setSheet] = useState<SheetData | null>(null);
const [mapping, setMapping] = useState<ColumnMapping | null>(null); const [mapping, setMapping] = useState<ColumnMapping | null>(null);
@@ -70,8 +70,8 @@ export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' }); toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
onImported(); onImported();
handleOpenChange(false); handleOpenChange(false);
} catch (err: any) { } catch (err) {
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' }); toast({ title: t('import.failed'), description: te(err), variant: 'destructive' });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
+41 -15
View File
@@ -126,30 +126,37 @@ export {
ToastAction, ToastAction,
}; };
// Simple toast hook type ToastMessage = {
let toastCount = 0; id: number;
const listeners: Set<(toast: any) => void> = new Set(); title?: string;
description?: string;
variant?: "default" | "destructive" | "success";
};
type ToastEvent = (ToastMessage & { open: true }) | { id: number; open: false };
export function toast(props: { title?: string; description?: string; variant?: "default" | "destructive" | "success" }) { let toastCount = 0;
const listeners: Set<(event: ToastEvent) => void> = new Set();
export function toast(props: Omit<ToastMessage, "id">) {
const id = ++toastCount; const id = ++toastCount;
const newToast = { id, ...props, open: true }; listeners.forEach((listener) => listener({ id, ...props, open: true }));
listeners.forEach((listener) => listener(newToast));
setTimeout(() => {
listeners.forEach((listener) => listener({ id, open: false }));
}, 5000);
return id; return id;
} }
function dismiss(id: number) {
listeners.forEach((listener) => listener({ id, open: false }));
}
export function useToast() { export function useToast() {
const [toasts, setToasts] = React.useState<any[]>([]); const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
React.useEffect(() => { React.useEffect(() => {
const listener = (toast: any) => { const listener = (event: ToastEvent) => {
setToasts((prev) => { setToasts((prev) => {
if (toast.open === false) { const others = prev.filter((t) => t.id !== event.id);
return prev.filter((t) => t.id !== toast.id); if (!event.open) return others;
} const { open: _open, ...message } = event;
return [...prev.filter((t) => t.id !== toast.id), toast]; return [...others, message];
}); });
}; };
listeners.add(listener); listeners.add(listener);
@@ -158,3 +165,22 @@ export function useToast() {
return { toasts, toast }; return { toasts, toast };
} }
/** Shows the toasts; Radix closes each one after 5 seconds (paused while hovered). */
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider duration={5000}>
{toasts.map(({ id, title, description, variant }) => (
<Toast key={id} variant={variant} open onOpenChange={(open) => !open && dismiss(id)}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>}
</div>
<ToastClose />
</Toast>
))}
<ToastViewport />
</ToastProvider>
);
}
+114 -39
View File
@@ -1,24 +1,105 @@
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1'; export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
interface ApiError { export interface ApiError {
code: string; code: string;
message: string; message: string;
} }
function getTokens() { export type Role = 'admin' | 'viewer';
if (typeof window === 'undefined') return { accessToken: null };
const accessToken = document.cookie export type TabLine = { productName: string; quantity: number; unitPriceInCents: number };
export interface Tab {
tabId: string;
customerName: string;
version: number;
totalInCents: number;
itemCount: number;
items: TabLine[] | null;
deviceId: string;
deviceName: string;
deviceLastSeenAt: string;
deviceRevokedAt: string | null;
lastReportedAt: string;
plannedTabId: string | null;
email: string | null;
}
export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired';
export interface Command {
id: string;
tabId: string;
customerName: string | null;
amountInCents: number | null;
type: string;
status: CommandStatus;
paymentMethod: string;
requestedBy: string;
expectedVersion: number;
createdAt: string;
processedAt: string | null;
result: string | null;
deviceId: string;
}
export interface Device {
id: string;
name: string;
lastSeenAt: string;
createdAt: string;
}
export interface User {
id: string;
email: string;
name: string;
role: Role;
createdAt: string;
}
export interface PlannedTab {
id: string;
customerName: string;
email: string | null;
position: number;
createdAt: string;
deliveredAt: string | null;
}
export interface ReminderCandidate {
tabId: string;
customerName: string;
email: string;
amountInCents: number;
itemCount: number;
items: TabLine[] | null;
}
export interface Reminder {
id: string;
tabId: string;
customerName: string;
email: string;
amountInCents: number;
sentAt: string;
status: string;
}
export function getCookie(name: string): string | null {
if (typeof document === 'undefined') return null;
return document.cookie
.split('; ') .split('; ')
.find((row) => row.startsWith('access_token=')) .find((row) => row.startsWith(`${name}=`))
?.split('=')[1]; ?.split('=')[1] ?? null;
return { accessToken };
} }
function setTokens(accessToken: string, refreshToken: string) { function setTokens(accessToken: string, refreshToken: string) {
// The access cookie outlives the JWT on purpose: pages decode it for venue/role, and an // 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. // expired JWT triggers a 401 which is transparently refreshed below.
document.cookie = `access_token=${accessToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; const attributes = `path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax${location.protocol === 'https:' ? '; Secure' : ''}`;
document.cookie = `refresh_token=${refreshToken}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`; document.cookie = `access_token=${accessToken}; ${attributes}`;
document.cookie = `refresh_token=${refreshToken}; ${attributes}`;
} }
function clearTokens() { function clearTokens() {
@@ -39,11 +120,7 @@ function refreshAccessToken(): Promise<string | null> {
} }
async function doRefreshAccessToken(): Promise<string | null> { async function doRefreshAccessToken(): Promise<string | null> {
const refreshToken = document.cookie const refreshToken = getCookie('refresh_token');
.split('; ')
.find((row) => row.startsWith('refresh_token='))
?.split('=')[1];
if (!refreshToken) return null; if (!refreshToken) return null;
try { try {
@@ -63,11 +140,19 @@ async function doRefreshAccessToken(): Promise<string | null> {
} }
} }
export async function api<T = any>( async function request(endpoint: string, options: RequestInit, headers: Record<string, string>): Promise<Response> {
try {
return await fetch(`${API_URL}${endpoint}`, { ...options, headers });
} catch (error) {
throw { code: 'network_error', message: String(error) } satisfies ApiError;
}
}
export async function api<T = unknown>(
endpoint: string, endpoint: string,
options: RequestInit = {}, options: RequestInit = {},
): Promise<T> { ): Promise<T> {
const { accessToken } = getTokens(); const accessToken = getCookie('access_token');
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
@@ -77,25 +162,19 @@ export async function api<T = any>(
headers['Authorization'] = `Bearer ${accessToken}`; headers['Authorization'] = `Bearer ${accessToken}`;
} }
let res = await fetch(`${API_URL}${endpoint}`, { let res = await request(endpoint, options, headers);
...options,
headers,
});
if (res.status === 401 && accessToken) { if (res.status === 401 && accessToken) {
const newToken = await refreshAccessToken(); const newToken = await refreshAccessToken();
if (newToken) { if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`; headers['Authorization'] = `Bearer ${newToken}`;
res = await fetch(`${API_URL}${endpoint}`, { res = await request(endpoint, options, headers);
...options,
headers,
});
} else { } else {
clearTokens(); clearTokens();
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.location.href = '/login'; window.location.href = '/login';
} }
throw new Error('Session expired'); throw { code: 'session_expired', message: 'Session expired' } satisfies ApiError;
} }
} }
@@ -114,16 +193,13 @@ export async function api<T = any>(
// Auth API // Auth API
export const authApi = { export const authApi = {
login: (email: string, password: string) => login: (email: string, password: string) =>
api<{ accessToken: string; refreshToken: string; user: any }>('/auth/login', { api<{ accessToken: string; refreshToken: string }>('/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email, password }),
}), }),
logout: () => { logout: () => {
const refreshToken = document.cookie const refreshToken = getCookie('refresh_token');
.split('; ')
.find((row) => row.startsWith('refresh_token='))
?.split('=')[1];
clearTokens(); clearTokens();
if (refreshToken) { if (refreshToken) {
api('/auth/logout', { api('/auth/logout', {
@@ -137,20 +213,20 @@ export const authApi = {
// Tabs API // Tabs API
export const tabsApi = { export const tabsApi = {
list: (venueId: string) => list: (venueId: string) =>
api<{ tabs: any[] }>(`/venues/${venueId}/tabs`), api<{ tabs: Tab[] }>(`/venues/${venueId}/tabs`),
}; };
// Devices API // Devices API
export const devicesApi = { export const devicesApi = {
get: (venueId: string) => get: (venueId: string) =>
api<{ device: { id: string; name: string; lastSeenAt: string; createdAt: string } | null }>(`/venues/${venueId}/devices`), api<{ device: Device | null }>(`/venues/${venueId}/devices`),
generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'), generateCode: () => api<{ code: string; expiresAt: string }>('/devices/generation-code'),
}; };
// Users API // Users API
export const usersApi = { export const usersApi = {
list: (venueId: string) => list: (venueId: string) =>
api<{ users: any[] }>(`/venues/${venueId}/users`), api<{ users: User[] }>(`/venues/${venueId}/users`),
create: (venueId: string, data: { email: string; password: string; name: string; role: string }) => create: (venueId: string, data: { email: string; password: string; name: string; role: string }) =>
api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }), api(`/venues/${venueId}/users`, { method: 'POST', body: JSON.stringify(data) }),
}; };
@@ -163,7 +239,7 @@ export const commandsApi = {
if (params?.limit) searchParams.set('limit', String(params.limit)); if (params?.limit) searchParams.set('limit', String(params.limit));
if (params?.offset) searchParams.set('offset', String(params.offset)); if (params?.offset) searchParams.set('offset', String(params.offset));
const qs = searchParams.toString(); const qs = searchParams.toString();
return api<{ commands: any[]; total: number }>( return api<{ commands: Command[]; total: number }>(
`/venues/${venueId}/commands${qs ? `?${qs}` : ''}`, `/venues/${venueId}/commands${qs ? `?${qs}` : ''}`,
); );
}, },
@@ -176,13 +252,12 @@ export const commandsApi = {
// Venues API // Venues API
export const venuesApi = { export const venuesApi = {
list: () => api<{ id: string; name: string }[]>('/venues'),
get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`), get: (id: string) => api<{ id: string; name: string }>(`/venues/${id}`),
}; };
// 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: PlannedTab[] }>(`/venues/${venueId}/planned-tabs`),
create: (venueId: string, customerName: string, email?: string) => create: (venueId: string, customerName: string, email?: string) =>
api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }), api(`/venues/${venueId}/planned-tabs`, { method: 'POST', body: JSON.stringify({ customerName, email }) }),
update: (venueId: string, id: string, customerName: string, email?: string) => update: (venueId: string, id: string, customerName: string, email?: string) =>
@@ -199,11 +274,11 @@ export const plannedTabsApi = {
// Reminders API // Reminders API
export const remindersApi = { export const remindersApi = {
candidates: (venueId: string) => candidates: (venueId: string) =>
api<{ candidates: any[] }>(`/venues/${venueId}/reminders/candidates`), api<{ candidates: ReminderCandidate[] }>(`/venues/${venueId}/reminders/candidates`),
send: (venueId: string) => send: (venueId: string) =>
api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }), api<{ sent: number; total: number; errors?: { customerName: string; error: string }[] }>(`/venues/${venueId}/reminders/send`, { method: 'POST' }),
history: (venueId: string) => history: (venueId: string) =>
api<{ reminders: any[] }>(`/venues/${venueId}/reminders`), api<{ reminders: Reminder[] }>(`/venues/${venueId}/reminders`),
sendTest: (venueId: string, plannedTabId: string) => sendTest: (venueId: string, plannedTabId: string) =>
api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }), api<{ ok: boolean; email: string; customerName: string }>(`/venues/${venueId}/reminders/test/${plannedTabId}`, { method: 'POST' }),
}; };
@@ -211,5 +286,5 @@ export const remindersApi = {
export { setTokens, clearTokens, refreshAccessToken }; export { setTokens, clearTokens, refreshAccessToken };
export function getAccessToken(): string | null { export function getAccessToken(): string | null {
return getTokens().accessToken ?? null; return getCookie('access_token');
} }
+68 -8
View File
@@ -23,6 +23,24 @@ const translations: Record<Lang, Record<string, string>> = {
'common.close': 'Close', 'common.close': 'Close',
'common.copied': 'Copied', 'common.copied': 'Copied',
'common.logged_out': 'Logged out', 'common.logged_out': 'Logged out',
'errors.invalid_credentials': 'Invalid email or password',
'errors.too_many_requests': 'Too many attempts. Wait a minute and try again.',
'errors.validation_error': 'Some fields are not filled in correctly',
'errors.version_mismatch': 'The tab changed since it was loaded. Check it and try again.',
'errors.command_already_pending': 'A close request for this tab is already waiting for the terminal',
'errors.tab_not_owned': 'This tab is no longer open on the terminal',
'errors.device_revoked': 'The terminal for this tab has been unpaired',
'errors.name_exists': 'This customer already exists',
'errors.email_exists': 'A user with this email already exists',
'errors.resend_not_configured': 'Sending email is not set up on the server',
'errors.reminders_in_progress': 'Reminders are already being sent',
'errors.no_email': 'This customer has no email address',
'errors.forbidden': "You don't have permission to do this",
'errors.venue_forbidden': "You don't have permission to do this",
'errors.internal_error': 'Something went wrong on the server',
'errors.network_error': "Can't reach the server. Check the connection.",
'errors.session_expired': 'Your session has expired. Log in again.',
'errors.unknown': 'An error occurred',
'status_page.home': 'Back to KoolTap', 'status_page.home': 'Back to KoolTap',
'not_found.title': 'Page not found', 'not_found.title': 'Page not found',
'not_found.desc': "This page doesn't exist or has been moved.", 'not_found.desc': "This page doesn't exist or has been moved.",
@@ -56,7 +74,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.email': 'Email', 'tabs.email': 'Email',
'tabs.amount': 'Amount', 'tabs.amount': 'Amount',
'tabs.items': 'Items', 'tabs.items': 'Items',
'tabs.version': 'Version', 'tabs.search_placeholder': 'Search customers…',
'tabs.sort': 'Sort by',
'tabs.sort_name': 'Name',
'tabs.sort_amount': 'Highest amount',
'tabs.outstanding': '{amount} outstanding',
'tabs.closing': 'Closing…',
'tabs.no_matches': 'No tabs match "{query}"',
'tabs.reported': 'Last updated', 'tabs.reported': 'Last updated',
'tabs.actions': 'Actions', 'tabs.actions': 'Actions',
'tabs.close': 'Close', 'tabs.close': 'Close',
@@ -140,7 +164,8 @@ const translations: Record<Lang, Record<string, string>> = {
'commands.load_failed': 'Failed to load commands', 'commands.load_failed': 'Failed to load commands',
'commands.empty': 'No commands found', 'commands.empty': 'No commands found',
'commands.empty_desc': 'Commands appear here when you send close requests.', 'commands.empty_desc': 'Commands appear here when you send close requests.',
'commands.tab': 'Tab', 'commands.customer': 'Customer',
'commands.amount': 'Amount',
'commands.requested': 'Requested By', 'commands.requested': 'Requested By',
'commands.payment': 'Payment', 'commands.payment': 'Payment',
'commands.created': 'Created', 'commands.created': 'Created',
@@ -157,7 +182,6 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.device': 'Terminal', 'settings.device': 'Terminal',
'settings.paired': 'Paired as "{name}" — last seen {time}', 'settings.paired': 'Paired as "{name}" — last seen {time}',
'settings.not_paired': 'No terminal paired yet', 'settings.not_paired': 'No terminal paired yet',
'settings.connected': 'Connected',
'settings.since': 'Since {date}', 'settings.since': 'Since {date}',
'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.', 'settings.pair_desc': 'Pair a bar terminal to start receiving tabs.',
'settings.pair': 'Pair Terminal', 'settings.pair': 'Pair Terminal',
@@ -178,7 +202,8 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.title': 'Overview', 'overview.title': 'Overview',
'overview.open_tabs': 'Open Tabs', 'overview.open_tabs': 'Open Tabs',
'overview.device': 'Terminal', 'overview.device': 'Terminal',
'overview.pending': 'Pending Commands', 'overview.pending': 'Waiting for terminal',
'overview.outstanding': 'Outstanding',
'overview.recent': 'Recent Commands', 'overview.recent': 'Recent Commands',
'overview.no_commands': 'No commands yet', 'overview.no_commands': 'No commands yet',
'overview.not_connected': 'Not connected', 'overview.not_connected': 'Not connected',
@@ -199,6 +224,7 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.no_candidates_desc': 'Make sure planned customers have an email address.', 'reminders.no_candidates_desc': 'Make sure planned customers have an email address.',
'reminders.send': 'Send Reminders', 'reminders.send': 'Send Reminders',
'reminders.sending': 'Sending...', 'reminders.sending': 'Sending...',
'reminders.admin_only': 'Only admins can send reminders.',
'reminders.confirm': 'This emails a payment reminder to {count} customer(s) with an unpaid tab.', 'reminders.confirm': 'This emails a payment reminder to {count} customer(s) with an unpaid tab.',
'reminders.sent_title': 'Reminders Sent', 'reminders.sent_title': 'Reminders Sent',
'reminders.partial': '{sent} of {total} sent', 'reminders.partial': '{sent} of {total} sent',
@@ -233,6 +259,24 @@ const translations: Record<Lang, Record<string, string>> = {
'common.close': 'Sluiten', 'common.close': 'Sluiten',
'common.copied': 'Gekopieerd', 'common.copied': 'Gekopieerd',
'common.logged_out': 'Uitgelogd', 'common.logged_out': 'Uitgelogd',
'errors.invalid_credentials': 'Ongeldig e-mailadres of wachtwoord',
'errors.too_many_requests': 'Te veel pogingen. Wacht een minuutje en probeer opnieuw.',
'errors.validation_error': 'Sommige velden zijn niet correct ingevuld',
'errors.version_mismatch': 'De poef is gewijzigd sinds ze geladen werd. Controleer en probeer opnieuw.',
'errors.command_already_pending': 'Er wacht al een sluitverzoek voor deze poef op de kassa',
'errors.tab_not_owned': 'Deze poef staat niet meer open op de kassa',
'errors.device_revoked': 'De kassa van deze poef is ontkoppeld',
'errors.name_exists': 'Deze klant bestaat al',
'errors.email_exists': 'Er bestaat al een gebruiker met dit e-mailadres',
'errors.resend_not_configured': 'E-mail versturen is niet ingesteld op de server',
'errors.reminders_in_progress': 'Herinneringen worden al verstuurd',
'errors.no_email': 'Deze klant heeft geen e-mailadres',
'errors.forbidden': 'Je hebt geen toestemming om dit te doen',
'errors.venue_forbidden': 'Je hebt geen toestemming om dit te doen',
'errors.internal_error': 'Er ging iets mis op de server',
'errors.network_error': 'Kan de server niet bereiken. Controleer de verbinding.',
'errors.session_expired': 'Je sessie is verlopen. Log opnieuw in.',
'errors.unknown': 'Er is een fout opgetreden',
'status_page.home': 'Terug naar KoolTap', 'status_page.home': 'Terug naar KoolTap',
'not_found.title': 'Pagina niet gevonden', 'not_found.title': 'Pagina niet gevonden',
'not_found.desc': 'Deze pagina bestaat niet of is verplaatst.', 'not_found.desc': 'Deze pagina bestaat niet of is verplaatst.',
@@ -266,7 +310,13 @@ const translations: Record<Lang, Record<string, string>> = {
'tabs.email': 'E-mail', 'tabs.email': 'E-mail',
'tabs.amount': 'Bedrag', 'tabs.amount': 'Bedrag',
'tabs.items': 'Items', 'tabs.items': 'Items',
'tabs.version': 'Versie', 'tabs.search_placeholder': 'Zoek klant…',
'tabs.sort': 'Sorteer op',
'tabs.sort_name': 'Naam',
'tabs.sort_amount': 'Hoogste bedrag',
'tabs.outstanding': '{amount} openstaand',
'tabs.closing': 'Wordt gesloten…',
'tabs.no_matches': 'Geen poefs gevonden voor "{query}"',
'tabs.reported': 'Laatst bijgewerkt', 'tabs.reported': 'Laatst bijgewerkt',
'tabs.actions': 'Acties', 'tabs.actions': 'Acties',
'tabs.close': 'Sluiten', 'tabs.close': 'Sluiten',
@@ -350,7 +400,8 @@ const translations: Record<Lang, Record<string, string>> = {
'commands.load_failed': 'Opdrachten konden niet geladen worden', 'commands.load_failed': 'Opdrachten konden niet geladen worden',
'commands.empty': 'Geen opdrachten gevonden', 'commands.empty': 'Geen opdrachten gevonden',
'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.', 'commands.empty_desc': 'Opdrachten verschijnen hier zodra je een sluitverzoek verstuurt.',
'commands.tab': 'Poef', 'commands.customer': 'Klant',
'commands.amount': 'Bedrag',
'commands.requested': 'Aangevraagd door', 'commands.requested': 'Aangevraagd door',
'commands.payment': 'Betaling', 'commands.payment': 'Betaling',
'commands.created': 'Aangemaakt', 'commands.created': 'Aangemaakt',
@@ -367,7 +418,6 @@ const translations: Record<Lang, Record<string, string>> = {
'settings.device': 'Terminal', 'settings.device': 'Terminal',
'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}', 'settings.paired': 'Gekoppeld als "{name}" — laatst gezien {time}',
'settings.not_paired': 'Nog geen terminal gekoppeld', 'settings.not_paired': 'Nog geen terminal gekoppeld',
'settings.connected': 'Verbonden',
'settings.since': 'Sinds {date}', 'settings.since': 'Sinds {date}',
'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.', 'settings.pair_desc': 'Koppel een barterminal om poefs te ontvangen.',
'settings.pair': 'Terminal koppelen', 'settings.pair': 'Terminal koppelen',
@@ -388,7 +438,8 @@ const translations: Record<Lang, Record<string, string>> = {
'overview.title': 'Overzicht', 'overview.title': 'Overzicht',
'overview.open_tabs': 'Open poefs', 'overview.open_tabs': 'Open poefs',
'overview.device': 'Terminal', 'overview.device': 'Terminal',
'overview.pending': 'Openstaande opdrachten', 'overview.pending': 'Wachtend op kassa',
'overview.outstanding': 'Openstaand',
'overview.recent': 'Recente opdrachten', 'overview.recent': 'Recente opdrachten',
'overview.no_commands': 'Nog geen opdrachten', 'overview.no_commands': 'Nog geen opdrachten',
'overview.not_connected': 'Niet verbonden', 'overview.not_connected': 'Niet verbonden',
@@ -409,6 +460,7 @@ const translations: Record<Lang, Record<string, string>> = {
'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.', 'reminders.no_candidates_desc': 'Zorg dat geplande klanten een e-mailadres hebben.',
'reminders.send': 'Herinneringen versturen', 'reminders.send': 'Herinneringen versturen',
'reminders.sending': 'Bezig met versturen...', 'reminders.sending': 'Bezig met versturen...',
'reminders.admin_only': 'Alleen beheerders kunnen herinneringen versturen.',
'reminders.confirm': 'Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.', 'reminders.confirm': 'Er wordt een betalingsherinnering gemaild naar {count} klant(en) met een openstaande poef.',
'reminders.sent_title': 'Herinneringen verstuurd', 'reminders.sent_title': 'Herinneringen verstuurd',
'reminders.partial': '{sent} van {total} verstuurd', 'reminders.partial': '{sent} van {total} verstuurd',
@@ -436,6 +488,8 @@ interface LangContextType {
t: (key: string, params?: Params) => string; t: (key: string, params?: Params) => string;
/** Uses `<key>_plural` unless `count` is 1; `{count}` is filled in. */ /** Uses `<key>_plural` unless `count` is 1; `{count}` is filled in. */
tn: (key: string, count: number, params?: Params) => string; tn: (key: string, count: number, params?: Params) => string;
/** An API error in the current language: by its code when translated, else its message. */
te: (error: unknown, fallbackKey?: string) => string;
/** "just now", "5m ago"…, falling back to a date after a week. */ /** "just now", "5m ago"…, falling back to a date after a week. */
relativeTime: (date: string | Date) => string; relativeTime: (date: string | Date) => string;
dateTime: (date: string | Date) => string; dateTime: (date: string | Date) => string;
@@ -459,6 +513,12 @@ function helpers(lang: Lang): Omit<LangContextType, 'setLang'> {
lang, lang,
t, t,
tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }), tn: (key, count, params) => t(count === 1 ? key : `${key}_plural`, { count, ...params }),
te: (error, fallbackKey = 'common.unknown_error') => {
const { code, message } = (error ?? {}) as { code?: string; message?: string };
const key = `errors.${code}`;
if (code && (translations[lang][key] ?? translations.en[key])) return t(key);
return message || t(fallbackKey);
},
relativeTime: (value) => { relativeTime: (value) => {
const date = new Date(value); const date = new Date(value);
const seconds = Math.floor((Date.now() - date.getTime()) / 1000); const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
+37
View File
@@ -0,0 +1,37 @@
'use client';
import { createContext, useContext, ReactNode } from 'react';
import { getCookie, type Role } from '@/lib/api/client';
export interface Session {
userId: string;
email: string;
role: Role;
venueId: string;
}
/** The signed-in user from the access token cookie, or null when there is none or it can't be read. */
export function readSession(): Session | null {
const token = getCookie('access_token');
if (!token) return null;
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
const payload = JSON.parse(atob(base64));
if (!payload.venueId || !payload.role) return null;
return { userId: payload.sub, email: payload.email, role: payload.role, venueId: payload.venueId };
} catch {
return null;
}
}
const SessionContext = createContext<Session | null>(null);
export function SessionProvider({ session, children }: { session: Session; children: ReactNode }) {
return <SessionContext.Provider value={session}>{children}</SessionContext.Provider>;
}
export function useSession(): Session {
const session = useContext(SessionContext);
if (!session) throw new Error('useSession must be used inside the dashboard layout');
return session;
}
+1 -1
View File
@@ -1,6 +1,6 @@
export function formatCents(cents: number, currency: string = 'EUR'): string { export function formatCents(cents: number, currency: string = 'EUR'): string {
const euros = cents / 100; const euros = cents / 100;
return new Intl.NumberFormat('nl-NL', { return new Intl.NumberFormat('nl-BE', {
style: 'currency', style: 'currency',
currency, currency,
}).format(euros); }).format(euros);
File diff suppressed because one or more lines are too long
-1
View File
@@ -27,7 +27,6 @@ services:
environment: environment:
DATABASE_URL: "postgresql://kooltap:${DB_PASSWORD:-changeme}@postgres:5432/kooltap" DATABASE_URL: "postgresql://kooltap:${DB_PASSWORD:-changeme}@postgres:5432/kooltap"
JWT_SECRET: "${JWT_SECRET}" JWT_SECRET: "${JWT_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_API_KEY: "${RESEND_API_KEY:-}"
+23 -5
View File
@@ -1,6 +1,24 @@
version: '3.8' version: '3.8'
services: services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: kooltap
POSTGRES_USER: kooltap
POSTGRES_PASSWORD: kooltap
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U kooltap"]
interval: 5s
timeout: 5s
retries: 5
networks:
- kooltap-net
api: api:
build: build:
context: . context: .
@@ -9,13 +27,13 @@ services:
ports: ports:
- "3001:3001" - "3001:3001"
environment: environment:
- DATABASE_URL=file:./dev.db - DATABASE_URL=postgresql://kooltap:kooltap@postgres:5432/kooltap
- JWT_SECRET=dev-jwt-secret-change-in-production - JWT_SECRET=dev-jwt-secret-change-in-production
- JWT_REFRESH_SECRET=dev-refresh-secret-change-in-production
- PORT=3001 - PORT=3001
- CORS_ORIGIN=http://localhost:3000 - CORS_ORIGIN=http://localhost:3000
volumes: depends_on:
- api-data:/app/prisma postgres:
condition: service_healthy
networks: networks:
- kooltap-net - kooltap-net
@@ -34,7 +52,7 @@ services:
- kooltap-net - kooltap-net
volumes: volumes:
api-data: pgdata:
networks: networks:
kooltap-net: kooltap-net:
+6 -6
View File
@@ -24,17 +24,17 @@
**Rationale:** Provides security (tokens can be revoked) and aligns with OAuth2 best practices for refresh token management. **Rationale:** Provides security (tokens can be revoked) and aligns with OAuth2 best practices for refresh token management.
## 5. SQLite for Development ## 5. PostgreSQL Everywhere
**Decision:** SQLite is the default development database. **Decision:** PostgreSQL is used in development, CI and production.
**Rationale:** Zero configuration, fast for local development, and Prisma abstracts most differences. PostgreSQL migration path is documented in production.md. **Rationale:** One database means one set of migrations; the schema uses JSON columns and serializable transactions that SQLite doesn't support the same way.
## 6. No WebSockets ## 6. Invalidation-Only Live Updates
**Decision:** Dashboard uses polling (TanStack Query) instead of WebSockets. **Decision:** The API pushes topic names (`tabs`, `commands`, ...) over Socket.IO; the dashboard refetches that data over the normal REST API. It falls back to polling while the socket is down.
**Rationale:** Simpler implementation, adequate for the expected update frequency (5-30 second intervals), and avoids WebSocket server infrastructure. **Rationale:** Events never carry data, so a socket can't see anything its owner couldn't fetch, and the REST API stays the single source of truth.
## 7. In-Memory Rate Limiting ## 7. In-Memory Rate Limiting
+3 -2
View File
@@ -10,7 +10,7 @@ KoolTap is a modular monolith designed for remote tab control in venues.
- REST API with versioned endpoints (`/api/v1`) - REST API with versioned endpoints (`/api/v1`)
- JWT authentication for dashboard users - JWT authentication for dashboard users
- Bearer token authentication for devices - Bearer token authentication for devices
- SQLite database via Prisma ORM - PostgreSQL database via Prisma ORM
- Scheduled command expiry job - Scheduled command expiry job
- In-memory rate limiting - In-memory rate limiting
@@ -19,7 +19,8 @@ KoolTap is a modular monolith designed for remote tab control in venues.
- TanStack Query for API data fetching - TanStack Query for API data fetching
- shadcn/ui component library - shadcn/ui component library
- Tailwind CSS styling - Tailwind CSS styling
- Secure HttpOnly cookie-based auth - Tokens kept in cookies (see security.md)
- Socket.IO connection for live updates, polling as fallback
### Shared Package ### Shared Package
- TypeScript types and constants - TypeScript types and constants
+1 -2
View File
@@ -54,9 +54,8 @@ pnpm db:reset # Reset database and re-seed
## Environment Variables ## Environment Variables
### API (`apps/api/.env`) ### API (`apps/api/.env`)
- `DATABASE_URL` - SQLite path (default: `file:./dev.db`) - `DATABASE_URL` - PostgreSQL connection URL (`docker compose up postgres` starts one)
- `JWT_SECRET` - JWT signing secret - `JWT_SECRET` - JWT signing secret
- `JWT_REFRESH_SECRET` - Refresh token secret
- `PORT` - API port (default: 3001) - `PORT` - API port (default: 3001)
- `CORS_ORIGIN` - Allowed CORS origin (default: http://localhost:3000) - `CORS_ORIGIN` - Allowed CORS origin (default: http://localhost:3000)
+8 -33
View File
@@ -12,9 +12,8 @@ Create `.env` in the project root:
# Database # Database
DB_PASSWORD=<generate-a-strong-password> DB_PASSWORD=<generate-a-strong-password>
# JWT secrets — generate with: openssl rand -hex 64 # JWT secret — generate with: openssl rand -hex 64
JWT_SECRET=<64-char-hex-string> JWT_SECRET=<64-char-hex-string>
JWT_REFRESH_SECRET=<64-char-hex-string>
# URLs — set your actual domain/port # URLs — set your actual domain/port
DASHBOARD_URL=https://dashboard.yourdomain.com DASHBOARD_URL=https://dashboard.yourdomain.com
@@ -29,16 +28,16 @@ API_PORT=3001
docker compose -f docker-compose.prod.yml up --build -d docker compose -f docker-compose.prod.yml up --build -d
``` ```
### 3. Seed the database (first time only) ### 3. Create the venue and admin (first time only)
Migrations run automatically when the API container starts.
```bash ```bash
# Generate Prisma client and run migrations docker compose -f docker-compose.prod.yml exec api node prisma/setup.js "My Venue" admin@myvenue.com '<strong password>'
docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy
# Seed demo data (or create your own venue/users)
docker compose -f docker-compose.prod.yml exec api npx ts-node prisma/seed.ts
``` ```
`setup.js` does nothing when a venue already exists. `--force` wipes ALL data and starts over.
### 4. Set up a reverse proxy (nginx/Caddy) ### 4. Set up a reverse proxy (nginx/Caddy)
Example Caddyfile: Example Caddyfile:
@@ -70,22 +69,11 @@ GRANT ALL PRIVILEGES ON DATABASE kooltap TO kooltap;
```env ```env
DATABASE_URL="postgresql://kooltap:your-password@localhost:5432/kooltap" DATABASE_URL="postgresql://kooltap:your-password@localhost:5432/kooltap"
JWT_SECRET="<random-64-char-string>" JWT_SECRET="<random-64-char-string>"
JWT_REFRESH_SECRET="<random-64-char-string>"
PORT=3001 PORT=3001
CORS_ORIGIN="https://dashboard.yourdomain.com" CORS_ORIGIN="https://dashboard.yourdomain.com"
``` ```
### 3. Update Prisma schema for PostgreSQL ### 3. Build and run
In `apps/api/prisma/schema.prisma`, change:
```prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
```
### 4. Build and run
```bash ```bash
cd apps/api cd apps/api
@@ -98,19 +86,6 @@ node dist/main
--- ---
## Migration Steps: SQLite → PostgreSQL
1. Update `schema.prisma` to use `postgresql` provider
2. Update `DATABASE_URL` environment variable
3. Run `prisma migrate dev` to create initial PostgreSQL migration
4. Export SQLite data and import to PostgreSQL (using `prisma db pull` + manual script, or insert via seed)
5. Run `prisma migrate deploy` on production
Key differences to handle:
- SQLite uses `Int` for booleans → use `Boolean` in PostgreSQL
- SQLite uses `String` for JSON → use `Json` type in PostgreSQL
- UUID generation is handled by Prisma on both
## Security Checklist ## Security Checklist
- [ ] JWT secrets are 64+ random hex characters - [ ] JWT secrets are 64+ random hex characters
+8 -7
View File
@@ -11,15 +11,16 @@
### Client Authentication ### Client Authentication
- Passwords hashed with bcrypt (10 rounds) - Passwords hashed with bcrypt (10 rounds)
- JWT access tokens (24h expiration) - JWT access tokens (15 minute expiration)
- Refresh tokens with rotation and revocation - Refresh tokens (30 days) with rotation and revocation; a refresh token works only once
- Tokens stored in HttpOnly cookies on dashboard - The dashboard keeps both tokens in `SameSite=Lax` cookies (`Secure` over HTTPS). They are readable by
the dashboard's JavaScript, so an XSS bug would expose them; the short access token lifetime limits the damage
## Authorization ## Authorization
### Role-Based Access ### Role-Based Access
- `admin`: Full access including device management and revocation - `admin`: Full access, including users, device pairing, planned customers and sending reminders
- `viewer`: Read-only access to tabs, devices, and commands - `viewer` (shown as staff): views tabs and commands and can close tabs
### Venue Isolation ### Venue Isolation
- Every user and device is scoped to exactly one venue - Every user and device is scoped to exactly one venue
@@ -34,9 +35,9 @@
- Body size limits enforced - Body size limits enforced
### Rate Limiting ### Rate Limiting
- Device sync: 1 request per 30 seconds per device - Login and device registration: 10 requests per minute per client
- Command creation: 10 requests per minute per user
- General: 100 requests per minute per client - General: 100 requests per minute per client
- Device sync and command acks are not rate limited (they are authenticated by the device token)
### Error Handling ### Error Handling
- Consistent error format never exposes internals - Consistent error format never exposes internals
-12
View File
@@ -1,12 +0,0 @@
export declare const DEVICE_ONLINE_THRESHOLD_MS = 60000;
export declare const DEVICE_STALE_THRESHOLD_MS = 300000;
export declare const DEVICE_OFFLINE_THRESHOLD_MS = 600000;
export declare const COMMAND_EXPIRY_HOURS = 24;
export declare const TOKEN_EXPIRY_DAYS = 90;
export declare const SYNC_RATE_LIMIT_WINDOW_MS = 30000;
export declare const COMMAND_RATE_LIMIT_WINDOW_MS = 60000;
export declare const COMMAND_RATE_LIMIT_MAX = 10;
export declare const CURSOR_TIEBREAKER_LENGTH = 8;
export declare const TOKEN_PREFIX_LENGTH = 8;
export declare const TOKEN_BYTE_LENGTH = 48;
//# sourceMappingURL=index.d.ts.map
@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,0BAA0B,QAAS,CAAC;AACjD,eAAO,MAAM,yBAAyB,SAAU,CAAC;AACjD,eAAO,MAAM,2BAA2B,SAAU,CAAC;AACnD,eAAO,MAAM,oBAAoB,KAAK,CAAC;AACvC,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,eAAO,MAAM,yBAAyB,QAAS,CAAC;AAChD,eAAO,MAAM,4BAA4B,QAAS,CAAC;AACnD,eAAO,MAAM,sBAAsB,KAAK,CAAC;AACzC,eAAO,MAAM,wBAAwB,IAAI,CAAC;AAC1C,eAAO,MAAM,mBAAmB,IAAI,CAAC;AACrC,eAAO,MAAM,iBAAiB,KAAK,CAAC"}
-15
View File
@@ -1,15 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TOKEN_BYTE_LENGTH = exports.TOKEN_PREFIX_LENGTH = exports.CURSOR_TIEBREAKER_LENGTH = exports.COMMAND_RATE_LIMIT_MAX = exports.COMMAND_RATE_LIMIT_WINDOW_MS = exports.SYNC_RATE_LIMIT_WINDOW_MS = exports.TOKEN_EXPIRY_DAYS = exports.COMMAND_EXPIRY_HOURS = exports.DEVICE_OFFLINE_THRESHOLD_MS = exports.DEVICE_STALE_THRESHOLD_MS = exports.DEVICE_ONLINE_THRESHOLD_MS = void 0;
exports.DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 1 minute
exports.DEVICE_STALE_THRESHOLD_MS = 300_000; // 5 minutes
exports.DEVICE_OFFLINE_THRESHOLD_MS = 600_000; // 10 minutes
exports.COMMAND_EXPIRY_HOURS = 24;
exports.TOKEN_EXPIRY_DAYS = 90;
exports.SYNC_RATE_LIMIT_WINDOW_MS = 30_000; // 30 seconds
exports.COMMAND_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
exports.COMMAND_RATE_LIMIT_MAX = 10;
exports.CURSOR_TIEBREAKER_LENGTH = 8;
exports.TOKEN_PREFIX_LENGTH = 8;
exports.TOKEN_BYTE_LENGTH = 48;
//# sourceMappingURL=index.js.map
@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAa,QAAA,0BAA0B,GAAG,MAAM,CAAC,CAAC,WAAW;AAChD,QAAA,yBAAyB,GAAG,OAAO,CAAC,CAAC,YAAY;AACjD,QAAA,2BAA2B,GAAG,OAAO,CAAC,CAAC,aAAa;AACpD,QAAA,oBAAoB,GAAG,EAAE,CAAC;AAC1B,QAAA,iBAAiB,GAAG,EAAE,CAAC;AACvB,QAAA,yBAAyB,GAAG,MAAM,CAAC,CAAC,aAAa;AACjD,QAAA,4BAA4B,GAAG,MAAM,CAAC,CAAC,WAAW;AAClD,QAAA,sBAAsB,GAAG,EAAE,CAAC;AAC5B,QAAA,wBAAwB,GAAG,CAAC,CAAC;AAC7B,QAAA,mBAAmB,GAAG,CAAC,CAAC;AACxB,QAAA,iBAAiB,GAAG,EAAE,CAAC"}
-5
View File
@@ -1,11 +1,6 @@
export const DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 1 minute export const DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 1 minute
export const DEVICE_STALE_THRESHOLD_MS = 300_000; // 5 minutes
export const DEVICE_OFFLINE_THRESHOLD_MS = 600_000; // 10 minutes export const DEVICE_OFFLINE_THRESHOLD_MS = 600_000; // 10 minutes
export const COMMAND_EXPIRY_HOURS = 24; export const COMMAND_EXPIRY_HOURS = 24;
export const TOKEN_EXPIRY_DAYS = 90; export const TOKEN_EXPIRY_DAYS = 90;
export const SYNC_RATE_LIMIT_WINDOW_MS = 30_000; // 30 seconds
export const COMMAND_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
export const COMMAND_RATE_LIMIT_MAX = 10;
export const CURSOR_TIEBREAKER_LENGTH = 8;
export const TOKEN_PREFIX_LENGTH = 8; export const TOKEN_PREFIX_LENGTH = 8;
export const TOKEN_BYTE_LENGTH = 48; export const TOKEN_BYTE_LENGTH = 48;
-3
View File
@@ -1,3 +0,0 @@
export * from './types';
export * from './constants';
//# sourceMappingURL=index.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,SAAS,CAAC;AACxB,cAAc,aAAa,CAAC"}
-19
View File
@@ -1,19 +0,0 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./types"), exports);
__exportStar(require("./constants"), exports);
//# sourceMappingURL=index.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAwB;AACxB,8CAA4B"}
-8
View File
@@ -1,8 +0,0 @@
export type DeviceStatus = 'online' | 'recently_seen' | 'stale' | 'offline' | 'revoked';
export type CommandStatus = 'pending' | 'completed' | 'failed' | 'conflict' | 'expired';
export type CommandType = 'close_tab';
export type PaymentMethod = 'cash' | 'payconiq' | 'payment_pending';
export type ClientUserRole = 'admin' | 'viewer';
export type ActorType = 'client_user' | 'device' | 'system';
export type AuditAction = 'command_created' | 'command_acknowledged' | 'command_expired' | 'device_registered' | 'device_revoked' | 'snapshot_conflict';
//# sourceMappingURL=index.d.ts.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,eAAe,GAAG,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;AAExF,MAAM,MAAM,aAAa,GAAG,SAAS,GAAG,WAAW,GAAG,QAAQ,GAAG,UAAU,GAAG,SAAS,CAAC;AAExF,MAAM,MAAM,WAAW,GAAG,WAAW,CAAC;AAEtC,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,UAAU,GAAG,iBAAiB,CAAC;AAEpE,MAAM,MAAM,cAAc,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEhD,MAAM,MAAM,SAAS,GAAG,aAAa,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,MAAM,MAAM,WAAW,GACnB,iBAAiB,GACjB,sBAAsB,GACtB,iBAAiB,GACjB,mBAAmB,GACnB,gBAAgB,GAChB,mBAAmB,CAAC"}
-3
View File
@@ -1,3 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=index.js.map
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":""}
+31
View File
@@ -117,6 +117,9 @@ importers:
'@types/passport-jwt': '@types/passport-jwt':
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.1 version: 4.0.1
'@types/supertest':
specifier: ^6.0.3
version: 6.0.3
'@types/uuid': '@types/uuid':
specifier: ^9.0.7 specifier: ^9.0.7
version: 9.0.8 version: 9.0.8
@@ -1452,6 +1455,9 @@ packages:
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cookiejar@2.1.5':
resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==}
'@types/cors@2.8.19': '@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
@@ -1506,6 +1512,9 @@ packages:
'@types/luxon@3.4.2': '@types/luxon@3.4.2':
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
'@types/mime@1.3.5': '@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
@@ -1556,6 +1565,12 @@ packages:
'@types/stack-utils@2.0.3': '@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/superagent@8.1.11':
resolution: {integrity: sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw==}
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/uuid@9.0.8': '@types/uuid@9.0.8':
resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==}
@@ -5416,6 +5431,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
'@types/cookiejar@2.1.5': {}
'@types/cors@2.8.19': '@types/cors@2.8.19':
dependencies: dependencies:
'@types/node': 20.19.43 '@types/node': 20.19.43
@@ -5493,6 +5510,8 @@ snapshots:
'@types/luxon@3.4.2': {} '@types/luxon@3.4.2': {}
'@types/methods@1.1.4': {}
'@types/mime@1.3.5': {} '@types/mime@1.3.5': {}
'@types/ms@2.1.0': {} '@types/ms@2.1.0': {}
@@ -5552,6 +5571,18 @@ snapshots:
'@types/stack-utils@2.0.3': {} '@types/stack-utils@2.0.3': {}
'@types/superagent@8.1.11':
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 20.19.43
form-data: 4.0.6
'@types/supertest@6.0.3':
dependencies:
'@types/methods': 1.1.4
'@types/superagent': 8.1.11
'@types/uuid@9.0.8': {} '@types/uuid@9.0.8': {}
'@types/validator@13.15.10': {} '@types/validator@13.15.10': {}
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# KoolTap Production Deploy # KoolTap Production Deploy
# Usage: ./scripts/deploy.sh "My Venue" admin@myvenue.com password # Usage: ./scripts/deploy.sh "My Venue" admin@myvenue.com password
# The venue and admin are only created on the first deploy; later deploys keep all data.
VENUE_NAME="${1:-${SETUP_VENUE_NAME}}" VENUE_NAME="${1:-${SETUP_VENUE_NAME}}"
ADMIN_EMAIL="${2:-${SETUP_ADMIN_EMAIL}}" ADMIN_EMAIL="${2:-${SETUP_ADMIN_EMAIL}}"
@@ -40,4 +41,4 @@ docker compose -f docker-compose.prod.yml exec api sh -c "
echo "" echo ""
echo "=== Done ===" echo "=== Done ==="
echo " Dashboard: https://kooltap.com" echo " Dashboard: https://kooltap.com"
echo " Login: $ADMIN_EMAIL / $ADMIN_PASSWORD" echo " Login: $ADMIN_EMAIL"
+10 -9
View File
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# KoolTap Production Setup # KoolTap Production Setup
# Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword # Usage: ./scripts/setup.sh "My Venue" admin@myvenue.com mypassword [--force]
# Does nothing if the database already has a venue, unless --force is given (wipes ALL data).
# #
# Or with env vars: # Or with env vars:
# SETUP_VENUE_NAME="My Venue" SETUP_ADMIN_EMAIL="admin@myvenue.com" SETUP_ADMIN_PASSWORD="mypassword" ./scripts/setup.sh # SETUP_VENUE_NAME="My Venue" SETUP_ADMIN_EMAIL="admin@myvenue.com" SETUP_ADMIN_PASSWORD="mypassword" ./scripts/setup.sh
@@ -21,20 +22,20 @@ echo " Venue: $VENUE_NAME"
echo " Admin: $ADMIN_EMAIL" echo " Admin: $ADMIN_EMAIL"
echo "" echo ""
# Step 1: Apply database schema # Step 1: Generate Prisma client
echo "[1/3] Applying database schema..." echo "[1/3] Generating Prisma client..."
npx prisma db push --accept-data-loss 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Schema applied"
# Step 2: Generate Prisma client
echo "[2/3] Generating Prisma client..."
npx prisma generate 2>&1 || { echo "FAILED"; exit 1; } npx prisma generate 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Client generated" echo " ✓ Client generated"
# Step 2: Apply database migrations
echo "[2/3] Applying database migrations..."
node prisma/deploy.js 2>&1 || { echo "FAILED"; exit 1; }
echo " ✓ Migrations applied"
# Step 3: Seed with your data # Step 3: Seed with your data
echo "[3/3] Seeding database..." echo "[3/3] Seeding database..."
SETUP_VENUE_NAME="$VENUE_NAME" SETUP_ADMIN_EMAIL="$ADMIN_EMAIL" SETUP_ADMIN_PASSWORD="$ADMIN_PASSWORD" \ SETUP_VENUE_NAME="$VENUE_NAME" SETUP_ADMIN_EMAIL="$ADMIN_EMAIL" SETUP_ADMIN_PASSWORD="$ADMIN_PASSWORD" \
node prisma/setup.js || { echo "FAILED"; exit 1; } node prisma/setup.js "$4" || { echo "FAILED"; exit 1; }
echo "" echo ""
echo "Setup complete. Login at https://kooltap.com" echo "Setup complete. Login at https://kooltap.com"