feat: move to V2
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
+5
View File
@@ -3,4 +3,9 @@
**/.next
**/.env
**/.env.*
**/coverage
.git
# Generated inside the image from the schema
apps/api/src/generated
# Local pictures (IMAGE_DIR)
apps/api/data
+9 -4
View File
@@ -1,11 +1,16 @@
# KoolTap Remote Tab Control
# Copy to apps/api/.env for local development (see docs/local-development.md)
DATABASE_URL="postgresql://kooltap:kooltap@localhost:5432/kooltap"
JWT_SECRET="dev-jwt-secret-change-in-production"
PORT=3001
# The dashboard's address; several are comma separated
CORS_ORIGIN="http://localhost:3000"
# Oldest terminal app version that may sync (defaults to the one in apps/api/src/devices/app-version.ts)
# MIN_APP_VERSION="1.4.0"
# Where product pictures are stored
IMAGE_DIR="data/images"
# Email (payment reminders, cash payment notices); leave empty to disable sending
RESEND_API_KEY=""
# Oldest terminal app version that may sync (defaults to the one in apps/api/src/terminal/app-version.ts)
# MIN_APP_VERSION="2.0.0"
# Dashboard
# Dashboard (apps/dashboard/.env.local)
NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1"
-14
View File
@@ -1,14 +0,0 @@
# Production environment variables for KoolTap
# Copy to .env and fill in your values
# PostgreSQL
DB_PASSWORD=changeme
# JWT secret - generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
JWT_SECRET=changeme
# URLs
DASHBOARD_URL=http://localhost:3000
API_URL=http://localhost:3001
DASHBOARD_PORT=3000
API_PORT=3001
+14 -12
View File
@@ -12,7 +12,7 @@ jobs:
services:
postgres:
image: postgres:16-alpine
image: postgres:17-alpine
env:
POSTGRES_DB: kooltap
POSTGRES_USER: kooltap
@@ -32,38 +32,40 @@ jobs:
steps:
- uses: actions/checkout@v4
# The version comes from packageManager in package.json
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
node-version: 24
cache: pnpm
# Also generates the Prisma client
- run: pnpm install --frozen-lockfile
- name: Generate Prisma client
run: cd apps/api && npx prisma generate
- name: Build shared package
run: pnpm --filter @kooltap/shared run build
- name: TypeScript check
- name: Typecheck
run: pnpm typecheck
# Uses the still-empty database as shadow database, so it must run before the migrations
- name: Check schema matches migrations
run: cd apps/api && npx prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url "$DATABASE_URL" --exit-code
working-directory: apps/api
run: SHADOW_DATABASE_URL="$DATABASE_URL" npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
- name: Apply migrations
run: cd apps/api && npx prisma migrate deploy
working-directory: apps/api
run: npx prisma migrate deploy
- name: Unit tests
run: pnpm test
- name: E2E tests
- name: End-to-end tests
run: pnpm test:e2e
- name: Build
run: pnpm build
- name: Terminal protocol JSON Schema
run: pnpm --filter @kooltap/shared run schema
+37 -52
View File
@@ -1,65 +1,50 @@
FROM node:20-alpine AS builder
# `--target api` or `--target dashboard` picks the image; each builds only what it needs, on one shared install.
FROM node:24-alpine AS deps
RUN corepack enable
WORKDIR /app
RUN npm install -g pnpm
RUN apk add --no-cache openssl
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001/api/v1}
# Public address of the dashboard, for the sitemap, robots.txt and link previews
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://kooltap.com}
COPY pnpm-workspace.yaml package.json tsconfig.base.json pnpm-lock.yaml ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY apps/api/package.json apps/api/
COPY apps/dashboard/package.json apps/dashboard/
RUN pnpm install --frozen-lockfile
# The API's postinstall generates the Prisma client from the schema
COPY apps/api/prisma/schema.prisma apps/api/prisma/
COPY apps/api/prisma.config.ts apps/api/
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
COPY packages/shared packages/shared
COPY apps/api apps/api
COPY apps/dashboard apps/dashboard
RUN pnpm --filter @kooltap/shared run build
RUN cd apps/api && npx prisma generate
RUN pnpm --filter @kooltap/api run build
FROM deps AS api-build
COPY apps/api apps/api
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm --filter @kooltap/api run build \
&& pnpm --filter @kooltap/api deploy --prod --legacy /deploy/api
FROM deps AS dashboard-build
# NEXT_PUBLIC_* values are baked into the dashboard bundle at build time
ARG NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
ARG NEXT_PUBLIC_SITE_URL=https://kooltap.com
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL NEXT_TELEMETRY_DISABLED=1
COPY apps/dashboard apps/dashboard
RUN pnpm --filter @kooltap/dashboard run build
# ---- Production API image ----
FROM node:20-alpine AS api
RUN npm install -g pnpm
RUN apk add --no-cache openssl
# ---- API: migrates the database, then serves
FROM node:24-alpine AS api
WORKDIR /app
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/apps/api/prisma ./prisma
COPY --from=builder /app/apps/api/templates ./templates
COPY --from=builder /app/apps/api/package.json ./
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
RUN pnpm install --prod
ENV NODE_ENV=production
ENV NODE_ENV=production IMAGE_DIR=/data/images
# The built API with its production dependencies, migrations and email templates
COPY --from=api-build /deploy/api ./
RUN mkdir -p /data/images && chown node:node /data/images
USER node
VOLUME /data/images
EXPOSE 3001
CMD ["sh", "-c", "node prisma/deploy.js && node dist/main.js"]
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
# ---- Production Dashboard image ----
FROM node:20-alpine AS dashboard
RUN npm install -g pnpm
# ---- Dashboard: Next's standalone server
FROM node:24-alpine AS dashboard
WORKDIR /app
COPY --from=builder /app/apps/dashboard/.next ./.next
COPY --from=builder /app/apps/dashboard/package.json ./
COPY --from=builder /app/apps/dashboard/next.config.js ./
COPY --from=builder /app/apps/dashboard/public ./public
COPY --from=builder /app/pnpm-lock.yaml ./
COPY --from=builder /app/pnpm-workspace.yaml ./
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
RUN pnpm install --prod
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
COPY --from=dashboard-build --chown=node:node /app/apps/dashboard/.next/standalone ./
COPY --from=dashboard-build --chown=node:node /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
COPY --from=dashboard-build --chown=node:node /app/apps/dashboard/public ./apps/dashboard/public
USER node
EXPOSE 3000
CMD ["node", "node_modules/next/dist/bin/next", "start"]
CMD ["node", "apps/dashboard/server.js"]
+24 -81
View File
@@ -1,102 +1,45 @@
# KoolTap Remote Tab Control
# KoolTap
A production-quality remote tab control system for venues. Allows bar staff to manage and close tabs remotely from a web dashboard, with commands delivered to bar terminals via a sync API.
## Architecture
- **Monorepo** managed with pnpm
- **NestJS** backend API with Prisma ORM on PostgreSQL
- **Next.js** dashboard with App Router, TanStack Query, and shadcn/ui
- **Shared** TypeScript types package
The backoffice for KoolTap, the bar till: bar terminals (a Flutter app) sync their tabs, sales and stock here, and the
venue runs tabs, customers, reminders and stock from a web dashboard.
```
apps/
api/ # NestJS backend
dashboard/ # Next.js frontend
api/ NestJS API on PostgreSQL (Prisma)
dashboard/ Next.js dashboard
packages/
shared/ # Shared types & constants
shared/ The contract: zod schemas and types for the terminal protocol and the dashboard API
scripts/
device-sync.ts # Device simulator
docs/
api.md # API documentation
architecture.md
architecture-decisions.md
local-development.md
testing.md
production.md
security.md
terminal-simulator.ts A terminal on the command line
docs/ Architecture, terminal protocol, development, production, security, testing
```
## Requirements
## Quick start
- Node.js >= 18
- pnpm >= 8
## Quick Start
Needs Node.js 24, pnpm (`corepack enable`) and Docker for the database.
```bash
# 1. Install dependencies
pnpm install
# 2. Generate Prisma client
cd apps/api
pnpm db:generate
# 3. Run migrations
docker compose up -d # PostgreSQL on localhost:5432
cp .env.example apps/api/.env # and NEXT_PUBLIC_API_URL into apps/dashboard/.env.local
pnpm install # also generates the Prisma client
pnpm db:migrate
# 4. Seed demo data
pnpm db:seed
cd ../..
# 5. Start both API and Dashboard in dev mode
pnpm dev
pnpm dev # API on :3001, dashboard on :3000
```
API runs on http://localhost:3001
Dashboard runs on http://localhost:3000
Swagger docs at http://localhost:3001/api/docs
The seed prints the demo accounts (one per role) and a terminal token; `pnpm simulate` then plays a terminal against
your local API.
## Demo Credentials
| Role | Email | Password |
|--------|--------------------|------------|
| Admin | admin@example.com | Admin123! |
| Manager | viewer@example.com | Viewer123! |
| Superadmin | superadmin@example.com | Super123! |
The seed also adds a second venue, KoolTap Festival Bar (festival@example.com / Admin123!). To make an existing
user a superadmin elsewhere, run `pnpm --filter @kooltap/api run superadmin <email>` (`--revoke` makes them an admin again).
## Device Simulator
## Tests
```bash
# Run a full demo (register, sync, ack)
cd scripts
npx ts-node device-sync.ts demo <venueId> <registrationCode>
# Or step by step:
npx ts-node device-sync.ts register <venueId> "Bar Terminal 1" ABC123
npx ts-node device-sync.ts sync <deviceId> <token>
npx ts-node device-sync.ts ack <deviceId> <token> <commandId> completed
pnpm test # unit tests: the tab, stock and product rules, parsing, email templates, dashboard helpers
pnpm test:e2e # the API against a real database (DATABASE_URL), terminal and dashboard together
```
## Running Tests
## More
```bash
# Unit tests
pnpm test
# E2E tests
pnpm test:e2e
```
## Docker
```bash
docker compose up
```
## Production Notes
See [docs/production.md](docs/production.md) for production deployment.
- [Architecture](docs/architecture.md) and the [decisions behind it](docs/architecture-decisions.md)
- [Terminal protocol](docs/terminal-protocol.md), for the Flutter app
- [Local development](docs/local-development.md), [testing](docs/testing.md)
- [Production on Coolify](docs/production.md), [security](docs/security.md)
+2
View File
@@ -0,0 +1,2 @@
src/generated/
data/
+2 -1
View File
@@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"tsConfigPath": "tsconfig.build.json"
}
}
+47 -52
View File
@@ -1,69 +1,68 @@
{
"name": "@kooltap/api",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"files": [
"dist",
"prisma",
"prisma.config.ts",
"templates"
],
"scripts": {
"dev": "nest start --watch",
"build": "nest build",
"start": "node dist/main",
"start:dev": "nest start --watch",
"lint": "eslint \"{src,test}/**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "jest",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:seed": "ts-node prisma/seed.ts",
"db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts",
"superadmin": "ts-node prisma/superadmin.ts",
"db:seed": "tsx prisma/seed.ts",
"db:reset": "prisma migrate reset --force && tsx prisma/seed.ts",
"setup": "tsx src/cli/setup.ts",
"superadmin": "tsx src/cli/superadmin.ts",
"postinstall": "prisma generate",
"clean": "rimraf dist"
},
"dependencies": {
"@kooltap/shared": "workspace:*",
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "^5.1.0",
"@nestjs/websockets": "^10.4.22",
"@prisma/client": "^5.10.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"helmet": "^7.1.0",
"passport": "^0.7.0",
"passport-custom": "^1.1.1",
"passport-jwt": "^4.0.1",
"prisma": "^5.10.0",
"reflect-metadata": "^0.2.1",
"rxjs": "^7.8.1",
"sharp": "0.33.5",
"@nestjs/common": "^11.2.8",
"@nestjs/core": "^11.2.8",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^11.2.8",
"@nestjs/platform-socket.io": "^11.2.8",
"@nestjs/throttler": "^6.7.1",
"@nestjs/websockets": "^11.2.8",
"@prisma/adapter-pg": "7.10.1",
"@prisma/client": "7.10.1",
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"dotenv": "^17.2.3",
"helmet": "^8.1.0",
"prisma": "7.10.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"sharp": "^0.34.5",
"socket.io": "^4.8.1",
"ts-node": "^10.9.2",
"typescript": "^5.4.0",
"uuid": "^9.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.0",
"@nestjs/testing": "^10.3.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.11",
"@types/multer": "1.4.12",
"@types/node": "^20.11.0",
"@types/passport-jwt": "^4.0.0",
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.2.8",
"@types/bcrypt": "^6.0.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/multer": "^2.0.0",
"@types/node": "^24.10.0",
"@types/supertest": "^6.0.3",
"@types/uuid": "^9.0.7",
"jest": "^29.7.0",
"rimraf": "^5.0.5",
"supertest": "^6.3.4",
"ts-jest": "^29.1.2",
"ts-loader": "^9.5.1"
"jest": "^30.2.0",
"rimraf": "^6.0.1",
"supertest": "^7.1.4",
"ts-jest": "^29.4.5",
"tsx": "^4.20.6",
"typescript": "~5.9.3"
},
"jest": {
"moduleFileExtensions": [
@@ -74,12 +73,8 @@
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node",
"moduleNameMapper": {
"^@kooltap/shared$": "<rootDir>/../../../packages/shared/src"
+16
View File
@@ -0,0 +1,16 @@
import 'dotenv/config';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts',
},
datasource: {
// Not required for generating the client, which needs no database
url: process.env.DATABASE_URL ?? '',
// Only for checking the migrations against the schema (`migrate diff --from-migrations`)
shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL,
},
});
-75
View File
@@ -1,75 +0,0 @@
/**
* Applies database migrations on container start.
*
* Databases created before migrations were used (via `prisma db push`) have no
* `_prisma_migrations` table. Those are brought up to date with a non-destructive
* `db push` (which refuses to drop data) and then baselined, so every later deploy
* can use plain `prisma migrate deploy`.
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { PrismaClient } = require('@prisma/client');
// Migrations that were first created with dates in the future, renamed to when they were written.
// The SQL is unchanged, so databases that already applied them only need the recorded name updated.
const RENAMED_MIGRATIONS = {
'20261020120000_tab_events': '20261019212336_tab_events',
'20261021120000_stock': '20261020124522_stock',
'20261022120000_server_tabs_and_stock': '20261020141836_server_tabs_and_stock',
'20261023120000_products_on_dashboard': '20261020215540_products_on_dashboard',
'20261024120000_product_images_and_events': '20261021104519_product_images_and_events',
'20261025120000_sold_unit_view': '20261021225808_sold_unit_view',
'20261026120000_drop_tab_snapshots': '20261021233219_drop_tab_snapshots',
};
function run(command) {
console.log(`[deploy] ${command}`);
execSync(command, { stdio: 'inherit' });
}
async function main() {
const prisma = new PrismaClient();
let hasMigrationsTable;
let hasTables;
try {
const [{ migrations, tables }] = await prisma.$queryRaw`
SELECT
to_regclass('public._prisma_migrations') IS NOT NULL AS migrations,
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
hasMigrationsTable = migrations;
hasTables = tables;
if (hasMigrationsTable) {
for (const [oldName, newName] of Object.entries(RENAMED_MIGRATIONS)) {
const renamed = await prisma.$executeRaw`
UPDATE "_prisma_migrations" SET "migration_name" = ${newName} WHERE "migration_name" = ${oldName}`;
if (renamed > 0) console.log(`[deploy] Renamed migration ${oldName} -> ${newName}`);
}
}
} finally {
await prisma.$disconnect();
}
if (!hasMigrationsTable && hasTables) {
console.log('[deploy] Existing database without migration history, baselining');
run('npx prisma db push --skip-generate');
const migrationsDir = path.join(__dirname, 'migrations');
const migrations = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const name of migrations) {
run(`npx prisma migrate resolve --applied ${name}`);
}
}
run('npx prisma migrate deploy');
}
main().catch((error) => {
console.error('[deploy] Migration failed:', error);
process.exit(1);
});
@@ -1,189 +0,0 @@
-- CreateTable
CREATE TABLE "Venue" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"tokenExpiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ClientUser" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'viewer',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationCode" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OpenTabSnapshot" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"totalInCents" INTEGER NOT NULL,
"itemCount" INTEGER NOT NULL,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenTabSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabOwnership" (
"tabId" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"lastReportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TabOwnership_pkey" PRIMARY KEY ("venueId","tabId")
);
-- CreateTable
CREATE TABLE "RemoteCommand" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"targetTabId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'close_tab',
"expectedVersion" INTEGER NOT NULL,
"paymentMethod" TEXT NOT NULL,
"requestedBy" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"result" TEXT,
"acknowledgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RemoteCommand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"details" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Device_venueId_key" ON "Device"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
-- CreateIndex
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email");
-- CreateIndex
CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
-- CreateIndex
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt");
-- CreateIndex
CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId");
-- CreateIndex
CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ClientUser" ADD CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OpenTabSnapshot" ADD CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,19 +0,0 @@
-- CreateTable
CREATE TABLE "PlannedTab" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlannedTab_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PlannedTab_venueId_idx" ON "PlannedTab"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "PlannedTab_venueId_customerName_key" ON "PlannedTab"("venueId", "customerName");
-- AddForeignKey
ALTER TABLE "PlannedTab" ADD CONSTRAINT "PlannedTab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,25 +0,0 @@
-- AlterTable
ALTER TABLE "PlannedTab" ADD COLUMN "email" TEXT;
-- CreateTable
CREATE TABLE "TabReminder" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"amountInCents" INTEGER NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL DEFAULT 'sent',
CONSTRAINT "TabReminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TabReminder_venueId_sentAt_idx" ON "TabReminder"("venueId", "sentAt");
-- CreateIndex
CREATE INDEX "TabReminder_venueId_tabId_idx" ON "TabReminder"("venueId", "tabId");
-- AddForeignKey
ALTER TABLE "TabReminder" ADD CONSTRAINT "TabReminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "PlannedTab" ADD COLUMN "deliveredAt" TIMESTAMP(3);
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB;
@@ -1,27 +0,0 @@
-- 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";
@@ -1,33 +0,0 @@
-- CreateTable
CREATE TABLE "TabEvent" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"productName" TEXT,
"unitPriceInCents" INTEGER,
"quantityDelta" INTEGER,
"amountInCents" INTEGER,
"paymentMethod" TEXT,
"commandId" TEXT,
"items" JSONB,
"occurredAt" TIMESTAMP(3) NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt");
-- CreateIndex
CREATE INDEX "TabEvent_venueId_type_occurredAt_idx" ON "TabEvent"("venueId", "type", "occurredAt");
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,45 +0,0 @@
-- CreateTable
CREATE TABLE "Product" (
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"priceInCents" INTEGER NOT NULL,
"stock" INTEGER,
"lowStockThreshold" INTEGER,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("venueId","productId")
);
-- CreateTable
CREATE TABLE "StockAdjustment" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"note" TEXT,
"requestedBy" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"stockAfter" INTEGER,
"result" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_status_createdAt_idx" ON "StockAdjustment"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_productId_createdAt_idx" ON "StockAdjustment"("venueId", "productId", "createdAt");
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,75 +0,0 @@
-- DropForeignKey
ALTER TABLE "RemoteCommand" DROP CONSTRAINT "RemoteCommand_deviceId_fkey";
-- DropIndex
DROP INDEX "Device_venueId_key";
-- AlterTable
ALTER TABLE "Venue" ADD COLUMN "tabsSeededAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "RemoteCommand" ALTER COLUMN "deviceId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "TabEvent" ADD COLUMN "lineId" TEXT,
ADD COLUMN "productId" TEXT,
ADD COLUMN "stockSetTo" INTEGER,
ALTER COLUMN "tabId" DROP NOT NULL,
ALTER COLUMN "customerName" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "countedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "Tab" (
"venueId" TEXT NOT NULL,
"id" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"openedAt" TIMESTAMP(3) NOT NULL,
"version" INTEGER NOT NULL DEFAULT 0,
"lines" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tab_pkey" PRIMARY KEY ("venueId","id")
);
-- CreateTable
CREATE TABLE "Settlement" (
"id" TEXT NOT NULL,
"sequence" SERIAL NOT NULL,
"venueId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"settledAt" TIMESTAMP(3) NOT NULL,
"paymentMethod" TEXT NOT NULL,
"amountInCents" INTEGER NOT NULL,
"lines" JSONB NOT NULL,
"deviceId" TEXT,
"requestedBy" TEXT,
"commandId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3);
@@ -1,28 +0,0 @@
-- AlterTable
ALTER TABLE "ClientUser" ALTER COLUMN "role" SET DEFAULT 'manager';
-- AlterTable
ALTER TABLE "TabEvent" ADD COLUMN "productData" JSONB;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "fieldUpdatedAt" JSONB,
ADD COLUMN "imageId" TEXT;
-- CreateTable
CREATE TABLE "ProductImage" (
"id" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"bytes" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- "viewer" was the name for the people running the venue: they are managers now
UPDATE "ClientUser" SET "role" = 'manager' WHERE "role" = 'viewer';
@@ -1,42 +0,0 @@
-- One row per drink sold: every unit that was not taken back, whether it is still on an open tab or already
-- settled. Tab.lines and Settlement.lines hold the same unit shape ({ id, addedAt, removedAt }); a unit is in
-- exactly one of them, so nothing is counted twice. Change this view whenever that JSON shape changes.
CREATE VIEW "SoldUnit" AS
SELECT
t."venueId",
u->>'id' AS "unitId",
t."id" AS "tabId",
t."customerName",
l->>'productId' AS "productId",
l->>'productName' AS "productName",
(l->>'unitPriceInCents')::int AS "unitPriceInCents",
(u->>'addedAt')::timestamptz AS "addedAt",
-- A night runs until 06:00
date_trunc('day', (u->>'addedAt')::timestamptz - interval '6 hours', 'Europe/Brussels') AS "night",
NULL::text AS "settlementId",
NULL::timestamptz AS "settledAt",
NULL::text AS "paymentMethod"
FROM "Tab" t,
jsonb_array_elements(t."lines") l,
jsonb_array_elements(l->'units') u
WHERE u->>'removedAt' IS NULL
UNION ALL
SELECT
s."venueId",
u->>'id',
s."tabId",
s."customerName",
l->>'productId',
l->>'productName',
(l->>'unitPriceInCents')::int,
(u->>'addedAt')::timestamptz,
date_trunc('day', (u->>'addedAt')::timestamptz - interval '6 hours', 'Europe/Brussels'),
s."id",
s."settledAt" AT TIME ZONE 'UTC',
s."paymentMethod"
FROM "Settlement" s,
jsonb_array_elements(s."lines") l,
jsonb_array_elements(l->'units') u
WHERE u->>'removedAt' IS NULL;
@@ -1,4 +0,0 @@
-- The terminals used to own the tabs and report them as snapshots; the server holds them in "Tab" now
DROP TABLE "OpenTabSnapshot";
DROP TABLE "TabOwnership";
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3);
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Venue" ADD COLUMN "cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,387 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('admin', 'manager', 'superadmin');
-- CreateEnum
CREATE TYPE "PaymentMethod" AS ENUM ('cash', 'payconiq', 'other');
-- CreateEnum
CREATE TYPE "StockMovementType" AS ENUM ('add', 'count');
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('user', 'device', 'system');
-- CreateEnum
CREATE TYPE "TerminalEventType" AS ENUM ('tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set', 'product_created', 'product_updated', 'product_archived', 'product_restored');
-- CreateTable
CREATE TABLE "Venue" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL,
"venueId" UUID,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'manager',
"sessionsRevokedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" UUID NOT NULL,
"userId" UUID NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
"revokedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"tokenExpiresAt" TIMESTAMPTZ(3) NOT NULL,
"lastSeenAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationCode" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
"usedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Customer" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"name" TEXT NOT NULL,
"nameKey" TEXT NOT NULL,
"email" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"tabOpenedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tab" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"customerName" TEXT NOT NULL,
"openedAt" TIMESTAMPTZ(3) NOT NULL,
"version" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Tab_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Settlement" (
"id" UUID NOT NULL,
"sequence" SERIAL NOT NULL,
"venueId" UUID NOT NULL,
"tabId" UUID NOT NULL,
"customerName" TEXT NOT NULL,
"settledAt" TIMESTAMPTZ(3) NOT NULL,
"paymentMethod" "PaymentMethod" NOT NULL,
"amountInCents" INTEGER NOT NULL,
"deviceId" UUID,
"settledByUserId" UUID,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabUnit" (
"id" TEXT NOT NULL,
"venueId" UUID NOT NULL,
"tabId" UUID,
"settlementId" UUID,
"lineId" TEXT NOT NULL,
"productId" UUID,
"productName" TEXT NOT NULL,
"unitPriceInCents" INTEGER NOT NULL,
"orderId" TEXT,
"addedAt" TIMESTAMPTZ(3) NOT NULL,
"removedAt" TIMESTAMPTZ(3),
CONSTRAINT "TabUnit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"priceInCents" INTEGER NOT NULL,
"buyPriceInCents" INTEGER,
"stock" INTEGER,
"countedAt" TIMESTAMPTZ(3),
"lowStockThreshold" INTEGER,
"desiredStock" INTEGER,
"imageId" TEXT,
"archivedAt" TIMESTAMPTZ(3),
"fieldUpdatedAt" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductImage" (
"id" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"bytes" INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"productId" UUID NOT NULL,
"type" "StockMovementType" NOT NULL,
"quantity" INTEGER NOT NULL,
"stockAfter" INTEGER NOT NULL,
"note" TEXT,
"userId" UUID,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabEvent" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"deviceId" UUID NOT NULL,
"type" "TerminalEventType" NOT NULL,
"payload" JSONB NOT NULL,
"occurredAt" TIMESTAMPTZ(3) NOT NULL,
"receivedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Reminder" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"customerId" UUID,
"customerName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"tabId" UUID NOT NULL,
"amountInCents" INTEGER NOT NULL,
"sentAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" UUID NOT NULL,
"venueId" UUID,
"actorType" "ActorType" NOT NULL,
"actorId" UUID,
"action" TEXT NOT NULL,
"details" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_venueId_idx" ON "User"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
-- CreateIndex
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
-- CreateIndex
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Customer_venueId_nameKey_key" ON "Customer"("venueId", "nameKey");
-- CreateIndex
CREATE INDEX "Tab_venueId_idx" ON "Tab"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
-- CreateIndex
CREATE INDEX "TabUnit_tabId_idx" ON "TabUnit"("tabId");
-- CreateIndex
CREATE INDEX "TabUnit_settlementId_idx" ON "TabUnit"("settlementId");
-- CreateIndex
CREATE INDEX "TabUnit_venueId_addedAt_idx" ON "TabUnit"("venueId", "addedAt");
-- CreateIndex
CREATE INDEX "TabUnit_productId_idx" ON "TabUnit"("productId");
-- CreateIndex
CREATE INDEX "Product_venueId_idx" ON "Product"("venueId");
-- CreateIndex
CREATE INDEX "StockMovement_venueId_createdAt_idx" ON "StockMovement"("venueId", "createdAt");
-- CreateIndex
CREATE INDEX "StockMovement_productId_createdAt_idx" ON "StockMovement"("productId", "createdAt");
-- CreateIndex
CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt");
-- CreateIndex
CREATE INDEX "TabEvent_receivedAt_idx" ON "TabEvent"("receivedAt");
-- CreateIndex
CREATE INDEX "Reminder_venueId_sentAt_idx" ON "Reminder"("venueId", "sentAt");
-- CreateIndex
CREATE INDEX "Reminder_customerId_idx" ON "Reminder"("customerId");
-- CreateIndex
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_settledByUserId_fkey" FOREIGN KEY ("settledByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_tabId_fkey" FOREIGN KEY ("tabId") REFERENCES "Tab"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_settlementId_fkey" FOREIGN KEY ("settlementId") REFERENCES "Settlement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- A drink is on an open tab or in a settlement, never both or neither
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_tab_or_settlement" CHECK (("tabId" IS NULL) <> ("settlementId" IS NULL));
-- A superadmin is above the venues; everyone else belongs to one
ALTER TABLE "User" ADD CONSTRAINT "User_superadmin_has_no_venue" CHECK (("role" = 'superadmin') = ("venueId" IS NULL));
-- Amounts and counts that can't be negative (stock can: a sale is never blocked)
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_amount_positive" CHECK ("amountInCents" >= 0);
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_price_positive" CHECK ("unitPriceInCents" >= 0);
ALTER TABLE "Product" ADD CONSTRAINT "Product_prices_positive" CHECK ("priceInCents" >= 0 AND ("buyPriceInCents" IS NULL OR "buyPriceInCents" >= 0));
@@ -1,5 +0,0 @@
-- Every venue gets a "Free Drinks" customer; the next sync opens its tab (or keeps one that is already open)
INSERT INTO "PlannedTab" ("id", "venueId", "customerName", "customerNameKey", "position", "createdAt")
SELECT gen_random_uuid()::text, "id", 'Free Drinks', 'free drinks', 0, CURRENT_TIMESTAMP
FROM "Venue"
ON CONFLICT ("venueId", "customerNameKey") DO NOTHING;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TabEvent" ADD COLUMN "orderId" TEXT;
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+300 -274
View File
@@ -1,331 +1,357 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
// Compiled to CommonJS by tsc: imports without an extension. Set, not inferred: the client is generated during
// install, before the tsconfig it would be inferred from is there
importFileExtension = ""
}
generator client {
provider = "prisma-client-js"
datasource db {
provider = "postgresql"
}
enum Role {
admin
manager
superadmin
}
enum PaymentMethod {
cash
payconiq
other
}
enum StockMovementType {
add
count
}
enum ActorType {
user
device
system
}
enum TerminalEventType {
tab_opened
tab_renamed
tab_deleted
line_added
line_removed
settled
stock_set
product_created
product_updated
product_archived
product_restored
}
model Venue {
id String @id @default(uuid())
id String @id @default(uuid()) @db.Uuid
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Who gets an email for every tab paid in cash, on a terminal or on the dashboard
cashPaymentEmails String[] @default([])
createdAt DateTime @default(now()) @db.Timestamptz(3)
users User[]
devices Device[]
clientUsers ClientUser[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
tabEvents TabEvent[]
products Product[]
stockAdjustments StockAdjustment[]
registrationCodes RegistrationCode[]
customers Customer[]
tabs Tab[]
settlements Settlement[]
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
// terminal's own open tabs, once
tabsSeededAt DateTime?
// Get an email for every tab paid in cash, on a terminal or on the dashboard
cashPaymentEmails String[] @default([])
units TabUnit[]
products Product[]
stockMovements StockMovement[]
tabEvents TabEvent[]
reminders Reminder[]
auditLog AuditLog[]
}
// A venue's admin or manager, or a superadmin, who runs KoolTap itself and belongs to no venue: they view a venue to
// act in it as its admin
model User {
id String @id @default(uuid()) @db.Uuid
// Null exactly for a superadmin
venueId String? @db.Uuid
// Stored lowercased
email String @unique
passwordHash String
name String
role Role @default(manager)
// Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
sessionsRevokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
refreshTokens RefreshToken[]
settlements Settlement[]
stockMovements StockMovement[]
@@index([venueId])
}
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid
tokenHash String @unique
expiresAt DateTime @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// A bar terminal, paired with a registration code
model Device {
id String @id @default(uuid())
// A venue can have several terminals
venueId String
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
name String
tokenHash String @unique
tokenPrefix String
tokenExpiresAt DateTime
lastSeenAt DateTime @default(now())
revokedAt DateTime?
createdAt DateTime @default(now())
tokenExpiresAt DateTime @db.Timestamptz(3)
lastSeenAt DateTime @default(now()) @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
remoteCommands RemoteCommand[]
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
model ClientUser {
id String @id @default(uuid())
venueId String
email String @unique
passwordHash String
name String
// admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) |
// superadmin (runs KoolTap itself: every venue, and the admin page)
role String @default("manager")
createdAt DateTime @default(now())
// Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
sessionsRevokedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId])
}
model RegistrationCode {
id String @id @default(uuid())
venueId String
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
code String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime @db.Timestamptz(3)
usedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
@@index([venueId])
}
// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles
// the tab itself and records the command as completed right away, for the history.
model RemoteCommand {
id String @id @default(uuid())
venueId String
// Null for a command the server carried out itself
deviceId String?
targetTabId String
customerName String?
amountInCents Int?
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
// A regular of the venue. Tabs are matched to customers by name: the next sync opens a tab for a customer that has
// none, and the customer's email is used for reminders
model Customer {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
name String
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
customerNameKey String
nameKey String
email String?
position Int @default(0)
createdAt DateTime @default(now())
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
deliveredAt DateTime?
// Null until the next sync opens their tab
tabOpenedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
reminders Reminder[]
@@unique([venueId, nameKey])
}
// An open tab: the source of truth the terminals replace their own tabs with on every sync. Tabs are never closed,
// only emptied by settlements; deleting one drops the drinks still on it
model Tab {
// Generated by the terminal that opened it, or by the server for a customer's tab
id String @id @db.Uuid
venueId String @db.Uuid
customerName String
openedAt DateTime @db.Timestamptz(3)
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
version Int @default(0)
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
units TabUnit[]
@@unique([venueId, customerNameKey])
@@index([venueId])
}
model TabReminder {
id String @id @default(uuid())
venueId String
tabId String
customerName String
email String
amountInCents Int
sentAt DateTime @default(now())
status String @default("sent")
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, sentAt])
@@index([venueId, tabId])
}
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
// stored, and applied to the tab state and stock, only once.
model TabEvent {
id String @id
venueId String
deviceId String
// Null for stock_set, which is not about a tab
tabId String?
customerName String?
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
type String
lineId String?
// line_added / line_removed: the order on the terminal the drinks belong to
orderId String?
productId String?
productName String?
unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed
quantityDelta Int?
// settled only
amountInCents Int?
paymentMethod String?
commandId String?
items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
// product_created / product_updated: the product's fields as sent by the terminal
productData Json?
occurredAt DateTime
receivedAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
device Device @relation(fields: [deviceId], references: [id])
@@index([venueId, occurredAt])
@@index([venueId, type, occurredAt])
}
// The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync;
// the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock
// counts and the dashboard's StockAdjustments.
model Product {
venueId String
// Generated by the server, or by the terminal that reported it before the dashboard managed the catalog
productId String
name String
category String?
// The sell price: what the terminals charge
priceInCents Int
// What the bar pays for one; null when not entered. Kept on the dashboard only, the terminals never get it
buyPriceInCents Int?
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
stock Int?
// When the stock was last counted; line events that happened before it are already in the count
countedAt DateTime?
// Warn when the stock drops to this level
lowStockThreshold Int?
// How many the bar wants in stock; the dashboard shows what to order to get there. Not sent to the terminals
desiredStock Int?
// When it was created or last changed
reportedAt DateTime
// Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it
archivedAt DateTime?
imageId String?
// When each field was last changed ({ name: ISO date, ... }): a change from the dashboard or an (offline)
// terminal only wins over a later one per field, so edits of different fields never overwrite each other
fieldUpdatedAt Json?
image ProductImage? @relation(fields: [imageId], references: [id])
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, productId])
}
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
// by a terminal, which reported the outcome.
model StockAdjustment {
id String @id @default(uuid())
venueId String
productId String
productName String
// add (signed: a delivery, or breakage when negative) | set (a stock count)
type String
quantity Int
note String?
requestedBy String
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
processedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
// never closed, only emptied by a settlement; tab_deleted removes one.
model Tab {
venueId String
// Generated by the terminal that opened it
id String
customerName String
openedAt DateTime
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
version Int @default(0)
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
lines Json
updatedAt DateTime @updatedAt
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, id])
}
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number.
// A paid tab, settled on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number
model Settlement {
id String @id
id String @id @db.Uuid
sequence Int @unique @default(autoincrement())
venueId String
tabId String
venueId String @db.Uuid
// The tab may be deleted since
tabId String @db.Uuid
customerName String
settledAt DateTime
// cash | payconiq | payment_pending | other
paymentMethod String
settledAt DateTime @db.Timestamptz(3)
paymentMethod PaymentMethod
// What was charged; the units are what the server had on the tab
amountInCents Int
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
lines Json
// Who settled it: a terminal, or a dashboard user
deviceId String?
requestedBy String?
commandId String?
createdAt DateTime @default(now())
deviceId String? @db.Uuid
settledByUserId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
settledBy User? @relation(fields: [settledByUserId], references: [id], onDelete: SetNull)
units TabUnit[]
@@index([venueId, sequence])
@@index([venueId, settledAt])
}
// A product picture, resized and cropped on upload. The id is the SHA-256 of the stored bytes, so the picture at
// an address never changes and can be cached forever, and the same picture is stored once.
// One drink ever rung up. It is on an open tab until a settlement takes it; a drink taken off again keeps its
// removedAt and moves with the settlement too. Lines are the units sharing a lineId
model TabUnit {
// `<line_added event id>:<index>`, derived the same way on the terminals
id String @id
venueId String @db.Uuid
// Exactly one of tabId and settlementId is set
tabId String? @db.Uuid
settlementId String? @db.Uuid
lineId String
productId String? @db.Uuid
// As rung up: a later change to the product doesn't change what was sold
productName String
unitPriceInCents Int
// The order on the terminal it was rung up in; null only for drinks settled on a terminal the server didn't have
orderId String?
addedAt DateTime @db.Timestamptz(3)
removedAt DateTime? @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
tab Tab? @relation(fields: [tabId], references: [id], onDelete: Cascade)
settlement Settlement? @relation(fields: [settlementId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([tabId])
@@index([settlementId])
@@index([venueId, addedAt])
@@index([productId])
}
// The venue's catalog and stock. The dashboard and the terminals both change products; per field the latest change
// wins. The stock follows the drinks rung up, the terminals' counts and the dashboard's stock movements
model Product {
// Generated by the server, or by the terminal that created it offline
id String @id @db.Uuid
venueId String @db.Uuid
name String
category String?
// The sell price: what the terminals charge
priceInCents Int
// What the bar pays for one. Dashboard only, the terminals never get it
buyPriceInCents Int?
// Null when stock is not tracked. Can be negative: a sale is never blocked
stock Int?
// When the stock was last counted; drinks rung up before it are already in the count
countedAt DateTime? @db.Timestamptz(3)
// Warn when the stock drops to this level
lowStockThreshold Int?
// How many the bar wants in stock. Dashboard only
desiredStock Int?
imageId String?
// Deleted: hidden on the terminals, kept because tabs and history refer to it
archivedAt DateTime? @db.Timestamptz(3)
// When each field was last changed ({ name: ISO date, ... }), so edits of different fields never overwrite each other
fieldUpdatedAt Json @default("{}")
createdAt DateTime @default(now()) @db.Timestamptz(3)
// The last change to what the terminals get (not the stock): part of the catalog version
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
units TabUnit[]
stockMovements StockMovement[]
@@index([venueId])
}
// A product picture, resized and cropped on upload and stored on disk as <id>.webp. The id is the SHA-256 of the
// bytes, so a picture's address never changes and the same picture is stored once
model ProductImage {
id String @id
contentType String
width Int
height Int
bytes Bytes
createdAt DateTime @default(now())
bytes Int
createdAt DateTime @default(now()) @db.Timestamptz(3)
products Product[]
}
// A stock change made on the dashboard: a delivery, breakage or count
model StockMovement {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
productId String @db.Uuid
type StockMovementType
quantity Int
stockAfter Int
note String?
userId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([venueId, createdAt])
@@index([productId, createdAt])
}
// What a terminal reported, as it sent it. The id is generated by the terminal, so an event that is sent again is
// stored, and applied, only once
model TabEvent {
id String @id @db.Uuid
venueId String @db.Uuid
deviceId String @db.Uuid
type TerminalEventType
// The event as sent, after validation
payload Json
occurredAt DateTime @db.Timestamptz(3)
receivedAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@index([venueId, occurredAt])
@@index([receivedAt])
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
// Null when the customer was removed since
customerId String? @db.Uuid
customerName String
email String
tabId String @db.Uuid
amountInCents Int
sentAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@index([venueId, sentAt])
@@index([customerId])
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
// Null for actions that are not about one venue (purging, signing everyone out)
venueId String? @db.Uuid
actorType ActorType
// A user or device id; null for the system
actorId String? @db.Uuid
action String
details Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
@@index([venueId, createdAt])
@@index([createdAt])
}
-173
View File
@@ -1,173 +0,0 @@
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const prisma = new PrismaClient();
// One line with itemCount units, priced so the tab adds up to about totalInCents
async function createTab(venueId, tab) {
const lineId = uuidv4();
const addedAt = tab.reportedAt.toISOString();
await prisma.tab.create({
data: {
venueId,
id: tab.tabId,
customerName: tab.customerName,
openedAt: tab.reportedAt,
version: tab.version,
lines: [{
id: lineId,
productId: null,
productName: 'Pils',
unitPriceInCents: Math.round(tab.totalInCents / tab.itemCount),
units: Array.from({ length: tab.itemCount }, (_, i) => ({ id: `${lineId}:${i}`, addedAt, removedAt: null })),
}],
},
});
}
async function main() {
console.log('Seeding database...');
await prisma.auditLog.deleteMany();
await prisma.tabEvent.deleteMany();
await prisma.tabReminder.deleteMany();
await prisma.plannedTab.deleteMany();
await prisma.remoteCommand.deleteMany();
await prisma.settlement.deleteMany();
await prisma.tab.deleteMany();
await prisma.stockAdjustment.deleteMany();
await prisma.product.deleteMany();
await prisma.productImage.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.registrationCode.deleteMany();
await prisma.device.deleteMany();
await prisma.clientUser.deleteMany();
await prisma.venue.deleteMany();
console.log('Cleared existing data');
const venueId = uuidv4();
await prisma.venue.create({
data: { id: venueId, name: 'KoolTap Demo Bar' },
});
console.log(`Created venue: KoolTap Demo Bar (${venueId})`);
const adminHash = await bcrypt.hash('Admin123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'admin@example.com',
passwordHash: adminHash,
name: 'Eigenaar',
role: 'admin',
},
});
console.log('Created admin user: admin@example.com / Admin123!');
const viewerHash = await bcrypt.hash('Viewer123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'viewer@example.com',
passwordHash: viewerHash,
name: 'Bartender',
role: 'viewer',
},
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
const device1Id = uuidv4();
const device2Id = uuidv4();
const token1 = 'dev_' + crypto.randomBytes(48).toString('base64url');
const token2 = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({
data: {
id: device1Id,
venueId,
name: 'Bar terminal 1',
tokenHash: crypto.createHash('sha256').update(token1).digest('hex'),
tokenPrefix: token1.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 1 * 60 * 1000),
},
});
await prisma.device.create({
data: {
id: device2Id,
venueId,
name: 'Bar terminal 2',
tokenHash: crypto.createHash('sha256').update(token2).digest('hex'),
tokenPrefix: token2.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 15 * 60 * 1000),
},
});
console.log(`Created device: Bar terminal 1 (${device1Id})`);
console.log(`Created device: Bar terminal 2 (${device2Id})`);
const tabsData = [
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Marie', version: 3, totalInCents: 1800, itemCount: 2, reportedAt: new Date(Date.now() - 5 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Thomas', version: 7, totalInCents: 4200, itemCount: 5, reportedAt: new Date(Date.now() - 3 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Lisa', version: 2, totalInCents: 900, itemCount: 1, reportedAt: new Date(Date.now() - 4 * 60 * 1000) },
];
for (const tab of tabsData) {
await createTab(venueId, tab);
}
const staleTabs = [
{ tabId: uuidv4(), customerName: 'Emma', version: 4, totalInCents: 3200, itemCount: 4, reportedAt: new Date(Date.now() - 16 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'David', version: 1, totalInCents: 1500, itemCount: 2, reportedAt: new Date(Date.now() - 20 * 60 * 1000) },
];
for (const tab of staleTabs) {
await createTab(venueId, tab);
}
console.log('Created 6 open tabs');
const now = new Date();
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device1Id, targetTabId: tabsData[1].tabId, type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash', requestedBy: 'Eigenaar', status: 'completed', result: 'Tab closed successfully', acknowledgedAt: new Date(now.getTime() - 30 * 60 * 1000), createdAt: new Date(now.getTime() - 35 * 60 * 1000), expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000) },
});
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device1Id, targetTabId: tabsData[0].tabId, type: 'close_tab', expectedVersion: 5, paymentMethod: 'payconiq', requestedBy: 'Eigenaar', status: 'pending', createdAt: new Date(now.getTime() - 5 * 60 * 1000), expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000) },
});
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device2Id, targetTabId: staleTabs[0].tabId, type: 'close_tab', expectedVersion: 4, paymentMethod: 'payment_pending', requestedBy: 'Bartender', status: 'failed', result: 'Tab could not be closed', acknowledgedAt: new Date(now.getTime() - 60 * 60 * 1000), createdAt: new Date(now.getTime() - 65 * 60 * 1000), expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000) },
});
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device1Id, targetTabId: tabsData[2].tabId, type: 'close_tab', expectedVersion: 7, paymentMethod: 'cash', requestedBy: 'Eigenaar', status: 'conflict', result: 'Version mismatch: expected 7, got 8', acknowledgedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000 - 5 * 60 * 1000), expiresAt: new Date(now.getTime() + 22 * 60 * 60 * 1000) },
});
console.log('Created sample commands: completed, pending, failed, conflict');
await prisma.auditLog.create({
data: { id: uuidv4(), venueId, actorType: 'client_user', actorId: 'admin', action: 'command_created', details: JSON.stringify({ commandId: 'sample', tabId: tabsData[0].tabId }) },
});
console.log('Seeding complete!');
console.log('');
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(`\nDevice tokens (for simulator):`);
console.log(` Bar terminal 1: ${token1}`);
console.log(` Bar terminal 2: ${token2}`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
+127 -228
View File
@@ -1,249 +1,148 @@
import * as bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import { ensureDefaultCustomers } from '../src/planned-tabs/default-customers';
/**
* Demo data for local development: two venues, a user of every role, a paired terminal, a catalog with stock, open
* tabs and some paid ones. Wipes the database first.
*
* pnpm db:seed
*/
import 'dotenv/config';
import { createHash, randomUUID } from 'crypto';
import { hashPassword } from '../src/auth/auth.service';
import { createPrismaClient } from '../src/prisma/prisma.service';
import { ensureDefaultCustomers } from '../src/customers/default-customers';
import { createdFieldTimes } from '../src/ledger/product-fields';
const prisma = new PrismaClient();
const prisma = createPrismaClient();
async function main() {
console.log('Seeding database...');
const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60 * 1000);
// Clean existing data so seed is idempotent
await prisma.auditLog.deleteMany();
await prisma.tabEvent.deleteMany();
await prisma.tabReminder.deleteMany();
await prisma.plannedTab.deleteMany();
await prisma.remoteCommand.deleteMany();
await prisma.settlement.deleteMany();
await prisma.tab.deleteMany();
await prisma.stockAdjustment.deleteMany();
await prisma.product.deleteMany();
await prisma.productImage.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.registrationCode.deleteMany();
await prisma.device.deleteMany();
await prisma.clientUser.deleteMany();
await prisma.venue.deleteMany();
console.log('Cleared existing data');
// Create venue
const venueId = uuidv4();
await prisma.venue.create({
data: { id: venueId, name: 'KoolTap Demo Bar' },
});
await ensureDefaultCustomers(prisma, venueId);
console.log(`Created venue: KoolTap Demo Bar (${venueId})`);
// Create admin user
const adminHash = await bcrypt.hash('Admin123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'admin@example.com',
passwordHash: adminHash,
name: 'Eigenaar',
role: 'admin',
},
});
console.log('Created admin user: admin@example.com / Admin123!');
// Create viewer user
const viewerHash = await bcrypt.hash('Viewer123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'viewer@example.com',
passwordHash: viewerHash,
name: 'Bartender',
role: 'manager',
},
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
// Runs KoolTap itself: every venue, and the admin page
const superadminHash = await bcrypt.hash('Super123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'superadmin@example.com',
passwordHash: superadminHash,
name: 'KoolTap',
role: 'superadmin',
},
});
console.log('Created superadmin user: superadmin@example.com / Super123!');
// A second venue, so the admin page has more than one
const secondVenueId = uuidv4();
await prisma.venue.create({ data: { id: secondVenueId, name: 'KoolTap Festival Bar' } });
await ensureDefaultCustomers(prisma, secondVenueId);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId: secondVenueId,
email: 'festival@example.com',
passwordHash: adminHash,
name: 'Festival',
role: 'admin',
},
});
console.log('Created venue: KoolTap Festival Bar, admin festival@example.com / Admin123!');
// Create one device
const deviceId = uuidv4();
const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({
data: {
id: deviceId,
venueId,
name: 'Bar terminal 1',
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
tokenPrefix: token.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 1 * 60 * 1000),
},
});
console.log(`Created device: Bar terminal 1 (${deviceId})`);
const tabsData = [
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Marie', version: 3, totalInCents: 1800, itemCount: 2, reportedAt: new Date(Date.now() - 5 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Thomas', version: 7, totalInCents: 4200, itemCount: 5, reportedAt: new Date(Date.now() - 3 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Lisa', version: 2, totalInCents: 900, itemCount: 1, reportedAt: new Date(Date.now() - 4 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Emma', version: 4, totalInCents: 3200, itemCount: 4, reportedAt: new Date(Date.now() - 16 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'David', version: 1, totalInCents: 1500, itemCount: 2, reportedAt: new Date(Date.now() - 20 * 60 * 1000) },
const CATALOG = [
{ name: 'Jupiler', category: 'Bier', priceInCents: 280, buyPriceInCents: 110, stock: 96, desiredStock: 144 },
{ name: 'Duvel', category: 'Bier', priceInCents: 450, buyPriceInCents: 190, stock: 24, desiredStock: 48 },
{ name: 'Cola', category: 'Frisdrank', priceInCents: 250, buyPriceInCents: 80, stock: 6, desiredStock: 48 },
{ name: 'Spa rood', category: 'Frisdrank', priceInCents: 230, buyPriceInCents: 70, stock: 30, desiredStock: 48 },
{ name: 'Koffie', category: 'Warm', priceInCents: 250, buyPriceInCents: null, stock: null, desiredStock: null },
{ name: 'Chips', category: null, priceInCents: 200, buyPriceInCents: 60, stock: 18, desiredStock: 30 },
];
for (const tab of tabsData) {
// One line with itemCount units, priced so the tab adds up to about totalInCents
const lineId = uuidv4();
const addedAt = tab.reportedAt.toISOString();
await prisma.tab.create({
/** A tab's drinks: [catalog index, quantity, minutes ago] per order line. */
const TABS: { customerName: string; lines: [number, number, number][] }[] = [
{ customerName: 'Jan', lines: [[0, 4, 90], [5, 1, 90], [1, 2, 20]] },
{ customerName: 'Marie', lines: [[2, 2, 45], [3, 1, 45]] },
{ customerName: 'Thomas', lines: [[1, 3, 120], [0, 2, 60]] },
{ customerName: 'Lisa', lines: [[4, 1, 10]] },
];
async function main() {
await prisma.productImage.deleteMany();
await prisma.venue.deleteMany();
const venue = await prisma.venue.create({ data: { name: 'KoolTap Demo Bar', cashPaymentEmails: [] } });
await ensureDefaultCustomers(prisma, venue.id);
const festival = await prisma.venue.create({ data: { name: 'KoolTap Festival Bar' } });
await ensureDefaultCustomers(prisma, festival.id);
const users = [
{ venueId: venue.id, email: 'admin@example.com', password: 'Admin123!', name: 'Eigenaar', role: 'admin' as const },
{ venueId: venue.id, email: 'manager@example.com', password: 'Manager123!', name: 'Bartender', role: 'manager' as const },
// Above the venues: views one to act in it as its admin
{ venueId: null, email: 'superadmin@example.com', password: 'Super123!', name: 'KoolTap', role: 'superadmin' as const },
{ venueId: festival.id, email: 'festival@example.com', password: 'Admin123!', name: 'Festival', role: 'admin' as const },
];
for (const { password, ...user } of users) {
await prisma.user.create({ data: { ...user, passwordHash: await hashPassword(password) } });
}
const manager = await prisma.user.findUniqueOrThrow({ where: { email: 'manager@example.com' } });
// A known token, so `scripts/terminal-simulator.ts` can sync as this terminal without pairing
const token = 'kt_demo-terminal-token';
const device = await prisma.device.create({
data: {
venueId,
id: tab.tabId,
customerName: tab.customerName,
openedAt: tab.reportedAt,
version: tab.version,
lines: [{
id: lineId,
productId: null,
productName: 'Pils',
unitPriceInCents: Math.round(tab.totalInCents / tab.itemCount),
units: Array.from({ length: tab.itemCount }, (_, i) => ({ id: `${lineId}:${i}`, addedAt, removedAt: null })),
}],
venueId: venue.id,
name: 'Bar terminal 1',
tokenHash: createHash('sha256').update(token).digest('hex'),
tokenPrefix: token.slice(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: minutesAgo(1),
},
});
const now = new Date();
const products = [];
for (const { stock, ...fields } of CATALOG) {
const product = await prisma.product.create({
data: {
id: randomUUID(),
venueId: venue.id,
...fields,
stock,
countedAt: stock === null ? null : minutesAgo(24 * 60),
lowStockThreshold: stock === null ? null : 12,
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now),
updatedAt: now,
},
});
products.push(product);
if (stock !== null) {
await prisma.stockMovement.create({
data: { venueId: venue.id, productId: product.id, type: 'count', quantity: stock, stockAfter: stock, userId: manager.id, createdAt: minutesAgo(24 * 60) },
});
}
}
console.log('Created 6 open tabs');
// Create sample commands
const now = new Date();
// Completed command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[1].tabId,
type: 'close_tab',
expectedVersion: 3,
paymentMethod: 'cash',
requestedBy: 'Eigenaar',
status: 'completed',
result: 'Tab closed successfully',
acknowledgedAt: new Date(now.getTime() - 30 * 60 * 1000),
createdAt: new Date(now.getTime() - 35 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
await prisma.customer.createMany({
data: ['Jan', 'Marie', 'Thomas'].map((name, index) => ({
venueId: venue.id, name, nameKey: name.toLowerCase(), email: `${name.toLowerCase()}@example.com`, position: index + 1, tabOpenedAt: now,
})),
});
// Pending command
await prisma.remoteCommand.create({
// Open tabs, and for each customer one paid tab from last week
for (const [index, { customerName, lines }] of TABS.entries()) {
const tab = await prisma.tab.create({
data: { id: randomUUID(), venueId: venue.id, customerName, openedAt: minutesAgo(180), version: lines.length, updatedAt: now },
});
await prisma.tabUnit.createMany({ data: lines.flatMap(([productIndex, quantity, ago]) => units(venue.id, products[productIndex], quantity, minutesAgo(ago), randomUUID(), { tabId: tab.id })) });
const settledAt = minutesAgo((index + 1) * 24 * 60 * 2);
const product = products[index % products.length];
const settlement = await prisma.settlement.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[0].tabId,
type: 'close_tab',
expectedVersion: 5,
paymentMethod: 'payconiq',
requestedBy: 'Eigenaar',
status: 'pending',
createdAt: new Date(now.getTime() - 5 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
id: randomUUID(), venueId: venue.id, tabId: tab.id, customerName, settledAt, paymentMethod: index % 2 === 0 ? 'cash' : 'payconiq',
amountInCents: 3 * product.priceInCents, deviceId: device.id,
},
});
await prisma.tabUnit.createMany({ data: units(venue.id, product, 3, minutesAgo((index + 1) * 24 * 60 * 2 + 60), randomUUID(), { settlementId: settlement.id }) });
}
// Failed command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
console.log('Seeded the demo database. Sign in with:');
for (const user of users) console.log(` ${user.role.padEnd(10)} ${user.email} / ${user.password}`);
console.log(`Terminal token for the simulator: ${token}`);
}
function units(
venueId: string,
product: { id: string; name: string; priceInCents: number },
quantity: number,
addedAt: Date,
lineId: string,
where: { tabId?: string; settlementId?: string },
) {
const eventId = randomUUID();
return Array.from({ length: quantity }, (_, n) => ({
id: `${eventId}:${n}`,
venueId,
deviceId,
targetTabId: tabsData[4].tabId,
type: 'close_tab',
expectedVersion: 4,
paymentMethod: 'payment_pending',
requestedBy: 'Bartender',
status: 'failed',
result: 'Tab could not be closed',
acknowledgedAt: new Date(now.getTime() - 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 65 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
});
// Conflict command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[2].tabId,
type: 'close_tab',
expectedVersion: 7,
paymentMethod: 'cash',
requestedBy: 'Eigenaar',
status: 'conflict',
result: 'Version mismatch: expected 7, got 8',
acknowledgedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000 - 5 * 60 * 1000),
expiresAt: new Date(now.getTime() + 22 * 60 * 60 * 1000),
},
});
console.log('Created sample commands: completed, pending, failed, conflict');
// Create audit log entries
await prisma.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: 'admin',
action: 'command_created',
details: JSON.stringify({ commandId: 'sample', tabId: tabsData[0].tabId }),
},
});
console.log('Seeding complete!');
console.log('');
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(' Superadmin: superadmin@example.com / Super123!');
console.log(`\nDevice token (for simulator): ${token}`);
...where,
lineId,
productId: product.id,
productName: product.name,
unitPriceInCents: product.priceInCents,
orderId: `order-${addedAt.getTime()}`,
addedAt,
}));
}
main()
.catch(console.error)
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-74
View File
@@ -1,74 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const prisma = new PrismaClient();
const args = process.argv.slice(2).filter((arg) => arg !== '--force');
const FORCE = process.argv.includes('--force');
const VENUE_NAME = process.env.SETUP_VENUE_NAME || args[0] || 'My Venue';
const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || args[1];
const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || args[2];
async function main() {
console.log('=== KoolTap Production Setup ===\n');
const venues = await prisma.venue.count();
if (venues > 0 && !FORCE) {
console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`);
return;
}
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error('Usage: node prisma/setup.js "<venue name>" <admin email> <admin password> [--force]');
}
if (ADMIN_PASSWORD.length < 10) {
throw new Error('The admin password must be at least 10 characters');
}
const venueId = uuidv4();
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
await prisma.$transaction(async (tx) => {
await tx.auditLog.deleteMany();
await tx.tabEvent.deleteMany();
await tx.tabReminder.deleteMany();
await tx.plannedTab.deleteMany();
await tx.remoteCommand.deleteMany();
await tx.settlement.deleteMany();
await tx.tab.deleteMany();
await tx.stockAdjustment.deleteMany();
await tx.product.deleteMany();
await tx.productImage.deleteMany();
await tx.refreshToken.deleteMany();
await tx.registrationCode.deleteMany();
await tx.device.deleteMany();
await tx.clientUser.deleteMany();
await tx.venue.deleteMany();
await tx.venue.create({ data: { id: venueId, name: VENUE_NAME } });
await tx.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: ADMIN_EMAIL,
passwordHash: hash,
name: 'Admin',
role: 'admin',
},
});
});
console.log('=== Setup complete ===');
console.log(` Venue: ${VENUE_NAME}`);
console.log(` Venue ID: ${venueId}`);
console.log(` Admin: ${ADMIN_EMAIL}`);
console.log('\nLog in to the dashboard with the admin credentials you provided.');
}
main()
.catch((err) => {
console.error('Setup failed:', err.message ?? err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-31
View File
@@ -1,31 +0,0 @@
import { PrismaClient } from '@prisma/client';
/**
* Makes an existing dashboard user a superadmin, or an admin again with --revoke. There is no other way to become
* the first superadmin: the dashboard only lets a superadmin hand out the role.
*
* pnpm --filter @kooltap/api run superadmin user@example.com [--revoke]
*/
async function main() {
const [email, flag] = process.argv.slice(2);
if (!email) {
console.error('Usage: superadmin <email> [--revoke]');
process.exit(1);
}
const role = flag === '--revoke' ? 'admin' : 'superadmin';
const prisma = new PrismaClient();
try {
const user = await prisma.clientUser.findUnique({ where: { email } });
if (!user) {
console.error(`No user with email ${email}`);
process.exit(1);
}
await prisma.clientUser.update({ where: { id: user.id }, data: { role } });
console.log(`${email} is now ${role === 'superadmin' ? 'a superadmin' : 'an admin'}`);
} finally {
await prisma.$disconnect();
}
}
main();
+54 -105
View File
@@ -1,225 +1,174 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { AdminService } from './admin.service';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { z } from 'zod';
import {
CopyCatalogDto,
DryRunDto,
PurgeDto,
ResetPasswordDto,
ResetVenueDto,
TestEmailDto,
UpdateRoleDto,
VenueNameDto,
} from './dto/admin.dto';
auditQuerySchema, copyCatalogSchema, deleteVenueQuerySchema, dryRunSchema, purgeSchema, resetPasswordSchema, resetVenueSchema,
testEmailSchema, updateRoleSchema, venueNameSchema,
} from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth';
import { AdminService } from './admin.service';
type Body<S extends z.ZodType> = z.output<S>;
/** The superadmin page: every venue, terminal and user of KoolTap. Destructive actions take a dryRun to preview. */
@ApiTags('Admin')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('superadmin')
@Controller('admin')
@Authorized()
@Roles('superadmin')
export class AdminController {
constructor(private admin: AdminService) {}
// ---- Venues
@Get('venues')
@ApiOperation({ summary: 'Every venue with what it holds' })
listVenues() {
return this.admin.listVenues();
}
@Post('venues')
@ApiOperation({ summary: 'Create a venue' })
createVenue(@Body() dto: VenueNameDto, @Req() req: any) {
return this.admin.createVenue(dto.name, req.user);
createVenue(@Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
return this.admin.createVenue(dto.name, user);
}
@Patch('venues/:venueId')
@ApiOperation({ summary: 'Rename a venue' })
renameVenue(@Param('venueId') venueId: string, @Body() dto: VenueNameDto, @Req() req: any) {
return this.admin.renameVenue(venueId, dto.name, req.user);
renameVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
return this.admin.renameVenue(venueId, dto.name, user);
}
@Delete('venues/:venueId')
@ApiOperation({ summary: 'Delete a venue with everything in it, users and terminals included' })
@ApiQuery({ name: 'confirmName', description: "The venue's name" })
deleteVenue(@Param('venueId') venueId: string, @Query('confirmName') confirmName: string | undefined, @Req() req: any) {
return this.admin.deleteVenue(venueId, confirmName, req.user);
deleteVenue(
@Param('venueId', ParseUUIDPipe) venueId: string,
@Query(zod(deleteVenueQuerySchema)) query: Body<typeof deleteVenueQuerySchema>,
@CurrentUser() user: AuthUser,
) {
return this.admin.deleteVenue(venueId, query.confirmName, user);
}
@Post('venues/:venueId/view')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Start viewing the dashboard as the venue; recorded in the venue's audit log" })
viewVenue(@Param('venueId') venueId: string, @Req() req: any) {
return this.admin.viewVenue(venueId, req.user);
viewVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
return this.admin.viewVenue(venueId, user);
}
@Post('venues/:venueId/copy-catalog')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Copy another venue's products that this venue doesn't have yet" })
copyCatalog(@Param('venueId') venueId: string, @Body() dto: CopyCatalogDto, @Req() req: any) {
return this.admin.copyCatalog(venueId, dto.fromVenueId, req.user);
copyCatalog(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(copyCatalogSchema)) dto: Body<typeof copyCatalogSchema>, @CurrentUser() user: AuthUser) {
return this.admin.copyCatalog(venueId, dto.fromVenueId, user);
}
@Post('venues/:venueId/reset')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Wipe parts of a venue's data; users, terminals and products stay" })
resetVenue(@Param('venueId') venueId: string, @Body() dto: ResetVenueDto, @Req() req: any) {
return this.admin.resetVenue(venueId, dto.scopes, { stockValue: dto.stockValue, dryRun: dto.dryRun }, req.user);
resetVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(resetVenueSchema)) dto: Body<typeof resetVenueSchema>, @CurrentUser() user: AuthUser) {
return this.admin.resetVenue(venueId, dto.scopes, dto, user);
}
@Get('venues/:venueId/consistency')
@ApiOperation({ summary: "Look for data in the venue that doesn't add up" })
consistency(@Param('venueId') venueId: string) {
consistency(@Param('venueId', ParseUUIDPipe) venueId: string) {
return this.admin.consistency(venueId);
}
@Get('venues/:venueId/export')
@ApiOperation({ summary: 'Everything of the venue as JSON, without passwords and pictures' })
exportVenue(@Param('venueId') venueId: string, @Req() req: any) {
return this.admin.exportVenue(venueId, req.user);
exportVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
return this.admin.exportVenue(venueId, user);
}
@Post('venues/:venueId/registration-codes')
@ApiOperation({ summary: 'A pairing code for a terminal of the venue' })
createRegistrationCode(@Param('venueId') venueId: string, @Req() req: any) {
return this.admin.createRegistrationCode(venueId, req.user);
createRegistrationCode(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
return this.admin.createRegistrationCode(venueId, user);
}
// ---- Terminals
@Get('devices')
@ApiOperation({ summary: 'Every terminal, unpaired ones included' })
listDevices() {
return this.admin.listDevices();
}
@Post('devices/:deviceId/revoke')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Unpair a terminal' })
revokeDevice(@Param('deviceId') deviceId: string, @Req() req: any) {
return this.admin.revokeDevice(deviceId, req.user);
revokeDevice(@Param('deviceId', ParseUUIDPipe) deviceId: string, @CurrentUser() user: AuthUser) {
return this.admin.revokeDevice(deviceId, user);
}
@Get('registration-codes')
@ApiOperation({ summary: 'Pairing codes that can still be used' })
listRegistrationCodes() {
return this.admin.listRegistrationCodes();
}
@Post('registration-codes/:id/revoke')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Make a pairing code unusable' })
revokeRegistrationCode(@Param('id') id: string) {
revokeRegistrationCode(@Param('id', ParseUUIDPipe) id: string) {
return this.admin.revokeRegistrationCode(id);
}
@Post('registration-codes/cleanup')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete used and expired pairing codes' })
cleanupRegistrationCodes() {
return this.admin.cleanupRegistrationCodes();
}
@Post('commands/expire-stuck')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Expire every pending command' })
expireStuckCommands(@Body() dto: DryRunDto, @Req() req: any) {
return this.admin.expireStuckCommands(dto.dryRun ?? false, req.user);
}
// ---- Users
@Get('users')
@ApiOperation({ summary: 'Every dashboard user' })
listUsers() {
return this.admin.listUsers();
}
@Patch('users/:userId/role')
@ApiOperation({ summary: "Change a user's role (not your own)" })
updateRole(@Param('userId') userId: string, @Body() dto: UpdateRoleDto, @Req() req: any) {
return this.admin.updateRole(userId, dto.role, req.user);
updateRole(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(updateRoleSchema)) dto: Body<typeof updateRoleSchema>, @CurrentUser() user: AuthUser) {
return this.admin.updateRole(userId, dto.role, dto.venueId, user);
}
@Post('users/:userId/password')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Set a user's password; signs them out everywhere" })
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @Req() req: any) {
return this.admin.resetPassword(userId, dto.password, req.user);
resetPassword(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(resetPasswordSchema)) dto: Body<typeof resetPasswordSchema>, @CurrentUser() user: AuthUser) {
return this.admin.resetPassword(userId, dto.password, user);
}
@Post('users/:userId/logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sign a user out everywhere, at once' })
logoutUser(@Param('userId') userId: string, @Req() req: any) {
return this.admin.logoutUser(userId, req.user);
logoutUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
return this.admin.logoutUser(userId, user);
}
@Delete('users/:userId')
@ApiOperation({ summary: 'Delete a user (not yourself)' })
deleteUser(@Param('userId') userId: string, @Req() req: any) {
return this.admin.deleteUser(userId, req.user);
deleteUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
return this.admin.deleteUser(userId, user);
}
@Post('sessions/revoke-all')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Sign out every user but yourself' })
logoutEveryone(@Req() req: any) {
return this.admin.logoutEveryone(req.user);
logoutEveryone(@CurrentUser() user: AuthUser) {
return this.admin.logoutEveryone(user);
}
// ---- Maintenance
@Post('purge')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete old terminal events, audit entries or finished commands' })
purge(@Body() dto: PurgeDto, @Req() req: any) {
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun ?? false, req.user);
purge(@Body(zod(purgeSchema)) dto: Body<typeof purgeSchema>, @CurrentUser() user: AuthUser) {
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun, user);
}
@Post('images/cleanup')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete pictures no product uses (older than a week)' })
cleanupImages(@Body() dto: DryRunDto, @Req() req: any) {
return this.admin.cleanupImages(dto.dryRun ?? false, req.user);
cleanupImages(@Body(zod(dryRunSchema)) dto: Body<typeof dryRunSchema>, @CurrentUser() user: AuthUser) {
return this.admin.cleanupImages(dto.dryRun, user);
}
@Post('email/test')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Send the test email to an address' })
sendTestEmail(@Body() dto: TestEmailDto, @Req() req: any) {
return this.admin.sendTestEmail(dto.to, req.user);
sendTestEmail(@Body(zod(testEmailSchema)) dto: Body<typeof testEmailSchema>, @CurrentUser() user: AuthUser) {
return this.admin.sendTestEmail(dto.to, user);
}
// ---- Insight
@Get('audit')
@ApiOperation({ summary: 'The audit log of every venue' })
@ApiQuery({ name: 'venueId', required: false })
@ApiQuery({ name: 'action', required: false, description: 'Part of the action name' })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
auditLog(
@Query('venueId') venueId?: string,
@Query('action') action?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.admin.auditLog({
venueId: venueId || undefined,
action: action || undefined,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
auditLog(@Query(zod(auditQuerySchema)) query: Body<typeof auditQuerySchema>) {
return this.admin.auditLog(query);
}
@Get('health')
@ApiOperation({ summary: 'Database, migrations, live connections and background jobs' })
health() {
return this.admin.health();
}
+3 -4
View File
@@ -1,13 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesModule } from '../devices/devices.module';
import { ImagesModule } from '../images/images.module';
import { RemindersModule } from '../reminders/reminders.module';
import { JobsModule } from '../jobs/jobs.module';
import { VenuesModule } from '../venues/venues.module';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
@Module({
imports: [AuthModule, DevicesModule, RemindersModule, JobsModule],
imports: [ImagesModule, RemindersModule, VenuesModule],
controllers: [AdminController],
providers: [AdminService],
})
+256 -338
View File
@@ -1,32 +1,35 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { randomUUID } from 'crypto';
import { promises as fs } from 'fs';
import * as path from 'path';
import {
AdminDevice, AdminRegistrationCode, AdminUser, AdminVenue, AuditEntry, Finding, PurgeTarget, RealtimeTopic, ResetScope, Role,
SystemHealth,
} from '@kooltap/shared';
import { Prisma, PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { AuthService } from '../auth/auth.service';
import { DevicesService } from '../devices/devices.service';
import { RealtimeService } from '../realtime/realtime.service';
import { RemindersService } from '../reminders/reminders.service';
import { CommandExpiryJob } from '../jobs/command-expiry.job';
import { createdFieldTimes } from '../stock/product-fields';
import { VenuesService } from '../venues/venues.service';
import { ImagesService, imageDir } from '../images/images.service';
import { Resend } from '../email/resend';
import { hashPassword } from '../auth/auth.service';
import { AuthUser } from '../common/auth';
import { createdFieldTimes } from '../ledger/product-fields';
import { lockVenue } from '../ledger/venue-ledger';
import { ensureDefaultCustomers } from '../customers/default-customers';
import { checkConsistency } from './consistency';
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
import { PurgeTarget } from './dto/admin.dto';
/** The superadmin doing it. */
export type Actor = { id: string; email: string; name: string; venueId: string };
import { countReset, resetVenue, ResetCounts } from './venue-reset';
// What each reset scope changes on the dashboard
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
// Plans the Free Drinks customer again
tabs: ['tabs', 'planned-tabs'],
// Adds the Free Drinks customer again
tabs: ['tabs', 'customers'],
settlements: ['tabs'],
tabEvents: [],
plannedTabs: ['planned-tabs', 'reminders'],
customers: ['customers', 'reminders'],
stock: ['stock'],
stockHistory: ['stock'],
commands: ['commands'],
auditLog: [],
};
@@ -35,26 +38,24 @@ const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log
* of the superadmin's own venue, since the log is kept per venue.
*/
/** Everything the superadmin page does. */
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private audit: AuditService,
private realtime: RealtimeService,
private authService: AuthService,
private devicesService: DevicesService,
private venues: VenuesService,
private reminders: RemindersService,
private commandExpiry: CommandExpiryJob,
private images: ImagesService,
private resend: Resend,
) {}
// ---- Venues
async listVenues() {
const venues = await this.prisma.venue.findMany({
async listVenues(): Promise<AdminVenue[]> {
const [venues, lastSeen] = await Promise.all([
this.prisma.venue.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
@@ -62,88 +63,75 @@ export class AdminService {
createdAt: true,
_count: {
select: {
clientUsers: true,
users: true,
devices: { where: { revokedAt: null } },
tabs: true,
products: { where: { archivedAt: null } },
plannedTabs: true,
customers: true,
settlements: true,
},
},
},
});
const lastSeen = await this.prisma.device.groupBy({
by: ['venueId'],
where: { revokedAt: null },
_max: { lastSeenAt: true },
});
}),
this.prisma.device.groupBy({ by: ['venueId'], where: { revokedAt: null }, _max: { lastSeenAt: true } }),
]);
const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt]));
return {
venues: venues.map(({ _count, ...venue }) => ({
return venues.map(({ _count, ...venue }) => ({
...venue,
users: _count.clientUsers,
createdAt: venue.createdAt.toISOString(),
users: _count.users,
devices: _count.devices,
openTabs: _count.tabs,
products: _count.products,
plannedTabs: _count.plannedTabs,
customers: _count.customers,
settlements: _count.settlements,
lastSyncAt: lastSeenByVenue.get(venue.id) ?? null,
})),
};
lastSyncAt: lastSeenByVenue.get(venue.id)?.toISOString() ?? null,
}));
}
async createVenue(name: string, actor: Actor) {
async createVenue(name: string, actor: AuthUser) {
const venue = await this.prisma.$transaction(async (tx) => {
const created = await tx.venue.create({ data: { name: name.trim() } });
const created = await tx.venue.create({ data: { name } });
await ensureDefaultCustomers(tx, created.id);
return created;
});
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
return venue;
await this.audit.log({ venueId: venue.id, actor: { type: 'user', id: actor.id }, action: 'admin_venue_created', details: { name } });
return { id: venue.id, name: venue.name };
}
async renameVenue(venueId: string, name: string, actor: Actor) {
async renameVenue(venueId: string, name: string, actor: AuthUser) {
const before = await this.findVenue(venueId);
const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } });
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email });
return venue;
await this.prisma.venue.update({ where: { id: venueId }, data: { name } });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_renamed', details: { from: before.name, to: name } });
return { ok: true };
}
/** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */
async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) {
/** Deletes a venue with everything in it. [confirmName] must be its name, so a wrong id can't delete one by accident. */
async deleteVenue(venueId: string, confirmName: string, actor: AuthUser) {
const venue = await this.findVenue(venueId);
if (venueId === actor.venueId) {
throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' });
}
if (confirmName !== venue.name) {
throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' });
}
await this.prisma.$transaction(async (tx) => {
await lockVenue(tx, venueId);
await deleteVenue(tx, venueId);
}, { timeout: 60000 });
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email });
await tx.venue.delete({ where: { id: venueId } });
}, { timeout: 60_000 });
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_venue_deleted', details: { venueId, name: venue.name } });
return { ok: true };
}
/** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */
async viewVenue(venueId: string, actor: Actor) {
async viewVenue(venueId: string, actor: AuthUser) {
const venue = await this.findVenue(venueId);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_viewed_venue', details: { by: actor.email } });
return { id: venue.id, name: venue.name };
}
/**
* Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without
* stock: stock is counted per venue.
*/
async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) {
if (venueId === fromVenueId) {
throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
}
/** Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but no stock. */
async copyCatalog(venueId: string, fromVenueId: string, actor: AuthUser) {
if (venueId === fromVenueId) throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
await this.findVenue(venueId);
await this.findVenue(fromVenueId);
@@ -156,184 +144,148 @@ export class AdminService {
]);
const taken = new Set(existing.map((p) => p.name.trim().toLowerCase()));
const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase()));
if (copies.length > 0) {
await tx.product.createMany({
data: copies.map((p) => {
const fields = {
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock,
};
return {
venueId,
productId: uuidv4(),
...fields,
stock: null,
countedAt: null,
reportedAt: now,
imageId: p.imageId,
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock, imageId: p.imageId,
};
return { id: randomUUID(), venueId, ...fields, fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now), updatedAt: now };
}),
});
}
return copies.length;
});
await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_catalog_copied', details: { fromVenueId, created } });
if (created > 0) this.realtime.notify(venueId, 'stock');
return { created };
}
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue: number; dryRun: boolean }, actor: AuthUser) {
await this.findVenue(venueId);
const unique = [...new Set(scopes)];
if (options.dryRun) {
return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) };
}
const stockValue = options.stockValue ?? 0;
const counts: ResetCounts = await this.prisma.$transaction(async (tx) => {
await lockVenue(tx, venueId);
return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() });
}, { timeout: 60000 });
return resetVenue(tx, venueId, unique, { stockValue: options.stockValue, userId: actor.id, now: new Date() });
}, { timeout: 60_000 });
// After the reset, so wiping the audit log keeps the entry that says who wiped it
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email });
const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope]));
if (topics.size > 0) this.realtime.notify(venueId, ...topics);
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_reset', details: { scopes: unique, stockValue: options.stockValue, counts } });
this.realtime.notify(venueId, ...unique.flatMap((scope) => SCOPE_TOPICS[scope]));
return { dryRun: false, counts };
}
async consistency(venueId: string) {
async consistency(venueId: string): Promise<{ checkedAt: string; findings: Finding[] }> {
await this.findVenue(venueId);
const where = { venueId };
const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([
this.prisma.settlement.findMany({
where,
select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true },
}),
this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }),
this.prisma.product.findMany({
where: { venueId, archivedAt: null },
select: { productId: true, name: true, stock: true, countedAt: true },
}),
this.prisma.remoteCommand.findMany({
where: { venueId, status: 'pending' },
select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } },
}),
const [settlements, drinks, tabs, products, devices] = await Promise.all([
this.prisma.settlement.findMany({ where, select: { id: true, customerName: true, amountInCents: true } }),
this.prisma.$queryRaw<{ settlementId: string; cents: bigint }[]>`
SELECT "settlementId", SUM("unitPriceInCents")::bigint AS cents FROM "TabUnit"
WHERE "venueId" = ${venueId}::uuid AND "settlementId" IS NOT NULL AND "removedAt" IS NULL
GROUP BY "settlementId"`,
this.prisma.tab.findMany({ where, select: { id: true, customerName: true } }),
this.prisma.product.findMany({ where: { venueId, archivedAt: null }, select: { id: true, name: true, stock: true } }),
this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
]);
const drinksBySettlement = new Map(drinks.map((row) => [row.settlementId, Number(row.cents)]));
const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
return { checkedAt: new Date(), findings };
const now = new Date();
const findings = checkConsistency({
settlements: settlements.map((s) => ({ ...s, drinksInCents: drinksBySettlement.get(s.id) ?? 0 })),
tabs,
products,
devices,
}, now);
return { checkedAt: now.toISOString(), findings };
}
/** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
async exportVenue(venueId: string, actor: Actor) {
/** Everything of a venue except password hashes and pictures, for a backup before a reset. */
async exportVenue(venueId: string, actor: AuthUser) {
const venue = await this.findVenue(venueId);
const where = { venueId };
const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] =
await Promise.all([
this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
const [users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog] = await Promise.all([
this.prisma.user.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
this.prisma.customer.findMany({ where, orderBy: { position: 'asc' } }),
this.prisma.product.findMany({ where }),
this.prisma.tab.findMany({ where }),
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }),
this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }),
this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }),
this.prisma.tabUnit.findMany({ where, orderBy: { addedAt: 'asc' } }),
this.prisma.stockMovement.findMany({ where, orderBy: { createdAt: 'asc' } }),
this.prisma.reminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
]);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email });
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_exported' });
return {
exportedAt: new Date(),
venue,
users,
devices,
products,
tabs,
settlements,
plannedTabs,
reminders,
stockAdjustments,
commands,
tabEvents,
auditLog,
exportedAt: new Date(), venue, users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog,
};
}
async createRegistrationCode(venueId: string, actor: AuthUser) {
await this.findVenue(venueId);
const code = await this.venues.createRegistrationCode(venueId);
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_registration_code_created' });
return code;
}
// ---- Terminals
async listDevices() {
async listDevices(): Promise<AdminDevice[]> {
const devices = await this.prisma.device.findMany({
orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }],
select: {
id: true,
name: true,
venueId: true,
tokenPrefix: true,
tokenExpiresAt: true,
lastSeenAt: true,
revokedAt: true,
createdAt: true,
venue: { select: { name: true } },
_count: { select: { remoteCommands: { where: { status: 'pending' } } } },
},
include: { venue: { select: { name: true } } },
});
return devices.map((d) => ({
id: d.id,
name: d.name,
venueId: d.venueId,
venueName: d.venue.name,
tokenPrefix: d.tokenPrefix,
tokenExpiresAt: d.tokenExpiresAt.toISOString(),
lastSeenAt: d.lastSeenAt.toISOString(),
revokedAt: d.revokedAt?.toISOString() ?? null,
createdAt: d.createdAt.toISOString(),
}));
}
async revokeDevice(deviceId: string, actor: AuthUser) {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device) throw new NotFoundException({ code: 'device_not_found', message: 'Terminal not found' });
return this.venues.unpairDevice(device.venueId, deviceId, { type: 'user', id: actor.id });
}
async listRegistrationCodes(): Promise<{ codes: AdminRegistrationCode[]; stale: number }> {
const now = new Date();
const [codes, stale] = await Promise.all([
this.prisma.registrationCode.findMany({
where: { usedAt: null, expiresAt: { gt: now } },
orderBy: { createdAt: 'desc' },
include: { venue: { select: { name: true } } },
}),
this.prisma.registrationCode.count({ where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: now } }] } }),
]);
return {
devices: devices.map(({ venue, _count, ...device }) => ({
...device,
venueName: venue.name,
pendingCommands: _count.remoteCommands,
codes: codes.map((c) => ({
id: c.id, code: c.code, venueId: c.venueId, venueName: c.venue.name, expiresAt: c.expiresAt.toISOString(), createdAt: c.createdAt.toISOString(),
})),
stale,
};
}
async revokeDevice(deviceId: string, actor: Actor) {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
await this.devicesService.revoke(device.venueId, deviceId);
await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email });
return { ok: true };
}
async listRegistrationCodes() {
const codes = await this.prisma.registrationCode.findMany({
where: { usedAt: null, expiresAt: { gt: new Date() } },
orderBy: { createdAt: 'desc' },
select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } },
});
const stale = await this.prisma.registrationCode.count({
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
});
return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale };
}
async createRegistrationCode(venueId: string, actor: Actor) {
await this.findVenue(venueId);
const code = await this.authService.generateRegistrationCode(venueId);
await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email });
return code;
}
async revokeRegistrationCode(id: string) {
const { count } = await this.prisma.registrationCode.updateMany({
where: { id, usedAt: null, expiresAt: { gt: new Date() } },
data: { expiresAt: new Date() },
});
const now = new Date();
const { count } = await this.prisma.registrationCode.updateMany({ where: { id, usedAt: null, expiresAt: { gt: now } }, data: { expiresAt: now } });
return { ok: true, revoked: count > 0 };
}
/** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */
/** Deletes used and expired codes; expiry already keeps them from being used again. */
async cleanupRegistrationCodes() {
const { count } = await this.prisma.registrationCode.deleteMany({
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
@@ -341,175 +293,139 @@ export class AdminService {
return { deleted: count };
}
/**
* Expires every pending command. The server settles tabs itself now, so a pending command is one from before that
* whose terminal never picked it up.
*/
async expireStuckCommands(dryRun: boolean, actor: Actor) {
const where = { status: 'pending' };
if (dryRun) {
return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) };
}
const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } });
if (stuck.length > 0) {
await this.prisma.remoteCommand.updateMany({
where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' },
data: { status: 'expired', result: 'Expired by a KoolTap admin' },
});
}
const venues = new Set(stuck.map((c) => c.venueId));
for (const venueId of venues) {
const count = stuck.filter((c) => c.venueId === venueId).length;
await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email });
this.realtime.notify(venueId, 'commands');
}
return { dryRun: false, count: stuck.length };
}
// ---- Users
async listUsers() {
const now = new Date();
async listUsers(): Promise<AdminUser[]> {
const [users, sessions] = await Promise.all([
this.prisma.clientUser.findMany({
orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }],
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } },
}),
this.prisma.refreshToken.groupBy({
by: ['userId'],
where: { revokedAt: null, expiresAt: { gt: now } },
_count: { _all: true },
}),
this.prisma.user.findMany({ orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }], include: { venue: { select: { name: true } } } }),
this.prisma.refreshToken.groupBy({ by: ['userId'], where: { revokedAt: null, expiresAt: { gt: new Date() } }, _count: { _all: true } }),
]);
const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all]));
return {
users: users.map(({ venue, ...user }) => ({
...user,
role: user.role === 'viewer' ? 'manager' : user.role,
venueName: venue.name,
activeSessions: sessionsByUser.get(user.id) ?? 0,
})),
};
return users.map((u) => ({
id: u.id,
email: u.email,
name: u.name,
role: u.role,
venueId: u.venueId,
venueName: u.venue?.name ?? null,
createdAt: u.createdAt.toISOString(),
activeSessions: sessionsByUser.get(u.id) ?? 0,
}));
}
async updateRole(userId: string, role: string, actor: Actor) {
/**
* A superadmin leaves their venue: they are above the venues. Anyone else stays in theirs, so a superadmin becoming
* an admin or manager needs [venueId] to join.
*/
async updateRole(userId: string, role: Role, venueId: string | undefined, actor: AuthUser) {
// Demoting yourself could leave nobody who can open this page
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
}
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
const user = await this.findUser(userId);
await this.prisma.clientUser.update({ where: { id: userId }, data: { role } });
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email });
this.realtime.notify(user.venueId, 'users');
const newVenueId = role === 'superadmin' ? null : user.venueId ?? venueId;
if (role !== 'superadmin' && !newVenueId) {
throw new BadRequestException({ code: 'venue_required', message: 'Pick the venue this user joins' });
}
if (newVenueId && newVenueId !== user.venueId) await this.findVenue(newVenueId);
await this.prisma.user.update({ where: { id: userId }, data: { role, venueId: newVenueId } });
await this.audit.log({
venueId: newVenueId ?? user.venueId,
actor: { type: 'user', id: actor.id },
action: 'admin_role_changed',
details: { userId, from: user.role, to: role },
});
for (const id of new Set([user.venueId, newVenueId])) if (id) this.realtime.notify(id, 'users');
return { ok: true };
}
/** Sets a new password and signs the user out everywhere. */
async resetPassword(userId: string, password: string, actor: Actor) {
async resetPassword(userId: string, password: string, actor: AuthUser) {
// That would sign you out too; My Account changes your own password and keeps you signed in
if (userId === actor.id) {
throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
}
if (userId === actor.id) throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
const user = await this.findUser(userId);
const passwordHash = await bcrypt.hash(password, 10);
const now = new Date();
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
]);
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email });
await this.signOut(userId, { passwordHash: await hashPassword(password) });
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_password_reset', details: { userId } });
return { ok: true };
}
/** Signs the user out everywhere, at once: their access tokens are refused too. */
async logoutUser(userId: string, actor: Actor) {
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
}
async logoutUser(userId: string, actor: AuthUser) {
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
const user = await this.findUser(userId);
const now = new Date();
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
]);
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email });
await this.signOut(userId);
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_user_logged_out', details: { userId } });
return { ok: true };
}
/** Signs out every user but the superadmin doing it. */
async logoutEveryone(actor: Actor) {
async logoutEveryone(actor: AuthUser) {
const now = new Date();
const others = { id: { not: actor.id } };
const [users] = await this.prisma.$transaction([
this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }),
this.prisma.user.updateMany({ where: { id: { not: actor.id } }, data: { sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }),
]);
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email });
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_everyone_logged_out', details: { users: users.count } });
return { users: users.count };
}
async deleteUser(userId: string, actor: Actor) {
async deleteUser(userId: string, actor: AuthUser) {
const user = await this.findUser(userId);
return this.authService.deleteUser(user.venueId, userId, actor);
if (user.venueId) return this.venues.deleteUser(user.venueId, userId, actor);
// Another superadmin
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
await this.prisma.user.delete({ where: { id: userId } });
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'user_deleted', details: { userId, email: user.email, role: user.role } });
return { ok: true };
}
// ---- Maintenance
/** Removes old history. Pending commands are never purged: a terminal may still pick them up. */
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) {
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: AuthUser) {
if (venueId) await this.findVenue(venueId);
const before = new Date(Date.now() - olderThanDays * DAY_MS);
const venue = venueId ? { venueId } : {};
const wheres = {
tabEvents: { ...venue, receivedAt: { lt: before } },
auditLog: { ...venue, createdAt: { lt: before } },
commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } },
};
const run = {
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)),
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)),
commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)),
const tabEvents = { ...venue, receivedAt: { lt: before } };
const auditLog = { ...venue, createdAt: { lt: before } };
const run: Record<PurgeTarget, () => Promise<number>> = {
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: tabEvents }) : this.prisma.tabEvent.deleteMany({ where: tabEvents }).then((r) => r.count)),
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: auditLog }) : this.prisma.auditLog.deleteMany({ where: auditLog }).then((r) => r.count)),
};
const counts: Partial<Record<PurgeTarget, number>> = {};
for (const target of new Set(targets)) counts[target] = await run[target]();
if (!dryRun) {
await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', {
targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email,
});
if (venueId && counts.commands) this.realtime.notify(venueId, 'commands');
await this.audit.log({ venueId: venueId ?? null, actor: { type: 'user', id: actor.id }, action: 'admin_purged', details: { targets, olderThanDays, counts } });
}
return { dryRun, counts };
}
/** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
async cleanupImages(dryRun: boolean, actor: Actor) {
const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } };
if (dryRun) {
const images = await this.prisma.productImage.findMany({ where, select: { id: true } });
const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>`
SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`;
return { dryRun: true, count: images.length, bytes: Number(bytes) };
async cleanupImages(dryRun: boolean, actor: AuthUser) {
const images = await this.prisma.productImage.findMany({
where: { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } },
select: { id: true, bytes: true },
});
const bytes = images.reduce((sum, image) => sum + image.bytes, 0);
if (!dryRun && images.length > 0) {
await this.images.remove(images.map((image) => image.id));
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_images_cleaned', details: { count: images.length } });
}
const { count } = await this.prisma.productImage.deleteMany({ where });
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email });
return { dryRun: false, count };
return { dryRun, count: images.length, bytes };
}
async sendTestEmail(to: string, actor: Actor) {
const result = await this.reminders.sendTestEmailTo(to);
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email });
async sendTestEmail(to: string, actor: AuthUser) {
const result = await this.reminders.sendTestEmail(to);
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_test_email_sent', details: { to } });
return result;
}
// ---- Insight
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
const where = {
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }): Promise<{ entries: AuditEntry[]; total: number }> {
const where: Prisma.AuditLogWhereInput = {
...(options.venueId ? { venueId: options.venueId } : {}),
...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
...(options.action ? { action: { contains: options.action, mode: 'insensitive' } } : {}),
};
const [entries, total] = await Promise.all([
this.prisma.auditLog.findMany({
@@ -523,42 +439,45 @@ export class AdminService {
]);
// Actors are stored as ids; show who they are
const actorIds = [...new Set(entries.map((e) => e.actorId))];
const actorIds = [...new Set(entries.map((e) => e.actorId).filter((id): id is string => id !== null))];
const [users, devices] = await Promise.all([
this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
this.prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }),
]);
const actorNames = new Map<string, string>([
...users.map((u): [string, string] => [u.id, u.email]),
...devices.map((d): [string, string] => [d.id, d.name]),
]);
const actorNames = new Map([...users.map((u) => [u.id, u.email] as const), ...devices.map((d) => [d.id, d.name] as const)]);
return {
total,
entries: entries.map(({ venue, details, ...entry }) => ({
...entry,
venueName: venue.name,
actorName: actorNames.get(entry.actorId) ?? null,
details: parseDetails(details),
entries: entries.map((e) => ({
id: e.id,
venueId: e.venueId,
venueName: e.venue?.name ?? null,
actorType: e.actorType,
actorId: e.actorId,
actorName: e.actorId ? actorNames.get(e.actorId) ?? null : null,
action: e.action,
details: e.details,
createdAt: e.createdAt.toISOString(),
})),
};
}
async health() {
const [[size], migrations, tables, stuckCommands] = await Promise.all([
async health(): Promise<SystemHealth> {
const [[size], migrations, tables, imageBytes] = await Promise.all([
this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`,
this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>`
SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`,
this.tableCounts(),
this.prisma.remoteCommand.count({ where: { status: 'pending' } }),
this.imageBytesOnDisk(),
]);
const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
return {
checkedAt: new Date(),
checkedAt: new Date().toISOString(),
uptimeSeconds: Math.round(process.uptime()),
nodeVersion: process.version,
databaseBytes: Number(size.bytes),
imageBytes,
tables,
migrations: {
applied: applied.length,
@@ -567,56 +486,55 @@ export class AdminService {
failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name),
},
realtimeConnections: this.realtime.connectionCount(),
commandExpiryLastRunAt: this.commandExpiry.lastRunAt,
stuckCommands,
emailConfigured: this.reminders.configured,
emailConfigured: this.resend.configured,
};
}
private async tableCounts() {
const p = this.prisma;
const entries = await Promise.all(Object.entries({
const counts: Record<string, () => Promise<number>> = {
Venue: () => p.venue.count(),
ClientUser: () => p.clientUser.count(),
Device: () => p.device.count(),
User: () => p.user.count(),
RefreshToken: () => p.refreshToken.count(),
Device: () => p.device.count(),
RegistrationCode: () => p.registrationCode.count(),
Customer: () => p.customer.count(),
Tab: () => p.tab.count(),
TabEvent: () => p.tabEvent.count(),
Settlement: () => p.settlement.count(),
PlannedTab: () => p.plannedTab.count(),
TabReminder: () => p.tabReminder.count(),
TabUnit: () => p.tabUnit.count(),
Product: () => p.product.count(),
ProductImage: () => p.productImage.count(),
StockAdjustment: () => p.stockAdjustment.count(),
RemoteCommand: () => p.remoteCommand.count(),
StockMovement: () => p.stockMovement.count(),
TabEvent: () => p.tabEvent.count(),
Reminder: () => p.reminder.count(),
AuditLog: () => p.auditLog.count(),
}).map(async ([table, count]) => ({ table, rows: await count() })));
return entries;
};
return Promise.all(Object.entries(counts).map(async ([table, count]) => ({ table, rows: await count() })));
}
private async imageBytesOnDisk(): Promise<number> {
const files = await fs.readdir(imageDir()).catch(() => [] as string[]);
const sizes = await Promise.all(files.map((file) => fs.stat(path.join(imageDir(), file)).then((s) => s.size, () => 0)));
return sizes.reduce((sum, size) => sum + size, 0);
}
private async findVenue(venueId: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
}
if (!venue) throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
return venue;
}
private async findUser(userId: string) {
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
return user;
}
}
function parseDetails(details: string | null): unknown {
if (!details) return null;
try {
return JSON.parse(details);
} catch {
return details;
private async signOut(userId: string, data: { passwordHash?: string } = {}) {
const now = new Date();
await this.prisma.$transaction([
this.prisma.user.update({ where: { id: userId }, data: { ...data, sessionsRevokedAt: now } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
]);
}
}
+24 -85
View File
@@ -1,97 +1,36 @@
import { checkConsistency, VenueSnapshot } from './consistency';
const now = new Date('2026-10-26T12:00:00Z');
const later = new Date('2026-10-27T12:00:00Z');
const earlier = new Date('2026-10-25T12:00:00Z');
const unit = (id: string, removedAt: string | null = null) => ({ id, addedAt: earlier.toISOString(), removedAt });
function snapshot(overrides: Partial<VenueSnapshot> = {}): VenueSnapshot {
return { settlements: [], tabs: [], products: [], pendingCommands: [], devices: [], ...overrides };
}
const codes = (venue: VenueSnapshot) => checkConsistency(venue, now).map((f) => f.code);
const now = new Date('2026-10-28T12:00:00Z');
const empty: VenueSnapshot = { settlements: [], tabs: [], products: [], devices: [] };
describe('checkConsistency', () => {
it('finds nothing in a venue whose data adds up', () => {
const venue = snapshot({
settlements: [{
id: 's1', customerName: 'Jan', amountInCents: 500, commandId: null,
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
}],
tabs: [{ id: 't1', customerName: 'Jan', lines: [{ id: 'l2', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, units: [unit('u1')] }] }],
products: [{ productId: 'p1', name: 'Pils', stock: 10, countedAt: earlier }],
pendingCommands: [{ id: 'c1', expiresAt: later, device: { name: 'Bar', revokedAt: null } }],
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: later }],
});
expect(checkConsistency(venue, now)).toEqual([]);
it('finds nothing in a venue that adds up', () => {
expect(checkConsistency({
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 500 }],
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: 'Marie' }],
products: [{ id: 'p1', name: 'Cola', stock: 3 }],
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2027-01-01') }],
}, now)).toEqual([]);
});
it('flags a settlement whose amount differs from its drinks', () => {
const venue = snapshot({
settlements: [{
id: 's1', customerName: 'Jan', amountInCents: 750, commandId: null,
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
}],
});
const [finding] = checkConsistency(venue, now);
expect(finding).toMatchObject({ severity: 'warning', code: 'settlement_amount_mismatch', subjectId: 's1' });
expect(finding.message).toContain('7.50');
expect(finding.message).toContain('5.00');
});
it('flags what does not add up, warnings before information', () => {
const findings = checkConsistency({
...empty,
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 250 }],
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: ' jan ' }],
products: [{ id: 'p1', name: 'Cola', stock: -2 }],
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2026-10-01') }],
}, now);
it('flags one close request paid twice as an error, before anything else', () => {
const settlement = { customerName: 'Jan', amountInCents: 0, lines: [], commandId: 'c1' };
const venue = snapshot({
settlements: [{ ...settlement, id: 's1' }, { ...settlement, id: 's2' }],
products: [{ productId: 'p1', name: 'Pils', stock: -2, countedAt: earlier }],
});
expect(codes(venue)).toEqual(['settlement_duplicate_command', 'stock_negative']);
});
it('flags two open tabs that only differ in case or spacing', () => {
const venue = snapshot({
tabs: [
{ id: 't1', customerName: 'Jan Peeters', lines: [] },
{ id: 't2', customerName: ' jan peeters ', lines: [] },
],
});
expect(codes(venue)).toEqual(['tab_duplicate_customer']);
});
it('notes drinks of a product that is gone, but not ones already taken off the tab', () => {
const line = { id: 'l1', productId: 'gone', productName: 'Cava', unitPriceInCents: 500 };
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1')] }] }] })))
.toEqual(['tab_unknown_product']);
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1', now.toISOString())] }] }] })))
.toEqual([]);
});
it('notes tracked stock that was never counted', () => {
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: 4, countedAt: null }] }))).toEqual(['stock_never_counted']);
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: null, countedAt: null }] }))).toEqual([]);
});
it('flags pending commands that expired or wait for an unpaired terminal', () => {
const venue = snapshot({
pendingCommands: [
{ id: 'c1', expiresAt: earlier, device: { name: 'Bar', revokedAt: null } },
{ id: 'c2', expiresAt: later, device: { name: 'Old', revokedAt: earlier } },
],
});
expect(checkConsistency(venue, now).map((f) => [f.code, f.subjectId])).toEqual([
['command_stuck', 'c1'],
['command_revoked_device', 'c2'],
expect(findings.map((f) => f.code)).toEqual([
'settlement_amount_mismatch', 'tab_duplicate_customer', 'stock_negative', 'device_token_expired',
]);
});
it('notes a paired terminal whose token expired, but not an unpaired one', () => {
const venue = snapshot({
devices: [
{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: earlier },
{ id: 'd2', name: 'Old', revokedAt: earlier, tokenExpiresAt: earlier },
],
});
expect(checkConsistency(venue, now).map((f) => f.subjectId)).toEqual(['d1']);
it('ignores the expired token of an unpaired terminal', () => {
expect(checkConsistency({
...empty,
devices: [{ id: 'd1', name: 'Old', revokedAt: new Date('2026-09-01'), tokenExpiresAt: new Date('2026-10-01') }],
}, now)).toEqual([]);
});
});
+17 -88
View File
@@ -1,23 +1,11 @@
import { customerNameKey } from '../planned-tabs/customer-name';
import { Line, SettledLine, isActive } from '../tabs/tab-state';
export type Severity = 'error' | 'warning' | 'info';
export type Finding = {
severity: Severity;
// settlement_amount_mismatch | settlement_duplicate_command | tab_duplicate_customer | tab_unknown_product |
// stock_negative | stock_never_counted | command_stuck | command_revoked_device | device_token_expired
code: string;
message: string;
// What it is about: a settlement, tab, product, command or terminal id
subjectId: string;
};
import { Finding, FindingSeverity } from '@kooltap/shared';
import { customerNameKey } from '../customers/customer-name';
export type VenueSnapshot = {
settlements: { id: string; customerName: string; amountInCents: number; lines: unknown; commandId: string | null }[];
tabs: { id: string; customerName: string; lines: unknown }[];
products: { productId: string; name: string; stock: number | null; countedAt: Date | null }[];
pendingCommands: { id: string; expiresAt: Date; device: { name: string; revokedAt: Date | null } | null }[];
/** Each settlement with what its drinks add up to. */
settlements: { id: string; customerName: string; amountInCents: number; drinksInCents: number }[];
tabs: { id: string; customerName: string }[];
products: { id: string; name: string; stock: number | null }[];
devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[];
};
@@ -30,102 +18,43 @@ const euro = (cents: number) => (cents / 100).toFixed(2);
export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
const findings: Finding[] = [];
// A settlement's amount is what the terminal charged; its lines are what the server had on the tab. A difference
// A settlement's amount is what the terminal charged; its drinks are what the server had on the tab. A difference
// means the terminal and the server disagreed about the tab when it was paid
for (const settlement of venue.settlements) {
const lines = (settlement.lines as SettledLine[] | null) ?? [];
const linesTotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
if (linesTotal !== settlement.amountInCents) {
if (settlement.drinksInCents !== settlement.amountInCents) {
findings.push({
severity: 'warning',
code: 'settlement_amount_mismatch',
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`,
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(settlement.drinksInCents)}`,
subjectId: settlement.id,
});
}
}
const byCommand = new Map<string, string[]>();
for (const settlement of venue.settlements) {
if (!settlement.commandId) continue;
byCommand.set(settlement.commandId, [...(byCommand.get(settlement.commandId) ?? []), settlement.id]);
}
for (const [commandId, ids] of byCommand) {
if (ids.length > 1) {
findings.push({
severity: 'error',
code: 'settlement_duplicate_command',
message: `One close request was paid ${ids.length} times`,
subjectId: commandId,
});
}
}
// Customers are matched on their name everywhere, so two open tabs with one name get mixed up
const tabsByName = new Map<string, string[]>();
const tabsByName = new Map<string, typeof venue.tabs>();
for (const tab of venue.tabs) {
const key = customerNameKey(tab.customerName);
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]);
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab]);
}
for (const ids of tabsByName.values()) {
if (ids.length > 1) {
const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName;
for (const tabs of tabsByName.values()) {
if (tabs.length > 1) {
findings.push({
severity: 'warning',
code: 'tab_duplicate_customer',
message: `${ids.length} open tabs are named ${name}`,
subjectId: ids[0],
message: `${tabs.length} open tabs are named ${tabs[0].customerName}`,
subjectId: tabs[0].id,
});
}
}
const productIds = new Set(venue.products.map((p) => p.productId));
for (const tab of venue.tabs) {
for (const line of (tab.lines as Line[] | null) ?? []) {
if (line.productId && !productIds.has(line.productId) && line.units.some(isActive)) {
findings.push({
severity: 'info',
code: 'tab_unknown_product',
message: `${tab.customerName}'s tab has ${line.productName}, which is not in the catalog`,
subjectId: tab.id,
});
}
}
}
for (const product of venue.products) {
if (product.stock !== null && product.stock < 0) {
findings.push({
severity: 'warning',
code: 'stock_negative',
message: `${product.name} has a stock of ${product.stock}: more was sold than counted`,
subjectId: product.productId,
});
}
if (product.stock !== null && product.countedAt === null) {
findings.push({
severity: 'info',
code: 'stock_never_counted',
message: `${product.name}'s stock is tracked but was never counted`,
subjectId: product.productId,
});
}
}
for (const command of venue.pendingCommands) {
if (command.device?.revokedAt) {
findings.push({
severity: 'warning',
code: 'command_revoked_device',
message: `A close request waits for ${command.device.name}, which is unpaired`,
subjectId: command.id,
});
} else if (command.expiresAt <= now) {
findings.push({
severity: 'warning',
code: 'command_stuck',
message: 'A close request expired but is still pending',
subjectId: command.id,
subjectId: product.id,
});
}
}
@@ -141,6 +70,6 @@ export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
}
}
const order: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
const order: Record<FindingSeverity, number> = { error: 0, warning: 1, info: 2 };
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
}
-95
View File
@@ -1,95 +0,0 @@
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsEmail,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { RESET_SCOPES, ResetScope } from '../venue-reset';
export const PURGE_TARGETS = ['tabEvents', 'auditLog', 'commands'] as const;
export type PurgeTarget = (typeof PURGE_TARGETS)[number];
export class VenueNameDto {
@ApiProperty({ example: 'De Kroeg' })
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
}
export class DryRunDto {
@ApiPropertyOptional({ description: 'Only count what would change' })
@IsOptional()
@IsBoolean()
dryRun?: boolean;
}
export class ResetVenueDto extends DryRunDto {
@ApiProperty({ enum: RESET_SCOPES, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsIn(RESET_SCOPES, { each: true })
scopes: ResetScope[];
@ApiPropertyOptional({ description: 'The count every tracked product gets with the stock scope', default: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
stockValue?: number;
}
export class PurgeDto extends DryRunDto {
@ApiProperty({ example: 90 })
@IsInt()
@Min(1)
@Max(3650)
olderThanDays: number;
@ApiProperty({ enum: PURGE_TARGETS, isArray: true })
@IsArray()
@ArrayMinSize(1)
@IsIn(PURGE_TARGETS, { each: true })
targets: PurgeTarget[];
@ApiPropertyOptional({ description: 'Only this venue; every venue when left out' })
@IsOptional()
@IsString()
venueId?: string;
}
export class CopyCatalogDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
fromVenueId: string;
}
export class UpdateRoleDto {
@ApiProperty({ enum: ['admin', 'manager', 'superadmin'] })
@IsIn(['admin', 'manager', 'superadmin'])
role: string;
}
export class ResetPasswordDto {
@ApiProperty({ example: 'SecurePass123!' })
@IsString()
@MinLength(6)
password: string;
}
export class TestEmailDto {
@ApiProperty({ example: 'you@example.com' })
@IsEmail()
to: string;
}
+26 -91
View File
@@ -1,40 +1,9 @@
import { Prisma } from '@prisma/client';
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
type Tx = Prisma.TransactionClient;
/**
* What a superadmin can wipe from a venue. Users, terminals and the product catalog always stay: a reset is for
* starting a new event or season with the same bar.
*/
export const RESET_SCOPES = [
// The open tabs. Terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
'tabs',
// Paid tabs: the sales history
'settlements',
// What the terminals reported. Also the record of which events were applied, so a terminal that re-sends an old
// event would apply it again: only wipe these when every terminal has synced
'tabEvents',
// Planned customers and the reminders sent to them
'plannedTabs',
// Every tracked product's stock is set to one count
'stock',
// Stock deliveries, breakage and counts made on the dashboard
'stockHistory',
// Command history
'commands',
'auditLog',
] as const;
export type ResetScope = (typeof RESET_SCOPES)[number];
import { ResetScope } from '@kooltap/shared';
import { Tx } from '../prisma/prisma.service';
import { ensureDefaultCustomers } from '../customers/default-customers';
export type ResetCounts = Partial<Record<ResetScope, number>>;
/** Locks the venue like a sync does, so no terminal changes it halfway through. */
export async function lockVenue(tx: Tx, venueId: string) {
await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`;
}
/** How many rows each scope would remove (or, for stock, how many products it would set). */
export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]): Promise<ResetCounts> {
const where = { venueId };
@@ -42,10 +11,9 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
tabs: () => tx.tab.count({ where }),
settlements: () => tx.settlement.count({ where }),
tabEvents: () => tx.tabEvent.count({ where }),
plannedTabs: () => tx.plannedTab.count({ where }),
customers: () => tx.customer.count({ where }),
stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }),
stockHistory: () => tx.stockAdjustment.count({ where }),
commands: () => tx.remoteCommand.count({ where }),
stockHistory: () => tx.stockMovement.count({ where }),
auditLog: () => tx.auditLog.count({ where }),
};
const counts: ResetCounts = {};
@@ -54,14 +22,21 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
}
/**
* Wipes [scopes] of a venue inside [tx], which must have locked it. [stockValue] is the count every tracked product
* gets; it is recorded as an applied stock count, after the stock history is wiped, so the history explains it.
* Wipes [scopes] of a venue inside [tx], which must have locked it. Users, terminals and the catalog always stay: a
* reset is for starting a new event or season with the same bar.
*
* - tabs: terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
* - tabEvents: also the record of which events were applied, so a terminal that sends an old event again would
* apply it again: only wipe these when every terminal has synced
* - customers: and the reminders sent to them
* - stock: every tracked product gets [stockValue] as a count, recorded after the stock history is wiped so the
* history explains it
*/
export async function resetVenue(
tx: Tx,
venueId: string,
scopes: ResetScope[],
options: { stockValue: number; actor: { id: string; name: string }; now: Date },
options: { stockValue: number; userId: string; now: Date },
): Promise<ResetCounts> {
const where = { venueId };
const counts: ResetCounts = {};
@@ -70,65 +45,25 @@ export async function resetVenue(
if (has('tabs')) counts.tabs = (await tx.tab.deleteMany({ where })).count;
if (has('settlements')) counts.settlements = (await tx.settlement.deleteMany({ where })).count;
if (has('tabEvents')) counts.tabEvents = (await tx.tabEvent.deleteMany({ where })).count;
if (has('plannedTabs')) {
await tx.tabReminder.deleteMany({ where });
counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
if (has('customers')) {
await tx.reminder.deleteMany({ where });
counts.customers = (await tx.customer.deleteMany({ where })).count;
}
// The Free Drinks tab is always there: planned again, and opened on the next sync
if (has('tabs') || has('plannedTabs')) await ensureDefaultCustomers(tx, venueId);
if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
// The Free Drinks tab is always there: added again, and opened on the next sync
if (has('tabs') || has('customers')) await ensureDefaultCustomers(tx, venueId);
if (has('stockHistory')) counts.stockHistory = (await tx.stockMovement.deleteMany({ where })).count;
if (has('stock')) {
const { now, actor, stockValue } = options;
const tracked = await tx.product.findMany({
where: { venueId, stock: { not: null } },
select: { productId: true, name: true },
});
await tx.product.updateMany({
where: { venueId, stock: { not: null } },
data: { stock: stockValue, countedAt: now },
});
if (tracked.length > 0) {
await tx.stockAdjustment.createMany({
const { now, userId, stockValue } = options;
const tracked = await tx.product.findMany({ where: { venueId, stock: { not: null } }, select: { id: true } });
await tx.product.updateMany({ where: { venueId, stock: { not: null } }, data: { stock: stockValue, countedAt: now } });
await tx.stockMovement.createMany({
data: tracked.map((product) => ({
venueId,
productId: product.productId,
productName: product.name,
type: 'set',
quantity: stockValue,
note: 'Stock reset',
requestedBy: actor.name,
requestedById: actor.id,
status: 'applied',
stockAfter: stockValue,
createdAt: now,
processedAt: now,
venueId, productId: product.id, type: 'count' as const, quantity: stockValue, stockAfter: stockValue, note: 'Stock reset', userId, createdAt: now,
})),
});
}
counts.stock = tracked.length;
}
if (has('commands')) counts.commands = (await tx.remoteCommand.deleteMany({ where })).count;
if (has('auditLog')) counts.auditLog = (await tx.auditLog.deleteMany({ where })).count;
return counts;
}
/** Deletes a venue and everything in it, users and terminals included. */
export async function deleteVenue(tx: Tx, venueId: string) {
const where = { venueId };
const users = await tx.clientUser.findMany({ where, select: { id: true } });
await tx.auditLog.deleteMany({ where });
await tx.tabEvent.deleteMany({ where });
await tx.tabReminder.deleteMany({ where });
await tx.plannedTab.deleteMany({ where });
await tx.remoteCommand.deleteMany({ where });
await tx.settlement.deleteMany({ where });
await tx.tab.deleteMany({ where });
await tx.stockAdjustment.deleteMany({ where });
await tx.product.deleteMany({ where });
await tx.registrationCode.deleteMany({ where });
await tx.device.deleteMany({ where });
await tx.refreshToken.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } });
await tx.clientUser.deleteMany({ where });
await tx.venue.delete({ where: { id: venueId } });
}
+13 -24
View File
@@ -1,50 +1,39 @@
import { Module } from '@nestjs/common';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { VenuesModule } from './venues/venues.module';
import { DevicesModule } from './devices/devices.module';
import { TabsModule } from './tabs/tabs.module';
import { CommandsModule } from './commands/commands.module';
import { AuditModule } from './audit/audit.module';
import { JobsModule } from './jobs/jobs.module';
import { PlannedTabsModule } from './planned-tabs/planned-tabs.module';
import { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
import { AuthModule } from './auth/auth.module';
import { TerminalModule } from './terminal/terminal.module';
import { VenuesModule } from './venues/venues.module';
import { TabsModule } from './tabs/tabs.module';
import { CustomersModule } from './customers/customers.module';
import { RemindersModule } from './reminders/reminders.module';
import { StockModule } from './stock/stock.module';
import { ImagesModule } from './images/images.module';
import { RealtimeModule } from './realtime/realtime.module';
import { EmailModule } from './email/email.module';
import { AdminModule } from './admin/admin.module';
import { HealthController } from './health.controller';
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60000, limit: 100 }]),
ScheduleModule.forRoot(),
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
PrismaModule,
AuditModule,
RealtimeModule,
EmailModule,
AuthModule,
TerminalModule,
VenuesModule,
DevicesModule,
TabsModule,
CommandsModule,
AuditModule,
JobsModule,
PlannedTabsModule,
CustomersModule,
RemindersModule,
StockModule,
ImagesModule,
AdminModule,
],
controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
})
export class AppModule {}
+24 -13
View File
@@ -1,6 +1,14 @@
import { Injectable, Logger } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { Prisma, PrismaService, Tx } from '../prisma/prisma.service';
import { ActorType } from '../generated/prisma/client';
export type AuditEntry = {
/** Null for actions that are not about one venue. */
venueId: string | null;
actor: { type: ActorType; id: string | null };
action: string;
details?: Prisma.InputJsonObject;
};
@Injectable()
export class AuditService {
@@ -8,18 +16,21 @@ export class AuditService {
constructor(private prisma: PrismaService) {}
async log(venueId: string, actorType: string, actorId: string, action: string, details?: Record<string, any>) {
/** Records [entry]; inside [tx] it is part of that change, otherwise a failure is only logged. */
async log(entry: AuditEntry, tx?: Tx) {
const data = {
venueId: entry.venueId,
actorType: entry.actor.type,
actorId: entry.actor.id,
action: entry.action,
details: entry.details,
};
if (tx) {
await tx.auditLog.create({ data });
return;
}
try {
await this.prisma.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType,
actorId,
action,
details: details ? JSON.stringify(details) : null,
},
});
await this.prisma.auditLog.create({ data });
} catch (error) {
this.logger.error(`Failed to write audit log: ${error}`);
}
+42 -24
View File
@@ -1,47 +1,65 @@
import { Controller, Post, Body, HttpCode, HttpStatus, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { z } from 'zod';
import { changePasswordSchema, loginSchema, Me } from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, AuthUser, CurrentUser } from '../common/auth';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { clearAuthCookies, REFRESH_COOKIE, setAuthCookies } from './cookies';
@ApiTags('Authentication')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
constructor(private auth: AuthService) {}
@Post('login')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Login with email and password' })
async login(@Body() dto: LoginDto) {
return this.authService.login(dto.email, dto.password);
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async login(@Body(zod(loginSchema)) dto: z.output<typeof loginSchema>, @Res({ passthrough: true }) res: Response): Promise<Me> {
const { me, tokens } = await this.auth.login(dto.email, dto.password);
setAuthCookies(res, tokens);
return me;
}
@Post('refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Refresh access token' })
async refresh(@Body() dto: RefreshTokenDto) {
return this.authService.refresh(dto.refreshToken);
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<Me> {
try {
const { me, tokens } = await this.auth.refresh(req.cookies?.[REFRESH_COOKIE]);
setAuthCookies(res, tokens);
return me;
} catch (error) {
clearAuthCookies(res);
throw error;
}
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Logout and revoke refresh token' })
async logout(@Body() dto: RefreshTokenDto) {
await this.authService.logout(dto.refreshToken);
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
await this.auth.logout(req.cookies?.[REFRESH_COOKIE]);
clearAuthCookies(res);
return { ok: true };
}
@Get('me')
@Authorized()
me(@CurrentUser() user: AuthUser): Promise<Me> {
return this.auth.me(user.id);
}
/** Signs out the user's other sessions; this one carries on with new tokens. */
@Post('change-password')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: "Change the signed-in user's password; signs out their other sessions and returns new tokens" })
async changePassword(@Body() dto: ChangePasswordDto, @Req() req: any) {
return this.authService.changePassword(req.user.id, dto.currentPassword, dto.newPassword);
@Authorized()
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async changePassword(
@CurrentUser() user: AuthUser,
@Body(zod(changePasswordSchema)) dto: z.output<typeof changePasswordSchema>,
@Res({ passthrough: true }) res: Response,
): Promise<Me> {
const { me, tokens } = await this.auth.changePassword(user.id, dto.currentPassword, dto.newPassword);
setAuthCookies(res, tokens);
return me;
}
}
+6 -13
View File
@@ -1,22 +1,15 @@
import { Module } from '@nestjs/common';
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { DeviceStrategy } from './strategies/device.strategy';
import { UserAuthGuard } from './user-auth.guard';
import { getJwtSecret } from './jwt-secret';
@Global()
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: getJwtSecret(),
signOptions: { expiresIn: '15m' },
}),
],
imports: [JwtModule.register({ secret: getJwtSecret(), signOptions: { expiresIn: '15m' } })],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, DeviceStrategy],
exports: [AuthService, JwtModule],
providers: [AuthService, UserAuthGuard],
exports: [AuthService, UserAuthGuard],
})
export class AuthModule {}
-72
View File
@@ -1,72 +0,0 @@
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);
});
});
+79 -224
View File
@@ -1,262 +1,117 @@
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { createHash, randomBytes } from 'crypto';
import { Me } from '@kooltap/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
import { AuthUser } from '../common/auth';
import { REFRESH_TOKEN_TTL_MS } from './cookies';
export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex');
export const hashPassword = (password: string) => bcrypt.hash(password, 12);
// Compared against when there is no such user, so the response time doesn't tell which emails exist
const NO_USER_HASH = bcrypt.hashSync('no user', 12);
type Tokens = { accessToken: string; refreshToken: string };
const userSelect = {
id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true, venue: { select: { name: true } },
} as const;
/** Whether a token was issued before the user's sessions were revoked. iat is in whole seconds. */
export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
}
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private auditService: AuditService,
private realtime: RealtimeService,
private jwt: JwtService,
private audit: AuditService,
) {}
async login(email: string, password: string) {
const user = await this.prisma.clientUser.findUnique({ where: { email } });
if (!user) {
async login(email: string, password: string): Promise<{ me: Me; tokens: Tokens }> {
const user = await this.prisma.user.findUnique({ where: { email }, select: { ...userSelect, passwordHash: true } });
const valid = await bcrypt.compare(password, user?.passwordHash ?? NO_USER_HASH);
if (!user || !valid) {
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
}
const valid = await bcrypt.compare(password, user.passwordHash);
if (!valid) {
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
}
return this.issueTokens(user);
/** Rotates the refresh token: of two requests with the same token, only one gets new tokens. */
async refresh(refreshToken: string | undefined): Promise<{ me: Me; tokens: Tokens }> {
const invalid = new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
if (!refreshToken) throw invalid;
const now = new Date();
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256(refreshToken) } });
if (!stored || stored.revokedAt || stored.expiresAt < now) throw invalid;
const { count } = await this.prisma.refreshToken.updateMany({ where: { id: stored.id, revokedAt: null }, data: { revokedAt: now } });
if (count === 0) throw invalid;
const user = await this.prisma.user.findUnique({ where: { id: stored.userId }, select: userSelect });
if (!user) throw invalid;
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
}
async refresh(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
// Revoke first and atomically: of two requests with the same token, only one gets new tokens
const claimed = await this.prisma.refreshToken.updateMany({
where: { id: stored.id, revokedAt: null },
data: { revokedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
}
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
}
return this.issueTokens(user);
}
async logout(refreshToken: string) {
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
async logout(refreshToken: string | undefined) {
if (!refreshToken) return;
await this.prisma.refreshToken.updateMany({
where: { tokenHash: hash, revokedAt: null },
where: { tokenHash: sha256(refreshToken), revokedAt: null },
data: { revokedAt: new Date() },
});
}
/**
* Changes the signed-in user's password. Every session of the user is signed out, also this one, which gets new
* tokens instead.
*/
async changePassword(userId: string, currentPassword: string, newPassword: string) {
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
async me(userId: string): Promise<Me> {
return toMe(await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: userSelect }));
}
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
if (!valid) {
/** Changes the user's password and signs out every session; this one gets new tokens. */
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<{ me: Me; tokens: Tokens }> {
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { ...userSelect, passwordHash: true } });
if (!(await bcrypt.compare(currentPassword, user.passwordHash))) {
throw new BadRequestException({ code: 'wrong_password', message: 'The current password is not correct' });
}
const passwordHash = await bcrypt.hash(newPassword, 10);
await this.prisma.$transaction([
this.prisma.clientUser.update({ where: { id: user.id }, data: { passwordHash } }),
this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(newPassword) } }),
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
]);
await this.auditService.log(user.venueId, 'client_user', user.id, 'password_changed');
return this.issueTokens(user);
}
private async issueTokens(user: { id: string; email: string; name: string; role: string; venueId: string }) {
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
const accessToken = this.jwtService.sign(payload);
const refreshToken = crypto.randomBytes(32).toString('hex');
const refreshHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
await this.prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: refreshHash,
expiresAt,
},
});
return {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
venueId: user.venueId,
},
};
}
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
const rawToken = 'dev_' + rawBytes.toString('base64url');
const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
const prefix = rawToken.substring(0, TOKEN_PREFIX_LENGTH);
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
return { rawToken, hash, prefix, expiresAt };
}
async registerDevice(venueId: string, name: string, registrationCode: string) {
const code = await this.prisma.registrationCode.findUnique({ where: { code: registrationCode } });
if (!code || code.usedAt || code.expiresAt < new Date()) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
if (code.venueId !== venueId) {
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
}
// Claim the code atomically so it can only ever be used once
const claimed = await this.prisma.registrationCode.updateMany({
where: { id: code.id, usedAt: null },
data: { usedAt: new Date() },
});
if (claimed.count === 0) {
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
}
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
// A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
// dashboard
const deviceId = uuidv4();
await this.prisma.device.create({
data: {
id: deviceId,
venueId,
name,
tokenHash: hash,
tokenPrefix: prefix,
tokenExpiresAt: expiresAt,
},
});
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
this.realtime.notify(venueId, 'device');
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
}
async createUser(venueId: string, email: string, password: string, name: string, role: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
if (!venue) {
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' });
}
const existingUser = await this.prisma.clientUser.findUnique({ where: { email } });
if (existingUser) {
throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' });
}
const passwordHash = await bcrypt.hash(password, 10);
const user = await this.prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email,
passwordHash,
name,
role,
},
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
});
this.realtime.notify(venueId, 'users');
return user;
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: userId }, action: 'password_changed' });
return { me: toMe(user), tokens: await this.issueTokens(userId) };
}
/**
* Deletes a dashboard user and signs them out: their refresh tokens are revoked, so they are out once their access
* token runs out. An admin cannot delete themself, so a venue always keeps an admin.
* The user an access token belongs to, as the database has them now: a deleted or signed-out user loses access at
* once, and a changed role applies right away. Null for a missing, invalid or revoked token.
*/
async deleteUser(venueId: string, userId: string, actor: { id: string }) {
if (userId === actor.id) {
throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
async userFromAccessToken(token: string | undefined): Promise<AuthUser | null> {
if (!token) return null;
let payload: { sub: string; iat?: number };
try {
payload = await this.jwt.verifyAsync(token);
} catch {
return null;
}
const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: userSelect });
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) return null;
return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId };
}
const user = await this.prisma.clientUser.findFirst({ where: { id: userId, venueId } });
if (!user) {
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
}
await this.prisma.$transaction([
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
this.prisma.clientUser.delete({ where: { id: userId } }),
]);
await this.auditService.log(venueId, 'client_user', actor.id, 'user_deleted', { userId, email: user.email, role: user.role });
this.realtime.notify(venueId, 'users');
return { ok: true };
}
async validateDevice(deviceId: string, token: string): Promise<any> {
const hash = crypto.createHash('sha256').update(token).digest('hex');
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device || device.tokenHash !== hash) {
throw new UnauthorizedException({ code: 'invalid_device_token', message: 'Invalid device token' });
}
if (device.revokedAt) {
throw new UnauthorizedException({ code: 'device_revoked', message: 'Device has been revoked' });
}
if (device.tokenExpiresAt < new Date()) {
throw new UnauthorizedException({ code: 'token_expired', message: 'Device token has expired' });
}
return device;
}
async generateRegistrationCode(venueId: string): Promise<{ code: string; expiresAt: Date }> {
const code = crypto.randomBytes(3).toString('hex').toUpperCase();
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await this.prisma.registrationCode.create({
data: {
venueId,
code,
expiresAt,
},
private async issueTokens(userId: string): Promise<Tokens> {
const refreshToken = randomBytes(32).toString('base64url');
await this.prisma.refreshToken.create({
data: { userId, tokenHash: sha256(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) },
});
return { accessToken: await this.jwt.signAsync({ sub: userId }), refreshToken };
}
}
return { code, expiresAt };
}
function toMe(user: { id: string; email: string; name: string; role: Me['role']; venueId: string | null; venue: { name: string } | null }): Me {
return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId, venueName: user.venue?.name ?? null };
}
+37
View File
@@ -0,0 +1,37 @@
import { CookieOptions, Response } from 'express';
export const ACCESS_COOKIE = 'kooltap_access';
export const REFRESH_COOKIE = 'kooltap_refresh';
export const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Tokens live in httpOnly cookies on the API's host, so no script on the dashboard can read them. SameSite=Lax: the
* dashboard and the API must share a site (kooltap.com and api.kooltap.com, or localhost with two ports).
*/
function options(maxAge: number, path: string): CookieOptions {
return { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path, maxAge };
}
// The refresh token is only sent to the endpoints that need it
const REFRESH_PATH = '/api/v1/auth';
export function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) {
res.cookie(ACCESS_COOKIE, tokens.accessToken, options(ACCESS_TOKEN_TTL_MS, '/'));
res.cookie(REFRESH_COOKIE, tokens.refreshToken, options(REFRESH_TOKEN_TTL_MS, REFRESH_PATH));
}
export function clearAuthCookies(res: Response) {
res.clearCookie(ACCESS_COOKIE, { ...options(0, '/'), maxAge: undefined });
res.clearCookie(REFRESH_COOKIE, { ...options(0, REFRESH_PATH), maxAge: undefined });
}
/** A cookie from a raw Cookie header, for the websocket handshake. */
export function cookieFromHeader(header: string | undefined, name: string): string | null {
for (const part of header?.split(';') ?? []) {
const at = part.indexOf('=');
if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
}
return null;
}
@@ -1,13 +0,0 @@
import { IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({ example: 'OldPass123!' })
@IsString()
currentPassword: string;
@ApiProperty({ example: 'NewPass123!' })
@IsString()
@MinLength(6)
newPassword: string;
}
-13
View File
@@ -1,13 +0,0 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({ example: 'admin@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'Admin123!' })
@IsString()
@MinLength(6)
password: string;
}
@@ -1,8 +0,0 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RefreshTokenDto {
@ApiProperty({ example: 'refresh-token-value' })
@IsString()
refreshToken: string;
}
@@ -1,12 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class DeviceAuthGuard extends AuthGuard('device') {
handleRequest(err: any, device: any, info: any, context: ExecutionContext) {
if (err || !device) {
throw err || new (require('@nestjs/common').UnauthorizedException)({ code: 'unauthorized', message: 'Invalid device authentication' });
}
return device;
}
}
@@ -1,12 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
if (err || !user) {
throw err || new (require('@nestjs/common').UnauthorizedException)({ code: 'unauthorized', message: 'Authentication required' });
}
return user;
}
}
@@ -1,29 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-custom';
import { Request } from 'express';
import { AuthService } from '../auth.service';
@Injectable()
export class DeviceStrategy extends PassportStrategy(Strategy, 'device') {
constructor(private authService: AuthService) {
super();
}
async validate(request: Request): Promise<any> {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException({ code: 'missing_token', message: 'Missing device token' });
}
const token = authHeader.substring(7);
const deviceId = request.params.deviceId || request.body?.deviceId;
if (!deviceId) {
throw new UnauthorizedException({ code: 'missing_device_id', message: 'Missing device ID' });
}
const device = await this.authService.validateDevice(deviceId, token);
return device;
}
}
@@ -1,41 +0,0 @@
import { UnauthorizedException } from '@nestjs/common';
import { issuedBefore, JwtStrategy } from './jwt.strategy';
const mona = { id: 'user-1', email: 'mona@example.com', name: 'Mona', role: 'manager', venueId: 'venue-1' };
function strategy(users: (typeof mona & { sessionsRevokedAt?: Date | null })[]) {
const prisma = { clientUser: { findUnique: async ({ where }: any) => users.find((u) => u.id === where.id) ?? null } };
return new JwtStrategy(prisma as any);
}
describe('JwtStrategy', () => {
it('takes the user as it is now, not as it was when the token was issued', async () => {
const user = await strategy([{ ...mona, role: 'admin' }]).validate({ sub: 'user-1', email: mona.email, role: 'manager', venueId: 'venue-1' });
expect(user).toEqual({ ...mona, role: 'admin' });
});
it('refuses the token of a deleted user', async () => {
await expect(strategy([]).validate({ sub: 'user-1', role: 'admin', venueId: 'venue-1' })).rejects.toBeInstanceOf(UnauthorizedException);
});
it('refuses a token issued before the user was signed out everywhere, and accepts a newer one', async () => {
const signedOutAt = new Date('2026-10-26T12:00:00Z');
const users = [{ ...mona, sessionsRevokedAt: signedOutAt }];
const before = Math.floor(signedOutAt.getTime() / 1000) - 60;
const after = Math.floor(signedOutAt.getTime() / 1000) + 60;
await expect(strategy(users).validate({ sub: 'user-1', iat: before })).rejects.toBeInstanceOf(UnauthorizedException);
expect(await strategy(users).validate({ sub: 'user-1', iat: after })).toEqual(mona);
});
});
describe('issuedBefore', () => {
it('only refuses tokens from before the sign-out', () => {
const at = new Date('2026-10-26T12:00:00.500Z');
const second = Math.floor(at.getTime() / 1000);
expect(issuedBefore({ iat: second - 1 }, at)).toBe(true);
expect(issuedBefore({ iat: second }, at)).toBe(false);
expect(issuedBefore({ iat: second - 1 }, null)).toBe(false);
expect(issuedBefore({ iat: second - 1 }, undefined)).toBe(false);
});
});
@@ -1,34 +0,0 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { getJwtSecret } from '../jwt-secret';
import { PrismaService } from '../../prisma/prisma.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: getJwtSecret(),
});
}
// Looked up on every request: a deleted or signed-out user loses access at once instead of when the token expires,
// and a changed role or name applies right away
async validate(payload: any) {
const user = await this.prisma.clientUser.findUnique({
where: { id: payload.sub },
select: { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true },
});
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) {
throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
}
const { sessionsRevokedAt, ...rest } = user;
return rest;
}
}
/** Whether the token was issued before the user's sessions were revoked. iat is in whole seconds. */
export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
}
+17
View File
@@ -0,0 +1,17 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ACCESS_COOKIE } from './cookies';
import { AuthService } from './auth.service';
/** The dashboard user from the access token cookie; 401 when it is missing, expired or revoked. */
@Injectable()
export class UserAuthGuard implements CanActivate {
constructor(private auth: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const user = await this.auth.userFromAccessToken(request.cookies?.[ACCESS_COOKIE]);
if (!user) throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
request.user = user;
return true;
}
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Creates the first venue and its admin on a new database. Does nothing when there is a venue already.
*
* node dist/cli/setup.js "<venue name>" <admin email> <admin password>
* (or SETUP_VENUE_NAME, SETUP_ADMIN_EMAIL, SETUP_ADMIN_PASSWORD)
*/
import 'dotenv/config';
import { hashPassword } from '../auth/auth.service';
import { createPrismaClient } from '../prisma/prisma.service';
import { ensureDefaultCustomers } from '../customers/default-customers';
async function main() {
const [venueName = process.env.SETUP_VENUE_NAME, email = process.env.SETUP_ADMIN_EMAIL, password = process.env.SETUP_ADMIN_PASSWORD] = process.argv.slice(2);
if (!venueName || !email || !password) {
throw new Error('Usage: setup "<venue name>" <admin email> <admin password>');
}
if (password.length < 10) throw new Error('The admin password must be at least 10 characters');
const prisma = createPrismaClient();
try {
const venues = await prisma.venue.count();
if (venues > 0) {
console.log(`The database already has ${venues} venue(s); nothing to do.`);
return;
}
const venue = await prisma.$transaction(async (tx) => {
const created = await tx.venue.create({ data: { name: venueName } });
await ensureDefaultCustomers(tx, created.id);
await tx.user.create({
data: { venueId: created.id, email: email.trim().toLowerCase(), name: 'Admin', role: 'admin', passwordHash: await hashPassword(password) },
});
return created;
});
console.log(`Created ${venue.name} with admin ${email}. Sign in to the dashboard with those credentials.`);
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error(`Setup failed: ${error.message ?? error}`);
process.exitCode = 1;
});
+50
View File
@@ -0,0 +1,50 @@
/**
* Superadmins run KoolTap itself, above the venues. There is no other way to get the first one: the dashboard only lets a
* superadmin hand out the role.
*
* node dist/cli/superadmin.js <email> makes an existing user a superadmin
* node dist/cli/superadmin.js <email> --create "<name>" <password> adds a new superadmin
* node dist/cli/superadmin.js <email> --revoke <venue id> makes a superadmin an admin of that venue
*/
import 'dotenv/config';
import { hashPassword } from '../auth/auth.service';
import { createPrismaClient } from '../prisma/prisma.service';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
async function main() {
const [rawEmail, flag, ...rest] = process.argv.slice(2);
if (!rawEmail) throw new Error('Usage: superadmin <email> [--create "<name>" <password> | --revoke <venue id>]');
const email = rawEmail.trim().toLowerCase();
const prisma = createPrismaClient();
try {
if (flag === '--create') {
const [name, password] = rest;
if (!name || !password || password.length < 10) throw new Error('Give a name and a password of at least 10 characters');
if (await prisma.user.findUnique({ where: { email } })) throw new Error(`${email} exists already: leave out --create to promote them`);
await prisma.user.create({ data: { email, name, role: 'superadmin', venueId: null, passwordHash: await hashPassword(password) } });
console.log(`Added superadmin ${email}`);
} else if (flag === '--revoke') {
const [venueId] = rest;
if (!venueId) throw new Error('Give the venue the user becomes an admin of');
const venue = UUID.test(venueId) ? await prisma.venue.findUnique({ where: { id: venueId } }) : null;
if (!venue) throw new Error(`No venue with id ${venueId}`);
const { count } = await prisma.user.updateMany({ where: { email, role: 'superadmin' }, data: { role: 'admin', venueId } });
if (count === 0) throw new Error(`No superadmin with email ${email}`);
console.log(`${email} is now an admin of ${venue.name}`);
} else {
// Leaves their venue: a superadmin is above the venues
const { count } = await prisma.user.updateMany({ where: { email }, data: { role: 'superadmin', venueId: null } });
if (count === 0) throw new Error(`No user with email ${email}`);
console.log(`${email} is now a superadmin`);
}
} finally {
await prisma.$disconnect();
}
}
main().catch((error) => {
console.error(error.message ?? error);
process.exitCode = 1;
});
@@ -1,71 +0,0 @@
import {
Controller,
Post,
Get,
Param,
Query,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
import { CommandsService } from './commands.service';
import { DevicesService } from '../devices/devices.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
import { CreateCommandDto } from './dto/create-command.dto';
@ApiTags('Commands')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, VenueAccessGuard)
@Controller('venues/:venueId/commands')
export class CommandsController {
constructor(
private commandsService: CommandsService,
private devicesService: DevicesService,
) {}
@Post()
@ApiOperation({ summary: 'Create a remote command' })
async create(
@Param('venueId') venueId: string,
@Body() dto: CreateCommandDto,
@Req() req: any,
) {
return this.commandsService.create(venueId, dto, req.user);
}
@Get()
@ApiOperation({ summary: 'List command history' })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'offset', required: false })
async findAll(
@Param('venueId') venueId: string,
@Query('status') status?: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
) {
return this.commandsService.findAll(venueId, {
status,
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
});
}
@Post('devices/:deviceId/revoke')
@UseGuards(RolesGuard)
@Roles('admin')
@ApiOperation({ summary: 'Revoke a device (admin only)' })
@ApiResponse({ status: 200, description: 'Device revoked' })
async revoke(
@Param('venueId') venueId: string,
@Param('deviceId') deviceId: string,
) {
return this.devicesService.revoke(venueId, deviceId);
}
}
-12
View File
@@ -1,12 +0,0 @@
import { Module } from '@nestjs/common';
import { DevicesModule } from '../devices/devices.module';
import { CommandsController } from './commands.controller';
import { CommandsService } from './commands.service';
@Module({
imports: [DevicesModule],
controllers: [CommandsController],
providers: [CommandsService],
exports: [CommandsService],
})
export class CommandsModule {}
@@ -1,78 +0,0 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { CommandsService } from './commands.service';
import { createFakePrisma } from '../testing/fake-prisma';
const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
const unit = (id: string, removed = false) => (
{ id, addedAt: '2026-09-24T20:00:00.000Z', removedAt: removed ? '2026-09-24T20:01:00.000Z' : null });
function createService({ lines = [] as unknown[] } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
prisma.tab.rows.push({
venueId: 'v1', id: 't1', customerName: 'Jan', openedAt: new Date('2026-09-24T19:00:00Z'), version: 3, lines,
updatedAt: new Date(),
});
const realtime = { notify: jest.fn() };
const cashPayments = { notify: jest.fn() };
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
return { service, prisma, realtime, cashPayments };
}
const colaLine = {
id: 'line-1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250,
units: [unit('u1'), unit('u2'), unit('u3', true)],
};
describe('CommandsService.create', () => {
it('settles everything on the tab at once and records it as completed', async () => {
const { service, prisma, realtime, cashPayments } = createService({ lines: [colaLine] });
const result = await service.create('v1', dto, user);
expect(result).toMatchObject({ status: 'completed', amountInCents: 500 });
expect(prisma.tab.rows[0]).toMatchObject({ version: 4, lines: [] });
expect(prisma.settlement.rows).toHaveLength(1);
expect(prisma.settlement.rows[0]).toMatchObject({
tabId: 't1', customerName: 'Jan', paymentMethod: 'cash', amountInCents: 500, requestedBy: 'Staff', deviceId: null,
});
// The struck-through drink goes along, so the history shows it
expect(prisma.settlement.rows[0].lines).toMatchObject([{ quantity: 2, units: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }] }]);
expect(prisma.remoteCommand.rows[0]).toMatchObject({
id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500,
});
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs');
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ paymentMethod: 'cash', amountInCents: 500 })], 'Staff');
});
it('rejects a tab that is not open', async () => {
const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', { ...dto, tabId: 't9' }, user)).rejects.toBeInstanceOf(NotFoundException);
});
it('rejects a tab that changed since it was loaded, changing nothing', async () => {
const { service, prisma } = createService({ lines: [colaLine] });
await expect(service.create('v1', { ...dto, expectedVersion: 2 }, user))
.rejects.toMatchObject({ response: { code: 'version_mismatch' } });
expect(prisma.settlement.rows).toEqual([]);
});
it('rejects a tab with nothing on it', async () => {
const { service } = createService({ lines: [] });
await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(ConflictException);
});
it('rejects an unknown payment method or command type', async () => {
const { service } = createService({ lines: [colaLine] });
await expect(service.create('v1', { ...dto, paymentMethod: 'bitcoin' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_payment_method' } });
await expect(service.create('v1', { ...dto, type: 'open_tab' }, user))
.rejects.toMatchObject({ response: { code: 'invalid_command_type' } });
});
});
-173
View File
@@ -1,173 +0,0 @@
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService } from '../realtime/realtime.service';
import { CreateCommandDto } from './dto/create-command.dto';
import { VenueLedger } from '../tabs/venue-ledger';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
@Injectable()
export class CommandsService {
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
/**
* Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is
* settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the
* history.
*/
async create(venueId: string, dto: CreateCommandDto, user: any) {
if (!dto.paymentMethod) {
throw new BadRequestException({
code: 'payment_method_required',
message: 'Payment method must be explicitly selected',
});
}
const validMethods = ['cash', 'payconiq', 'payment_pending'];
if (!validMethods.includes(dto.paymentMethod)) {
throw new BadRequestException({
code: 'invalid_payment_method',
message: `Payment method must be one of: ${validMethods.join(', ')}`,
});
}
const type = dto.type || 'close_tab';
if (type !== 'close_tab') {
throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` });
}
const requestedBy = user.name || user.email;
const { result, settlements } = await this.prisma.$transaction(async (tx) => {
const now = new Date();
const ledger = await VenueLedger.open(tx, venueId);
const tab = ledger.tabs.get(dto.tabId);
if (!tab) {
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
}
if (tab.version !== dto.expectedVersion) {
throw new ConflictException({
code: 'version_mismatch',
message: 'The tab has changed since it was loaded. Refresh and try again.',
});
}
const id = uuidv4();
const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod });
if (!settlement) {
throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
}
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
await ledger.save(tx, now);
const command = await tx.remoteCommand.create({
data: {
id,
venueId,
deviceId: null,
targetTabId: dto.tabId,
customerName: settlement.customerName,
amountInCents: settlement.amountInCents,
type,
expectedVersion: dto.expectedVersion,
paymentMethod: dto.paymentMethod,
requestedBy,
status: 'completed',
acknowledgedAt: now,
expiresAt: now,
},
});
await tx.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: user.id,
action: 'tab_settled',
details: JSON.stringify({
commandId: command.id,
tabId: dto.tabId,
paymentMethod: dto.paymentMethod,
amountInCents: settlement.amountInCents,
}),
},
});
return {
result: { id: command.id, status: command.status, amountInCents: settlement.amountInCents },
settlements: ledger.settlements,
};
});
this.realtime.notify(venueId, 'commands', 'tabs');
this.cashPayments.notify(venueId, settlements, requestedBy);
return result;
}
async findAll(
venueId: string,
options: { status?: string; limit: number; offset: number },
) {
const where: any = { venueId };
if (options.status) {
where.status = options.status;
}
const [commands, total] = await Promise.all([
this.prisma.remoteCommand.findMany({
where,
select: {
id: true,
targetTabId: true,
customerName: true,
amountInCents: true,
type: true,
status: true,
paymentMethod: true,
requestedBy: true,
expectedVersion: true,
createdAt: true,
acknowledgedAt: true,
result: true,
deviceId: true,
},
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
}),
this.prisma.remoteCommand.count({ where }),
]);
return {
commands: commands.map((c) => ({
id: c.id,
tabId: c.targetTabId,
customerName: c.customerName,
amountInCents: c.amountInCents,
type: c.type,
status: c.status,
paymentMethod: c.paymentMethod,
requestedBy: c.requestedBy,
expectedVersion: c.expectedVersion,
createdAt: c.createdAt,
processedAt: c.acknowledgedAt,
result: c.result,
deviceId: c.deviceId,
})),
total,
};
}
}
@@ -1,22 +0,0 @@
import { IsString, IsIn, IsInt, Min, IsUUID } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateCommandDto {
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
tabId: string;
@ApiProperty({ example: 'close_tab', default: 'close_tab' })
@IsString()
type: string = 'close_tab';
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
expectedVersion: number;
@ApiProperty({ example: 'cash', enum: ['cash', 'payconiq', 'payment_pending'] })
@IsString()
@IsIn(['cash', 'payconiq', 'payment_pending'])
paymentMethod: string;
}
+51
View File
@@ -0,0 +1,51 @@
import {
CanActivate, createParamDecorator, ExecutionContext, ForbiddenException, Injectable, SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '@kooltap/shared';
import { UserAuthGuard } from '../auth/user-auth.guard';
/** The signed-in dashboard user, as the database has them now. A superadmin has no venue. */
export type AuthUser = { id: string; email: string; name: string; role: Role; venueId: string | null };
/** The terminal a request comes from. */
export type AuthDevice = { id: string; venueId: string; name: string; lastSeenAt: Date };
export const CurrentUser = createParamDecorator((_: unknown, context: ExecutionContext): AuthUser => (
context.switchToHttp().getRequest().user));
export const CurrentDevice = createParamDecorator((_: unknown, context: ExecutionContext): AuthDevice => (
context.switchToHttp().getRequest().device));
const ROLES_KEY = 'roles';
/** A superadmin may do everything an admin or manager may, in every venue. */
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [context.getHandler(), context.getClass()]);
const user: AuthUser | undefined = context.switchToHttp().getRequest().user;
if (!roles || user?.role === 'superadmin' || (user && roles.includes(user.role))) return true;
throw new ForbiddenException({ code: 'forbidden', message: 'Your role does not allow this' });
}
}
/** A user only reaches their own venue's routes (`:venueId`); a superadmin reaches every venue's. */
@Injectable()
export class VenueAccessGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const venueId: string | undefined = request.params?.venueId;
const user: AuthUser | undefined = request.user;
if (!venueId || user?.role === 'superadmin' || user?.venueId === venueId) return true;
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
}
/** Signed in, with access to the route's venue, and one of the @Roles when the route has them. */
export const Authorized = () => UseGuards(UserAuthGuard, VenueAccessGuard, RolesGuard);
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
-1
View File
@@ -1 +0,0 @@
export { Roles, ROLES_KEY } from './roles.decorator';
@@ -1,4 +0,0 @@
import { SetMetadata } from '@nestjs/common';
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
@@ -1,38 +0,0 @@
import { RolesGuard } from './roles.guard';
function context(role: string | undefined) {
return {
getHandler: () => null,
getClass: () => null,
switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }),
} as any;
}
function guard(required: string[] | undefined) {
return new RolesGuard({ getAllAndOverride: () => required } as any);
}
describe('RolesGuard', () => {
it('lets managers do what managers may, and keeps admin-only actions for the admin', () => {
expect(guard(['admin', 'manager']).canActivate(context('manager'))).toBe(true);
expect(guard(['admin']).canActivate(context('manager'))).toBe(false);
expect(guard(['admin']).canActivate(context('admin'))).toBe(true);
});
it('treats a token from before the rename ("viewer") as a manager', () => {
expect(guard(['admin', 'manager']).canActivate(context('viewer'))).toBe(true);
expect(guard(['admin']).canActivate(context('viewer'))).toBe(false);
});
it('lets a superadmin do everything, and keeps the admin page for superadmins', () => {
expect(guard(['admin']).canActivate(context('superadmin'))).toBe(true);
expect(guard(['admin', 'manager']).canActivate(context('superadmin'))).toBe(true);
expect(guard(['superadmin']).canActivate(context('superadmin'))).toBe(true);
expect(guard(['superadmin']).canActivate(context('admin'))).toBe(false);
});
it('refuses a request without a user when a role is required', () => {
expect(guard(['manager']).canActivate(context(undefined))).toBe(false);
expect(guard(undefined).canActivate(context(undefined))).toBe(true);
});
});
-26
View File
@@ -1,26 +0,0 @@
import { Injectable, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ROLES_KEY } from '../decorators/roles.decorator';
@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
if (!user) {
return false;
}
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
const role = user.role === 'viewer' ? 'manager' : user.role;
// A superadmin may do everything an admin or manager may, in every venue
return role === 'superadmin' || requiredRoles.includes(role);
}
}
@@ -1,33 +0,0 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { VenueAccessGuard } from './venue-access.guard';
function contextFor(params: Record<string, string>, user?: { venueId: string; role?: string }): ExecutionContext {
return {
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
} as unknown as ExecutionContext;
}
describe('VenueAccessGuard', () => {
const guard = new VenueAccessGuard();
it('allows routes without a venueId param', () => {
expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true);
});
it('allows access to the user\'s own venue', () => {
expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true);
});
it('rejects access to another venue', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
});
it('lets a superadmin into every venue', () => {
expect(guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'superadmin' }))).toBe(true);
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'admin' }))).toThrow(ForbiddenException);
});
it('rejects when no user is attached', () => {
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
});
});
@@ -1,27 +0,0 @@
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
/**
* Ensures the authenticated user can only access the venue they belong to. A superadmin can access every venue: that
* is how they view the dashboard as a venue. Must run after JwtAuthGuard so that `request.user` is populated.
*/
@Injectable()
export class VenueAccessGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const venueId = request.params?.venueId;
if (!venueId) {
return true;
}
if (request.user?.role === 'superadmin') {
return true;
}
if (!request.user || request.user.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
}
return true;
}
}
@@ -1,20 +0,0 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;
const start = Date.now();
return next.handle().pipe(
tap(() => {
const duration = Date.now() - start;
const userId = request.user?.id || request.device?.id || 'anonymous';
const venueId = request.user?.venueId || request.device?.venueId || '-';
console.log(`[${method}] ${url} | user=${userId} venue=${venueId} | ${duration}ms`);
}),
);
}
}
@@ -0,0 +1,19 @@
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
import { Observable, tap } from 'rxjs';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger('HTTP');
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest();
const start = Date.now();
return next.handle().pipe(
tap(() => {
const actor = request.user?.id ?? request.device?.id ?? 'anonymous';
const venueId = request.user?.venueId ?? request.device?.venueId ?? '-';
this.logger.log(`${request.method} ${request.url} | actor=${actor} venue=${venueId} | ${Date.now() - start}ms`);
}),
);
}
}
+17
View File
@@ -0,0 +1,17 @@
import { BadRequestException, PipeTransform } from '@nestjs/common';
import { z } from 'zod';
/** Validates and converts a body or query with a schema from @kooltap/shared: `@Body(zod(schema))`. */
export class ZodPipe<S extends z.ZodType> implements PipeTransform<unknown, z.output<S>> {
constructor(private readonly schema: S) {}
transform(value: unknown): z.output<S> {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException({ code: 'validation_error', message: z.prettifyError(result.error) });
}
return result.data;
}
}
export const zod = <S extends z.ZodType>(schema: S) => new ZodPipe(schema);
+4
View File
@@ -0,0 +1,4 @@
/** The dashboard's origin(s), comma separated in CORS_ORIGIN. */
export function corsOrigin(): string[] {
return (process.env.CORS_ORIGIN || 'http://localhost:3000').split(',').map((origin) => origin.trim()).filter(Boolean);
}
@@ -0,0 +1,36 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { z } from 'zod';
import { Customer, customerSchema, ImportResult, importCustomersSchema } from '@kooltap/shared';
import { zod } from '../common/zod.pipe';
import { Authorized, Roles } from '../common/auth';
import { CustomersService } from './customers.service';
@Controller('venues/:venueId/customers')
@Authorized()
export class CustomersController {
constructor(private customers: CustomersService) {}
@Get()
findAll(@Param('venueId') venueId: string): Promise<Customer[]> {
return this.customers.findAll(venueId);
}
@Post()
@Roles('admin', 'manager')
create(@Param('venueId') venueId: string, @Body(zod(customerSchema)) dto: z.output<typeof customerSchema>): Promise<Customer> {
return this.customers.create(venueId, dto);
}
@Post('import')
@HttpCode(HttpStatus.OK)
@Roles('admin', 'manager')
import(@Param('venueId') venueId: string, @Body(zod(importCustomersSchema)) dto: z.output<typeof importCustomersSchema>): Promise<ImportResult> {
return this.customers.import(venueId, dto.entries, dto.mode);
}
@Delete(':id')
@Roles('admin', 'manager')
remove(@Param('venueId') venueId: string, @Param('id', ParseUUIDPipe) id: string) {
return this.customers.remove(venueId, id);
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
@Module({
controllers: [CustomersController],
providers: [CustomersService],
})
export class CustomersModule {}
+101
View File
@@ -0,0 +1,101 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { Customer, ImportResult } from '@kooltap/shared';
import { Prisma, PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey, normalizeCustomerName } from './customer-name';
const customerSelect = { id: true, name: true, email: true, position: true, createdAt: true, tabOpenedAt: true } as const;
function toCustomer(row: { id: string; name: string; email: string | null; position: number; createdAt: Date; tabOpenedAt: Date | null }): Customer {
return { ...row, createdAt: row.createdAt.toISOString(), tabOpenedAt: row.tabOpenedAt?.toISOString() ?? null };
}
@Injectable()
export class CustomersService {
constructor(
private prisma: PrismaService,
private realtime: RealtimeService,
) {}
async findAll(venueId: string): Promise<Customer[]> {
const customers = await this.prisma.customer.findMany({ where: { venueId }, orderBy: { position: 'asc' }, select: customerSelect });
return customers.map(toCustomer);
}
/** Adds a customer at the end of the list; the next sync opens their tab. */
async create(venueId: string, dto: { name: string; email?: string | null }): Promise<Customer> {
const name = normalizeCustomerName(dto.name);
try {
const customer = await this.prisma.$transaction(async (tx) => {
const last = await tx.customer.aggregate({ where: { venueId }, _max: { position: true } });
return tx.customer.create({
data: { venueId, name, nameKey: customerNameKey(name), email: dto.email ?? null, position: (last._max.position ?? -1) + 1 },
select: customerSelect,
});
});
this.realtime.notify(venueId, 'customers');
return toCustomer(customer);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
}
throw error;
}
}
/**
* Bulk import of the customer list (e.g. from the venue's Excel file). Names match case-insensitively and the
* existing spelling is kept: tabs are matched by name and a respelled name would open a second tab. A missing email
* never clears an existing one. Customers whose tab was opened before get one again on the next sync when they have
* none (it may have been deleted since).
*/
async import(venueId: string, entries: { name: string; email?: string | null }[], mode: 'merge' | 'replace'): Promise<ImportResult> {
const incoming = new Map<string, { name: string; email: string | null }>();
for (const entry of entries) {
const name = normalizeCustomerName(entry.name);
const key = customerNameKey(name);
incoming.set(key, { name, email: entry.email || incoming.get(key)?.email || null });
}
const result = await this.prisma.$transaction(async (tx) => {
const existing = await tx.customer.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
const existingByKey = new Map(existing.map((c) => [c.nameKey, c]));
let position = (existing.at(-1)?.position ?? -1) + 1;
const counts: ImportResult = { created: 0, updated: 0, unchanged: 0, removed: 0 };
const created = [...incoming].filter(([key]) => !existingByKey.has(key));
await tx.customer.createMany({
data: created.map(([nameKey, entry]) => ({ venueId, name: entry.name, nameKey, email: entry.email, position: position++ })),
});
counts.created = created.length;
for (const [key, entry] of incoming) {
const current = existingByKey.get(key);
if (!current) continue;
const newEmail = entry.email && entry.email !== current.email ? entry.email : undefined;
if (current.tabOpenedAt || newEmail) {
await tx.customer.update({ where: { id: current.id }, data: { tabOpenedAt: null, ...(newEmail ? { email: newEmail } : {}) } });
counts.updated++;
} else {
counts.unchanged++;
}
}
if (mode === 'replace') {
const removed = existing.filter((c) => !incoming.has(c.nameKey)).map((c) => c.id);
counts.removed = (await tx.customer.deleteMany({ where: { id: { in: removed } } })).count;
}
return counts;
}, { timeout: 30_000 });
this.realtime.notify(venueId, 'customers', 'tabs');
return result;
}
async remove(venueId: string, id: string) {
const { count } = await this.prisma.customer.deleteMany({ where: { id, venueId } });
if (count === 0) throw new NotFoundException({ code: 'customer_not_found', message: 'Customer not found' });
this.realtime.notify(venueId, 'customers', 'tabs');
return { ok: true };
}
}
@@ -0,0 +1,19 @@
import { Tx } from '../prisma/prisma.service';
import { customerNameKey } from './customer-name';
/** The tab free drinks are put on, so they still count in the stock and history. A normal customer without email. */
export const FREE_DRINKS_CUSTOMER = 'Free Drinks';
/**
* Adds the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose
* tab was wiped, comes back: run when a venue is created or reset.
*/
export async function ensureDefaultCustomers(tx: Tx, venueId: string) {
const nameKey = customerNameKey(FREE_DRINKS_CUSTOMER);
await tx.customer.upsert({
where: { venueId_nameKey: { venueId, nameKey } },
create: { venueId, name: FREE_DRINKS_CUSTOMER, nameKey },
// Opened again when its tab is gone; an open tab with this name is kept
update: { tabOpenedAt: null },
});
}
@@ -1,97 +0,0 @@
import {
Controller,
Post,
Get,
Param,
Body,
UseGuards,
HttpCode,
HttpStatus,
Req,
Query,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { SkipThrottle, Throttle } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes } from '@nestjs/swagger';
import { ImagesService } from '../images/images.service';
import { MAX_UPLOAD_BYTES } from '../images/image-processing';
import { DevicesService } from './devices.service';
import { AuthService } from '../auth/auth.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { DeviceAuthGuard } from '../auth/guards/device-auth.guard';
import { AppVersionGuard } from './app-version.guard';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { RegisterDeviceDto } from './dto/register-device.dto';
import { SyncDto } from './dto/sync.dto';
import { AckCommandDto } from './dto/ack-command.dto';
@ApiTags('Devices')
@Controller('devices')
export class DevicesController {
constructor(
private devicesService: DevicesService,
private authService: AuthService,
private images: ImagesService,
) {}
@Post('register')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@UseGuards(AppVersionGuard)
@ApiOperation({ summary: 'Register a new device' })
@ApiResponse({ status: 201, description: 'Device registered successfully' })
async register(@Body() dto: RegisterDeviceDto) {
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
}
// Authenticated by the device token; the till must never be rate-limited out by other
// traffic from the venue's shared IP
@Post(':deviceId/sync')
@SkipThrottle()
@UseGuards(AppVersionGuard, DeviceAuthGuard)
@ApiOperation({ summary: 'Sync device state with server' })
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
return this.devicesService.sync(req.user, dto);
}
// A picture taken on a terminal. Only stored: the terminal links it to its product with a product_updated event,
// so an offline edit and its picture follow the same rules as every other change
@Post(':deviceId/images')
@SkipThrottle()
@UseGuards(AppVersionGuard, DeviceAuthGuard)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) {
return { imageId: await this.images.store(file?.buffer) };
}
@Post(':deviceId/commands/:commandId/ack')
@SkipThrottle()
@UseGuards(AppVersionGuard, DeviceAuthGuard)
@ApiOperation({ summary: 'Acknowledge a command result' })
@ApiResponse({ status: 200, description: 'Command acknowledged' })
async ackCommand(
@Param('deviceId') deviceId: string,
@Param('commandId') commandId: string,
@Body() dto: AckCommandDto,
@Req() req: any,
) {
return this.devicesService.ackCommand(req.user, commandId, dto);
}
@Get('generation-code')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('admin')
@ApiBearerAuth()
@ApiOperation({ summary: 'Generate a registration code for pairing' })
@ApiResponse({ status: 200, description: 'Registration code generated' })
async generateCode(@Req() req: any, @Query('venueId') viewing?: string) {
// A superadmin pairs terminals for the venue they're viewing
const venueId = req.user.role === 'superadmin' && viewing ? viewing : req.user.venueId;
return this.authService.generateRegistrationCode(venueId);
}
}
-13
View File
@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { DevicesController } from './devices.controller';
import { DevicesService } from './devices.service';
import { ImagesModule } from '../images/images.module';
@Module({
imports: [AuthModule, ImagesModule],
controllers: [DevicesController],
providers: [DevicesService],
exports: [DevicesService],
})
export class DevicesModule {}
@@ -1,694 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { DevicesService } from './devices.service';
import { SyncDto } from './dto/sync.dto';
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
import { StockService } from '../stock/stock.service';
type Device = { id: string; venueId: string; name?: string; revokedAt: null; lastSeenAt: Date };
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
// tablet's first settlements feed only goes back a few days
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const at = (minutes: number) => new Date(Date.UTC(
yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate(), 20, minutes,
)).toISOString();
function setup({ seeded = true } = {}) {
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
const tabletA: Device = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
const realtime = { notify: jest.fn() };
const cashPayments = { notify: jest.fn() };
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
return { prisma, service, realtime, cashPayments, sync, tabletA, tabletB };
}
/** Builds the events a terminal sends, with fresh ids. */
const events = {
opened: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_opened', tabId, customerName, occurredAt: at(minutes), openedAt: at(minutes) }),
renamed: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_renamed', tabId, customerName, occurredAt: at(minutes) }),
deleted: (tabId: string, customerName: string, minutes = 0) => (
{ id: uuidv4(), type: 'tab_deleted', tabId, customerName, occurredAt: at(minutes) }),
line: (type: 'line_added' | 'line_removed', tabId: string, quantity: number, {
minutes = 0, lineId = undefined as string | undefined, productId = 'cola', productName = 'Cola', price = 250,
} = {}) => ({
id: uuidv4(), type, tabId, customerName: 'Alice', occurredAt: at(minutes),
lineId, productId, productName, unitPriceInCents: price, quantity,
}),
settled: (tabId: string, quantity: number, minutes = 0) => ({
id: uuidv4(), type: 'settled', tabId, customerName: 'Alice', occurredAt: at(minutes),
amountInCents: quantity * 250, paymentMethod: 'payconiq',
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
}),
product: (type: string, productId: string, product: Record<string, unknown> | undefined, minutes = 0) => (
{ id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }),
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
};
const catalog = (stock: number | null = 0) => [
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock },
];
function tab(result: SyncResult, tabId: string) {
return result.tabs.find((t) => t.id === tabId);
}
function colaStock(result: SyncResult) {
return result.stock.find((s) => s.productId === 'cola')?.stock;
}
describe('DevicesService.sync: the server holds the tabs', () => {
it('shows a newly paired tablet the open tabs of the venue', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
const result = await sync(tabletB);
expect(result.tabs).toHaveLength(1);
expect(result.tabs[0]).toMatchObject({ id: 't1', customerName: 'Alice', version: 2 });
expect(result.tabs[0].lines).toMatchObject([{ productId: 'cola', productName: 'Cola', quantity: 2 }]);
});
it('gives units the ids the tablets give them when replaying', async () => {
const { sync, tabletA } = setup();
const added = events.line('line_added', 't1', 2, { lineId: 'line-1' });
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
expect(tab(result, 't1')!.lines[0]).toMatchObject({
id: 'line-1',
units: [
{ id: `${added.id}:0`, addedAt: at(0), removedAt: null },
{ id: `${added.id}:1`, addedAt: at(0), removedAt: null },
],
});
});
it('adds what two tablets put on one tab', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'line-1' })] });
// Tablet B replayed its drink onto the server's line, so it uses that line id
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'line-1', minutes: 1 })] });
const result = await sync(tabletA);
expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
});
it('applies an event once, also when it arrives again in a later sync', async () => {
const { sync, tabletA, prisma } = setup();
const added = events.line('line_added', 't1', 1);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
const result = await sync(tabletA, { events: [added, added] });
expect(result.acknowledgedEventIds).toEqual([added.id, added.id]);
expect(tab(result, 't1')!.lines[0].quantity).toBe(1);
expect(prisma.tabEvent.rows).toHaveLength(2);
});
it('catches up with everything a tablet did offline, in order', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletB, { events: [events.opened('bob', 'Bob')] });
await sync(tabletA, {
events: [
events.opened('t1', 'Alice', 1),
events.line('line_added', 't1', 3, { minutes: 2 }),
events.line('line_removed', 't1', 1, { minutes: 3 }),
events.renamed('t1', 'Alice V.', 4),
events.deleted('bob', 'Bob', 5),
],
});
const result = await sync(tabletB);
expect(result.tabs.map((t) => t.customerName)).toEqual(['Alice V.']);
const [line] = tab(result, 't1')!.lines;
expect(line.quantity).toBe(2);
// The latest drink was taken back
expect(line.units.map((u) => u.removedAt)).toEqual([null, null, at(3)]);
});
it('settles only what the tablet saw, leaving a drink added elsewhere on the tab', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { minutes: 5 })] });
// Tablet A was offline and settled the 2 Cola it knew about
const result = await sync(tabletA, { events: [events.settled('t1', 2, 3)] });
expect(tab(result, 't1')!.lines).toMatchObject([{ quantity: 1, units: [{ addedAt: at(5) }] }]);
expect(result.settlements).toHaveLength(1);
expect(result.settlements[0]).toMatchObject({ tabId: 't1', paymentMethod: 'payconiq', amountInCents: 500 });
expect(result.settlements[0].lines).toMatchObject([{ productId: 'cola', quantity: 2 }]);
expect(result.settlements[0].lines[0].units.map((u) => u.addedAt)).toEqual([at(0), at(0)]);
});
it('brings a tab deleted elsewhere back with the drink that was put on it', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.opened('bob', 'Bob')] });
await sync(tabletB, { events: [events.deleted('bob', 'Bob', 1)] });
const result = await sync(tabletA, {
events: [{ ...events.line('line_added', 'bob', 1, { minutes: 2, productId: 'beer', productName: 'Beer', price: 400 }), customerName: 'Bob' }],
});
expect(tab(result, 'bob')).toMatchObject({ customerName: 'Bob', lines: [{ productName: 'Beer', quantity: 1 }] });
});
it('matches events from older app versions by product name and price', async () => {
const { sync, tabletA } = setup();
const { lineId, productId, ...old } = events.line('line_added', 't1', 2);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), old] });
const { lineId: _, productId: __, ...removed } = events.line('line_removed', 't1', 1, { minutes: 1 });
const result = await sync(tabletA, { events: [removed] });
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: null, quantity: 1 }]);
});
it('does not apply a rejected event, and still syncs the rest', async () => {
const { sync, tabletA } = setup();
const invalid = { ...events.line('line_added', 't1', 1), quantity: 0 };
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), invalid] });
expect(result.rejectedEvents).toEqual([{ id: invalid.id, reason: expect.any(String) }]);
expect(tab(result, 't1')!.lines).toEqual([]);
});
it('opens planned customers as tabs once, instead of on every tablet', async () => {
const { sync, tabletA, tabletB, prisma } = setup();
prisma.plannedTab.rows.push(
{ id: 'p1', venueId: 'v1', customerName: 'Els', customerNameKey: 'els', deliveredAt: null, position: 0 },
{ id: 'p2', venueId: 'v1', customerName: 'alice', customerNameKey: 'alice', deliveredAt: null, position: 1 },
);
await sync(tabletA, { events: [events.opened('t1', 'Alice')] });
const result = await sync(tabletB);
expect(result.plannedTabNames).toEqual([]);
expect(result.tabs.map((t) => t.customerName).sort()).toEqual(['Alice', 'Els']);
expect(prisma.plannedTab.rows.every((p) => p.deliveredAt !== null)).toBe(true);
});
});
describe('DevicesService.sync: taking over a tablet once', () => {
it("takes over the tablet's tabs and stock without applying that request's events again", async () => {
const { sync, tabletA, prisma } = setup({ seeded: false });
const result = await sync(tabletA, {
openTabs: [{
id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3,
items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }],
}],
// Already in the tabs and stock above
events: [events.line('line_added', 't1', 1)],
products: catalog(45),
});
expect(tab(result, 't1')).toMatchObject({ customerName: 'Alice', version: 4, lines: [{ productName: 'Cola', quantity: 3 }] });
expect(colaStock(result)).toBe(45);
expect(prisma.venue.rows[0].tabsSeededAt).not.toBeNull();
const next = await sync(tabletA, {
openTabs: [],
events: [events.line('line_added', 't1', 1, { productId: 'cola', minutes: 1 })],
products: catalog(44),
});
expect(tab(next, 't1')!.lines[0].quantity).toBe(4);
expect(colaStock(next)).toBe(44);
});
});
describe('DevicesService.sync: settlements feed', () => {
it("sends every tablet the venue's settlements once, after its cursor", async () => {
const { sync, tabletA, tabletB } = setup();
const settled = events.settled('t1', 1, 2);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled] });
const first = await sync(tabletB);
expect(first.settlements.map((s) => s.id)).toEqual([settled.id]);
const second = await sync(tabletB, { settlementsCursor: first.settlementsCursor });
expect(second.settlements).toEqual([]);
expect(second.settlementsCursor).toBe(first.settlementsCursor);
});
it('hands a settlement to the cash payment emails once, with the tablet that settled it', async () => {
const { sync, tabletA, cashPayments } = setup();
const settled = { ...events.settled('t1', 1, 2), paymentMethod: 'cash' };
const batch = [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled];
await sync(tabletA, { events: batch });
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ id: settled.id, paymentMethod: 'cash' })], 'Bar tablet');
// Resent because the acknowledgement was lost: already applied, so nothing new to email
await sync(tabletA, { events: batch });
expect(cashPayments.notify).toHaveBeenLastCalledWith('v1', [], 'Bar tablet');
});
it('starts an empty feed after the latest settlement', async () => {
const { sync, tabletA } = setup();
const result = await sync(tabletA);
expect(result.settlements).toEqual([]);
expect(result.settlementsCursor).toBe('0');
});
});
/** A product added on the dashboard, untracked. */
function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record<string, unknown> = {}) {
prisma.product.rows.push({
venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null,
lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields,
});
}
describe('DevicesService.sync: the dashboard manages the catalog', () => {
it('sends every product, archived ones marked, and ignores what a tablet reports', async () => {
const { sync, tabletA, prisma } = setup();
addProduct(prisma, 'cola', { lowStockThreshold: 6 });
addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() });
const result = await sync(tabletA, {
products: [
{ id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 },
{ id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 },
],
});
// By category, products without one last (as Postgres sorts)
expect(result.catalog).toEqual([
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false },
{ id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true },
]);
expect(prisma.product.rows).toHaveLength(2);
});
it("takes over the tablet's catalog when the venue is taken over", async () => {
const { sync, tabletA } = setup({ seeded: false });
const result = await sync(tabletA, { products: catalog(12) });
expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]);
expect(colaStock(result)).toBe(12);
});
});
describe('DevicesService.sync: the server holds the stock', () => {
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
return sync(tablet, { events: [events.stockSet('cola', stock, -10)] });
}
/** Every test here starts with Cola on the dashboard. */
function setupWithCola() {
const venue = setup();
addProduct(venue.prisma);
return venue;
}
it('ignores a count for a product the venue does not have', async () => {
const { sync, tabletA } = setupWithCola();
const result = await sync(tabletA, { events: [events.stockSet('local', 24)] });
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
});
it('counts the sales of every tablet once', async () => {
const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA);
const sale = events.line('line_added', 't1', 2, { minutes: 1 });
await sync(tabletA, { events: [events.opened('t1', 'Alice'), sale] });
await sync(tabletA, { events: [sale] });
const result = await sync(tabletB, { events: [events.line('line_added', 't1', 2, { minutes: 2 })] });
expect(colaStock(result)).toBe(44);
});
it('gives stock back for drinks taken off, but not for a settlement', async () => {
const { sync, tabletA } = setupWithCola();
await trackedCola(sync, tabletA);
const result = await sync(tabletA, {
events: [
events.opened('t1', 'Alice'),
events.line('line_added', 't1', 3, { minutes: 1 }),
events.line('line_removed', 't1', 1, { minutes: 2 }),
events.settled('t1', 2, 3),
],
});
expect(colaStock(result)).toBe(46);
});
it('keeps a count when sales from before it arrive later', async () => {
const { sync, tabletA, tabletB } = setupWithCola();
await trackedCola(sync, tabletA);
await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] });
const result = await sync(tabletA, {
events: [
events.opened('t1', 'Alice'),
// Rung up offline before the count: already in it
events.line('line_added', 't1', 2, { minutes: 50 }),
// An older count from an offline tablet doesn't overwrite the newer one
events.stockSet('cola', 70, 55),
events.line('line_added', 't1', 3, { minutes: 70 }),
],
});
expect(colaStock(result)).toBe(27);
expect(result.stock[0].countedAt).toBe(at(60));
});
it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => {
const { sync, tabletA, tabletB, prisma } = setupWithCola();
await trackedCola(sync, tabletA);
prisma.stockAdjustment.rows.push({
id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24,
status: 'pending', createdAt: new Date(),
});
const first = await sync(tabletA);
const second = await sync(tabletB);
expect(first.stockAdjustments).toEqual([]);
expect(colaStock(first)).toBe(72);
expect(colaStock(second)).toBe(72);
expect(prisma.stockAdjustment.rows[0]).toMatchObject({ status: 'applied', stockAfter: 72 });
});
it('records results from older tablets that applied an adjustment themselves', async () => {
const { sync, tabletA, prisma } = setupWithCola();
await trackedCola(sync, tabletA);
const id = uuidv4();
prisma.stockAdjustment.rows.push({
id, venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, status: 'pending', createdAt: new Date(),
});
const result = await sync(tabletA, { stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }] });
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
// Applied by the tablet, so not again by the server
expect(colaStock(result)).toBe(48);
});
});
describe('DevicesService.sync live update signals', () => {
it('sends nothing when the sync changed nothing', async () => {
const { sync, tabletA, realtime } = setup();
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
realtime.notify.mockClear();
await sync(tabletA, { products: catalog() });
expect(realtime.notify).not.toHaveBeenCalled();
});
it('signals "tabs" and "stock" when they changed', async () => {
const { sync, tabletA, realtime, prisma } = setup();
addProduct(prisma);
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
});
it('signals "device" when a terminal comes back online', async () => {
const { sync, realtime } = setup();
await sync({ id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(Date.now() - 3_600_000) });
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
});
});
describe('DevicesService.sync: website product changes reach the tablets', () => {
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
function setupWithWebsite() {
const venue = setup();
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
return { ...venue, stock };
}
it('sends a new price to every tablet in its next sync', async () => {
const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite();
addProduct(prisma);
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
const a = await sync(tabletA);
const b = await sync(tabletB);
for (const result of [a, b]) {
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', priceInCents: 270 })]);
}
});
it('keeps old drinks at their price and puts new ones on a new line', async () => {
const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite();
addProduct(prisma);
await sync(tabletA, {
events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'old-line' })],
});
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
// The tablet charges the new price on a line of its own
const result = await sync(tabletB, {
events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })],
});
expect(tab(result, 't1')!.lines.map((l) => [l.id, l.unitPriceInCents, l.quantity])).toEqual([
['old-line', 250, 2],
['new-line', 270, 1],
]);
});
it('settles every line at its own price', async () => {
const { sync, tabletA, prisma } = setupWithWebsite();
addProduct(prisma);
const settled = {
...events.settled('t1', 0, 10),
amountInCents: 2 * 250 + 270,
items: [
{ productId: 'cola', productName: 'Cola', quantity: 2, unitPriceInCents: 250 },
{ productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 270 },
],
};
const result = await sync(tabletA, {
events: [
events.opened('t1', 'Alice'),
events.line('line_added', 't1', 2, { lineId: 'old-line' }),
events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 }),
settled,
],
});
expect(tab(result, 't1')!.lines).toEqual([]);
expect(result.settlements[0].lines.map((l) => [l.unitPriceInCents, l.quantity])).toEqual([[250, 2], [270, 1]]);
});
it('counts both price lines down from the one product stock', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
const result = await sync(tabletA, {
events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })],
});
expect(colaStock(result)).toBe(17);
});
it('sends a product added on the website with its starting stock', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
const created = await stock.createProduct('v1', { name: 'Ice Tea', category: 'Frisdrank', priceInCents: 280, stock: 24 }, admin);
const result = await sync(tabletA);
expect(result.catalog).toEqual([expect.objectContaining({ id: created.productId, name: 'Ice Tea', priceInCents: 280 })]);
expect(result.stock).toEqual([expect.objectContaining({ productId: created.productId, stock: 24 })]);
});
it('never sends the buy price or desired stock to the tablets', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
await stock.createProduct('v1', { name: 'Ice Tea', priceInCents: 280, buyPriceInCents: 110, stock: 4, desiredStock: 24 }, admin);
const result = await sync(tabletA);
expect(result.catalog![0]).not.toHaveProperty('buyPriceInCents');
expect(result.catalog![0]).not.toHaveProperty('desiredStock');
});
it('marks an archived product, and still counts a sale from a tablet that had not synced yet', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
await stock.updateProduct('v1', 'cola', { archived: true }, admin);
const result = await sync(tabletA, {
events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1, { minutes: 1 })],
});
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', archived: true })]);
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: 'cola', quantity: 1 }]);
expect(colaStock(result)).toBe(19);
});
it('a renamed product keeps its id, so tablets update it instead of adding one', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
addProduct(prisma);
await stock.updateProduct('v1', 'cola', { name: 'Coca-Cola' }, admin);
const result = await sync(tabletA);
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]);
});
});
describe('DevicesService.sync: products created and changed on a tablet', () => {
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
function setupWithWebsite() {
const venue = setup();
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
return { ...venue, stock };
}
const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id);
it('creates a product made offline, and sells and counts it in the same sync', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, {
events: [
events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1),
events.stockSet('iced', 12, 2),
events.opened('t1', 'Alice', 3),
events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }),
],
});
const result = await sync(tabletB);
expect(catalogEntry(result, 'iced')).toEqual(
{ id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null });
expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10);
});
it('keeps both a website price change and an offline rename of the same product', async () => {
const { sync, tabletA, stock, prisma } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
// Renamed offline before the price change, synced after it
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] });
expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 });
expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) });
});
it('lets the latest change of a field win, also when the older one arrives last', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] });
// Tablet A was offline: its older price change arrives later and loses
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] });
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300);
});
it('a website change made after a tablet change wins', async () => {
const { sync, tabletA, stock } = setupWithWebsite();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] });
await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin);
const result = await sync(tabletA);
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260);
});
it('archives and restores a product from a tablet', async () => {
const { sync, tabletA } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] });
expect(catalogEntry(archived, 'cola')!.archived).toBe(true);
const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] });
expect(catalogEntry(restored, 'cola')!.archived).toBe(false);
});
it('only links a picture that was uploaded', async () => {
const { sync, tabletA, prisma } = setup();
prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') });
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] });
expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull();
const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] });
expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64));
const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] });
expect(catalogEntry(removed, 'cola')!.imageId).toBeNull();
});
it('accepts two products with the same name from two offline tablets', async () => {
const { sync, tabletA, tabletB } = setup();
await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] });
const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] });
expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']);
});
it('acknowledges a change to a product the venue does not have, without creating it', async () => {
const { sync, tabletA } = setup();
const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0);
const result = await sync(tabletA, { events: [change] });
expect(result.acknowledgedEventIds).toEqual([change.id]);
expect(result.catalog).toEqual([]);
});
it('rejects a new product without a price', async () => {
const { sync, tabletA } = setup();
const incomplete = events.product('product_created', 'x', { name: 'X' }, 0);
const result = await sync(tabletA, { events: [incomplete] });
expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]);
});
it('applies a product event once, also when it is sent again', async () => {
const { sync, tabletA, tabletB } = setup();
const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0);
const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1);
await sync(tabletA, { events: [created, renamed] });
await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] });
// The old rename again, with its id: already applied, so it must not undo the newer name
const result = await sync(tabletA, { events: [renamed] });
expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic');
});
});
-349
View File
@@ -1,349 +0,0 @@
import {
Injectable,
Logger,
NotFoundException,
ConflictException,
ForbiddenException,
} from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
import { Prisma } from '@prisma/client';
import { SyncDto } from './dto/sync.dto';
import { AckCommandDto } from './dto/ack-command.dto';
import { eventTime, parseTabEvents, tabEventRow } from './tab-events';
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
import { VenueLedger } from '../tabs/venue-ledger';
import { seedTabs, tabJson } from '../tabs/tab-state';
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
import { applyPendingAdjustments } from '../stock/stock-adjustments';
import { applyProductEvent } from '../stock/product-events';
import { isProductEvent } from './dto/tab-event.dto';
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
@Injectable()
export class DevicesService {
private readonly logger = new Logger(DevicesService.name);
constructor(
private prisma: PrismaService,
private auditService: AuditService,
private realtime: RealtimeService,
private cashPayments: CashPaymentNotifier,
) {}
async sync(device: any, dto: SyncDto) {
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
if (device.revokedAt) {
throw new ForbiddenException({ code: 'device_revoked', message: 'Device has been revoked' });
}
const now = new Date();
const venueId: string = device.venueId;
const events = parseTabEvents(dto.events ?? []);
for (const { id, reason } of events.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
}
// Omitted by app versions without stock
const reported = dto.products ? parseProducts(dto.products) : null;
for (const { id, reason } of reported?.rejected ?? []) {
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
}
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
for (const { id, reason } of stockResults.rejected) {
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
}
const synced = await this.prisma.$transaction(async (tx) => {
// Locks the venue: other terminals' syncs wait, so no change is lost
const ledger = await VenueLedger.open(tx, venueId);
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
let productsChanged = reported && !ledger.seeded
? await this.saveProducts(tx, ledger, reported.products, now)
: false;
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({
where: { id: { in: unique.map((event) => event.id) } },
select: { id: true },
});
const storedIds = new Set(stored.map((event) => event.id));
const fresh = unique.filter((event) => !storedIds.has(event.id));
if (fresh.length > 0) {
await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) });
}
if (!ledger.seeded) {
// The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already
// include the events of this request, so they are stored but not applied again
ledger.seed(seedTabs(dto.openTabs ?? [], now));
for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock);
} else {
for (const event of fresh) {
const occurredAt = eventTime(event, now);
// Products first: a sale or count of a product created offline arrives in the same request
if (isProductEvent(event)) {
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
continue;
}
// Only drinks that were still on the tab go back into the stock: one settled or deleted meanwhile was sold
const stockEvent = event.type === 'line_removed' ? { ...event, quantity: ledger.tabs.removable(event) } : event;
const settlement = ledger.tabs.apply(event, occurredAt);
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
ledger.stock.apply(stockEvent, occurredAt);
}
}
// 3. Outcomes from app versions that applied dashboard adjustments themselves. The terminal's outcome is the
// truth, even for an adjustment cancelled while it was on its way
let stockResultsRecorded = 0;
for (const result of stockResults.accepted) {
const { count } = await tx.stockAdjustment.updateMany({
where: { id: result.id, venueId, status: { in: ['pending', 'cancelled'] } },
data: {
status: result.status,
stockAfter: result.status === 'applied' ? result.stockAfter : null,
result: result.reason ?? null,
processedAt: now,
},
});
stockResultsRecorded += count;
}
// Requested before the server applied adjustments itself, and never applied by a terminal
const adjustmentsApplied = await applyPendingAdjustments(tx, ledger, now);
// 4. Planned customers become tabs here, once, instead of on every terminal
const plannedDelivered = await openPlannedTabs(tx, ledger, now);
await ledger.save(tx, now);
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
const catalog = await tx.product.findMany({
where: { venueId },
orderBy: [{ category: 'asc' }, { name: 'asc' }],
select: {
productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true,
imageId: true,
},
});
return {
ledger,
feed,
catalog,
topics: {
tabs: ledger.changedTabs,
plannedTabs: plannedDelivered > 0,
stock: productsChanged || ledger.stock.changed.size > 0 || stockResultsRecorded > 0 || adjustmentsApplied > 0,
},
};
}, { timeout: 15000 });
// Expire old commands
const expired = await this.prisma.remoteCommand.updateMany({
where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } },
data: { status: 'expired' },
});
// close_tab commands are no longer created (the server settles tabs itself); ones from before that are
// still delivered. Every pending command is returned on each sync; devices dedupe by command id
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
const commands = await this.prisma.remoteCommand.findMany({
where: { deviceId: device.id, status: 'pending' },
select: {
id: true,
type: true,
targetTabId: true,
expectedVersion: true,
paymentMethod: true,
requestedBy: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
});
const topics: RealtimeTopic[] = [];
if (synced.topics.tabs) topics.push('tabs');
if (synced.topics.plannedTabs) topics.push('planned-tabs');
if (expired.count > 0) topics.push('commands');
if (synced.topics.stock) topics.push('stock');
// Only when the device comes back online; dashboards poll the device status themselves
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
const { ledger, feed, catalog } = synced;
this.cashPayments.notify(venueId, ledger.settlements, device.name);
return {
cursor,
commands: commands.map((c) => ({
id: c.id,
type: c.type,
tabId: c.targetTabId,
expectedVersion: c.expectedVersion,
paymentMethod: c.paymentMethod,
requestedBy: c.requestedBy,
createdAt: c.createdAt,
})),
// The server opens planned tabs itself; terminals must not open them as well
plannedTabNames: [],
// Applied (or already applied earlier): the terminal can drop these from its outbox
acknowledgedEventIds: events.accepted.map((e) => e.id),
// Invalid and never applied: the terminal should log and drop these too, re-sending won't help
rejectedEvents: events.rejected,
// The server applies dashboard adjustments itself
stockAdjustments: [],
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
// Invalid and never recorded: drop these too, re-sending won't help
rejectedStockAdjustmentResults: stockResults.rejected,
// The venue's complete state after this request's events: the terminal replaces its own with it
tabs: ledger.tabs.all().map(tabJson),
// Every product of the venue; an archived one is hidden on the terminal (tabs and history refer to it)
catalog: catalog.map((p) => ({
id: p.productId,
name: p.name,
category: p.category,
priceInCents: p.priceInCents,
lowStockThreshold: p.lowStockThreshold,
archived: p.archivedAt !== null,
// The picture is at /images/<imageId>; null: none
imageId: p.imageId,
})),
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
settlements: feed.settlements,
settlementsCursor: feed.settlementsCursor,
};
}
/**
* Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it
* from then on. Never removes a product. Returns whether anything the dashboard shows changed.
*/
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
const stored = await tx.product.findMany({
where: { venueId: ledger.venueId },
select: { productId: true, name: true, category: true, priceInCents: true },
});
const storedById = new Map(stored.map((p) => [p.productId, p]));
let changed = false;
const unchanged: string[] = [];
for (const product of products) {
const previous = storedById.get(product.id);
if (previous && previous.name === product.name && previous.category === product.category
&& previous.priceInCents === product.priceInCents) {
unchanged.push(product.id);
continue;
}
const data = { name: product.name, category: product.category, priceInCents: product.priceInCents, reportedAt: now };
await tx.product.upsert({
where: { venueId_productId: { venueId: ledger.venueId, productId: product.id } },
// Not tracked until counted: its starting stock arrives as a stock_set event
create: { venueId: ledger.venueId, productId: product.id, ...data, stock: null },
update: data,
});
ledger.stock.addProduct(product.id);
changed = true;
}
if (unchanged.length > 0) {
await tx.product.updateMany({
where: { venueId: ledger.venueId, productId: { in: unchanged } },
data: { reportedAt: now },
});
}
return changed;
}
async ackCommand(device: any, commandId: string, dto: AckCommandDto) {
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
const command = await this.prisma.remoteCommand.findUnique({ where: { id: commandId } });
if (!command) {
throw new NotFoundException({ code: 'command_not_found', message: 'Command not found' });
}
if (command.deviceId !== device.id) {
throw new ForbiddenException({ code: 'command_not_for_device', message: 'Command does not belong to this device' });
}
if (command.venueId !== device.venueId) {
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
}
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
if (command.acknowledgedAt) {
return { ok: true, status: command.status };
}
const updated = await this.prisma.remoteCommand.updateMany({
where: { id: commandId, acknowledgedAt: null },
data: {
status: dto.status,
result: dto.result || null,
acknowledgedAt: new Date(),
},
});
if (updated.count === 0) {
// A concurrent ack got there first
return { ok: true };
}
await this.auditService.log(
device.venueId,
'device',
device.id,
'command_acknowledged',
{ commandId, status: dto.status, result: dto.result },
);
this.realtime.notify(device.venueId, 'commands', 'tabs');
return { ok: true };
}
async revoke(venueId: string, deviceId: string) {
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
if (!device) {
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
}
if (device.venueId !== venueId) {
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Device belongs to a different venue' });
}
if (device.revokedAt) {
return { ok: true }; // Idempotent
}
await this.prisma.device.update({
where: { id: deviceId },
data: { revokedAt: new Date() },
});
// Expire pending commands
await this.prisma.remoteCommand.updateMany({
where: { deviceId, status: 'pending' },
data: { status: 'expired' },
});
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
this.realtime.notify(venueId, 'device', 'commands');
return { ok: true };
}
}
@@ -1,18 +0,0 @@
import { IsString, IsIn, IsOptional } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class AckCommandDto {
@ApiProperty({ example: 'cmd-uuid' })
@IsString()
commandId: string;
@ApiProperty({ example: 'completed', enum: ['completed', 'failed', 'conflict', 'expired'] })
@IsString()
@IsIn(['completed', 'failed', 'conflict', 'expired'])
status: string;
@ApiPropertyOptional({ example: 'Tab closed successfully' })
@IsOptional()
@IsString()
result?: string;
}
@@ -1,18 +0,0 @@
import { IsString, IsUUID, IsNotEmpty } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class RegisterDeviceDto {
@ApiProperty({ example: 'uuid' })
@IsUUID()
venueId: string;
@ApiProperty({ example: 'Bar terminal 1' })
@IsString()
@IsNotEmpty()
name: string;
@ApiProperty({ example: 'ABC123' })
@IsString()
@IsNotEmpty()
registrationCode: string;
}
@@ -1,55 +0,0 @@
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ReportedProductDto {
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
@IsString()
@IsNotEmpty()
@MaxLength(100)
id: string;
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
name: string;
@ApiPropertyOptional({ example: 'Frisdrank' })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
priceInCents: number;
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
@IsInt()
stock: number | null;
}
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
export class StockAdjustmentResultDto {
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
@IsUUID()
id: string;
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
@IsIn(STOCK_RESULT_STATUSES)
status: (typeof STOCK_RESULT_STATUSES)[number];
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
@IsInt()
stockAfter?: number;
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
@IsOptional()
@IsString()
@MaxLength(500)
reason?: string;
}
-102
View File
@@ -1,102 +0,0 @@
import { IsString, IsOptional, IsArray, IsInt, Min, IsUUID, ValidateNested, ArrayMaxSize, IsNotEmpty, MaxLength } from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class OpenTabItemDto {
@ApiProperty({ example: 'Cola' })
@IsString()
@IsNotEmpty()
@MaxLength(200)
productName: string;
@ApiProperty({ example: 10 })
@IsInt()
@Min(0)
quantity: number;
@ApiProperty({ example: 250 })
@IsInt()
@Min(0)
unitPriceInCents: number;
}
export class OpenTabDto {
@ApiProperty({ example: 'local-tab-uuid' })
@IsString()
@IsNotEmpty()
id: string;
@ApiProperty({ example: 'Jan' })
@IsString()
customerName: string;
@ApiProperty({ example: 5 })
@IsInt()
@Min(0)
version: number;
@ApiProperty({ example: 2450 })
@IsInt()
@Min(0)
totalInCents: number;
@ApiProperty({ example: 3 })
@IsInt()
@Min(0)
itemCount: number;
@ApiPropertyOptional({ type: [OpenTabItemDto], description: 'Lines on the tab; omitted by older app versions' })
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => OpenTabItemDto)
items?: OpenTabItemDto[];
}
export class SyncDto {
@ApiProperty({ example: 'uuid' })
@IsUUID()
deviceId: string;
@ApiPropertyOptional({ example: 'cursor-value' })
@IsOptional()
@IsString()
cursor?: string;
@ApiPropertyOptional({ description: 'Where the settlements feed left off; absent on the first sync after pairing' })
@IsOptional()
@IsString()
@MaxLength(100)
settlementsCursor?: string;
// The server holds the tabs: only used once, to take over the terminal's tabs on its first sync
@ApiProperty({ type: [OpenTabDto] })
@IsArray()
@ArrayMaxSize(1000)
@ValidateNested({ each: true })
@Type(() => OpenTabDto)
openTabs: OpenTabDto[];
// Validated one by one in the service: a bad event is rejected on its own instead of failing the sync
@ApiPropertyOptional({ description: 'TabEventDto[]: what happened on tabs since the last acknowledged sync' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
events?: unknown[];
// Validated one by one in the service, like events
@ApiPropertyOptional({
description: "ReportedProductDto[]: the terminal's complete product catalog with stock. Omitted by app versions without stock",
})
@IsOptional()
@IsArray()
@ArrayMaxSize(2000)
products?: unknown[];
@ApiPropertyOptional({ description: 'StockAdjustmentResultDto[]: outcomes of stock adjustments the terminal received' })
@IsOptional()
@IsArray()
@ArrayMaxSize(1000)
stockAdjustmentResults?: unknown[];
}
-177
View File
@@ -1,177 +0,0 @@
import {
IsString, IsIn, IsInt, Min, Max, IsUUID, IsNotEmpty, MaxLength, IsISO8601, ValidateIf, IsOptional, IsArray,
ArrayMaxSize, ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { OpenTabItemDto } from './sync.dto';
export const TAB_EVENT_TYPES = [
'tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set',
'product_created', 'product_updated', 'product_archived', 'product_restored',
] as const;
export const PRODUCT_EVENT_TYPES = ['product_created', 'product_updated', 'product_archived', 'product_restored'] as const;
export type TabEventType = (typeof TAB_EVENT_TYPES)[number];
export const SETTLEMENT_PAYMENT_METHODS = ['cash', 'payconiq', 'payment_pending', 'other'] as const;
const isLine = (event: TabEventDto) => event.type === 'line_added' || event.type === 'line_removed';
const isSettlement = (event: TabEventDto) => event.type === 'settled';
const isStockSet = (event: TabEventDto) => event.type === 'stock_set';
export const isProductEvent = (event: TabEventDto) => (PRODUCT_EVENT_TYPES as readonly string[]).includes(event.type);
const isAboutTab = (event: TabEventDto) => !isStockSet(event) && !isProductEvent(event);
const carriesProduct = (event: TabEventDto) => event.type === 'product_created' || event.type === 'product_updated';
/** A product as created or changed on a terminal: only the fields that changed are sent. */
export class ProductEventFieldsDto {
@ApiPropertyOptional({ example: 'Cola' })
@IsOptional()
@IsString()
@IsNotEmpty()
@MaxLength(200)
name?: string;
@ApiPropertyOptional({ example: 'Frisdrank', nullable: true })
@IsOptional()
@IsString()
@MaxLength(100)
category?: string | null;
@ApiPropertyOptional({ example: 250 })
@IsOptional()
@IsInt()
@Min(0)
@Max(1_000_000)
priceInCents?: number;
@ApiPropertyOptional({ example: 12, nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(100000)
lowStockThreshold?: number | null;
@ApiPropertyOptional({ description: 'A picture uploaded to /devices/:deviceId/images first; null removes it', nullable: true })
@IsOptional()
@IsString()
@MaxLength(64)
imageId?: string | null;
}
export class SettledItemDto extends OpenTabItemDto {
@ApiPropertyOptional({ example: '12', description: 'Missing in events from older app versions' })
@IsOptional()
@IsString()
@MaxLength(100)
productId?: string;
}
export class TabEventDto {
@ApiProperty({ description: 'Generated by the terminal; re-sending the same id stores the event once' })
@IsUUID()
id: string;
@ApiProperty({ enum: TAB_EVENT_TYPES })
@IsIn(TAB_EVENT_TYPES)
type: TabEventType;
@ApiPropertyOptional({ example: 'local-tab-uuid', description: 'Every type except stock_set' })
@ValidateIf(isAboutTab)
@IsString()
@IsNotEmpty()
@MaxLength(100)
tabId?: string;
@ApiPropertyOptional({ example: 'Jan', description: 'Every type except stock_set; tab_renamed: the new name' })
@ValidateIf(isAboutTab)
@IsString()
@MaxLength(200)
customerName?: string;
@ApiProperty({ example: '2026-10-24T21:41:03.120Z', description: 'When it happened on the terminal' })
@IsISO8601({ strict: true })
occurredAt: string;
@ApiPropertyOptional({ example: '2026-10-24T20:00:00.000Z', description: 'tab_opened' })
@IsOptional()
@IsISO8601({ strict: true })
openedAt?: string;
@ApiPropertyOptional({ description: "line_added / line_removed: the terminal's line; missing from older app versions" })
@IsOptional()
@IsString()
@MaxLength(100)
lineId?: string;
@ApiPropertyOptional({ example: '12', description: 'line_added / line_removed (missing from older app versions), stock_set' })
@ValidateIf((event: TabEventDto) => isStockSet(event) || isProductEvent(event) || event.productId !== undefined)
@IsString()
@IsNotEmpty()
@MaxLength(100)
productId?: string;
@ApiPropertyOptional({ example: 'Cola', description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsString()
@IsNotEmpty()
@MaxLength(200)
productName?: string;
@ApiPropertyOptional({
example: 'order-uuid',
nullable: true,
description: 'line_added: the order the drinks are part of. line_removed: the order they are taken off, null for '
+ 'drinks without one. Missing from older app versions: line_removed then takes the latest drinks of the line',
})
@IsOptional()
@IsString()
@MaxLength(100)
orderId?: string | null;
@ApiPropertyOptional({ example: 250, description: 'line_added / line_removed' })
@ValidateIf(isLine)
@IsInt()
@Min(0)
unitPriceInCents?: number;
@ApiPropertyOptional({
example: 2,
description: 'line_added / line_removed: how many were added or removed, at least 1. '
+ 'stock_set: the counted stock (≥ 0), null to stop tracking',
})
@ValidateIf((event: TabEventDto) => isLine(event) || (isStockSet(event) && event.quantity !== null))
@IsInt()
@Min(0)
quantity?: number | null;
@ApiPropertyOptional({ example: 2450, description: 'settled: the amount that was settled' })
@ValidateIf(isSettlement)
@IsInt()
@Min(0)
amountInCents?: number;
@ApiPropertyOptional({ enum: SETTLEMENT_PAYMENT_METHODS, description: 'settled' })
@ValidateIf(isSettlement)
@IsIn(SETTLEMENT_PAYMENT_METHODS)
paymentMethod?: string;
@ApiPropertyOptional({ description: 'settled: the remote close command that caused it, if any' })
@IsOptional()
@IsUUID()
commandId?: string;
@ApiPropertyOptional({ type: ProductEventFieldsDto, description: 'product_created / product_updated' })
@ValidateIf(carriesProduct)
@ValidateNested()
@Type(() => ProductEventFieldsDto)
product?: ProductEventFieldsDto;
@ApiPropertyOptional({ type: [SettledItemDto], description: 'settled: the lines that were settled' })
@IsOptional()
@IsArray()
@ArrayMaxSize(500)
@ValidateNested({ each: true })
@Type(() => SettledItemDto)
items?: SettledItemDto[];
}
-54
View File
@@ -1,54 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
import { describe } from './tab-events';
export type Rejected = { id: string | null; reason: string };
/**
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
* instead of failing the sync (and with it the open tabs and sales).
*/
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
const accepted: T[] = [];
const rejected: Rejected[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'must be an object' });
continue;
}
const entry = plainToInstance(cls, value);
const errors = validateSync(entry, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
accepted.push(entry);
}
return { accepted, rejected };
}
export function parseProducts(raw: unknown[]) {
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
// A duplicated id would make the upserts fight; the last one reported wins
const byId = new Map(accepted.map((product) => [product.id, product]));
return {
products: [...byId.values()].map((p) => ({
id: p.id,
name: p.name.trim(),
category: p.category?.trim() || null,
priceInCents: p.priceInCents,
stock: p.stock ?? null,
})),
rejected,
};
}
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
export function parseStockResults(raw: unknown[]) {
return parseEach(StockAdjustmentResultDto, raw);
}
-139
View File
@@ -1,139 +0,0 @@
import { Prisma } from '@prisma/client';
import { parseTabEvents, tabEventRow } from './tab-events';
const now = new Date('2026-10-24T22:00:00.000Z');
const lineAdded = {
id: '6f1c2c0e-6a2b-4c1e-9a4c-1f2a3b4c5d6e',
type: 'line_added',
tabId: 'tab-1',
customerName: 'Jan',
occurredAt: '2026-10-24T21:41:03.120Z',
productName: 'Cola',
unitPriceInCents: 250,
quantity: 2,
};
const settled = {
id: '7a2d3e4f-5b6c-4d7e-8f9a-0b1c2d3e4f5a',
type: 'settled',
tabId: 'tab-1',
customerName: 'Jan',
occurredAt: '2026-10-24T21:55:00.000Z',
amountInCents: 500,
paymentMethod: 'payconiq',
items: [{ productName: 'Cola', quantity: 2, unitPriceInCents: 250 }],
};
describe('parseTabEvents', () => {
it('accepts valid line and settlement events', () => {
const { accepted, rejected } = parseTabEvents([lineAdded, settled]);
expect(accepted.map((e) => e.id)).toEqual([lineAdded.id, settled.id]);
expect(rejected).toEqual([]);
});
it('accepts tab and stock events, and a stock count of zero', () => {
const base = { occurredAt: '2026-10-24T21:00:00.000Z' };
const { accepted, rejected } = parseTabEvents([
{ ...base, id: '1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e', type: 'tab_opened', tabId: 't1', customerName: 'Jan', openedAt: base.occurredAt },
{ ...base, id: '2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f', type: 'tab_renamed', tabId: 't1', customerName: 'Jan P.' },
{ ...base, id: '3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f7a', type: 'stock_set', productId: '12', quantity: 0 },
{ ...base, id: '4e5f6a7b-8c9d-4e0f-9a2b-3c4d5e6f7a8b', type: 'stock_set', productId: '12', quantity: null },
]);
expect(rejected).toEqual([]);
expect(accepted.map((e) => e.type)).toEqual(['tab_opened', 'tab_renamed', 'stock_set', 'stock_set']);
});
it('rejects a stock count without a product or below zero, and a tab event without a tab', () => {
const base = { occurredAt: '2026-10-24T21:00:00.000Z' };
const { accepted, rejected } = parseTabEvents([
{ ...base, id: '5f6a7b8c-9d0e-4f1a-8b3c-4d5e6f7a8b9c', type: 'stock_set', quantity: 3 },
{ ...base, id: '6a7b8c9d-0e1f-4a2b-9c4d-5e6f7a8b9c0d', type: 'stock_set', productId: '12', quantity: -1 },
{ ...base, id: '7b8c9d0e-1f2a-4b3c-8d5e-6f7a8b9c0d1e', type: 'tab_opened', customerName: 'Jan' },
]);
expect(accepted).toEqual([]);
expect(rejected).toHaveLength(3);
});
it('stores a stock count without a tab', () => {
const [event] = parseTabEvents([
{ id: '8c9d0e1f-2a3b-4c4d-9e6f-7a8b9c0d1e2f', type: 'stock_set', productId: '12', quantity: 30, occurredAt: '2026-10-24T21:00:00.000Z' },
]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({ type: 'stock_set', tabId: null, productId: '12', stockSetTo: 30 });
});
it('rejects invalid events one by one, keeping their id', () => {
const { accepted, rejected } = parseTabEvents([
lineAdded,
{ ...lineAdded, id: '8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b', quantity: 0 },
{ ...settled, id: '9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c', paymentMethod: 'bitcoin' },
{ ...lineAdded, id: 'not-a-uuid' },
'nonsense',
]);
expect(accepted.map((e) => e.id)).toEqual([lineAdded.id]);
expect(rejected.map((r) => r.id)).toEqual([
'8b3e4f5a-6c7d-4e8f-9a0b-1c2d3e4f5a6b',
'9c4f5a6b-7d8e-4f9a-0b1c-2d3e4f5a6b7c',
'not-a-uuid',
null,
]);
expect(rejected[0].reason).toMatch(/quantity/);
});
it('ignores fields it does not know, so newer app versions keep working', () => {
const { accepted } = parseTabEvents([{ ...lineAdded, discountInCents: 50 }]);
expect(accepted).toHaveLength(1);
expect(accepted[0]).not.toHaveProperty('discountInCents');
});
it('rejects line events without a product', () => {
const { productName, ...withoutProduct } = lineAdded;
expect(parseTabEvents([withoutProduct]).rejected).toHaveLength(1);
});
});
describe('tabEventRow', () => {
it('stores removed lines as a negative quantity', () => {
const [event] = parseTabEvents([{ ...lineAdded, type: 'line_removed', quantity: 3 }]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now)).toMatchObject({
venueId: 'v1',
deviceId: 'd1',
type: 'line_removed',
productName: 'Cola',
unitPriceInCents: 250,
quantityDelta: -3,
occurredAt: new Date(lineAdded.occurredAt),
});
});
it('stores a settlement with its amount, payment method and lines', () => {
const [event] = parseTabEvents([settled]).accepted;
const row = tabEventRow(event, 'v1', 'd1', now);
expect(row).toMatchObject({ type: 'settled', amountInCents: 500, paymentMethod: 'payconiq', commandId: null });
expect(row.items).toEqual([{ productId: null, productName: 'Cola', quantity: 2, unitPriceInCents: 250 }]);
expect(row.quantityDelta).toBeUndefined();
});
it('stores JSON null for a settlement without lines', () => {
const { items, ...withoutItems } = settled;
const [event] = parseTabEvents([withoutItems]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now).items).toBe(Prisma.JsonNull);
});
it('uses the receive time when the terminal clock runs ahead', () => {
const [event] = parseTabEvents([{ ...lineAdded, occurredAt: '2026-10-25T03:00:00.000Z' }]).accepted;
expect(tabEventRow(event, 'v1', 'd1', now).occurredAt).toEqual(now);
});
});
-106
View File
@@ -1,106 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { validateSync, ValidationError } from 'class-validator';
import { Prisma } from '@prisma/client';
import { isProductEvent, TabEventDto } from './dto/tab-event.dto';
// A terminal clock that runs ahead must not put sales in the future
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
export type RejectedTabEvent = { id: string | null; reason: string };
export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejected: RejectedTabEvent[] } {
const accepted: TabEventDto[] = [];
const rejected: RejectedTabEvent[] = [];
for (const value of raw) {
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
rejected.push({ id, reason: 'event must be an object' });
continue;
}
const event = plainToInstance(TabEventDto, value);
// Unknown fields are ignored so a newer app version adding one never loses sales
const errors = validateSync(event, { whitelist: true });
if (errors.length > 0) {
rejected.push({ id, reason: describe(errors) });
continue;
}
// Validated here: 0 is a valid stock count, but not a valid number of drinks
if ((event.type === 'line_added' || event.type === 'line_removed') && event.quantity! < 1) {
rejected.push({ id, reason: 'quantity must not be less than 1' });
continue;
}
if (event.type === 'product_created' && (!event.product?.name || event.product.priceInCents === undefined)) {
rejected.push({ id, reason: 'a new product needs a name and a price' });
continue;
}
accepted.push(event);
}
return { accepted, rejected };
}
/** When the event happened, as stored and applied: a terminal clock running ahead is capped at [now]. */
export function eventTime(event: TabEventDto, now: Date): Date {
const occurredAt = new Date(event.occurredAt);
return occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt;
}
export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput {
const base = {
id: event.id,
venueId,
deviceId,
tabId: event.tabId ?? null,
customerName: event.customerName ?? null,
type: event.type,
occurredAt: eventTime(event, now),
receivedAt: now,
};
if (event.type === 'stock_set') {
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
}
if (isProductEvent(event)) {
return {
...base,
productId: event.productId,
productData: event.product ? ({ ...event.product } as Prisma.InputJsonValue) : Prisma.JsonNull,
};
}
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
return base;
}
if (event.type === 'settled') {
return {
...base,
amountInCents: event.amountInCents,
paymentMethod: event.paymentMethod,
commandId: event.commandId ?? null,
items: event.items
? event.items.map(({ productId, productName, quantity, unitPriceInCents }) => (
{ productId: productId ?? null, productName, quantity, unitPriceInCents }))
: Prisma.JsonNull,
};
}
return {
...base,
lineId: event.lineId ?? null,
productId: event.productId ?? null,
productName: event.productName,
unitPriceInCents: event.unitPriceInCents,
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
orderId: event.orderId ?? null,
};
}
export function describe(errors: ValidationError[]): string {
return errors
.flatMap((error) => [
...Object.values(error.constraints ?? {}),
...(error.children?.length ? [`${error.property}: ${describe(error.children)}`] : []),
])
.join('; ');
}
@@ -1,51 +0,0 @@
import { CashPaymentNotifier } from './cash-payment-notifier';
import { createFakePrisma } from '../testing/fake-prisma';
const settlement = (paymentMethod: string, id = 's1') => ({
id, tabId: 't1', customerName: 'Jan', settledAt: new Date('2026-10-27T20:15:00Z'), paymentMethod, amountInCents: 500,
commandId: null,
lines: [{ id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, units: [] }],
});
function setup(emails: string[]) {
process.env.RESEND_API_KEY = 'test-key';
const prisma = createFakePrisma();
prisma.venue.rows.push({ id: 'v1', name: 'Café De Kroon', cashPaymentEmails: emails });
const render = jest.fn().mockResolvedValue({ subject: 'Cash', html: '<p>Cash</p>' });
const notifier = new CashPaymentNotifier(prisma as any, { render } as any);
const fetchMock = jest.fn().mockResolvedValue({ ok: true, text: async () => '' });
global.fetch = fetchMock as any;
return { notifier, render, fetchMock };
}
afterEach(() => {
delete process.env.RESEND_API_KEY;
});
describe('CashPaymentNotifier', () => {
it('emails every address about a cash payment, once per settlement', async () => {
const { notifier, render, fetchMock } = setup(['a@bar.be', 'b@bar.be']);
await notifier.send('v1', [settlement('cash')], 'Bar tablet');
expect(render).toHaveBeenCalledWith('cash-payment', expect.objectContaining({
customerName: 'Jan', venueName: 'Café De Kroon', settledBy: 'Bar tablet', hasItems: true,
items: [expect.objectContaining({ quantity: 2, productName: 'Cola' })],
}));
const [url, request] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.resend.com/emails/batch');
expect(request.headers['Idempotency-Key']).toBe('cash-payment-s1');
expect(JSON.parse(request.body).map((m: { to: string }) => m.to)).toEqual(['a@bar.be', 'b@bar.be']);
});
it('sends nothing for other payment methods or without addresses', async () => {
const withAddress = setup(['a@bar.be']);
withAddress.notifier.notify('v1', [settlement('payconiq')], 'Bar tablet');
await new Promise((resolve) => setImmediate(resolve));
expect(withAddress.fetchMock).not.toHaveBeenCalled();
const withoutAddress = setup([]);
await withoutAddress.notifier.send('v1', [settlement('cash')], 'Bar tablet');
expect(withoutAddress.fetchMock).not.toHaveBeenCalled();
});
});
+14 -33
View File
@@ -1,11 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { EmailTemplatesService } from './email-templates.service';
import { RecordedSettlement } from '../tabs/venue-ledger';
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
const formatEuro = (cents: number) => euro.format(cents / 100);
import { formatEuro, Resend } from './resend';
import { SettlementDraft } from '../ledger/tab-state';
const dateTime = new Intl.DateTimeFormat('nl-BE', {
timeZone: 'Europe/Brussels', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
@@ -18,16 +15,15 @@ const dateTime = new Intl.DateTimeFormat('nl-BE', {
@Injectable()
export class CashPaymentNotifier {
private readonly logger = new Logger(CashPaymentNotifier.name);
private readonly resendApiKey = process.env.RESEND_API_KEY || '';
private readonly fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
constructor(
private prisma: PrismaService,
private emailTemplates: EmailTemplatesService,
private templates: EmailTemplatesService,
private resend: Resend,
) {}
/** Sends in the background; [settlements] may hold other payment methods, which are skipped. */
notify(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
notify(venueId: string, settlements: SettlementDraft[], settledBy: string) {
const cash = settlements.filter((s) => s.paymentMethod === 'cash');
if (cash.length === 0) return;
this.send(venueId, cash, settledBy).catch((error) => {
@@ -35,22 +31,18 @@ export class CashPaymentNotifier {
});
}
async send(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
const venue = await this.prisma.venue.findUnique({
where: { id: venueId },
select: { name: true, cashPaymentEmails: true },
});
const recipients = venue?.cashPaymentEmails ?? [];
if (recipients.length === 0) return;
if (!this.resendApiKey) {
async send(venueId: string, settlements: SettlementDraft[], settledBy: string) {
const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { name: true, cashPaymentEmails: true } });
if (!venue || venue.cashPaymentEmails.length === 0) return;
if (!this.resend.configured) {
this.logger.warn(`Not emailing ${settlements.length} cash payment(s) of venue ${venueId}: RESEND_API_KEY is not set`);
return;
}
for (const settlement of settlements) {
const items = settlement.lines.filter((line) => line.quantity > 0);
const email = await this.emailTemplates.render('cash-payment', {
venueName: venue!.name,
const email = await this.templates.render('cash-payment', {
venueName: venue.name,
customerName: settlement.customerName,
amount: formatEuro(settlement.amountInCents),
settledAt: dateTime.format(settlement.settledAt),
@@ -62,20 +54,9 @@ export class CashPaymentNotifier {
lineTotal: formatEuro(line.quantity * line.unitPriceInCents),
})),
});
const res = await fetch('https://api.resend.com/emails/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
// One settlement is emailed once, even when this request is retried
'Idempotency-Key': `cash-payment-${settlement.id}`,
},
body: JSON.stringify(recipients.map((to) => ({ from: this.fromEmail, to, ...email }))),
});
if (!res.ok) {
this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${await res.text()}`);
}
// One settlement is emailed once, even when this is retried
const error = await this.resend.sendBatch(venue.cashPaymentEmails.map((to) => ({ to, ...email })), `cash-payment-${settlement.id}`);
if (error) this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${error}`);
}
}
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { EmailTemplatesService } from './email-templates.service';
import { CashPaymentNotifier } from './cash-payment-notifier';
import { Resend } from './resend';
@Global()
@Module({
providers: [EmailTemplatesService, CashPaymentNotifier],
exports: [EmailTemplatesService, CashPaymentNotifier],
providers: [EmailTemplatesService, CashPaymentNotifier, Resend],
exports: [EmailTemplatesService, CashPaymentNotifier, Resend],
})
export class EmailModule {}
+45
View File
@@ -0,0 +1,45 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { RenderedEmail } from './email-templates.service';
export type OutgoingEmail = RenderedEmail & { to: string };
/** Sends email through Resend (RESEND_API_KEY, RESEND_FROM_EMAIL). */
@Injectable()
export class Resend {
private readonly apiKey = process.env.RESEND_API_KEY || '';
private readonly from = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
/** Whether sending is set up, without sending anything. */
get configured(): boolean {
return this.apiKey !== '';
}
/** Throws the error the dashboard shows when sending is not set up. */
ensureConfigured() {
if (!this.configured) {
throw new BadRequestException({ code: 'resend_not_configured', message: 'Email is not set up: set RESEND_API_KEY' });
}
}
/**
* Sends up to 100 emails in one request. Resend ignores a request with the same [idempotencyKey] for 24 hours, so a
* retry never emails twice. Returns Resend's error text, or null when sent.
*/
async sendBatch(emails: OutgoingEmail[], idempotencyKey?: string): Promise<string | null> {
const res = await fetch('https://api.resend.com/emails/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
},
body: JSON.stringify(emails.map((email) => ({ from: this.from, ...email }))),
});
return res.ok ? null : await res.text();
}
}
// Belgian notation, as on the dashboard: € 25,00
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
export const formatEuro = (cents: number) => euro.format(cents / 100);

Some files were not shown because too many files have changed in this diff Show More