diff --git a/.dockerignore b/.dockerignore index cb31493..26c3baf 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example index 7488d03..76a7099 100644 --- a/.env.example +++ b/.env.example @@ -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 -NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1" \ No newline at end of file +# Dashboard (apps/dashboard/.env.local) +NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1" diff --git a/.env.prod.example b/.env.prod.example deleted file mode 100644 index 287996a..0000000 --- a/.env.prod.example +++ /dev/null @@ -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 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a8f9e6..fbce9fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index 2b07097..fb00280 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] \ No newline at end of file +CMD ["node", "apps/dashboard/server.js"] diff --git a/README.md b/README.md index 87f1cf5..da6f4de 100644 --- a/README.md +++ b/README.md @@ -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 ` (`--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 - -# Or step by step: -npx ts-node device-sync.ts register "Bar Terminal 1" ABC123 -npx ts-node device-sync.ts sync -npx ts-node device-sync.ts ack 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. \ No newline at end of file +- [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) diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..afdebdf --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,2 @@ +src/generated/ +data/ diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json index bc097ce..7645e67 100644 --- a/apps/api/nest-cli.json +++ b/apps/api/nest-cli.json @@ -3,6 +3,7 @@ "collection": "@nestjs/schematics", "sourceRoot": "src", "compilerOptions": { - "deleteOutDir": true + "deleteOutDir": true, + "tsConfigPath": "tsconfig.build.json" } } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index ab631bf..18309e7 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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,15 +73,11 @@ "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$": "/../../../packages/shared/src" } } -} \ No newline at end of file +} diff --git a/apps/api/prisma.config.ts b/apps/api/prisma.config.ts new file mode 100644 index 0000000..083ba08 --- /dev/null +++ b/apps/api/prisma.config.ts @@ -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, + }, +}); diff --git a/apps/api/prisma/deploy.js b/apps/api/prisma/deploy.js deleted file mode 100644 index d8890d2..0000000 --- a/apps/api/prisma/deploy.js +++ /dev/null @@ -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); -}); diff --git a/apps/api/prisma/migrations/20260920072747_init/migration.sql b/apps/api/prisma/migrations/20260920072747_init/migration.sql deleted file mode 100644 index 1c8b10a..0000000 --- a/apps/api/prisma/migrations/20260920072747_init/migration.sql +++ /dev/null @@ -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; diff --git a/apps/api/prisma/migrations/20260920101506_planned_tabs/migration.sql b/apps/api/prisma/migrations/20260920101506_planned_tabs/migration.sql deleted file mode 100644 index 09df58d..0000000 --- a/apps/api/prisma/migrations/20260920101506_planned_tabs/migration.sql +++ /dev/null @@ -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; diff --git a/apps/api/prisma/migrations/20260926201526_add_reminders/migration.sql b/apps/api/prisma/migrations/20260926201526_add_reminders/migration.sql deleted file mode 100644 index 53d3a15..0000000 --- a/apps/api/prisma/migrations/20260926201526_add_reminders/migration.sql +++ /dev/null @@ -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; diff --git a/apps/api/prisma/migrations/20261008120000_planned_tab_delivered/migration.sql b/apps/api/prisma/migrations/20261008120000_planned_tab_delivered/migration.sql deleted file mode 100644 index 42bad2b..0000000 --- a/apps/api/prisma/migrations/20261008120000_planned_tab_delivered/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "PlannedTab" ADD COLUMN "deliveredAt" TIMESTAMP(3); diff --git a/apps/api/prisma/migrations/20261009120000_tab_snapshot_items/migration.sql b/apps/api/prisma/migrations/20261009120000_tab_snapshot_items/migration.sql deleted file mode 100644 index a7e1ebf..0000000 --- a/apps/api/prisma/migrations/20261009120000_tab_snapshot_items/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB; diff --git a/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql b/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql deleted file mode 100644 index 786ced1..0000000 --- a/apps/api/prisma/migrations/20261019120000_customer_name_key/migration.sql +++ /dev/null @@ -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"; diff --git a/apps/api/prisma/migrations/20261019212336_tab_events/migration.sql b/apps/api/prisma/migrations/20261019212336_tab_events/migration.sql deleted file mode 100644 index 667c5fd..0000000 --- a/apps/api/prisma/migrations/20261019212336_tab_events/migration.sql +++ /dev/null @@ -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; - diff --git a/apps/api/prisma/migrations/20261020124522_stock/migration.sql b/apps/api/prisma/migrations/20261020124522_stock/migration.sql deleted file mode 100644 index c6c9d60..0000000 --- a/apps/api/prisma/migrations/20261020124522_stock/migration.sql +++ /dev/null @@ -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; diff --git a/apps/api/prisma/migrations/20261020141836_server_tabs_and_stock/migration.sql b/apps/api/prisma/migrations/20261020141836_server_tabs_and_stock/migration.sql deleted file mode 100644 index 8936c3b..0000000 --- a/apps/api/prisma/migrations/20261020141836_server_tabs_and_stock/migration.sql +++ /dev/null @@ -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; - diff --git a/apps/api/prisma/migrations/20261020215540_products_on_dashboard/migration.sql b/apps/api/prisma/migrations/20261020215540_products_on_dashboard/migration.sql deleted file mode 100644 index 5b1ceb9..0000000 --- a/apps/api/prisma/migrations/20261020215540_products_on_dashboard/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3); - diff --git a/apps/api/prisma/migrations/20261021104519_product_images_and_events/migration.sql b/apps/api/prisma/migrations/20261021104519_product_images_and_events/migration.sql deleted file mode 100644 index b5b0a7f..0000000 --- a/apps/api/prisma/migrations/20261021104519_product_images_and_events/migration.sql +++ /dev/null @@ -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'; diff --git a/apps/api/prisma/migrations/20261021225808_sold_unit_view/migration.sql b/apps/api/prisma/migrations/20261021225808_sold_unit_view/migration.sql deleted file mode 100644 index 35d12d2..0000000 --- a/apps/api/prisma/migrations/20261021225808_sold_unit_view/migration.sql +++ /dev/null @@ -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; diff --git a/apps/api/prisma/migrations/20261021233219_drop_tab_snapshots/migration.sql b/apps/api/prisma/migrations/20261021233219_drop_tab_snapshots/migration.sql deleted file mode 100644 index 5275970..0000000 --- a/apps/api/prisma/migrations/20261021233219_drop_tab_snapshots/migration.sql +++ /dev/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"; diff --git a/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql b/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql deleted file mode 100644 index cdb4f8b..0000000 --- a/apps/api/prisma/migrations/20261026120000_sessions_revoked_at/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3); diff --git a/apps/api/prisma/migrations/20261027120000_product_buy_price/migration.sql b/apps/api/prisma/migrations/20261027120000_product_buy_price/migration.sql deleted file mode 100644 index 8720462..0000000 --- a/apps/api/prisma/migrations/20261027120000_product_buy_price/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER; diff --git a/apps/api/prisma/migrations/20261027130000_product_desired_stock/migration.sql b/apps/api/prisma/migrations/20261027130000_product_desired_stock/migration.sql deleted file mode 100644 index 4780469..0000000 --- a/apps/api/prisma/migrations/20261027130000_product_desired_stock/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER; diff --git a/apps/api/prisma/migrations/20261027140000_venue_cash_payment_emails/migration.sql b/apps/api/prisma/migrations/20261027140000_venue_cash_payment_emails/migration.sql deleted file mode 100644 index 9a0aa43..0000000 --- a/apps/api/prisma/migrations/20261027140000_venue_cash_payment_emails/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "Venue" ADD COLUMN "cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/apps/api/prisma/migrations/20261028000000_init/migration.sql b/apps/api/prisma/migrations/20261028000000_init/migration.sql new file mode 100644 index 0000000..12efd25 --- /dev/null +++ b/apps/api/prisma/migrations/20261028000000_init/migration.sql @@ -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)); diff --git a/apps/api/prisma/migrations/20261028120000_free_drinks_customer/migration.sql b/apps/api/prisma/migrations/20261028120000_free_drinks_customer/migration.sql deleted file mode 100644 index 3172473..0000000 --- a/apps/api/prisma/migrations/20261028120000_free_drinks_customer/migration.sql +++ /dev/null @@ -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; diff --git a/apps/api/prisma/migrations/20261028130000_tab_event_order_id/migration.sql b/apps/api/prisma/migrations/20261028130000_tab_event_order_id/migration.sql deleted file mode 100644 index a723c08..0000000 --- a/apps/api/prisma/migrations/20261028130000_tab_event_order_id/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "TabEvent" ADD COLUMN "orderId" TEXT; diff --git a/apps/api/prisma/migrations/migration_lock.toml b/apps/api/prisma/migrations/migration_lock.toml index fbffa92..044d57c 100644 --- a/apps/api/prisma/migrations/migration_lock.toml +++ b/apps/api/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index cf1e49b..a75242c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -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()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - devices Device[] - clientUsers ClientUser[] - remoteCommands RemoteCommand[] - auditLogs AuditLog[] - regCodes RegistrationCode[] - plannedTabs PlannedTab[] - tabReminders TabReminder[] - tabEvents TabEvent[] - products Product[] - stockAdjustments StockAdjustment[] - 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 + id String @id @default(uuid()) @db.Uuid + name String + // 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[] + registrationCodes RegistrationCode[] + customers Customer[] + tabs Tab[] + settlements Settlement[] + units TabUnit[] + products Product[] + stockMovements StockMovement[] + tabEvents TabEvent[] + reminders Reminder[] + auditLog AuditLog[] } -model Device { - id String @id @default(uuid()) - // A venue can have several terminals - venueId String - name String - tokenHash String @unique - tokenPrefix String - tokenExpiresAt DateTime - lastSeenAt DateTime @default(now()) - revokedAt DateTime? - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - remoteCommands RemoteCommand[] - 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()) +// 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? + sessionsRevokedAt 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) + refreshTokens RefreshToken[] + settlements Settlement[] + stockMovements StockMovement[] @@index([venueId]) } -model RegistrationCode { - id String @id @default(uuid()) - venueId String - code String @unique - expiresAt DateTime - usedAt DateTime? - createdAt DateTime @default(now()) - - venue Venue @relation(fields: [venueId], references: [id]) - - @@index([venueId]) -} - -// 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 + id String @id @default(uuid()) @db.Uuid + userId String @db.Uuid tokenHash String @unique - expiresAt DateTime - revokedAt DateTime? - createdAt DateTime @default(now()) + 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]) } -model PlannedTab { - id String @id @default(uuid()) - venueId String - customerName String - // Lowercased, whitespace-collapsed name: customers are matched on this everywhere - customerNameKey String - email String? - position Int @default(0) - createdAt DateTime @default(now()) - // Set once the terminal reports an open tab with this name; it is then no longer sent to devices - deliveredAt DateTime? +// A bar terminal, paired with a registration code +model Device { + id String @id @default(uuid()) @db.Uuid + venueId String @db.Uuid + name String + tokenHash String @unique + tokenPrefix String + 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]) + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + tabEvents TabEvent[] + settlements Settlement[] + + @@index([venueId, revokedAt]) +} + +model RegistrationCode { + id String @id @default(uuid()) @db.Uuid + venueId String @db.Uuid + code String @unique + expiresAt DateTime @db.Timestamptz(3) + usedAt DateTime? @db.Timestamptz(3) + createdAt DateTime @default(now()) @db.Timestamptz(3) + + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) - @@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") +// 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 + nameKey String + email String? + position Int @default(0) + // 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[] - @@index([venueId, sentAt]) - @@index([venueId, tabId]) + @@unique([venueId, nameKey]) } -// 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. +// 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 { - venueId String - // Generated by the terminal that opened it - id String + // 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 + 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) - // [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }] - lines Json - updatedAt DateTime @updatedAt + updatedAt DateTime @db.Timestamptz(3) - venue Venue @relation(fields: [venueId], references: [id]) + venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade) + units TabUnit[] - @@id([venueId, id]) + @@index([venueId]) } -// 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 - sequence Int @unique @default(autoincrement()) - venueId String - tabId String - customerName String - settledAt DateTime - // cash | payconiq | payment_pending | other - paymentMethod String - amountInCents Int - // The settled lines, in the shape of Tab.lines with each line's settled and removed units - lines Json + id String @id @db.Uuid + sequence Int @unique @default(autoincrement()) + venueId String @db.Uuid + // The tab may be deleted since + tabId String @db.Uuid + customerName String + settledAt DateTime @db.Timestamptz(3) + paymentMethod PaymentMethod + // What was charged; the units are what the server had on the tab + amountInCents Int // 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 { + // `:`, 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 .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]) +} diff --git a/apps/api/prisma/seed.js b/apps/api/prisma/seed.js deleted file mode 100644 index c34d47f..0000000 --- a/apps/api/prisma/seed.js +++ /dev/null @@ -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()); \ No newline at end of file diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 945a97b..944229f 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -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(); + +const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 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 }, +]; + +/** 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() { - console.log('Seeding database...'); - - // 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})`); + 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); - // 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 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' } }); - 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 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: 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: { - 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 })), - }], + 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({ - 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), - }, - }); + // 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 })) }); - // Failed command - await prisma.remoteCommand.create({ - data: { - id: uuidv4(), - 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), - }, - }); + const settledAt = minutesAgo((index + 1) * 24 * 60 * 2); + const product = products[index % products.length]; + const settlement = await prisma.settlement.create({ + data: { + 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 }) }); + } - // 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('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}`); +} - 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}`); +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, + ...where, + lineId, + productId: product.id, + productName: product.name, + unitPriceInCents: product.priceInCents, + orderId: `order-${addedAt.getTime()}`, + addedAt, + })); } main() - .catch(console.error) - .finally(() => prisma.$disconnect()); \ No newline at end of file + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/api/prisma/setup.js b/apps/api/prisma/setup.js deleted file mode 100644 index 6781145..0000000 --- a/apps/api/prisma/setup.js +++ /dev/null @@ -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 "" [--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()); diff --git a/apps/api/prisma/superadmin.ts b/apps/api/prisma/superadmin.ts deleted file mode 100644 index 5bdcdf9..0000000 --- a/apps/api/prisma/superadmin.ts +++ /dev/null @@ -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 [--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(); diff --git a/apps/api/src/admin/admin.controller.ts b/apps/api/src/admin/admin.controller.ts index 34edac7..e64a39a 100644 --- a/apps/api/src/admin/admin.controller.ts +++ b/apps/api/src/admin/admin.controller.ts @@ -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 = z.output; /** 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, @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, @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, + @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, @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, @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, @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, @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, @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, @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, @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) { + return this.admin.auditLog(query); } @Get('health') - @ApiOperation({ summary: 'Database, migrations, live connections and background jobs' }) health() { return this.admin.health(); } diff --git a/apps/api/src/admin/admin.module.ts b/apps/api/src/admin/admin.module.ts index ab44c45..bd66f2f 100644 --- a/apps/api/src/admin/admin.module.ts +++ b/apps/api/src/admin/admin.module.ts @@ -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], }) diff --git a/apps/api/src/admin/admin.service.ts b/apps/api/src/admin/admin.service.ts index 4724aef..74e575c 100644 --- a/apps/api/src/admin/admin.service.ts +++ b/apps/api/src/admin/admin.service.ts @@ -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 = { - // 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,115 +38,100 @@ 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({ - orderBy: { name: 'asc' }, - select: { - id: true, - name: true, - createdAt: true, - _count: { - select: { - clientUsers: true, - devices: { where: { revokedAt: null } }, - tabs: true, - products: { where: { archivedAt: null } }, - plannedTabs: true, - settlements: true, + async listVenues(): Promise { + const [venues, lastSeen] = await Promise.all([ + this.prisma.venue.findMany({ + orderBy: { name: 'asc' }, + select: { + id: true, + name: true, + createdAt: true, + _count: { + select: { + users: true, + devices: { where: { revokedAt: null } }, + tabs: true, + products: { where: { archivedAt: null } }, + 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 }) => ({ - ...venue, - users: _count.clientUsers, - devices: _count.devices, - openTabs: _count.tabs, - products: _count.products, - plannedTabs: _count.plannedTabs, - settlements: _count.settlements, - lastSyncAt: lastSeenByVenue.get(venue.id) ?? null, - })), - }; + return venues.map(({ _count, ...venue }) => ({ + ...venue, + createdAt: venue.createdAt.toISOString(), + users: _count.users, + devices: _count.devices, + openTabs: _count.tabs, + products: _count.products, + customers: _count.customers, + settlements: _count.settlements, + 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), - }; - }), - }); - } + 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, 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 } }), - this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }), - 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.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 }); + 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.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, 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 { 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 { 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 Promise> = { + 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> = {}; 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([ - ...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 { + 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 Promise> = { 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 { + 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 } }), + ]); } } diff --git a/apps/api/src/admin/consistency.spec.ts b/apps/api/src/admin/consistency.spec.ts index e68cda8..6768abb 100644 --- a/apps/api/src/admin/consistency.spec.ts +++ b/apps/api/src/admin/consistency.spec.ts @@ -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 { - 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([]); }); }); diff --git a/apps/api/src/admin/consistency.ts b/apps/api/src/admin/consistency.ts index 6cf4516..d1a620a 100644 --- a/apps/api/src/admin/consistency.ts +++ b/apps/api/src/admin/consistency.ts @@ -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(); - 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(); + const tabsByName = new Map(); 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 = { error: 0, warning: 1, info: 2 }; + const order: Record = { error: 0, warning: 1, info: 2 }; return findings.sort((a, b) => order[a.severity] - order[b.severity]); } diff --git a/apps/api/src/admin/dto/admin.dto.ts b/apps/api/src/admin/dto/admin.dto.ts deleted file mode 100644 index 482cd00..0000000 --- a/apps/api/src/admin/dto/admin.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/api/src/admin/venue-reset.ts b/apps/api/src/admin/venue-reset.ts index b6039ed..89f51ff 100644 --- a/apps/api/src/admin/venue-reset.ts +++ b/apps/api/src/admin/venue-reset.ts @@ -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>; -/** 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 { 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 { 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 }, + 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.id, type: 'count' as const, quantity: stockValue, stockAfter: stockValue, note: 'Stock reset', userId, createdAt: now, + })), }); - await tx.product.updateMany({ - where: { venueId, stock: { not: null } }, - data: { stock: stockValue, countedAt: now }, - }); - if (tracked.length > 0) { - await tx.stockAdjustment.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, - })), - }); - } 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 } }); -} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 97b90e4..7ce2187 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} \ No newline at end of file +export class AppModule {} diff --git a/apps/api/src/audit/audit.service.ts b/apps/api/src/audit/audit.service.ts index 7c333b4..92e26c3 100644 --- a/apps/api/src/audit/audit.service.ts +++ b/apps/api/src/audit/audit.service.ts @@ -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,20 +16,23 @@ export class AuditService { constructor(private prisma: PrismaService) {} - async log(venueId: string, actorType: string, actorId: string, action: string, details?: Record) { + /** 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}`); } } -} \ No newline at end of file +} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index e681525..b1e45de 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -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, @Res({ passthrough: true }) res: Response): Promise { + 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 { + 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 }; } - @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); + @Get('me') + @Authorized() + me(@CurrentUser() user: AuthUser): Promise { + return this.auth.me(user.id); } -} \ No newline at end of file + + /** Signs out the user's other sessions; this one carries on with new tokens. */ + @Post('change-password') + @HttpCode(HttpStatus.OK) + @Authorized() + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + async changePassword( + @CurrentUser() user: AuthUser, + @Body(zod(changePasswordSchema)) dto: z.output, + @Res({ passthrough: true }) res: Response, + ): Promise { + const { me, tokens } = await this.auth.changePassword(user.id, dto.currentPassword, dto.newPassword); + setAuthCookies(res, tokens); + return me; + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index 2a51db3..c7e158e 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} \ No newline at end of file +export class AuthModule {} diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts deleted file mode 100644 index 1fce3e8..0000000 --- a/apps/api/src/auth/auth.service.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 5656fc5..f8df94b 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -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 this.issueTokens(user); + 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 } }); + /** 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; - if (!stored || stored.revokedAt || stored.expiresAt < new Date()) { - throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' }); - } + 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; - // 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); + 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 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 { + 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 { + 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.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 }; + 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 }; } - async validateDevice(deviceId: string, token: string): Promise { - 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 { + 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 { code, expiresAt }; + return { accessToken: await this.jwt.signAsync({ sub: userId }), refreshToken }; } -} \ No newline at end of file +} + +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 }; +} diff --git a/apps/api/src/auth/cookies.ts b/apps/api/src/auth/cookies.ts new file mode 100644 index 0000000..0c5f6cf --- /dev/null +++ b/apps/api/src/auth/cookies.ts @@ -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; +} diff --git a/apps/api/src/auth/dto/change-password.dto.ts b/apps/api/src/auth/dto/change-password.dto.ts deleted file mode 100644 index 2f8998c..0000000 --- a/apps/api/src/auth/dto/change-password.dto.ts +++ /dev/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; -} diff --git a/apps/api/src/auth/dto/login.dto.ts b/apps/api/src/auth/dto/login.dto.ts deleted file mode 100644 index 7b63a47..0000000 --- a/apps/api/src/auth/dto/login.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/apps/api/src/auth/dto/refresh-token.dto.ts b/apps/api/src/auth/dto/refresh-token.dto.ts deleted file mode 100644 index dd80ded..0000000 --- a/apps/api/src/auth/dto/refresh-token.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/apps/api/src/auth/guards/device-auth.guard.ts b/apps/api/src/auth/guards/device-auth.guard.ts deleted file mode 100644 index 6e78f87..0000000 --- a/apps/api/src/auth/guards/device-auth.guard.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/apps/api/src/auth/guards/jwt-auth.guard.ts b/apps/api/src/auth/guards/jwt-auth.guard.ts deleted file mode 100644 index 5c8a6ba..0000000 --- a/apps/api/src/auth/guards/jwt-auth.guard.ts +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/apps/api/src/auth/strategies/device.strategy.ts b/apps/api/src/auth/strategies/device.strategy.ts deleted file mode 100644 index 24fca7e..0000000 --- a/apps/api/src/auth/strategies/device.strategy.ts +++ /dev/null @@ -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 { - 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; - } -} \ No newline at end of file diff --git a/apps/api/src/auth/strategies/jwt.strategy.spec.ts b/apps/api/src/auth/strategies/jwt.strategy.spec.ts deleted file mode 100644 index 0ab8c4b..0000000 --- a/apps/api/src/auth/strategies/jwt.strategy.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts deleted file mode 100644 index 7f42f56..0000000 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ /dev/null @@ -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); -} diff --git a/apps/api/src/auth/user-auth.guard.ts b/apps/api/src/auth/user-auth.guard.ts new file mode 100644 index 0000000..240618b --- /dev/null +++ b/apps/api/src/auth/user-auth.guard.ts @@ -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 { + 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; + } +} diff --git a/apps/api/src/cli/setup.ts b/apps/api/src/cli/setup.ts new file mode 100644 index 0000000..e202a6b --- /dev/null +++ b/apps/api/src/cli/setup.ts @@ -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 "" + * (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 "" '); + } + 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; +}); diff --git a/apps/api/src/cli/superadmin.ts b/apps/api/src/cli/superadmin.ts new file mode 100644 index 0000000..165a770 --- /dev/null +++ b/apps/api/src/cli/superadmin.ts @@ -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 makes an existing user a superadmin + * node dist/cli/superadmin.js --create "" adds a new superadmin + * node dist/cli/superadmin.js --revoke 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 [--create "" | --revoke ]'); + 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; +}); diff --git a/apps/api/src/commands/commands.controller.ts b/apps/api/src/commands/commands.controller.ts deleted file mode 100644 index 2d3c5ac..0000000 --- a/apps/api/src/commands/commands.controller.ts +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/apps/api/src/commands/commands.module.ts b/apps/api/src/commands/commands.module.ts deleted file mode 100644 index 82f1c82..0000000 --- a/apps/api/src/commands/commands.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/apps/api/src/commands/commands.service.spec.ts b/apps/api/src/commands/commands.service.spec.ts deleted file mode 100644 index 7cd2ef5..0000000 --- a/apps/api/src/commands/commands.service.spec.ts +++ /dev/null @@ -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' } }); - }); -}); diff --git a/apps/api/src/commands/commands.service.ts b/apps/api/src/commands/commands.service.ts deleted file mode 100644 index 4759573..0000000 --- a/apps/api/src/commands/commands.service.ts +++ /dev/null @@ -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, - }; - } -} \ No newline at end of file diff --git a/apps/api/src/commands/dto/create-command.dto.ts b/apps/api/src/commands/dto/create-command.dto.ts deleted file mode 100644 index 9d09944..0000000 --- a/apps/api/src/commands/dto/create-command.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/apps/api/src/common/auth.ts b/apps/api/src/common/auth.ts new file mode 100644 index 0000000..95a3aa2 --- /dev/null +++ b/apps/api/src/common/auth.ts @@ -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(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); diff --git a/apps/api/src/common/decorators/index.ts b/apps/api/src/common/decorators/index.ts deleted file mode 100644 index 5bb6b34..0000000 --- a/apps/api/src/common/decorators/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Roles, ROLES_KEY } from './roles.decorator'; \ No newline at end of file diff --git a/apps/api/src/common/decorators/roles.decorator.ts b/apps/api/src/common/decorators/roles.decorator.ts deleted file mode 100644 index e4a5641..0000000 --- a/apps/api/src/common/decorators/roles.decorator.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { SetMetadata } from '@nestjs/common'; - -export const ROLES_KEY = 'roles'; -export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); \ No newline at end of file diff --git a/apps/api/src/common/guards/roles.guard.spec.ts b/apps/api/src/common/guards/roles.guard.spec.ts deleted file mode 100644 index 9f872e7..0000000 --- a/apps/api/src/common/guards/roles.guard.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/api/src/common/guards/roles.guard.ts b/apps/api/src/common/guards/roles.guard.ts deleted file mode 100644 index 7e09272..0000000 --- a/apps/api/src/common/guards/roles.guard.ts +++ /dev/null @@ -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(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); - } -} \ No newline at end of file diff --git a/apps/api/src/common/guards/venue-access.guard.spec.ts b/apps/api/src/common/guards/venue-access.guard.spec.ts deleted file mode 100644 index 98cc471..0000000 --- a/apps/api/src/common/guards/venue-access.guard.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { ExecutionContext, ForbiddenException } from '@nestjs/common'; -import { VenueAccessGuard } from './venue-access.guard'; - -function contextFor(params: Record, 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); - }); -}); diff --git a/apps/api/src/common/guards/venue-access.guard.ts b/apps/api/src/common/guards/venue-access.guard.ts deleted file mode 100644 index 8987f06..0000000 --- a/apps/api/src/common/guards/venue-access.guard.ts +++ /dev/null @@ -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; - } -} diff --git a/apps/api/src/common/filters/http-exception.filter.ts b/apps/api/src/common/http-exception.filter.ts similarity index 100% rename from apps/api/src/common/filters/http-exception.filter.ts rename to apps/api/src/common/http-exception.filter.ts diff --git a/apps/api/src/common/interceptors/logging.interceptor.ts b/apps/api/src/common/interceptors/logging.interceptor.ts deleted file mode 100644 index fc512ed..0000000 --- a/apps/api/src/common/interceptors/logging.interceptor.ts +++ /dev/null @@ -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 { - 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`); - }), - ); - } -} \ No newline at end of file diff --git a/apps/api/src/common/logging.interceptor.ts b/apps/api/src/common/logging.interceptor.ts new file mode 100644 index 0000000..d1f07c2 --- /dev/null +++ b/apps/api/src/common/logging.interceptor.ts @@ -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 { + 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`); + }), + ); + } +} diff --git a/apps/api/src/common/zod.pipe.ts b/apps/api/src/common/zod.pipe.ts new file mode 100644 index 0000000..7b14684 --- /dev/null +++ b/apps/api/src/common/zod.pipe.ts @@ -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 implements PipeTransform> { + constructor(private readonly schema: S) {} + + transform(value: unknown): z.output { + 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 = (schema: S) => new ZodPipe(schema); diff --git a/apps/api/src/cors.ts b/apps/api/src/cors.ts new file mode 100644 index 0000000..9977f0c --- /dev/null +++ b/apps/api/src/cors.ts @@ -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); +} diff --git a/apps/api/src/planned-tabs/customer-name.ts b/apps/api/src/customers/customer-name.ts similarity index 100% rename from apps/api/src/planned-tabs/customer-name.ts rename to apps/api/src/customers/customer-name.ts diff --git a/apps/api/src/customers/customers.controller.ts b/apps/api/src/customers/customers.controller.ts new file mode 100644 index 0000000..da8f33a --- /dev/null +++ b/apps/api/src/customers/customers.controller.ts @@ -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 { + return this.customers.findAll(venueId); + } + + @Post() + @Roles('admin', 'manager') + create(@Param('venueId') venueId: string, @Body(zod(customerSchema)) dto: z.output): Promise { + 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): Promise { + 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); + } +} diff --git a/apps/api/src/customers/customers.module.ts b/apps/api/src/customers/customers.module.ts new file mode 100644 index 0000000..f913f25 --- /dev/null +++ b/apps/api/src/customers/customers.module.ts @@ -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 {} diff --git a/apps/api/src/customers/customers.service.ts b/apps/api/src/customers/customers.service.ts new file mode 100644 index 0000000..f999be1 --- /dev/null +++ b/apps/api/src/customers/customers.service.ts @@ -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 { + 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 { + 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 { + const incoming = new Map(); + 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 }; + } +} diff --git a/apps/api/src/customers/default-customers.ts b/apps/api/src/customers/default-customers.ts new file mode 100644 index 0000000..0512241 --- /dev/null +++ b/apps/api/src/customers/default-customers.ts @@ -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 }, + }); +} diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts deleted file mode 100644 index e4beb8d..0000000 --- a/apps/api/src/devices/devices.controller.ts +++ /dev/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); - } -} \ No newline at end of file diff --git a/apps/api/src/devices/devices.module.ts b/apps/api/src/devices/devices.module.ts deleted file mode 100644 index 962c987..0000000 --- a/apps/api/src/devices/devices.module.ts +++ /dev/null @@ -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 {} \ No newline at end of file diff --git a/apps/api/src/devices/devices.service.spec.ts b/apps/api/src/devices/devices.service.spec.ts deleted file mode 100644 index afa8306..0000000 --- a/apps/api/src/devices/devices.service.spec.ts +++ /dev/null @@ -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>; - -// 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 = {}) => - 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 | 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 = {}) { - 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['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'); - }); -}); diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts deleted file mode 100644 index f8fc2b8..0000000 --- a/apps/api/src/devices/devices.service.ts +++ /dev/null @@ -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/; 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 }; - } -} diff --git a/apps/api/src/devices/dto/ack-command.dto.ts b/apps/api/src/devices/dto/ack-command.dto.ts deleted file mode 100644 index 1f27194..0000000 --- a/apps/api/src/devices/dto/ack-command.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/apps/api/src/devices/dto/register-device.dto.ts b/apps/api/src/devices/dto/register-device.dto.ts deleted file mode 100644 index b826dcd..0000000 --- a/apps/api/src/devices/dto/register-device.dto.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/apps/api/src/devices/dto/stock-sync.dto.ts b/apps/api/src/devices/dto/stock-sync.dto.ts deleted file mode 100644 index 3530bb4..0000000 --- a/apps/api/src/devices/dto/stock-sync.dto.ts +++ /dev/null @@ -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; -} diff --git a/apps/api/src/devices/dto/sync.dto.ts b/apps/api/src/devices/dto/sync.dto.ts deleted file mode 100644 index ed81be5..0000000 --- a/apps/api/src/devices/dto/sync.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/api/src/devices/dto/tab-event.dto.ts b/apps/api/src/devices/dto/tab-event.dto.ts deleted file mode 100644 index 46dce6c..0000000 --- a/apps/api/src/devices/dto/tab-event.dto.ts +++ /dev/null @@ -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[]; -} diff --git a/apps/api/src/devices/stock-sync.ts b/apps/api/src/devices/stock-sync.ts deleted file mode 100644 index eab1057..0000000 --- a/apps/api/src/devices/stock-sync.ts +++ /dev/null @@ -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(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['products'][number]; - -export function parseStockResults(raw: unknown[]) { - return parseEach(StockAdjustmentResultDto, raw); -} diff --git a/apps/api/src/devices/tab-events.spec.ts b/apps/api/src/devices/tab-events.spec.ts deleted file mode 100644 index ebb0c29..0000000 --- a/apps/api/src/devices/tab-events.spec.ts +++ /dev/null @@ -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); - }); -}); diff --git a/apps/api/src/devices/tab-events.ts b/apps/api/src/devices/tab-events.ts deleted file mode 100644 index 6bb5bb7..0000000 --- a/apps/api/src/devices/tab-events.ts +++ /dev/null @@ -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('; '); -} diff --git a/apps/api/src/email/cash-payment-notifier.spec.ts b/apps/api/src/email/cash-payment-notifier.spec.ts deleted file mode 100644 index 74a562b..0000000 --- a/apps/api/src/email/cash-payment-notifier.spec.ts +++ /dev/null @@ -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: '

Cash

' }); - 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(); - }); -}); diff --git a/apps/api/src/email/cash-payment-notifier.ts b/apps/api/src/email/cash-payment-notifier.ts index e8f08d2..c222dab 100644 --- a/apps/api/src/email/cash-payment-notifier.ts +++ b/apps/api/src/email/cash-payment-notifier.ts @@ -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 '; 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}`); } } } diff --git a/apps/api/src/email/email.module.ts b/apps/api/src/email/email.module.ts index 48d1460..5366102 100644 --- a/apps/api/src/email/email.module.ts +++ b/apps/api/src/email/email.module.ts @@ -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 {} diff --git a/apps/api/src/email/resend.ts b/apps/api/src/email/resend.ts new file mode 100644 index 0000000..73a56b6 --- /dev/null +++ b/apps/api/src/email/resend.ts @@ -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 '; + + /** 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 { + 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); diff --git a/apps/api/src/health.controller.ts b/apps/api/src/health.controller.ts index a3ec417..abdb4c0 100644 --- a/apps/api/src/health.controller.ts +++ b/apps/api/src/health.controller.ts @@ -1,32 +1,18 @@ import { Controller, Get, HttpStatus, Res } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { SkipThrottle } from '@nestjs/throttler'; import { Response } from 'express'; import { PrismaService } from './prisma/prisma.service'; -@ApiTags('Health') -@SkipThrottle() @Controller('health') +@SkipThrottle() export class HealthController { constructor(private prisma: PrismaService) {} + /** 503 when the database is unreachable, so the container is marked unhealthy. */ @Get() - @ApiOperation({ summary: 'Health check' }) - @ApiResponse({ status: 200, description: 'Server is healthy' }) - @ApiResponse({ status: 503, description: 'Database is unreachable' }) async check(@Res({ passthrough: true }) res: Response) { - let dbStatus = 'ok'; - try { - await this.prisma.$queryRaw`SELECT 1`; - } catch { - dbStatus = 'error'; - res.status(HttpStatus.SERVICE_UNAVAILABLE); - } - - return { - status: dbStatus === 'ok' ? 'ok' : 'degraded', - database: dbStatus, - timestamp: new Date().toISOString(), - }; + const database = await this.prisma.$queryRaw`SELECT 1`.then(() => 'ok', () => 'error'); + if (database !== 'ok') res.status(HttpStatus.SERVICE_UNAVAILABLE); + return { status: database === 'ok' ? 'ok' : 'degraded', database, timestamp: new Date().toISOString() }; } } diff --git a/apps/api/src/images/image-processing.ts b/apps/api/src/images/image-processing.ts index 3f54870..e9052da 100644 --- a/apps/api/src/images/image-processing.ts +++ b/apps/api/src/images/image-processing.ts @@ -1,7 +1,7 @@ import { createHash } from 'crypto'; import sharp from 'sharp'; -/** Product pictures are stored as squares of this size: sharp on a tablet tile, small enough to store in the database. */ +/** Product pictures are stored as squares of this size: sharp on a tablet tile, a few tens of kB on disk. */ export const IMAGE_SIZE = 512; /** The largest upload accepted; phone photos are a few MB. */ diff --git a/apps/api/src/images/images.controller.ts b/apps/api/src/images/images.controller.ts index 420087d..c07f6b4 100644 --- a/apps/api/src/images/images.controller.ts +++ b/apps/api/src/images/images.controller.ts @@ -1,31 +1,27 @@ import { Controller, Get, Param, Res } from '@nestjs/common'; import { SkipThrottle } from '@nestjs/throttler'; -import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Response } from 'express'; import { ImagesService } from './images.service'; -@ApiTags('Images') @Controller('images') export class ImagesController { constructor(private images: ImagesService) {} /** - * Public: product pictures are not secret, and the dashboard's and the terminals load them without a - * token. The id is the hash of the content, so the response never changes and is cached for good. + * Public: product pictures are not secret, and the dashboard's and the terminals load them without a token. + * The id is the hash of the content, so the response never changes and is cached for good. */ @Get(':id') @SkipThrottle() - @ApiOperation({ summary: 'A product picture' }) async get(@Param('id') id: string, @Res() res: Response) { - const image = await this.images.get(id.replace(/\.webp$/, '')); - res.set({ - 'Content-Type': image.contentType, - 'Content-Length': String(image.bytes.length), - 'Cache-Control': 'public, max-age=31536000, immutable', - ETag: `"${image.id}"`, - // Helmet's default (same-origin) would keep the dashboard on another domain from showing it - 'Cross-Origin-Resource-Policy': 'cross-origin', + const image = await this.images.find(id.replace(/\.webp$/, '')); + res.sendFile(image.file, { + headers: { + 'Content-Type': image.contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + // Helmet's default (same-origin) would keep the dashboard on another domain from showing it + 'Cross-Origin-Resource-Policy': 'cross-origin', + }, }); - res.end(Buffer.from(image.bytes)); } } diff --git a/apps/api/src/images/images.service.ts b/apps/api/src/images/images.service.ts index a4e303a..3fcf03f 100644 --- a/apps/api/src/images/images.service.ts +++ b/apps/api/src/images/images.service.ts @@ -1,17 +1,25 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import * as path from 'path'; import { PrismaService } from '../prisma/prisma.service'; import { InvalidImageError, processProductImage } from './image-processing'; +/** Where pictures are stored; a volume in production, so they survive a redeploy. */ +export const imageDir = () => path.resolve(process.env.IMAGE_DIR || 'data/images'); + +/** + * Product pictures: a file per picture on disk, named by the hash of its bytes, and a row with its size. The same + * picture is stored once, and a picture's address never changes. + */ @Injectable() export class ImagesService { constructor(private prisma: PrismaService) {} - /** Processes and stores an upload; storing the same picture twice keeps one copy. Returns its id. */ + /** Processes and stores an upload. Returns its id. */ async store(upload: Buffer | undefined): Promise { if (!upload || upload.length === 0) { throw new BadRequestException({ code: 'image_required', message: 'Send the picture as the "file" field' }); } - let image; try { image = await processProductImage(upload); @@ -22,19 +30,35 @@ export class ImagesService { throw error; } + const file = this.file(image.id); + await fs.mkdir(path.dirname(file), { recursive: true }); + // Written next to it and renamed, so a picture is never read half-written + const temp = `${file}.${process.pid}.tmp`; + await fs.writeFile(temp, image.bytes); + await fs.rename(temp, file); + await this.prisma.productImage.upsert({ where: { id: image.id }, - create: image, + create: { id: image.id, contentType: image.contentType, width: image.width, height: image.height, bytes: image.bytes.length }, update: {}, }); return image.id; } - async get(id: string) { - const image = await this.prisma.productImage.findUnique({ where: { id } }); - if (!image) { - throw new NotFoundException({ code: 'image_not_found', message: 'Image not found' }); - } - return image; + /** The picture's file and type; 404 when there is no such picture. */ + async find(id: string): Promise<{ file: string; contentType: string }> { + const image = /^[0-9a-f]{64}$/.test(id) ? await this.prisma.productImage.findUnique({ where: { id } }) : null; + if (!image) throw new NotFoundException({ code: 'image_not_found', message: 'Image not found' }); + return { file: this.file(id), contentType: image.contentType }; + } + + /** Deletes pictures' files and rows. */ + async remove(ids: string[]) { + await this.prisma.productImage.deleteMany({ where: { id: { in: ids } } }); + await Promise.all(ids.map((id) => fs.rm(this.file(id), { force: true }))); + } + + private file(id: string) { + return path.join(imageDir(), `${id}.webp`); } } diff --git a/apps/api/src/jobs/command-expiry.job.ts b/apps/api/src/jobs/command-expiry.job.ts deleted file mode 100644 index 2fefb82..0000000 --- a/apps/api/src/jobs/command-expiry.job.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Cron, CronExpression } from '@nestjs/schedule'; -import { PrismaService } from '../prisma/prisma.service'; -import { RealtimeService } from '../realtime/realtime.service'; -import { v4 as uuidv4 } from 'uuid'; - -@Injectable() -export class CommandExpiryJob { - private readonly logger = new Logger(CommandExpiryJob.name); - /** Null until it ran since the server started; shown on the admin page. */ - lastRunAt: Date | null = null; - - constructor( - private prisma: PrismaService, - private realtime: RealtimeService, - ) {} - - @Cron(CronExpression.EVERY_HOUR) - async handleExpiredCommands() { - this.logger.log('Running command expiry job...'); - - const now = new Date(); - this.lastRunAt = now; - - const expiredCommands = await this.prisma.remoteCommand.findMany({ - where: { - status: 'pending', - expiresAt: { lte: now }, - }, - }); - - if (expiredCommands.length === 0) { - this.logger.log('No expired commands found'); - return; - } - - await this.prisma.remoteCommand.updateMany({ - where: { - id: { in: expiredCommands.map((c) => c.id) }, - }, - data: { status: 'expired' }, - }); - - // Write audit entries - for (const cmd of expiredCommands) { - await this.prisma.auditLog.create({ - data: { - id: uuidv4(), - venueId: cmd.venueId, - actorType: 'system', - actorId: 'command-expiry-job', - action: 'command_expired', - details: JSON.stringify({ commandId: cmd.id, tabId: cmd.targetTabId }), - }, - }); - } - - for (const venueId of new Set(expiredCommands.map((c) => c.venueId))) { - this.realtime.notify(venueId, 'commands'); - } - - this.logger.log(`Expired ${expiredCommands.length} commands`); - } -} \ No newline at end of file diff --git a/apps/api/src/jobs/jobs.module.ts b/apps/api/src/jobs/jobs.module.ts deleted file mode 100644 index ec1fe92..0000000 --- a/apps/api/src/jobs/jobs.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { CommandExpiryJob } from './command-expiry.job'; - -@Module({ - providers: [CommandExpiryJob], - exports: [CommandExpiryJob], -}) -export class JobsModule {} \ No newline at end of file diff --git a/apps/api/src/ledger/product-events.ts b/apps/api/src/ledger/product-events.ts new file mode 100644 index 0000000..8349e42 --- /dev/null +++ b/apps/api/src/ledger/product-events.ts @@ -0,0 +1,64 @@ +import { ProductEvent } from '@kooltap/shared'; +import { Tx } from '../prisma/prisma.service'; +import { VenueLedger } from './venue-ledger'; +import { createdFieldTimes, mergeProductChanges, ProductChanges } from './product-fields'; + +/** + * Applies a product created or changed on a terminal (also offline: [occurredAt] is when it happened there). + * A new product keeps the terminal's id, so it can be sold before the terminal synced. Duplicate names are + * accepted, a sale must never be lost over one; the dashboard shows them so one can be archived. + * Returns whether anything changed. + */ +export async function applyProductEvent(tx: Tx, ledger: VenueLedger, event: ProductEvent, occurredAt: Date): Promise { + const { venueId } = ledger; + const changes = await productChanges(tx, event); + + const existing = await tx.product.findUnique({ where: { id: event.productId } }); + // Another venue's product: the id came from a terminal, it can't take it over + if (existing && existing.venueId !== venueId) return false; + if (!existing) { + // Changes to a product the server doesn't have (deleted) are acknowledged and dropped + if (event.type !== 'product_created') return false; + + await tx.product.create({ + data: { + id: event.productId, + venueId, + name: changes.name!, + category: changes.category ?? null, + priceInCents: changes.priceInCents!, + lowStockThreshold: changes.lowStockThreshold ?? null, + imageId: changes.imageId ?? null, + // Its starting stock arrives as a stock_set event + stock: null, + fieldUpdatedAt: createdFieldTimes({ ...changes, archived: false }, occurredAt), + updatedAt: new Date(), + }, + }); + ledger.stock.addProduct(event.productId); + return true; + } + + const merged = mergeProductChanges(existing.fieldUpdatedAt, changes, occurredAt, existing); + if (!merged.changed) return false; + await tx.product.update({ + where: { id: event.productId }, + // The catalog version must move even when the change happened before the last one the terminals got + data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, updatedAt: new Date() }, + }); + return true; +} + +async function productChanges(tx: Tx, event: ProductEvent): Promise { + if (event.type === 'product_archived') return { archived: true }; + if (event.type === 'product_restored') return { archived: false }; + + const { imageId, ...fields } = event.product; + const changes: ProductChanges = { ...fields }; + if (imageId !== undefined) { + // Only a picture that was uploaded: the terminal uploads before it sends the change + const known = imageId === null || await tx.productImage.findUnique({ where: { id: imageId }, select: { id: true } }); + if (known) changes.imageId = imageId; + } + return changes; +} diff --git a/apps/api/src/stock/product-fields.ts b/apps/api/src/ledger/product-fields.ts similarity index 68% rename from apps/api/src/stock/product-fields.ts rename to apps/api/src/ledger/product-fields.ts index 97b3b87..132e054 100644 --- a/apps/api/src/stock/product-fields.ts +++ b/apps/api/src/ledger/product-fields.ts @@ -1,7 +1,9 @@ -import { Prisma } from '@prisma/client'; +import { Prisma } from '../prisma/prisma.service'; /** The fields of a product that the dashboard and the terminals change; stock changes through its own rules. */ -export const PRODUCT_FIELDS = ['name', 'category', 'priceInCents', 'buyPriceInCents', 'lowStockThreshold', 'desiredStock', 'archived', 'imageId'] as const; +export const PRODUCT_FIELDS = [ + 'name', 'category', 'priceInCents', 'buyPriceInCents', 'lowStockThreshold', 'desiredStock', 'archived', 'imageId', +] as const; export type ProductField = (typeof PRODUCT_FIELDS)[number]; export type ProductChanges = { @@ -44,10 +46,21 @@ export function mergeProductChanges(times: unknown, changes: ProductChanges, at: } } - return { data, fieldUpdatedAt: next as Prisma.InputJsonValue, changed: Object.keys(data).length > 0 }; + return { data, fieldUpdatedAt: next as Prisma.InputJsonObject, changed: Object.keys(data).length > 0 }; } /** Field times for a product created at [at]: every field it was created with. */ -export function createdFieldTimes(changes: ProductChanges, at: Date): Prisma.InputJsonValue { +export function createdFieldTimes(changes: ProductChanges, at: Date): Prisma.InputJsonObject { return Object.fromEntries(PRODUCT_FIELDS.filter((f) => changes[f] !== undefined).map((f) => [f, at.toISOString()])); } + +/** + * When a change made now on the dashboard happened: now, or just after the latest field time when a terminal whose + * clock runs a little ahead stamped one later. A change on the dashboard is made last, so it wins. + */ +export function dashboardChangeTime(now: Date, times: unknown): Date { + const stamps = Object.values((times && typeof times === 'object' ? times : {}) as Record) + .map((t) => new Date(t).getTime()) + .filter((t) => !Number.isNaN(t)); + return new Date(Math.max(now.getTime(), ...stamps)); +} diff --git a/apps/api/src/ledger/stock-rules.spec.ts b/apps/api/src/ledger/stock-rules.spec.ts new file mode 100644 index 0000000..4aff886 --- /dev/null +++ b/apps/api/src/ledger/stock-rules.spec.ts @@ -0,0 +1,53 @@ +import { VenueStock } from './stock-rules'; + +const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)); + +function stockOf(stock: number | null, countedAt: Date | null = null) { + return new VenueStock(new Map([['cola', { stock, countedAt }]])); +} + +describe('VenueStock', () => { + it('counts sales down and drinks taken off back up', () => { + const stock = stockOf(10); + + stock.sold('cola', 3, at(1)); + stock.sold('cola', -1, at(2)); + + expect(stock.get('cola')!.stock).toBe(8); + }); + + it('may go negative: a sale is never blocked', () => { + const stock = stockOf(1); + + stock.sold('cola', 3, at(1)); + + expect(stock.get('cola')!.stock).toBe(-2); + }); + + it('leaves sales from before the latest count out: the count has them', () => { + const stock = stockOf(10, at(5)); + + stock.sold('cola', 3, at(4)); + + expect(stock.get('cola')!.stock).toBe(10); + expect(stock.changed.size).toBe(0); + }); + + it('keeps the latest count when an older one arrives later, unless forced', () => { + const stock = stockOf(10, at(5)); + + expect(stock.count('cola', 3, at(4))).toBe(false); + expect(stock.get('cola')).toEqual({ stock: 10, countedAt: at(5) }); + + expect(stock.count('cola', 3, at(4), { force: true })).toBe(true); + expect(stock.get('cola')).toEqual({ stock: 3, countedAt: at(5) }); + }); + + it('does not add to a product whose stock is not tracked', () => { + const stock = stockOf(null); + + expect(stock.add('cola', 5)).toBeNull(); + stock.sold('cola', 1, at(1)); + expect(stock.get('cola')!.stock).toBeNull(); + }); +}); diff --git a/apps/api/src/stock/stock-rules.ts b/apps/api/src/ledger/stock-rules.ts similarity index 55% rename from apps/api/src/stock/stock-rules.ts rename to apps/api/src/ledger/stock-rules.ts index 3b3e0bf..a9d1499 100644 --- a/apps/api/src/stock/stock-rules.ts +++ b/apps/api/src/ledger/stock-rules.ts @@ -1,5 +1,3 @@ -import { TabEventDto } from '../devices/dto/tab-event.dto'; - /** One product's stock: null when not tracked, may be negative. */ export type ProductStock = { stock: number | null; countedAt: Date | null }; @@ -8,12 +6,13 @@ export type ProductStock = { stock: number | null; countedAt: Date | null }; * with the same rules on top of the stock the sync returns. */ export class VenueStock { - private readonly products: Map; /** Products to write back. */ readonly changed = new Set(); - constructor(products: Map) { - this.products = products; + constructor(private readonly products: Map) {} + + has(productId: string): boolean { + return this.products.has(productId); } get(productId: string): ProductStock | undefined { @@ -24,42 +23,27 @@ export class VenueStock { return [...this.products.entries()]; } - /** A product a terminal reported for the first time: not tracked until it is counted. */ - addProduct(productId: string) { - if (!this.products.has(productId)) this.products.set(productId, { stock: null, countedAt: null }); + /** A product created during this sync or request: not tracked until it is counted. */ + addProduct(productId: string, stock: ProductStock = { stock: null, countedAt: null }) { + if (!this.products.has(productId)) this.products.set(productId, stock); } - /** Takes over a terminal's own stock, once, when the server starts owning the stock. */ - seed(productId: string, stock: number | null) { - this.products.set(productId, { stock, countedAt: null }); + /** + * Drinks rung up ([quantity] > 0) or taken off again (< 0). A sale before the latest count is already in that + * count. + */ + sold(productId: string, quantity: number, occurredAt: Date) { + const product = this.products.get(productId); + if (!product || product.stock === null) return; + if (product.countedAt && occurredAt <= product.countedAt) return; + + product.stock -= quantity; this.changed.add(productId); } - /** - * Applies a tab event that happened at [occurredAt]. Only line events and stock counts change stock; a settlement - * doesn't, the drinks were counted down when they were put on the tab. - */ - apply(event: TabEventDto, occurredAt: Date) { - if (!event.productId) return; - - switch (event.type) { - case 'line_added': - this.sold(event.productId, event.quantity!, occurredAt); - return; - case 'line_removed': - this.sold(event.productId, -event.quantity!, occurredAt); - return; - case 'stock_set': - this.count(event.productId, event.quantity ?? null, occurredAt); - return; - default: - return; - } - } - /** * A count: the latest one wins, also when an older one arrives later (a terminal that was offline). [force] applies - * it regardless and makes it the latest. + * it regardless and makes it the latest. Null stops tracking. Returns whether it was applied. */ count(productId: string, stock: number | null, countedAt: Date, { force = false } = {}): boolean { const product = this.products.get(productId); @@ -74,7 +58,7 @@ export class VenueStock { return true; } - /** Adds [quantity] (signed) to a tracked product: a delivery or breakage. Returns the new stock. */ + /** Adds [quantity] (signed) to a tracked product: a delivery or breakage. Returns the new stock, null when untracked. */ add(productId: string, quantity: number): number | null { const product = this.products.get(productId); if (!product || product.stock === null) return null; @@ -83,14 +67,4 @@ export class VenueStock { this.changed.add(productId); return product.stock; } - - /** A sale before the latest count is already in that count. */ - private sold(productId: string, quantity: number, occurredAt: Date) { - const product = this.products.get(productId); - if (!product || product.stock === null) return; - if (product.countedAt && occurredAt <= product.countedAt) return; - - product.stock -= quantity; - this.changed.add(productId); - } } diff --git a/apps/api/src/ledger/tab-orders.spec.ts b/apps/api/src/ledger/tab-orders.spec.ts new file mode 100644 index 0000000..9ecd139 --- /dev/null +++ b/apps/api/src/ledger/tab-orders.spec.ts @@ -0,0 +1,49 @@ +import { Line } from './tab-state'; +import { tabOrders } from './tab-orders'; + +const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes)).toISOString(); + +function line(id: string, productName: string, unitPriceInCents: number, units: [orderId: string, minutes: number, removed?: boolean][]): Line { + return { + id, + productId: null, + productName, + unitPriceInCents, + units: units.map(([orderId, minutes, removed], index) => ({ + id: `${id}-${orderId}:${index}`, addedAt: at(minutes), removedAt: removed ? at(minutes + 1) : null, orderId, + })), + }; +} + +describe('tabOrders', () => { + it('shows the drinks of each order under the time it started', () => { + const lines = [ + line('duvel', 'Duvel', 400, [['o1', 0], ['o1', 0], ['o2', 38]]), + line('cola', 'Cola', 250, [['o1', 1]]), + line('beer', 'Beer', 300, [['o2', 39]]), + ]; + + expect(tabOrders(lines)).toEqual([ + { + orderId: 'o1', startedAt: at(0), + items: [ + { productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 }, + { productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 }, + ], + }, + { + orderId: 'o2', startedAt: at(38), + items: [ + { productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 }, + { productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 }, + ], + }, + ]); + }); + + it('counts drinks taken off again apart', () => { + expect(tabOrders([line('cola', 'Cola', 250, [['o1', 0], ['o1', 0, true]])])[0].items).toEqual([ + { productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 1 }, + ]); + }); +}); diff --git a/apps/api/src/ledger/tab-orders.ts b/apps/api/src/ledger/tab-orders.ts new file mode 100644 index 0000000..9b5adc7 --- /dev/null +++ b/apps/api/src/ledger/tab-orders.ts @@ -0,0 +1,35 @@ +import { TabOrder } from '@kooltap/shared'; +import { compareUnits, isActive, Line, Unit } from './tab-state'; + +/** + * Splits a tab's (or settlement's) lines into the orders they were rung up in, oldest first, as the terminal shows + * them under one "12:32 today" divider. Within an order every line shows how many of its drinks are still on it and + * how many were taken off again. + */ +export function tabOrders(lines: Line[]): TabOrder[] { + const orders = new Map }>(); + const drinks = lines.flatMap((line) => line.units.map((unit) => ({ line, unit }))) + .sort((a, b) => compareUnits(a.unit, b.unit)); + + for (const { line, unit } of drinks) { + let order = orders.get(unit.orderId); + if (!order) { + order = { startedAt: unit.addedAt, units: new Map() }; + orders.set(unit.orderId, order); + } + order.units.set(line, [...(order.units.get(line) ?? []), unit]); + } + + return [...orders] + .map(([orderId, { startedAt, units }]) => ({ + orderId, + startedAt, + items: [...units].map(([line, lineUnits]) => ({ + productName: line.productName, + quantity: lineUnits.filter(isActive).length, + unitPriceInCents: line.unitPriceInCents, + removed: lineUnits.filter((unit) => !isActive(unit)).length, + })), + })) + .sort((a, b) => a.startedAt.localeCompare(b.startedAt)); +} diff --git a/apps/api/src/ledger/tab-state.spec.ts b/apps/api/src/ledger/tab-state.spec.ts new file mode 100644 index 0000000..8b24b81 --- /dev/null +++ b/apps/api/src/ledger/tab-state.spec.ts @@ -0,0 +1,150 @@ +import { TabEvent } from '@kooltap/shared'; +import { compareUnits, tabJson, VenueTabs } from './tab-state'; + +const time = new Date('2026-09-24T20:00:00.000Z'); +const TAB = '00000000-0000-4000-8000-000000000001'; +const COLA = '00000000-0000-4000-8000-0000000000c0'; +let counter = 0; + +type Fields = Omit, 'id' | 'occurredAt' | 'type'>; + +function event(type: T, fields: Partial> = {}): Extract { + return { + id: `e${String(++counter).padStart(3, '0')}`, type, occurredAt: time.toISOString(), tabId: TAB, customerName: 'Jan', ...fields, + } as unknown as Extract; +} + +const cola = { lineId: 'line-1', productId: COLA, productName: 'Cola', unitPriceInCents: 250 }; + +function withCola(quantity: number, orderId = 'o1') { + const tabs = new VenueTabs([]); + tabs.apply(event('tab_opened'), time); + tabs.apply(event('line_added', { ...cola, orderId, quantity }), time); + return tabs; +} + +describe('VenueTabs.apply', () => { + it('ignores a second tab_opened for a tab that exists', () => { + const tabs = withCola(1); + + tabs.apply(event('tab_opened', { customerName: 'Other' }), time); + + expect(tabs.get(TAB)).toMatchObject({ customerName: 'Jan', version: 2 }); + }); + + it('opens a deleted tab again when drinks are added to it, so no sale is lost', () => { + const tabs = withCola(1); + tabs.apply(event('tab_deleted'), time); + + tabs.apply(event('line_added', { ...cola, orderId: 'o2', quantity: 2 }), time); + + expect(tabJson(tabs.get(TAB)!).lines).toMatchObject([{ id: 'line-1', quantity: 2 }]); + expect(tabs.deleted.size).toBe(0); + }); + + it('keeps only products the venue has', () => { + const tabs = new VenueTabs([], (id) => id === COLA); + + tabs.apply(event('line_added', { ...cola, lineId: 'a', orderId: 'o1', quantity: 1 }), time); + tabs.apply(event('line_added', { ...cola, lineId: 'b', productId: '00000000-0000-4000-8000-0000000000ff', orderId: 'o1', quantity: 1 }), time); + + expect(tabs.get(TAB)!.lines.map((line) => line.productId)).toEqual([COLA, null]); + }); + + it('takes off the latest drinks of the order, never more than are on it', () => { + const tabs = withCola(2, 'o1'); + tabs.apply(event('line_added', { ...cola, orderId: 'o2', quantity: 2 }), time); + + tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 5 }), time); + + const units = tabs.get(TAB)!.lines[0].units; + expect(units.filter((unit) => unit.removedAt !== null).map((unit) => unit.orderId)).toEqual(['o1', 'o1']); + expect(tabJson(tabs.get(TAB)!).lines[0].quantity).toBe(2); + }); + + it('settles the oldest drinks of each line and takes the removed ones along', () => { + const tabs = withCola(3); + tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 1 }), time); + + const settlement = tabs.apply(event('settled', { amountInCents: 250, paymentMethod: 'cash', items: [{ ...cola, quantity: 1 }] }), time)!; + + expect(settlement).toMatchObject({ amountInCents: 250, lines: [{ id: 'line-1', quantity: 1 }] }); + expect(settlement.lines[0].units).toHaveLength(2); + expect(tabJson(tabs.get(TAB)!).lines).toMatchObject([{ quantity: 1 }]); + }); + + it('keeps settled lines the server never saw in the settlement, with made-up drinks', () => { + const tabs = withCola(1); + const settled = event('settled', { + amountInCents: 650, + paymentMethod: 'cash', + items: [{ ...cola, quantity: 1 }, { lineId: 'chips', productId: null, productName: 'Chips', unitPriceInCents: 200, quantity: 2 }], + }); + + const settlement = tabs.apply(settled, time)!; + + expect(settlement.lines).toMatchObject([ + { id: 'line-1', quantity: 1 }, + { id: 'chips', productId: null, quantity: 2, units: [{ id: `${settled.id}:item:1:0`, orderId: null }, { id: `${settled.id}:item:1:1` }] }, + ]); + expect(tabs.get(TAB)!.lines).toEqual([]); + }); + + it('records a settlement of a tab that is gone', () => { + const tabs = new VenueTabs([]); + + const settlement = tabs.apply(event('settled', { amountInCents: 250, paymentMethod: 'cash', items: [{ ...cola, quantity: 1 }] }), time); + + expect(settlement).toMatchObject({ tabId: TAB, customerName: 'Jan', lines: [{ quantity: 1 }] }); + expect(tabs.all()).toEqual([]); + }); + + it('changes nothing for events about tabs that are gone', () => { + const tabs = new VenueTabs([]); + + tabs.apply(event('tab_renamed'), time); + tabs.apply(event('tab_deleted'), time); + tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 1 }), time); + + expect(tabs.changed.size + tabs.deleted.size).toBe(0); + }); +}); + +describe('VenueTabs.removableUnits', () => { + it('counts only drinks still on the line, so a removal after a settlement returns nothing to the stock', () => { + const tabs = withCola(3); + const removal = event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 5 }); + expect(tabs.removableUnits(removal)).toHaveLength(3); + + tabs.apply(event('settled', { amountInCents: 750, paymentMethod: 'cash', items: [{ ...cola, quantity: 3 }] }), time); + expect(tabs.removableUnits(removal)).toHaveLength(0); + }); +}); + +describe('VenueTabs.settleAll', () => { + it('settles every drink, keeping the tab open and empty', () => { + const tabs = withCola(3); + tabs.apply(event('line_removed', { lineId: 'line-1', orderId: 'o1', quantity: 1 }), time); + + const settlement = tabs.settleAll(TAB, { id: 's1', settledAt: time, paymentMethod: 'cash' })!; + + expect(settlement).toMatchObject({ amountInCents: 500, lines: [{ quantity: 2 }] }); + expect(settlement.lines[0].units).toHaveLength(3); + expect(tabJson(tabs.get(TAB)!)).toMatchObject({ version: 4, lines: [] }); + }); + + it('returns null for an empty tab', () => { + const tabs = new VenueTabs([]); + tabs.apply(event('tab_opened'), time); + + expect(tabs.settleAll(TAB, { id: 's1', settledAt: time, paymentMethod: 'cash' })).toBeNull(); + }); +}); + +describe('compareUnits', () => { + it('orders the drinks of one event by their index as a number', () => { + const unit = (id: string) => ({ id, addedAt: time.toISOString(), removedAt: null, orderId: null }); + + expect(['e:10', 'e:2', 'e:1'].map(unit).sort(compareUnits).map((u) => u.id)).toEqual(['e:1', 'e:2', 'e:10']); + }); +}); diff --git a/apps/api/src/ledger/tab-state.ts b/apps/api/src/ledger/tab-state.ts new file mode 100644 index 0000000..2b5a0a7 --- /dev/null +++ b/apps/api/src/ledger/tab-state.ts @@ -0,0 +1,260 @@ +import { PaymentMethod, TabEvent, TerminalLine, TerminalTab } from '@kooltap/shared'; + +/** + * The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and + * stores what changed. The terminals apply the same rules to replay their unsent events, so a unit's id must be + * derived the same way on both sides (`:`). + */ + +/** One drink ever put on a line. [orderId] is the order it was rung up in on the terminal. */ +export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId: string | null }; + +export type Line = { + id: string; + productId: string | null; + productName: string; + unitPriceInCents: number; + units: Unit[]; +}; + +export type TabState = { + id: string; + customerName: string; + openedAt: Date; + version: number; + lines: Line[]; +}; + +export type SettledLine = Line & { quantity: number }; + +export type SettlementDraft = { + id: string; + tabId: string; + customerName: string; + settledAt: Date; + paymentMethod: PaymentMethod; + amountInCents: number; + lines: SettledLine[]; +}; + +export const isActive = (unit: Unit) => unit.removedAt === null; + +export const lineQuantity = (line: Line) => line.units.filter(isActive).length; + +/** + * Oldest first, as the terminal added them: by time, then by the event that added them and their index in it (as a + * number, so `:10` comes after `:9`). + */ +export function compareUnits(a: Unit, b: Unit): number { + const [aEvent, aIndex] = splitUnitId(a.id); + const [bEvent, bIndex] = splitUnitId(b.id); + return a.addedAt.localeCompare(b.addedAt) || aEvent.localeCompare(bEvent) || aIndex - bIndex; +} + +function splitUnitId(id: string): [string, number] { + const at = id.lastIndexOf(':'); + return at < 0 ? [id, 0] : [id.slice(0, at), Number(id.slice(at + 1)) || 0]; +} + +export class VenueTabs { + private readonly tabs: Map; + /** Tabs to write back, and tabs to delete. */ + readonly changed = new Set(); + readonly deleted = new Set(); + + /** [isProduct] says whether a product id is in the venue's catalog: lines keep only known products. */ + constructor(tabs: TabState[], private readonly isProduct: (productId: string) => boolean = () => true) { + this.tabs = new Map(tabs.map((tab) => [tab.id, tab])); + } + + get(tabId: string): TabState | undefined { + return this.tabs.get(tabId); + } + + all(): TabState[] { + return [...this.tabs.values()]; + } + + /** + * Applies one event that happened at [occurredAt]. Returns the settlement a `settled` event made. Events about a + * tab or line that is gone change nothing (they are acknowledged all the same). + */ + apply(event: TabEvent, occurredAt: Date): SettlementDraft | null { + const at = occurredAt.toISOString(); + + switch (event.type) { + case 'tab_opened': + if (!this.tabs.has(event.tabId)) { + this.open(event.tabId, event.customerName, occurredAt); + this.touch(event.tabId); + } + return null; + + case 'tab_renamed': { + const tab = this.tabs.get(event.tabId); + if (tab) { + tab.customerName = event.customerName; + this.touch(event.tabId); + } + return null; + } + + case 'tab_deleted': + this.remove(event.tabId); + return null; + + case 'line_added': { + // Deleted elsewhere: bring it back, so no sale is lost + const tab = this.tabs.get(event.tabId) ?? this.open(event.tabId, event.customerName, occurredAt); + let line = tab.lines.find((l) => l.id === event.lineId); + if (!line) { + line = { + id: event.lineId, + productId: this.isProduct(event.productId) ? event.productId : null, + productName: event.productName, + unitPriceInCents: event.unitPriceInCents, + units: [], + }; + tab.lines.push(line); + } + for (let index = 0; index < event.quantity; index++) { + line.units.push({ id: `${event.id}:${index}`, addedAt: at, removedAt: null, orderId: event.orderId }); + } + this.touch(event.tabId); + return null; + } + + case 'line_removed': { + // The latest drinks of the order: the ones that were just rung up by mistake + const removable = this.removableUnits(event); + for (const unit of removable.slice(Math.max(0, removable.length - event.quantity))) unit.removedAt = at; + if (removable.length > 0) this.touch(event.tabId); + return null; + } + + case 'settled': { + const tab = this.tabs.get(event.tabId); + const lines: SettledLine[] = []; + event.items.forEach((item, index) => { + const line = tab?.lines.find((l) => l.id === item.lineId); + if (tab && line) { + const settled = settleLine(tab, line, item.quantity); + if (settled.units.length > 0) lines.push(settled); + } else if (item.quantity > 0) { + // Settled on the terminal but unknown here: keep it in the history all the same + lines.push({ + id: item.lineId, + productId: item.productId && this.isProduct(item.productId) ? item.productId : null, + productName: item.productName, + unitPriceInCents: item.unitPriceInCents, + quantity: item.quantity, + units: Array.from({ length: item.quantity }, (_, n) => ( + { id: `${event.id}:item:${index}:${n}`, addedAt: at, removedAt: null, orderId: null })), + }); + } + }); + if (tab) this.touch(event.tabId); + + return { + id: event.id, + tabId: event.tabId, + customerName: tab?.customerName ?? event.customerName, + settledAt: occurredAt, + paymentMethod: event.paymentMethod, + amountInCents: event.amountInCents, + lines, + }; + } + } + } + + /** + * The drinks a line_removed event can take off: the active ones of its order. None when the tab or line is gone + * (settled or deleted elsewhere while the terminal was offline). + */ + removableUnits(event: Extract): Unit[] { + const line = this.tabs.get(event.tabId)?.lines.find((l) => l.id === event.lineId); + return line?.units.filter((unit) => isActive(unit) && unit.orderId === event.orderId) ?? []; + } + + /** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */ + settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: PaymentMethod }): SettlementDraft | null { + const tab = this.tabs.get(tabId); + if (!tab) return null; + + const lines = tab.lines + .filter((line) => lineQuantity(line) > 0) + .map((line) => settleLine(tab, line, lineQuantity(line))); + if (lines.length === 0) return null; + this.touch(tabId); + + return { + ...settlement, + tabId, + customerName: tab.customerName, + amountInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0), + lines, + }; + } + + /** Deletes a tab, from a terminal or the dashboard. False when it is not open. */ + remove(tabId: string): boolean { + if (!this.tabs.delete(tabId)) return false; + this.changed.delete(tabId); + this.deleted.add(tabId); + return true; + } + + /** Opens a tab without an event: a customer's tab, or a line added to a tab that is gone. */ + open(tabId: string, customerName: string, openedAt: Date): TabState { + const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] }; + this.tabs.set(tabId, tab); + this.deleted.delete(tabId); + this.changed.add(tabId); + return tab; + } + + private touch(tabId: string) { + this.tabs.get(tabId)!.version++; + this.changed.add(tabId); + } +} + +/** + * Takes the [quantity] oldest active units off [line], together with its removed units, and drops the line when + * nothing is left. Drinks added after the settlement (by another terminal) stay on the tab. + */ +function settleLine(tab: TabState, line: Line, quantity: number): SettledLine { + const settled = line.units.filter(isActive).slice(0, quantity); + const removed = line.units.filter((unit) => !isActive(unit)); + const taken = new Set([...settled, ...removed]); + + line.units = line.units.filter((unit) => !taken.has(unit)); + if (line.units.length === 0) tab.lines = tab.lines.filter((l) => l !== line); + + return { + id: line.id, + productId: line.productId, + productName: line.productName, + unitPriceInCents: line.unitPriceInCents, + quantity: settled.length, + units: [...settled, ...removed].sort(compareUnits), + }; +} + +/** A line as the terminals get it: with how many drinks are still on it. */ +export function lineJson(line: Line): TerminalLine { + return { ...line, quantity: lineQuantity(line) }; +} + +/** [email]: the email of the customer with the tab's name. */ +export function tabJson(tab: TabState, email: string | null = null): TerminalTab { + return { + id: tab.id, + customerName: tab.customerName, + email, + openedAt: tab.openedAt.toISOString(), + version: tab.version, + lines: tab.lines.map(lineJson), + }; +} diff --git a/apps/api/src/ledger/venue-ledger.ts b/apps/api/src/ledger/venue-ledger.ts new file mode 100644 index 0000000..1049505 --- /dev/null +++ b/apps/api/src/ledger/venue-ledger.ts @@ -0,0 +1,187 @@ +import { Tx } from '../prisma/prisma.service'; +import { compareUnits, Line, SettlementDraft, TabState, Unit, VenueTabs } from './tab-state'; +import { ProductStock, VenueStock } from './stock-rules'; + +export type RecordedSettlement = SettlementDraft & { deviceId?: string | null; settledByUserId?: string | null }; + +type UnitRow = { + id: string; + tabId: string | null; + settlementId: string | null; + lineId: string; + productId: string | null; + productName: string; + unitPriceInCents: number; + orderId: string | null; + addedAt: Date; + removedAt: Date | null; +}; + +/** Locks the venue: syncs, dashboard changes and resets of one venue run one after the other. */ +export async function lockVenue(tx: Tx, venueId: string) { + await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId}::uuid FOR UPDATE`; +} + +/** + * A venue's open tabs, stock and new settlements inside one transaction. Opening it locks the venue, so terminals + * syncing at the same time and the dashboard apply their changes one after the other instead of overwriting each + * other. Everything is loaded at once: a venue has tens of open tabs and products, not thousands. + */ +export class VenueLedger { + readonly settlements: RecordedSettlement[] = []; + + private constructor( + readonly venueId: string, + readonly tabs: VenueTabs, + readonly stock: VenueStock, + /** The units on open tabs as loaded, to write only what changed. */ + private readonly loaded: Map, + ) {} + + static async open(tx: Tx, venueId: string): Promise { + await lockVenue(tx, venueId); + + const [tabs, units, products] = await Promise.all([ + tx.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }), + tx.tabUnit.findMany({ where: { venueId, tabId: { not: null } } }), + tx.product.findMany({ where: { venueId }, select: { id: true, stock: true, countedAt: true } }), + ]); + + const unitsByTab = groupBy(units, (unit) => unit.tabId!); + const stock = new VenueStock(new Map(products.map((p): [string, ProductStock] => ( + [p.id, { stock: p.stock, countedAt: p.countedAt }])))); + + return new VenueLedger( + venueId, + new VenueTabs( + tabs.map((tab) => ({ + id: tab.id, + customerName: tab.customerName, + openedAt: tab.openedAt, + version: tab.version, + lines: linesOf(unitsByTab.get(tab.id) ?? []), + })), + (productId) => stock.has(productId), + ), + stock, + new Map(units.map((unit) => [unit.id, unit])), + ); + } + + /** Writes what changed. Call once, at the end of the transaction. */ + async save(tx: Tx, now: Date) { + const { venueId } = this; + + for (const tabId of this.tabs.changed) { + const tab = this.tabs.get(tabId)!; + const data = { customerName: tab.customerName, openedAt: tab.openedAt, version: tab.version, updatedAt: now }; + await tx.tab.upsert({ where: { id: tabId }, create: { id: tabId, venueId, ...data }, update: data }); + } + + if (this.settlements.length > 0) { + await tx.settlement.createMany({ + data: this.settlements.map((s) => ({ + id: s.id, + venueId, + tabId: s.tabId, + customerName: s.customerName, + settledAt: s.settledAt, + paymentMethod: s.paymentMethod, + amountInCents: s.amountInCents, + deviceId: s.deviceId ?? null, + settledByUserId: s.settledByUserId ?? null, + })), + }); + } + + // Where every unit of a changed tab or a new settlement belongs now + const wanted: UnitRow[] = [ + ...[...this.tabs.changed].flatMap((tabId) => this.tabs.get(tabId)!.lines.flatMap((line) => ( + line.units.map((unit) => unitRow(unit, line, { tabId, settlementId: null }))))), + ...this.settlements.flatMap((s) => s.lines.flatMap((line) => ( + line.units.map((unit) => unitRow(unit, line, { tabId: null, settlementId: s.id }))))), + ]; + + // A tab deleted and opened again in one go lost the drinks it had + const wantedIds = new Set(wanted.map((unit) => unit.id)); + const dropped = [...this.loaded.values()] + .filter((unit) => this.tabs.changed.has(unit.tabId!) && !wantedIds.has(unit.id)) + .map((unit) => unit.id); + if (dropped.length > 0) await tx.tabUnit.deleteMany({ where: { id: { in: dropped } } }); + + const inserts = wanted.filter((unit) => !this.loaded.has(unit.id)); + if (inserts.length > 0) await tx.tabUnit.createMany({ data: inserts.map((unit) => ({ ...unit, venueId })) }); + + // Moved to a settlement or taken off: grouped, since one event changes many units the same way + const updates = groupBy( + wanted.filter((unit) => { + const before = this.loaded.get(unit.id); + return before && (before.tabId !== unit.tabId || before.settlementId !== unit.settlementId + || before.removedAt?.getTime() !== unit.removedAt?.getTime()); + }), + (unit) => JSON.stringify([unit.tabId, unit.settlementId, unit.removedAt]), + ); + for (const units of updates.values()) { + const [{ tabId, settlementId, removedAt }] = units; + await tx.tabUnit.updateMany({ where: { id: { in: units.map((u) => u.id) } }, data: { tabId, settlementId, removedAt } }); + } + + // Deleting a tab deletes the drinks still on it + if (this.tabs.deleted.size > 0) { + await tx.tab.deleteMany({ where: { venueId, id: { in: [...this.tabs.deleted] } } }); + } + + for (const productId of this.stock.changed) { + const { stock, countedAt } = this.stock.get(productId)!; + await tx.product.update({ where: { id: productId }, data: { stock, countedAt } }); + } + } + + get changedTabs(): boolean { + return this.tabs.changed.size > 0 || this.tabs.deleted.size > 0; + } +} + +/** Units grouped into lines, each line's units and the lines oldest first. */ +export function linesOf(units: Omit[]): Line[] { + const lines = [...groupBy(units, (unit) => unit.lineId).values()].map((rows): Line => ({ + id: rows[0].lineId, + productId: rows[0].productId, + productName: rows[0].productName, + unitPriceInCents: rows[0].unitPriceInCents, + units: rows + .map((row): Unit => ({ + id: row.id, + addedAt: row.addedAt.toISOString(), + removedAt: row.removedAt?.toISOString() ?? null, + orderId: row.orderId, + })) + .sort(compareUnits), + })); + return lines.sort((a, b) => compareUnits(a.units[0], b.units[0])); +} + +function unitRow(unit: Unit, line: Line, where: { tabId: string | null; settlementId: string | null }): UnitRow { + return { + id: unit.id, + ...where, + lineId: line.id, + productId: line.productId, + productName: line.productName, + unitPriceInCents: line.unitPriceInCents, + orderId: unit.orderId, + addedAt: new Date(unit.addedAt), + removedAt: unit.removedAt ? new Date(unit.removedAt) : null, + }; +} + +function groupBy(items: T[], key: (item: T) => K): Map { + const groups = new Map(); + for (const item of items) { + const k = key(item); + const group = groups.get(k); + if (group) group.push(item); + else groups.set(k, [item]); + } + return groups; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3570778..92e00a6 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,61 +1,36 @@ +import 'dotenv/config'; +import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; -import helmet from 'helmet'; import { NestExpressApplication } from '@nestjs/platform-express'; +import cookieParser from 'cookie-parser'; +import helmet from 'helmet'; import { AppModule } from './app.module'; -import { HttpExceptionFilter } from './common/filters/http-exception.filter'; -import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; - -async function bootstrap() { - const app = await NestFactory.create(AppModule); +import { corsOrigin } from './cors'; +import { HttpExceptionFilter } from './common/http-exception.filter'; +import { LoggingInterceptor } from './common/logging.interceptor'; +/** The app as it serves requests; also used by the end-to-end tests. */ +export function configure(app: NestExpressApplication) { // Bulk customer imports can exceed the 100kb default app.useBodyParser('json', { limit: '1mb' }); - app.setGlobalPrefix('api/v1'); - - // Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy. - if (process.env.TRUST_PROXY) { - const value = process.env.TRUST_PROXY; - app.set('trust proxy', /^\d+$/.test(value) ? Number(value) : value === 'true' ? true : value); - } - + // Behind a reverse proxy, trust X-Forwarded-For so rate limiting is per client instead of per proxy + const trustProxy = process.env.TRUST_PROXY; + if (trustProxy) app.set('trust proxy', /^\d+$/.test(trustProxy) ? Number(trustProxy) : trustProxy === 'true' || trustProxy); app.use(helmet()); - - app.enableCors({ - origin: process.env.CORS_ORIGIN || 'http://localhost:3000', - credentials: true, - }); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); - + app.use(cookieParser()); + app.enableCors({ origin: corsOrigin(), credentials: true }); app.useGlobalFilters(new HttpExceptionFilter()); - app.useGlobalInterceptors(new LoggingInterceptor()); - - const swagger = process.env.NODE_ENV !== 'production'; - if (swagger) { - const config = new DocumentBuilder() - .setTitle('KoolTap Remote Tab Control API') - .setDescription('API for managing remote tab control in venues') - .setVersion('1.0') - .addBearerAuth() - .build(); - - const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api/docs', app, document); - } - - const port = process.env.PORT || 3001; - await app.listen(port); - console.log(`API running on http://localhost:${port}`); - if (swagger) console.log(`Swagger docs at http://localhost:${port}/api/docs`); + app.enableShutdownHooks(); + return app; } -bootstrap(); \ No newline at end of file +async function bootstrap() { + const app = configure(await NestFactory.create(AppModule)); + app.useGlobalInterceptors(new LoggingInterceptor()); + const port = Number(process.env.PORT) || 3001; + await app.listen(port); + new Logger('Bootstrap').log(`API running on http://localhost:${port}/api/v1`); +} + +if (require.main === module) bootstrap(); diff --git a/apps/api/src/planned-tabs/default-customers.spec.ts b/apps/api/src/planned-tabs/default-customers.spec.ts deleted file mode 100644 index b60fe2c..0000000 --- a/apps/api/src/planned-tabs/default-customers.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { DevicesService } from '../devices/devices.service'; -import { SyncDto } from '../devices/dto/sync.dto'; -import { resetVenue } from '../admin/venue-reset'; -import { createFakePrisma } from '../testing/fake-prisma'; -import { ensureDefaultCustomers, FREE_DRINKS_CUSTOMER } from './default-customers'; - -function setup() { - const prisma = createFakePrisma(); - prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') }); - const tablet = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() }; - prisma.device.rows.push({ ...tablet }); - const service = new DevicesService(prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any, { notify: jest.fn() } as any); - const sync = () => service.sync(tablet, { deviceId: tablet.id, openTabs: [] } as SyncDto); - const reset = (scopes: ('tabs' | 'plannedTabs')[]) => - resetVenue(prisma as any, 'v1', scopes, { stockValue: 0, actor: { id: 'u1', name: 'Admin' }, now: new Date() }); - return { prisma, sync, reset }; -} - -const names = (result: { tabs: { customerName: string }[] }) => result.tabs.map((tab) => tab.customerName); - -describe('ensureDefaultCustomers', () => { - it('gives a venue a Free Drinks tab, without email, on the next sync', async () => { - const { prisma, sync } = setup(); - - await ensureDefaultCustomers(prisma as any, 'v1'); - - expect(prisma.plannedTab.rows).toEqual([expect.objectContaining({ customerName: FREE_DRINKS_CUSTOMER, email: null })]); - expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]); - // Opened once: a second sync keeps the one tab - expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]); - }); - - it('brings the tab back after a reset of the tabs or the planned customers', async () => { - const { prisma, sync, reset } = setup(); - await ensureDefaultCustomers(prisma as any, 'v1'); - await sync(); - - await reset(['tabs']); - expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]); - - await reset(['plannedTabs']); - expect(prisma.plannedTab.rows).toHaveLength(1); - // Its tab was still open: kept, not opened a second time - expect(names(await sync())).toEqual([FREE_DRINKS_CUSTOMER]); - }); -}); diff --git a/apps/api/src/planned-tabs/default-customers.ts b/apps/api/src/planned-tabs/default-customers.ts deleted file mode 100644 index 36adce8..0000000 --- a/apps/api/src/planned-tabs/default-customers.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { customerNameKey } from './customer-name'; - -type Tx = Prisma.TransactionClient; - -/** 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'; - -/** - * Plans the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose - * tab was wiped, is planned again: run when a venue is created or reset. - */ -export async function ensureDefaultCustomers(tx: Tx, venueId: string) { - const key = customerNameKey(FREE_DRINKS_CUSTOMER); - await tx.plannedTab.upsert({ - where: { venueId_customerNameKey: { venueId, customerNameKey: key } }, - create: { venueId, customerName: FREE_DRINKS_CUSTOMER, customerNameKey: key }, - // Opened again when its tab is gone; an open tab with this name is kept - update: { deliveredAt: null }, - }); -} diff --git a/apps/api/src/planned-tabs/dto/create-planned-tab.dto.ts b/apps/api/src/planned-tabs/dto/create-planned-tab.dto.ts deleted file mode 100644 index 1535e07..0000000 --- a/apps/api/src/planned-tabs/dto/create-planned-tab.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator'; -import { Transform } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class CreatePlannedTabDto { - @ApiProperty({ example: 'Jan' }) - @IsString() - @IsNotEmpty() - @MaxLength(100) - customerName: string; - - @ApiPropertyOptional({ example: 'jan@example.com' }) - @IsOptional() - @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) - @IsEmail() - email?: string; -} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/dto/import-planned-tabs.dto.ts b/apps/api/src/planned-tabs/dto/import-planned-tabs.dto.ts deleted file mode 100644 index e0d7526..0000000 --- a/apps/api/src/planned-tabs/dto/import-planned-tabs.dto.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength, IsArray, ArrayMaxSize, ValidateNested, IsIn } from 'class-validator'; -import { Transform, Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class ImportPlannedTabEntryDto { - @ApiProperty({ example: 'Jan Peeters' }) - @IsString() - @IsNotEmpty() - @MaxLength(100) - customerName: string; - - @ApiPropertyOptional({ example: 'jan@example.com' }) - @IsOptional() - @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) - @IsEmail() - email?: string; -} - -export class ImportPlannedTabsDto { - @ApiProperty({ type: [ImportPlannedTabEntryDto] }) - @IsArray() - @ArrayMaxSize(5000) - @ValidateNested({ each: true }) - @Type(() => ImportPlannedTabEntryDto) - entries: ImportPlannedTabEntryDto[]; - - @ApiProperty({ - enum: ['merge', 'replace'], - description: 'merge adds new names and updates emails; replace also removes names that are not in the import', - }) - @IsIn(['merge', 'replace']) - mode: 'merge' | 'replace'; -} diff --git a/apps/api/src/planned-tabs/dto/update-planned-tab.dto.ts b/apps/api/src/planned-tabs/dto/update-planned-tab.dto.ts deleted file mode 100644 index b1a41ab..0000000 --- a/apps/api/src/planned-tabs/dto/update-planned-tab.dto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsString, IsOptional, IsNotEmpty, IsEmail, MaxLength } from 'class-validator'; -import { Transform } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export class UpdatePlannedTabDto { - @ApiProperty({ example: 'Jan' }) - @IsString() - @IsNotEmpty() - @MaxLength(100) - customerName: string; - - @ApiPropertyOptional({ example: 'jan@example.com' }) - @IsOptional() - @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) - @IsEmail() - email?: string; -} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/planned-tabs.controller.ts b/apps/api/src/planned-tabs/planned-tabs.controller.ts deleted file mode 100644 index 2b232c6..0000000 --- a/apps/api/src/planned-tabs/planned-tabs.controller.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { VenueAccessGuard } from '../common/guards/venue-access.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; -import { Roles } from '../common/decorators/roles.decorator'; -import { PlannedTabsService } from './planned-tabs.service'; -import { CreatePlannedTabDto } from './dto/create-planned-tab.dto'; -import { UpdatePlannedTabDto } from './dto/update-planned-tab.dto'; -import { ImportPlannedTabsDto } from './dto/import-planned-tabs.dto'; - -@ApiTags('Planned Tabs') -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) -@Controller('venues/:venueId/planned-tabs') -export class PlannedTabsController { - constructor(private service: PlannedTabsService) {} - - @Get() - @ApiOperation({ summary: 'List planned tab names' }) - async findAll(@Param('venueId') venueId: string) { - return this.service.findAll(venueId); - } - - @Post() - @Roles('admin', 'manager') - @ApiOperation({ summary: 'Add a planned tab name' }) - async create(@Param('venueId') venueId: string, @Body() dto: CreatePlannedTabDto) { - return this.service.create(venueId, dto.customerName, dto.email); - } - - @Post('import') - @Roles('admin', 'manager') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Bulk import planned tab names (e.g. from an Excel file)' }) - async import(@Param('venueId') venueId: string, @Body() dto: ImportPlannedTabsDto) { - return this.service.import(venueId, dto.entries, dto.mode); - } - - @Put(':id') - @Roles('admin', 'manager') - @ApiOperation({ summary: 'Rename a planned tab' }) - async update(@Param('venueId') venueId: string, @Param('id') id: string, @Body() dto: UpdatePlannedTabDto) { - return this.service.update(venueId, id, dto.customerName, dto.email); - } - - @Delete(':id') - @Roles('admin', 'manager') - @ApiOperation({ summary: 'Remove a planned tab' }) - async remove(@Param('venueId') venueId: string, @Param('id') id: string) { - return this.service.remove(venueId, id); - } -} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/planned-tabs.module.ts b/apps/api/src/planned-tabs/planned-tabs.module.ts deleted file mode 100644 index 665d9ba..0000000 --- a/apps/api/src/planned-tabs/planned-tabs.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PlannedTabsController } from './planned-tabs.controller'; -import { PlannedTabsService } from './planned-tabs.service'; - -@Module({ - controllers: [PlannedTabsController], - providers: [PlannedTabsService], - exports: [PlannedTabsService], -}) -export class PlannedTabsModule {} \ No newline at end of file diff --git a/apps/api/src/planned-tabs/planned-tabs.service.spec.ts b/apps/api/src/planned-tabs/planned-tabs.service.spec.ts deleted file mode 100644 index c0aaa05..0000000 --- a/apps/api/src/planned-tabs/planned-tabs.service.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { PlannedTabsService } from './planned-tabs.service'; - -type Row = { id: string; venueId: string; customerName: string; email: string | null; position: number; deliveredAt?: Date | null }; - -function createService(initial: Omit[]) { - let rows: Row[] = initial.map((r) => ({ deliveredAt: null, ...r, venueId: 'v1' })); - const tx = { - plannedTab: { - findMany: jest.fn(async () => [...rows].sort((a, b) => a.position - b.position)), - create: jest.fn(async ({ data }: { data: Row }) => { - rows.push(data); - return data; - }), - update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial }) => { - const row = rows.find((r) => r.id === where.id)!; - Object.assign(row, data); - return row; - }), - deleteMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => { - const before = rows.length; - rows = rows.filter((r) => !where.id.in.includes(r.id)); - return { count: before - rows.length }; - }), - }, - }; - const prisma = { $transaction: (fn: (t: typeof tx) => unknown) => fn(tx) }; - const service = new PlannedTabsService(prisma as any, { notify: jest.fn() } as any); - return { service, rows: () => rows }; -} - -describe('PlannedTabsService.import', () => { - it('adds new names, keeps existing spelling and only fills in emails', async () => { - const { service, rows } = createService([ - { id: 'a', customerName: 'Jan Peeters', email: null, position: 0 }, - { id: 'b', customerName: 'Els', email: 'els@example.com', position: 1 }, - ]); - - const result = await service.import('v1', [ - { customerName: ' jan peeters ', email: 'jan@example.com' }, - { customerName: 'Els' }, - { customerName: 'Piet' }, - ], 'merge'); - - expect(result).toEqual({ created: 1, updated: 1, unchanged: 1, removed: 0 }); - expect(rows().find((r) => r.id === 'a')).toMatchObject({ customerName: 'Jan Peeters', email: 'jan@example.com' }); - expect(rows().find((r) => r.id === 'b')?.email).toBe('els@example.com'); - expect(rows().find((r) => r.customerName === 'Piet')?.position).toBe(2); - }); - - it('collapses duplicate names within the import', async () => { - const { service, rows } = createService([]); - - const result = await service.import('v1', [ - { customerName: 'Piet', email: 'piet@example.com' }, - { customerName: 'PIET' }, - ], 'merge'); - - expect(result.created).toBe(1); - expect(rows()).toHaveLength(1); - expect(rows()[0].email).toBe('piet@example.com'); - }); - - it('queues delivered customers again so a deleted tab is reopened', async () => { - const { service, rows } = createService([ - { id: 'a', customerName: 'Jan', email: 'jan@example.com', position: 0, deliveredAt: new Date() }, - ]); - - const result = await service.import('v1', [{ customerName: 'jan' }], 'merge'); - - expect(result).toEqual({ created: 0, updated: 1, unchanged: 0, removed: 0 }); - expect(rows()[0]).toMatchObject({ deliveredAt: null, email: 'jan@example.com' }); - }); - - it('removes names missing from the import only in replace mode', async () => { - const initial = [ - { id: 'a', customerName: 'Jan', email: null, position: 0 }, - { id: 'b', customerName: 'Els', email: null, position: 1 }, - ]; - - const merge = createService(initial); - expect((await merge.service.import('v1', [{ customerName: 'Jan' }], 'merge')).removed).toBe(0); - expect(merge.rows()).toHaveLength(2); - - const replace = createService(initial); - expect((await replace.service.import('v1', [{ customerName: 'jan' }], 'replace')).removed).toBe(1); - expect(replace.rows().map((r) => r.customerName)).toEqual(['Jan']); - }); -}); diff --git a/apps/api/src/planned-tabs/planned-tabs.service.ts b/apps/api/src/planned-tabs/planned-tabs.service.ts deleted file mode 100644 index d4ad9ea..0000000 --- a/apps/api/src/planned-tabs/planned-tabs.service.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common'; -import { PrismaService } from '../prisma/prisma.service'; -import { RealtimeService } from '../realtime/realtime.service'; -import { v4 as uuidv4 } from 'uuid'; -import { customerNameKey, normalizeCustomerName } from './customer-name'; - -@Injectable() -export class PlannedTabsService { - constructor( - private prisma: PrismaService, - private realtime: RealtimeService, - ) {} - - async findAll(venueId: string) { - const tabs = await this.prisma.plannedTab.findMany({ - where: { venueId }, - orderBy: { position: 'asc' }, - select: { id: true, customerName: true, email: true, position: true, createdAt: true, deliveredAt: true }, - }); - return { plannedTabs: tabs }; - } - - async create(venueId: string, customerName: string, email?: string) { - const name = normalizeCustomerName(customerName); - if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' }); - const key = customerNameKey(name); - - const existing = await this.prisma.plannedTab.findUnique({ - where: { venueId_customerNameKey: { venueId, customerNameKey: key } }, - }); - if (existing) { - throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); - } - - // Get next position - const last = await this.prisma.plannedTab.findFirst({ - where: { venueId }, - orderBy: { position: 'desc' }, - }); - - const tab = await this.prisma.plannedTab.create({ - data: { - id: uuidv4(), - venueId, - customerName: name, - customerNameKey: key, - email: email?.trim() || null, - position: (last?.position ?? -1) + 1, - }, - select: { id: true, customerName: true, email: true, position: true, createdAt: true }, - }); - - this.realtime.notify(venueId, 'planned-tabs'); - return tab; - } - - async update(venueId: string, id: string, customerName: string, email?: string) { - const name = normalizeCustomerName(customerName); - if (!name) throw new BadRequestException({ code: 'name_required', message: 'Customer name is required' }); - const key = customerNameKey(name); - - const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } }); - if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' }); - - const duplicate = await this.prisma.plannedTab.findUnique({ - where: { venueId_customerNameKey: { venueId, customerNameKey: key } }, - }); - if (duplicate && duplicate.id !== id) { - throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' }); - } - - const result = await this.prisma.plannedTab.update({ - where: { id }, - data: { customerName: name, customerNameKey: key, email: email?.trim() || null }, - select: { id: true, customerName: true, email: true, position: true, createdAt: true }, - }); - - this.realtime.notify(venueId, 'planned-tabs', 'tabs'); - return result; - } - - /** - * Bulk import of the customer list (e.g. from the client's Excel file). - * Names match case-insensitively and existing spelling is kept, because the terminal matches - * planned names to tabs exactly and a respelled name would open a second tab. - * A missing email in the import never clears an existing one. - * Customers already delivered are queued again, so a tab deleted since is reopened on the next sync. - */ - async import(venueId: string, entries: { customerName: string; email?: string }[], mode: 'merge' | 'replace') { - const incoming = new Map(); - for (const entry of entries) { - const customerName = normalizeCustomerName(entry.customerName); - if (!customerName) continue; - const key = customerNameKey(customerName); - const previous = incoming.get(key); - incoming.set(key, { customerName, email: entry.email || previous?.email }); - } - - const result = await this.prisma.$transaction(async (tx) => { - const existing = await tx.plannedTab.findMany({ where: { venueId }, orderBy: { position: 'asc' } }); - const existingByKey = new Map(existing.map((t) => [customerNameKey(t.customerName), t])); - let nextPosition = (existing[existing.length - 1]?.position ?? -1) + 1; - - let created = 0; - let updated = 0; - let unchanged = 0; - let removed = 0; - - for (const [key, entry] of incoming) { - const current = existingByKey.get(key); - if (!current) { - await tx.plannedTab.create({ - data: { id: uuidv4(), venueId, customerName: entry.customerName, customerNameKey: key, email: entry.email ?? null, position: nextPosition++ }, - }); - created++; - } else if (current.deliveredAt || (entry.email && entry.email !== current.email)) { - // Re-importing a delivered customer queues them again: their tab may have been deleted since, and the - // next sync skips anyone who still has an open tab - await tx.plannedTab.update({ - where: { id: current.id }, - data: { deliveredAt: null, ...(entry.email && entry.email !== current.email ? { email: entry.email } : {}) }, - }); - updated++; - } else { - unchanged++; - } - } - - if (mode === 'replace') { - const toRemove = existing.filter((t) => !incoming.has(customerNameKey(t.customerName))).map((t) => t.id); - if (toRemove.length > 0) { - removed = (await tx.plannedTab.deleteMany({ where: { id: { in: toRemove } } })).count; - } - } - - return { created, updated, unchanged, removed }; - }, { timeout: 30000 }); - - this.realtime.notify(venueId, 'planned-tabs', 'tabs'); - return result; - } - - async remove(venueId: string, id: string) { - const tab = await this.prisma.plannedTab.findFirst({ where: { id, venueId } }); - if (!tab) throw new NotFoundException({ code: 'not_found', message: 'Planned tab not found' }); - - await this.prisma.plannedTab.delete({ where: { id } }); - this.realtime.notify(venueId, 'planned-tabs', 'tabs'); - return { ok: true }; - } -} \ No newline at end of file diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index e6f1503..b49f225 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -1,8 +1,25 @@ -import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { Prisma, PrismaClient } from '../generated/prisma/client'; + +export { Prisma }; + +/** A transaction, or the client itself where a transaction isn't needed. */ +export type Tx = Prisma.TransactionClient; + +export function createPrismaClient(connectionString = process.env.DATABASE_URL): PrismaClient { + if (!connectionString) throw new Error('DATABASE_URL must be set'); + return new PrismaClient({ adapter: new PrismaPg({ connectionString }) }); +} @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + const connectionString = process.env.DATABASE_URL; + if (!connectionString) throw new Error('DATABASE_URL must be set'); + super({ adapter: new PrismaPg({ connectionString }) }); + } + async onModuleInit() { await this.$connect(); } @@ -10,4 +27,4 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul async onModuleDestroy() { await this.$disconnect(); } -} \ No newline at end of file +} diff --git a/apps/api/src/realtime/realtime.gateway.ts b/apps/api/src/realtime/realtime.gateway.ts index 9616d81..5a3ffad 100644 --- a/apps/api/src/realtime/realtime.gateway.ts +++ b/apps/api/src/realtime/realtime.gateway.ts @@ -1,41 +1,32 @@ -import { JwtService } from '@nestjs/jwt'; -import { PrismaService } from '../prisma/prisma.service'; import { OnGatewayConnection, OnGatewayInit, WebSocketGateway } from '@nestjs/websockets'; import { Namespace, Socket } from 'socket.io'; +import { AuthService } from '../auth/auth.service'; +import { ACCESS_COOKIE, cookieFromHeader } from '../auth/cookies'; +import { corsOrigin } from '../cors'; import { RealtimeService, venueRoom } from './realtime.service'; -@WebSocketGateway({ - namespace: '/realtime', - cors: { origin: process.env.CORS_ORIGIN || 'http://localhost:3000', credentials: true }, -}) +@WebSocketGateway({ namespace: '/realtime', cors: { origin: corsOrigin(), credentials: true } }) export class RealtimeGateway implements OnGatewayInit, OnGatewayConnection { constructor( - private jwtService: JwtService, + private auth: AuthService, private realtime: RealtimeService, - private prisma: PrismaService, ) {} afterInit(namespace: Namespace) { this.realtime.attach(namespace); - - // Authenticate during the handshake so rejected clients never count as connected; - // the dashboard refreshes its access token on `unauthorized` and reconnects + // Authenticated during the handshake with the access token cookie, so rejected clients never count as connected; + // the dashboard refreshes its token on `unauthorized` and reconnects namespace.use(async (socket, next) => { - try { - const payload = await this.jwtService.verifyAsync(String(socket.handshake.auth?.token ?? '')); - socket.data.venueId = payload.venueId; - // A superadmin viewing another venue follows that venue. The role is checked in the database, not the token, - // so a demoted superadmin can't keep following other venues - const viewing = socket.handshake.auth?.venueId; - if (typeof viewing === 'string' && viewing && viewing !== payload.venueId) { - const user = await this.prisma.clientUser.findUnique({ where: { id: payload.sub }, select: { role: true } }); - if (user?.role !== 'superadmin') return next(new Error('unauthorized')); - socket.data.venueId = viewing; - } - next(); - } catch { - next(new Error('unauthorized')); + const user = await this.auth.userFromAccessToken(cookieFromHeader(socket.handshake.headers.cookie, ACCESS_COOKIE) ?? undefined); + if (!user) return next(new Error('unauthorized')); + // A superadmin follows the venue they are viewing; everyone else their own + const viewing = socket.handshake.auth?.venueId; + const venueId = user.role === 'superadmin' ? viewing : user.venueId; + if (typeof venueId !== 'string' || !venueId || (user.role !== 'superadmin' && viewing && viewing !== venueId)) { + return next(new Error('unauthorized')); } + socket.data.venueId = venueId; + next(); }); } diff --git a/apps/api/src/realtime/realtime.module.ts b/apps/api/src/realtime/realtime.module.ts index 340d7d8..b6ead61 100644 --- a/apps/api/src/realtime/realtime.module.ts +++ b/apps/api/src/realtime/realtime.module.ts @@ -1,12 +1,9 @@ import { Global, Module } from '@nestjs/common'; -import { JwtModule } from '@nestjs/jwt'; -import { getJwtSecret } from '../auth/jwt-secret'; import { RealtimeGateway } from './realtime.gateway'; import { RealtimeService } from './realtime.service'; @Global() @Module({ - imports: [JwtModule.register({ secret: getJwtSecret() })], providers: [RealtimeGateway, RealtimeService], exports: [RealtimeService], }) diff --git a/apps/api/src/realtime/realtime.service.ts b/apps/api/src/realtime/realtime.service.ts index 2bec40d..0cf8f53 100644 --- a/apps/api/src/realtime/realtime.service.ts +++ b/apps/api/src/realtime/realtime.service.ts @@ -1,14 +1,12 @@ import { Injectable } from '@nestjs/common'; import { Namespace } from 'socket.io'; - -/** What changed; the dashboard refetches the matching data over the normal API. */ -export type RealtimeTopic = 'tabs' | 'commands' | 'planned-tabs' | 'device' | 'reminders' | 'users' | 'stock'; +import { RealtimeTopic } from '@kooltap/shared'; export const venueRoom = (venueId: string) => `venue:${venueId}`; /** - * Tells connected dashboards of a venue that data changed. Events only carry topic names, - * never data, so a socket can't see anything its owner couldn't fetch through the API. + * Tells connected dashboards of a venue that data changed. Events only carry topic names, never data, so a socket + * can't see anything its owner couldn't fetch through the API. */ @Injectable() export class RealtimeService { @@ -19,7 +17,7 @@ export class RealtimeService { } notify(venueId: string, ...topics: RealtimeTopic[]) { - this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics }); + if (topics.length > 0) this.namespace?.to(venueRoom(venueId)).emit('invalidate', { topics: [...new Set(topics)] }); } /** Dashboards connected right now. */ diff --git a/apps/api/src/reminders/dto/send-reminders.dto.ts b/apps/api/src/reminders/dto/send-reminders.dto.ts deleted file mode 100644 index 6ab3e6a..0000000 --- a/apps/api/src/reminders/dto/send-reminders.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { IsArray, IsOptional, IsString, ArrayMaxSize } from 'class-validator'; -import { ApiPropertyOptional } from '@nestjs/swagger'; - -export class SendRemindersDto { - @ApiPropertyOptional({ - type: [String], - description: 'Tabs to remind, including ones still in their cooldown. Omit to remind every eligible customer.', - }) - @IsOptional() - @IsArray() - @ArrayMaxSize(1000) - @IsString({ each: true }) - tabIds?: string[]; -} diff --git a/apps/api/src/reminders/reminders.controller.ts b/apps/api/src/reminders/reminders.controller.ts index e3d482a..94e55cd 100644 --- a/apps/api/src/reminders/reminders.controller.ts +++ b/apps/api/src/reminders/reminders.controller.ts @@ -1,56 +1,28 @@ -import { Controller, Get, Post, Param, Body, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiQuery } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; -import { Roles } from '../common/decorators/roles.decorator'; -import { VenueAccessGuard } from '../common/guards/venue-access.guard'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { z } from 'zod'; +import { Reminder, ReminderCandidate, SendRemindersResult, sendRemindersSchema } from '@kooltap/shared'; +import { zod } from '../common/zod.pipe'; +import { Authorized, Roles } from '../common/auth'; import { RemindersService } from './reminders.service'; -import { SendRemindersDto } from './dto/send-reminders.dto'; -@ApiTags('Reminders') -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, VenueAccessGuard) @Controller('venues/:venueId/reminders') +@Authorized() export class RemindersController { - constructor(private service: RemindersService) {} + constructor(private reminders: RemindersService) {} @Get('candidates') - @ApiOperation({ summary: 'List customers with an unpaid tab and their reminder history' }) - @ApiResponse({ status: 200, description: 'Unpaid tabs with email, amount, reminder count and eligibility' }) - async listCandidates(@Param('venueId') venueId: string) { - return this.service.listCandidates(venueId); + candidates(@Param('venueId') venueId: string): Promise<{ candidates: ReminderCandidate[]; cooldownDays: number }> { + return this.reminders.candidates(venueId); } @Post('send') - @UseGuards(RolesGuard) @Roles('admin', 'manager') - @ApiOperation({ summary: 'Send payment reminders via Resend (admin only)' }) - @ApiResponse({ status: 201, description: 'Reminders sent' }) - @ApiResponse({ status: 400, description: 'Resend not configured' }) - @ApiResponse({ status: 409, description: 'Reminders are already being sent' }) - async send(@Param('venueId') venueId: string, @Body() dto: SendRemindersDto) { - return this.service.sendReminders(venueId, dto.tabIds); + send(@Param('venueId') venueId: string, @Body(zod(sendRemindersSchema)) dto: z.output): Promise { + return this.reminders.send(venueId, dto.tabIds); } @Get() - @ApiOperation({ summary: 'Get reminder history' }) - @ApiQuery({ name: 'customerName', required: false, description: 'Only reminders sent to this customer' }) - @ApiResponse({ status: 200, description: 'List of previously sent reminders' }) - async history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string) { - return this.service.history(venueId, customerName); + history(@Param('venueId') venueId: string, @Query('customerName') customerName?: string): Promise { + return this.reminders.history(venueId, customerName); } - - @Post('test/:plannedTabId') - @UseGuards(RolesGuard) - @Roles('admin', 'manager') - @ApiOperation({ summary: 'Send a test email to a planned customer (admin only)' }) - @ApiResponse({ status: 201, description: 'Test email sent' }) - @ApiResponse({ status: 400, description: 'Resend not configured or customer has no email' }) - @ApiResponse({ status: 404, description: 'Planned customer not found' }) - async sendTest( - @Param('venueId') venueId: string, - @Param('plannedTabId') plannedTabId: string, - ) { - return this.service.sendTestEmail(venueId, plannedTabId); - } -} \ No newline at end of file +} diff --git a/apps/api/src/reminders/reminders.service.spec.ts b/apps/api/src/reminders/reminders.service.spec.ts deleted file mode 100644 index 6d68003..0000000 --- a/apps/api/src/reminders/reminders.service.spec.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { ConflictException } from '@nestjs/common'; -import { EmailTemplatesService } from '../email/email-templates.service'; -import { RemindersService } from './reminders.service'; - -type SentRow = { customerName: string; _count: { _all: number }; _max: { sentAt: Date | null } }; - -type Item = { productName: string; quantity: number; unitPriceInCents: number }; - -/** € 29,00 for 12 drinks. */ -const defaultItems: Item[] = [ - { productName: 'Cola', quantity: 10, unitPriceInCents: 250 }, - { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, -]; - -/** The tab's lines as the server holds them: one unit per drink. */ -function lines(items: Item[]) { - return items.map((item, i) => ({ - id: `line-${i}`, - productId: null, - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - units: Array.from({ length: item.quantity }, (_, n) => ({ id: `u${i}:${n}`, addedAt: '2026-09-24T20:00:00.000Z', removedAt: null })), - })); -} - -function createService(items: Item[] | null, { plannedName = 'Bram', sent = [] as SentRow[] } = {}) { - const prisma = { - tab: { - findMany: jest.fn().mockResolvedValue([ - { id: 't1', customerName: 'Bram', version: 3, lines: lines(items ?? defaultItems), updatedAt: new Date() }, - ]), - }, - plannedTab: { findMany: jest.fn().mockResolvedValue([{ customerName: plannedName, email: 'bram@example.com' }]) }, - tabReminder: { groupBy: jest.fn().mockResolvedValue(sent), createMany: jest.fn() }, - venue: { findUnique: jest.fn().mockResolvedValue({ name: 'De Kroon' }) }, - }; - const service = new RemindersService(prisma as any, { notify: jest.fn() } as any, new EmailTemplatesService()); - return { service, prisma }; -} - -describe('RemindersService.sendReminders', () => { - const originalFetch = global.fetch; - let requests: { url: string; headers: Record; body: any }[]; - - beforeEach(() => { - process.env.RESEND_API_KEY = 'test-key'; - requests = []; - global.fetch = jest.fn(async (url: any, init: any) => { - requests.push({ url, headers: init.headers, body: JSON.parse(init.body) }); - return { ok: true, text: async () => '' } as Response; - }) as any; - }); - - afterEach(() => { - global.fetch = originalFetch; - delete process.env.RESEND_API_KEY; - }); - - it('includes the tab summary with Belgian amounts', async () => { - const { service } = createService(defaultItems); - - const result = await service.sendReminders('v1'); - - expect(result.sent).toBe(1); - const [email] = requests[0].body; - expect(email.to).toBe('bram@example.com'); - expect(email.text).toMatch(/10x Cola\s+€\s25,00/); - expect(email.text).toMatch(/2x Chips\s+€\s4,00/); - expect(email.html).toContain('10x Cola'); - expect(email.subject).toContain('De Kroon'); - }); - - it('sends one batch request with an idempotency key and records the reminders', async () => { - const { service, prisma } = createService(null); - - await service.sendReminders('v1'); - - expect(requests).toHaveLength(1); - expect(requests[0].url).toBe('https://api.resend.com/emails/batch'); - expect(requests[0].headers['Idempotency-Key']).toMatch(/^reminders-[0-9a-f]{64}$/); - expect(prisma.tabReminder.createMany).toHaveBeenCalledWith({ - data: [expect.objectContaining({ tabId: 't1', email: 'bram@example.com', amountInCents: 2900 })], - }); - }); - - it('matches planned customers regardless of case and spacing', async () => { - const { service } = createService(null, { plannedName: ' bRAM ' }); - - const { candidates } = await service.listCandidates('v1'); - - expect(candidates.map((c) => c.email)).toEqual(['bram@example.com']); - }); - - it('refuses a second send for the venue while one is in progress', async () => { - const { service } = createService(null); - - const first = service.sendReminders('v1'); - await expect(service.sendReminders('v1')).rejects.toBeInstanceOf(ConflictException); - await first; - await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 1 }); - }); - - it('does not record reminders when Resend rejects the batch', async () => { - global.fetch = jest.fn(async () => ({ ok: false, text: async () => 'rate limited' }) as Response) as any; - const { service, prisma } = createService(null); - - const result = await service.sendReminders('v1'); - - expect(result).toMatchObject({ sent: 0, total: 1, errors: [{ customerName: 'Bram', error: 'rate limited' }] }); - expect(prisma.tabReminder.createMany).not.toHaveBeenCalled(); - }); - - it('lets database errors surface instead of re-sending to everyone', async () => { - const { service, prisma } = createService(null); - prisma.tabReminder.groupBy.mockRejectedValue(new Error('db down')); - - await expect(service.sendReminders('v1')).rejects.toThrow('db down'); - expect(requests).toHaveLength(0); - }); - - it('skips customers in their cooldown unless they are picked by hand', async () => { - const daysAgo = (d: number) => new Date(Date.now() - d * 24 * 60 * 60 * 1000); - const { service } = createService(null, { - sent: [{ customerName: 'bram', _count: { _all: 2 }, _max: { sentAt: daysAgo(1) } }], - }); - - const [candidate] = (await service.listCandidates('v1')).candidates; - expect(candidate).toMatchObject({ eligible: false, reminderCount: 2 }); - - await expect(service.sendReminders('v1')).resolves.toMatchObject({ sent: 0 }); - await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 1 }); - }); - - it('only sends to the picked tabs', async () => { - const { service } = createService(null); - - await expect(service.sendReminders('v1', ['other'])).resolves.toMatchObject({ sent: 0, total: 0 }); - expect(requests).toHaveLength(0); - }); - - it('lists customers without an email as not eligible', async () => { - const { service, prisma } = createService(null); - prisma.plannedTab.findMany.mockResolvedValue([]); - - const { candidates } = await service.listCandidates('v1'); - - expect(candidates).toEqual([expect.objectContaining({ email: null, eligible: false, reminderCount: 0 })]); - await expect(service.sendReminders('v1', ['t1'])).resolves.toMatchObject({ sent: 0 }); - }); - - it('uses a new idempotency key for a deliberate repeat', async () => { - const { service, prisma } = createService(null); - await service.sendReminders('v1', ['t1']); - prisma.tabReminder.groupBy.mockResolvedValue([ - { customerName: 'Bram', _count: { _all: 1 }, _max: { sentAt: new Date() } }, - ]); - - await service.sendReminders('v1', ['t1']); - - expect(requests[0].headers['Idempotency-Key']).not.toBe(requests[1].headers['Idempotency-Key']); - }); -}); diff --git a/apps/api/src/reminders/reminders.service.ts b/apps/api/src/reminders/reminders.service.ts index e8fcd6b..06b4afc 100644 --- a/apps/api/src/reminders/reminders.service.ts +++ b/apps/api/src/reminders/reminders.service.ts @@ -1,123 +1,92 @@ -import { Injectable, BadRequestException, ConflictException, NotFoundException, InternalServerErrorException } from '@nestjs/common'; -import * as crypto from 'crypto'; +import { ConflictException, Injectable, InternalServerErrorException } from '@nestjs/common'; +import { createHash } from 'crypto'; +import { Reminder, ReminderCandidate, SendRemindersResult } from '@kooltap/shared'; import { PrismaService } from '../prisma/prisma.service'; import { RealtimeService } from '../realtime/realtime.service'; -import { EmailTemplateError, EmailTemplatesService, RenderedEmail } from '../email/email-templates.service'; -import { customerNameKey } from '../planned-tabs/customer-name'; -import { tabSummary } from '../tabs/tab-state'; +import { EmailTemplateError, EmailTemplatesService } from '../email/email-templates.service'; +import { formatEuro, OutgoingEmail, Resend } from '../email/resend'; +import { customerNameKey } from '../customers/customer-name'; +import { linesOf } from '../ledger/venue-ledger'; +import { tabContents } from '../tabs/tabs.service'; -// Tabs stay open per customer (closing only settles them), so a customer is eligible again -// once this many days have passed since their last reminder. Admins can still pick them by hand. +// Tabs stay open per customer (settling only empties them), so a customer is eligible again once this many days have +// passed since their last reminder. Admins can still pick them by hand export const REMINDER_COOLDOWN_DAYS = 7; const COOLDOWN_MS = REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000; // Resend accepts at most 100 emails per batch request const BATCH_SIZE = 100; -type Candidate = { - tabId: string; - customerName: string; - // Null when the planned customer has no email address: shown, but can't be reminded - email: string | null; - amountInCents: number; - itemCount: number; - items: TabLine[] | null; - reminderCount: number; - lastReminderAt: Date | null; - // When the cooldown ends; null when they have never been reminded - nextEligibleAt: Date | null; - // Has an email and is not within the cooldown: selected by default - eligible: boolean; -}; - +type Candidate = ReminderCandidate & { customerId: string | null }; type Recipient = Candidate & { email: string }; @Injectable() export class RemindersService { - private resendApiKey: string; - private fromEmail: string; private sendingVenues = new Set(); constructor( private prisma: PrismaService, private realtime: RealtimeService, - private emailTemplates: EmailTemplatesService, - ) { - this.resendApiKey = process.env.RESEND_API_KEY || ''; - this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap '; - } + private templates: EmailTemplatesService, + private resend: Resend, + ) {} /** Every customer with an unpaid open tab, with their reminder track record. */ - async listCandidates(venueId: string): Promise<{ candidates: Candidate[]; cooldownDays: number }> { - const tabs = (await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } })).map(tabSummary); - - const plannedTabs = await this.prisma.plannedTab.findMany({ - where: { venueId, email: { not: null } }, - select: { customerName: true, email: true }, - }); - const emailByKey = new Map(plannedTabs.map((pt) => [customerNameKey(pt.customerName), pt.email!])); - - // Reminders are tracked per customer: names differing only in case or spacing are one person - const sentPerName = await this.prisma.tabReminder.groupBy({ - by: ['customerName'], - where: { venueId }, - _count: { _all: true }, - _max: { sentAt: true }, - }); - const sentByKey = new Map(); - for (const row of sentPerName) { - const key = customerNameKey(row.customerName); - const prev = sentByKey.get(key) ?? { count: 0, last: null }; - const last = row._max.sentAt; - sentByKey.set(key, { - count: prev.count + row._count._all, - last: !prev.last || (last && last > prev.last) ? last : prev.last, - }); - } - - const now = Date.now(); - const candidates: Candidate[] = []; - for (const snap of tabs) { - if (snap.totalInCents <= 0) continue; - const key = customerNameKey(snap.customerName); - const email = emailByKey.get(key) ?? null; - const sent = sentByKey.get(key); - const nextEligibleAt = sent?.last ? new Date(sent.last.getTime() + COOLDOWN_MS) : null; - candidates.push({ - tabId: snap.tabId, - customerName: snap.customerName, - email, - amountInCents: snap.totalInCents, - itemCount: snap.itemCount, - items: snap.items, - reminderCount: sent?.count ?? 0, - lastReminderAt: sent?.last ?? null, - nextEligibleAt, - eligible: email !== null && (!nextEligibleAt || nextEligibleAt.getTime() <= now), - }); - } - + async candidates(venueId: string): Promise<{ candidates: ReminderCandidate[]; cooldownDays: number }> { + const candidates = (await this.listCandidates(venueId)).map(({ customerId, ...candidate }) => candidate); return { candidates, cooldownDays: REMINDER_COOLDOWN_DAYS }; } - /** - * Sends to the given tabs, eligible or not (an admin's explicit choice overrides the cooldown), - * or to every eligible customer when no tabs are given. - */ - async sendReminders(venueId: string, tabIds?: string[]) { - if (!this.resendApiKey) { - throw new BadRequestException({ - code: 'resend_not_configured', - message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.', - }); - } + private async listCandidates(venueId: string): Promise { + const [tabs, units, customers, sent] = await Promise.all([ + this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }), + this.prisma.tabUnit.findMany({ where: { venueId, tabId: { not: null } } }), + this.prisma.customer.findMany({ where: { venueId }, select: { id: true, nameKey: true, email: true } }), + this.prisma.reminder.groupBy({ + by: ['customerId'], + where: { venueId, customerId: { not: null } }, + _count: { _all: true }, + _max: { sentAt: true }, + }), + ]); + const customersByKey = new Map(customers.map((c) => [c.nameKey, c])); + const sentByCustomer = new Map(sent.map((row) => [row.customerId!, row])); + const now = Date.now(); + return tabs.flatMap((tab): Candidate[] => { + const { items, totalInCents, itemCount } = tabContents(linesOf(units.filter((unit) => unit.tabId === tab.id))); + if (totalInCents <= 0) return []; + const customer = customersByKey.get(customerNameKey(tab.customerName)); + const reminded = customer ? sentByCustomer.get(customer.id) : undefined; + const last = reminded?._max.sentAt ?? null; + const nextEligibleAt = last ? new Date(last.getTime() + COOLDOWN_MS) : null; + return [{ + tabId: tab.id, + customerId: customer?.id ?? null, + customerName: tab.customerName, + email: customer?.email ?? null, + amountInCents: totalInCents, + itemCount, + items, + reminderCount: reminded?._count._all ?? 0, + lastReminderAt: last?.toISOString() ?? null, + nextEligibleAt: nextEligibleAt?.toISOString() ?? null, + eligible: customer?.email != null && (!nextEligibleAt || nextEligibleAt.getTime() <= now), + }]; + }); + } + + /** + * Sends to the given tabs, eligible or not (an admin's explicit choice overrides the cooldown), or to every + * eligible customer when no tabs are given. + */ + async send(venueId: string, tabIds?: string[]): Promise { + this.resend.ensureConfigured(); // A double click or a second admin must not email everyone twice if (this.sendingVenues.has(venueId)) { throw new ConflictException({ code: 'reminders_in_progress', message: 'Reminders are already being sent' }); } this.sendingVenues.add(venueId); - try { return await this.sendToCandidates(venueId, tabIds); } finally { @@ -125,116 +94,70 @@ export class RemindersService { } } - private async sendToCandidates(venueId: string, tabIds?: string[]) { - const { candidates: all } = await this.listCandidates(venueId); + private async sendToCandidates(venueId: string, tabIds?: string[]): Promise { const selected = tabIds ? new Set(tabIds) : null; - const candidates = all.filter((c): c is Recipient => - c.email !== null && (selected ? selected.has(c.tabId) : c.eligible), - ); + const recipients = (await this.listCandidates(venueId)).filter((c): c is Recipient => ( + c.email !== null && (selected ? selected.has(c.tabId) : c.eligible))); + if (recipients.length === 0) return { sent: 0, total: 0 }; - if (candidates.length === 0) { - return { sent: 0, total: 0 }; - } - - const venueName = await this.venueName(venueId); - let sent = 0; + const venue = await this.prisma.venue.findUniqueOrThrow({ where: { id: venueId }, select: { name: true } }); const errors: { customerName: string; error: string }[] = []; - - const rendered: { candidate: Recipient; email: RenderedEmail }[] = []; - for (const candidate of candidates) { + const rendered: { recipient: Recipient; email: OutgoingEmail }[] = []; + for (const recipient of recipients) { try { - rendered.push({ candidate, email: await this.renderReminder(candidate, venueName) }); - } catch (err: any) { - errors.push({ customerName: candidate.customerName, error: err.message }); + rendered.push({ recipient, email: { to: recipient.email, ...(await this.renderReminder(recipient, venue.name)) } }); + } catch (error) { + errors.push({ customerName: recipient.customerName, error: (error as Error).message }); } } + let sent = 0; for (let i = 0; i < rendered.length; i += BATCH_SIZE) { const batch = rendered.slice(i, i + BATCH_SIZE); - const error = await this.sendBatch(batch.map(({ candidate, email }) => ({ - to: candidate.email, - email, - reminderCount: candidate.reminderCount, - }))); + // The reminder counts are part of the key: a deliberate repeat after a successful send is a new request + const key = createHash('sha256') + .update(JSON.stringify([batch.map((b) => b.email), batch.map((b) => b.recipient.reminderCount)])) + .digest('hex'); + const error = await this.resend.sendBatch(batch.map((b) => b.email), `reminders-${key}`); if (error) { - for (const { candidate } of batch) errors.push({ customerName: candidate.customerName, error }); + for (const { recipient } of batch) errors.push({ customerName: recipient.customerName, error }); continue; } - await this.prisma.tabReminder.createMany({ - data: batch.map(({ candidate }) => ({ + await this.prisma.reminder.createMany({ + data: batch.map(({ recipient }) => ({ venueId, - tabId: candidate.tabId, - customerName: candidate.customerName, - email: candidate.email, - amountInCents: candidate.amountInCents, + customerId: recipient.customerId, + customerName: recipient.customerName, + email: recipient.email, + tabId: recipient.tabId, + amountInCents: recipient.amountInCents, })), }); sent += batch.length; } if (sent > 0) this.realtime.notify(venueId, 'reminders'); - - return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined }; + return { sent, total: recipients.length, ...(errors.length > 0 ? { errors } : {}) }; } - async history(venueId: string, customerName?: string) { - const reminders = await this.prisma.tabReminder.findMany({ - where: { - venueId, - ...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' as const } } : {}), - }, + async history(venueId: string, customerName?: string): Promise { + const reminders = await this.prisma.reminder.findMany({ + where: { venueId, ...(customerName ? { customerName: { equals: customerName.trim(), mode: 'insensitive' } } : {}) }, orderBy: { sentAt: 'desc' }, take: 100, }); - return { reminders }; - } - - async sendTestEmail(venueId: string, plannedTabId: string) { - this.ensureConfigured(); - - const tab = await this.prisma.plannedTab.findFirst({ - where: { id: plannedTabId, venueId }, - }); - - if (!tab) { - throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' }); - } - - if (!tab.email) { - throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' }); - } - - await this.sendTest(tab.email, tab.customerName, await this.venueName(venueId)); - - return { ok: true, email: tab.email, customerName: tab.customerName }; + return reminders.map((r) => ({ + id: r.id, tabId: r.tabId, customerName: r.customerName, email: r.email, amountInCents: r.amountInCents, sentAt: r.sentAt.toISOString(), + })); } /** The test email to any address: for a superadmin checking that sending works. */ - async sendTestEmailTo(to: string) { - this.ensureConfigured(); - await this.sendTest(to, to, 'KoolTap'); - return { ok: true, email: to }; - } - - /** Whether sending is set up, without sending anything. */ - get configured(): boolean { - return this.resendApiKey !== ''; - } - - private ensureConfigured() { - if (!this.resendApiKey) { - throw new BadRequestException({ - code: 'resend_not_configured', - message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.', - }); - } - } - - private async sendTest(to: string, customerName: string, venueName: string) { - let email: RenderedEmail; + async sendTestEmail(to: string) { + this.resend.ensureConfigured(); + let email; try { - email = await this.emailTemplates.render('test-email', { customerName, venueName }); + email = await this.templates.render('test-email', { customerName: to, venueName: 'KoolTap' }); } catch (error) { // Template mistakes are shown to the admin instead of a generic server error if (error instanceof EmailTemplateError) { @@ -242,24 +165,19 @@ export class RemindersService { } throw error; } - - const error = await this.resend('/emails', { from: this.fromEmail, to, ...email }); - if (error) { - throw new BadRequestException({ - code: 'email_failed', - message: `Failed to send test email: ${error}`, - }); - } + const error = await this.resend.sendBatch([{ to, ...email }]); + if (error) throw new InternalServerErrorException({ code: 'email_failed', message: `Failed to send the test email: ${error}` }); + return { ok: true, email: to }; } - private renderReminder(candidate: Recipient, venueName: string): Promise { - return this.emailTemplates.render('payment-reminder', { - customerName: candidate.customerName, + private renderReminder(recipient: Recipient, venueName: string) { + return this.templates.render('payment-reminder', { + customerName: recipient.customerName, venueName, - amount: formatEuro(candidate.amountInCents), - itemCount: candidate.itemCount, - hasItems: (candidate.items?.length ?? 0) > 0, - items: (candidate.items ?? []).map((item) => ({ + amount: formatEuro(recipient.amountInCents), + itemCount: recipient.itemCount, + hasItems: recipient.items.length > 0, + items: recipient.items.map((item) => ({ quantity: item.quantity, productName: item.productName, unitPrice: formatEuro(item.unitPriceInCents), @@ -267,42 +185,4 @@ export class RemindersService { })), }); } - - /** One Resend batch request; returns Resend's error text, or null when sent. */ - private sendBatch(messages: { to: string; email: RenderedEmail; reminderCount: number }[]): Promise { - const body = messages.map(({ to, email }) => ({ from: this.fromEmail, to, ...email })); - // Resend ignores a retried request with the same key for 24 hours, so a retry never emails twice. - // The reminder counts are part of the key: a deliberate repeat after a successful send is a new request. - const counts = messages.map((m) => m.reminderCount); - const idempotencyKey = 'reminders-' + crypto.createHash('sha256').update(JSON.stringify([body, counts])).digest('hex'); - return this.resend('/emails/batch', body, idempotencyKey); - } - - private async resend(path: string, body: unknown, idempotencyKey?: string): Promise { - const res = await fetch(`https://api.resend.com${path}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${this.resendApiKey}`, - ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), - }, - body: JSON.stringify(body), - }); - - return res.ok ? null : await res.text(); - } - - private async venueName(venueId: string): Promise { - const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { name: true } }); - return venue?.name ?? 'KoolTap'; - } -} - -type TabLine = { productName: string; quantity: number; unitPriceInCents: number }; - -// Belgian notation, as on the dashboard: € 25,00 -const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' }); - -function formatEuro(cents: number): string { - return euro.format(cents / 100); } diff --git a/apps/api/src/stock/dto/create-stock-adjustments.dto.ts b/apps/api/src/stock/dto/create-stock-adjustments.dto.ts deleted file mode 100644 index 0d424bb..0000000 --- a/apps/api/src/stock/dto/create-stock-adjustments.dto.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { - IsString, IsIn, IsInt, Min, Max, IsNotEmpty, MaxLength, IsOptional, IsArray, ArrayMinSize, ArrayMaxSize, - ValidateNested, -} from 'class-validator'; -import { Transform, Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -export const STOCK_ADJUSTMENT_TYPES = ['add', 'set'] as const; -export type StockAdjustmentType = (typeof STOCK_ADJUSTMENT_TYPES)[number]; - -export class StockAdjustmentEntryDto { - @ApiProperty({ example: 'local-product-id' }) - @IsString() - @IsNotEmpty() - productId: string; - - @ApiProperty({ enum: STOCK_ADJUSTMENT_TYPES, description: 'add: change the stock by quantity; set: the stock is now quantity' }) - @IsIn(STOCK_ADJUSTMENT_TYPES) - type: StockAdjustmentType; - - @ApiProperty({ example: 24, description: 'add: non-zero, negative to take stock out; set: zero or more' }) - @IsInt() - @Min(-100000) - @Max(100000) - quantity: number; - - @ApiPropertyOptional({ example: 'Delivery from the brewery' }) - @IsOptional() - @Transform(({ value }) => (typeof value === 'string' ? value.trim() || undefined : value)) - @IsString() - @MaxLength(200) - note?: string; -} - -export class CreateStockAdjustmentsDto { - @ApiProperty({ type: [StockAdjustmentEntryDto] }) - @IsArray() - @ArrayMinSize(1) - @ArrayMaxSize(500) - @ValidateNested({ each: true }) - @Type(() => StockAdjustmentEntryDto) - adjustments: StockAdjustmentEntryDto[]; -} diff --git a/apps/api/src/stock/dto/update-product.dto.ts b/apps/api/src/stock/dto/update-product.dto.ts deleted file mode 100644 index 7f56821..0000000 --- a/apps/api/src/stock/dto/update-product.dto.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { IsInt, Min, Max, ValidateIf, IsString, IsNotEmpty, MaxLength, IsOptional, IsBoolean } from 'class-validator'; -import { Transform } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; - -const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value); -// An empty category is no category -const trimToNull = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() || null : value); - -export class CreateProductDto { - @ApiProperty({ example: 'Cola' }) - @Transform(trim) - @IsString() - @IsNotEmpty() - @MaxLength(200) - name: string; - - @ApiPropertyOptional({ example: 'Frisdrank', nullable: true }) - @IsOptional() - @Transform(trimToNull) - @IsString() - @MaxLength(100) - category?: string | null; - - @ApiProperty({ example: 250, description: 'The sell price, charged on the terminals' }) - @IsInt() - @Min(0) - @Max(1_000_000) - priceInCents: number; - - @ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' }) - @IsOptional() - @ValidateIf((dto: CreateProductDto) => dto.buyPriceInCents !== null) - @IsInt() - @Min(0) - @Max(1_000_000) - buyPriceInCents?: number | null; - - @ApiPropertyOptional({ example: 24, nullable: true, description: 'The starting count; null or left out: stock is not tracked' }) - @IsOptional() - @ValidateIf((dto: CreateProductDto) => dto.stock !== null) - @IsInt() - @Min(0) - @Max(100000) - stock?: number | null; - - @ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' }) - @IsOptional() - @ValidateIf((dto: CreateProductDto) => dto.lowStockThreshold !== null) - @IsInt() - @Min(0) - @Max(100000) - lowStockThreshold?: number | null; - - @ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' }) - @IsOptional() - @ValidateIf((dto: CreateProductDto) => dto.desiredStock !== null) - @IsInt() - @Min(0) - @Max(100000) - desiredStock?: number | null; -} - -/** Only the fields that are sent change. The stock itself changes through stock adjustments. */ -export class UpdateProductDto { - @ApiPropertyOptional({ example: 'Cola Zero' }) - @IsOptional() - @Transform(trim) - @IsString() - @IsNotEmpty() - @MaxLength(200) - name?: string; - - @ApiPropertyOptional({ example: 'Frisdrank', nullable: true }) - @IsOptional() - @Transform(trimToNull) - @ValidateIf((dto: UpdateProductDto) => dto.category !== null) - @IsString() - @MaxLength(100) - category?: string | null; - - @ApiPropertyOptional({ example: 250 }) - @IsOptional() - @IsInt() - @Min(0) - @Max(1_000_000) - priceInCents?: number; - - @ApiPropertyOptional({ example: 120, nullable: true, description: 'What the bar pays for one; null for not entered. Not sent to the terminals' }) - @IsOptional() - @ValidateIf((dto: UpdateProductDto) => dto.buyPriceInCents !== null) - @IsInt() - @Min(0) - @Max(1_000_000) - buyPriceInCents?: number | null; - - @ApiPropertyOptional({ example: 12, nullable: true, description: 'Warn when stock drops to this level; null for no warning' }) - @IsOptional() - @ValidateIf((dto: UpdateProductDto) => dto.lowStockThreshold !== null) - @IsInt() - @Min(0) - @Max(100000) - lowStockThreshold?: number | null; - - @ApiPropertyOptional({ example: 100, nullable: true, description: 'How many to have in stock; null for none. Not sent to the terminals' }) - @IsOptional() - @ValidateIf((dto: UpdateProductDto) => dto.desiredStock !== null) - @IsInt() - @Min(0) - @Max(100000) - desiredStock?: number | null; - - @ApiPropertyOptional({ description: 'true hides the product on the terminals (deleting it); false brings it back' }) - @IsOptional() - @IsBoolean() - archived?: boolean; -} diff --git a/apps/api/src/stock/product-events.ts b/apps/api/src/stock/product-events.ts deleted file mode 100644 index ef745c2..0000000 --- a/apps/api/src/stock/product-events.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { TabEventDto } from '../devices/dto/tab-event.dto'; -import { VenueLedger } from '../tabs/venue-ledger'; -import { createdFieldTimes, mergeProductChanges, ProductChanges } from './product-fields'; - -type Tx = Prisma.TransactionClient; - -/** - * Applies a product created or changed on a terminal (also offline: [occurredAt] is when it happened there). - * A new product gets the terminal's id, so it can be sold before the terminal synced. Duplicate names are - * accepted, a sale must never be lost over one; the dashboard shows them so one can be archived. - * Returns whether anything changed. - */ -export async function applyProductEvent(tx: Tx, ledger: VenueLedger, event: TabEventDto, occurredAt: Date): Promise { - const { venueId } = ledger; - const productId = event.productId!; - const changes = await productChanges(tx, event); - - const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } }); - if (!existing) { - // Changes to a product the server doesn't have (deleted, or another venue's) are acknowledged and dropped - if (event.type !== 'product_created') return false; - - await tx.product.create({ - data: { - venueId, - productId, - name: changes.name!, - category: changes.category ?? null, - priceInCents: changes.priceInCents!, - lowStockThreshold: changes.lowStockThreshold ?? null, - imageId: changes.imageId ?? null, - // Its starting stock arrives as a stock_set event - stock: null, - reportedAt: occurredAt, - fieldUpdatedAt: createdFieldTimes({ ...changes, archived: false }, occurredAt), - }, - }); - ledger.stock.addProduct(productId); - return true; - } - - const merged = mergeProductChanges(existing.fieldUpdatedAt, changes, occurredAt, existing); - if (!merged.changed) return false; - await tx.product.update({ - where: { venueId_productId: { venueId, productId } }, - data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: occurredAt }, - }); - return true; -} - -async function productChanges(tx: Tx, event: TabEventDto): Promise { - const fields = event.product ?? {}; - const changes: ProductChanges = {}; - if (fields.name !== undefined) changes.name = fields.name.trim(); - // An empty category is no category - if (fields.category !== undefined) changes.category = fields.category?.trim() || null; - if (fields.priceInCents !== undefined) changes.priceInCents = fields.priceInCents; - if (fields.lowStockThreshold !== undefined) changes.lowStockThreshold = fields.lowStockThreshold; - if (event.type === 'product_archived') changes.archived = true; - if (event.type === 'product_restored') changes.archived = false; - - if (fields.imageId !== undefined) { - // Only a picture that was uploaded: the terminal uploads before it sends the change - const known = fields.imageId === null - || await tx.productImage.findUnique({ where: { id: fields.imageId }, select: { id: true } }); - if (known) changes.imageId = fields.imageId; - } - return changes; -} diff --git a/apps/api/src/stock/stock-adjustments.ts b/apps/api/src/stock/stock-adjustments.ts deleted file mode 100644 index 09a4339..0000000 --- a/apps/api/src/stock/stock-adjustments.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { VenueLedger } from '../tabs/venue-ledger'; - -type Tx = Prisma.TransactionClient; - -/** - * Applies the venue's pending dashboard adjustments (or only [ids]) to the stock, oldest first, and records the - * outcome. Returns how many were handled. - */ -export async function applyPendingAdjustments(tx: Tx, ledger: VenueLedger, now: Date, ids?: string[]): Promise { - const pending = await tx.stockAdjustment.findMany({ - where: { venueId: ledger.venueId, status: 'pending', ...(ids ? { id: { in: ids } } : {}) }, - orderBy: { createdAt: 'asc' }, - }); - // Created in one go share their time: keep the order they were entered in - if (ids) pending.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); - - for (const adjustment of pending) { - let stockAfter: number | null; - if (adjustment.type === 'set') { - // A count on the dashboard is the latest count, also when a terminal's clock runs a little ahead - ledger.stock.count(adjustment.productId, adjustment.quantity, now, { force: true }); - stockAfter = ledger.stock.get(adjustment.productId) ? adjustment.quantity : null; - } else { - stockAfter = ledger.stock.add(adjustment.productId, adjustment.quantity); - } - - const rejected = stockAfter === null - ? (ledger.stock.get(adjustment.productId) ? 'stock_not_tracked' : 'unknown_product') - : null; - await tx.stockAdjustment.update({ - where: { id: adjustment.id }, - data: { status: rejected ? 'rejected' : 'applied', stockAfter, result: rejected, processedAt: now }, - }); - } - - return pending.length; -} diff --git a/apps/api/src/stock/stock-rules.spec.ts b/apps/api/src/stock/stock-rules.spec.ts deleted file mode 100644 index 00c7dad..0000000 --- a/apps/api/src/stock/stock-rules.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { TabEventDto } from '../devices/dto/tab-event.dto'; -import { VenueStock } from './stock-rules'; - -const at = (minutes: number) => new Date(Date.UTC(2026, 8, 24, 20, minutes)); - -const sale = (type: 'line_added' | 'line_removed', quantity: number, productId = 'cola') => ( - { id: 'e', type, productId, quantity } as TabEventDto); - -function stockOf(stock: number | null, countedAt: Date | null = null) { - return new VenueStock(new Map([['cola', { stock, countedAt }]])); -} - -describe('VenueStock', () => { - it('counts sales down and drinks taken off back up', () => { - const stock = stockOf(10); - - stock.apply(sale('line_added', 3), at(1)); - stock.apply(sale('line_removed', 1), at(2)); - - expect(stock.get('cola')!.stock).toBe(8); - }); - - it('may go negative: a sale is never blocked', () => { - const stock = stockOf(1); - - stock.apply(sale('line_added', 3), at(1)); - - expect(stock.get('cola')!.stock).toBe(-2); - }); - - it('ignores untracked and unknown products, and events without a product', () => { - const stock = stockOf(null); - - stock.apply(sale('line_added', 3), at(1)); - stock.apply(sale('line_added', 3, 'unknown'), at(1)); - stock.apply({ ...sale('line_added', 3), productId: undefined }, at(1)); - - expect(stock.get('cola')!.stock).toBeNull(); - expect(stock.changed.size).toBe(0); - }); - - it('skips sales from before the latest count, which already has them', () => { - const stock = stockOf(30, at(10)); - - stock.apply(sale('line_added', 2), at(10)); - stock.apply(sale('line_added', 2), at(11)); - - expect(stock.get('cola')!.stock).toBe(28); - }); - - it('lets the latest count win, unless forced by the dashboard', () => { - const stock = stockOf(30, at(10)); - - expect(stock.count('cola', 70, at(5))).toBe(false); - expect(stock.count('cola', null, at(12))).toBe(true); - expect(stock.get('cola')).toEqual({ stock: null, countedAt: at(12) }); - - stock.count('cola', 40, at(11), { force: true }); - expect(stock.get('cola')).toEqual({ stock: 40, countedAt: at(12) }); - }); - - it('only adds to a tracked product', () => { - expect(stockOf(10).add('cola', 24)).toBe(34); - expect(stockOf(null).add('cola', 24)).toBeNull(); - expect(stockOf(10).add('unknown', 24)).toBeNull(); - }); -}); diff --git a/apps/api/src/stock/stock.controller.ts b/apps/api/src/stock/stock.controller.ts index df9fd4d..2a69656 100644 --- a/apps/api/src/stock/stock.controller.ts +++ b/apps/api/src/stock/stock.controller.ts @@ -1,102 +1,83 @@ -import { - Controller, Get, Post, Patch, Delete, Param, Query, Body, UseGuards, HttpCode, HttpStatus, Req, UploadedFile, UseInterceptors, -} from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, UploadedFile, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiConsumes } from '@nestjs/swagger'; +import { z } from 'zod'; +import { createProductSchema, Product, stockMovementsQuerySchema, stockMovementsSchema, updateProductSchema } from '@kooltap/shared'; +import { zod } from '../common/zod.pipe'; +import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth'; import { ImagesService } from '../images/images.service'; import { MAX_UPLOAD_BYTES } from '../images/image-processing'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { VenueAccessGuard } from '../common/guards/venue-access.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; -import { Roles } from '../common/decorators/roles.decorator'; import { StockService } from './stock.service'; -import { CreateStockAdjustmentsDto } from './dto/create-stock-adjustments.dto'; -import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto'; -@ApiTags('Stock') -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) @Controller('venues/:venueId/stock') +@Authorized() export class StockController { - constructor(private service: StockService, private images: ImagesService) {} + constructor( + private stock: StockService, + private images: ImagesService, + ) {} - @Get() - @ApiOperation({ summary: "The venue's products (also archived ones) with their stock" }) - async findAll(@Param('venueId') venueId: string) { - return this.service.findAll(venueId); + @Get('products') + products(@Param('venueId') venueId: string): Promise { + return this.stock.products(venueId); } - @Get('adjustments') - @ApiOperation({ summary: 'Stock adjustment history' }) - @ApiQuery({ name: 'productId', required: false }) - @ApiQuery({ name: 'limit', required: false }) - @ApiQuery({ name: 'offset', required: false }) - async history( + @Get('movements') + movements(@Param('venueId') venueId: string, @Query(zod(stockMovementsQuerySchema)) query: z.output) { + return this.stock.movements(venueId, query); + } + + @Post('movements') + @Roles('admin', 'manager') + addMovements( @Param('venueId') venueId: string, - @Query('productId') productId?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, + @Body(zod(stockMovementsSchema)) dto: z.output, + @CurrentUser() user: AuthUser, ) { - return this.service.history(venueId, { - productId: productId || undefined, - limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200), - offset: Math.max(parseInt(offset ?? '', 10) || 0, 0), - }); + return this.stock.addMovements(venueId, dto.movements, user); } - // Viewers (bar staff) change stock too; only the warning level is an admin setting - @Post('adjustments') - @ApiOperation({ summary: 'Add to, take from or count the stock of one or more products' }) - async create(@Param('venueId') venueId: string, @Body() dto: CreateStockAdjustmentsDto, @Req() req: any) { - return this.service.createAdjustments(venueId, dto.adjustments, req.user); - } - - @Post('adjustments/:id/cancel') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: 'Cancel a stock adjustment the terminal has not processed yet' }) - async cancel(@Param('venueId') venueId: string, @Param('id') id: string, @Req() req: any) { - return this.service.cancelAdjustment(venueId, id, req.user); - } - - // The catalog is managed here; the terminals get it on every sync @Post('products') @Roles('admin', 'manager') - @ApiOperation({ summary: 'Add a product (admin)' }) - async createProduct(@Param('venueId') venueId: string, @Body() dto: CreateProductDto, @Req() req: any) { - return this.service.createProduct(venueId, dto, req.user); + createProduct( + @Param('venueId') venueId: string, + @Body(zod(createProductSchema)) dto: z.output, + @CurrentUser() user: AuthUser, + ): Promise { + return this.stock.createProduct(venueId, dto, user); } @Patch('products/:productId') @Roles('admin', 'manager') - @ApiOperation({ summary: 'Change, archive (delete) or restore a product (admin)' }) - async updateProduct( + updateProduct( @Param('venueId') venueId: string, - @Param('productId') productId: string, - @Body() dto: UpdateProductDto, - @Req() req: any, - ) { - return this.service.updateProduct(venueId, productId, dto, req.user); + @Param('productId', ParseUUIDPipe) productId: string, + @Body(zod(updateProductSchema)) dto: z.output, + @CurrentUser() user: AuthUser, + ): Promise { + return this.stock.updateProduct(venueId, productId, dto, user); } + /** Cropped to a square and stored; the terminals download it on their next sync. */ @Post('products/:productId/image') @Roles('admin', 'manager') @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } })) - @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'Upload a product picture: cropped to a square, stored as WebP' }) async uploadImage( @Param('venueId') venueId: string, - @Param('productId') productId: string, + @Param('productId', ParseUUIDPipe) productId: string, @UploadedFile() file: Express.Multer.File | undefined, - @Req() req: any, - ) { + @CurrentUser() user: AuthUser, + ): Promise { const imageId = await this.images.store(file?.buffer); - return this.service.setImage(venueId, productId, imageId, req.user); + return this.stock.updateProduct(venueId, productId, { imageId }, user); } @Delete('products/:productId/image') @Roles('admin', 'manager') - @ApiOperation({ summary: "Remove a product's picture" }) - async removeImage(@Param('venueId') venueId: string, @Param('productId') productId: string, @Req() req: any) { - return this.service.setImage(venueId, productId, null, req.user); + removeImage( + @Param('venueId') venueId: string, + @Param('productId', ParseUUIDPipe) productId: string, + @CurrentUser() user: AuthUser, + ): Promise { + return this.stock.updateProduct(venueId, productId, { imageId: null }, user); } } diff --git a/apps/api/src/stock/stock.module.ts b/apps/api/src/stock/stock.module.ts index 695775b..e210180 100644 --- a/apps/api/src/stock/stock.module.ts +++ b/apps/api/src/stock/stock.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; +import { ImagesModule } from '../images/images.module'; import { StockController } from './stock.controller'; import { StockService } from './stock.service'; -import { ImagesModule } from '../images/images.module'; @Module({ imports: [ImagesModule], diff --git a/apps/api/src/stock/stock.service.spec.ts b/apps/api/src/stock/stock.service.spec.ts deleted file mode 100644 index 4a55dd9..0000000 --- a/apps/api/src/stock/stock.service.spec.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { StockService } from './stock.service'; -import { createFakePrisma } from '../testing/fake-prisma'; - -const user = { id: 'u1', email: 'bram@example.com' }; - -function createService({ products = [] as { productId: string; name: string; stock: number | null }[], cancelled = 1, existing = null as unknown } = {}) { - const prisma = { - product: { findMany: jest.fn().mockResolvedValue(products), updateMany: jest.fn().mockResolvedValue({ count: 1 }) }, - stockAdjustment: { - create: jest.fn((args: { data: Record }) => ({ id: 'a1', ...args.data })), - updateMany: jest.fn().mockResolvedValue({ count: cancelled }), - findFirst: jest.fn().mockResolvedValue(existing), - }, - $transaction: jest.fn((operations: unknown[]) => Promise.all(operations)), - }; - const audit = { log: jest.fn() }; - const realtime = { notify: jest.fn() }; - const service = new StockService(prisma as any, audit as any, realtime as any); - return { service, prisma, audit, realtime }; -} - -const cola = { productId: 'p1', name: 'Cola', stock: 10 }; -const water = { productId: 'p2', name: 'Water', stock: null }; - -function createWithVenue(products: { productId: string; name: string; stock: number | null }[]) { - const prisma = createFakePrisma(); - prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') }); - for (const p of products) { - prisma.product.rows.push({ - venueId: 'v1', ...p, category: null, priceInCents: 250, countedAt: null, lowStockThreshold: null, archivedAt: null, - reportedAt: new Date(), - }); - } - const realtime = { notify: jest.fn() }; - const service = new StockService(prisma as any, { log: jest.fn() } as any, realtime as any); - return { service, prisma, realtime }; -} - -describe('StockService.createAdjustments', () => { - it('applies the adjustments at once, in the order they were entered', async () => { - const { service, prisma, realtime } = createWithVenue([cola, water]); - - const result = await service.createAdjustments('v1', [ - { productId: 'p1', type: 'add', quantity: 24, note: 'Delivery' }, - { productId: 'p2', type: 'set', quantity: 12 }, - { productId: 'p1', type: 'add', quantity: -4, note: 'Broken' }, - ], user); - - expect(result.adjustments).toMatchObject([ - { productId: 'p1', productName: 'Cola', status: 'applied', stockAfter: 34, note: 'Delivery', requestedBy: 'bram@example.com' }, - { productId: 'p2', status: 'applied', stockAfter: 12 }, - { productId: 'p1', status: 'applied', stockAfter: 30 }, - ]); - expect(prisma.product.rows.map((p) => p.stock)).toEqual([30, 12]); - // A count starts tracking and becomes the latest count - expect(prisma.product.rows[1].countedAt).toBeInstanceOf(Date); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock'); - }); - - it('rejects a negative count and adding zero', async () => { - const { service } = createService({ products: [cola] }); - - await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'set', quantity: -1 }], user)) - .rejects.toMatchObject({ response: { code: 'invalid_quantity' } }); - await expect(service.createAdjustments('v1', [{ productId: 'p1', type: 'add', quantity: 0 }], user)) - .rejects.toMatchObject({ response: { code: 'invalid_quantity' } }); - }); - - it('rejects unknown products and adding to an untracked product, creating nothing', async () => { - const { service, prisma } = createService({ products: [cola, water] }); - - await expect(service.createAdjustments('v1', [{ productId: 'p9', type: 'set', quantity: 1 }], user)) - .rejects.toMatchObject({ response: { code: 'product_not_found' } }); - await expect(service.createAdjustments('v1', [ - { productId: 'p1', type: 'add', quantity: 6 }, - { productId: 'p2', type: 'add', quantity: 6 }, - ], user)).rejects.toMatchObject({ response: { code: 'stock_not_tracked' } }); - expect(prisma.stockAdjustment.create).not.toHaveBeenCalled(); - }); -}); - -describe('StockService.cancelAdjustment', () => { - it('cancels a pending adjustment', async () => { - const { service, prisma, realtime } = createService(); - - await expect(service.cancelAdjustment('v1', 'a1', user)).resolves.toEqual({ ok: true }); - expect(prisma.stockAdjustment.updateMany).toHaveBeenCalledWith({ - where: { id: 'a1', venueId: 'v1', status: 'pending' }, - data: { status: 'cancelled', processedAt: expect.any(Date) }, - }); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock'); - }); - - it('refuses once the terminal processed it', async () => { - const { service } = createService({ cancelled: 0, existing: { id: 'a1', status: 'applied' } }); - - await expect(service.cancelAdjustment('v1', 'a1', user)).rejects.toMatchObject({ response: { code: 'adjustment_not_pending' } }); - }); - - it('is idempotent and 404s for another venue', async () => { - await expect(createService({ cancelled: 0, existing: { id: 'a1', status: 'cancelled' } }).service.cancelAdjustment('v1', 'a1', user)) - .resolves.toEqual({ ok: true }); - await expect(createService({ cancelled: 0 }).service.cancelAdjustment('v1', 'a1', user)) - .rejects.toMatchObject({ response: { code: 'adjustment_not_found' } }); - }); -}); - -describe('StockService products', () => { - const admin = { id: 'u1', email: 'bram@example.com', name: 'Bram' }; - - it('adds a product with a starting count, kept in the stock history', async () => { - const { service, prisma, realtime } = createWithVenue([cola]); - - const product = await service.createProduct('v1', { name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24 }, admin); - - expect(product).toMatchObject({ name: 'Water', category: 'Frisdrank', priceInCents: 200, stock: 24, archivedAt: null }); - expect(prisma.product.rows.find((p) => p.productId === product.productId)!.countedAt).toBeInstanceOf(Date); - expect(prisma.stockAdjustment.rows).toMatchObject([ - { productId: product.productId, type: 'set', quantity: 24, status: 'applied', stockAfter: 24, requestedBy: 'Bram' }, - ]); - expect(realtime.notify).toHaveBeenCalledWith('v1', 'stock'); - }); - - it('adds an untracked product without history', async () => { - const { service, prisma } = createWithVenue([]); - - const product = await service.createProduct('v1', { name: 'Tap water', priceInCents: 0 }, admin); - - expect(product).toMatchObject({ stock: null, category: null, lowStockThreshold: null }); - expect(prisma.stockAdjustment.rows).toEqual([]); - }); - - it('refuses a second active product with the same name, ignoring case', async () => { - const { service } = createWithVenue([cola]); - - await expect(service.createProduct('v1', { name: ' cola ', priceInCents: 250 }, admin)) - .rejects.toMatchObject({ response: { code: 'product_name_exists' } }); - }); - - it('changes only the fields that are sent', async () => { - const { service, prisma } = createWithVenue([cola]); - - await service.updateProduct('v1', 'p1', { priceInCents: 300, category: null }, admin); - - expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola', priceInCents: 300, category: null, stock: 10, archivedAt: null }); - }); - - it('keeps a buy price next to the sell price, and clears it with null', async () => { - const { service, prisma } = createWithVenue([]); - - const product = await service.createProduct('v1', { name: 'Water', priceInCents: 200, buyPriceInCents: 80 }, admin); - expect(product).toMatchObject({ priceInCents: 200, buyPriceInCents: 80 }); - - await service.updateProduct('v1', product.productId, { buyPriceInCents: 90 }, admin); - expect(prisma.product.rows[0]).toMatchObject({ priceInCents: 200, buyPriceInCents: 90 }); - - const cleared = await service.updateProduct('v1', product.productId, { buyPriceInCents: null }, admin); - expect(cleared.buyPriceInCents).toBeNull(); - }); - - it('keeps a desired stock, and clears it with null', async () => { - const { service, prisma } = createWithVenue([]); - - const product = await service.createProduct('v1', { name: 'Cola', priceInCents: 250, stock: 43, desiredStock: 100 }, admin); - expect(product).toMatchObject({ stock: 43, desiredStock: 100 }); - - await service.updateProduct('v1', product.productId, { desiredStock: 120 }, admin); - expect(prisma.product.rows[0]).toMatchObject({ stock: 43, desiredStock: 120 }); - - const cleared = await service.updateProduct('v1', product.productId, { desiredStock: null }, admin); - expect(cleared.desiredStock).toBeNull(); - }); - - it('archives a product and brings it back, unless its name was taken meanwhile', async () => { - const { service, prisma } = createWithVenue([cola]); - - await service.updateProduct('v1', 'p1', { archived: true }, admin); - expect(prisma.product.rows[0].archivedAt).toBeInstanceOf(Date); - - await service.createProduct('v1', { name: 'Cola', priceInCents: 250 }, admin); - await expect(service.updateProduct('v1', 'p1', { archived: false }, admin)) - .rejects.toMatchObject({ response: { code: 'product_name_exists' } }); - - await service.updateProduct('v1', 'p1', { archived: false, name: 'Cola (old)' }, admin); - expect(prisma.product.rows[0]).toMatchObject({ name: 'Cola (old)', archivedAt: null }); - }); - - it('404s for a product of another venue', async () => { - const { service } = createWithVenue([cola]); - - await expect(service.updateProduct('v1', 'p9', { priceInCents: 1 }, admin)) - .rejects.toMatchObject({ response: { code: 'product_not_found' } }); - }); -}); - -describe('StockService.findAll', () => { - it('sends when each product was last counted, archived ones included', async () => { - const countedAt = new Date('2026-10-20T08:00:00Z'); - const { service, prisma } = createWithVenue([cola, water]); - prisma.product.rows[0].countedAt = countedAt; - prisma.product.rows[1].archivedAt = new Date(); - - const { products } = await service.findAll('v1'); - - expect(products).toEqual([ - expect.objectContaining({ productId: 'p1', stock: 10, countedAt, archivedAt: null }), - expect.objectContaining({ productId: 'p2', countedAt: null, archivedAt: expect.any(Date) }), - ]); - expect(products[0]).not.toHaveProperty('pendingAdjustments'); - }); -}); - -describe('StockService pictures and field times', () => { - const admin = { id: 'u1', email: 'bram@example.com', name: 'Bram' }; - - it('sets and removes a picture', async () => { - const { service, prisma } = createWithVenue([cola]); - - expect(await service.setImage('v1', 'p1', 'f'.repeat(64), admin)).toMatchObject({ imageId: 'f'.repeat(64) }); - expect(prisma.product.rows[0].imageId).toBe('f'.repeat(64)); - - expect(await service.setImage('v1', 'p1', null, admin)).toMatchObject({ imageId: null }); - }); - - it('a change on the dashboard wins even over a terminal clock running a little ahead', async () => { - const { service, prisma } = createWithVenue([cola]); - const ahead = new Date(Date.now() + 60_000).toISOString(); - prisma.product.rows[0].fieldUpdatedAt = { priceInCents: ahead }; - - await service.updateProduct('v1', 'p1', { priceInCents: 300 }, admin); - - expect(prisma.product.rows[0].priceInCents).toBe(300); - }); - - it('records when each field changed', async () => { - const { service, prisma } = createWithVenue([]); - - const created = await service.createProduct('v1', { name: 'Water', priceInCents: 200 }, admin); - const times = prisma.product.rows.find((p) => p.productId === created.productId)!.fieldUpdatedAt; - - expect(Object.keys(times).sort()).toEqual(['archived', 'buyPriceInCents', 'category', 'desiredStock', 'lowStockThreshold', 'name', 'priceInCents']); - }); -}); diff --git a/apps/api/src/stock/stock.service.ts b/apps/api/src/stock/stock.service.ts index cad34b2..218b22b 100644 --- a/apps/api/src/stock/stock.service.ts +++ b/apps/api/src/stock/stock.service.ts @@ -1,173 +1,127 @@ -import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { v4 as uuidv4 } from 'uuid'; -import { PrismaService } from '../prisma/prisma.service'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { z } from 'zod'; +import { createProductSchema, Product, StockMovement, stockMovementsSchema, updateProductSchema } from '@kooltap/shared'; +import { PrismaService, Tx } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { RealtimeService } from '../realtime/realtime.service'; -import { StockAdjustmentEntryDto } from './dto/create-stock-adjustments.dto'; -import { CreateProductDto, UpdateProductDto } from './dto/update-product.dto'; -import { VenueLedger } from '../tabs/venue-ledger'; -import { applyPendingAdjustments } from './stock-adjustments'; -import { createdFieldTimes, mergeProductChanges } from './product-fields'; +import { AuthUser } from '../common/auth'; +import { lockVenue, VenueLedger } from '../ledger/venue-ledger'; +import { createdFieldTimes, dashboardChangeTime, mergeProductChanges, ProductChanges } from '../ledger/product-fields'; -const adjustmentSelect = { - id: true, - productId: true, - productName: true, - type: true, - quantity: true, - note: true, - requestedBy: true, - status: true, - stockAfter: true, - result: true, - createdAt: true, - processedAt: true, -} as const; +type ProductRow = { + id: string; name: string; category: string | null; priceInCents: number; buyPriceInCents: number | null; stock: number | null; + lowStockThreshold: number | null; desiredStock: number | null; countedAt: Date | null; updatedAt: Date; archivedAt: Date | null; + imageId: string | null; +}; + +function toProduct(p: ProductRow): Product { + return { + id: p.id, + name: p.name, + category: p.category, + priceInCents: p.priceInCents, + buyPriceInCents: p.buyPriceInCents, + stock: p.stock, + lowStockThreshold: p.lowStockThreshold, + desiredStock: p.desiredStock, + countedAt: p.countedAt?.toISOString() ?? null, + updatedAt: p.updatedAt.toISOString(), + archivedAt: p.archivedAt?.toISOString() ?? null, + imageId: p.imageId, + }; +} @Injectable() export class StockService { constructor( private prisma: PrismaService, - private auditService: AuditService, + private audit: AuditService, private realtime: RealtimeService, ) {} - /** Adjustments are applied at once, so there is nothing pending to show with the products. */ - async findAll(venueId: string) { - const products = await this.prisma.product.findMany({ - where: { venueId }, - orderBy: [{ category: 'asc' }, { name: 'asc' }], - }); - - return { - products: products.map(productView), - // Null until the venue has products - reportedAt: products.reduce((latest, p) => (!latest || p.reportedAt > latest ? p.reportedAt : latest), null), - }; + /** The venue's products, archived ones included, with their stock. */ + async products(venueId: string): Promise { + const products = await this.prisma.product.findMany({ where: { venueId }, orderBy: [{ category: 'asc' }, { name: 'asc' }] }); + return products.map(toProduct); } - async history(venueId: string, options: { productId?: string; limit: number; offset: number }) { + async movements(venueId: string, options: { productId?: string; limit: number; offset: number }) { const where = { venueId, ...(options.productId ? { productId: options.productId } : {}) }; - const [adjustments, total] = await Promise.all([ - this.prisma.stockAdjustment.findMany({ + const [rows, total] = await Promise.all([ + this.prisma.stockMovement.findMany({ where, orderBy: { createdAt: 'desc' }, take: options.limit, skip: options.offset, - select: adjustmentSelect, + include: { product: { select: { name: true } }, user: { select: { name: true } } }, }), - this.prisma.stockAdjustment.count({ where }), + this.prisma.stockMovement.count({ where }), ]); - return { adjustments, total }; + return { movements: rows.map(toMovement), total }; } - async createAdjustments(venueId: string, entries: StockAdjustmentEntryDto[], user: { id: string; email: string; name?: string }) { - for (const entry of entries) { - if (entry.type === 'set' && entry.quantity < 0) { + /** + * Deliveries, breakage and counts, applied at once (also when every terminal is offline); the terminals get the new + * stock on their next sync. Counting is how tracking starts: adding to an untracked product is refused. + */ + async addMovements(venueId: string, movements: z.output['movements'], user: AuthUser) { + for (const movement of movements) { + if (movement.type === 'count' && movement.quantity < 0) { throw new BadRequestException({ code: 'invalid_quantity', message: 'A stock count cannot be negative' }); } - if (entry.type === 'add' && entry.quantity === 0) { + if (movement.type === 'add' && movement.quantity === 0) { throw new BadRequestException({ code: 'invalid_quantity', message: 'Adding zero changes nothing' }); } } - const products = await this.prisma.product.findMany({ - where: { venueId, productId: { in: entries.map((e) => e.productId) } }, - select: { productId: true, name: true, stock: true }, - }); - const productsById = new Map(products.map((p) => [p.productId, p])); - - for (const entry of entries) { - const product = productsById.get(entry.productId); - if (!product) { - throw new NotFoundException({ code: 'product_not_found', message: 'This product is not on the terminal' }); - } - // Counting is how tracking starts; adding to an untracked product has nothing to add to - if (entry.type === 'add' && product.stock === null) { - throw new BadRequestException({ - code: 'stock_not_tracked', - message: `Stock is not tracked for ${product.name}. Count it first.`, - }); - } - } - - // Applied by the server right away, once, also when every terminal is offline; the terminals get the new stock - // on their next sync - const requestedBy = user.name || user.email; const created = await this.prisma.$transaction(async (tx) => { const now = new Date(); const ledger = await VenueLedger.open(tx, venueId); - const ids: string[] = []; - for (const entry of entries) { - const { id } = await tx.stockAdjustment.create({ - data: { - venueId, - productId: entry.productId, - productName: productsById.get(entry.productId)!.name, - type: entry.type, - quantity: entry.quantity, - note: entry.note ?? null, - requestedBy, - requestedById: user.id, - createdAt: now, - }, - select: { id: true }, - }); - ids.push(id); + const rows = []; + for (const movement of movements) { + const product = ledger.stock.get(movement.productId); + if (!product) throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' }); + + let stockAfter: number | null; + if (movement.type === 'count') { + // A count here is the latest count, also when a terminal's clock runs a little ahead + ledger.stock.count(movement.productId, movement.quantity, now, { force: true }); + stockAfter = movement.quantity; + } else { + stockAfter = ledger.stock.add(movement.productId, movement.quantity); + } + if (stockAfter === null) { + throw new BadRequestException({ code: 'stock_not_tracked', message: 'Stock is not tracked for this product: count it first' }); + } + rows.push({ id: randomUUID(), venueId, productId: movement.productId, type: movement.type, quantity: movement.quantity, stockAfter, note: movement.note ?? null, userId: user.id, createdAt: now }); } - await applyPendingAdjustments(tx, ledger, now, ids); await ledger.save(tx, now); - - const adjustments = await tx.stockAdjustment.findMany({ where: { id: { in: ids } }, select: adjustmentSelect }); - return ids.map((id) => adjustments.find((a) => a.id === id)!); - }); - - await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustments_created', { - adjustments: created.map((a) => ({ id: a.id, productId: a.productId, type: a.type, quantity: a.quantity })), - }); - - this.realtime.notify(venueId, 'stock'); - - return { adjustments: created }; - } - - async cancelAdjustment(venueId: string, id: string, user: { id: string }) { - const { count } = await this.prisma.stockAdjustment.updateMany({ - where: { id, venueId, status: 'pending' }, - data: { status: 'cancelled', processedAt: new Date() }, - }); - - if (count === 0) { - const existing = await this.prisma.stockAdjustment.findFirst({ where: { id, venueId } }); - if (!existing) { - throw new NotFoundException({ code: 'adjustment_not_found', message: 'Stock adjustment not found' }); - } - if (existing.status === 'cancelled') return { ok: true }; // Idempotent - throw new ConflictException({ - code: 'adjustment_not_pending', - message: 'The terminal already processed this stock adjustment', + await tx.stockMovement.createMany({ data: rows }); + return tx.stockMovement.findMany({ + where: { id: { in: rows.map((r) => r.id) } }, + include: { product: { select: { name: true } }, user: { select: { name: true } } }, }); - } + }); - await this.auditService.log(venueId, 'client_user', user.id, 'stock_adjustment_cancelled', { adjustmentId: id }); + await this.audit.log({ + venueId, + actor: { type: 'user', id: user.id }, + action: 'stock_movements_created', + details: { movements: created.map((m) => ({ id: m.id, productId: m.productId, type: m.type, quantity: m.quantity })) }, + }); this.realtime.notify(venueId, 'stock'); - - return { ok: true }; + return { movements: created.map(toMovement) }; } - /** - * Adds a product to the venue's catalog; the terminals get it on their next sync. A starting count tracks its stock - * from now on and is kept in the stock history. - */ - async createProduct(venueId: string, dto: CreateProductDto, user: { id: string; email: string; name?: string }) { + /** Adds a product; the terminals get it on their next sync. A starting count tracks its stock from now on. */ + async createProduct(venueId: string, dto: z.output, user: AuthUser): Promise { const product = await this.prisma.$transaction(async (tx) => { const now = new Date(); - // Locked like a sync, so a name check and the stock can't race with one - await VenueLedger.open(tx, venueId); + // Locked like a sync, so the name check can't race with one + await lockVenue(tx, venueId); await this.ensureNameFree(tx, venueId, dto.name); - const stock = dto.stock ?? null; const fields = { name: dto.name, category: dto.category ?? null, @@ -176,157 +130,83 @@ export class StockService { lowStockThreshold: dto.lowStockThreshold ?? null, desiredStock: dto.desiredStock ?? null, }; + const stock = dto.stock ?? null; const created = await tx.product.create({ data: { + id: randomUUID(), venueId, - productId: uuidv4(), ...fields, stock, countedAt: stock === null ? null : now, - reportedAt: now, fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now), + updatedAt: now, }, }); if (stock !== null) { - await tx.stockAdjustment.create({ - data: { - venueId, - productId: created.productId, - productName: created.name, - type: 'set', - quantity: stock, - requestedBy: user.name || user.email, - requestedById: user.id, - status: 'applied', - stockAfter: stock, - createdAt: now, - processedAt: now, - }, - }); + await tx.stockMovement.create({ data: { venueId, productId: created.id, type: 'count', quantity: stock, stockAfter: stock, userId: user.id, createdAt: now } }); } return created; }); - await this.auditService.log(venueId, 'client_user', user.id, 'product_created', { - productId: product.productId, name: product.name, priceInCents: product.priceInCents, - }); + await this.audit.log({ venueId, actor: { type: 'user', id: user.id }, action: 'product_created', details: { productId: product.id, name: product.name } }); this.realtime.notify(venueId, 'stock'); - - return productView(product); + return toProduct(product); } /** - * Changes a product's name, category, sell or buy price, warning level, desired stock, or archives it (a deleted product stays, hidden, because - * tabs and history refer to it). Lines already on a tab keep the name and price they were rung up with. + * Changes or archives (deletes) a product. A deleted product stays, hidden, because tabs and history refer to it. + * Drinks already rung up keep the name and price they were sold with. */ - async updateProduct(venueId: string, productId: string, dto: UpdateProductDto, user: { id: string }) { + async updateProduct(venueId: string, productId: string, changes: ProductChanges, user: AuthUser): Promise { const product = await this.prisma.$transaction(async (tx) => { - await VenueLedger.open(tx, venueId); - const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } }); - if (!existing) { - throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' }); - } + await lockVenue(tx, venueId); + const existing = await this.findProduct(tx, venueId, productId); - const archived = dto.archived ?? existing.archivedAt != null; - const name = dto.name ?? existing.name; // A product coming back, or renamed, must not take an active product's name - if (!archived && (name !== existing.name || existing.archivedAt != null)) { - await this.ensureNameFree(tx, venueId, name, productId); - } - - // A change made here is the latest change of each field it touches - const now = new Date(); - const merged = mergeProductChanges(existing.fieldUpdatedAt, { - ...(dto.name != null ? { name: dto.name } : {}), - ...(dto.category !== undefined ? { category: dto.category } : {}), - ...(dto.priceInCents != null ? { priceInCents: dto.priceInCents } : {}), - ...(dto.buyPriceInCents !== undefined ? { buyPriceInCents: dto.buyPriceInCents } : {}), - ...(dto.lowStockThreshold !== undefined ? { lowStockThreshold: dto.lowStockThreshold } : {}), - ...(dto.desiredStock !== undefined ? { desiredStock: dto.desiredStock } : {}), - ...(dto.archived !== undefined ? { archived: dto.archived } : {}), - }, maxDate(now, existing.fieldUpdatedAt), existing); - - return tx.product.update({ - where: { venueId_productId: { venueId, productId } }, - data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: now }, - }); - }); - - await this.auditService.log(venueId, 'client_user', user.id, 'product_updated', { productId, changes: dto }); - this.realtime.notify(venueId, 'stock'); - - return productView(product); - } - - /** - * Sets (or with null removes) a product's picture; the picture was stored first. Terminals download it on their - * next sync. - */ - async setImage(venueId: string, productId: string, imageId: string | null, user: { id: string }) { - const product = await this.prisma.$transaction(async (tx) => { - await VenueLedger.open(tx, venueId); - const existing = await tx.product.findUnique({ where: { venueId_productId: { venueId, productId } } }); - if (!existing) { - throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' }); - } + const archived = changes.archived ?? existing.archivedAt !== null; + const name = changes.name ?? existing.name; + if (!archived && (name !== existing.name || existing.archivedAt !== null)) await this.ensureNameFree(tx, venueId, name, productId); const now = new Date(); - const merged = mergeProductChanges(existing.fieldUpdatedAt, { imageId }, maxDate(now, existing.fieldUpdatedAt), existing); - return tx.product.update({ - where: { venueId_productId: { venueId, productId } }, - data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, reportedAt: now }, - }); + const merged = mergeProductChanges(existing.fieldUpdatedAt, changes, dashboardChangeTime(now, existing.fieldUpdatedAt), existing); + return tx.product.update({ where: { id: productId }, data: { ...merged.data, fieldUpdatedAt: merged.fieldUpdatedAt, updatedAt: now } }); }); - await this.auditService.log(venueId, 'client_user', user.id, imageId ? 'product_image_set' : 'product_image_removed', { - productId, imageId, - }); + await this.audit.log({ venueId, actor: { type: 'user', id: user.id }, action: 'product_updated', details: { productId, changes } }); this.realtime.notify(venueId, 'stock'); - - return productView(product); + return toProduct(product); } - private async ensureNameFree(tx: Prisma.TransactionClient, venueId: string, name: string, exceptProductId?: string) { - const products = await tx.product.findMany({ - where: { venueId, archivedAt: null }, - select: { productId: true, name: true }, + private async findProduct(tx: Tx, venueId: string, productId: string) { + const product = await tx.product.findFirst({ where: { id: productId, venueId } }); + if (!product) throw new NotFoundException({ code: 'product_not_found', message: 'Product not found' }); + return product; + } + + private async ensureNameFree(tx: Tx, venueId: string, name: string, exceptProductId?: string) { + const taken = await tx.product.findFirst({ + where: { venueId, archivedAt: null, name: { equals: name.trim(), mode: 'insensitive' }, ...(exceptProductId ? { id: { not: exceptProductId } } : {}) }, + select: { id: true }, }); - const key = name.trim().toLowerCase(); - if (products.some((p) => p.productId !== exceptProductId && p.name.trim().toLowerCase() === key)) { - throw new ConflictException({ code: 'product_name_exists', message: 'A product with this name already exists' }); - } + if (taken) throw new ConflictException({ code: 'product_name_exists', message: 'A product with this name already exists' }); } } -/** - * A change on the dashboard is the latest change of its fields. A terminal whose clock runs a little ahead may - * have stamped a field just after now: the dashboard change still wins, it was made last. - */ -function maxDate(now: Date, times: unknown): Date { - const stamps = Object.values((times && typeof times === 'object' ? times : {}) as Record) - .map((t) => new Date(t).getTime()) - .filter((t) => !Number.isNaN(t)); - return new Date(Math.max(now.getTime(), ...stamps)); -} +export type UpdateProduct = z.output; -function productView(p: { - productId: string; name: string; category: string | null; priceInCents: number; buyPriceInCents?: number | null; stock: number | null; - lowStockThreshold: number | null; desiredStock?: number | null; countedAt: Date | null; reportedAt: Date; archivedAt: Date | null; - imageId?: string | null; -}) { +function toMovement(m: { + id: string; productId: string; type: StockMovement['type']; quantity: number; note: string | null; stockAfter: number; createdAt: Date; + product: { name: string }; user: { name: string } | null; +}): StockMovement { return { - productId: p.productId, - name: p.name, - category: p.category, - priceInCents: p.priceInCents, - buyPriceInCents: p.buyPriceInCents ?? null, - stock: p.stock, - lowStockThreshold: p.lowStockThreshold, - desiredStock: p.desiredStock ?? null, - // The latest count, on the dashboard or a terminal: how fresh the stock is - countedAt: p.countedAt, - reportedAt: p.reportedAt, - archivedAt: p.archivedAt, - imageId: p.imageId ?? null, + id: m.id, + productId: m.productId, + productName: m.product.name, + type: m.type, + quantity: m.quantity, + note: m.note, + stockAfter: m.stockAfter, + userName: m.user?.name ?? null, + createdAt: m.createdAt.toISOString(), }; } diff --git a/apps/api/src/tabs/tab-orders.spec.ts b/apps/api/src/tabs/tab-orders.spec.ts deleted file mode 100644 index 5de098f..0000000 --- a/apps/api/src/tabs/tab-orders.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { TabEventDto } from '../devices/dto/tab-event.dto'; -import { tabSummary, VenueTabs } from './tab-state'; -import { tabOrders } from './tab-orders'; - -const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes)); -let counter = 0; - -function event(fields: Partial & { type: TabEventDto['type'] }): TabEventDto { - return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: at(0).toISOString(), ...fields } as TabEventDto; -} - -const duvel = { lineId: 'duvel', productId: 'duvel', productName: 'Duvel', unitPriceInCents: 400 }; -const cola = { lineId: 'cola', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 }; -const beer = { lineId: 'beer', productId: 'beer', productName: 'Beer', unitPriceInCents: 300 }; - -function tabWith(changes: [minutes: number, fields: Partial & { type: TabEventDto['type'] }][]) { - const tabs = new VenueTabs([]); - tabs.apply(event({ type: 'tab_opened' }), at(0)); - for (const [minutes, fields] of changes) tabs.apply(event(fields), at(minutes)); - return tabs; -} - -describe('tabOrders', () => { - it('shows the drinks of each order under the time it started', () => { - const tabs = tabWith([ - [0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }], - [1, { type: 'line_added', quantity: 1, orderId: 'o1', ...cola }], - [38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }], - [39, { type: 'line_added', quantity: 1, orderId: 'o2', ...beer }], - ]); - - expect(tabOrders(tabs.get('t1')!.lines)).toEqual([ - { - orderId: 'o1', startedAt: at(0).toISOString(), - items: [ - { productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 }, - { productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 }, - ], - }, - { - orderId: 'o2', startedAt: at(38).toISOString(), - items: [ - { productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 }, - { productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 }, - ], - }, - ]); - }); - - it('groups drinks from older app versions by time: a gap of over 5 minutes starts a new order', () => { - const tabs = tabWith([ - [0, { type: 'line_added', quantity: 1, ...duvel }], - [4, { type: 'line_added', quantity: 1, ...cola }], - [20, { type: 'line_added', quantity: 1, ...beer }], - ]); - - const orders = tabOrders(tabs.get('t1')!.lines); - - expect(orders.map((order) => [order.orderId, order.startedAt, order.items.map((item) => item.productName)])).toEqual([ - [null, at(0).toISOString(), ['Duvel', 'Cola']], - [null, at(20).toISOString(), ['Beer']], - ]); - }); - - it('takes a drink off the order it is removed from, and leaves the others', () => { - const tabs = tabWith([ - [0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }], - [38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }], - [40, { type: 'line_removed', quantity: 1, orderId: 'o1', ...duvel }], - ]); - - expect(tabOrders(tabs.get('t1')!.lines).map((order) => [order.orderId, order.items[0].quantity, order.items[0].removed])) - .toEqual([['o1', 1, 1], ['o2', 1, 0]]); - // The open tab only shows what is still on it - expect(tabSummary({ ...tabs.get('t1')!, updatedAt: at(40) }).orders.map((order) => order.items)) - .toEqual([[{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }], [{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }]]); - }); - - it('still takes the latest drinks off for a removal without an order (older app versions)', () => { - const tabs = tabWith([ - [0, { type: 'line_added', quantity: 1, orderId: 'o1', ...duvel }], - [38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }], - ]); - const removal = event({ type: 'line_removed', quantity: 5, ...duvel }); - - expect(tabs.removable(removal)).toBe(2); - expect(tabs.removable({ ...removal, orderId: 'o1' })).toBe(1); - expect(tabs.removable({ ...removal, orderId: null })).toBe(0); - }); -}); diff --git a/apps/api/src/tabs/tab-orders.ts b/apps/api/src/tabs/tab-orders.ts deleted file mode 100644 index 3577d81..0000000 --- a/apps/api/src/tabs/tab-orders.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { isActive, Line, Unit } from './tab-state'; - -/** Drinks rung up without an order (older app versions) that follow each other this closely form one order. */ -export const ORDER_GAP_MS = 5 * 60 * 1000; - -export type OrderLine = { productName: string; quantity: number; unitPriceInCents: number; removed: number }; - -/** Drinks ordered together, as the terminal shows them under one "12:32 today" divider. */ -export type TabOrder = { - // Null for drinks from older app versions, grouped by time instead - orderId: string | null; - // When its first drink was rung up - startedAt: string; - items: OrderLine[]; -}; - -/** - * Splits a tab's (or settlement's) lines into its orders, oldest first. Within an order every line shows how many of - * its drinks are still on it and how many were taken off again. - */ -export function tabOrders(lines: Line[]): TabOrder[] { - const drinks = lines - .flatMap((line) => line.units.map((unit) => ({ line, unit }))) - .sort((a, b) => a.unit.addedAt.localeCompare(b.unit.addedAt) || a.unit.id.localeCompare(b.unit.id)); - - const orders: { orderId: string | null; startedAt: string; units: Map }[] = []; - const byId = new Map(); - let unordered: { order: (typeof orders)[number]; lastAt: number } | null = null; - - for (const { line, unit } of drinks) { - let order: (typeof orders)[number]; - if (unit.orderId) { - order = byId.get(unit.orderId) ?? { orderId: unit.orderId, startedAt: unit.addedAt, units: new Map() }; - if (!byId.has(unit.orderId)) { - byId.set(unit.orderId, order); - orders.push(order); - } - } else { - const at = Date.parse(unit.addedAt); - if (!unordered || at - unordered.lastAt > ORDER_GAP_MS) { - unordered = { order: { orderId: null, startedAt: unit.addedAt, units: new Map() }, lastAt: at }; - orders.push(unordered.order); - } - unordered.lastAt = at; - order = unordered.order; - } - order.units.set(line, [...(order.units.get(line) ?? []), unit]); - } - - return orders - .sort((a, b) => a.startedAt.localeCompare(b.startedAt)) - .map(({ orderId, startedAt, units }) => ({ - orderId, - startedAt, - items: [...units].map(([line, lineUnits]) => ({ - productName: line.productName, - quantity: lineUnits.filter(isActive).length, - unitPriceInCents: line.unitPriceInCents, - removed: lineUnits.filter((unit) => !isActive(unit)).length, - })), - })); -} diff --git a/apps/api/src/tabs/tab-state.spec.ts b/apps/api/src/tabs/tab-state.spec.ts deleted file mode 100644 index b215f9a..0000000 --- a/apps/api/src/tabs/tab-state.spec.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { TabEventDto } from '../devices/dto/tab-event.dto'; -import { findLine, seedTabs, tabJson, tabSummary, VenueTabs } from './tab-state'; - -const time = new Date('2026-09-24T20:00:00.000Z'); -let counter = 0; - -function event(fields: Partial & { type: TabEventDto['type'] }): TabEventDto { - return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: time.toISOString(), ...fields } as TabEventDto; -} - -const cola = { productId: 'cola', productName: 'Cola', unitPriceInCents: 250 }; - -function withCola(quantity: number) { - const tabs = new VenueTabs([]); - tabs.apply(event({ type: 'tab_opened' }), time); - tabs.apply(event({ type: 'line_added', lineId: 'line-1', quantity, ...cola }), time); - return tabs; -} - -describe('VenueTabs.apply', () => { - it('ignores a second tab_opened for a tab that exists', () => { - const tabs = withCola(1); - - tabs.apply(event({ type: 'tab_opened', customerName: 'Other' }), time); - - expect(tabs.get('t1')).toMatchObject({ customerName: 'Jan', version: 2 }); - }); - - it('gives a line a new id when its id is taken on another tab', () => { - const tabs = withCola(1); - const added = event({ type: 'line_added', tabId: 't2', lineId: 'line-1', quantity: 1, ...cola }); - - tabs.apply(added, time); - - expect(tabs.get('t2')!.lines[0].id).toBe(`${added.id}:line`); - }); - - it('removes no more drinks than are on the line', () => { - const tabs = withCola(2); - - tabs.apply(event({ type: 'line_removed', lineId: 'line-1', quantity: 5, ...cola }), time); - - expect(tabs.get('t1')!.lines[0].units.every((unit) => unit.removedAt !== null)).toBe(true); - }); - - it('keeps settled lines the server never saw in the settlement, with made-up units', () => { - const tabs = withCola(1); - const settled = event({ - type: 'settled', amountInCents: 650, paymentMethod: 'cash', - items: [ - { productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 250 }, - { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, - ], - }); - - const settlement = tabs.apply(settled, time)!; - - expect(settlement.amountInCents).toBe(650); - expect(settlement.lines).toMatchObject([ - { id: 'line-1', quantity: 1 }, - { productId: null, productName: 'Chips', quantity: 2, units: [{ id: `${settled.id}:item:1:0` }, { id: `${settled.id}:item:1:1` }] }, - ]); - expect(tabs.get('t1')!.lines).toEqual([]); - }); - - it('records a settlement of a tab that is gone', () => { - const tabs = new VenueTabs([]); - - const settlement = tabs.apply(event({ - type: 'settled', amountInCents: 250, paymentMethod: 'cash', - items: [{ productName: 'Cola', quantity: 1, unitPriceInCents: 250 }], - }), time); - - expect(settlement).toMatchObject({ tabId: 't1', customerName: 'Jan', lines: [{ quantity: 1 }] }); - expect(tabs.all()).toEqual([]); - }); - - it('changes nothing for events about tabs that are gone', () => { - const tabs = new VenueTabs([]); - - tabs.apply(event({ type: 'tab_renamed' }), time); - tabs.apply(event({ type: 'tab_deleted' }), time); - tabs.apply(event({ type: 'line_removed', quantity: 1, ...cola }), time); - - expect(tabs.changed.size + tabs.deleted.size).toBe(0); - }); -}); - -describe('VenueTabs.settleAll', () => { - it('settles every drink, keeping the tab open and empty', () => { - const tabs = withCola(3); - tabs.apply(event({ type: 'line_removed', lineId: 'line-1', quantity: 1, ...cola }), time); - - const settlement = tabs.settleAll('t1', { id: 's1', settledAt: time, paymentMethod: 'cash' })!; - - expect(settlement).toMatchObject({ amountInCents: 500, lines: [{ quantity: 2 }] }); - expect(settlement.lines[0].units).toHaveLength(3); - expect(tabJson(tabs.get('t1')!)).toMatchObject({ version: 4, lines: [] }); - }); - - it('returns null for an empty tab', () => { - const tabs = new VenueTabs([]); - tabs.apply(event({ type: 'tab_opened' }), time); - - expect(tabs.settleAll('t1', { id: 's1', settledAt: time, paymentMethod: 'cash' })).toBeNull(); - }); -}); - -describe('findLine', () => { - it('prefers the line id, then the product, then the name, at the same price', () => { - const tab = { - id: 't1', customerName: 'Jan', openedAt: time, version: 0, - lines: [ - { id: 'a', productId: null, productName: 'Cola', unitPriceInCents: 250, units: [] }, - { id: 'b', productId: 'cola', productName: 'Coca-Cola', unitPriceInCents: 250, units: [] }, - { id: 'c', productId: 'cola', productName: 'Cola', unitPriceInCents: 300, units: [] }, - ], - }; - - expect(findLine(tab, { lineId: 'c', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('c'); - expect(findLine(tab, { productId: 'cola', productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('b'); - expect(findLine(tab, { productName: 'Cola', unitPriceInCents: 250 })!.id).toBe('a'); - expect(findLine(tab, { productName: 'Cola', unitPriceInCents: 100 })).toBeUndefined(); - }); -}); - -describe('VenueTabs.removable', () => { - it('counts only drinks still on the line, so a removal after a settlement returns nothing to the stock', () => { - const tabs = withCola(3); - const removal = event({ type: 'line_removed', lineId: 'line-1', quantity: 5, ...cola }); - expect(tabs.removable(removal)).toBe(3); - - tabs.apply(event({ type: 'settled', amountInCents: 750, paymentMethod: 'cash', items: [{ ...cola, quantity: 3 }] }), time); - expect(tabs.removable(removal)).toBe(0); - expect(tabs.removable(event({ type: 'line_removed', tabId: 'gone', quantity: 1, ...cola }))).toBe(0); - }); -}); - -describe('seedTabs and tabSummary', () => { - it("turns a terminal's snapshot into tabs the dashboard sums up the same", () => { - const [seeded] = seedTabs([{ - id: 't1', customerName: 'Jan', version: 7, totalInCents: 900, itemCount: 4, - items: [ - { productName: 'Cola', quantity: 2, unitPriceInCents: 250 }, - { productName: 'Chips', quantity: 2, unitPriceInCents: 200 }, - { productName: 'Gone', quantity: 0, unitPriceInCents: 100 }, - ], - }], time); - - expect(seeded.lines.map((line) => line.units.length)).toEqual([2, 2]); - expect(tabSummary({ ...seeded, updatedAt: time })).toMatchObject({ totalInCents: 900, itemCount: 4, version: 7 }); - }); -}); diff --git a/apps/api/src/tabs/tab-state.ts b/apps/api/src/tabs/tab-state.ts deleted file mode 100644 index 35c057d..0000000 --- a/apps/api/src/tabs/tab-state.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { TabEventDto } from '../devices/dto/tab-event.dto'; -import { OpenTabDto } from '../devices/dto/sync.dto'; -import { tabOrders } from './tab-orders'; - -/** - * The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and - * stores what changed. The terminals apply the same rules to replay their unsent events, so a unit's id must be - * derived the same way on both sides (`:`). - */ - -/** - * One piece ever put on a line. [orderId] is the order it was rung up in on the terminal: drinks ordered together are - * shown together. Missing for drinks from older app versions. - */ -export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId?: string | null }; - -export type Line = { - id: string; - productId: string | null; - productName: string; - unitPriceInCents: number; - units: Unit[]; -}; - -export type TabState = { - id: string; - customerName: string; - openedAt: Date; - version: number; - lines: Line[]; -}; - -export type SettledLine = Line & { quantity: number }; - -export type SettlementDraft = { - id: string; - tabId: string; - customerName: string; - settledAt: Date; - paymentMethod: string; - amountInCents: number; - lines: SettledLine[]; - commandId: string | null; -}; - -/** What an event needs to find its line. */ -type LineRef = { lineId?: string; productId?: string | null; productName?: string; unitPriceInCents?: number }; - -export const isActive = (unit: Unit) => unit.removedAt === null; - -export const lineQuantity = (line: Line) => line.units.filter(isActive).length; - -/** - * The drinks of [line] a line_removed event may take off: those of its order (null: drinks without one), or any when - * the event has no order (older app versions). - */ -function removableUnits(line: Line, event: { orderId?: string | null }): Unit[] { - const active = line.units.filter(isActive); - if (event.orderId === undefined) return active; - return active.filter((unit) => (unit.orderId ?? null) === event.orderId); -} - -export class VenueTabs { - private readonly tabs: Map; - /** Tabs to write back, and tabs to delete. */ - readonly changed = new Set(); - readonly deleted = new Set(); - - constructor(tabs: TabState[]) { - this.tabs = new Map(tabs.map((tab) => [tab.id, tab])); - } - - get(tabId: string): TabState | undefined { - return this.tabs.get(tabId); - } - - all(): TabState[] { - return [...this.tabs.values()]; - } - - /** - * Applies one event that happened at [occurredAt]. Returns the settlement a `settled` event made. Events about a - * tab or line that is gone change nothing (they are acknowledged all the same). - */ - apply(event: TabEventDto, occurredAt: Date): SettlementDraft | null { - const tabId = event.tabId; - if (!tabId) return null; - const customerName = event.customerName ?? ''; - - switch (event.type) { - case 'tab_opened': - if (!this.tabs.has(tabId)) { - this.open(tabId, customerName, event.openedAt ? new Date(event.openedAt) : occurredAt); - this.touch(tabId); - } - return null; - - case 'tab_renamed': { - const tab = this.tabs.get(tabId); - if (tab) { - tab.customerName = customerName; - this.touch(tabId); - } - return null; - } - - case 'tab_deleted': - this.remove(tabId); - return null; - - case 'line_added': { - // Deleted elsewhere: bring it back, so no sale is lost - const tab = this.tabs.get(tabId) ?? this.open(tabId, customerName, occurredAt); - let line = findLine(tab, event); - if (!line) { - const lineIdTaken = event.lineId && this.all().some((t) => t.lines.some((l) => l.id === event.lineId)); - line = { - id: event.lineId && !lineIdTaken ? event.lineId : `${event.id}:line`, - productId: event.productId ?? null, - productName: event.productName!, - unitPriceInCents: event.unitPriceInCents!, - units: [], - }; - tab.lines.push(line); - } - const addedAt = occurredAt.toISOString(); - const orderId = event.orderId ?? null; - for (let index = 0; index < event.quantity!; index++) { - line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null, orderId }); - } - this.touch(tabId); - return null; - } - - case 'line_removed': { - const tab = this.tabs.get(tabId); - const line = tab && findLine(tab, event); - if (!line) return null; - // The latest ones of the order: the drinks that were just rung up by mistake - const active = removableUnits(line, event); - for (const unit of active.slice(Math.max(0, active.length - event.quantity!))) { - unit.removedAt = occurredAt.toISOString(); - } - this.touch(tabId); - return null; - } - - case 'settled': { - const tab = this.tabs.get(tabId); - const settledAt = occurredAt; - const lines: SettledLine[] = []; - (event.items ?? []).forEach((item, index) => { - const line = tab && findLine(tab, item); - if (line) { - const settled = settleLine(tab, line, item.quantity); - if (settled.units.length > 0) lines.push(settled); - } else if (item.quantity > 0) { - // Settled on the terminal but unknown here: keep it in the history all the same - lines.push({ - id: `${event.id}:item:${index}`, - productId: item.productId ?? null, - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - quantity: item.quantity, - units: Array.from({ length: item.quantity }, (_, n) => ( - { id: `${event.id}:item:${index}:${n}`, addedAt: settledAt.toISOString(), removedAt: null })), - }); - } - }); - if (tab) this.touch(tabId); - - return { - id: event.id, - tabId, - customerName: tab?.customerName ?? customerName, - settledAt, - paymentMethod: event.paymentMethod!, - amountInCents: event.amountInCents!, - lines, - commandId: event.commandId ?? null, - }; - } - - default: - return null; - } - } - - /** - * How many drinks a line_removed event can actually take off: none when the tab or line is gone (settled or - * deleted elsewhere while the terminal was offline). - */ - removable(event: TabEventDto): number { - const tab = event.tabId ? this.tabs.get(event.tabId) : undefined; - const line = tab && findLine(tab, event); - return line ? Math.min(event.quantity ?? 0, removableUnits(line, event).length) : 0; - } - - /** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */ - settleAll(tabId: string, settlement: { id: string; settledAt: Date; paymentMethod: string }): SettlementDraft | null { - const tab = this.tabs.get(tabId); - if (!tab) return null; - - const lines = tab.lines - .filter((line) => lineQuantity(line) > 0) - .map((line) => settleLine(tab, line, lineQuantity(line))); - if (lines.length === 0) return null; - this.touch(tabId); - - return { - ...settlement, - tabId, - customerName: tab.customerName, - amountInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0), - lines, - commandId: null, - }; - } - - /** Deletes a tab, from a terminal or the dashboard. False when it is not open. */ - remove(tabId: string): boolean { - if (!this.tabs.delete(tabId)) return false; - this.changed.delete(tabId); - this.deleted.add(tabId); - return true; - } - - /** Opens a tab without an event: planned customers, and taking over a terminal's tabs. */ - open(tabId: string, customerName: string, openedAt: Date): TabState { - const tab: TabState = { id: tabId, customerName, openedAt, version: 0, lines: [] }; - this.tabs.set(tabId, tab); - this.deleted.delete(tabId); - this.changed.add(tabId); - return tab; - } - - private touch(tabId: string) { - this.tabs.get(tabId)!.version++; - this.changed.add(tabId); - } -} - -/** - * The line with the event's line id; else the oldest line with the same product and price; else the oldest with the - * same name and price (events from app versions without line or product ids). - */ -export function findLine(tab: TabState, ref: LineRef): Line | undefined { - const samePrice = (line: Line) => line.unitPriceInCents === ref.unitPriceInCents; - return (ref.lineId ? tab.lines.find((line) => line.id === ref.lineId) : undefined) - ?? (ref.productId ? tab.lines.find((line) => line.productId === ref.productId && samePrice(line)) : undefined) - ?? tab.lines.find((line) => line.productName === ref.productName && samePrice(line)); -} - -/** - * Takes the [quantity] oldest active units off [line], together with its removed units, and drops the line when - * nothing is left. Drinks added after the settlement (by another terminal) stay on the tab. - */ -function settleLine(tab: TabState, line: Line, quantity: number): SettledLine { - const settled = line.units.filter(isActive).slice(0, quantity); - const removed = line.units.filter((unit) => !isActive(unit)); - const taken = new Set([...settled, ...removed]); - - line.units = line.units.filter((unit) => !taken.has(unit)); - if (line.units.length === 0) tab.lines = tab.lines.filter((l) => l !== line); - - return { - id: line.id, - productId: line.productId, - productName: line.productName, - unitPriceInCents: line.unitPriceInCents, - quantity: settled.length, - units: [...settled, ...removed].sort(byAddedAt), - }; -} - -const byAddedAt = (a: Unit, b: Unit) => a.addedAt.localeCompare(b.addedAt); - -/** - * The terminal's own open tabs, taken over once when the server starts holding the tabs. The snapshot has no line or - * unit ids, so they are made up from the tab id. - */ -export function seedTabs(openTabs: OpenTabDto[], now: Date): TabState[] { - return openTabs.map((tab) => ({ - id: tab.id, - customerName: tab.customerName, - openedAt: now, - version: tab.version, - lines: (tab.items ?? []) - .filter((item) => item.quantity > 0) - .map((item, index) => ({ - id: `seed:${tab.id}:${index}`, - productId: null, - productName: item.productName, - unitPriceInCents: item.unitPriceInCents, - units: Array.from({ length: item.quantity }, (_, n) => ( - { id: `seed:${tab.id}:${index}:${n}`, addedAt: now.toISOString(), removedAt: null })), - })), - })); -} - -/** A tab as sent to terminals and stored: lines carry their quantity. */ -export function tabJson(tab: TabState) { - return { - id: tab.id, - customerName: tab.customerName, - openedAt: tab.openedAt.toISOString(), - version: tab.version, - lines: tab.lines.map(lineJson), - }; -} - -export function lineJson(line: Line) { - return { ...line, quantity: lineQuantity(line) }; -} - -/** A tab as the dashboard shows it: totals, and the lines that are still on it. */ -export function tabSummary(tab: { id: string; customerName: string; version: number; lines: unknown; updatedAt: Date }) { - const lines = (tab.lines as Line[]) - .map((line) => ({ productName: line.productName, quantity: lineQuantity(line), unitPriceInCents: line.unitPriceInCents })) - .filter((line) => line.quantity > 0); - - return { - tabId: tab.id, - customerName: tab.customerName, - version: tab.version, - totalInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0), - itemCount: lines.reduce((sum, line) => sum + line.quantity, 0), - items: lines, - // Only what is still on the tab: drinks taken off again are left out - orders: tabOrders(tab.lines as Line[]) - .map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) })) - .filter((order) => order.items.length > 0), - updatedAt: tab.updatedAt, - }; -} diff --git a/apps/api/src/tabs/tab-sync.ts b/apps/api/src/tabs/tab-sync.ts deleted file mode 100644 index 93844dc..0000000 --- a/apps/api/src/tabs/tab-sync.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { v4 as uuidv4 } from 'uuid'; -import { customerNameKey } from '../planned-tabs/customer-name'; -import { VenueLedger } from './venue-ledger'; -import { SettledLine } from './tab-state'; - -type Tx = Prisma.TransactionClient; - -/** Most settlements sent per sync; a longer feed continues in the next sync. */ -export const MAX_SETTLEMENTS_PER_SYNC = 500; - -/** What a terminal without a cursor gets: enough to fill a new terminal's history. */ -const FIRST_FEED_DAYS = 30; - -/** - * Opens a tab for every planned customer that has none yet, and marks them delivered. Returns how many were - * delivered. - */ -export async function openPlannedTabs(tx: Tx, ledger: VenueLedger, now: Date): Promise { - const planned = await tx.plannedTab.findMany({ - where: { venueId: ledger.venueId, deliveredAt: null }, - orderBy: { position: 'asc' }, - select: { id: true, customerName: true, customerNameKey: true }, - }); - if (planned.length === 0) return 0; - - const open = new Set(ledger.tabs.all().map((tab) => customerNameKey(tab.customerName))); - for (const customer of planned) { - if (open.has(customer.customerNameKey)) continue; - ledger.tabs.open(uuidv4(), customer.customerName, now); - open.add(customer.customerNameKey); - } - - const { count } = await tx.plannedTab.updateMany({ - where: { id: { in: planned.map((p) => p.id) } }, - data: { deliveredAt: now }, - }); - return count; -} - -/** - * The venue's settlements after [cursor] (oldest first), or of the last days when there is none. The cursor is the - * last sequence number sent. Settlements are written while the venue is locked, so sequence numbers of one venue - * become visible in order and the feed never skips one. - */ -export async function settlementsAfter(tx: Tx, venueId: string, cursor: string | undefined, now: Date) { - const after = cursor && /^\d+$/.test(cursor) ? Number(cursor) : null; - const rows = await tx.settlement.findMany({ - where: after !== null - ? { venueId, sequence: { gt: after } } - : { venueId, settledAt: { gte: new Date(now.getTime() - FIRST_FEED_DAYS * 24 * 60 * 60 * 1000) } }, - orderBy: { sequence: 'asc' }, - take: MAX_SETTLEMENTS_PER_SYNC, - }); - - let next = rows.at(-1)?.sequence ?? after; - if (next === null) { - // Nothing yet: start after the venue's latest settlement, so the next sync only asks for new ones - const latest = await tx.settlement.aggregate({ where: { venueId }, _max: { sequence: true } }); - next = latest._max.sequence ?? 0; - } - - return { - settlements: rows.map((row) => ({ - id: row.id, - tabId: row.tabId, - customerName: row.customerName, - settledAt: row.settledAt.toISOString(), - paymentMethod: row.paymentMethod, - amountInCents: row.amountInCents, - lines: row.lines as unknown as SettledLine[], - })), - settlementsCursor: String(next), - }; -} diff --git a/apps/api/src/tabs/tabs.controller.ts b/apps/api/src/tabs/tabs.controller.ts index c7510ff..1bcc5f9 100644 --- a/apps/api/src/tabs/tabs.controller.ts +++ b/apps/api/src/tabs/tabs.controller.ts @@ -1,55 +1,48 @@ -import { Controller, Delete, Get, Param, ParseIntPipe, Query, Req, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common'; +import { z } from 'zod'; +import { deleteTabQuerySchema, settlementsQuerySchema, SettlementPage, settleTabSchema, Tab } from '@kooltap/shared'; +import { zod } from '../common/zod.pipe'; +import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth'; import { TabsService } from './tabs.service'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { VenueAccessGuard } from '../common/guards/venue-access.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; -import { Roles } from '../common/decorators/roles.decorator'; -@ApiTags('Tabs') -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) -@Controller('venues/:venueId/tabs') +@Controller('venues/:venueId') +@Authorized() export class TabsController { - constructor(private tabsService: TabsService) {} + constructor(private tabs: TabsService) {} - @Get() - @ApiOperation({ summary: 'List all open tabs for a venue' }) - async findAll(@Param('venueId') venueId: string) { - return this.tabsService.findAll(venueId); + @Get('tabs') + findAll(@Param('venueId') venueId: string): Promise { + return this.tabs.findAll(venueId); } - @Get('history') - @ApiOperation({ summary: 'List paid tabs, newest first' }) - @ApiQuery({ name: 'search', required: false, description: 'Part of the customer name' }) - @ApiQuery({ name: 'paymentMethod', required: false }) - @ApiQuery({ name: 'limit', required: false }) - @ApiQuery({ name: 'offset', required: false }) - async history( - @Param('venueId') venueId: string, - @Query('search') search?: string, - @Query('paymentMethod') paymentMethod?: string, - @Query('limit') limit?: string, - @Query('offset') offset?: string, - ) { - return this.tabsService.history(venueId, { - search, - paymentMethod, - limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200), - offset: Math.max(parseInt(offset ?? '', 10) || 0, 0), - }); - } - - @Delete(':tabId') + @Post('tabs/:tabId/settle') + @HttpCode(HttpStatus.OK) @Roles('admin', 'manager') - @ApiOperation({ summary: 'Delete an open tab without settling it' }) - @ApiQuery({ name: 'expectedVersion', required: false, description: 'Refuse when the tab changed since this version' }) - async remove( + settle( @Param('venueId') venueId: string, - @Param('tabId') tabId: string, - @Req() req: any, - @Query('expectedVersion', new ParseIntPipe({ optional: true })) expectedVersion?: number, + @Param('tabId', ParseUUIDPipe) tabId: string, + @Body(zod(settleTabSchema)) dto: z.output, + @CurrentUser() user: AuthUser, ) { - return this.tabsService.remove(venueId, tabId, expectedVersion, req.user); + return this.tabs.settle(venueId, tabId, dto, user); + } + + @Delete('tabs/:tabId') + @Roles('admin', 'manager') + remove( + @Param('venueId') venueId: string, + @Param('tabId', ParseUUIDPipe) tabId: string, + @Query(zod(deleteTabQuerySchema)) query: z.output, + @CurrentUser() user: AuthUser, + ) { + return this.tabs.remove(venueId, tabId, query.expectedVersion, user); + } + + @Get('settlements') + settlements( + @Param('venueId') venueId: string, + @Query(zod(settlementsQuerySchema)) query: z.output, + ): Promise { + return this.tabs.settlements(venueId, query); } } diff --git a/apps/api/src/tabs/tabs.module.ts b/apps/api/src/tabs/tabs.module.ts index 9dfc2c4..c8e623c 100644 --- a/apps/api/src/tabs/tabs.module.ts +++ b/apps/api/src/tabs/tabs.module.ts @@ -5,6 +5,5 @@ import { TabsService } from './tabs.service'; @Module({ controllers: [TabsController], providers: [TabsService], - exports: [TabsService], }) -export class TabsModule {} \ No newline at end of file +export class TabsModule {} diff --git a/apps/api/src/tabs/tabs.service.spec.ts b/apps/api/src/tabs/tabs.service.spec.ts deleted file mode 100644 index 769b13a..0000000 --- a/apps/api/src/tabs/tabs.service.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { TabsService } from './tabs.service'; -import { createFakePrisma } from '../testing/fake-prisma'; - -const at = (minutes: number) => new Date(Date.UTC(2026, 9, 27, 20, minutes)); - -function setup() { - const prisma = createFakePrisma(); - prisma.device.rows.push({ id: 'd1', venueId: 'v1', name: 'Bar tablet' }); - const service = new TabsService(prisma as any, { notify: jest.fn() } as any); - - const settle = (id: string, customerName: string, minutes: number, extra: Record = {}) => - prisma.settlement.create({ - data: { - id, venueId: 'v1', tabId: `tab-${id}`, customerName, settledAt: at(minutes), paymentMethod: 'cash', - amountInCents: 500, deviceId: 'd1', requestedBy: null, - lines: [{ - id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, - units: [ - { id: 'u1', addedAt: at(0).toISOString(), removedAt: null }, - { id: 'u2', addedAt: at(0).toISOString(), removedAt: null }, - { id: 'u3', addedAt: at(0).toISOString(), removedAt: at(1).toISOString() }, - ], - }], - ...extra, - }, - }); - - return { prisma, service, settle }; -} - -describe('TabsService.history', () => { - it('lists paid tabs newest first, with their lines and who settled them', async () => { - const { service, settle } = setup(); - await settle('s1', 'Alice', 1); - await settle('s2', 'Bob', 5, { deviceId: null, requestedBy: 'manager@bar.be', paymentMethod: 'payconiq' }); - await settle('other', 'Eve', 9, { venueId: 'v2' }); - - const result = await service.history('v1', { limit: 50, offset: 0 }); - - expect(result.total).toBe(2); - expect(result.totalAmountInCents).toBe(1000); - expect(result.settlements.map((s) => s.id)).toEqual(['s2', 's1']); - expect(result.settlements[0]).toMatchObject({ settledBy: 'manager@bar.be', onDashboard: true, paymentMethod: 'payconiq' }); - expect(result.settlements[1]).toMatchObject({ settledBy: 'Bar tablet', onDashboard: false, itemCount: 2 }); - expect(result.settlements[1].items).toEqual([{ productName: 'Cola', quantity: 2, unitPriceInCents: 250, removed: 1 }]); - }); - - it('filters by part of the name and payment method, and pages', async () => { - const { service, settle } = setup(); - await settle('s1', 'Alice', 1); - await settle('s2', 'alicia', 2, { paymentMethod: 'payconiq' }); - await settle('s3', 'Bob', 3); - - const byName = await service.history('v1', { search: ' ALI ', limit: 50, offset: 0 }); - expect(byName.settlements.map((s) => s.id)).toEqual(['s2', 's1']); - - const byMethod = await service.history('v1', { search: 'ali', paymentMethod: 'cash', limit: 50, offset: 0 }); - expect(byMethod.settlements.map((s) => s.id)).toEqual(['s1']); - - const secondPage = await service.history('v1', { limit: 2, offset: 2 }); - expect(secondPage.total).toBe(3); - expect(secondPage.settlements.map((s) => s.id)).toEqual(['s1']); - }); -}); diff --git a/apps/api/src/tabs/tabs.service.ts b/apps/api/src/tabs/tabs.service.ts index e98cf33..a11bf44 100644 --- a/apps/api/src/tabs/tabs.service.ts +++ b/apps/api/src/tabs/tabs.service.ts @@ -1,56 +1,130 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; -import { v4 as uuidv4 } from 'uuid'; -import { PrismaService } from '../prisma/prisma.service'; +import { randomUUID } from 'crypto'; +import { DASHBOARD_PAYMENT_METHODS, PaymentMethod, Settlement, SettlementPage, Tab, TabLine } from '@kooltap/shared'; +import { Prisma, PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; import { RealtimeService } from '../realtime/realtime.service'; -import { customerNameKey } from '../planned-tabs/customer-name'; -import { SettledLine, tabSummary } from './tab-state'; -import { tabOrders } from './tab-orders'; -import { VenueLedger } from './venue-ledger'; +import { CashPaymentNotifier } from '../email/cash-payment-notifier'; +import { AuthUser } from '../common/auth'; +import { customerNameKey } from '../customers/customer-name'; +import { linesOf, VenueLedger } from '../ledger/venue-ledger'; +import { isActive, Line, lineQuantity, TabState } from '../ledger/tab-state'; +import { tabOrders } from '../ledger/tab-orders'; + +/** What is still on a tab: its lines with drinks, and the total. */ +export function tabContents(lines: Line[]): { items: TabLine[]; totalInCents: number; itemCount: number } { + const items = lines + .map((line) => ({ productName: line.productName, quantity: lineQuantity(line), unitPriceInCents: line.unitPriceInCents })) + .filter((item) => item.quantity > 0); + return { + items, + totalInCents: items.reduce((sum, item) => sum + item.quantity * item.unitPriceInCents, 0), + itemCount: items.reduce((sum, item) => sum + item.quantity, 0), + }; +} @Injectable() export class TabsService { constructor( private prisma: PrismaService, + private audit: AuditService, private realtime: RealtimeService, + private cashPayments: CashPaymentNotifier, ) {} - /** The venue's open tabs as the server holds them; every terminal shows the same. */ - async findAll(venueId: string) { - const tabs = await this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }); + /** The venue's open tabs, with the email of the customer with the same name. */ + async findAll(venueId: string): Promise { + const [tabs, units, customers] = await Promise.all([ + this.prisma.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }), + this.prisma.tabUnit.findMany({ where: { venueId, tabId: { not: null } } }), + this.prisma.customer.findMany({ where: { venueId }, select: { id: true, nameKey: true, email: true } }), + ]); + const customersByKey = new Map(customers.map((c) => [c.nameKey, c])); - // Customer details (email for reminders) live on the planned customer with the same name - const planned = await this.prisma.plannedTab.findMany({ - where: { venueId }, - select: { id: true, customerNameKey: true, email: true }, + return tabs.map((tab) => { + const lines = linesOf(units.filter((unit) => unit.tabId === tab.id)); + const customer = customersByKey.get(customerNameKey(tab.customerName)); + return { + id: tab.id, + customerName: tab.customerName, + version: tab.version, + ...tabContents(lines), + // Only what is still on the tab: drinks taken off again are left out + orders: tabOrders(lines) + .map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) })) + .filter((order) => order.items.length > 0), + updatedAt: tab.updatedAt.toISOString(), + customerId: customer?.id ?? null, + email: customer?.email ?? null, + }; }); - const plannedByKey = new Map(planned.map((p) => [p.customerNameKey, p])); - - return { - tabs: tabs.map(tabSummary).map(({ updatedAt, ...tab }) => { - const plannedTab = plannedByKey.get(customerNameKey(tab.customerName)); - return { - ...tab, - lastChangedAt: updatedAt, - plannedTabId: plannedTab?.id ?? null, - email: plannedTab?.email ?? null, - }; - }), - }; } /** - * The venue's paid tabs, newest first, as the terminals' history shows them: every settlement made on a terminal - * or on the dashboard. [search] matches the customer name. + * Settles everything on a tab, at once, also when every terminal is offline; the terminals see the drinks go on + * their next sync. Refused when the tab changed since [expectedVersion]. */ - async history( + async settle( venueId: string, - options: { search?: string; paymentMethod?: string; limit: number; offset: number }, + tabId: string, + dto: { expectedVersion: number; paymentMethod: (typeof DASHBOARD_PAYMENT_METHODS)[number] }, + user: AuthUser, ) { - const search = options.search?.trim(); + const settlement = await this.prisma.$transaction(async (tx) => { + const now = new Date(); + const ledger = await VenueLedger.open(tx, venueId); + this.checkVersion(ledger.tabs.get(tabId), dto.expectedVersion); + + const draft = ledger.tabs.settleAll(tabId, { id: randomUUID(), settledAt: now, paymentMethod: dto.paymentMethod }); + if (!draft) throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' }); + ledger.settlements.push({ ...draft, settledByUserId: user.id }); + await ledger.save(tx, now); + + await this.audit.log({ + venueId, + actor: { type: 'user', id: user.id }, + action: 'tab_settled', + details: { settlementId: draft.id, tabId, paymentMethod: dto.paymentMethod, amountInCents: draft.amountInCents }, + }, tx); + return draft; + }); + + this.realtime.notify(venueId, 'tabs'); + this.cashPayments.notify(venueId, [settlement], user.name); + return { id: settlement.id, amountInCents: settlement.amountInCents }; + } + + /** + * Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays + * out of the stock. A terminal that still has unsent drinks for it brings it back, so no sale is lost. + */ + async remove(venueId: string, tabId: string, expectedVersion: number, user: AuthUser) { + await this.prisma.$transaction(async (tx) => { + const now = new Date(); + const ledger = await VenueLedger.open(tx, venueId); + const tab = this.checkVersion(ledger.tabs.get(tabId), expectedVersion); + + const { items, totalInCents } = tabContents(tab.lines); + ledger.tabs.remove(tabId); + await ledger.save(tx, now); + + await this.audit.log({ + venueId, + actor: { type: 'user', id: user.id }, + action: 'tab_deleted', + details: { tabId, customerName: tab.customerName, amountInCents: totalInCents, items }, + }, tx); + }); + + this.realtime.notify(venueId, 'tabs'); + return { ok: true }; + } + + /** The venue's paid tabs, newest first. [search] matches the customer name. */ + async settlements(venueId: string, options: { search?: string; paymentMethod?: PaymentMethod; limit: number; offset: number }): Promise { const where: Prisma.SettlementWhereInput = { venueId, - ...(search ? { customerName: { contains: search, mode: 'insensitive' as const } } : {}), + ...(options.search ? { customerName: { contains: options.search, mode: 'insensitive' } } : {}), ...(options.paymentMethod ? { paymentMethod: options.paymentMethod } : {}), }; @@ -60,38 +134,33 @@ export class TabsService { orderBy: [{ settledAt: 'desc' }, { sequence: 'desc' }], skip: options.offset, take: options.limit, + include: { units: true, device: { select: { name: true } }, settledBy: { select: { name: true } } }, }), this.prisma.settlement.count({ where }), this.prisma.settlement.aggregate({ where, _sum: { amountInCents: true } }), ]); - const deviceIds = [...new Set(rows.map((row) => row.deviceId).filter((id): id is string => id !== null))]; - const devices = deviceIds.length > 0 - ? await this.prisma.device.findMany({ where: { id: { in: deviceIds } }, select: { id: true, name: true } }) - : []; - const deviceNames = new Map(devices.map((device) => [device.id, device.name])); - return { - settlements: rows.map((row) => { - const lines = row.lines as unknown as SettledLine[]; + settlements: rows.map((row): Settlement => { + const lines = linesOf(row.units); const items = lines.map((line) => ({ productName: line.productName, - quantity: line.quantity, + quantity: lineQuantity(line), unitPriceInCents: line.unitPriceInCents, // Rung up and taken off again before paying - removed: line.units.filter((unit) => unit.removedAt !== null).length, + removed: line.units.filter((unit) => !isActive(unit)).length, })); return { id: row.id, tabId: row.tabId, customerName: row.customerName, - settledAt: row.settledAt, + settledAt: row.settledAt.toISOString(), paymentMethod: row.paymentMethod, amountInCents: row.amountInCents, itemCount: items.reduce((count, item) => count + item.quantity, 0), items, orders: tabOrders(lines), - settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null, + settledBy: row.device?.name ?? row.settledBy?.name ?? null, onDashboard: row.deviceId === null, }; }), @@ -100,45 +169,11 @@ export class TabsService { }; } - /** - * Deletes a tab without settling it, as tab_deleted does on a terminal: what is still on it is not paid and stays - * out of the stock. The terminals drop it on their next sync; one that still has unsent drinks for it brings it - * back, so no sale is lost. [expectedVersion] refuses a tab that changed since it was shown. - */ - async remove(venueId: string, tabId: string, expectedVersion: number | undefined, user: { id: string }) { - await this.prisma.$transaction(async (tx) => { - const now = new Date(); - const ledger = await VenueLedger.open(tx, venueId); - - const tab = ledger.tabs.get(tabId); - if (!tab) { - throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' }); - } - if (expectedVersion !== undefined && tab.version !== expectedVersion) { - throw new ConflictException({ - code: 'version_mismatch', - message: 'The tab has changed since it was loaded. Refresh and try again.', - }); - } - - const { customerName, totalInCents, items } = tabSummary({ ...tab, updatedAt: now }); - ledger.tabs.remove(tabId); - await ledger.save(tx, now); - - await tx.auditLog.create({ - data: { - id: uuidv4(), - venueId, - actorType: 'client_user', - actorId: user.id, - action: 'tab_deleted', - details: JSON.stringify({ tabId, customerName, amountInCents: totalInCents, items }), - }, - }); - }); - - this.realtime.notify(venueId, 'tabs'); - - return { ok: true }; + private checkVersion(tab: TabState | undefined, expectedVersion: number): TabState { + if (!tab) throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' }); + if (tab.version !== expectedVersion) { + throw new ConflictException({ code: 'version_mismatch', message: 'The tab has changed since it was loaded. Refresh and try again.' }); + } + return tab; } } diff --git a/apps/api/src/tabs/venue-ledger.ts b/apps/api/src/tabs/venue-ledger.ts deleted file mode 100644 index 3186517..0000000 --- a/apps/api/src/tabs/venue-ledger.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { Prisma } from '@prisma/client'; -import { Line, SettlementDraft, TabState, VenueTabs } from './tab-state'; -import { ProductStock, VenueStock } from '../stock/stock-rules'; - -type Tx = Prisma.TransactionClient; - -export type RecordedSettlement = SettlementDraft & { deviceId?: string | null; requestedBy?: string | null }; - -/** - * A venue's open tabs, stock and new settlements inside one transaction. Opening it locks the venue, so terminals - * syncing at the same time and the dashboard apply their changes one after the other instead of overwriting each - * other. Everything is loaded at once: a venue has tens of open tabs and products, not thousands. - */ -export class VenueLedger { - readonly settlements: RecordedSettlement[] = []; - private seededNow = false; - - private constructor( - readonly venueId: string, - readonly tabs: VenueTabs, - readonly stock: VenueStock, - /** False until the server took over the terminal's open tabs. */ - readonly seeded: boolean, - ) {} - - static async open(tx: Tx, venueId: string): Promise { - const [venue] = await tx.$queryRaw<{ tabsSeededAt: Date | null }[]>` - SELECT "tabsSeededAt" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`; - - const [tabs, products] = await Promise.all([ - tx.tab.findMany({ where: { venueId }, orderBy: { openedAt: 'asc' } }), - tx.product.findMany({ where: { venueId }, select: { productId: true, stock: true, countedAt: true } }), - ]); - - return new VenueLedger( - venueId, - new VenueTabs(tabs.map((tab) => ({ - id: tab.id, - customerName: tab.customerName, - openedAt: tab.openedAt, - version: tab.version, - lines: tab.lines as unknown as Line[], - }))), - new VenueStock(new Map(products.map((p): [string, ProductStock] => ( - [p.productId, { stock: p.stock, countedAt: p.countedAt }])))), - venue?.tabsSeededAt != null, - ); - } - - /** Writes what changed. Call once, at the end of the transaction. */ - async save(tx: Tx, now: Date) { - const { venueId } = this; - - if (this.tabs.deleted.size > 0) { - await tx.tab.deleteMany({ where: { venueId, id: { in: [...this.tabs.deleted] } } }); - } - for (const tabId of this.tabs.changed) { - const tab = this.tabs.get(tabId)!; - const data = { - customerName: tab.customerName, - openedAt: tab.openedAt, - version: tab.version, - lines: tab.lines as unknown as Prisma.InputJsonValue, - }; - await tx.tab.upsert({ - where: { venueId_id: { venueId, id: tabId } }, - create: { venueId, id: tabId, ...data }, - update: data, - }); - } - - for (const productId of this.stock.changed) { - const { stock, countedAt } = this.stock.get(productId)!; - await tx.product.update({ - where: { venueId_productId: { venueId, productId } }, - data: { stock, countedAt }, - }); - } - - if (this.settlements.length > 0) { - await tx.settlement.createMany({ - data: this.settlements.map((s) => ({ - id: s.id, - venueId, - tabId: s.tabId, - customerName: s.customerName, - settledAt: s.settledAt, - paymentMethod: s.paymentMethod, - amountInCents: s.amountInCents, - lines: s.lines as unknown as Prisma.InputJsonValue, - deviceId: s.deviceId ?? null, - requestedBy: s.requestedBy ?? null, - commandId: s.commandId, - })), - skipDuplicates: true, - }); - } - - if (this.seededNow) { - await tx.venue.update({ where: { id: venueId }, data: { tabsSeededAt: now } }); - } - } - - get changedTabs(): boolean { - return this.tabs.changed.size > 0 || this.tabs.deleted.size > 0; - } - - /** Takes over a terminal's own open tabs, once. */ - seed(tabs: TabState[]) { - this.seededNow = true; - for (const tab of tabs) { - if (this.tabs.get(tab.id)) continue; - const opened = this.tabs.open(tab.id, tab.customerName, tab.openedAt); - opened.version = tab.version; - opened.lines = tab.lines; - } - } -} diff --git a/apps/api/src/devices/app-version.guard.ts b/apps/api/src/terminal/app-version.guard.ts similarity index 60% rename from apps/api/src/devices/app-version.guard.ts rename to apps/api/src/terminal/app-version.guard.ts index 5a591bc..f7bca91 100644 --- a/apps/api/src/devices/app-version.guard.ts +++ b/apps/api/src/terminal/app-version.guard.ts @@ -1,5 +1,6 @@ import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common'; -import { APP_VERSION_HEADER, isAppVersionSupported, minAppVersion } from './app-version'; +import { APP_VERSION_HEADER } from '@kooltap/shared'; +import { isAppVersionSupported, minAppVersion } from './app-version'; /** * Refuses terminals running an app older than the minimum version, so they ask their user to update instead of @@ -10,13 +11,14 @@ import { APP_VERSION_HEADER, isAppVersionSupported, minAppVersion } from './app- export class AppVersionGuard implements CanActivate { canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); - const minVersion = minAppVersion(); - if (!isAppVersionSupported(request.headers[APP_VERSION_HEADER], minVersion)) { - throw new HttpException( - { code: 'app_update_required', message: `Update the app to version ${minVersion} or newer` }, - 426, // Upgrade Required; HttpStatus has no name for it - ); - } + if (!isAppVersionSupported(request.headers[APP_VERSION_HEADER])) throw appUpdateRequired(); return true; } } + +export function appUpdateRequired(): HttpException { + return new HttpException( + { code: 'app_update_required', message: `Update the app to version ${minAppVersion()} or newer` }, + 426, // Upgrade Required; HttpStatus has no name for it + ); +} diff --git a/apps/api/src/devices/app-version.spec.ts b/apps/api/src/terminal/app-version.spec.ts similarity index 100% rename from apps/api/src/devices/app-version.spec.ts rename to apps/api/src/terminal/app-version.spec.ts diff --git a/apps/api/src/devices/app-version.ts b/apps/api/src/terminal/app-version.ts similarity index 69% rename from apps/api/src/devices/app-version.ts rename to apps/api/src/terminal/app-version.ts index 177579d..eb96b35 100644 --- a/apps/api/src/devices/app-version.ts +++ b/apps/api/src/terminal/app-version.ts @@ -1,10 +1,8 @@ /** - * The oldest terminal app that may use the backoffice. Raise it with every app release that older versions must not - * keep syncing past; MIN_APP_VERSION overrides it without a deploy. + * The oldest terminal app that may use the backoffice: 2.0.0 speaks the current terminal protocol. Raise it with every + * app release that older versions must not keep syncing past; MIN_APP_VERSION overrides it without a deploy. */ -export const MIN_APP_VERSION = '1.4.0'; - -export const APP_VERSION_HEADER = 'x-app-version'; +export const MIN_APP_VERSION = '2.0.0'; export function minAppVersion(): string { return process.env.MIN_APP_VERSION?.trim() || MIN_APP_VERSION; @@ -17,7 +15,7 @@ export function parseAppVersion(value: unknown): number[] | null { return match ? match[1].split('.').map(Number) : null; } -/** Whether [version] is at least [minimum]. Apps from before the version header send none and are outdated */ +/** Whether [version] is at least [minimum]. A missing or unreadable version is outdated */ export function isAppVersionSupported(version: unknown, minimum = minAppVersion()): boolean { const current = parseAppVersion(version); const required = parseAppVersion(minimum); diff --git a/apps/api/src/terminal/device-auth.guard.ts b/apps/api/src/terminal/device-auth.guard.ts new file mode 100644 index 0000000..35e7e4a --- /dev/null +++ b/apps/api/src/terminal/device-auth.guard.ts @@ -0,0 +1,30 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { sha256 } from '../auth/auth.service'; +import { AuthDevice } from '../common/auth'; + +/** The terminal from its `Authorization: Bearer `; 401 when unknown, unpaired or expired. */ +@Injectable() +export class DeviceAuthGuard implements CanActivate { + constructor(private prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const header: string | undefined = request.headers.authorization; + const token = header?.startsWith('Bearer ') ? header.slice(7) : null; + if (!token) throw new UnauthorizedException({ code: 'missing_token', message: 'Missing device token' }); + + const device = await this.prisma.device.findUnique({ + where: { tokenHash: sha256(token) }, + select: { id: true, venueId: true, name: true, lastSeenAt: true, revokedAt: true, tokenExpiresAt: true }, + }); + if (!device) throw new UnauthorizedException({ code: 'invalid_device_token', message: 'Invalid device token' }); + if (device.revokedAt) throw new UnauthorizedException({ code: 'device_revoked', message: 'This terminal was unpaired' }); + if (device.tokenExpiresAt < new Date()) { + throw new UnauthorizedException({ code: 'token_expired', message: 'The device token expired: pair the terminal again' }); + } + + request.device = { id: device.id, venueId: device.venueId, name: device.name, lastSeenAt: device.lastSeenAt } satisfies AuthDevice; + return true; + } +} diff --git a/apps/api/src/terminal/legacy-terminal.controller.ts b/apps/api/src/terminal/legacy-terminal.controller.ts new file mode 100644 index 0000000..dd9226f --- /dev/null +++ b/apps/api/src/terminal/legacy-terminal.controller.ts @@ -0,0 +1,20 @@ +import { All, Controller, Get } from '@nestjs/common'; +import { appUpdateRequired } from './app-version.guard'; + +/** + * The routes of the v1 terminal protocol (apps before 2.0). A tablet that hasn't been updated yet gets + * `426 app_update_required` instead of a 404, so it stops syncing, keeps its outbox and asks to be updated. + */ +@Controller() +export class LegacyTerminalController { + @All(['devices', 'devices/*path']) + device(): never { + throw appUpdateRequired(); + } + + // Where pairing started: the list of venues to pick from + @Get('venues') + venues(): never { + throw appUpdateRequired(); + } +} diff --git a/apps/api/src/terminal/sync-feeds.ts b/apps/api/src/terminal/sync-feeds.ts new file mode 100644 index 0000000..8c99a5c --- /dev/null +++ b/apps/api/src/terminal/sync-feeds.ts @@ -0,0 +1,105 @@ +import { randomUUID } from 'crypto'; +import { TerminalProduct, TerminalSettlement } from '@kooltap/shared'; +import { Tx } from '../prisma/prisma.service'; +import { customerNameKey, normalizeCustomerName } from '../customers/customer-name'; +import { VenueLedger, linesOf } from '../ledger/venue-ledger'; +import { lineJson } from '../ledger/tab-state'; + +/** Most settlements sent per sync; a longer feed continues in the next sync. */ +export const MAX_SETTLEMENTS_PER_SYNC = 500; + +/** What a terminal without a cursor gets: enough to fill a new terminal's history. */ +const FIRST_FEED_DAYS = 30; + +/** Opens a tab for every customer that has none yet, once. Returns how many customers got one. */ +export async function openCustomerTabs(tx: Tx, ledger: VenueLedger, now: Date): Promise { + const waiting = await tx.customer.findMany({ + where: { venueId: ledger.venueId, tabOpenedAt: null }, + orderBy: { position: 'asc' }, + select: { id: true, name: true, nameKey: true }, + }); + if (waiting.length === 0) return 0; + + const open = new Set(ledger.tabs.all().map((tab) => customerNameKey(tab.customerName))); + for (const customer of waiting) { + if (open.has(customer.nameKey)) continue; + ledger.tabs.open(randomUUID(), customer.name, now); + open.add(customer.nameKey); + } + + const { count } = await tx.customer.updateMany({ where: { id: { in: waiting.map((c) => c.id) } }, data: { tabOpenedAt: now } }); + return count; +} + +/** + * A tab opened on a terminal with an email: adds the customer with that email, or fills in the email of the customer + * with that name. Their tab is open already, so the next sync doesn't open another. + */ +export async function rememberCustomer(tx: Tx, venueId: string, customerName: string, email: string, now: Date) { + const name = normalizeCustomerName(customerName); + const nameKey = customerNameKey(name); + const last = await tx.customer.aggregate({ where: { venueId }, _max: { position: true } }); + await tx.customer.upsert({ + where: { venueId_nameKey: { venueId, nameKey } }, + create: { venueId, name, nameKey, email, position: (last._max.position ?? -1) + 1, tabOpenedAt: now }, + update: { email }, + }); +} + +/** The customers' emails by name key, for the tabs the terminals show. */ +export async function customerEmails(tx: Tx, venueId: string): Promise> { + const customers = await tx.customer.findMany({ where: { venueId, email: { not: null } }, select: { nameKey: true, email: true } }); + return new Map(customers.map((c) => [c.nameKey, c.email!])); +} + +/** + * The venue's settlements after [cursor] (oldest first), or of the last days when there is none. The cursor is the + * last sequence number sent. Settlements are written while the venue is locked, so sequence numbers of one venue + * become visible in order and the feed never skips one. + */ +export async function settlementsAfter(tx: Tx, venueId: string, cursor: string | undefined, now: Date) { + const after = cursor && /^\d+$/.test(cursor) ? Number(cursor) : null; + const rows = await tx.settlement.findMany({ + where: after !== null + ? { venueId, sequence: { gt: after } } + : { venueId, settledAt: { gte: new Date(now.getTime() - FIRST_FEED_DAYS * 24 * 60 * 60 * 1000) } }, + orderBy: { sequence: 'asc' }, + take: MAX_SETTLEMENTS_PER_SYNC, + include: { units: true }, + }); + + let next = rows.at(-1)?.sequence ?? after; + if (next === null) { + // Nothing yet: start after the venue's latest settlement, so the next sync only asks for new ones + const latest = await tx.settlement.aggregate({ where: { venueId }, _max: { sequence: true } }); + next = latest._max.sequence ?? 0; + } + + return { + settlements: rows.map((row): TerminalSettlement => ({ + id: row.id, + tabId: row.tabId, + customerName: row.customerName, + settledAt: row.settledAt.toISOString(), + paymentMethod: row.paymentMethod, + amountInCents: row.amountInCents, + lines: linesOf(row.units).map(lineJson), + })), + settlementsCursor: String(next), + }; +} + +/** Changes whenever a product is added or changed (not its stock): the terminals only download the catalog then. */ +export async function catalogVersion(tx: Tx, venueId: string): Promise { + const { _count, _max } = await tx.product.aggregate({ where: { venueId }, _count: { _all: true }, _max: { updatedAt: true } }); + return `${_count._all}.${_max.updatedAt?.getTime() ?? 0}`; +} + +export async function catalog(tx: Tx, venueId: string): Promise { + const products = await tx.product.findMany({ + where: { venueId }, + orderBy: [{ category: 'asc' }, { name: 'asc' }], + select: { id: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true, imageId: true }, + }); + return products.map(({ archivedAt, ...product }) => ({ ...product, archived: archivedAt !== null })); +} diff --git a/apps/api/src/terminal/terminal-events.spec.ts b/apps/api/src/terminal/terminal-events.spec.ts new file mode 100644 index 0000000..3962911 --- /dev/null +++ b/apps/api/src/terminal/terminal-events.spec.ts @@ -0,0 +1,48 @@ +import { eventTime, parseEvents } from './terminal-events'; + +const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; +const tabOpened = (n: number, fields: Record = {}) => ({ + id: id(n), type: 'tab_opened', occurredAt: '2026-10-28T20:00:00.000Z', tabId: id(100), customerName: 'Jan', ...fields, +}); + +describe('parseEvents', () => { + it('rejects an invalid event on its own and keeps the rest', () => { + const { accepted, rejected } = parseEvents([tabOpened(1), tabOpened(2, { customerName: '' }), 'nonsense', { id: 'x' }]); + + expect(accepted.map((e) => e.id)).toEqual([id(1)]); + expect(rejected.map((r) => r.id)).toEqual([id(2), null, 'x']); + expect(rejected[0].reason).toContain('customerName'); + }); + + it('keeps an event sent twice in one request once', () => { + expect(parseEvents([tabOpened(1), tabOpened(1)]).accepted).toHaveLength(1); + }); + + it('ignores fields it does not know, so a newer app never loses a sale', () => { + const [event] = parseEvents([tabOpened(1, { mood: 'happy' })]).accepted; + + expect(event).not.toHaveProperty('mood'); + }); + + it('trims names and turns an empty category into none', () => { + const [event] = parseEvents([{ + id: id(1), type: 'product_created', occurredAt: '2026-10-28T20:00:00Z', productId: id(2), + product: { name: ' Cola ', priceInCents: 250, category: ' ' }, + }]).accepted; + + expect(event).toMatchObject({ product: { name: 'Cola', category: null } }); + }); +}); + +describe('eventTime', () => { + it('caps a terminal clock that runs ahead at the server time', () => { + const now = new Date('2026-10-28T20:00:00Z'); + const [ahead, behind] = parseEvents([ + tabOpened(1, { occurredAt: '2026-10-28T21:00:00Z' }), + tabOpened(2, { occurredAt: '2026-10-28T19:00:00Z' }), + ]).accepted; + + expect(eventTime(ahead, now)).toEqual(now); + expect(eventTime(behind, now)).toEqual(new Date('2026-10-28T19:00:00Z')); + }); +}); diff --git a/apps/api/src/terminal/terminal-events.ts b/apps/api/src/terminal/terminal-events.ts new file mode 100644 index 0000000..f64f717 --- /dev/null +++ b/apps/api/src/terminal/terminal-events.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; +import { TerminalEvent, terminalEventSchema } from '@kooltap/shared'; + +// A terminal clock that runs ahead must not put sales in the future +const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000; + +export type RejectedEvent = { id: string | null; reason: string }; + +/** + * Validates events one by one: an invalid one is rejected on its own instead of failing the sync (and with it the + * other sales). An event sent twice in one request is kept once. + */ +export function parseEvents(raw: unknown[]): { accepted: TerminalEvent[]; rejected: RejectedEvent[] } { + const accepted = new Map(); + const rejected: RejectedEvent[] = []; + + for (const value of raw) { + const result = terminalEventSchema.safeParse(value); + if (result.success) { + accepted.set(result.data.id, result.data); + } else { + const id = (value as { id?: unknown } | null)?.id; + rejected.push({ id: typeof id === 'string' ? id : null, reason: z.prettifyError(result.error) }); + } + } + + return { accepted: [...accepted.values()], rejected }; +} + +/** When the event happened, as stored and applied: a terminal clock running ahead is capped at [now]. */ +export function eventTime(event: TerminalEvent, now: Date): Date { + const occurredAt = new Date(event.occurredAt); + return occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt; +} diff --git a/apps/api/src/terminal/terminal.controller.ts b/apps/api/src/terminal/terminal.controller.ts new file mode 100644 index 0000000..eea5a1a --- /dev/null +++ b/apps/api/src/terminal/terminal.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, HttpCode, HttpStatus, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; +import { z } from 'zod'; +import { registerTerminalRequestSchema, RegisterTerminalResponse, SyncResponse, syncRequestSchema } from '@kooltap/shared'; +import { zod } from '../common/zod.pipe'; +import { AuthDevice, CurrentDevice } from '../common/auth'; +import { ImagesService } from '../images/images.service'; +import { MAX_UPLOAD_BYTES } from '../images/image-processing'; +import { AppVersionGuard } from './app-version.guard'; +import { DeviceAuthGuard } from './device-auth.guard'; +import { TerminalService } from './terminal.service'; + +/** The bar terminals: see docs/terminal-protocol.md. */ +@Controller('terminal') +@UseGuards(AppVersionGuard) +export class TerminalController { + constructor( + private terminal: TerminalService, + private images: ImagesService, + ) {} + + @Post('register') + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + register(@Body(zod(registerTerminalRequestSchema)) dto: z.output): Promise { + return this.terminal.register(dto.registrationCode, dto.name); + } + + // Authenticated by the device token; the till must never be rate-limited out by other traffic from the venue's + // shared IP + @Post('sync') + @HttpCode(HttpStatus.OK) + @SkipThrottle() + @UseGuards(DeviceAuthGuard) + sync(@CurrentDevice() device: AuthDevice, @Body(zod(syncRequestSchema)) dto: z.output): Promise { + return this.terminal.sync(device, 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('images') + @SkipThrottle() + @UseGuards(DeviceAuthGuard) + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } })) + async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) { + return { imageId: await this.images.store(file?.buffer) }; + } +} diff --git a/apps/api/src/terminal/terminal.module.ts b/apps/api/src/terminal/terminal.module.ts new file mode 100644 index 0000000..a8b0bfe --- /dev/null +++ b/apps/api/src/terminal/terminal.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ImagesModule } from '../images/images.module'; +import { LegacyTerminalController } from './legacy-terminal.controller'; +import { TerminalController } from './terminal.controller'; +import { TerminalService } from './terminal.service'; +import { DeviceAuthGuard } from './device-auth.guard'; + +@Module({ + imports: [ImagesModule], + controllers: [TerminalController, LegacyTerminalController], + providers: [TerminalService, DeviceAuthGuard], +}) +export class TerminalModule {} diff --git a/apps/api/src/terminal/terminal.service.ts b/apps/api/src/terminal/terminal.service.ts new file mode 100644 index 0000000..190f263 --- /dev/null +++ b/apps/api/src/terminal/terminal.service.ts @@ -0,0 +1,155 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import { + DEVICE_ONLINE_THRESHOLD_MS, DEVICE_TOKEN_DAYS, isProductEvent, RealtimeTopic, RegisterTerminalResponse, SyncRequest, + SyncResponse, +} from '@kooltap/shared'; +import { Prisma, PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { CashPaymentNotifier } from '../email/cash-payment-notifier'; +import { sha256 } from '../auth/auth.service'; +import { AuthDevice } from '../common/auth'; +import { VenueLedger } from '../ledger/venue-ledger'; +import { tabJson } from '../ledger/tab-state'; +import { applyProductEvent } from '../ledger/product-events'; +import { eventTime, parseEvents } from './terminal-events'; +import { catalog, catalogVersion, customerEmails, openCustomerTabs, rememberCustomer, settlementsAfter } from './sync-feeds'; +import { customerNameKey } from '../customers/customer-name'; + +@Injectable() +export class TerminalService { + private readonly logger = new Logger(TerminalService.name); + + constructor( + private prisma: PrismaService, + private audit: AuditService, + private realtime: RealtimeService, + private cashPayments: CashPaymentNotifier, + ) {} + + /** Pairs a terminal with the venue of a registration code; a code works once. */ + async register(registrationCode: string, name: string): Promise { + const now = new Date(); + const token = 'kt_' + randomBytes(48).toString('base64url'); + const tokenExpiresAt = new Date(now.getTime() + DEVICE_TOKEN_DAYS * 24 * 60 * 60 * 1000); + + const device = await this.prisma.$transaction(async (tx) => { + // Claimed atomically, so a code can only ever be used once + const code = await tx.registrationCode.findUnique({ where: { code: registrationCode } }); + const claimed = code && await tx.registrationCode.updateMany({ + where: { id: code.id, usedAt: null, expiresAt: { gt: now } }, + data: { usedAt: now }, + }); + if (!code || !claimed?.count) { + throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' }); + } + // A venue can have several terminals: every pairing adds one. A replaced terminal is unpaired on the dashboard + return tx.device.create({ + data: { venueId: code.venueId, name, tokenHash: sha256(token), tokenPrefix: token.slice(0, 8), tokenExpiresAt }, + include: { venue: { select: { name: true } } }, + }); + }); + + await this.audit.log({ venueId: device.venueId, actor: { type: 'device', id: device.id }, action: 'device_registered', details: { name } }); + this.realtime.notify(device.venueId, 'devices'); + return { deviceId: device.id, venueId: device.venueId, venueName: device.venue.name, token, tokenExpiresAt: tokenExpiresAt.toISOString() }; + } + + /** + * Applies the terminal's events, each once, and returns the venue's state after them: the open tabs, catalog, stock + * and new settlements. + */ + async sync(device: AuthDevice, request: SyncRequest): Promise { + const now = new Date(); + const { venueId } = device; + const events = parseEvents(request.events); + for (const { id, reason } of events.rejected) { + this.logger.warn(`Terminal ${device.id} sent an invalid event ${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); + + // A terminal sends an event again until its acknowledgement arrives + const stored = new Set((await tx.tabEvent.findMany({ + where: { id: { in: events.accepted.map((event) => event.id) } }, + select: { id: true }, + })).map((event) => event.id)); + const fresh = events.accepted.filter((event) => !stored.has(event.id)); + if (fresh.length > 0) { + await tx.tabEvent.createMany({ + data: fresh.map((event) => ({ + id: event.id, + venueId, + deviceId: device.id, + type: event.type, + payload: event as unknown as Prisma.InputJsonObject, + occurredAt: eventTime(event, now), + receivedAt: now, + })), + }); + } + + let productsChanged = false; + for (const event of fresh) { + const occurredAt = eventTime(event, now); + if (isProductEvent(event)) { + // A sale or count of a product created offline comes in the same request, after it + if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true; + } else if (event.type === 'stock_set') { + ledger.stock.count(event.productId, event.quantity, occurredAt); + } else { + if (event.type === 'line_added') ledger.stock.sold(event.productId, event.quantity, occurredAt); + // Only drinks still on the tab go back into the stock: one settled or deleted meanwhile was sold + if (event.type === 'line_removed') { + const removed = Math.min(event.quantity, ledger.tabs.removableUnits(event).length); + const productId = ledger.tabs.get(event.tabId)?.lines.find((line) => line.id === event.lineId)?.productId; + if (productId && removed > 0) ledger.stock.sold(productId, -removed, occurredAt); + } + if (event.type === 'tab_opened' && event.email) await rememberCustomer(tx, venueId, event.customerName, event.email, now); + const settlement = ledger.tabs.apply(event, occurredAt); + if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id }); + } + } + + const customerTabs = await openCustomerTabs(tx, ledger, now); + await ledger.save(tx, now); + await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } }); + + const version = await catalogVersion(tx, venueId); + return { + ledger, + emails: await customerEmails(tx, venueId), + feed: await settlementsAfter(tx, venueId, request.settlementsCursor, now), + catalog: version === request.catalogVersion ? null : await catalog(tx, venueId), + catalogVersion: version, + topics: [ + ...(ledger.changedTabs ? ['tabs' as const] : []), + ...(customerTabs > 0 ? ['customers' as const] : []), + ...(productsChanged || ledger.stock.changed.size > 0 ? ['stock' as const] : []), + ], + }; + }, { timeout: 15_000 }); + + const { ledger, feed } = synced; + const topics: RealtimeTopic[] = synced.topics; + // Only when the terminal comes back online; dashboards poll the status themselves + if (now.getTime() - device.lastSeenAt.getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('devices'); + this.realtime.notify(venueId, ...topics); + this.cashPayments.notify(venueId, ledger.settlements, device.name); + + return { + acknowledgedEventIds: events.accepted.map((event) => event.id), + rejectedEvents: events.rejected, + tabs: ledger.tabs.all().map((tab) => tabJson(tab, synced.emails.get(customerNameKey(tab.customerName)) ?? null)), + catalog: synced.catalog, + catalogVersion: synced.catalogVersion, + stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => ( + { productId, stock, countedAt: countedAt?.toISOString() ?? null })), + settlements: feed.settlements, + settlementsCursor: feed.settlementsCursor, + }; + } +} diff --git a/apps/api/src/testing/fake-prisma.ts b/apps/api/src/testing/fake-prisma.ts deleted file mode 100644 index bd60beb..0000000 --- a/apps/api/src/testing/fake-prisma.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * An in-memory stand-in for the Prisma client, for tests that follow a venue through several syncs. It supports the - * queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte`, `contains` and - * compound unique keys, `orderBy` on one or more fields, `skip`, `take`, `select`, `count` and `_max`/`_sum`. Transactions run directly on the same data. - */ - -import { randomUUID } from 'crypto'; - -type Row = Record; - -const COMPOUND_KEYS: Record = { - venueId_id: ['venueId', 'id'], - venueId_productId: ['venueId', 'productId'], - venueId_customerNameKey: ['venueId', 'customerNameKey'], -}; - -function matches(row: Row, where: Row = {}): boolean { - return Object.entries(where).every(([field, condition]) => { - if (field in COMPOUND_KEYS) return COMPOUND_KEYS[field].every((key) => row[key] === condition[key]); - const value = row[field]; - if (condition === null || typeof condition !== 'object' || condition instanceof Date) { - return equal(value, condition); - } - return Object.entries(condition).every(([op, operand]: [string, any]) => { - switch (op) { - case 'in': return operand.some((o: unknown) => equal(value, o)); - case 'notIn': return !operand.some((o: unknown) => equal(value, o)); - case 'not': return !equal(value, operand); - case 'gt': return value !== null && value > operand; - case 'gte': return value !== null && value >= operand; - case 'lte': return value !== null && value <= operand; - case 'contains': return typeof value === 'string' && (condition.mode === 'insensitive' - ? value.toLowerCase().includes(operand.toLowerCase()) - : value.includes(operand)); - case 'mode': return true; - default: throw new Error(`fake-prisma: unsupported operator ${op}`); - } - }); - }); -} - -function equal(a: unknown, b: unknown) { - if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); - return (a ?? null) === (b ?? null); -} - -/** A deep copy, so tests can't change stored rows by accident. structuredClone's Dates fail instanceof under Jest. */ -function clone(value: T): T { - if (value instanceof Date) return new Date(value) as T; - if (Array.isArray(value)) return value.map(clone) as T; - if (value && typeof value === 'object') { - return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v)])) as T; - } - return value; -} - -function pick(row: Row, select?: Row): Row { - const copy = clone(row); - if (!select) return copy; - return Object.fromEntries(Object.keys(select).filter((key) => select[key]).map((key) => [key, copy[key]])); -} - -function sort(rows: Row[], orderBy?: Row | Row[]) { - const orders = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : []; - return [...rows].sort((a, b) => { - for (const order of orders) { - const [field, direction] = Object.entries(order)[0]; - // Postgres puts nulls last when ascending, first when descending - if ((a[field] ?? null) === null || (b[field] ?? null) === null) { - if ((a[field] ?? null) === (b[field] ?? null)) continue; - return ((a[field] ?? null) === null ? 1 : -1) * (direction === 'asc' ? 1 : -1); - } - if (a[field] < b[field]) return direction === 'asc' ? -1 : 1; - if (a[field] > b[field]) return direction === 'asc' ? 1 : -1; - } - return 0; - }); -} - -class FakeTable { - rows: Row[] = []; - private sequence = 0; - - constructor(private defaults: () => Row = () => ({}), private sequenceField?: string) {} - - private insert(data: Row) { - // Like @default(uuid()); harmless on tables keyed otherwise - const row: Row = { id: randomUUID(), ...this.defaults(), ...clone(data) }; - if (this.sequenceField) row[this.sequenceField] = ++this.sequence; - this.rows.push(row); - return row; - } - - async findMany(args: Row = {}) { - const found = sort(this.rows.filter((row) => matches(row, args.where)), args.orderBy); - const skip = args.skip ?? 0; - return found.slice(skip, skip + (args.take ?? found.length)).map((row) => pick(row, args.select)); - } - - async count(args: Row = {}) { - return this.rows.filter((row) => matches(row, args.where)).length; - } - - async findUnique(args: Row) { - const row = this.rows.find((r) => matches(r, args.where)); - return row ? pick(row, args.select) : null; - } - - async findFirst(args: Row = {}) { - return (await this.findMany({ ...args, take: 1 }))[0] ?? null; - } - - async create(args: Row) { - return pick(this.insert(args.data), args.select); - } - - async createMany(args: Row) { - let count = 0; - for (const data of args.data) { - if (args.skipDuplicates && this.rows.some((row) => row.id !== undefined && row.id === data.id)) continue; - this.insert(data); - count++; - } - return { count }; - } - - async update(args: Row) { - const row = this.rows.find((r) => matches(r, args.where)); - if (!row) throw new Error('fake-prisma: record to update not found'); - Object.assign(row, clone(args.data)); - return pick(row, args.select); - } - - async updateMany(args: Row) { - const found = this.rows.filter((row) => matches(row, args.where)); - for (const row of found) Object.assign(row, clone(args.data)); - return { count: found.length }; - } - - async upsert(args: Row) { - const row = this.rows.find((r) => matches(r, args.where)); - if (row) { - Object.assign(row, clone(args.update)); - return pick(row); - } - return pick(this.insert(args.create)); - } - - async deleteMany(args: Row = {}) { - const before = this.rows.length; - this.rows = this.rows.filter((row) => !matches(row, args.where)); - return { count: before - this.rows.length }; - } - - async aggregate(args: Row) { - const found = this.rows.filter((row) => matches(row, args.where)); - const max = Object.fromEntries(Object.keys(args._max ?? {}).map((field) => [ - field, - found.length === 0 ? null : Math.max(...found.map((row) => row[field])), - ])); - const sum = Object.fromEntries(Object.keys(args._sum ?? {}).map((field) => [ - field, - found.length === 0 ? null : found.reduce((total, row) => total + row[field], 0), - ])); - return { _max: max, _sum: sum }; - } -} - -export function createFakePrisma() { - const now = () => new Date(); - const prisma = { - venue: new FakeTable(() => ({ tabsSeededAt: null })), - device: new FakeTable(), - tab: new FakeTable(() => ({ updatedAt: now() })), - product: new FakeTable(() => ({ - stock: null, countedAt: null, lowStockThreshold: null, archivedAt: null, imageId: null, fieldUpdatedAt: null, - })), - productImage: new FakeTable(), - tabEvent: new FakeTable(), - settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'), - stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })), - plannedTab: new FakeTable(() => ({ deliveredAt: null, email: null, position: 0 })), - remoteCommand: new FakeTable(), - auditLog: new FakeTable(), - tabReminder: new FakeTable(), - - /** Only the venue lock of VenueLedger.open. */ - async $queryRaw(strings: TemplateStringsArray, venueId: string) { - const venue = prisma.venue.rows.find((row) => row.id === venueId); - return venue ? [{ tabsSeededAt: venue.tabsSeededAt }] : []; - }, - - async $transaction(fn: unknown) { - if (typeof fn === 'function') return fn(prisma); - return Promise.all(fn as Promise[]); - }, - }; - - // Tab rows bump their update time like Prisma's @updatedAt - const upsertTab = prisma.tab.upsert.bind(prisma.tab); - prisma.tab.upsert = (args: Row) => upsertTab({ - ...args, - create: { ...args.create, updatedAt: now() }, - update: { ...args.update, updatedAt: now() }, - }); - - return prisma; -} - -export type FakePrisma = ReturnType; diff --git a/apps/api/src/venues/dto/create-user.dto.ts b/apps/api/src/venues/dto/create-user.dto.ts deleted file mode 100644 index 18de2b2..0000000 --- a/apps/api/src/venues/dto/create-user.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { IsString, IsEmail, IsIn, MinLength, IsNotEmpty } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class CreateUserDto { - @ApiProperty({ example: 'user@example.com' }) - @IsEmail() - email: string; - - @ApiProperty({ example: 'SecurePass123!' }) - @IsString() - @MinLength(6) - password: string; - - @ApiProperty({ example: 'John Doe' }) - @IsString() - @IsNotEmpty() - name: string; - - @ApiProperty({ example: 'manager', enum: ['admin', 'manager'], description: 'manager: runs the venue; admin: also users and terminals' }) - @IsString() - @IsIn(['admin', 'manager']) - role: string; -} \ No newline at end of file diff --git a/apps/api/src/venues/dto/update-cash-payment-emails.dto.ts b/apps/api/src/venues/dto/update-cash-payment-emails.dto.ts deleted file mode 100644 index e60ddd0..0000000 --- a/apps/api/src/venues/dto/update-cash-payment-emails.dto.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ArrayMaxSize, IsArray, IsEmail } from 'class-validator'; -import { ApiProperty } from '@nestjs/swagger'; - -export class UpdateCashPaymentEmailsDto { - @ApiProperty({ example: ['owner@example.com'], description: 'Who gets an email for every tab paid in cash' }) - @IsArray() - @ArrayMaxSize(20) - @IsEmail({}, { each: true }) - emails: string[]; -} diff --git a/apps/api/src/venues/venues.controller.ts b/apps/api/src/venues/venues.controller.ts index 3d7e905..2655d53 100644 --- a/apps/api/src/venues/venues.controller.ts +++ b/apps/api/src/venues/venues.controller.ts @@ -1,96 +1,63 @@ -import { Controller, Get, Param, UseGuards, Post, Put, Body, Delete, Req } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common'; +import { z } from 'zod'; +import { createUserSchema, Device, RegistrationCode, updateVenueSchema, User, Venue } from '@kooltap/shared'; +import { zod } from '../common/zod.pipe'; +import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth'; import { VenuesService } from './venues.service'; -import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../common/guards/roles.guard'; -import { Roles } from '../common/decorators/roles.decorator'; -import { VenueAccessGuard } from '../common/guards/venue-access.guard'; -import { AuthService } from '../auth/auth.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { CreateUserDto } from './dto/create-user.dto'; -import { UpdateCashPaymentEmailsDto } from './dto/update-cash-payment-emails.dto'; -@ApiTags('Venues') -@Controller('venues') +@Controller('venues/:venueId') +@Authorized() export class VenuesController { - constructor( - private venuesService: VenuesService, - private authService: AuthService, - private prisma: PrismaService, - ) {} + constructor(private venues: VenuesService) {} @Get() - @ApiOperation({ summary: 'List all venues (public)' }) - async findAll() { - return this.venuesService.findAll(); + find(@Param('venueId') venueId: string): Promise { + return this.venues.find(venueId); } - @Get(':venueId') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, VenueAccessGuard) - @ApiOperation({ summary: 'Get venue by ID' }) - async findOne(@Param('venueId') venueId: string) { - return this.venuesService.findOne(venueId); - } - - @Put(':venueId/cash-payment-emails') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) + @Patch() @Roles('admin', 'manager') - @ApiOperation({ summary: 'Set who gets an email for every tab paid in cash' }) - async updateCashPaymentEmails(@Param('venueId') venueId: string, @Body() dto: UpdateCashPaymentEmailsDto) { - return this.venuesService.updateCashPaymentEmails(venueId, dto.emails); + update(@Param('venueId') venueId: string, @Body(zod(updateVenueSchema)) dto: z.output): Promise { + return this.venues.update(venueId, dto); } - @Get(':venueId/users') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) + @Get('users') @Roles('admin') - @ApiOperation({ summary: 'List users for a venue' }) - async findUsers(@Param('venueId') venueId: string) { - const users = await this.prisma.clientUser.findMany({ - where: { venueId }, - select: { id: true, email: true, name: true, role: true, createdAt: true }, - orderBy: { createdAt: 'desc' }, - }); - return { users }; + users(@Param('venueId') venueId: string): Promise { + return this.venues.users(venueId); } - @Post(':venueId/users') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) + @Post('users') @Roles('admin') - @ApiOperation({ summary: 'Create a new user for the venue' }) - async createUser( + createUser( @Param('venueId') venueId: string, - @Body() dto: CreateUserDto, + @Body(zod(createUserSchema)) dto: z.output, + @CurrentUser() user: AuthUser, ) { - return this.authService.createUser(venueId, dto.email, dto.password, dto.name, dto.role); + return this.venues.createUser(venueId, dto, user); } - @Delete(':venueId/users/:userId') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, VenueAccessGuard, RolesGuard) + @Delete('users/:userId') @Roles('admin') - @ApiOperation({ summary: 'Delete a user of the venue (not yourself)' }) - async deleteUser( - @Param('venueId') venueId: string, - @Param('userId') userId: string, - @Req() req: any, - ) { - return this.authService.deleteUser(venueId, userId, req.user); + deleteUser(@Param('venueId') venueId: string, @Param('userId') userId: string, @CurrentUser() user: AuthUser) { + return this.venues.deleteUser(venueId, userId, user); } - @Get(':venueId/devices') - @ApiBearerAuth() - @UseGuards(JwtAuthGuard, VenueAccessGuard) - @ApiOperation({ summary: "The venue's paired terminals" }) - async findDevices(@Param('venueId') venueId: string) { - const devices = await this.prisma.device.findMany({ - where: { venueId, revokedAt: null }, - select: { id: true, name: true, lastSeenAt: true, createdAt: true }, - orderBy: { createdAt: 'asc' }, - }); - return { devices }; + @Get('devices') + devices(@Param('venueId') venueId: string): Promise { + return this.venues.devices(venueId); } -} \ No newline at end of file + + @Delete('devices/:deviceId') + @Roles('admin') + unpairDevice(@Param('venueId') venueId: string, @Param('deviceId') deviceId: string, @CurrentUser() user: AuthUser) { + return this.venues.unpairDevice(venueId, deviceId, { type: 'user', id: user.id }); + } + + @Post('registration-codes') + @HttpCode(HttpStatus.CREATED) + @Roles('admin') + createRegistrationCode(@Param('venueId') venueId: string): Promise { + return this.venues.createRegistrationCode(venueId); + } +} diff --git a/apps/api/src/venues/venues.module.ts b/apps/api/src/venues/venues.module.ts index abc4c4a..c396498 100644 --- a/apps/api/src/venues/venues.module.ts +++ b/apps/api/src/venues/venues.module.ts @@ -1,12 +1,10 @@ import { Module } from '@nestjs/common'; -import { AuthModule } from '../auth/auth.module'; import { VenuesController } from './venues.controller'; import { VenuesService } from './venues.service'; @Module({ - imports: [AuthModule], controllers: [VenuesController], providers: [VenuesService], exports: [VenuesService], }) -export class VenuesModule {} \ No newline at end of file +export class VenuesModule {} diff --git a/apps/api/src/venues/venues.service.ts b/apps/api/src/venues/venues.service.ts index 5aab31e..6321fee 100644 --- a/apps/api/src/venues/venues.service.ts +++ b/apps/api/src/venues/venues.service.ts @@ -1,30 +1,112 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomBytes } from 'crypto'; +import { Device, REGISTRATION_CODE_MINUTES, RegistrationCode, User, Venue, VENUE_ROLES } from '@kooltap/shared'; import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { RealtimeService } from '../realtime/realtime.service'; +import { hashPassword } from '../auth/auth.service'; +import { AuthUser } from '../common/auth'; +import { Prisma } from '../prisma/prisma.service'; @Injectable() export class VenuesService { - constructor(private prisma: PrismaService) {} + constructor( + private prisma: PrismaService, + private audit: AuditService, + private realtime: RealtimeService, + ) {} - async findAll() { - return this.prisma.venue.findMany({ select: { id: true, name: true, createdAt: true } }); - } - - async findOne(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' }); - } + async find(venueId: string): Promise { + const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { id: true, name: true, cashPaymentEmails: true } }); + if (!venue) throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' }); return venue; } - /** Who gets an email for every tab paid in cash. Each address once, whatever its case. */ - async updateCashPaymentEmails(venueId: string, emails: string[]) { - const unique = [...new Set(emails.map((email) => email.trim().toLowerCase()).filter(Boolean))]; - const venue = await this.prisma.venue.update({ + /** Who gets an email for every tab paid in cash. Each address once. */ + async update(venueId: string, changes: { cashPaymentEmails: string[] }): Promise { + return this.prisma.venue.update({ where: { id: venueId }, - data: { cashPaymentEmails: unique }, - select: { cashPaymentEmails: true }, + data: { cashPaymentEmails: [...new Set(changes.cashPaymentEmails)] }, + select: { id: true, name: true, cashPaymentEmails: true }, }); - return { emails: venue.cashPaymentEmails }; + } + + async users(venueId: string): Promise { + const users = await this.prisma.user.findMany({ + where: { venueId }, + select: { id: true, email: true, name: true, role: true, createdAt: true }, + orderBy: { createdAt: 'desc' }, + }); + // Every sign-in and refresh (access tokens last 15 minutes) issues a refresh token, so the newest one is when the + // user was last active + const latest = await this.prisma.refreshToken.groupBy({ + by: ['userId'], + where: { userId: { in: users.map((u) => u.id) } }, + _max: { createdAt: true }, + }); + const lastActive = new Map(latest.map((r) => [r.userId, r._max.createdAt])); + return users.map((u) => ({ ...u, createdAt: u.createdAt.toISOString(), lastActiveAt: lastActive.get(u.id)?.toISOString() ?? null })); + } + + async createUser(venueId: string, data: { email: string; password: string; name: string; role: (typeof VENUE_ROLES)[number] }, actor: AuthUser) { + try { + const user = await this.prisma.user.create({ + data: { venueId, email: data.email, name: data.name, role: data.role, passwordHash: await hashPassword(data.password) }, + select: { id: true }, + }); + await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'user_created', details: { userId: user.id, email: data.email, role: data.role } }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' }); + } + throw error; + } + this.realtime.notify(venueId, 'users'); + return { ok: true }; + } + + /** Deletes a user, which signs them out at once. Not yourself, so a venue always keeps an admin. */ + async deleteUser(venueId: string, userId: string, actor: AuthUser) { + if (userId === actor.id) { + throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' }); + } + const user = await this.prisma.user.findFirst({ where: { id: userId, venueId } }); + if (!user) throw new NotFoundException({ code: 'user_not_found', message: 'User not found' }); + + await this.prisma.user.delete({ where: { id: userId } }); + await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'user_deleted', details: { userId, email: user.email, role: user.role } }); + this.realtime.notify(venueId, 'users'); + return { ok: true }; + } + + async devices(venueId: string): Promise { + const devices = await this.prisma.device.findMany({ + where: { venueId, revokedAt: null }, + select: { id: true, name: true, lastSeenAt: true, createdAt: true }, + orderBy: { createdAt: 'asc' }, + }); + return devices.map((d) => ({ ...d, lastSeenAt: d.lastSeenAt.toISOString(), createdAt: d.createdAt.toISOString() })); + } + + /** Unpairs a terminal: its token stops working at once. */ + async unpairDevice(venueId: string, deviceId: string, actor: { type: 'user'; id: string }) { + const { count } = await this.prisma.device.updateMany({ where: { id: deviceId, venueId, revokedAt: null }, data: { revokedAt: new Date() } }); + if (count === 0) { + const exists = await this.prisma.device.count({ where: { id: deviceId, venueId } }); + if (!exists) throw new NotFoundException({ code: 'device_not_found', message: 'Terminal not found' }); + return { ok: true }; + } + await this.audit.log({ venueId, actor, action: 'device_revoked', details: { deviceId } }); + this.realtime.notify(venueId, 'devices'); + return { ok: true }; + } + + /** A code to pair a terminal with, for a few minutes. */ + async createRegistrationCode(venueId: string): Promise { + const expiresAt = new Date(Date.now() + REGISTRATION_CODE_MINUTES * 60 * 1000); + const { code } = await this.prisma.registrationCode.create({ + data: { venueId, code: randomBytes(4).toString('hex').toUpperCase(), expiresAt }, + }); + return { code, expiresAt: expiresAt.toISOString() }; } } diff --git a/apps/api/templates/emails/README.md b/apps/api/templates/emails/README.md index d9d4c04..6e54176 100644 --- a/apps/api/templates/emails/README.md +++ b/apps/api/templates/emails/README.md @@ -45,7 +45,7 @@ Sent by "Send reminders" to customers with an unpaid balance. | `{{venueName}}` | Café De Kroon | | `{{amount}}` | € 29,00 | | `{{itemCount}}` | 12 | -| `{{#hasItems}}…{{/hasItems}}` | Shown when the tab's lines are known. Terminals on an older app version don't report them. | +| `{{#hasItems}}…{{/hasItems}}` | Shown when there are drinks to list. | | `{{#items}}…{{/items}}` | Repeated for each line on the tab, with: | |   `{{quantity}}` | 10 | |   `{{productName}}` | Cola | diff --git a/apps/api/test/api.e2e-spec.ts b/apps/api/test/api.e2e-spec.ts new file mode 100644 index 0000000..846b67f --- /dev/null +++ b/apps/api/test/api.e2e-spec.ts @@ -0,0 +1,262 @@ +/** + * The API against a real, migrated database (DATABASE_URL): the terminal protocol, the dashboard and the superadmin + * page as they are used together. Wipes the database first. + */ +import { Test } from '@nestjs/testing'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import { randomUUID } from 'crypto'; +import request from 'supertest'; +import { APP_VERSION_HEADER, SyncResponse } from '@kooltap/shared'; +import { AppModule } from '../src/app.module'; +import { configure } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { hashPassword } from '../src/auth/auth.service'; + +const API = '/api/v1'; +const APP_VERSION = { [APP_VERSION_HEADER]: '2.0.0' }; + +let app: NestExpressApplication; +let prisma: PrismaService; +let venueId: string; +let otherVenueId: string; + +beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }).compile(); + app = configure(module.createNestApplication({ logger: ['error'] })); + await app.init(); + prisma = app.get(PrismaService); + + await prisma.productImage.deleteMany(); + await prisma.auditLog.deleteMany(); + // Superadmins belong to no venue, so deleting the venues leaves them + await prisma.user.deleteMany(); + await prisma.venue.deleteMany(); + venueId = (await prisma.venue.create({ data: { name: 'Café De Kroon' } })).id; + otherVenueId = (await prisma.venue.create({ data: { name: 'Elsewhere' } })).id; + const passwordHash = await hashPassword('Secret123!'); + await prisma.user.createMany({ + data: [ + { venueId, email: 'admin@kroon.be', name: 'Owner', role: 'admin', passwordHash }, + { venueId: null, email: 'root@kooltap.be', name: 'KoolTap', role: 'superadmin', passwordHash }, + ], + }); +}); + +afterAll(async () => { + await app.close(); +}); + +const server = () => app.getHttpServer(); + +// Signed in once per user: logins are rate limited +const agents = new Map>(); + +async function signIn(email: string) { + let agent = agents.get(email); + if (!agent) { + agent = request.agent(server()); + await agent.post(`${API}/auth/login`).send({ email, password: 'Secret123!' }).expect(200); + agents.set(email, agent); + } + return agent; +} + +describe('auth', () => { + it('signs in with httpOnly cookies, refreshes and signs out', async () => { + const login = await request(server()).post(`${API}/auth/login`).send({ email: ' ADMIN@kroon.be ', password: 'Secret123!' }).expect(200); + expect(login.body).toMatchObject({ email: 'admin@kroon.be', role: 'admin', venueId, venueName: 'Café De Kroon' }); + const cookies = ([] as string[]).concat(login.headers['set-cookie'] ?? []); + expect(cookies.every((cookie) => /HttpOnly/i.test(cookie))).toBe(true); + + const agent = request.agent(server()); + await agent.post(`${API}/auth/login`).send({ email: 'admin@kroon.be', password: 'Secret123!' }).expect(200); + await agent.get(`${API}/auth/me`).expect(200); + await agent.post(`${API}/auth/refresh`).expect(200); + await agent.post(`${API}/auth/logout`).expect(200); + await agent.post(`${API}/auth/refresh`).expect(401); + }); + + it('refuses a wrong password and requests without a session', async () => { + await request(server()).post(`${API}/auth/login`).send({ email: 'admin@kroon.be', password: 'nope' }).expect(401); + await request(server()).get(`${API}/venues/${venueId}/tabs`).expect(401); + }); + + it("keeps users out of other venues, but not a superadmin, who belongs to none", async () => { + await (await signIn('admin@kroon.be')).get(`${API}/venues/${otherVenueId}/tabs`).expect(403); + const root = await signIn('root@kooltap.be'); + expect((await root.get(`${API}/auth/me`).expect(200)).body).toMatchObject({ role: 'superadmin', venueId: null, venueName: null }); + await root.get(`${API}/venues/${otherVenueId}/tabs`).expect(200); + // As the venue's admin + await root.get(`${API}/venues/${otherVenueId}/users`).expect(200); + }); +}); + +describe('terminal and dashboard', () => { + const productId = randomUUID(); + const tabId = randomUUID(); + const lineId = randomUUID(); + let token: string; + let sync: (body: object) => Promise; + // In the past: a terminal clock ahead of the server is capped at its time + let at = Date.now() - 60 * 60 * 1000; + const event = (type: string, fields: object) => ({ id: randomUUID(), type, occurredAt: new Date((at += 1000)).toISOString(), ...fields }); + + it('pairs a terminal with a registration code, once, and refuses an outdated app', async () => { + const admin = await signIn('admin@kroon.be'); + const { body: { code } } = await admin.post(`${API}/venues/${venueId}/registration-codes`).expect(201); + + await request(server()).post(`${API}/terminal/register`).send({ registrationCode: code, name: 'Bar' }).expect(426); + const registered = await request(server()).post(`${API}/terminal/register`).set(APP_VERSION) + .send({ registrationCode: code.toLowerCase(), name: 'Bar' }).expect(201); + expect(registered.body).toMatchObject({ venueId, venueName: 'Café De Kroon' }); + await request(server()).post(`${API}/terminal/register`).set(APP_VERSION).send({ registrationCode: code, name: 'Bar 2' }).expect(401); + + // A 1.x app still on the old protocol is told to update, so it keeps its outbox + for (const path of ['/devices/register', `/devices/${registered.body.deviceId}/sync`]) { + const old = await request(server()).post(`${API}${path}`).set('x-app-version', '1.4.4+0').send({}).expect(426); + expect(old.body.error.code).toBe('app_update_required'); + } + await request(server()).get(`${API}/venues/`).set('x-app-version', '1.4.4+0').expect(426); + + token = registered.body.token; + sync = async (body) => (await request(server()).post(`${API}/terminal/sync`).set(APP_VERSION) + .set('Authorization', `Bearer ${token}`).send(body).expect(200)).body; + }); + + it('applies events once and returns the venue state', async () => { + const events = [ + event('product_created', { productId, product: { name: 'Cola', priceInCents: 250 } }), + event('stock_set', { productId, quantity: 24 }), + event('tab_opened', { tabId, customerName: 'Jan', email: 'Jan@Example.com' }), + event('line_added', { tabId, customerName: 'Jan', lineId, orderId: 'o1', productId, productName: 'Cola', unitPriceInCents: 250, quantity: 3 }), + event('line_removed', { tabId, lineId, orderId: 'o1', quantity: 1 }), + { id: 'broken', type: 'tab_opened' }, + ]; + + const first = await sync({ events }); + expect(first.acknowledgedEventIds).toHaveLength(5); + expect(first.rejectedEvents).toMatchObject([{ id: 'broken' }]); + expect(first.tabs).toMatchObject([{ id: tabId, customerName: 'Jan', email: 'jan@example.com', lines: [{ id: lineId, quantity: 2 }] }]); + expect(first.stock).toEqual([{ productId, stock: 22, countedAt: expect.any(String) }]); + expect(first.catalog).toMatchObject([{ id: productId, name: 'Cola', archived: false }]); + + // Sent again, as a terminal does until it gets the acknowledgement: nothing changes, and the catalog isn't resent + const again = await sync({ events, catalogVersion: first.catalogVersion, settlementsCursor: first.settlementsCursor }); + expect(again.tabs).toEqual(first.tabs); + expect(again.stock).toEqual(first.stock); + expect(again.catalog).toBeNull(); + expect(await prisma.tabUnit.count({ where: { tabId } })).toBe(3); + }); + + it('settles on a terminal and feeds the settlement to every terminal', async () => { + const settled = event('settled', { + tabId, customerName: 'Jan', amountInCents: 250, paymentMethod: 'cash', + items: [{ lineId, productId, productName: 'Cola', unitPriceInCents: 250, quantity: 1 }], + }); + const result = await sync({ events: [settled], settlementsCursor: '0' }); + + expect(result.tabs[0].lines).toMatchObject([{ quantity: 1 }]); + expect(result.settlements).toMatchObject([{ id: settled.id, amountInCents: 250, lines: [{ quantity: 1, units: [{}, { removedAt: expect.any(String) }] }] }]); + expect((await sync({ settlementsCursor: result.settlementsCursor })).settlements).toEqual([]); + }); + + it('shows the tab on the dashboard and settles it there, refusing a stale version', async () => { + const admin = await signIn('admin@kroon.be'); + const [tab] = (await admin.get(`${API}/venues/${venueId}/tabs`).expect(200)).body; + expect(tab).toMatchObject({ id: tabId, totalInCents: 250, itemCount: 1 }); + + await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version - 1, paymentMethod: 'cash' }).expect(409); + await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version }).expect(400); + const settled = await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version, paymentMethod: 'payconiq' }).expect(200); + expect(settled.body.amountInCents).toBe(250); + + await admin.post(`${API}/venues/${venueId}/tabs/${tabId}/settle`).send({ expectedVersion: tab.version, paymentMethod: 'payment_pending' }).expect(400); + const history = (await admin.get(`${API}/venues/${venueId}/settlements?limit=10`).expect(200)).body; + expect(history).toMatchObject({ total: 2, totalAmountInCents: 500 }); + expect(history.settlements[0]).toMatchObject({ paymentMethod: 'payconiq', settledBy: 'Owner', onDashboard: true }); + expect(history.settlements[1]).toMatchObject({ paymentMethod: 'cash', settledBy: 'Bar', items: [{ quantity: 1, removed: 1 }] }); + }); + + it('opens a tab for a new customer on the next sync', async () => { + const admin = await signIn('admin@kroon.be'); + await admin.post(`${API}/venues/${venueId}/customers`).send({ name: ' Marie Peeters ', email: 'marie@example.com' }).expect(201); + await admin.post(`${API}/venues/${venueId}/customers`).send({ name: 'marie peeters' }).expect(409); + + const result = await sync({}); + expect(result.tabs.map((t) => t.customerName)).toContain('Marie Peeters'); + // Jan was added by the terminal that opened his tab with an email + expect((await admin.get(`${API}/venues/${venueId}/customers`).expect(200)).body).toMatchObject([ + { name: 'Jan', email: 'jan@example.com' }, + { name: 'Marie Peeters', tabOpenedAt: expect.any(String) }, + ]); + }); + + it('records stock movements and refuses adding to an untracked product', async () => { + const admin = await signIn('admin@kroon.be'); + const { body: chips } = await admin.post(`${API}/venues/${venueId}/stock/products`).send({ name: 'Chips', priceInCents: 200 }).expect(201); + + await admin.post(`${API}/venues/${venueId}/stock/movements`).send({ movements: [{ productId: chips.id, type: 'add', quantity: 5 }] }).expect(400); + const { body } = await admin.post(`${API}/venues/${venueId}/stock/movements`).send({ + movements: [{ productId: chips.id, type: 'count', quantity: 10 }, { productId: chips.id, type: 'add', quantity: -2, note: 'Broken' }], + }).expect(201); + expect(body.movements.map((m: { stockAfter: number }) => m.stockAfter).sort()).toEqual([10, 8]); + + const products = (await admin.get(`${API}/venues/${venueId}/stock/products`).expect(200)).body; + expect(products.find((p: { id: string }) => p.id === chips.id)).toMatchObject({ stock: 8 }); + await admin.post(`${API}/venues/${venueId}/stock/products`).send({ name: 'chips', priceInCents: 100 }).expect(409); + }); + + it('unpairs the terminal, which then cannot sync', async () => { + const admin = await signIn('admin@kroon.be'); + const [device] = (await admin.get(`${API}/venues/${venueId}/devices`).expect(200)).body; + await admin.delete(`${API}/venues/${venueId}/devices/${device.id}`).expect(200); + + await request(server()).post(`${API}/terminal/sync`).set(APP_VERSION).set('Authorization', `Bearer ${token}`).send({}).expect(401); + }); +}); + +describe('superadmin', () => { + it('is the only one who reaches the admin page', async () => { + await (await signIn('admin@kroon.be')).get(`${API}/admin/venues`).expect(403); + const venues = (await (await signIn('root@kooltap.be')).get(`${API}/admin/venues`).expect(200)).body; + expect(venues.map((v: { name: string }) => v.name)).toEqual(['Café De Kroon', 'Elsewhere']); + }); + + it('resets the tabs, which brings the Free Drinks customer back', async () => { + const root = await signIn('root@kooltap.be'); + const dryRun = (await root.post(`${API}/admin/venues/${venueId}/reset`).send({ scopes: ['tabs'], dryRun: true }).expect(200)).body; + expect(dryRun.counts.tabs).toBeGreaterThan(0); + + await root.post(`${API}/admin/venues/${venueId}/reset`).send({ scopes: ['tabs'] }).expect(200); + expect(await prisma.tab.count({ where: { venueId } })).toBe(0); + expect(await prisma.customer.findMany({ where: { venueId }, select: { name: true } })).toContainEqual({ name: 'Free Drinks' }); + }); + + it('checks a venue and the system', async () => { + const root = await signIn('root@kooltap.be'); + const consistency = (await root.get(`${API}/admin/venues/${venueId}/consistency`).expect(200)).body; + expect(consistency.findings.filter((f: { code: string }) => f.code === 'settlement_amount_mismatch')).toEqual([]); + + const health = (await root.get(`${API}/admin/health`).expect(200)).body; + expect(health.migrations).toMatchObject({ applied: 1, failed: [] }); + }); + + it('moves a user between a venue and above the venues', async () => { + const root = await signIn('root@kooltap.be'); + const { body: users } = await root.get(`${API}/admin/users`).expect(200); + const owner = users.find((u: { email: string }) => u.email === 'admin@kroon.be'); + + await root.patch(`${API}/admin/users/${owner.id}/role`).send({ role: 'superadmin' }).expect(200); + expect(await prisma.user.findUnique({ where: { id: owner.id } })).toMatchObject({ role: 'superadmin', venueId: null }); + await root.patch(`${API}/admin/users/${owner.id}/role`).send({ role: 'admin' }).expect(400); + await root.patch(`${API}/admin/users/${owner.id}/role`).send({ role: 'admin', venueId }).expect(200); + expect(await prisma.user.findUnique({ where: { id: owner.id } })).toMatchObject({ role: 'admin', venueId }); + }); + + it('deletes a venue with everything in it', async () => { + const root = await signIn('root@kooltap.be'); + await root.delete(`${API}/admin/venues/${otherVenueId}?confirmName=Wrong`).expect(400); + await root.delete(`${API}/admin/venues/${otherVenueId}?confirmName=Elsewhere`).expect(200); + expect(await prisma.venue.count({ where: { id: otherVenueId } })).toBe(0); + }); +}); diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts deleted file mode 100644 index f78bc02..0000000 --- a/apps/api/test/app.e2e-spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication, ValidationPipe } from '@nestjs/common'; -import request = require('supertest'); -import { AppModule } from '../src/app.module'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter'; - -describe('KoolTap API (e2e)', () => { - let app: INestApplication; - let prisma: PrismaService; - - beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.setGlobalPrefix('api/v1'); - app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); - app.useGlobalFilters(new HttpExceptionFilter()); - - prisma = app.get(PrismaService); - await app.init(); - }); - - afterAll(async () => { - await app.close(); - }); - - describe('Health', () => { - it('GET /api/v1/health should return ok', () => { - return request(app.getHttpServer()) - .get('/api/v1/health') - .expect(200) - .expect((res) => { - expect(res.body.status).toBe('ok'); - }); - }); - }); - - describe('Auth', () => { - it('POST /api/v1/auth/login with invalid credentials should return 401', () => { - return request(app.getHttpServer()) - .post('/api/v1/auth/login') - .send({ email: 'nonexistent@test.com', password: 'wrong-password' }) - .expect(401); - }); - - it('POST /api/v1/auth/login with valid credentials should return tokens', async () => { - // First create a user via seed or directly - const bcrypt = require('bcrypt'); - const hash = await bcrypt.hash('Test123!', 10); - const { v4: uuid } = require('uuid'); - const venueId = uuid(); - const email = `test-${venueId}@test.com`; - - await prisma.venue.create({ - data: { id: venueId, name: 'Test Venue' }, - }); - - await prisma.clientUser.create({ - data: { - id: uuid(), - venueId, - email, - passwordHash: hash, - name: 'Test User', - role: 'admin', - }, - }); - - return request(app.getHttpServer()) - .post('/api/v1/auth/login') - .send({ email, password: 'Test123!' }) - .expect(200) - .expect((res) => { - expect(res.body.accessToken).toBeDefined(); - expect(res.body.refreshToken).toBeDefined(); - expect(res.body.user.email).toBe(email); - }); - }); - }); - - describe('Venue isolation', () => { - it('should not allow cross-venue access', async () => { - const bcrypt = require('bcrypt'); - const { v4: uuid } = require('uuid'); - const venueAId = uuid(); - const venueBId = uuid(); - const email = `venuea-${venueAId}@test.com`; - - await prisma.venue.create({ data: { id: venueAId, name: 'Venue A' } }); - await prisma.venue.create({ data: { id: venueBId, name: 'Venue B' } }); - - const hash = await bcrypt.hash('Pass123!', 10); - await prisma.clientUser.create({ - data: { - id: uuid(), - venueId: venueAId, - email, - passwordHash: hash, - name: 'Venue A User', - role: 'admin', - }, - }); - - // Login as Venue A user - const loginRes = await request(app.getHttpServer()) - .post('/api/v1/auth/login') - .send({ email, password: 'Pass123!' }) - .expect(200); - - const token = loginRes.body.accessToken; - - // Try to access Venue B data - await request(app.getHttpServer()) - .get(`/api/v1/venues/${venueBId}/tabs`) - .set('Authorization', `Bearer ${token}`) - .expect(403) - .expect((res) => { - expect(res.body.error.code).toBe('venue_forbidden'); - }); - }); - }); -}); \ No newline at end of file diff --git a/apps/api/test/jest-e2e.json b/apps/api/test/jest-e2e.json index fd8633b..5f9bd64 100644 --- a/apps/api/test/jest-e2e.json +++ b/apps/api/test/jest-e2e.json @@ -2,11 +2,13 @@ "moduleFileExtensions": ["js", "json", "ts"], "rootDir": ".", "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.ts$": "ts-jest" + }, "moduleNameMapper": { "^@kooltap/shared$": "/../../../packages/shared/src" }, - "testRegex": ".e2e-spec.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - } -} \ No newline at end of file + "setupFiles": ["dotenv/config"], + "testTimeout": 30000 +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json new file mode 100644 index 0000000..5417c7d --- /dev/null +++ b/apps/api/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["src/**/*.spec.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 85baea8..d1c261d 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,15 +2,13 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "module": "commonjs", - "target": "ES2021", + "target": "ES2023", + "lib": ["ES2023"], "emitDecoratorMetadata": true, "experimentalDecorators": true, - "strict": true, - "strictNullChecks": true, - "noUnusedLocals": false, "strictPropertyInitialization": false, - "skipLibCheck": true + "declaration": false, + "declarationMap": false }, "include": ["src/**/*"] -} \ No newline at end of file +} diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts index 40c3d68..ce4e94a 100644 --- a/apps/dashboard/next-env.d.ts +++ b/apps/dashboard/next-env.d.ts @@ -1,5 +1,7 @@ /// /// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited -// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dashboard/next.config.js b/apps/dashboard/next.config.js index a124670..878a83e 100644 --- a/apps/dashboard/next.config.js +++ b/apps/dashboard/next.config.js @@ -10,6 +10,11 @@ const securityHeaders = [ /** @type {import('next').NextConfig} */ const nextConfig = { poweredByHeader: false, + // A small server with only what it needs, for the Docker image + output: 'standalone', + outputFileTracingRoot: require('path').join(__dirname, '../..'), + // The shared package is TypeScript source + transpilePackages: ['@kooltap/shared'], async headers() { return [ { source: '/:path*', headers: securityHeaders }, diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 623364e..f371440 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,48 +1,43 @@ { "name": "@kooltap/dashboard", - "version": "1.0.0", + "version": "2.0.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint", "typecheck": "tsc --noEmit", - "clean": "echo 'clean'" + "test": "vitest run", + "clean": "rimraf .next" }, "dependencies": { "@kooltap/shared": "workspace:*", - "@radix-ui/react-alert-dialog": "^1.0.5", - "@radix-ui/react-dialog": "^1.0.5", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-label": "^2.0.2", - "@radix-ui/react-progress": "^1.0.3", - "@radix-ui/react-radio-group": "^1.1.3", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-separator": "^1.0.3", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-toast": "^1.1.5", - "@radix-ui/react-tooltip": "^1.0.7", - "@tanstack/react-query": "^5.17.0", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.0", - "lucide-react": "^0.309.0", - "next": "^14.1.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-hook-form": "^7.49.0", - "socket.io-client": "^4.8.4", - "tailwind-merge": "^2.2.1", - "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz", - "zod": "^3.22.4" + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-toast": "^1.2.15", + "@tanstack/react-query": "^5.90.5", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.548.0", + "next": "^16.0.1", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "socket.io-client": "^4.8.1", + "tailwind-merge": "^3.3.1", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz" }, "devDependencies": { - "@types/node": "^20.11.0", - "@types/react": "^18.2.48", - "@types/react-dom": "^18.2.18", - "autoprefixer": "^10.4.17", - "postcss": "^8.4.33", - "tailwindcss": "^3.4.1", - "typescript": "^5.4.0" + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.6", + "rimraf": "^6.0.1", + "tailwindcss": "^3.4.18", + "typescript": "~5.9.3", + "vitest": "^4.0.6" } -} \ No newline at end of file +} diff --git a/apps/dashboard/src/app/dashboard/account/page.tsx b/apps/dashboard/src/app/dashboard/account/page.tsx index 671b0ea..b24c604 100644 --- a/apps/dashboard/src/app/dashboard/account/page.tsx +++ b/apps/dashboard/src/app/dashboard/account/page.tsx @@ -11,7 +11,8 @@ import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; import { useSession } from '@/lib/session'; -const MIN_PASSWORD_LENGTH = 6; +// As the API requires +const MIN_PASSWORD_LENGTH = 8; export default function AccountPage() { const { email, role } = useSession(); diff --git a/apps/dashboard/src/app/dashboard/admin/devices-section.tsx b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx index 0adf86d..626789b 100644 --- a/apps/dashboard/src/app/dashboard/admin/devices-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/devices-section.tsx @@ -27,7 +27,7 @@ export function DevicesSection() { const { t, te, relativeTime, dateTime } = useLang(); const [showUnpaired, setShowUnpaired] = useState(false); const [unpairing, setUnpairing] = useState(null); - const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' | 'pending'>(); + const sort = useSort<'name' | 'venue' | 'status' | 'lastSeen' | 'tokenExpires' >(); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'devices'], @@ -60,14 +60,13 @@ export function DevicesSection() { if (isLoading) return ; if (error) return ; - const all = data?.devices ?? []; + const all = data ?? []; const devices = sort.apply(showUnpaired ? all : all.filter((d) => !d.revokedAt), { name: (d) => d.name, venue: (d) => d.venueName, status: (d) => STATUS_ORDER.indexOf(getDeviceStatus(d.lastSeenAt, d.revokedAt)), lastSeen: (d) => new Date(d.lastSeenAt), tokenExpires: (d) => new Date(d.tokenExpiresAt), - pending: (d) => d.pendingCommands, }); const now = Date.now(); @@ -89,7 +88,6 @@ export function DevicesSection() { {t('admin.devices.status')} {t('admin.devices.last_seen')} {t('admin.devices.token_expires')} - {t('admin.devices.pending')} @@ -108,7 +106,6 @@ export function DevicesSection() { {dateTime(device.tokenExpiresAt)} - {device.pendingCommands || ''} {!device.revokedAt && ( diff --git a/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx b/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx index f14e5d0..7e5971d 100644 --- a/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx +++ b/apps/dashboard/src/app/dashboard/admin/insight-sections.tsx @@ -47,7 +47,7 @@ export function AuditSection() { {t('admin.all_venues')} - {(venues.data?.venues ?? []).map((v) => {v.name})} + {(venues.data ?? []).map((v) => {v.name})} @@ -75,9 +75,9 @@ export function AuditSection() { return ( {dateTime(entry.createdAt)} - {entry.venueName} + {entry.venueName ?? '—'} - {entry.actorName ?? {entry.actorId.slice(0, 8)}} + {entry.actorName ?? {entry.actorId?.slice(0, 8) ?? '—'}} {entry.actorType} @@ -130,11 +130,7 @@ export function HealthSection() { value: data.migrations.failed.length > 0 ? t('admin.health.migrations_failed', { count: data.migrations.failed.length }) : String(data.migrations.applied), bad: data.migrations.failed.length > 0, }, - { - label: t('admin.health.expiry_job'), - value: data.commandExpiryLastRunAt ? relativeTime(data.commandExpiryLastRunAt) : t('admin.health.not_yet'), - }, - { label: t('admin.health.stuck'), value: String(data.stuckCommands), bad: data.stuckCommands > 0 }, + { label: t('admin.health.images'), value: formatBytes(data.imageBytes) }, { label: t('admin.health.email'), value: data.emailConfigured ? t('admin.health.configured') : t('admin.health.not_configured'), bad: !data.emailConfigured }, { label: t('admin.health.node'), value: data.nodeVersion }, ]; diff --git a/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx b/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx index 4774fa1..de53560 100644 --- a/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/maintenance-section.tsx @@ -2,7 +2,8 @@ import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { adminApi, PURGE_TARGETS, type AdminVenue, type PurgeTarget } from '@/lib/api/client'; +import { PURGE_TARGETS } from '@kooltap/shared'; +import { adminApi, type AdminVenue, type PurgeTarget } from '@/lib/api/client'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -17,7 +18,6 @@ const ALL_VENUES = 'all'; export function MaintenanceSection() { return (
- @@ -34,34 +34,6 @@ function useFeedback() { }; } -function StuckCommandsCard() { - const { t } = useLang(); - const { onError, refresh } = useFeedback(); - const preview = useQuery({ queryKey: ['admin', 'stuck-commands'], queryFn: () => adminApi.expireStuckCommands(true) }); - const expire = useMutation({ - mutationFn: () => adminApi.expireStuckCommands(false), - onSuccess: ({ count }) => toast({ title: t('admin.stuck.done', { count }), variant: 'success' }), - onError, - onSettled: refresh, - }); - const count = preview.data?.count ?? 0; - - return ( - - - {t('admin.stuck.title')} - {t('admin.stuck.desc')} - - -

{preview.isLoading ? t('common.loading') : t('admin.stuck.count', { count })}

- -
-
- ); -} - function ImagesCard() { const { t } = useLang(); const { onError, refresh } = useFeedback(); @@ -108,7 +80,7 @@ function ImagesCard() { function PurgeCard() { const { t, te } = useLang(); const { onError, refresh } = useFeedback(); - const [targets, setTargets] = useState(['tabEvents', 'commands']); + const [targets, setTargets] = useState(['tabEvents']); const [days, setDays] = useState('90'); const [venueId, setVenueId] = useState(ALL_VENUES); const [confirming, setConfirming] = useState(false); @@ -162,7 +134,7 @@ function PurgeCard() { {t('admin.all_venues')} - {(venues.data?.venues ?? []).map((v: AdminVenue) => {v.name})} + {(venues.data ?? []).map((v: AdminVenue) => {v.name})}
diff --git a/apps/dashboard/src/app/dashboard/admin/users-section.tsx b/apps/dashboard/src/app/dashboard/admin/users-section.tsx index 23da6de..d145d0a 100644 --- a/apps/dashboard/src/app/dashboard/admin/users-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/users-section.tsx @@ -30,9 +30,12 @@ export function UsersSection() { const [action, setAction] = useState<{ kind: 'password' | 'logout' | 'delete'; user: AdminUser } | null>(null); const [password, setPassword] = useState(''); const [logoutAll, setLogoutAll] = useState(false); + // A superadmin becoming an admin or manager joins a venue: picked here first + const [joining, setJoining] = useState<{ user: AdminUser; role: Role; venueId: string } | null>(null); const sort = useSort<'name' | 'venue' | 'role' | 'sessions' | 'created'>(); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'users'], queryFn: adminApi.users }); + const venues = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues, enabled: joining !== null }); const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin'] }); const onError = (err: unknown) => toast({ title: t('common.failed'), description: te(err), variant: 'destructive' }); @@ -43,10 +46,18 @@ export function UsersSection() { }; const role = useMutation({ - mutationFn: ({ user, role }: { user: AdminUser; role: Role }) => adminApi.updateRole(user.id, role), - onSuccess: (_, { user, role }) => done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) })), + mutationFn: ({ user, role, venueId }: { user: AdminUser; role: Role; venueId?: string }) => adminApi.updateRole(user.id, role, venueId), + onSuccess: (_, { user, role }) => { + setJoining(null); + done(t('admin.users.role_changed', { email: user.email, role: t(`users.role.${role}`) })); + }, onError, }); + + function changeRole(user: AdminUser, next: Role) { + if (user.venueId === null && next !== 'superadmin') setJoining({ user, role: next, venueId: '' }); + else role.mutate({ user, role: next }); + } const resetPassword = useMutation({ mutationFn: (user: AdminUser) => adminApi.resetPassword(user.id, password), onSuccess: (_, user) => done(t('admin.users.password_set', { email: user.email })), @@ -76,8 +87,8 @@ export function UsersSection() { if (error) return ; const query = search.trim().toLowerCase(); - const users = sort.apply((data?.users ?? []).filter((u) => - !query || [u.name, u.email, u.venueName].some((field) => field.toLowerCase().includes(query))), { + const users = sort.apply((data ?? []).filter((u) => + !query || [u.name, u.email, u.venueName ?? ''].some((field) => field.toLowerCase().includes(query))), { name: (u) => u.name, venue: (u) => u.venueName, role: (u) => t(`users.role.${u.role}`), @@ -120,10 +131,10 @@ export function UsersSection() { {user.name} {user.email}
- {user.venueName} + {user.venueName ?? t('admin.users.no_venue')} {/* You can't change your own role, so there is always a superadmin left */} - changeRole(user, next as Role)} disabled={self || role.isPending}> {ROLES.map((r) => {t(`users.role.${r}`)})} @@ -171,13 +182,34 @@ export function UsersSection() { - + !open && !role.isPending && setJoining(null)}> + + + {t('admin.users.pick_venue', { name: joining?.user.name ?? '' })} + {t('admin.users.pick_venue_desc')} + + + + + + + + + !open && !logout.isPending && setAction(null)} diff --git a/apps/dashboard/src/app/dashboard/admin/venues-section.tsx b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx index e92dc5e..d21c5f7 100644 --- a/apps/dashboard/src/app/dashboard/admin/venues-section.tsx +++ b/apps/dashboard/src/app/dashboard/admin/venues-section.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, CheckCircle2, Copy, Download, Eye, Info, KeyRound, Pencil, Plus, RefreshCw, RotateCcw, Stethoscope, Trash2 } from 'lucide-react'; -import { adminApi, RESET_SCOPES, type AdminVenue, type Finding, type ResetScope } from '@/lib/api/client'; +import { RESET_SCOPES } from '@kooltap/shared'; +import { adminApi, type AdminVenue, type Finding, type ResetScope } from '@/lib/api/client'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -18,13 +19,13 @@ import { LoadingState } from '@/components/loading-state'; import { ErrorState } from '@/components/error-state'; import { toast } from '@/components/ui/toast'; import { useLang } from '@/lib/i18n'; -import { useSession, viewVenue } from '@/lib/session'; +import { viewVenue } from '@/lib/session'; import { Checkbox, CountList, DangerDialog, downloadVenueExport } from './shared'; // "Clear all tabs" and "full reset" are the common cases; any other mix can be ticked by hand const PRESETS: { key: string; scopes: ResetScope[] }[] = [ { key: 'admin.reset.preset_tabs', scopes: ['tabs'] }, - { key: 'admin.reset.preset_event', scopes: ['tabs', 'plannedTabs', 'stock'] }, + { key: 'admin.reset.preset_event', scopes: ['tabs', 'customers', 'stock'] }, { key: 'admin.reset.preset_full', scopes: [...RESET_SCOPES] }, ]; @@ -36,7 +37,7 @@ export function VenuesSection() { const sort = useSort<'name' | 'users' | 'terminals' | 'openTabs' | 'products' | 'lastSync'>(); const { data, isLoading, error } = useQuery({ queryKey: ['admin', 'venues'], queryFn: adminApi.venues }); - const venues = data?.venues ?? []; + const venues = data ?? []; const selected = venues.find((v) => v.id === selectedId) ?? null; if (isLoading) return ; @@ -118,7 +119,6 @@ export function VenuesSection() { function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: AdminVenue[]; onDeleted: () => void }) { const queryClient = useQueryClient(); - const { homeVenueId } = useSession(); const { t, te, dateTime } = useLang(); const [dialog, setDialog] = useState<'rename' | 'reset' | 'copy' | 'delete' | null>(null); const [findings, setFindings] = useState(null); @@ -148,7 +148,7 @@ function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: A
@@ -174,11 +174,9 @@ function VenuePanel({ venue, venues, onDeleted }: { venue: AdminVenue; venues: A - {venue.id !== homeVenueId && ( - - )} +
{pairCode && ( diff --git a/apps/dashboard/src/app/dashboard/commands/page.tsx b/apps/dashboard/src/app/dashboard/commands/page.tsx deleted file mode 100644 index 75c9b4b..0000000 --- a/apps/dashboard/src/app/dashboard/commands/page.tsx +++ /dev/null @@ -1,137 +0,0 @@ -'use client'; - -import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; -import { commandsApi } from '@/lib/api/client'; -import { PageHeader } from '@/components/page-header'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { CommandStatusBadge } from '@/components/command-status-badge'; -import { PaymentMethodBadge } from '@/components/payment-method-badge'; -import { EmptyState } from '@/components/empty-state'; -import { LoadingState } from '@/components/loading-state'; -import { ErrorState } from '@/components/error-state'; -import { Filter } from 'lucide-react'; -import { useRealtime } from '@/lib/realtime'; -import { useLang } from '@/lib/i18n'; -import { useSession } from '@/lib/session'; -import { formatCents } from '@/lib/utils'; - -const statusFilters = [ - { value: '', label: 'commands.all' }, - { value: 'pending', label: 'status.pending' }, - { value: 'completed', label: 'status.completed' }, - { value: 'failed', label: 'status.failed' }, - { value: 'conflict', label: 'status.conflict' }, - { value: 'expired', label: 'status.expired' }, -]; - -export default function CommandsPage() { - // Live updates make polling unnecessary; it only runs while the socket is down - const { connected: live } = useRealtime(); - const { t, tn, te, relativeTime } = useLang(); - const { venueId } = useSession(); - const [statusFilter, setStatusFilter] = useState(''); - const [page, setPage] = useState(0); - const limit = 20; - - const { data, isLoading, error } = useQuery({ - queryKey: ['commands', venueId, statusFilter, page], - queryFn: () => commandsApi.list(venueId, { limit, offset: page * limit, status: statusFilter || undefined }), - refetchInterval: live ? false : 10000, - }); - - const commands = data?.commands ?? []; - const total = data?.total ?? 0; - const totalPages = Math.ceil(total / limit); - - if (isLoading) return ; - if (error) return ; - - return ( -
- - {statusFilters.map((f) => ( - - ))} -
- } - /> - - {commands.length === 0 ? ( - - ) : ( - <> -
- - - - {t('commands.customer')} - {t('commands.amount')} - {t('commands.requested')} - {t('commands.payment')} - {t('commands.created')} - {t('commands.status')} - {t('commands.processed')} - {t('commands.result')} - - - - {commands.map((cmd) => ( - - {cmd.customerName ?? {cmd.tabId.substring(0, 8)}} - {cmd.amountInCents != null ? formatCents(cmd.amountInCents) : '-'} - {cmd.requestedBy} - - {relativeTime(cmd.createdAt)} - - {cmd.processedAt ? relativeTime(cmd.processedAt) : '-'} - {cmd.result || '-'} - - ))} - -
-
- - {totalPages > 1 && ( -
-

- {t('commands.page', { page: page + 1, total: totalPages })} -

-
- - -
-
- )} - - )} - - ); -} \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/history/page.tsx b/apps/dashboard/src/app/dashboard/history/page.tsx index 77eefb6..9b59220 100644 --- a/apps/dashboard/src/app/dashboard/history/page.tsx +++ b/apps/dashboard/src/app/dashboard/history/page.tsx @@ -22,7 +22,6 @@ const paymentFilters = [ { value: '', label: 'history.all_methods' }, { value: 'cash', label: 'payment.cash' }, { value: 'payconiq', label: 'payment.payconiq' }, - { value: 'payment_pending', label: 'payment.payment_pending' }, ]; const limit = 25; @@ -49,8 +48,8 @@ export default function HistoryPage() { }, [search]); const { data, isLoading, error } = useQuery({ - queryKey: ['tab-history', venueId, debouncedSearch, paymentMethod, page], - queryFn: () => tabsApi.history(venueId, { search: debouncedSearch, paymentMethod, limit, offset: page * limit }), + queryKey: ['settlements', venueId, debouncedSearch, paymentMethod, page], + queryFn: () => tabsApi.settlements(venueId, { search: debouncedSearch, paymentMethod, limit, offset: page * limit }), placeholderData: keepPreviousData, refetchInterval: live ? false : 10000, }); @@ -76,7 +75,7 @@ export default function HistoryPage() { queryClient.invalidateQueries({ queryKey: ['tab-history'] })}>{t('common.refresh')}} + actions={} /> {total === 0 && !filtered ? ( diff --git a/apps/dashboard/src/app/dashboard/layout.tsx b/apps/dashboard/src/app/dashboard/layout.tsx index 6c24769..98ebef3 100644 --- a/apps/dashboard/src/app/dashboard/layout.tsx +++ b/apps/dashboard/src/app/dashboard/layout.tsx @@ -7,7 +7,6 @@ import { LayoutDashboard, ClipboardList, Monitor, - Terminal, Settings, LogOut, Menu, @@ -23,14 +22,17 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/cn'; import { useLang } from '@/lib/i18n'; import { LanguageSelector } from '@/components/language-selector'; -import { authApi, venuesApi } from '@/lib/api/client'; +import { authApi } from '@/lib/api/client'; import { toast } from '@/components/ui/toast'; -import { RealtimeProvider, useRealtime } from '@/lib/realtime'; -import { canAdministerVenue, readSession, Session, SessionProvider, viewVenue } from '@/lib/session'; +import { RealtimeProvider } from '@/lib/realtime'; +import { canAdministerVenue, hasVenue, Session, sessionFor, SessionProvider, viewVenue } from '@/lib/session'; import { LoadingState } from '@/components/loading-state'; -// Who sees each item: everyone, admins (and superadmins), or superadmins only -type Access = 'all' | 'admin' | 'superadmin'; +// Who sees each item: everyone in a venue, admins (and superadmins viewing a venue), superadmins only, or everyone +type Access = 'all' | 'admin' | 'superadmin' | 'always'; + +// The pages a superadmin reaches without viewing a venue +const VENUELESS_PAGES = ['/dashboard/admin', '/dashboard/account']; const navItems: { href: string; key: string; icon: typeof LayoutDashboard; access: Access }[] = [ { href: '/dashboard', key: 'nav.overview', icon: LayoutDashboard, access: 'all' }, @@ -39,9 +41,8 @@ const navItems: { href: string; key: string; icon: typeof LayoutDashboard; acces { href: '/dashboard/reminders', key: 'nav.reminders', icon: Bell, access: 'all' }, { href: '/dashboard/stock', key: 'nav.stock', icon: Package, access: 'all' }, { href: '/dashboard/users', key: 'nav.users', icon: Monitor, access: 'admin' }, - { href: '/dashboard/commands', key: 'nav.commands', icon: Terminal, access: 'all' }, { href: '/dashboard/settings', key: 'nav.settings', icon: Settings, access: 'all' }, - { href: '/dashboard/account', key: 'nav.account', icon: UserCog, access: 'all' }, + { href: '/dashboard/account', key: 'nav.account', icon: UserCog, access: 'always' }, { href: '/dashboard/admin', key: 'nav.admin', icon: ShieldAlert, access: 'superadmin' }, ]; @@ -54,30 +55,30 @@ export default function DashboardLayout({ const pathname = usePathname(); const [sidebarOpen, setSidebarOpen] = useState(false); const [user, setUser] = useState(null); - const [venue, setVenue] = useState<{ name: string } | null>(null); const { t } = useLang(); + // Without a session the API call sends the user to the login page useEffect(() => { - const session = readSession(); - if (!session) { - router.replace('/login'); - return; - } - setUser(session); - venuesApi.get(session.venueId).then(setVenue).catch(() => {}); + authApi.me().then((me) => setUser(sessionFor(me)), () => router.replace('/login')); }, [router]); - if (!user) return ; + const inVenue = user !== null && hasVenue(user); + // A superadmin without a venue starts on the admin page + useEffect(() => { + if (user && !inVenue && !VENUELESS_PAGES.includes(pathname)) router.replace('/dashboard/admin'); + }, [user, inVenue, pathname, router]); - function handleLogout() { - authApi.logout(); + if (!user || (!inVenue && !VENUELESS_PAGES.includes(pathname))) return ; + + async function handleLogout() { + await authApi.logout().catch(() => {}); toast({ title: t('common.logged_out'), variant: 'default' }); router.push('/login'); } return ( - +
{/* Mobile sidebar toggle */}
@@ -106,9 +107,7 @@ export default function DashboardLayout({

KoolTap

- {venue && ( -

{venue.name}

- )} +

{inVenue ? user.venueName : t('admin.title')}