@@ -3,4 +3,9 @@
|
||||
**/.next
|
||||
**/.env
|
||||
**/.env.*
|
||||
**/coverage
|
||||
.git
|
||||
# Generated inside the image from the schema
|
||||
apps/api/src/generated
|
||||
# Local pictures (IMAGE_DIR)
|
||||
apps/api/data
|
||||
|
||||
+9
-4
@@ -1,11 +1,16 @@
|
||||
# KoolTap Remote Tab Control
|
||||
# Copy to apps/api/.env for local development (see docs/local-development.md)
|
||||
|
||||
DATABASE_URL="postgresql://kooltap:kooltap@localhost:5432/kooltap"
|
||||
JWT_SECRET="dev-jwt-secret-change-in-production"
|
||||
PORT=3001
|
||||
# The dashboard's address; several are comma separated
|
||||
CORS_ORIGIN="http://localhost:3000"
|
||||
# Oldest terminal app version that may sync (defaults to the one in apps/api/src/devices/app-version.ts)
|
||||
# MIN_APP_VERSION="1.4.0"
|
||||
# Where product pictures are stored
|
||||
IMAGE_DIR="data/images"
|
||||
# Email (payment reminders, cash payment notices); leave empty to disable sending
|
||||
RESEND_API_KEY=""
|
||||
# Oldest terminal app version that may sync (defaults to the one in apps/api/src/terminal/app-version.ts)
|
||||
# MIN_APP_VERSION="2.0.0"
|
||||
|
||||
# Dashboard
|
||||
# Dashboard (apps/dashboard/.env.local)
|
||||
NEXT_PUBLIC_API_URL="http://localhost:3001/api/v1"
|
||||
@@ -1,14 +0,0 @@
|
||||
# Production environment variables for KoolTap
|
||||
# Copy to .env and fill in your values
|
||||
|
||||
# PostgreSQL
|
||||
DB_PASSWORD=changeme
|
||||
|
||||
# JWT secret - generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||
JWT_SECRET=changeme
|
||||
|
||||
# URLs
|
||||
DASHBOARD_URL=http://localhost:3000
|
||||
API_URL=http://localhost:3001
|
||||
DASHBOARD_PORT=3000
|
||||
API_PORT=3001
|
||||
+14
-12
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_DB: kooltap
|
||||
POSTGRES_USER: kooltap
|
||||
@@ -32,38 +32,40 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# The version comes from packageManager in package.json
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
# Also generates the Prisma client
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: cd apps/api && npx prisma generate
|
||||
|
||||
- name: Build shared package
|
||||
run: pnpm --filter @kooltap/shared run build
|
||||
|
||||
- name: TypeScript check
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
# Uses the still-empty database as shadow database, so it must run before the migrations
|
||||
- name: Check schema matches migrations
|
||||
run: cd apps/api && npx prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --shadow-database-url "$DATABASE_URL" --exit-code
|
||||
working-directory: apps/api
|
||||
run: SHADOW_DATABASE_URL="$DATABASE_URL" npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
|
||||
|
||||
- name: Apply migrations
|
||||
run: cd apps/api && npx prisma migrate deploy
|
||||
working-directory: apps/api
|
||||
run: npx prisma migrate deploy
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: E2E tests
|
||||
- name: End-to-end tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Terminal protocol JSON Schema
|
||||
run: pnpm --filter @kooltap/shared run schema
|
||||
|
||||
+37
-52
@@ -1,65 +1,50 @@
|
||||
FROM node:20-alpine AS builder
|
||||
# `--target api` or `--target dashboard` picks the image; each builds only what it needs, on one shared install.
|
||||
FROM node:24-alpine AS deps
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
RUN apk add --no-cache openssl
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:3001/api/v1}
|
||||
# Public address of the dashboard, for the sitemap, robots.txt and link previews
|
||||
ARG NEXT_PUBLIC_SITE_URL
|
||||
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL:-https://kooltap.com}
|
||||
|
||||
COPY pnpm-workspace.yaml package.json tsconfig.base.json pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/dashboard/package.json apps/dashboard/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# The API's postinstall generates the Prisma client from the schema
|
||||
COPY apps/api/prisma/schema.prisma apps/api/prisma/
|
||||
COPY apps/api/prisma.config.ts apps/api/
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile
|
||||
COPY packages/shared packages/shared
|
||||
COPY apps/api apps/api
|
||||
COPY apps/dashboard apps/dashboard
|
||||
|
||||
RUN pnpm --filter @kooltap/shared run build
|
||||
RUN cd apps/api && npx prisma generate
|
||||
RUN pnpm --filter @kooltap/api run build
|
||||
|
||||
FROM deps AS api-build
|
||||
COPY apps/api apps/api
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store pnpm --filter @kooltap/api run build \
|
||||
&& pnpm --filter @kooltap/api deploy --prod --legacy /deploy/api
|
||||
|
||||
FROM deps AS dashboard-build
|
||||
# NEXT_PUBLIC_* values are baked into the dashboard bundle at build time
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:3001/api/v1
|
||||
ARG NEXT_PUBLIC_SITE_URL=https://kooltap.com
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL NEXT_TELEMETRY_DISABLED=1
|
||||
COPY apps/dashboard apps/dashboard
|
||||
RUN pnpm --filter @kooltap/dashboard run build
|
||||
|
||||
# ---- Production API image ----
|
||||
FROM node:20-alpine AS api
|
||||
RUN npm install -g pnpm
|
||||
RUN apk add --no-cache openssl
|
||||
# ---- API: migrates the database, then serves
|
||||
FROM node:24-alpine AS api
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/apps/api/dist ./dist
|
||||
COPY --from=builder /app/apps/api/prisma ./prisma
|
||||
COPY --from=builder /app/apps/api/templates ./templates
|
||||
COPY --from=builder /app/apps/api/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
|
||||
RUN pnpm install --prod
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=production IMAGE_DIR=/data/images
|
||||
# The built API with its production dependencies, migrations and email templates
|
||||
COPY --from=api-build /deploy/api ./
|
||||
RUN mkdir -p /data/images && chown node:node /data/images
|
||||
USER node
|
||||
VOLUME /data/images
|
||||
EXPOSE 3001
|
||||
CMD ["sh", "-c", "node prisma/deploy.js && node dist/main.js"]
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
|
||||
|
||||
# ---- Production Dashboard image ----
|
||||
FROM node:20-alpine AS dashboard
|
||||
RUN npm install -g pnpm
|
||||
# ---- Dashboard: Next's standalone server
|
||||
FROM node:24-alpine AS dashboard
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/apps/dashboard/.next ./.next
|
||||
COPY --from=builder /app/apps/dashboard/package.json ./
|
||||
COPY --from=builder /app/apps/dashboard/next.config.js ./
|
||||
COPY --from=builder /app/apps/dashboard/public ./public
|
||||
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/pnpm-workspace.yaml ./
|
||||
COPY --from=builder /app/packages/shared/package.json ./packages/shared/
|
||||
COPY --from=builder /app/packages/shared/dist ./packages/shared/dist/
|
||||
RUN pnpm install --prod
|
||||
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
|
||||
COPY --from=dashboard-build --chown=node:node /app/apps/dashboard/.next/standalone ./
|
||||
COPY --from=dashboard-build --chown=node:node /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
|
||||
COPY --from=dashboard-build --chown=node:node /app/apps/dashboard/public ./apps/dashboard/public
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start"]
|
||||
CMD ["node", "apps/dashboard/server.js"]
|
||||
|
||||
@@ -1,102 +1,45 @@
|
||||
# KoolTap Remote Tab Control
|
||||
# KoolTap
|
||||
|
||||
A production-quality remote tab control system for venues. Allows bar staff to manage and close tabs remotely from a web dashboard, with commands delivered to bar terminals via a sync API.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Monorepo** managed with pnpm
|
||||
- **NestJS** backend API with Prisma ORM on PostgreSQL
|
||||
- **Next.js** dashboard with App Router, TanStack Query, and shadcn/ui
|
||||
- **Shared** TypeScript types package
|
||||
The backoffice for KoolTap, the bar till: bar terminals (a Flutter app) sync their tabs, sales and stock here, and the
|
||||
venue runs tabs, customers, reminders and stock from a web dashboard.
|
||||
|
||||
```
|
||||
apps/
|
||||
api/ # NestJS backend
|
||||
dashboard/ # Next.js frontend
|
||||
api/ NestJS API on PostgreSQL (Prisma)
|
||||
dashboard/ Next.js dashboard
|
||||
packages/
|
||||
shared/ # Shared types & constants
|
||||
shared/ The contract: zod schemas and types for the terminal protocol and the dashboard API
|
||||
scripts/
|
||||
device-sync.ts # Device simulator
|
||||
docs/
|
||||
api.md # API documentation
|
||||
architecture.md
|
||||
architecture-decisions.md
|
||||
local-development.md
|
||||
testing.md
|
||||
production.md
|
||||
security.md
|
||||
terminal-simulator.ts A terminal on the command line
|
||||
docs/ Architecture, terminal protocol, development, production, security, testing
|
||||
```
|
||||
|
||||
## Requirements
|
||||
## Quick start
|
||||
|
||||
- Node.js >= 18
|
||||
- pnpm >= 8
|
||||
|
||||
## Quick Start
|
||||
Needs Node.js 24, pnpm (`corepack enable`) and Docker for the database.
|
||||
|
||||
```bash
|
||||
# 1. Install dependencies
|
||||
pnpm install
|
||||
|
||||
# 2. Generate Prisma client
|
||||
cd apps/api
|
||||
pnpm db:generate
|
||||
|
||||
# 3. Run migrations
|
||||
docker compose up -d # PostgreSQL on localhost:5432
|
||||
cp .env.example apps/api/.env # and NEXT_PUBLIC_API_URL into apps/dashboard/.env.local
|
||||
pnpm install # also generates the Prisma client
|
||||
pnpm db:migrate
|
||||
|
||||
# 4. Seed demo data
|
||||
pnpm db:seed
|
||||
cd ../..
|
||||
|
||||
# 5. Start both API and Dashboard in dev mode
|
||||
pnpm dev
|
||||
pnpm dev # API on :3001, dashboard on :3000
|
||||
```
|
||||
|
||||
API runs on http://localhost:3001
|
||||
Dashboard runs on http://localhost:3000
|
||||
Swagger docs at http://localhost:3001/api/docs
|
||||
The seed prints the demo accounts (one per role) and a terminal token; `pnpm simulate` then plays a terminal against
|
||||
your local API.
|
||||
|
||||
## Demo Credentials
|
||||
|
||||
| Role | Email | Password |
|
||||
|--------|--------------------|------------|
|
||||
| Admin | admin@example.com | Admin123! |
|
||||
| Manager | viewer@example.com | Viewer123! |
|
||||
| Superadmin | superadmin@example.com | Super123! |
|
||||
|
||||
The seed also adds a second venue, KoolTap Festival Bar (festival@example.com / Admin123!). To make an existing
|
||||
user a superadmin elsewhere, run `pnpm --filter @kooltap/api run superadmin <email>` (`--revoke` makes them an admin again).
|
||||
|
||||
## Device Simulator
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
# Run a full demo (register, sync, ack)
|
||||
cd scripts
|
||||
npx ts-node device-sync.ts demo <venueId> <registrationCode>
|
||||
|
||||
# Or step by step:
|
||||
npx ts-node device-sync.ts register <venueId> "Bar Terminal 1" ABC123
|
||||
npx ts-node device-sync.ts sync <deviceId> <token>
|
||||
npx ts-node device-sync.ts ack <deviceId> <token> <commandId> completed
|
||||
pnpm test # unit tests: the tab, stock and product rules, parsing, email templates, dashboard helpers
|
||||
pnpm test:e2e # the API against a real database (DATABASE_URL), terminal and dashboard together
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
## More
|
||||
|
||||
```bash
|
||||
# Unit tests
|
||||
pnpm test
|
||||
|
||||
# E2E tests
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
## Production Notes
|
||||
|
||||
See [docs/production.md](docs/production.md) for production deployment.
|
||||
- [Architecture](docs/architecture.md) and the [decisions behind it](docs/architecture-decisions.md)
|
||||
- [Terminal protocol](docs/terminal-protocol.md), for the Flutter app
|
||||
- [Local development](docs/local-development.md), [testing](docs/testing.md)
|
||||
- [Production on Coolify](docs/production.md), [security](docs/security.md)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
src/generated/
|
||||
data/
|
||||
@@ -3,6 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
+47
-52
@@ -1,69 +1,68 @@
|
||||
{
|
||||
"name": "@kooltap/api",
|
||||
"version": "1.0.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"dist",
|
||||
"prisma",
|
||||
"prisma.config.ts",
|
||||
"templates"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main",
|
||||
"start:dev": "nest start --watch",
|
||||
"lint": "eslint \"{src,test}/**/*.ts\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json --runInBand",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:seed": "ts-node prisma/seed.ts",
|
||||
"db:reset": "prisma migrate reset --force && ts-node prisma/seed.ts",
|
||||
"superadmin": "ts-node prisma/superadmin.ts",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:reset": "prisma migrate reset --force && tsx prisma/seed.ts",
|
||||
"setup": "tsx src/cli/setup.ts",
|
||||
"superadmin": "tsx src/cli/superadmin.ts",
|
||||
"postinstall": "prisma generate",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kooltap/shared": "workspace:*",
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/jwt": "^10.2.0",
|
||||
"@nestjs/passport": "^10.0.3",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"@nestjs/platform-socket.io": "^10.4.22",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.0",
|
||||
"@nestjs/websockets": "^10.4.22",
|
||||
"@prisma/client": "^5.10.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"helmet": "^7.1.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"prisma": "^5.10.0",
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "0.33.5",
|
||||
"@nestjs/common": "^11.2.8",
|
||||
"@nestjs/core": "^11.2.8",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^11.2.8",
|
||||
"@nestjs/platform-socket.io": "^11.2.8",
|
||||
"@nestjs/throttler": "^6.7.1",
|
||||
"@nestjs/websockets": "^11.2.8",
|
||||
"@prisma/adapter-pg": "7.10.1",
|
||||
"@prisma/client": "7.10.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dotenv": "^17.2.3",
|
||||
"helmet": "^8.1.0",
|
||||
"prisma": "7.10.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"sharp": "^0.34.5",
|
||||
"socket.io": "^4.8.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.0",
|
||||
"uuid": "^9.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.0",
|
||||
"@nestjs/testing": "^10.3.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@types/multer": "1.4.12",
|
||||
"@types/node": "^20.11.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@nestjs/cli": "^11.0.24",
|
||||
"@nestjs/schematics": "^11.1.0",
|
||||
"@nestjs/testing": "^11.2.8",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"jest": "^29.7.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.1.2",
|
||||
"ts-loader": "^9.5.1"
|
||||
"jest": "^30.2.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.5",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
@@ -74,12 +73,8 @@
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node",
|
||||
"moduleNameMapper": {
|
||||
"^@kooltap/shared$": "<rootDir>/../../../packages/shared/src"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Applies database migrations on container start.
|
||||
*
|
||||
* Databases created before migrations were used (via `prisma db push`) have no
|
||||
* `_prisma_migrations` table. Those are brought up to date with a non-destructive
|
||||
* `db push` (which refuses to drop data) and then baselined, so every later deploy
|
||||
* can use plain `prisma migrate deploy`.
|
||||
*/
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
// Migrations that were first created with dates in the future, renamed to when they were written.
|
||||
// The SQL is unchanged, so databases that already applied them only need the recorded name updated.
|
||||
const RENAMED_MIGRATIONS = {
|
||||
'20261020120000_tab_events': '20261019212336_tab_events',
|
||||
'20261021120000_stock': '20261020124522_stock',
|
||||
'20261022120000_server_tabs_and_stock': '20261020141836_server_tabs_and_stock',
|
||||
'20261023120000_products_on_dashboard': '20261020215540_products_on_dashboard',
|
||||
'20261024120000_product_images_and_events': '20261021104519_product_images_and_events',
|
||||
'20261025120000_sold_unit_view': '20261021225808_sold_unit_view',
|
||||
'20261026120000_drop_tab_snapshots': '20261021233219_drop_tab_snapshots',
|
||||
};
|
||||
|
||||
function run(command) {
|
||||
console.log(`[deploy] ${command}`);
|
||||
execSync(command, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
let hasMigrationsTable;
|
||||
let hasTables;
|
||||
try {
|
||||
const [{ migrations, tables }] = await prisma.$queryRaw`
|
||||
SELECT
|
||||
to_regclass('public._prisma_migrations') IS NOT NULL AS migrations,
|
||||
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
|
||||
hasMigrationsTable = migrations;
|
||||
hasTables = tables;
|
||||
|
||||
if (hasMigrationsTable) {
|
||||
for (const [oldName, newName] of Object.entries(RENAMED_MIGRATIONS)) {
|
||||
const renamed = await prisma.$executeRaw`
|
||||
UPDATE "_prisma_migrations" SET "migration_name" = ${newName} WHERE "migration_name" = ${oldName}`;
|
||||
if (renamed > 0) console.log(`[deploy] Renamed migration ${oldName} -> ${newName}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
if (!hasMigrationsTable && hasTables) {
|
||||
console.log('[deploy] Existing database without migration history, baselining');
|
||||
run('npx prisma db push --skip-generate');
|
||||
|
||||
const migrationsDir = path.join(__dirname, 'migrations');
|
||||
const migrations = fs
|
||||
.readdirSync(migrationsDir, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const name of migrations) {
|
||||
run(`npx prisma migrate resolve --applied ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
run('npx prisma migrate deploy');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[deploy] Migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,189 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Venue" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Device" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenPrefix" TEXT NOT NULL,
|
||||
"tokenExpiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ClientUser" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'viewer',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegistrationCode" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"usedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OpenTabSnapshot" (
|
||||
"id" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"tabId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"version" INTEGER NOT NULL,
|
||||
"totalInCents" INTEGER NOT NULL,
|
||||
"itemCount" INTEGER NOT NULL,
|
||||
"reportedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "OpenTabSnapshot_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TabOwnership" (
|
||||
"tabId" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"lastReportedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "TabOwnership_pkey" PRIMARY KEY ("venueId","tabId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RemoteCommand" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"targetTabId" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL DEFAULT 'close_tab',
|
||||
"expectedVersion" INTEGER NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"requestedBy" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"result" TEXT,
|
||||
"acknowledgedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "RemoteCommand_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"actorType" TEXT NOT NULL,
|
||||
"actorId" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Device_venueId_key" ON "Device"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ClientUser" ADD CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "OpenTabSnapshot" ADD CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PlannedTab" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "PlannedTab_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PlannedTab_venueId_idx" ON "PlannedTab"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PlannedTab_venueId_customerName_key" ON "PlannedTab"("venueId", "customerName");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PlannedTab" ADD CONSTRAINT "PlannedTab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,25 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlannedTab" ADD COLUMN "email" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TabReminder" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"tabId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"amountInCents" INTEGER NOT NULL,
|
||||
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" TEXT NOT NULL DEFAULT 'sent',
|
||||
|
||||
CONSTRAINT "TabReminder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabReminder_venueId_sentAt_idx" ON "TabReminder"("venueId", "sentAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabReminder_venueId_tabId_idx" ON "TabReminder"("venueId", "tabId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabReminder" ADD CONSTRAINT "TabReminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "PlannedTab" ADD COLUMN "deliveredAt" TIMESTAMP(3);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB;
|
||||
@@ -1,27 +0,0 @@
|
||||
-- PlannedTab: match customers case-insensitively
|
||||
ALTER TABLE "PlannedTab" ADD COLUMN "customerNameKey" TEXT;
|
||||
|
||||
UPDATE "PlannedTab" SET "customerNameKey" = lower(trim(regexp_replace("customerName", '\s+', ' ', 'g')));
|
||||
|
||||
-- Names that only differ in case or spacing keep the oldest row as the match; later ones get a unique key
|
||||
UPDATE "PlannedTab" p SET "customerNameKey" = p."customerNameKey" || '#' || p."id"
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM "PlannedTab" o
|
||||
WHERE o."venueId" = p."venueId"
|
||||
AND o."customerNameKey" = p."customerNameKey"
|
||||
AND (o."createdAt", o."id") < (p."createdAt", p."id")
|
||||
);
|
||||
|
||||
ALTER TABLE "PlannedTab" ALTER COLUMN "customerNameKey" SET NOT NULL;
|
||||
|
||||
DROP INDEX "PlannedTab_venueId_customerName_key";
|
||||
|
||||
CREATE UNIQUE INDEX "PlannedTab_venueId_customerNameKey_key" ON "PlannedTab"("venueId", "customerNameKey");
|
||||
|
||||
-- RemoteCommand: keep who the tab belonged to, the tab itself disappears once closed
|
||||
ALTER TABLE "RemoteCommand" ADD COLUMN "customerName" TEXT,
|
||||
ADD COLUMN "amountInCents" INTEGER;
|
||||
|
||||
UPDATE "RemoteCommand" c SET "customerName" = s."customerName", "amountInCents" = s."totalInCents"
|
||||
FROM "OpenTabSnapshot" s
|
||||
WHERE s."deviceId" = c."deviceId" AND s."tabId" = c."targetTabId";
|
||||
@@ -1,33 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TabEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"tabId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"productName" TEXT,
|
||||
"unitPriceInCents" INTEGER,
|
||||
"quantityDelta" INTEGER,
|
||||
"amountInCents" INTEGER,
|
||||
"paymentMethod" TEXT,
|
||||
"commandId" TEXT,
|
||||
"items" JSONB,
|
||||
"occurredAt" TIMESTAMP(3) NOT NULL,
|
||||
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabEvent_venueId_type_occurredAt_idx" ON "TabEvent"("venueId", "type", "occurredAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"venueId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"priceInCents" INTEGER NOT NULL,
|
||||
"stock" INTEGER,
|
||||
"lowStockThreshold" INTEGER,
|
||||
"reportedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("venueId","productId")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StockAdjustment" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"productName" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"note" TEXT,
|
||||
"requestedBy" TEXT NOT NULL,
|
||||
"requestedById" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"stockAfter" INTEGER,
|
||||
"result" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"processedAt" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockAdjustment_venueId_status_createdAt_idx" ON "StockAdjustment"("venueId", "status", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockAdjustment_venueId_productId_createdAt_idx" ON "StockAdjustment"("venueId", "productId", "createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -1,75 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "RemoteCommand" DROP CONSTRAINT "RemoteCommand_deviceId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Device_venueId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Venue" ADD COLUMN "tabsSeededAt" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "RemoteCommand" ALTER COLUMN "deviceId" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TabEvent" ADD COLUMN "lineId" TEXT,
|
||||
ADD COLUMN "productId" TEXT,
|
||||
ADD COLUMN "stockSetTo" INTEGER,
|
||||
ALTER COLUMN "tabId" DROP NOT NULL,
|
||||
ALTER COLUMN "customerName" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "countedAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tab" (
|
||||
"venueId" TEXT NOT NULL,
|
||||
"id" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"openedAt" TIMESTAMP(3) NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 0,
|
||||
"lines" JSONB NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Tab_pkey" PRIMARY KEY ("venueId","id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Settlement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sequence" SERIAL NOT NULL,
|
||||
"venueId" TEXT NOT NULL,
|
||||
"tabId" TEXT NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"settledAt" TIMESTAMP(3) NOT NULL,
|
||||
"paymentMethod" TEXT NOT NULL,
|
||||
"amountInCents" INTEGER NOT NULL,
|
||||
"lines" JSONB NOT NULL,
|
||||
"deviceId" TEXT,
|
||||
"requestedBy" TEXT,
|
||||
"commandId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3);
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ClientUser" ALTER COLUMN "role" SET DEFAULT 'manager';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "TabEvent" ADD COLUMN "productData" JSONB;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "fieldUpdatedAt" JSONB,
|
||||
ADD COLUMN "imageId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contentType" TEXT NOT NULL,
|
||||
"width" INTEGER NOT NULL,
|
||||
"height" INTEGER NOT NULL,
|
||||
"bytes" BYTEA NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- "viewer" was the name for the people running the venue: they are managers now
|
||||
UPDATE "ClientUser" SET "role" = 'manager' WHERE "role" = 'viewer';
|
||||
@@ -1,42 +0,0 @@
|
||||
-- One row per drink sold: every unit that was not taken back, whether it is still on an open tab or already
|
||||
-- settled. Tab.lines and Settlement.lines hold the same unit shape ({ id, addedAt, removedAt }); a unit is in
|
||||
-- exactly one of them, so nothing is counted twice. Change this view whenever that JSON shape changes.
|
||||
CREATE VIEW "SoldUnit" AS
|
||||
SELECT
|
||||
t."venueId",
|
||||
u->>'id' AS "unitId",
|
||||
t."id" AS "tabId",
|
||||
t."customerName",
|
||||
l->>'productId' AS "productId",
|
||||
l->>'productName' AS "productName",
|
||||
(l->>'unitPriceInCents')::int AS "unitPriceInCents",
|
||||
(u->>'addedAt')::timestamptz AS "addedAt",
|
||||
-- A night runs until 06:00
|
||||
date_trunc('day', (u->>'addedAt')::timestamptz - interval '6 hours', 'Europe/Brussels') AS "night",
|
||||
NULL::text AS "settlementId",
|
||||
NULL::timestamptz AS "settledAt",
|
||||
NULL::text AS "paymentMethod"
|
||||
FROM "Tab" t,
|
||||
jsonb_array_elements(t."lines") l,
|
||||
jsonb_array_elements(l->'units') u
|
||||
WHERE u->>'removedAt' IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
s."venueId",
|
||||
u->>'id',
|
||||
s."tabId",
|
||||
s."customerName",
|
||||
l->>'productId',
|
||||
l->>'productName',
|
||||
(l->>'unitPriceInCents')::int,
|
||||
(u->>'addedAt')::timestamptz,
|
||||
date_trunc('day', (u->>'addedAt')::timestamptz - interval '6 hours', 'Europe/Brussels'),
|
||||
s."id",
|
||||
s."settledAt" AT TIME ZONE 'UTC',
|
||||
s."paymentMethod"
|
||||
FROM "Settlement" s,
|
||||
jsonb_array_elements(s."lines") l,
|
||||
jsonb_array_elements(l->'units') u
|
||||
WHERE u->>'removedAt' IS NULL;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- The terminals used to own the tabs and report them as snapshots; the server holds them in "Tab" now
|
||||
DROP TABLE "OpenTabSnapshot";
|
||||
|
||||
DROP TABLE "TabOwnership";
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3);
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Venue" ADD COLUMN "cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
@@ -0,0 +1,387 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('admin', 'manager', 'superadmin');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PaymentMethod" AS ENUM ('cash', 'payconiq', 'other');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StockMovementType" AS ENUM ('add', 'count');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ActorType" AS ENUM ('user', 'device', 'system');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminalEventType" AS ENUM ('tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set', 'product_created', 'product_updated', 'product_archived', 'product_restored');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Venue" (
|
||||
"id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[],
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID,
|
||||
"email" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'manager',
|
||||
"sessionsRevokedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RefreshToken" (
|
||||
"id" UUID NOT NULL,
|
||||
"userId" UUID NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"revokedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Device" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenPrefix" TEXT NOT NULL,
|
||||
"tokenExpiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"lastSeenAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"revokedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RegistrationCode" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"usedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Customer" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"nameKey" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"position" INTEGER NOT NULL DEFAULT 0,
|
||||
"tabOpenedAt" TIMESTAMPTZ(3),
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tab" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"openedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"version" INTEGER NOT NULL DEFAULT 0,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Tab_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Settlement" (
|
||||
"id" UUID NOT NULL,
|
||||
"sequence" SERIAL NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"tabId" UUID NOT NULL,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"settledAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"paymentMethod" "PaymentMethod" NOT NULL,
|
||||
"amountInCents" INTEGER NOT NULL,
|
||||
"deviceId" UUID,
|
||||
"settledByUserId" UUID,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TabUnit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"tabId" UUID,
|
||||
"settlementId" UUID,
|
||||
"lineId" TEXT NOT NULL,
|
||||
"productId" UUID,
|
||||
"productName" TEXT NOT NULL,
|
||||
"unitPriceInCents" INTEGER NOT NULL,
|
||||
"orderId" TEXT,
|
||||
"addedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"removedAt" TIMESTAMPTZ(3),
|
||||
|
||||
CONSTRAINT "TabUnit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Product" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"category" TEXT,
|
||||
"priceInCents" INTEGER NOT NULL,
|
||||
"buyPriceInCents" INTEGER,
|
||||
"stock" INTEGER,
|
||||
"countedAt" TIMESTAMPTZ(3),
|
||||
"lowStockThreshold" INTEGER,
|
||||
"desiredStock" INTEGER,
|
||||
"imageId" TEXT,
|
||||
"archivedAt" TIMESTAMPTZ(3),
|
||||
"fieldUpdatedAt" JSONB NOT NULL DEFAULT '{}',
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ProductImage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"contentType" TEXT NOT NULL,
|
||||
"width" INTEGER NOT NULL,
|
||||
"height" INTEGER NOT NULL,
|
||||
"bytes" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "StockMovement" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"productId" UUID NOT NULL,
|
||||
"type" "StockMovementType" NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"stockAfter" INTEGER NOT NULL,
|
||||
"note" TEXT,
|
||||
"userId" UUID,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TabEvent" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"deviceId" UUID NOT NULL,
|
||||
"type" "TerminalEventType" NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"occurredAt" TIMESTAMPTZ(3) NOT NULL,
|
||||
"receivedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Reminder" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID NOT NULL,
|
||||
"customerId" UUID,
|
||||
"customerName" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"tabId" UUID NOT NULL,
|
||||
"amountInCents" INTEGER NOT NULL,
|
||||
"sentAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AuditLog" (
|
||||
"id" UUID NOT NULL,
|
||||
"venueId" UUID,
|
||||
"actorType" "ActorType" NOT NULL,
|
||||
"actorId" UUID,
|
||||
"action" TEXT NOT NULL,
|
||||
"details" JSONB,
|
||||
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "User_venueId_idx" ON "User"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Customer_venueId_nameKey_key" ON "Customer"("venueId", "nameKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Tab_venueId_idx" ON "Tab"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabUnit_tabId_idx" ON "TabUnit"("tabId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabUnit_settlementId_idx" ON "TabUnit"("settlementId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabUnit_venueId_addedAt_idx" ON "TabUnit"("venueId", "addedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabUnit_productId_idx" ON "TabUnit"("productId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Product_venueId_idx" ON "Product"("venueId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockMovement_venueId_createdAt_idx" ON "StockMovement"("venueId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "StockMovement_productId_createdAt_idx" ON "StockMovement"("productId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TabEvent_receivedAt_idx" ON "TabEvent"("receivedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Reminder_venueId_sentAt_idx" ON "Reminder"("venueId", "sentAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Reminder_customerId_idx" ON "Reminder"("customerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_settledByUserId_fkey" FOREIGN KEY ("settledByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_tabId_fkey" FOREIGN KEY ("tabId") REFERENCES "Tab"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_settlementId_fkey" FOREIGN KEY ("settlementId") REFERENCES "Settlement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
|
||||
-- A drink is on an open tab or in a settlement, never both or neither
|
||||
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_tab_or_settlement" CHECK (("tabId" IS NULL) <> ("settlementId" IS NULL));
|
||||
|
||||
-- A superadmin is above the venues; everyone else belongs to one
|
||||
ALTER TABLE "User" ADD CONSTRAINT "User_superadmin_has_no_venue" CHECK (("role" = 'superadmin') = ("venueId" IS NULL));
|
||||
|
||||
-- Amounts and counts that can't be negative (stock can: a sale is never blocked)
|
||||
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_amount_positive" CHECK ("amountInCents" >= 0);
|
||||
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_price_positive" CHECK ("unitPriceInCents" >= 0);
|
||||
ALTER TABLE "Product" ADD CONSTRAINT "Product_prices_positive" CHECK ("priceInCents" >= 0 AND ("buyPriceInCents" IS NULL OR "buyPriceInCents" >= 0));
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Every venue gets a "Free Drinks" customer; the next sync opens its tab (or keeps one that is already open)
|
||||
INSERT INTO "PlannedTab" ("id", "venueId", "customerName", "customerNameKey", "position", "createdAt")
|
||||
SELECT gen_random_uuid()::text, "id", 'Free Drinks', 'free drinks', 0, CURRENT_TIMESTAMP
|
||||
FROM "Venue"
|
||||
ON CONFLICT ("venueId", "customerNameKey") DO NOTHING;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TabEvent" ADD COLUMN "orderId" TEXT;
|
||||
@@ -1,3 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
+300
-274
@@ -1,331 +1,357 @@
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
moduleFormat = "cjs"
|
||||
// Compiled to CommonJS by tsc: imports without an extension. Set, not inferred: the client is generated during
|
||||
// install, before the tsconfig it would be inferred from is there
|
||||
importFileExtension = ""
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
enum Role {
|
||||
admin
|
||||
manager
|
||||
superadmin
|
||||
}
|
||||
|
||||
enum PaymentMethod {
|
||||
cash
|
||||
payconiq
|
||||
other
|
||||
}
|
||||
|
||||
enum StockMovementType {
|
||||
add
|
||||
count
|
||||
}
|
||||
|
||||
enum ActorType {
|
||||
user
|
||||
device
|
||||
system
|
||||
}
|
||||
|
||||
enum TerminalEventType {
|
||||
tab_opened
|
||||
tab_renamed
|
||||
tab_deleted
|
||||
line_added
|
||||
line_removed
|
||||
settled
|
||||
stock_set
|
||||
product_created
|
||||
product_updated
|
||||
product_archived
|
||||
product_restored
|
||||
}
|
||||
|
||||
model Venue {
|
||||
id String @id @default(uuid())
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// Who gets an email for every tab paid in cash, on a terminal or on the dashboard
|
||||
cashPaymentEmails String[] @default([])
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
users User[]
|
||||
devices Device[]
|
||||
clientUsers ClientUser[]
|
||||
remoteCommands RemoteCommand[]
|
||||
auditLogs AuditLog[]
|
||||
regCodes RegistrationCode[]
|
||||
plannedTabs PlannedTab[]
|
||||
tabReminders TabReminder[]
|
||||
tabEvents TabEvent[]
|
||||
products Product[]
|
||||
stockAdjustments StockAdjustment[]
|
||||
registrationCodes RegistrationCode[]
|
||||
customers Customer[]
|
||||
tabs Tab[]
|
||||
settlements Settlement[]
|
||||
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
|
||||
// terminal's own open tabs, once
|
||||
tabsSeededAt DateTime?
|
||||
// Get an email for every tab paid in cash, on a terminal or on the dashboard
|
||||
cashPaymentEmails String[] @default([])
|
||||
units TabUnit[]
|
||||
products Product[]
|
||||
stockMovements StockMovement[]
|
||||
tabEvents TabEvent[]
|
||||
reminders Reminder[]
|
||||
auditLog AuditLog[]
|
||||
}
|
||||
|
||||
// A venue's admin or manager, or a superadmin, who runs KoolTap itself and belongs to no venue: they view a venue to
|
||||
// act in it as its admin
|
||||
model User {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
// Null exactly for a superadmin
|
||||
venueId String? @db.Uuid
|
||||
// Stored lowercased
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
role Role @default(manager)
|
||||
// Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
|
||||
sessionsRevokedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
refreshTokens RefreshToken[]
|
||||
settlements Settlement[]
|
||||
stockMovements StockMovement[]
|
||||
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @db.Uuid
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
revokedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
// A bar terminal, paired with a registration code
|
||||
model Device {
|
||||
id String @id @default(uuid())
|
||||
// A venue can have several terminals
|
||||
venueId String
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
name String
|
||||
tokenHash String @unique
|
||||
tokenPrefix String
|
||||
tokenExpiresAt DateTime
|
||||
lastSeenAt DateTime @default(now())
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
tokenExpiresAt DateTime @db.Timestamptz(3)
|
||||
lastSeenAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
revokedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
remoteCommands RemoteCommand[]
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
tabEvents TabEvent[]
|
||||
settlements Settlement[]
|
||||
|
||||
@@index([venueId, revokedAt])
|
||||
}
|
||||
|
||||
model ClientUser {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
email String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
// admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) |
|
||||
// superadmin (runs KoolTap itself: every venue, and the admin page)
|
||||
role String @default("manager")
|
||||
createdAt DateTime @default(now())
|
||||
// Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
|
||||
sessionsRevokedAt DateTime?
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model RegistrationCode {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
code String @unique
|
||||
expiresAt DateTime
|
||||
usedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime @db.Timestamptz(3)
|
||||
usedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles
|
||||
// the tab itself and records the command as completed right away, for the history.
|
||||
model RemoteCommand {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
// Null for a command the server carried out itself
|
||||
deviceId String?
|
||||
targetTabId String
|
||||
customerName String?
|
||||
amountInCents Int?
|
||||
type String @default("close_tab")
|
||||
expectedVersion Int
|
||||
paymentMethod String
|
||||
requestedBy String
|
||||
status String @default("pending")
|
||||
result String?
|
||||
acknowledgedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device? @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([deviceId, status, createdAt])
|
||||
@@index([venueId, status, createdAt])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
actorType String
|
||||
actorId String
|
||||
action String
|
||||
details String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, createdAt])
|
||||
}
|
||||
|
||||
model RefreshToken {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
tokenHash String @unique
|
||||
expiresAt DateTime
|
||||
revokedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model PlannedTab {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
customerName String
|
||||
// A regular of the venue. Tabs are matched to customers by name: the next sync opens a tab for a customer that has
|
||||
// none, and the customer's email is used for reminders
|
||||
model Customer {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
name String
|
||||
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
|
||||
customerNameKey String
|
||||
nameKey String
|
||||
email String?
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
|
||||
deliveredAt DateTime?
|
||||
// Null until the next sync opens their tab
|
||||
tabOpenedAt DateTime? @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
reminders Reminder[]
|
||||
|
||||
@@unique([venueId, nameKey])
|
||||
}
|
||||
|
||||
// An open tab: the source of truth the terminals replace their own tabs with on every sync. Tabs are never closed,
|
||||
// only emptied by settlements; deleting one drops the drinks still on it
|
||||
model Tab {
|
||||
// Generated by the terminal that opened it, or by the server for a customer's tab
|
||||
id String @id @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
customerName String
|
||||
openedAt DateTime @db.Timestamptz(3)
|
||||
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
|
||||
version Int @default(0)
|
||||
updatedAt DateTime @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
units TabUnit[]
|
||||
|
||||
@@unique([venueId, customerNameKey])
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model TabReminder {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
tabId String
|
||||
customerName String
|
||||
email String
|
||||
amountInCents Int
|
||||
sentAt DateTime @default(now())
|
||||
status String @default("sent")
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, sentAt])
|
||||
@@index([venueId, tabId])
|
||||
}
|
||||
|
||||
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
|
||||
// stored, and applied to the tab state and stock, only once.
|
||||
model TabEvent {
|
||||
id String @id
|
||||
venueId String
|
||||
deviceId String
|
||||
// Null for stock_set, which is not about a tab
|
||||
tabId String?
|
||||
customerName String?
|
||||
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
|
||||
type String
|
||||
lineId String?
|
||||
// line_added / line_removed: the order on the terminal the drinks belong to
|
||||
orderId String?
|
||||
productId String?
|
||||
productName String?
|
||||
unitPriceInCents Int?
|
||||
// Signed: positive for line_added, negative for line_removed
|
||||
quantityDelta Int?
|
||||
// settled only
|
||||
amountInCents Int?
|
||||
paymentMethod String?
|
||||
commandId String?
|
||||
items Json?
|
||||
// stock_set: the counted stock, null to stop tracking
|
||||
stockSetTo Int?
|
||||
// product_created / product_updated: the product's fields as sent by the terminal
|
||||
productData Json?
|
||||
occurredAt DateTime
|
||||
receivedAt DateTime @default(now())
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([venueId, occurredAt])
|
||||
@@index([venueId, type, occurredAt])
|
||||
}
|
||||
|
||||
// The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync;
|
||||
// the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock
|
||||
// counts and the dashboard's StockAdjustments.
|
||||
model Product {
|
||||
venueId String
|
||||
// Generated by the server, or by the terminal that reported it before the dashboard managed the catalog
|
||||
productId String
|
||||
name String
|
||||
category String?
|
||||
// The sell price: what the terminals charge
|
||||
priceInCents Int
|
||||
// What the bar pays for one; null when not entered. Kept on the dashboard only, the terminals never get it
|
||||
buyPriceInCents Int?
|
||||
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
|
||||
stock Int?
|
||||
// When the stock was last counted; line events that happened before it are already in the count
|
||||
countedAt DateTime?
|
||||
// Warn when the stock drops to this level
|
||||
lowStockThreshold Int?
|
||||
// How many the bar wants in stock; the dashboard shows what to order to get there. Not sent to the terminals
|
||||
desiredStock Int?
|
||||
// When it was created or last changed
|
||||
reportedAt DateTime
|
||||
// Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it
|
||||
archivedAt DateTime?
|
||||
imageId String?
|
||||
// When each field was last changed ({ name: ISO date, ... }): a change from the dashboard or an (offline)
|
||||
// terminal only wins over a later one per field, so edits of different fields never overwrite each other
|
||||
fieldUpdatedAt Json?
|
||||
|
||||
image ProductImage? @relation(fields: [imageId], references: [id])
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@id([venueId, productId])
|
||||
}
|
||||
|
||||
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
|
||||
// by a terminal, which reported the outcome.
|
||||
model StockAdjustment {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
productId String
|
||||
productName String
|
||||
// add (signed: a delivery, or breakage when negative) | set (a stock count)
|
||||
type String
|
||||
quantity Int
|
||||
note String?
|
||||
requestedBy String
|
||||
requestedById String
|
||||
// pending | applied | rejected | cancelled
|
||||
status String @default("pending")
|
||||
// The stock right after applying it
|
||||
stockAfter Int?
|
||||
result String?
|
||||
createdAt DateTime @default(now())
|
||||
processedAt DateTime?
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, status, createdAt])
|
||||
@@index([venueId, productId, createdAt])
|
||||
}
|
||||
|
||||
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
|
||||
// never closed, only emptied by a settlement; tab_deleted removes one.
|
||||
model Tab {
|
||||
venueId String
|
||||
// Generated by the terminal that opened it
|
||||
id String
|
||||
customerName String
|
||||
openedAt DateTime
|
||||
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
|
||||
version Int @default(0)
|
||||
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
|
||||
lines Json
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@id([venueId, id])
|
||||
}
|
||||
|
||||
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
|
||||
// after their cursor, which is the sequence number.
|
||||
// A paid tab, settled on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
|
||||
// after their cursor, which is the sequence number
|
||||
model Settlement {
|
||||
id String @id
|
||||
id String @id @db.Uuid
|
||||
sequence Int @unique @default(autoincrement())
|
||||
venueId String
|
||||
tabId String
|
||||
venueId String @db.Uuid
|
||||
// The tab may be deleted since
|
||||
tabId String @db.Uuid
|
||||
customerName String
|
||||
settledAt DateTime
|
||||
// cash | payconiq | payment_pending | other
|
||||
paymentMethod String
|
||||
settledAt DateTime @db.Timestamptz(3)
|
||||
paymentMethod PaymentMethod
|
||||
// What was charged; the units are what the server had on the tab
|
||||
amountInCents Int
|
||||
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
|
||||
lines Json
|
||||
// Who settled it: a terminal, or a dashboard user
|
||||
deviceId String?
|
||||
requestedBy String?
|
||||
commandId String?
|
||||
createdAt DateTime @default(now())
|
||||
deviceId String? @db.Uuid
|
||||
settledByUserId String? @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
device Device? @relation(fields: [deviceId], references: [id])
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
|
||||
settledBy User? @relation(fields: [settledByUserId], references: [id], onDelete: SetNull)
|
||||
units TabUnit[]
|
||||
|
||||
@@index([venueId, sequence])
|
||||
@@index([venueId, settledAt])
|
||||
}
|
||||
|
||||
// A product picture, resized and cropped on upload. The id is the SHA-256 of the stored bytes, so the picture at
|
||||
// an address never changes and can be cached forever, and the same picture is stored once.
|
||||
// One drink ever rung up. It is on an open tab until a settlement takes it; a drink taken off again keeps its
|
||||
// removedAt and moves with the settlement too. Lines are the units sharing a lineId
|
||||
model TabUnit {
|
||||
// `<line_added event id>:<index>`, derived the same way on the terminals
|
||||
id String @id
|
||||
venueId String @db.Uuid
|
||||
// Exactly one of tabId and settlementId is set
|
||||
tabId String? @db.Uuid
|
||||
settlementId String? @db.Uuid
|
||||
lineId String
|
||||
productId String? @db.Uuid
|
||||
// As rung up: a later change to the product doesn't change what was sold
|
||||
productName String
|
||||
unitPriceInCents Int
|
||||
// The order on the terminal it was rung up in; null only for drinks settled on a terminal the server didn't have
|
||||
orderId String?
|
||||
addedAt DateTime @db.Timestamptz(3)
|
||||
removedAt DateTime? @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
tab Tab? @relation(fields: [tabId], references: [id], onDelete: Cascade)
|
||||
settlement Settlement? @relation(fields: [settlementId], references: [id], onDelete: Cascade)
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([tabId])
|
||||
@@index([settlementId])
|
||||
@@index([venueId, addedAt])
|
||||
@@index([productId])
|
||||
}
|
||||
|
||||
// The venue's catalog and stock. The dashboard and the terminals both change products; per field the latest change
|
||||
// wins. The stock follows the drinks rung up, the terminals' counts and the dashboard's stock movements
|
||||
model Product {
|
||||
// Generated by the server, or by the terminal that created it offline
|
||||
id String @id @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
name String
|
||||
category String?
|
||||
// The sell price: what the terminals charge
|
||||
priceInCents Int
|
||||
// What the bar pays for one. Dashboard only, the terminals never get it
|
||||
buyPriceInCents Int?
|
||||
// Null when stock is not tracked. Can be negative: a sale is never blocked
|
||||
stock Int?
|
||||
// When the stock was last counted; drinks rung up before it are already in the count
|
||||
countedAt DateTime? @db.Timestamptz(3)
|
||||
// Warn when the stock drops to this level
|
||||
lowStockThreshold Int?
|
||||
// How many the bar wants in stock. Dashboard only
|
||||
desiredStock Int?
|
||||
imageId String?
|
||||
// Deleted: hidden on the terminals, kept because tabs and history refer to it
|
||||
archivedAt DateTime? @db.Timestamptz(3)
|
||||
// When each field was last changed ({ name: ISO date, ... }), so edits of different fields never overwrite each other
|
||||
fieldUpdatedAt Json @default("{}")
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
// The last change to what the terminals get (not the stock): part of the catalog version
|
||||
updatedAt DateTime @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
|
||||
units TabUnit[]
|
||||
stockMovements StockMovement[]
|
||||
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
// A product picture, resized and cropped on upload and stored on disk as <id>.webp. The id is the SHA-256 of the
|
||||
// bytes, so a picture's address never changes and the same picture is stored once
|
||||
model ProductImage {
|
||||
id String @id
|
||||
contentType String
|
||||
width Int
|
||||
height Int
|
||||
bytes Bytes
|
||||
createdAt DateTime @default(now())
|
||||
bytes Int
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
products Product[]
|
||||
}
|
||||
|
||||
// A stock change made on the dashboard: a delivery, breakage or count
|
||||
model StockMovement {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
productId String @db.Uuid
|
||||
type StockMovementType
|
||||
quantity Int
|
||||
stockAfter Int
|
||||
note String?
|
||||
userId String? @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([venueId, createdAt])
|
||||
@@index([productId, createdAt])
|
||||
}
|
||||
|
||||
// What a terminal reported, as it sent it. The id is generated by the terminal, so an event that is sent again is
|
||||
// stored, and applied, only once
|
||||
model TabEvent {
|
||||
id String @id @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
deviceId String @db.Uuid
|
||||
type TerminalEventType
|
||||
// The event as sent, after validation
|
||||
payload Json
|
||||
occurredAt DateTime @db.Timestamptz(3)
|
||||
receivedAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([venueId, occurredAt])
|
||||
@@index([receivedAt])
|
||||
}
|
||||
|
||||
model Reminder {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
venueId String @db.Uuid
|
||||
// Null when the customer was removed since
|
||||
customerId String? @db.Uuid
|
||||
customerName String
|
||||
email String
|
||||
tabId String @db.Uuid
|
||||
amountInCents Int
|
||||
sentAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([venueId, sentAt])
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
// Null for actions that are not about one venue (purging, signing everyone out)
|
||||
venueId String? @db.Uuid
|
||||
actorType ActorType
|
||||
// A user or device id; null for the system
|
||||
actorId String? @db.Uuid
|
||||
action String
|
||||
details Json?
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||
|
||||
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([venueId, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
+123
-224
@@ -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,
|
||||
id: tab.tabId,
|
||||
customerName: tab.customerName,
|
||||
openedAt: tab.reportedAt,
|
||||
version: tab.version,
|
||||
lines: [{
|
||||
id: lineId,
|
||||
productId: null,
|
||||
productName: 'Pils',
|
||||
unitPriceInCents: Math.round(tab.totalInCents / tab.itemCount),
|
||||
units: Array.from({ length: tab.itemCount }, (_, i) => ({ id: `${lineId}:${i}`, addedAt, removedAt: null })),
|
||||
}],
|
||||
venueId: venue.id,
|
||||
name: 'Bar terminal 1',
|
||||
tokenHash: createHash('sha256').update(token).digest('hex'),
|
||||
tokenPrefix: token.slice(0, 8),
|
||||
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
|
||||
lastSeenAt: minutesAgo(1),
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const products = [];
|
||||
for (const { stock, ...fields } of CATALOG) {
|
||||
const product = await prisma.product.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
venueId: venue.id,
|
||||
...fields,
|
||||
stock,
|
||||
countedAt: stock === null ? null : minutesAgo(24 * 60),
|
||||
lowStockThreshold: stock === null ? null : 12,
|
||||
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now),
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
products.push(product);
|
||||
if (stock !== null) {
|
||||
await prisma.stockMovement.create({
|
||||
data: { venueId: venue.id, productId: product.id, type: 'count', quantity: stock, stockAfter: stock, userId: manager.id, createdAt: minutesAgo(24 * 60) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Created 6 open tabs');
|
||||
|
||||
// Create sample commands
|
||||
const now = new Date();
|
||||
|
||||
// Completed command
|
||||
await prisma.remoteCommand.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
deviceId,
|
||||
targetTabId: tabsData[1].tabId,
|
||||
type: 'close_tab',
|
||||
expectedVersion: 3,
|
||||
paymentMethod: 'cash',
|
||||
requestedBy: 'Eigenaar',
|
||||
status: 'completed',
|
||||
result: 'Tab closed successfully',
|
||||
acknowledgedAt: new Date(now.getTime() - 30 * 60 * 1000),
|
||||
createdAt: new Date(now.getTime() - 35 * 60 * 1000),
|
||||
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
|
||||
},
|
||||
await prisma.customer.createMany({
|
||||
data: ['Jan', 'Marie', 'Thomas'].map((name, index) => ({
|
||||
venueId: venue.id, name, nameKey: name.toLowerCase(), email: `${name.toLowerCase()}@example.com`, position: index + 1, tabOpenedAt: now,
|
||||
})),
|
||||
});
|
||||
|
||||
// Pending command
|
||||
await prisma.remoteCommand.create({
|
||||
// Open tabs, and for each customer one paid tab from last week
|
||||
for (const [index, { customerName, lines }] of TABS.entries()) {
|
||||
const tab = await prisma.tab.create({
|
||||
data: { id: randomUUID(), venueId: venue.id, customerName, openedAt: minutesAgo(180), version: lines.length, updatedAt: now },
|
||||
});
|
||||
await prisma.tabUnit.createMany({ data: lines.flatMap(([productIndex, quantity, ago]) => units(venue.id, products[productIndex], quantity, minutesAgo(ago), randomUUID(), { tabId: tab.id })) });
|
||||
|
||||
const settledAt = minutesAgo((index + 1) * 24 * 60 * 2);
|
||||
const product = products[index % products.length];
|
||||
const settlement = await prisma.settlement.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
deviceId,
|
||||
targetTabId: tabsData[0].tabId,
|
||||
type: 'close_tab',
|
||||
expectedVersion: 5,
|
||||
paymentMethod: 'payconiq',
|
||||
requestedBy: 'Eigenaar',
|
||||
status: 'pending',
|
||||
createdAt: new Date(now.getTime() - 5 * 60 * 1000),
|
||||
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
|
||||
id: randomUUID(), venueId: venue.id, tabId: tab.id, customerName, settledAt, paymentMethod: index % 2 === 0 ? 'cash' : 'payconiq',
|
||||
amountInCents: 3 * product.priceInCents, deviceId: device.id,
|
||||
},
|
||||
});
|
||||
await prisma.tabUnit.createMany({ data: units(venue.id, product, 3, minutesAgo((index + 1) * 24 * 60 * 2 + 60), randomUUID(), { settlementId: settlement.id }) });
|
||||
}
|
||||
|
||||
// Failed command
|
||||
await prisma.remoteCommand.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
console.log('Seeded the demo database. Sign in with:');
|
||||
for (const user of users) console.log(` ${user.role.padEnd(10)} ${user.email} / ${user.password}`);
|
||||
console.log(`Terminal token for the simulator: ${token}`);
|
||||
}
|
||||
|
||||
function units(
|
||||
venueId: string,
|
||||
product: { id: string; name: string; priceInCents: number },
|
||||
quantity: number,
|
||||
addedAt: Date,
|
||||
lineId: string,
|
||||
where: { tabId?: string; settlementId?: string },
|
||||
) {
|
||||
const eventId = randomUUID();
|
||||
return Array.from({ length: quantity }, (_, n) => ({
|
||||
id: `${eventId}:${n}`,
|
||||
venueId,
|
||||
deviceId,
|
||||
targetTabId: tabsData[4].tabId,
|
||||
type: 'close_tab',
|
||||
expectedVersion: 4,
|
||||
paymentMethod: 'payment_pending',
|
||||
requestedBy: 'Bartender',
|
||||
status: 'failed',
|
||||
result: 'Tab could not be closed',
|
||||
acknowledgedAt: new Date(now.getTime() - 60 * 60 * 1000),
|
||||
createdAt: new Date(now.getTime() - 65 * 60 * 1000),
|
||||
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
// Conflict command
|
||||
await prisma.remoteCommand.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
deviceId,
|
||||
targetTabId: tabsData[2].tabId,
|
||||
type: 'close_tab',
|
||||
expectedVersion: 7,
|
||||
paymentMethod: 'cash',
|
||||
requestedBy: 'Eigenaar',
|
||||
status: 'conflict',
|
||||
result: 'Version mismatch: expected 7, got 8',
|
||||
acknowledgedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
|
||||
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000 - 5 * 60 * 1000),
|
||||
expiresAt: new Date(now.getTime() + 22 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Created sample commands: completed, pending, failed, conflict');
|
||||
|
||||
// Create audit log entries
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
actorType: 'client_user',
|
||||
actorId: 'admin',
|
||||
action: 'command_created',
|
||||
details: JSON.stringify({ commandId: 'sample', tabId: tabsData[0].tabId }),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seeding complete!');
|
||||
console.log('');
|
||||
console.log('Demo credentials:');
|
||||
console.log(' Admin: admin@example.com / Admin123!');
|
||||
console.log(' Viewer: viewer@example.com / Viewer123!');
|
||||
console.log(' Superadmin: superadmin@example.com / Super123!');
|
||||
console.log(`\nDevice token (for simulator): ${token}`);
|
||||
...where,
|
||||
lineId,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
unitPriceInCents: product.priceInCents,
|
||||
orderId: `order-${addedAt.getTime()}`,
|
||||
addedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,74 +0,0 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const args = process.argv.slice(2).filter((arg) => arg !== '--force');
|
||||
const FORCE = process.argv.includes('--force');
|
||||
const VENUE_NAME = process.env.SETUP_VENUE_NAME || args[0] || 'My Venue';
|
||||
const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || args[1];
|
||||
const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || args[2];
|
||||
|
||||
async function main() {
|
||||
console.log('=== KoolTap Production Setup ===\n');
|
||||
|
||||
const venues = await prisma.venue.count();
|
||||
if (venues > 0 && !FORCE) {
|
||||
console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
|
||||
throw new Error('Usage: node prisma/setup.js "<venue name>" <admin email> <admin password> [--force]');
|
||||
}
|
||||
if (ADMIN_PASSWORD.length < 10) {
|
||||
throw new Error('The admin password must be at least 10 characters');
|
||||
}
|
||||
|
||||
const venueId = uuidv4();
|
||||
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.auditLog.deleteMany();
|
||||
await tx.tabEvent.deleteMany();
|
||||
await tx.tabReminder.deleteMany();
|
||||
await tx.plannedTab.deleteMany();
|
||||
await tx.remoteCommand.deleteMany();
|
||||
await tx.settlement.deleteMany();
|
||||
await tx.tab.deleteMany();
|
||||
await tx.stockAdjustment.deleteMany();
|
||||
await tx.product.deleteMany();
|
||||
await tx.productImage.deleteMany();
|
||||
await tx.refreshToken.deleteMany();
|
||||
await tx.registrationCode.deleteMany();
|
||||
await tx.device.deleteMany();
|
||||
await tx.clientUser.deleteMany();
|
||||
await tx.venue.deleteMany();
|
||||
|
||||
await tx.venue.create({ data: { id: venueId, name: VENUE_NAME } });
|
||||
await tx.clientUser.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
email: ADMIN_EMAIL,
|
||||
passwordHash: hash,
|
||||
name: 'Admin',
|
||||
role: 'admin',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
console.log('=== Setup complete ===');
|
||||
console.log(` Venue: ${VENUE_NAME}`);
|
||||
console.log(` Venue ID: ${venueId}`);
|
||||
console.log(` Admin: ${ADMIN_EMAIL}`);
|
||||
console.log('\nLog in to the dashboard with the admin credentials you provided.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error('Setup failed:', err.message ?? err);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -1,31 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Makes an existing dashboard user a superadmin, or an admin again with --revoke. There is no other way to become
|
||||
* the first superadmin: the dashboard only lets a superadmin hand out the role.
|
||||
*
|
||||
* pnpm --filter @kooltap/api run superadmin user@example.com [--revoke]
|
||||
*/
|
||||
async function main() {
|
||||
const [email, flag] = process.argv.slice(2);
|
||||
if (!email) {
|
||||
console.error('Usage: superadmin <email> [--revoke]');
|
||||
process.exit(1);
|
||||
}
|
||||
const role = flag === '--revoke' ? 'admin' : 'superadmin';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const user = await prisma.clientUser.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
console.error(`No user with email ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
await prisma.clientUser.update({ where: { id: user.id }, data: { role } });
|
||||
console.log(`${email} is now ${role === 'superadmin' ? 'a superadmin' : 'an admin'}`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,225 +1,174 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { AdminService } from './admin.service';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
CopyCatalogDto,
|
||||
DryRunDto,
|
||||
PurgeDto,
|
||||
ResetPasswordDto,
|
||||
ResetVenueDto,
|
||||
TestEmailDto,
|
||||
UpdateRoleDto,
|
||||
VenueNameDto,
|
||||
} from './dto/admin.dto';
|
||||
auditQuerySchema, copyCatalogSchema, deleteVenueQuerySchema, dryRunSchema, purgeSchema, resetPasswordSchema, resetVenueSchema,
|
||||
testEmailSchema, updateRoleSchema, venueNameSchema,
|
||||
} from '@kooltap/shared';
|
||||
import { zod } from '../common/zod.pipe';
|
||||
import { Authorized, AuthUser, CurrentUser, Roles } from '../common/auth';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
type Body<S extends z.ZodType> = z.output<S>;
|
||||
|
||||
/** The superadmin page: every venue, terminal and user of KoolTap. Destructive actions take a dryRun to preview. */
|
||||
@ApiTags('Admin')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('superadmin')
|
||||
@Controller('admin')
|
||||
@Authorized()
|
||||
@Roles('superadmin')
|
||||
export class AdminController {
|
||||
constructor(private admin: AdminService) {}
|
||||
|
||||
// ---- Venues
|
||||
|
||||
@Get('venues')
|
||||
@ApiOperation({ summary: 'Every venue with what it holds' })
|
||||
listVenues() {
|
||||
return this.admin.listVenues();
|
||||
}
|
||||
|
||||
@Post('venues')
|
||||
@ApiOperation({ summary: 'Create a venue' })
|
||||
createVenue(@Body() dto: VenueNameDto, @Req() req: any) {
|
||||
return this.admin.createVenue(dto.name, req.user);
|
||||
createVenue(@Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.createVenue(dto.name, user);
|
||||
}
|
||||
|
||||
@Patch('venues/:venueId')
|
||||
@ApiOperation({ summary: 'Rename a venue' })
|
||||
renameVenue(@Param('venueId') venueId: string, @Body() dto: VenueNameDto, @Req() req: any) {
|
||||
return this.admin.renameVenue(venueId, dto.name, req.user);
|
||||
renameVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(venueNameSchema)) dto: Body<typeof venueNameSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.renameVenue(venueId, dto.name, user);
|
||||
}
|
||||
|
||||
@Delete('venues/:venueId')
|
||||
@ApiOperation({ summary: 'Delete a venue with everything in it, users and terminals included' })
|
||||
@ApiQuery({ name: 'confirmName', description: "The venue's name" })
|
||||
deleteVenue(@Param('venueId') venueId: string, @Query('confirmName') confirmName: string | undefined, @Req() req: any) {
|
||||
return this.admin.deleteVenue(venueId, confirmName, req.user);
|
||||
deleteVenue(
|
||||
@Param('venueId', ParseUUIDPipe) venueId: string,
|
||||
@Query(zod(deleteVenueQuerySchema)) query: Body<typeof deleteVenueQuerySchema>,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.admin.deleteVenue(venueId, query.confirmName, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/view')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Start viewing the dashboard as the venue; recorded in the venue's audit log" })
|
||||
viewVenue(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.admin.viewVenue(venueId, req.user);
|
||||
viewVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.viewVenue(venueId, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/copy-catalog')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Copy another venue's products that this venue doesn't have yet" })
|
||||
copyCatalog(@Param('venueId') venueId: string, @Body() dto: CopyCatalogDto, @Req() req: any) {
|
||||
return this.admin.copyCatalog(venueId, dto.fromVenueId, req.user);
|
||||
copyCatalog(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(copyCatalogSchema)) dto: Body<typeof copyCatalogSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.copyCatalog(venueId, dto.fromVenueId, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/reset')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Wipe parts of a venue's data; users, terminals and products stay" })
|
||||
resetVenue(@Param('venueId') venueId: string, @Body() dto: ResetVenueDto, @Req() req: any) {
|
||||
return this.admin.resetVenue(venueId, dto.scopes, { stockValue: dto.stockValue, dryRun: dto.dryRun }, req.user);
|
||||
resetVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @Body(zod(resetVenueSchema)) dto: Body<typeof resetVenueSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.resetVenue(venueId, dto.scopes, dto, user);
|
||||
}
|
||||
|
||||
@Get('venues/:venueId/consistency')
|
||||
@ApiOperation({ summary: "Look for data in the venue that doesn't add up" })
|
||||
consistency(@Param('venueId') venueId: string) {
|
||||
consistency(@Param('venueId', ParseUUIDPipe) venueId: string) {
|
||||
return this.admin.consistency(venueId);
|
||||
}
|
||||
|
||||
@Get('venues/:venueId/export')
|
||||
@ApiOperation({ summary: 'Everything of the venue as JSON, without passwords and pictures' })
|
||||
exportVenue(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.admin.exportVenue(venueId, req.user);
|
||||
exportVenue(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.exportVenue(venueId, user);
|
||||
}
|
||||
|
||||
@Post('venues/:venueId/registration-codes')
|
||||
@ApiOperation({ summary: 'A pairing code for a terminal of the venue' })
|
||||
createRegistrationCode(@Param('venueId') venueId: string, @Req() req: any) {
|
||||
return this.admin.createRegistrationCode(venueId, req.user);
|
||||
createRegistrationCode(@Param('venueId', ParseUUIDPipe) venueId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.createRegistrationCode(venueId, user);
|
||||
}
|
||||
|
||||
// ---- Terminals
|
||||
|
||||
@Get('devices')
|
||||
@ApiOperation({ summary: 'Every terminal, unpaired ones included' })
|
||||
listDevices() {
|
||||
return this.admin.listDevices();
|
||||
}
|
||||
|
||||
@Post('devices/:deviceId/revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Unpair a terminal' })
|
||||
revokeDevice(@Param('deviceId') deviceId: string, @Req() req: any) {
|
||||
return this.admin.revokeDevice(deviceId, req.user);
|
||||
revokeDevice(@Param('deviceId', ParseUUIDPipe) deviceId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.revokeDevice(deviceId, user);
|
||||
}
|
||||
|
||||
@Get('registration-codes')
|
||||
@ApiOperation({ summary: 'Pairing codes that can still be used' })
|
||||
listRegistrationCodes() {
|
||||
return this.admin.listRegistrationCodes();
|
||||
}
|
||||
|
||||
@Post('registration-codes/:id/revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Make a pairing code unusable' })
|
||||
revokeRegistrationCode(@Param('id') id: string) {
|
||||
revokeRegistrationCode(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.admin.revokeRegistrationCode(id);
|
||||
}
|
||||
|
||||
@Post('registration-codes/cleanup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete used and expired pairing codes' })
|
||||
cleanupRegistrationCodes() {
|
||||
return this.admin.cleanupRegistrationCodes();
|
||||
}
|
||||
|
||||
@Post('commands/expire-stuck')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Expire every pending command' })
|
||||
expireStuckCommands(@Body() dto: DryRunDto, @Req() req: any) {
|
||||
return this.admin.expireStuckCommands(dto.dryRun ?? false, req.user);
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
@Get('users')
|
||||
@ApiOperation({ summary: 'Every dashboard user' })
|
||||
listUsers() {
|
||||
return this.admin.listUsers();
|
||||
}
|
||||
|
||||
@Patch('users/:userId/role')
|
||||
@ApiOperation({ summary: "Change a user's role (not your own)" })
|
||||
updateRole(@Param('userId') userId: string, @Body() dto: UpdateRoleDto, @Req() req: any) {
|
||||
return this.admin.updateRole(userId, dto.role, req.user);
|
||||
updateRole(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(updateRoleSchema)) dto: Body<typeof updateRoleSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.updateRole(userId, dto.role, dto.venueId, user);
|
||||
}
|
||||
|
||||
@Post('users/:userId/password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Set a user's password; signs them out everywhere" })
|
||||
resetPassword(@Param('userId') userId: string, @Body() dto: ResetPasswordDto, @Req() req: any) {
|
||||
return this.admin.resetPassword(userId, dto.password, req.user);
|
||||
resetPassword(@Param('userId', ParseUUIDPipe) userId: string, @Body(zod(resetPasswordSchema)) dto: Body<typeof resetPasswordSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.resetPassword(userId, dto.password, user);
|
||||
}
|
||||
|
||||
@Post('users/:userId/logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Sign a user out everywhere, at once' })
|
||||
logoutUser(@Param('userId') userId: string, @Req() req: any) {
|
||||
return this.admin.logoutUser(userId, req.user);
|
||||
logoutUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.logoutUser(userId, user);
|
||||
}
|
||||
|
||||
@Delete('users/:userId')
|
||||
@ApiOperation({ summary: 'Delete a user (not yourself)' })
|
||||
deleteUser(@Param('userId') userId: string, @Req() req: any) {
|
||||
return this.admin.deleteUser(userId, req.user);
|
||||
deleteUser(@Param('userId', ParseUUIDPipe) userId: string, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.deleteUser(userId, user);
|
||||
}
|
||||
|
||||
@Post('sessions/revoke-all')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Sign out every user but yourself' })
|
||||
logoutEveryone(@Req() req: any) {
|
||||
return this.admin.logoutEveryone(req.user);
|
||||
logoutEveryone(@CurrentUser() user: AuthUser) {
|
||||
return this.admin.logoutEveryone(user);
|
||||
}
|
||||
|
||||
// ---- Maintenance
|
||||
|
||||
@Post('purge')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete old terminal events, audit entries or finished commands' })
|
||||
purge(@Body() dto: PurgeDto, @Req() req: any) {
|
||||
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun ?? false, req.user);
|
||||
purge(@Body(zod(purgeSchema)) dto: Body<typeof purgeSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.purge(dto.targets, dto.olderThanDays, dto.venueId, dto.dryRun, user);
|
||||
}
|
||||
|
||||
@Post('images/cleanup')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Delete pictures no product uses (older than a week)' })
|
||||
cleanupImages(@Body() dto: DryRunDto, @Req() req: any) {
|
||||
return this.admin.cleanupImages(dto.dryRun ?? false, req.user);
|
||||
cleanupImages(@Body(zod(dryRunSchema)) dto: Body<typeof dryRunSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.cleanupImages(dto.dryRun, user);
|
||||
}
|
||||
|
||||
@Post('email/test')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Send the test email to an address' })
|
||||
sendTestEmail(@Body() dto: TestEmailDto, @Req() req: any) {
|
||||
return this.admin.sendTestEmail(dto.to, req.user);
|
||||
sendTestEmail(@Body(zod(testEmailSchema)) dto: Body<typeof testEmailSchema>, @CurrentUser() user: AuthUser) {
|
||||
return this.admin.sendTestEmail(dto.to, user);
|
||||
}
|
||||
|
||||
// ---- Insight
|
||||
|
||||
@Get('audit')
|
||||
@ApiOperation({ summary: 'The audit log of every venue' })
|
||||
@ApiQuery({ name: 'venueId', required: false })
|
||||
@ApiQuery({ name: 'action', required: false, description: 'Part of the action name' })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'offset', required: false })
|
||||
auditLog(
|
||||
@Query('venueId') venueId?: string,
|
||||
@Query('action') action?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
return this.admin.auditLog({
|
||||
venueId: venueId || undefined,
|
||||
action: action || undefined,
|
||||
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||
});
|
||||
auditLog(@Query(zod(auditQuerySchema)) query: Body<typeof auditQuerySchema>) {
|
||||
return this.admin.auditLog(query);
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
@ApiOperation({ summary: 'Database, migrations, live connections and background jobs' })
|
||||
health() {
|
||||
return this.admin.health();
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DevicesModule } from '../devices/devices.module';
|
||||
import { ImagesModule } from '../images/images.module';
|
||||
import { RemindersModule } from '../reminders/reminders.module';
|
||||
import { JobsModule } from '../jobs/jobs.module';
|
||||
import { VenuesModule } from '../venues/venues.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, DevicesModule, RemindersModule, JobsModule],
|
||||
imports: [ImagesModule, RemindersModule, VenuesModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
|
||||
+256
-338
@@ -1,32 +1,35 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
AdminDevice, AdminRegistrationCode, AdminUser, AdminVenue, AuditEntry, Finding, PurgeTarget, RealtimeTopic, ResetScope, Role,
|
||||
SystemHealth,
|
||||
} from '@kooltap/shared';
|
||||
import { Prisma, PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { DevicesService } from '../devices/devices.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { RemindersService } from '../reminders/reminders.service';
|
||||
import { CommandExpiryJob } from '../jobs/command-expiry.job';
|
||||
import { createdFieldTimes } from '../stock/product-fields';
|
||||
import { VenuesService } from '../venues/venues.service';
|
||||
import { ImagesService, imageDir } from '../images/images.service';
|
||||
import { Resend } from '../email/resend';
|
||||
import { hashPassword } from '../auth/auth.service';
|
||||
import { AuthUser } from '../common/auth';
|
||||
import { createdFieldTimes } from '../ledger/product-fields';
|
||||
import { lockVenue } from '../ledger/venue-ledger';
|
||||
import { ensureDefaultCustomers } from '../customers/default-customers';
|
||||
import { checkConsistency } from './consistency';
|
||||
import { countReset, deleteVenue, lockVenue, resetVenue, ResetCounts, ResetScope } from './venue-reset';
|
||||
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
|
||||
import { PurgeTarget } from './dto/admin.dto';
|
||||
|
||||
/** The superadmin doing it. */
|
||||
export type Actor = { id: string; email: string; name: string; venueId: string };
|
||||
import { countReset, resetVenue, ResetCounts } from './venue-reset';
|
||||
|
||||
// What each reset scope changes on the dashboard
|
||||
const SCOPE_TOPICS: Record<ResetScope, RealtimeTopic[]> = {
|
||||
// Plans the Free Drinks customer again
|
||||
tabs: ['tabs', 'planned-tabs'],
|
||||
// Adds the Free Drinks customer again
|
||||
tabs: ['tabs', 'customers'],
|
||||
settlements: ['tabs'],
|
||||
tabEvents: [],
|
||||
plannedTabs: ['planned-tabs', 'reminders'],
|
||||
customers: ['customers', 'reminders'],
|
||||
stock: ['stock'],
|
||||
stockHistory: ['stock'],
|
||||
commands: ['commands'],
|
||||
auditLog: [],
|
||||
};
|
||||
|
||||
@@ -35,26 +38,24 @@ const ORPHAN_IMAGE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Everything the superadmin page does. Global actions (purging, signing everyone out) are written to the audit log
|
||||
* of the superadmin's own venue, since the log is kept per venue.
|
||||
*/
|
||||
/** Everything the superadmin page does. */
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private audit: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private authService: AuthService,
|
||||
private devicesService: DevicesService,
|
||||
private venues: VenuesService,
|
||||
private reminders: RemindersService,
|
||||
private commandExpiry: CommandExpiryJob,
|
||||
private images: ImagesService,
|
||||
private resend: Resend,
|
||||
) {}
|
||||
|
||||
// ---- Venues
|
||||
|
||||
async listVenues() {
|
||||
const venues = await this.prisma.venue.findMany({
|
||||
async listVenues(): Promise<AdminVenue[]> {
|
||||
const [venues, lastSeen] = await Promise.all([
|
||||
this.prisma.venue.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -62,88 +63,75 @@ export class AdminService {
|
||||
createdAt: true,
|
||||
_count: {
|
||||
select: {
|
||||
clientUsers: true,
|
||||
users: true,
|
||||
devices: { where: { revokedAt: null } },
|
||||
tabs: true,
|
||||
products: { where: { archivedAt: null } },
|
||||
plannedTabs: true,
|
||||
customers: true,
|
||||
settlements: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const lastSeen = await this.prisma.device.groupBy({
|
||||
by: ['venueId'],
|
||||
where: { revokedAt: null },
|
||||
_max: { lastSeenAt: true },
|
||||
});
|
||||
}),
|
||||
this.prisma.device.groupBy({ by: ['venueId'], where: { revokedAt: null }, _max: { lastSeenAt: true } }),
|
||||
]);
|
||||
const lastSeenByVenue = new Map(lastSeen.map((row) => [row.venueId, row._max.lastSeenAt]));
|
||||
|
||||
return {
|
||||
venues: venues.map(({ _count, ...venue }) => ({
|
||||
return venues.map(({ _count, ...venue }) => ({
|
||||
...venue,
|
||||
users: _count.clientUsers,
|
||||
createdAt: venue.createdAt.toISOString(),
|
||||
users: _count.users,
|
||||
devices: _count.devices,
|
||||
openTabs: _count.tabs,
|
||||
products: _count.products,
|
||||
plannedTabs: _count.plannedTabs,
|
||||
customers: _count.customers,
|
||||
settlements: _count.settlements,
|
||||
lastSyncAt: lastSeenByVenue.get(venue.id) ?? null,
|
||||
})),
|
||||
};
|
||||
lastSyncAt: lastSeenByVenue.get(venue.id)?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async createVenue(name: string, actor: Actor) {
|
||||
async createVenue(name: string, actor: AuthUser) {
|
||||
const venue = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.venue.create({ data: { name: name.trim() } });
|
||||
const created = await tx.venue.create({ data: { name } });
|
||||
await ensureDefaultCustomers(tx, created.id);
|
||||
return created;
|
||||
});
|
||||
await this.audit.log(venue.id, 'client_user', actor.id, 'admin_venue_created', { name: venue.name, by: actor.email });
|
||||
return venue;
|
||||
await this.audit.log({ venueId: venue.id, actor: { type: 'user', id: actor.id }, action: 'admin_venue_created', details: { name } });
|
||||
return { id: venue.id, name: venue.name };
|
||||
}
|
||||
|
||||
async renameVenue(venueId: string, name: string, actor: Actor) {
|
||||
async renameVenue(venueId: string, name: string, actor: AuthUser) {
|
||||
const before = await this.findVenue(venueId);
|
||||
const venue = await this.prisma.venue.update({ where: { id: venueId }, data: { name: name.trim() } });
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_renamed', { from: before.name, to: venue.name, by: actor.email });
|
||||
return venue;
|
||||
await this.prisma.venue.update({ where: { id: venueId }, data: { name } });
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_renamed', details: { from: before.name, to: name } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** [confirmName] must be the venue's name, so a wrong id can't delete a venue by accident. */
|
||||
async deleteVenue(venueId: string, confirmName: string | undefined, actor: Actor) {
|
||||
/** Deletes a venue with everything in it. [confirmName] must be its name, so a wrong id can't delete one by accident. */
|
||||
async deleteVenue(venueId: string, confirmName: string, actor: AuthUser) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
if (venueId === actor.venueId) {
|
||||
throw new BadRequestException({ code: 'cannot_delete_own_venue', message: 'You cannot delete the venue your account belongs to' });
|
||||
}
|
||||
if (confirmName !== venue.name) {
|
||||
throw new BadRequestException({ code: 'confirm_name_mismatch', message: 'Type the venue name to confirm' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await lockVenue(tx, venueId);
|
||||
await deleteVenue(tx, venueId);
|
||||
}, { timeout: 60000 });
|
||||
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_venue_deleted', { venueId, name: venue.name, by: actor.email });
|
||||
await tx.venue.delete({ where: { id: venueId } });
|
||||
}, { timeout: 60_000 });
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_venue_deleted', details: { venueId, name: venue.name } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Recorded in the venue's own audit log, so its owner can see that KoolTap looked. */
|
||||
async viewVenue(venueId: string, actor: Actor) {
|
||||
async viewVenue(venueId: string, actor: AuthUser) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_viewed_venue', { by: actor.email });
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_viewed_venue', details: { by: actor.email } });
|
||||
return { id: venue.id, name: venue.name };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but without
|
||||
* stock: stock is counted per venue.
|
||||
*/
|
||||
async copyCatalog(venueId: string, fromVenueId: string, actor: Actor) {
|
||||
if (venueId === fromVenueId) {
|
||||
throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
|
||||
}
|
||||
/** Copies the products of [fromVenueId] that [venueId] doesn't have yet (by name), with their pictures but no stock. */
|
||||
async copyCatalog(venueId: string, fromVenueId: string, actor: AuthUser) {
|
||||
if (venueId === fromVenueId) throw new BadRequestException({ code: 'same_venue', message: 'Pick another venue to copy from' });
|
||||
await this.findVenue(venueId);
|
||||
await this.findVenue(fromVenueId);
|
||||
|
||||
@@ -156,184 +144,148 @@ export class AdminService {
|
||||
]);
|
||||
const taken = new Set(existing.map((p) => p.name.trim().toLowerCase()));
|
||||
const copies = source.filter((p) => !taken.has(p.name.trim().toLowerCase()));
|
||||
if (copies.length > 0) {
|
||||
await tx.product.createMany({
|
||||
data: copies.map((p) => {
|
||||
const fields = {
|
||||
name: p.name, category: p.category, priceInCents: p.priceInCents, buyPriceInCents: p.buyPriceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock,
|
||||
};
|
||||
return {
|
||||
venueId,
|
||||
productId: uuidv4(),
|
||||
...fields,
|
||||
stock: null,
|
||||
countedAt: null,
|
||||
reportedAt: now,
|
||||
imageId: p.imageId,
|
||||
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false, imageId: p.imageId }, now),
|
||||
lowStockThreshold: p.lowStockThreshold, desiredStock: p.desiredStock, imageId: p.imageId,
|
||||
};
|
||||
return { id: randomUUID(), venueId, ...fields, fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now), updatedAt: now };
|
||||
}),
|
||||
});
|
||||
}
|
||||
return copies.length;
|
||||
});
|
||||
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_catalog_copied', { fromVenueId, created, by: actor.email });
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_catalog_copied', details: { fromVenueId, created } });
|
||||
if (created > 0) this.realtime.notify(venueId, 'stock');
|
||||
return { created };
|
||||
}
|
||||
|
||||
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue?: number; dryRun?: boolean }, actor: Actor) {
|
||||
async resetVenue(venueId: string, scopes: ResetScope[], options: { stockValue: number; dryRun: boolean }, actor: AuthUser) {
|
||||
await this.findVenue(venueId);
|
||||
const unique = [...new Set(scopes)];
|
||||
|
||||
if (options.dryRun) {
|
||||
return { dryRun: true, counts: await this.prisma.$transaction((tx) => countReset(tx, venueId, unique)) };
|
||||
}
|
||||
|
||||
const stockValue = options.stockValue ?? 0;
|
||||
const counts: ResetCounts = await this.prisma.$transaction(async (tx) => {
|
||||
await lockVenue(tx, venueId);
|
||||
return resetVenue(tx, venueId, unique, { stockValue, actor: { id: actor.id, name: actor.name || actor.email }, now: new Date() });
|
||||
}, { timeout: 60000 });
|
||||
return resetVenue(tx, venueId, unique, { stockValue: options.stockValue, userId: actor.id, now: new Date() });
|
||||
}, { timeout: 60_000 });
|
||||
|
||||
// After the reset, so wiping the audit log keeps the entry that says who wiped it
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_reset', { scopes: unique, stockValue, counts, by: actor.email });
|
||||
|
||||
const topics = new Set(unique.flatMap((scope) => SCOPE_TOPICS[scope]));
|
||||
if (topics.size > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_reset', details: { scopes: unique, stockValue: options.stockValue, counts } });
|
||||
this.realtime.notify(venueId, ...unique.flatMap((scope) => SCOPE_TOPICS[scope]));
|
||||
return { dryRun: false, counts };
|
||||
}
|
||||
|
||||
async consistency(venueId: string) {
|
||||
async consistency(venueId: string): Promise<{ checkedAt: string; findings: Finding[] }> {
|
||||
await this.findVenue(venueId);
|
||||
const where = { venueId };
|
||||
const [settlements, tabs, products, pendingCommands, devices] = await Promise.all([
|
||||
this.prisma.settlement.findMany({
|
||||
where,
|
||||
select: { id: true, customerName: true, amountInCents: true, lines: true, commandId: true },
|
||||
}),
|
||||
this.prisma.tab.findMany({ where, select: { id: true, customerName: true, lines: true } }),
|
||||
this.prisma.product.findMany({
|
||||
where: { venueId, archivedAt: null },
|
||||
select: { productId: true, name: true, stock: true, countedAt: true },
|
||||
}),
|
||||
this.prisma.remoteCommand.findMany({
|
||||
where: { venueId, status: 'pending' },
|
||||
select: { id: true, expiresAt: true, device: { select: { name: true, revokedAt: true } } },
|
||||
}),
|
||||
const [settlements, drinks, tabs, products, devices] = await Promise.all([
|
||||
this.prisma.settlement.findMany({ where, select: { id: true, customerName: true, amountInCents: true } }),
|
||||
this.prisma.$queryRaw<{ settlementId: string; cents: bigint }[]>`
|
||||
SELECT "settlementId", SUM("unitPriceInCents")::bigint AS cents FROM "TabUnit"
|
||||
WHERE "venueId" = ${venueId}::uuid AND "settlementId" IS NOT NULL AND "removedAt" IS NULL
|
||||
GROUP BY "settlementId"`,
|
||||
this.prisma.tab.findMany({ where, select: { id: true, customerName: true } }),
|
||||
this.prisma.product.findMany({ where: { venueId, archivedAt: null }, select: { id: true, name: true, stock: true } }),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, revokedAt: true, tokenExpiresAt: true } }),
|
||||
]);
|
||||
const drinksBySettlement = new Map(drinks.map((row) => [row.settlementId, Number(row.cents)]));
|
||||
|
||||
const findings = checkConsistency({ settlements, tabs, products, pendingCommands, devices }, new Date());
|
||||
return { checkedAt: new Date(), findings };
|
||||
const now = new Date();
|
||||
const findings = checkConsistency({
|
||||
settlements: settlements.map((s) => ({ ...s, drinksInCents: drinksBySettlement.get(s.id) ?? 0 })),
|
||||
tabs,
|
||||
products,
|
||||
devices,
|
||||
}, now);
|
||||
return { checkedAt: now.toISOString(), findings };
|
||||
}
|
||||
|
||||
/** Everything of a venue except password hashes and picture bytes, for a backup before a reset. */
|
||||
async exportVenue(venueId: string, actor: Actor) {
|
||||
/** Everything of a venue except password hashes and pictures, for a backup before a reset. */
|
||||
async exportVenue(venueId: string, actor: AuthUser) {
|
||||
const venue = await this.findVenue(venueId);
|
||||
const where = { venueId };
|
||||
const [users, devices, products, tabs, settlements, plannedTabs, reminders, stockAdjustments, commands, tabEvents, auditLog] =
|
||||
await Promise.all([
|
||||
this.prisma.clientUser.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
|
||||
const [users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog] = await Promise.all([
|
||||
this.prisma.user.findMany({ where, select: { id: true, email: true, name: true, role: true, createdAt: true } }),
|
||||
this.prisma.device.findMany({ where, select: { id: true, name: true, lastSeenAt: true, revokedAt: true, createdAt: true } }),
|
||||
this.prisma.customer.findMany({ where, orderBy: { position: 'asc' } }),
|
||||
this.prisma.product.findMany({ where }),
|
||||
this.prisma.tab.findMany({ where }),
|
||||
this.prisma.settlement.findMany({ where, orderBy: { sequence: 'asc' } }),
|
||||
this.prisma.plannedTab.findMany({ where, orderBy: { position: 'asc' } }),
|
||||
this.prisma.tabReminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
|
||||
this.prisma.stockAdjustment.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.remoteCommand.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.tabUnit.findMany({ where, orderBy: { addedAt: 'asc' } }),
|
||||
this.prisma.stockMovement.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
this.prisma.reminder.findMany({ where, orderBy: { sentAt: 'asc' } }),
|
||||
this.prisma.tabEvent.findMany({ where, orderBy: { occurredAt: 'asc' } }),
|
||||
this.prisma.auditLog.findMany({ where, orderBy: { createdAt: 'asc' } }),
|
||||
]);
|
||||
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_venue_exported', { by: actor.email });
|
||||
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_venue_exported' });
|
||||
return {
|
||||
exportedAt: new Date(),
|
||||
venue,
|
||||
users,
|
||||
devices,
|
||||
products,
|
||||
tabs,
|
||||
settlements,
|
||||
plannedTabs,
|
||||
reminders,
|
||||
stockAdjustments,
|
||||
commands,
|
||||
tabEvents,
|
||||
auditLog,
|
||||
exportedAt: new Date(), venue, users, devices, customers, products, tabs, settlements, units, stockMovements, reminders, tabEvents, auditLog,
|
||||
};
|
||||
}
|
||||
|
||||
async createRegistrationCode(venueId: string, actor: AuthUser) {
|
||||
await this.findVenue(venueId);
|
||||
const code = await this.venues.createRegistrationCode(venueId);
|
||||
await this.audit.log({ venueId, actor: { type: 'user', id: actor.id }, action: 'admin_registration_code_created' });
|
||||
return code;
|
||||
}
|
||||
|
||||
// ---- Terminals
|
||||
|
||||
async listDevices() {
|
||||
async listDevices(): Promise<AdminDevice[]> {
|
||||
const devices = await this.prisma.device.findMany({
|
||||
orderBy: [{ revokedAt: { sort: 'desc', nulls: 'first' } }, { lastSeenAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
venueId: true,
|
||||
tokenPrefix: true,
|
||||
tokenExpiresAt: true,
|
||||
lastSeenAt: true,
|
||||
revokedAt: true,
|
||||
createdAt: true,
|
||||
venue: { select: { name: true } },
|
||||
_count: { select: { remoteCommands: { where: { status: 'pending' } } } },
|
||||
},
|
||||
include: { venue: { select: { name: true } } },
|
||||
});
|
||||
return devices.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
venueId: d.venueId,
|
||||
venueName: d.venue.name,
|
||||
tokenPrefix: d.tokenPrefix,
|
||||
tokenExpiresAt: d.tokenExpiresAt.toISOString(),
|
||||
lastSeenAt: d.lastSeenAt.toISOString(),
|
||||
revokedAt: d.revokedAt?.toISOString() ?? null,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async revokeDevice(deviceId: string, actor: AuthUser) {
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
if (!device) throw new NotFoundException({ code: 'device_not_found', message: 'Terminal not found' });
|
||||
return this.venues.unpairDevice(device.venueId, deviceId, { type: 'user', id: actor.id });
|
||||
}
|
||||
|
||||
async listRegistrationCodes(): Promise<{ codes: AdminRegistrationCode[]; stale: number }> {
|
||||
const now = new Date();
|
||||
const [codes, stale] = await Promise.all([
|
||||
this.prisma.registrationCode.findMany({
|
||||
where: { usedAt: null, expiresAt: { gt: now } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { venue: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.registrationCode.count({ where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: now } }] } }),
|
||||
]);
|
||||
return {
|
||||
devices: devices.map(({ venue, _count, ...device }) => ({
|
||||
...device,
|
||||
venueName: venue.name,
|
||||
pendingCommands: _count.remoteCommands,
|
||||
codes: codes.map((c) => ({
|
||||
id: c.id, code: c.code, venueId: c.venueId, venueName: c.venue.name, expiresAt: c.expiresAt.toISOString(), createdAt: c.createdAt.toISOString(),
|
||||
})),
|
||||
stale,
|
||||
};
|
||||
}
|
||||
|
||||
async revokeDevice(deviceId: string, actor: Actor) {
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
if (!device) {
|
||||
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
|
||||
}
|
||||
await this.devicesService.revoke(device.venueId, deviceId);
|
||||
await this.audit.log(device.venueId, 'client_user', actor.id, 'admin_device_revoked', { deviceId, deviceName: device.name, by: actor.email });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listRegistrationCodes() {
|
||||
const codes = await this.prisma.registrationCode.findMany({
|
||||
where: { usedAt: null, expiresAt: { gt: new Date() } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: { id: true, code: true, venueId: true, expiresAt: true, createdAt: true, venue: { select: { name: true } } },
|
||||
});
|
||||
const stale = await this.prisma.registrationCode.count({
|
||||
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
|
||||
});
|
||||
return { codes: codes.map(({ venue, ...code }) => ({ ...code, venueName: venue.name })), stale };
|
||||
}
|
||||
|
||||
async createRegistrationCode(venueId: string, actor: Actor) {
|
||||
await this.findVenue(venueId);
|
||||
const code = await this.authService.generateRegistrationCode(venueId);
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_registration_code_created', { by: actor.email });
|
||||
return code;
|
||||
}
|
||||
|
||||
async revokeRegistrationCode(id: string) {
|
||||
const { count } = await this.prisma.registrationCode.updateMany({
|
||||
where: { id, usedAt: null, expiresAt: { gt: new Date() } },
|
||||
data: { expiresAt: new Date() },
|
||||
});
|
||||
const now = new Date();
|
||||
const { count } = await this.prisma.registrationCode.updateMany({ where: { id, usedAt: null, expiresAt: { gt: now } }, data: { expiresAt: now } });
|
||||
return { ok: true, revoked: count > 0 };
|
||||
}
|
||||
|
||||
/** Deletes used and expired codes; they are only kept so a code can't be used twice, which expiry already ensures. */
|
||||
/** Deletes used and expired codes; expiry already keeps them from being used again. */
|
||||
async cleanupRegistrationCodes() {
|
||||
const { count } = await this.prisma.registrationCode.deleteMany({
|
||||
where: { OR: [{ usedAt: { not: null } }, { expiresAt: { lte: new Date() } }] },
|
||||
@@ -341,175 +293,139 @@ export class AdminService {
|
||||
return { deleted: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Expires every pending command. The server settles tabs itself now, so a pending command is one from before that
|
||||
* whose terminal never picked it up.
|
||||
*/
|
||||
async expireStuckCommands(dryRun: boolean, actor: Actor) {
|
||||
const where = { status: 'pending' };
|
||||
if (dryRun) {
|
||||
return { dryRun: true, count: await this.prisma.remoteCommand.count({ where }) };
|
||||
}
|
||||
|
||||
const stuck = await this.prisma.remoteCommand.findMany({ where, select: { id: true, venueId: true } });
|
||||
if (stuck.length > 0) {
|
||||
await this.prisma.remoteCommand.updateMany({
|
||||
where: { id: { in: stuck.map((c) => c.id) }, status: 'pending' },
|
||||
data: { status: 'expired', result: 'Expired by a KoolTap admin' },
|
||||
});
|
||||
}
|
||||
const venues = new Set(stuck.map((c) => c.venueId));
|
||||
for (const venueId of venues) {
|
||||
const count = stuck.filter((c) => c.venueId === venueId).length;
|
||||
await this.audit.log(venueId, 'client_user', actor.id, 'admin_commands_expired', { count, by: actor.email });
|
||||
this.realtime.notify(venueId, 'commands');
|
||||
}
|
||||
return { dryRun: false, count: stuck.length };
|
||||
}
|
||||
|
||||
// ---- Users
|
||||
|
||||
async listUsers() {
|
||||
const now = new Date();
|
||||
async listUsers(): Promise<AdminUser[]> {
|
||||
const [users, sessions] = await Promise.all([
|
||||
this.prisma.clientUser.findMany({
|
||||
orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }],
|
||||
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true, venue: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.refreshToken.groupBy({
|
||||
by: ['userId'],
|
||||
where: { revokedAt: null, expiresAt: { gt: now } },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.user.findMany({ orderBy: [{ venue: { name: 'asc' } }, { email: 'asc' }], include: { venue: { select: { name: true } } } }),
|
||||
this.prisma.refreshToken.groupBy({ by: ['userId'], where: { revokedAt: null, expiresAt: { gt: new Date() } }, _count: { _all: true } }),
|
||||
]);
|
||||
const sessionsByUser = new Map(sessions.map((row) => [row.userId, row._count._all]));
|
||||
return {
|
||||
users: users.map(({ venue, ...user }) => ({
|
||||
...user,
|
||||
role: user.role === 'viewer' ? 'manager' : user.role,
|
||||
venueName: venue.name,
|
||||
activeSessions: sessionsByUser.get(user.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
return users.map((u) => ({
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
role: u.role,
|
||||
venueId: u.venueId,
|
||||
venueName: u.venue?.name ?? null,
|
||||
createdAt: u.createdAt.toISOString(),
|
||||
activeSessions: sessionsByUser.get(u.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateRole(userId: string, role: string, actor: Actor) {
|
||||
/**
|
||||
* A superadmin leaves their venue: they are above the venues. Anyone else stays in theirs, so a superadmin becoming
|
||||
* an admin or manager needs [venueId] to join.
|
||||
*/
|
||||
async updateRole(userId: string, role: Role, venueId: string | undefined, actor: AuthUser) {
|
||||
// Demoting yourself could leave nobody who can open this page
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
|
||||
}
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_change_own_role', message: 'You cannot change your own role' });
|
||||
const user = await this.findUser(userId);
|
||||
await this.prisma.clientUser.update({ where: { id: userId }, data: { role } });
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_role_changed', { userId, email: user.email, from: user.role, to: role, by: actor.email });
|
||||
this.realtime.notify(user.venueId, 'users');
|
||||
const newVenueId = role === 'superadmin' ? null : user.venueId ?? venueId;
|
||||
if (role !== 'superadmin' && !newVenueId) {
|
||||
throw new BadRequestException({ code: 'venue_required', message: 'Pick the venue this user joins' });
|
||||
}
|
||||
if (newVenueId && newVenueId !== user.venueId) await this.findVenue(newVenueId);
|
||||
|
||||
await this.prisma.user.update({ where: { id: userId }, data: { role, venueId: newVenueId } });
|
||||
await this.audit.log({
|
||||
venueId: newVenueId ?? user.venueId,
|
||||
actor: { type: 'user', id: actor.id },
|
||||
action: 'admin_role_changed',
|
||||
details: { userId, from: user.role, to: role },
|
||||
});
|
||||
for (const id of new Set([user.venueId, newVenueId])) if (id) this.realtime.notify(id, 'users');
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Sets a new password and signs the user out everywhere. */
|
||||
async resetPassword(userId: string, password: string, actor: Actor) {
|
||||
async resetPassword(userId: string, password: string, actor: AuthUser) {
|
||||
// That would sign you out too; My Account changes your own password and keeps you signed in
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
|
||||
}
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'use_account_page', message: 'Change your own password on My Account' });
|
||||
const user = await this.findUser(userId);
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: userId }, data: { passwordHash, sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_password_reset', { userId, email: user.email, by: actor.email });
|
||||
await this.signOut(userId, { passwordHash: await hashPassword(password) });
|
||||
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_password_reset', details: { userId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Signs the user out everywhere, at once: their access tokens are refused too. */
|
||||
async logoutUser(userId: string, actor: Actor) {
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
|
||||
}
|
||||
async logoutUser(userId: string, actor: AuthUser) {
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_logout_self', message: 'Use Logout to sign yourself out' });
|
||||
const user = await this.findUser(userId);
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: userId }, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(user.venueId, 'client_user', actor.id, 'admin_user_logged_out', { userId, email: user.email, by: actor.email });
|
||||
await this.signOut(userId);
|
||||
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: actor.id }, action: 'admin_user_logged_out', details: { userId } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Signs out every user but the superadmin doing it. */
|
||||
async logoutEveryone(actor: Actor) {
|
||||
async logoutEveryone(actor: AuthUser) {
|
||||
const now = new Date();
|
||||
const others = { id: { not: actor.id } };
|
||||
const [users] = await this.prisma.$transaction([
|
||||
this.prisma.clientUser.updateMany({ where: others, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.user.updateMany({ where: { id: { not: actor.id } }, data: { sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId: { not: actor.id }, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_everyone_logged_out', { users: users.count, by: actor.email });
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_everyone_logged_out', details: { users: users.count } });
|
||||
return { users: users.count };
|
||||
}
|
||||
|
||||
async deleteUser(userId: string, actor: Actor) {
|
||||
async deleteUser(userId: string, actor: AuthUser) {
|
||||
const user = await this.findUser(userId);
|
||||
return this.authService.deleteUser(user.venueId, userId, actor);
|
||||
if (user.venueId) return this.venues.deleteUser(user.venueId, userId, actor);
|
||||
|
||||
// Another superadmin
|
||||
if (userId === actor.id) throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
|
||||
await this.prisma.user.delete({ where: { id: userId } });
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'user_deleted', details: { userId, email: user.email, role: user.role } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---- Maintenance
|
||||
|
||||
/** Removes old history. Pending commands are never purged: a terminal may still pick them up. */
|
||||
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: Actor) {
|
||||
async purge(targets: PurgeTarget[], olderThanDays: number, venueId: string | undefined, dryRun: boolean, actor: AuthUser) {
|
||||
if (venueId) await this.findVenue(venueId);
|
||||
const before = new Date(Date.now() - olderThanDays * DAY_MS);
|
||||
const venue = venueId ? { venueId } : {};
|
||||
const wheres = {
|
||||
tabEvents: { ...venue, receivedAt: { lt: before } },
|
||||
auditLog: { ...venue, createdAt: { lt: before } },
|
||||
commands: { ...venue, createdAt: { lt: before }, status: { not: 'pending' } },
|
||||
};
|
||||
const run = {
|
||||
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: wheres.tabEvents }) : this.prisma.tabEvent.deleteMany({ where: wheres.tabEvents }).then((r) => r.count)),
|
||||
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: wheres.auditLog }) : this.prisma.auditLog.deleteMany({ where: wheres.auditLog }).then((r) => r.count)),
|
||||
commands: () => (dryRun ? this.prisma.remoteCommand.count({ where: wheres.commands }) : this.prisma.remoteCommand.deleteMany({ where: wheres.commands }).then((r) => r.count)),
|
||||
const tabEvents = { ...venue, receivedAt: { lt: before } };
|
||||
const auditLog = { ...venue, createdAt: { lt: before } };
|
||||
const run: Record<PurgeTarget, () => Promise<number>> = {
|
||||
tabEvents: () => (dryRun ? this.prisma.tabEvent.count({ where: tabEvents }) : this.prisma.tabEvent.deleteMany({ where: tabEvents }).then((r) => r.count)),
|
||||
auditLog: () => (dryRun ? this.prisma.auditLog.count({ where: auditLog }) : this.prisma.auditLog.deleteMany({ where: auditLog }).then((r) => r.count)),
|
||||
};
|
||||
|
||||
const counts: Partial<Record<PurgeTarget, number>> = {};
|
||||
for (const target of new Set(targets)) counts[target] = await run[target]();
|
||||
|
||||
if (!dryRun) {
|
||||
await this.audit.log(venueId ?? actor.venueId, 'client_user', actor.id, 'admin_purged', {
|
||||
targets, olderThanDays, venueId: venueId ?? 'all', counts, by: actor.email,
|
||||
});
|
||||
if (venueId && counts.commands) this.realtime.notify(venueId, 'commands');
|
||||
await this.audit.log({ venueId: venueId ?? null, actor: { type: 'user', id: actor.id }, action: 'admin_purged', details: { targets, olderThanDays, counts } });
|
||||
}
|
||||
return { dryRun, counts };
|
||||
}
|
||||
|
||||
/** Deletes pictures no product uses, after a grace period for terminals that upload before they link. */
|
||||
async cleanupImages(dryRun: boolean, actor: Actor) {
|
||||
const where = { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } };
|
||||
if (dryRun) {
|
||||
const images = await this.prisma.productImage.findMany({ where, select: { id: true } });
|
||||
const [{ bytes }] = images.length === 0 ? [{ bytes: 0n }] : await this.prisma.$queryRaw<{ bytes: bigint }[]>`
|
||||
SELECT COALESCE(SUM(OCTET_LENGTH("bytes")), 0)::bigint AS bytes FROM "ProductImage" WHERE "id" = ANY(${images.map((i) => i.id)})`;
|
||||
return { dryRun: true, count: images.length, bytes: Number(bytes) };
|
||||
async cleanupImages(dryRun: boolean, actor: AuthUser) {
|
||||
const images = await this.prisma.productImage.findMany({
|
||||
where: { products: { none: {} }, createdAt: { lt: new Date(Date.now() - ORPHAN_IMAGE_GRACE_MS) } },
|
||||
select: { id: true, bytes: true },
|
||||
});
|
||||
const bytes = images.reduce((sum, image) => sum + image.bytes, 0);
|
||||
if (!dryRun && images.length > 0) {
|
||||
await this.images.remove(images.map((image) => image.id));
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_images_cleaned', details: { count: images.length } });
|
||||
}
|
||||
const { count } = await this.prisma.productImage.deleteMany({ where });
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_images_cleaned', { count, by: actor.email });
|
||||
return { dryRun: false, count };
|
||||
return { dryRun, count: images.length, bytes };
|
||||
}
|
||||
|
||||
async sendTestEmail(to: string, actor: Actor) {
|
||||
const result = await this.reminders.sendTestEmailTo(to);
|
||||
await this.audit.log(actor.venueId, 'client_user', actor.id, 'admin_test_email_sent', { to, by: actor.email });
|
||||
async sendTestEmail(to: string, actor: AuthUser) {
|
||||
const result = await this.reminders.sendTestEmail(to);
|
||||
await this.audit.log({ venueId: null, actor: { type: 'user', id: actor.id }, action: 'admin_test_email_sent', details: { to } });
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---- Insight
|
||||
|
||||
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }) {
|
||||
const where = {
|
||||
async auditLog(options: { venueId?: string; action?: string; limit: number; offset: number }): Promise<{ entries: AuditEntry[]; total: number }> {
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
...(options.venueId ? { venueId: options.venueId } : {}),
|
||||
...(options.action ? { action: { contains: options.action, mode: 'insensitive' as const } } : {}),
|
||||
...(options.action ? { action: { contains: options.action, mode: 'insensitive' } } : {}),
|
||||
};
|
||||
const [entries, total] = await Promise.all([
|
||||
this.prisma.auditLog.findMany({
|
||||
@@ -523,42 +439,45 @@ export class AdminService {
|
||||
]);
|
||||
|
||||
// Actors are stored as ids; show who they are
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorId))];
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorId).filter((id): id is string => id !== null))];
|
||||
const [users, devices] = await Promise.all([
|
||||
this.prisma.clientUser.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
|
||||
this.prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, email: true } }),
|
||||
this.prisma.device.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }),
|
||||
]);
|
||||
const actorNames = new Map<string, string>([
|
||||
...users.map((u): [string, string] => [u.id, u.email]),
|
||||
...devices.map((d): [string, string] => [d.id, d.name]),
|
||||
]);
|
||||
const actorNames = new Map([...users.map((u) => [u.id, u.email] as const), ...devices.map((d) => [d.id, d.name] as const)]);
|
||||
|
||||
return {
|
||||
total,
|
||||
entries: entries.map(({ venue, details, ...entry }) => ({
|
||||
...entry,
|
||||
venueName: venue.name,
|
||||
actorName: actorNames.get(entry.actorId) ?? null,
|
||||
details: parseDetails(details),
|
||||
entries: entries.map((e) => ({
|
||||
id: e.id,
|
||||
venueId: e.venueId,
|
||||
venueName: e.venue?.name ?? null,
|
||||
actorType: e.actorType,
|
||||
actorId: e.actorId,
|
||||
actorName: e.actorId ? actorNames.get(e.actorId) ?? null : null,
|
||||
action: e.action,
|
||||
details: e.details,
|
||||
createdAt: e.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async health() {
|
||||
const [[size], migrations, tables, stuckCommands] = await Promise.all([
|
||||
async health(): Promise<SystemHealth> {
|
||||
const [[size], migrations, tables, imageBytes] = await Promise.all([
|
||||
this.prisma.$queryRaw<{ bytes: bigint }[]>`SELECT pg_database_size(current_database())::bigint AS bytes`,
|
||||
this.prisma.$queryRaw<{ migration_name: string; finished_at: Date | null; rolled_back_at: Date | null }[]>`
|
||||
SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations" ORDER BY "started_at" ASC`,
|
||||
this.tableCounts(),
|
||||
this.prisma.remoteCommand.count({ where: { status: 'pending' } }),
|
||||
this.imageBytesOnDisk(),
|
||||
]);
|
||||
const applied = migrations.filter((m) => m.finished_at && !m.rolled_back_at);
|
||||
|
||||
return {
|
||||
checkedAt: new Date(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
nodeVersion: process.version,
|
||||
databaseBytes: Number(size.bytes),
|
||||
imageBytes,
|
||||
tables,
|
||||
migrations: {
|
||||
applied: applied.length,
|
||||
@@ -567,56 +486,55 @@ export class AdminService {
|
||||
failed: migrations.filter((m) => !m.finished_at && !m.rolled_back_at).map((m) => m.migration_name),
|
||||
},
|
||||
realtimeConnections: this.realtime.connectionCount(),
|
||||
commandExpiryLastRunAt: this.commandExpiry.lastRunAt,
|
||||
stuckCommands,
|
||||
emailConfigured: this.reminders.configured,
|
||||
emailConfigured: this.resend.configured,
|
||||
};
|
||||
}
|
||||
|
||||
private async tableCounts() {
|
||||
const p = this.prisma;
|
||||
const entries = await Promise.all(Object.entries({
|
||||
const counts: Record<string, () => Promise<number>> = {
|
||||
Venue: () => p.venue.count(),
|
||||
ClientUser: () => p.clientUser.count(),
|
||||
Device: () => p.device.count(),
|
||||
User: () => p.user.count(),
|
||||
RefreshToken: () => p.refreshToken.count(),
|
||||
Device: () => p.device.count(),
|
||||
RegistrationCode: () => p.registrationCode.count(),
|
||||
Customer: () => p.customer.count(),
|
||||
Tab: () => p.tab.count(),
|
||||
TabEvent: () => p.tabEvent.count(),
|
||||
Settlement: () => p.settlement.count(),
|
||||
PlannedTab: () => p.plannedTab.count(),
|
||||
TabReminder: () => p.tabReminder.count(),
|
||||
TabUnit: () => p.tabUnit.count(),
|
||||
Product: () => p.product.count(),
|
||||
ProductImage: () => p.productImage.count(),
|
||||
StockAdjustment: () => p.stockAdjustment.count(),
|
||||
RemoteCommand: () => p.remoteCommand.count(),
|
||||
StockMovement: () => p.stockMovement.count(),
|
||||
TabEvent: () => p.tabEvent.count(),
|
||||
Reminder: () => p.reminder.count(),
|
||||
AuditLog: () => p.auditLog.count(),
|
||||
}).map(async ([table, count]) => ({ table, rows: await count() })));
|
||||
return entries;
|
||||
};
|
||||
return Promise.all(Object.entries(counts).map(async ([table, count]) => ({ table, rows: await count() })));
|
||||
}
|
||||
|
||||
private async imageBytesOnDisk(): Promise<number> {
|
||||
const files = await fs.readdir(imageDir()).catch(() => [] as string[]);
|
||||
const sizes = await Promise.all(files.map((file) => fs.stat(path.join(imageDir(), file)).then((s) => s.size, () => 0)));
|
||||
return sizes.reduce((sum, size) => sum + size, 0);
|
||||
}
|
||||
|
||||
private async findVenue(venueId: string) {
|
||||
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
|
||||
if (!venue) {
|
||||
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
|
||||
}
|
||||
if (!venue) throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found' });
|
||||
return venue;
|
||||
}
|
||||
|
||||
private async findUser(userId: string) {
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
function parseDetails(details: string | null): unknown {
|
||||
if (!details) return null;
|
||||
try {
|
||||
return JSON.parse(details);
|
||||
} catch {
|
||||
return details;
|
||||
private async signOut(userId: string, data: { passwordHash?: string } = {}) {
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({ where: { id: userId }, data: { ...data, sessionsRevokedAt: now } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: now } }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,36 @@
|
||||
import { checkConsistency, VenueSnapshot } from './consistency';
|
||||
|
||||
const now = new Date('2026-10-26T12:00:00Z');
|
||||
const later = new Date('2026-10-27T12:00:00Z');
|
||||
const earlier = new Date('2026-10-25T12:00:00Z');
|
||||
|
||||
const unit = (id: string, removedAt: string | null = null) => ({ id, addedAt: earlier.toISOString(), removedAt });
|
||||
|
||||
function snapshot(overrides: Partial<VenueSnapshot> = {}): VenueSnapshot {
|
||||
return { settlements: [], tabs: [], products: [], pendingCommands: [], devices: [], ...overrides };
|
||||
}
|
||||
|
||||
const codes = (venue: VenueSnapshot) => checkConsistency(venue, now).map((f) => f.code);
|
||||
const now = new Date('2026-10-28T12:00:00Z');
|
||||
const empty: VenueSnapshot = { settlements: [], tabs: [], products: [], devices: [] };
|
||||
|
||||
describe('checkConsistency', () => {
|
||||
it('finds nothing in a venue whose data adds up', () => {
|
||||
const venue = snapshot({
|
||||
settlements: [{
|
||||
id: 's1', customerName: 'Jan', amountInCents: 500, commandId: null,
|
||||
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
|
||||
}],
|
||||
tabs: [{ id: 't1', customerName: 'Jan', lines: [{ id: 'l2', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, units: [unit('u1')] }] }],
|
||||
products: [{ productId: 'p1', name: 'Pils', stock: 10, countedAt: earlier }],
|
||||
pendingCommands: [{ id: 'c1', expiresAt: later, device: { name: 'Bar', revokedAt: null } }],
|
||||
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: later }],
|
||||
});
|
||||
expect(checkConsistency(venue, now)).toEqual([]);
|
||||
it('finds nothing in a venue that adds up', () => {
|
||||
expect(checkConsistency({
|
||||
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 500 }],
|
||||
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: 'Marie' }],
|
||||
products: [{ id: 'p1', name: 'Cola', stock: 3 }],
|
||||
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2027-01-01') }],
|
||||
}, now)).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags a settlement whose amount differs from its drinks', () => {
|
||||
const venue = snapshot({
|
||||
settlements: [{
|
||||
id: 's1', customerName: 'Jan', amountInCents: 750, commandId: null,
|
||||
lines: [{ id: 'l1', productId: 'p1', productName: 'Pils', unitPriceInCents: 250, quantity: 2, units: [] }],
|
||||
}],
|
||||
});
|
||||
const [finding] = checkConsistency(venue, now);
|
||||
expect(finding).toMatchObject({ severity: 'warning', code: 'settlement_amount_mismatch', subjectId: 's1' });
|
||||
expect(finding.message).toContain('7.50');
|
||||
expect(finding.message).toContain('5.00');
|
||||
});
|
||||
it('flags what does not add up, warnings before information', () => {
|
||||
const findings = checkConsistency({
|
||||
...empty,
|
||||
settlements: [{ id: 's1', customerName: 'Jan', amountInCents: 500, drinksInCents: 250 }],
|
||||
tabs: [{ id: 't1', customerName: 'Jan' }, { id: 't2', customerName: ' jan ' }],
|
||||
products: [{ id: 'p1', name: 'Cola', stock: -2 }],
|
||||
devices: [{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: new Date('2026-10-01') }],
|
||||
}, now);
|
||||
|
||||
it('flags one close request paid twice as an error, before anything else', () => {
|
||||
const settlement = { customerName: 'Jan', amountInCents: 0, lines: [], commandId: 'c1' };
|
||||
const venue = snapshot({
|
||||
settlements: [{ ...settlement, id: 's1' }, { ...settlement, id: 's2' }],
|
||||
products: [{ productId: 'p1', name: 'Pils', stock: -2, countedAt: earlier }],
|
||||
});
|
||||
expect(codes(venue)).toEqual(['settlement_duplicate_command', 'stock_negative']);
|
||||
});
|
||||
|
||||
it('flags two open tabs that only differ in case or spacing', () => {
|
||||
const venue = snapshot({
|
||||
tabs: [
|
||||
{ id: 't1', customerName: 'Jan Peeters', lines: [] },
|
||||
{ id: 't2', customerName: ' jan peeters ', lines: [] },
|
||||
],
|
||||
});
|
||||
expect(codes(venue)).toEqual(['tab_duplicate_customer']);
|
||||
});
|
||||
|
||||
it('notes drinks of a product that is gone, but not ones already taken off the tab', () => {
|
||||
const line = { id: 'l1', productId: 'gone', productName: 'Cava', unitPriceInCents: 500 };
|
||||
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1')] }] }] })))
|
||||
.toEqual(['tab_unknown_product']);
|
||||
expect(codes(snapshot({ tabs: [{ id: 't1', customerName: 'Jan', lines: [{ ...line, units: [unit('u1', now.toISOString())] }] }] })))
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it('notes tracked stock that was never counted', () => {
|
||||
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: 4, countedAt: null }] }))).toEqual(['stock_never_counted']);
|
||||
expect(codes(snapshot({ products: [{ productId: 'p1', name: 'Pils', stock: null, countedAt: null }] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags pending commands that expired or wait for an unpaired terminal', () => {
|
||||
const venue = snapshot({
|
||||
pendingCommands: [
|
||||
{ id: 'c1', expiresAt: earlier, device: { name: 'Bar', revokedAt: null } },
|
||||
{ id: 'c2', expiresAt: later, device: { name: 'Old', revokedAt: earlier } },
|
||||
],
|
||||
});
|
||||
expect(checkConsistency(venue, now).map((f) => [f.code, f.subjectId])).toEqual([
|
||||
['command_stuck', 'c1'],
|
||||
['command_revoked_device', 'c2'],
|
||||
expect(findings.map((f) => f.code)).toEqual([
|
||||
'settlement_amount_mismatch', 'tab_duplicate_customer', 'stock_negative', 'device_token_expired',
|
||||
]);
|
||||
});
|
||||
|
||||
it('notes a paired terminal whose token expired, but not an unpaired one', () => {
|
||||
const venue = snapshot({
|
||||
devices: [
|
||||
{ id: 'd1', name: 'Bar', revokedAt: null, tokenExpiresAt: earlier },
|
||||
{ id: 'd2', name: 'Old', revokedAt: earlier, tokenExpiresAt: earlier },
|
||||
],
|
||||
});
|
||||
expect(checkConsistency(venue, now).map((f) => f.subjectId)).toEqual(['d1']);
|
||||
it('ignores the expired token of an unpaired terminal', () => {
|
||||
expect(checkConsistency({
|
||||
...empty,
|
||||
devices: [{ id: 'd1', name: 'Old', revokedAt: new Date('2026-09-01'), tokenExpiresAt: new Date('2026-10-01') }],
|
||||
}, now)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,11 @@
|
||||
import { customerNameKey } from '../planned-tabs/customer-name';
|
||||
import { Line, SettledLine, isActive } from '../tabs/tab-state';
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type Finding = {
|
||||
severity: Severity;
|
||||
// settlement_amount_mismatch | settlement_duplicate_command | tab_duplicate_customer | tab_unknown_product |
|
||||
// stock_negative | stock_never_counted | command_stuck | command_revoked_device | device_token_expired
|
||||
code: string;
|
||||
message: string;
|
||||
// What it is about: a settlement, tab, product, command or terminal id
|
||||
subjectId: string;
|
||||
};
|
||||
import { Finding, FindingSeverity } from '@kooltap/shared';
|
||||
import { customerNameKey } from '../customers/customer-name';
|
||||
|
||||
export type VenueSnapshot = {
|
||||
settlements: { id: string; customerName: string; amountInCents: number; lines: unknown; commandId: string | null }[];
|
||||
tabs: { id: string; customerName: string; lines: unknown }[];
|
||||
products: { productId: string; name: string; stock: number | null; countedAt: Date | null }[];
|
||||
pendingCommands: { id: string; expiresAt: Date; device: { name: string; revokedAt: Date | null } | null }[];
|
||||
/** Each settlement with what its drinks add up to. */
|
||||
settlements: { id: string; customerName: string; amountInCents: number; drinksInCents: number }[];
|
||||
tabs: { id: string; customerName: string }[];
|
||||
products: { id: string; name: string; stock: number | null }[];
|
||||
devices: { id: string; name: string; revokedAt: Date | null; tokenExpiresAt: Date }[];
|
||||
};
|
||||
|
||||
@@ -30,102 +18,43 @@ const euro = (cents: number) => (cents / 100).toFixed(2);
|
||||
export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
|
||||
const findings: Finding[] = [];
|
||||
|
||||
// A settlement's amount is what the terminal charged; its lines are what the server had on the tab. A difference
|
||||
// A settlement's amount is what the terminal charged; its drinks are what the server had on the tab. A difference
|
||||
// means the terminal and the server disagreed about the tab when it was paid
|
||||
for (const settlement of venue.settlements) {
|
||||
const lines = (settlement.lines as SettledLine[] | null) ?? [];
|
||||
const linesTotal = lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0);
|
||||
if (linesTotal !== settlement.amountInCents) {
|
||||
if (settlement.drinksInCents !== settlement.amountInCents) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'settlement_amount_mismatch',
|
||||
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(linesTotal)}`,
|
||||
message: `${settlement.customerName} paid € ${euro(settlement.amountInCents)}, the drinks add up to € ${euro(settlement.drinksInCents)}`,
|
||||
subjectId: settlement.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const byCommand = new Map<string, string[]>();
|
||||
for (const settlement of venue.settlements) {
|
||||
if (!settlement.commandId) continue;
|
||||
byCommand.set(settlement.commandId, [...(byCommand.get(settlement.commandId) ?? []), settlement.id]);
|
||||
}
|
||||
for (const [commandId, ids] of byCommand) {
|
||||
if (ids.length > 1) {
|
||||
findings.push({
|
||||
severity: 'error',
|
||||
code: 'settlement_duplicate_command',
|
||||
message: `One close request was paid ${ids.length} times`,
|
||||
subjectId: commandId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customers are matched on their name everywhere, so two open tabs with one name get mixed up
|
||||
const tabsByName = new Map<string, string[]>();
|
||||
const tabsByName = new Map<string, typeof venue.tabs>();
|
||||
for (const tab of venue.tabs) {
|
||||
const key = customerNameKey(tab.customerName);
|
||||
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab.id]);
|
||||
tabsByName.set(key, [...(tabsByName.get(key) ?? []), tab]);
|
||||
}
|
||||
for (const ids of tabsByName.values()) {
|
||||
if (ids.length > 1) {
|
||||
const name = venue.tabs.find((tab) => tab.id === ids[0])!.customerName;
|
||||
for (const tabs of tabsByName.values()) {
|
||||
if (tabs.length > 1) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'tab_duplicate_customer',
|
||||
message: `${ids.length} open tabs are named ${name}`,
|
||||
subjectId: ids[0],
|
||||
message: `${tabs.length} open tabs are named ${tabs[0].customerName}`,
|
||||
subjectId: tabs[0].id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const productIds = new Set(venue.products.map((p) => p.productId));
|
||||
for (const tab of venue.tabs) {
|
||||
for (const line of (tab.lines as Line[] | null) ?? []) {
|
||||
if (line.productId && !productIds.has(line.productId) && line.units.some(isActive)) {
|
||||
findings.push({
|
||||
severity: 'info',
|
||||
code: 'tab_unknown_product',
|
||||
message: `${tab.customerName}'s tab has ${line.productName}, which is not in the catalog`,
|
||||
subjectId: tab.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const product of venue.products) {
|
||||
if (product.stock !== null && product.stock < 0) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'stock_negative',
|
||||
message: `${product.name} has a stock of ${product.stock}: more was sold than counted`,
|
||||
subjectId: product.productId,
|
||||
});
|
||||
}
|
||||
if (product.stock !== null && product.countedAt === null) {
|
||||
findings.push({
|
||||
severity: 'info',
|
||||
code: 'stock_never_counted',
|
||||
message: `${product.name}'s stock is tracked but was never counted`,
|
||||
subjectId: product.productId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const command of venue.pendingCommands) {
|
||||
if (command.device?.revokedAt) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'command_revoked_device',
|
||||
message: `A close request waits for ${command.device.name}, which is unpaired`,
|
||||
subjectId: command.id,
|
||||
});
|
||||
} else if (command.expiresAt <= now) {
|
||||
findings.push({
|
||||
severity: 'warning',
|
||||
code: 'command_stuck',
|
||||
message: 'A close request expired but is still pending',
|
||||
subjectId: command.id,
|
||||
subjectId: product.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -141,6 +70,6 @@ export function checkConsistency(venue: VenueSnapshot, now: Date): Finding[] {
|
||||
}
|
||||
}
|
||||
|
||||
const order: Record<Severity, number> = { error: 0, warning: 1, info: 2 };
|
||||
const order: Record<FindingSeverity, number> = { error: 0, warning: 1, info: 2 };
|
||||
return findings.sort((a, b) => order[a.severity] - order[b.severity]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,40 +1,9 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { ensureDefaultCustomers } from '../planned-tabs/default-customers';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* What a superadmin can wipe from a venue. Users, terminals and the product catalog always stay: a reset is for
|
||||
* starting a new event or season with the same bar.
|
||||
*/
|
||||
export const RESET_SCOPES = [
|
||||
// The open tabs. Terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
|
||||
'tabs',
|
||||
// Paid tabs: the sales history
|
||||
'settlements',
|
||||
// What the terminals reported. Also the record of which events were applied, so a terminal that re-sends an old
|
||||
// event would apply it again: only wipe these when every terminal has synced
|
||||
'tabEvents',
|
||||
// Planned customers and the reminders sent to them
|
||||
'plannedTabs',
|
||||
// Every tracked product's stock is set to one count
|
||||
'stock',
|
||||
// Stock deliveries, breakage and counts made on the dashboard
|
||||
'stockHistory',
|
||||
// Command history
|
||||
'commands',
|
||||
'auditLog',
|
||||
] as const;
|
||||
|
||||
export type ResetScope = (typeof RESET_SCOPES)[number];
|
||||
import { ResetScope } from '@kooltap/shared';
|
||||
import { Tx } from '../prisma/prisma.service';
|
||||
import { ensureDefaultCustomers } from '../customers/default-customers';
|
||||
|
||||
export type ResetCounts = Partial<Record<ResetScope, number>>;
|
||||
|
||||
/** Locks the venue like a sync does, so no terminal changes it halfway through. */
|
||||
export async function lockVenue(tx: Tx, venueId: string) {
|
||||
await tx.$queryRaw`SELECT "id" FROM "Venue" WHERE "id" = ${venueId} FOR UPDATE`;
|
||||
}
|
||||
|
||||
/** How many rows each scope would remove (or, for stock, how many products it would set). */
|
||||
export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]): Promise<ResetCounts> {
|
||||
const where = { venueId };
|
||||
@@ -42,10 +11,9 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
|
||||
tabs: () => tx.tab.count({ where }),
|
||||
settlements: () => tx.settlement.count({ where }),
|
||||
tabEvents: () => tx.tabEvent.count({ where }),
|
||||
plannedTabs: () => tx.plannedTab.count({ where }),
|
||||
customers: () => tx.customer.count({ where }),
|
||||
stock: () => tx.product.count({ where: { venueId, stock: { not: null } } }),
|
||||
stockHistory: () => tx.stockAdjustment.count({ where }),
|
||||
commands: () => tx.remoteCommand.count({ where }),
|
||||
stockHistory: () => tx.stockMovement.count({ where }),
|
||||
auditLog: () => tx.auditLog.count({ where }),
|
||||
};
|
||||
const counts: ResetCounts = {};
|
||||
@@ -54,14 +22,21 @@ export async function countReset(tx: Tx, venueId: string, scopes: ResetScope[]):
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes [scopes] of a venue inside [tx], which must have locked it. [stockValue] is the count every tracked product
|
||||
* gets; it is recorded as an applied stock count, after the stock history is wiped, so the history explains it.
|
||||
* Wipes [scopes] of a venue inside [tx], which must have locked it. Users, terminals and the catalog always stay: a
|
||||
* reset is for starting a new event or season with the same bar.
|
||||
*
|
||||
* - tabs: terminals drop them on their next sync; drinks a terminal still has to send bring a tab back
|
||||
* - tabEvents: also the record of which events were applied, so a terminal that sends an old event again would
|
||||
* apply it again: only wipe these when every terminal has synced
|
||||
* - customers: and the reminders sent to them
|
||||
* - stock: every tracked product gets [stockValue] as a count, recorded after the stock history is wiped so the
|
||||
* history explains it
|
||||
*/
|
||||
export async function resetVenue(
|
||||
tx: Tx,
|
||||
venueId: string,
|
||||
scopes: ResetScope[],
|
||||
options: { stockValue: number; actor: { id: string; name: string }; now: Date },
|
||||
options: { stockValue: number; userId: string; now: Date },
|
||||
): Promise<ResetCounts> {
|
||||
const where = { venueId };
|
||||
const counts: ResetCounts = {};
|
||||
@@ -70,65 +45,25 @@ export async function resetVenue(
|
||||
if (has('tabs')) counts.tabs = (await tx.tab.deleteMany({ where })).count;
|
||||
if (has('settlements')) counts.settlements = (await tx.settlement.deleteMany({ where })).count;
|
||||
if (has('tabEvents')) counts.tabEvents = (await tx.tabEvent.deleteMany({ where })).count;
|
||||
if (has('plannedTabs')) {
|
||||
await tx.tabReminder.deleteMany({ where });
|
||||
counts.plannedTabs = (await tx.plannedTab.deleteMany({ where })).count;
|
||||
if (has('customers')) {
|
||||
await tx.reminder.deleteMany({ where });
|
||||
counts.customers = (await tx.customer.deleteMany({ where })).count;
|
||||
}
|
||||
// The Free Drinks tab is always there: planned again, and opened on the next sync
|
||||
if (has('tabs') || has('plannedTabs')) await ensureDefaultCustomers(tx, venueId);
|
||||
if (has('stockHistory')) counts.stockHistory = (await tx.stockAdjustment.deleteMany({ where })).count;
|
||||
// The Free Drinks tab is always there: added again, and opened on the next sync
|
||||
if (has('tabs') || has('customers')) await ensureDefaultCustomers(tx, venueId);
|
||||
if (has('stockHistory')) counts.stockHistory = (await tx.stockMovement.deleteMany({ where })).count;
|
||||
if (has('stock')) {
|
||||
const { now, actor, stockValue } = options;
|
||||
const tracked = await tx.product.findMany({
|
||||
where: { venueId, stock: { not: null } },
|
||||
select: { productId: true, name: true },
|
||||
});
|
||||
await tx.product.updateMany({
|
||||
where: { venueId, stock: { not: null } },
|
||||
data: { stock: stockValue, countedAt: now },
|
||||
});
|
||||
if (tracked.length > 0) {
|
||||
await tx.stockAdjustment.createMany({
|
||||
const { now, userId, stockValue } = options;
|
||||
const tracked = await tx.product.findMany({ where: { venueId, stock: { not: null } }, select: { id: true } });
|
||||
await tx.product.updateMany({ where: { venueId, stock: { not: null } }, data: { stock: stockValue, countedAt: now } });
|
||||
await tx.stockMovement.createMany({
|
||||
data: tracked.map((product) => ({
|
||||
venueId,
|
||||
productId: product.productId,
|
||||
productName: product.name,
|
||||
type: 'set',
|
||||
quantity: stockValue,
|
||||
note: 'Stock reset',
|
||||
requestedBy: actor.name,
|
||||
requestedById: actor.id,
|
||||
status: 'applied',
|
||||
stockAfter: stockValue,
|
||||
createdAt: now,
|
||||
processedAt: now,
|
||||
venueId, productId: product.id, type: 'count' as const, quantity: stockValue, stockAfter: stockValue, note: 'Stock reset', userId, createdAt: now,
|
||||
})),
|
||||
});
|
||||
}
|
||||
counts.stock = tracked.length;
|
||||
}
|
||||
if (has('commands')) counts.commands = (await tx.remoteCommand.deleteMany({ where })).count;
|
||||
if (has('auditLog')) counts.auditLog = (await tx.auditLog.deleteMany({ where })).count;
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/** Deletes a venue and everything in it, users and terminals included. */
|
||||
export async function deleteVenue(tx: Tx, venueId: string) {
|
||||
const where = { venueId };
|
||||
const users = await tx.clientUser.findMany({ where, select: { id: true } });
|
||||
await tx.auditLog.deleteMany({ where });
|
||||
await tx.tabEvent.deleteMany({ where });
|
||||
await tx.tabReminder.deleteMany({ where });
|
||||
await tx.plannedTab.deleteMany({ where });
|
||||
await tx.remoteCommand.deleteMany({ where });
|
||||
await tx.settlement.deleteMany({ where });
|
||||
await tx.tab.deleteMany({ where });
|
||||
await tx.stockAdjustment.deleteMany({ where });
|
||||
await tx.product.deleteMany({ where });
|
||||
await tx.registrationCode.deleteMany({ where });
|
||||
await tx.device.deleteMany({ where });
|
||||
await tx.refreshToken.deleteMany({ where: { userId: { in: users.map((u) => u.id) } } });
|
||||
await tx.clientUser.deleteMany({ where });
|
||||
await tx.venue.delete({ where: { id: venueId } });
|
||||
}
|
||||
|
||||
+13
-24
@@ -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 {}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { Prisma, PrismaService, Tx } from '../prisma/prisma.service';
|
||||
import { ActorType } from '../generated/prisma/client';
|
||||
|
||||
export type AuditEntry = {
|
||||
/** Null for actions that are not about one venue. */
|
||||
venueId: string | null;
|
||||
actor: { type: ActorType; id: string | null };
|
||||
action: string;
|
||||
details?: Prisma.InputJsonObject;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
@@ -8,18 +16,21 @@ export class AuditService {
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async log(venueId: string, actorType: string, actorId: string, action: string, details?: Record<string, any>) {
|
||||
/** Records [entry]; inside [tx] it is part of that change, otherwise a failure is only logged. */
|
||||
async log(entry: AuditEntry, tx?: Tx) {
|
||||
const data = {
|
||||
venueId: entry.venueId,
|
||||
actorType: entry.actor.type,
|
||||
actorId: entry.actor.id,
|
||||
action: entry.action,
|
||||
details: entry.details,
|
||||
};
|
||||
if (tx) {
|
||||
await tx.auditLog.create({ data });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
actorType,
|
||||
actorId,
|
||||
action,
|
||||
details: details ? JSON.stringify(details) : null,
|
||||
},
|
||||
});
|
||||
await this.prisma.auditLog.create({ data });
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write audit log: ${error}`);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,65 @@
|
||||
import { Controller, Post, Body, HttpCode, HttpStatus, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { changePasswordSchema, loginSchema, Me } from '@kooltap/shared';
|
||||
import { zod } from '../common/zod.pipe';
|
||||
import { Authorized, AuthUser, CurrentUser } from '../common/auth';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh-token.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { clearAuthCookies, REFRESH_COOKIE, setAuthCookies } from './cookies';
|
||||
|
||||
@ApiTags('Authentication')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private authService: AuthService) {}
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
async login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto.email, dto.password);
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async login(@Body(zod(loginSchema)) dto: z.output<typeof loginSchema>, @Res({ passthrough: true }) res: Response): Promise<Me> {
|
||||
const { me, tokens } = await this.auth.login(dto.email, dto.password);
|
||||
setAuthCookies(res, tokens);
|
||||
return me;
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
async refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refresh(dto.refreshToken);
|
||||
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response): Promise<Me> {
|
||||
try {
|
||||
const { me, tokens } = await this.auth.refresh(req.cookies?.[REFRESH_COOKIE]);
|
||||
setAuthCookies(res, tokens);
|
||||
return me;
|
||||
} catch (error) {
|
||||
clearAuthCookies(res);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Logout and revoke refresh token' })
|
||||
async logout(@Body() dto: RefreshTokenDto) {
|
||||
await this.authService.logout(dto.refreshToken);
|
||||
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||
await this.auth.logout(req.cookies?.[REFRESH_COOKIE]);
|
||||
clearAuthCookies(res);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@Authorized()
|
||||
me(@CurrentUser() user: AuthUser): Promise<Me> {
|
||||
return this.auth.me(user.id);
|
||||
}
|
||||
|
||||
/** Signs out the user's other sessions; this one carries on with new tokens. */
|
||||
@Post('change-password')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: "Change the signed-in user's password; signs out their other sessions and returns new tokens" })
|
||||
async changePassword(@Body() dto: ChangePasswordDto, @Req() req: any) {
|
||||
return this.authService.changePassword(req.user.id, dto.currentPassword, dto.newPassword);
|
||||
@Authorized()
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body(zod(changePasswordSchema)) dto: z.output<typeof changePasswordSchema>,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<Me> {
|
||||
const { me, tokens } = await this.auth.changePassword(user.id, dto.currentPassword, dto.newPassword);
|
||||
setAuthCookies(res, tokens);
|
||||
return me;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,262 +1,117 @@
|
||||
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { Me } from '@kooltap/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { TOKEN_EXPIRY_DAYS, TOKEN_BYTE_LENGTH, TOKEN_PREFIX_LENGTH } from '@kooltap/shared';
|
||||
import { AuthUser } from '../common/auth';
|
||||
import { REFRESH_TOKEN_TTL_MS } from './cookies';
|
||||
|
||||
export const sha256 = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
export const hashPassword = (password: string) => bcrypt.hash(password, 12);
|
||||
|
||||
// Compared against when there is no such user, so the response time doesn't tell which emails exist
|
||||
const NO_USER_HASH = bcrypt.hashSync('no user', 12);
|
||||
|
||||
type Tokens = { accessToken: string; refreshToken: string };
|
||||
|
||||
const userSelect = {
|
||||
id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true, venue: { select: { name: true } },
|
||||
} as const;
|
||||
|
||||
/** Whether a token was issued before the user's sessions were revoked. iat is in whole seconds. */
|
||||
export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
|
||||
return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private jwt: JwtService,
|
||||
private audit: AuditService,
|
||||
) {}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
async login(email: string, password: string): Promise<{ me: Me; tokens: Tokens }> {
|
||||
const user = await this.prisma.user.findUnique({ where: { email }, select: { ...userSelect, passwordHash: true } });
|
||||
const valid = await bcrypt.compare(password, user?.passwordHash ?? NO_USER_HASH);
|
||||
if (!user || !valid) {
|
||||
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!valid) {
|
||||
throw new UnauthorizedException({ code: 'invalid_credentials', message: 'Invalid email or password' });
|
||||
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
/** Rotates the refresh token: of two requests with the same token, only one gets new tokens. */
|
||||
async refresh(refreshToken: string | undefined): Promise<{ me: Me; tokens: Tokens }> {
|
||||
const invalid = new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
if (!refreshToken) throw invalid;
|
||||
|
||||
const now = new Date();
|
||||
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: sha256(refreshToken) } });
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < now) throw invalid;
|
||||
const { count } = await this.prisma.refreshToken.updateMany({ where: { id: stored.id, revokedAt: null }, data: { revokedAt: now } });
|
||||
if (count === 0) throw invalid;
|
||||
|
||||
const user = await this.prisma.user.findUnique({ where: { id: stored.userId }, select: userSelect });
|
||||
if (!user) throw invalid;
|
||||
return { me: toMe(user), tokens: await this.issueTokens(user.id) };
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const stored = await this.prisma.refreshToken.findUnique({ where: { tokenHash: hash } });
|
||||
|
||||
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
// Revoke first and atomically: of two requests with the same token, only one gets new tokens
|
||||
const claimed = await this.prisma.refreshToken.updateMany({
|
||||
where: { id: stored.id, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
throw new UnauthorizedException({ code: 'invalid_refresh_token', message: 'Invalid or expired refresh token' });
|
||||
}
|
||||
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { id: stored.userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
|
||||
}
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
async logout(refreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
async logout(refreshToken: string | undefined) {
|
||||
if (!refreshToken) return;
|
||||
await this.prisma.refreshToken.updateMany({
|
||||
where: { tokenHash: hash, revokedAt: null },
|
||||
where: { tokenHash: sha256(refreshToken), revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the signed-in user's password. Every session of the user is signed out, also this one, which gets new
|
||||
* tokens instead.
|
||||
*/
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string) {
|
||||
const user = await this.prisma.clientUser.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new UnauthorizedException({ code: 'user_not_found', message: 'User not found' });
|
||||
async me(userId: string): Promise<Me> {
|
||||
return toMe(await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: userSelect }));
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
/** Changes the user's password and signs out every session; this one gets new tokens. */
|
||||
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<{ me: Me; tokens: Tokens }> {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId }, select: { ...userSelect, passwordHash: true } });
|
||||
if (!(await bcrypt.compare(currentPassword, user.passwordHash))) {
|
||||
throw new BadRequestException({ code: 'wrong_password', message: 'The current password is not correct' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, 10);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.clientUser.update({ where: { id: user.id }, data: { passwordHash } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } }),
|
||||
this.prisma.user.update({ where: { id: userId }, data: { passwordHash: await hashPassword(newPassword) } }),
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
|
||||
]);
|
||||
|
||||
await this.auditService.log(user.venueId, 'client_user', user.id, 'password_changed');
|
||||
|
||||
return this.issueTokens(user);
|
||||
}
|
||||
|
||||
private async issueTokens(user: { id: string; email: string; name: string; role: string; venueId: string }) {
|
||||
const payload = { sub: user.id, email: user.email, role: user.role, venueId: user.venueId };
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
|
||||
const refreshToken = crypto.randomBytes(32).toString('hex');
|
||||
const refreshHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
await this.prisma.refreshToken.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
tokenHash: refreshHash,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
venueId: user.venueId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async generateDeviceToken(): Promise<{ rawToken: string; hash: string; prefix: string; expiresAt: Date }> {
|
||||
const rawBytes = crypto.randomBytes(TOKEN_BYTE_LENGTH);
|
||||
const rawToken = 'dev_' + rawBytes.toString('base64url');
|
||||
const hash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const prefix = rawToken.substring(0, TOKEN_PREFIX_LENGTH);
|
||||
const expiresAt = new Date(Date.now() + TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
return { rawToken, hash, prefix, expiresAt };
|
||||
}
|
||||
|
||||
async registerDevice(venueId: string, name: string, registrationCode: string) {
|
||||
const code = await this.prisma.registrationCode.findUnique({ where: { code: registrationCode } });
|
||||
|
||||
if (!code || code.usedAt || code.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
|
||||
}
|
||||
|
||||
if (code.venueId !== venueId) {
|
||||
throw new UnauthorizedException({ code: 'venue_mismatch', message: 'Registration code does not match venue' });
|
||||
}
|
||||
|
||||
// Claim the code atomically so it can only ever be used once
|
||||
const claimed = await this.prisma.registrationCode.updateMany({
|
||||
where: { id: code.id, usedAt: null },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
throw new UnauthorizedException({ code: 'invalid_registration_code', message: 'Invalid or expired registration code' });
|
||||
}
|
||||
|
||||
const { rawToken, hash, prefix, expiresAt } = await this.generateDeviceToken();
|
||||
// A venue can have several terminals: every pairing adds one. A terminal that is replaced is unpaired on the
|
||||
// dashboard
|
||||
const deviceId = uuidv4();
|
||||
await this.prisma.device.create({
|
||||
data: {
|
||||
id: deviceId,
|
||||
venueId,
|
||||
name,
|
||||
tokenHash: hash,
|
||||
tokenPrefix: prefix,
|
||||
tokenExpiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await this.auditService.log(venueId, 'device', deviceId, 'device_registered', { deviceName: name });
|
||||
|
||||
this.realtime.notify(venueId, 'device');
|
||||
|
||||
return { deviceId, token: rawToken, tokenExpiresAt: expiresAt };
|
||||
}
|
||||
|
||||
async createUser(venueId: string, email: string, password: string, name: string, role: string) {
|
||||
const venue = await this.prisma.venue.findUnique({ where: { id: venueId } });
|
||||
if (!venue) {
|
||||
throw new NotFoundException({ code: 'venue_not_found', message: 'Venue not found. Your session may be stale — try logging out and back in.' });
|
||||
}
|
||||
|
||||
const existingUser = await this.prisma.clientUser.findUnique({ where: { email } });
|
||||
if (existingUser) {
|
||||
throw new ConflictException({ code: 'email_exists', message: 'A user with this email already exists' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
const user = await this.prisma.clientUser.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
email,
|
||||
passwordHash,
|
||||
name,
|
||||
role,
|
||||
},
|
||||
select: { id: true, email: true, name: true, role: true, venueId: true, createdAt: true },
|
||||
});
|
||||
|
||||
this.realtime.notify(venueId, 'users');
|
||||
|
||||
return user;
|
||||
await this.audit.log({ venueId: user.venueId, actor: { type: 'user', id: userId }, action: 'password_changed' });
|
||||
return { me: toMe(user), tokens: await this.issueTokens(userId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a dashboard user and signs them out: their refresh tokens are revoked, so they are out once their access
|
||||
* token runs out. An admin cannot delete themself, so a venue always keeps an admin.
|
||||
* The user an access token belongs to, as the database has them now: a deleted or signed-out user loses access at
|
||||
* once, and a changed role applies right away. Null for a missing, invalid or revoked token.
|
||||
*/
|
||||
async deleteUser(venueId: string, userId: string, actor: { id: string }) {
|
||||
if (userId === actor.id) {
|
||||
throw new BadRequestException({ code: 'cannot_delete_self', message: 'You cannot delete your own account' });
|
||||
async userFromAccessToken(token: string | undefined): Promise<AuthUser | null> {
|
||||
if (!token) return null;
|
||||
let payload: { sub: string; iat?: number };
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync(token);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: payload.sub }, select: userSelect });
|
||||
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) return null;
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role, venueId: user.venueId };
|
||||
}
|
||||
|
||||
const user = await this.prisma.clientUser.findFirst({ where: { id: userId, venueId } });
|
||||
if (!user) {
|
||||
throw new NotFoundException({ code: 'user_not_found', message: 'User not found' });
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.refreshToken.updateMany({ where: { userId, revokedAt: null }, data: { revokedAt: new Date() } }),
|
||||
this.prisma.clientUser.delete({ where: { id: userId } }),
|
||||
]);
|
||||
|
||||
await this.auditService.log(venueId, 'client_user', actor.id, 'user_deleted', { userId, email: user.email, role: user.role });
|
||||
|
||||
this.realtime.notify(venueId, 'users');
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async validateDevice(deviceId: string, token: string): Promise<any> {
|
||||
const hash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
|
||||
if (!device || device.tokenHash !== hash) {
|
||||
throw new UnauthorizedException({ code: 'invalid_device_token', message: 'Invalid device token' });
|
||||
}
|
||||
|
||||
if (device.revokedAt) {
|
||||
throw new UnauthorizedException({ code: 'device_revoked', message: 'Device has been revoked' });
|
||||
}
|
||||
|
||||
if (device.tokenExpiresAt < new Date()) {
|
||||
throw new UnauthorizedException({ code: 'token_expired', message: 'Device token has expired' });
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
async generateRegistrationCode(venueId: string): Promise<{ code: string; expiresAt: Date }> {
|
||||
const code = crypto.randomBytes(3).toString('hex').toUpperCase();
|
||||
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
|
||||
|
||||
await this.prisma.registrationCode.create({
|
||||
data: {
|
||||
venueId,
|
||||
code,
|
||||
expiresAt,
|
||||
},
|
||||
private async issueTokens(userId: string): Promise<Tokens> {
|
||||
const refreshToken = randomBytes(32).toString('base64url');
|
||||
await this.prisma.refreshToken.create({
|
||||
data: { userId, tokenHash: sha256(refreshToken), expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS) },
|
||||
});
|
||||
|
||||
return { code, expiresAt };
|
||||
return { accessToken: await this.jwt.signAsync({ sub: userId }), refreshToken };
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CookieOptions, Response } from 'express';
|
||||
|
||||
export const ACCESS_COOKIE = 'kooltap_access';
|
||||
export const REFRESH_COOKIE = 'kooltap_refresh';
|
||||
|
||||
export const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
|
||||
export const REFRESH_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Tokens live in httpOnly cookies on the API's host, so no script on the dashboard can read them. SameSite=Lax: the
|
||||
* dashboard and the API must share a site (kooltap.com and api.kooltap.com, or localhost with two ports).
|
||||
*/
|
||||
function options(maxAge: number, path: string): CookieOptions {
|
||||
return { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production', path, maxAge };
|
||||
}
|
||||
|
||||
// The refresh token is only sent to the endpoints that need it
|
||||
const REFRESH_PATH = '/api/v1/auth';
|
||||
|
||||
export function setAuthCookies(res: Response, tokens: { accessToken: string; refreshToken: string }) {
|
||||
res.cookie(ACCESS_COOKIE, tokens.accessToken, options(ACCESS_TOKEN_TTL_MS, '/'));
|
||||
res.cookie(REFRESH_COOKIE, tokens.refreshToken, options(REFRESH_TOKEN_TTL_MS, REFRESH_PATH));
|
||||
}
|
||||
|
||||
export function clearAuthCookies(res: Response) {
|
||||
res.clearCookie(ACCESS_COOKIE, { ...options(0, '/'), maxAge: undefined });
|
||||
res.clearCookie(REFRESH_COOKIE, { ...options(0, REFRESH_PATH), maxAge: undefined });
|
||||
}
|
||||
|
||||
/** A cookie from a raw Cookie header, for the websocket handshake. */
|
||||
export function cookieFromHeader(header: string | undefined, name: string): string | null {
|
||||
for (const part of header?.split(';') ?? []) {
|
||||
const at = part.indexOf('=');
|
||||
if (at > 0 && part.slice(0, at).trim() === name) return decodeURIComponent(part.slice(at + 1).trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ example: 'OldPass123!' })
|
||||
@IsString()
|
||||
currentPassword: string;
|
||||
|
||||
@ApiProperty({ example: 'NewPass123!' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
newPassword: string;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({ example: 'admin@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Admin123!' })
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RefreshTokenDto {
|
||||
@ApiProperty({ example: 'refresh-token-value' })
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceAuthGuard extends AuthGuard('device') {
|
||||
handleRequest(err: any, device: any, info: any, context: ExecutionContext) {
|
||||
if (err || !device) {
|
||||
throw err || new (require('@nestjs/common').UnauthorizedException)({ code: 'unauthorized', message: 'Invalid device authentication' });
|
||||
}
|
||||
return device;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
handleRequest(err: any, user: any, info: any, context: ExecutionContext) {
|
||||
if (err || !user) {
|
||||
throw err || new (require('@nestjs/common').UnauthorizedException)({ code: 'unauthorized', message: 'Authentication required' });
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy } from 'passport-custom';
|
||||
import { Request } from 'express';
|
||||
import { AuthService } from '../auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class DeviceStrategy extends PassportStrategy(Strategy, 'device') {
|
||||
constructor(private authService: AuthService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async validate(request: Request): Promise<any> {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException({ code: 'missing_token', message: 'Missing device token' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const deviceId = request.params.deviceId || request.body?.deviceId;
|
||||
|
||||
if (!deviceId) {
|
||||
throw new UnauthorizedException({ code: 'missing_device_id', message: 'Missing device ID' });
|
||||
}
|
||||
|
||||
const device = await this.authService.validateDevice(deviceId, token);
|
||||
return device;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { issuedBefore, JwtStrategy } from './jwt.strategy';
|
||||
|
||||
const mona = { id: 'user-1', email: 'mona@example.com', name: 'Mona', role: 'manager', venueId: 'venue-1' };
|
||||
|
||||
function strategy(users: (typeof mona & { sessionsRevokedAt?: Date | null })[]) {
|
||||
const prisma = { clientUser: { findUnique: async ({ where }: any) => users.find((u) => u.id === where.id) ?? null } };
|
||||
return new JwtStrategy(prisma as any);
|
||||
}
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
it('takes the user as it is now, not as it was when the token was issued', async () => {
|
||||
const user = await strategy([{ ...mona, role: 'admin' }]).validate({ sub: 'user-1', email: mona.email, role: 'manager', venueId: 'venue-1' });
|
||||
expect(user).toEqual({ ...mona, role: 'admin' });
|
||||
});
|
||||
|
||||
it('refuses the token of a deleted user', async () => {
|
||||
await expect(strategy([]).validate({ sub: 'user-1', role: 'admin', venueId: 'venue-1' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('refuses a token issued before the user was signed out everywhere, and accepts a newer one', async () => {
|
||||
const signedOutAt = new Date('2026-10-26T12:00:00Z');
|
||||
const users = [{ ...mona, sessionsRevokedAt: signedOutAt }];
|
||||
const before = Math.floor(signedOutAt.getTime() / 1000) - 60;
|
||||
const after = Math.floor(signedOutAt.getTime() / 1000) + 60;
|
||||
|
||||
await expect(strategy(users).validate({ sub: 'user-1', iat: before })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(await strategy(users).validate({ sub: 'user-1', iat: after })).toEqual(mona);
|
||||
});
|
||||
});
|
||||
|
||||
describe('issuedBefore', () => {
|
||||
it('only refuses tokens from before the sign-out', () => {
|
||||
const at = new Date('2026-10-26T12:00:00.500Z');
|
||||
const second = Math.floor(at.getTime() / 1000);
|
||||
expect(issuedBefore({ iat: second - 1 }, at)).toBe(true);
|
||||
expect(issuedBefore({ iat: second }, at)).toBe(false);
|
||||
expect(issuedBefore({ iat: second - 1 }, null)).toBe(false);
|
||||
expect(issuedBefore({ iat: second - 1 }, undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { getJwtSecret } from '../jwt-secret';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(private prisma: PrismaService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: getJwtSecret(),
|
||||
});
|
||||
}
|
||||
|
||||
// Looked up on every request: a deleted or signed-out user loses access at once instead of when the token expires,
|
||||
// and a changed role or name applies right away
|
||||
async validate(payload: any) {
|
||||
const user = await this.prisma.clientUser.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, email: true, name: true, role: true, venueId: true, sessionsRevokedAt: true },
|
||||
});
|
||||
if (!user || issuedBefore(payload, user.sessionsRevokedAt)) {
|
||||
throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
|
||||
}
|
||||
const { sessionsRevokedAt, ...rest } = user;
|
||||
return rest;
|
||||
}
|
||||
}
|
||||
/** Whether the token was issued before the user's sessions were revoked. iat is in whole seconds. */
|
||||
export function issuedBefore(payload: { iat?: number }, revokedAt: Date | null | undefined): boolean {
|
||||
return revokedAt != null && (payload.iat ?? 0) < Math.floor(revokedAt.getTime() / 1000);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ACCESS_COOKIE } from './cookies';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/** The dashboard user from the access token cookie; 401 when it is missing, expired or revoked. */
|
||||
@Injectable()
|
||||
export class UserAuthGuard implements CanActivate {
|
||||
constructor(private auth: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = await this.auth.userFromAccessToken(request.cookies?.[ACCESS_COOKIE]);
|
||||
if (!user) throw new UnauthorizedException({ code: 'unauthorized', message: 'Authentication required' });
|
||||
request.user = user;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Creates the first venue and its admin on a new database. Does nothing when there is a venue already.
|
||||
*
|
||||
* node dist/cli/setup.js "<venue name>" <admin email> <admin password>
|
||||
* (or SETUP_VENUE_NAME, SETUP_ADMIN_EMAIL, SETUP_ADMIN_PASSWORD)
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { hashPassword } from '../auth/auth.service';
|
||||
import { createPrismaClient } from '../prisma/prisma.service';
|
||||
import { ensureDefaultCustomers } from '../customers/default-customers';
|
||||
|
||||
async function main() {
|
||||
const [venueName = process.env.SETUP_VENUE_NAME, email = process.env.SETUP_ADMIN_EMAIL, password = process.env.SETUP_ADMIN_PASSWORD] = process.argv.slice(2);
|
||||
if (!venueName || !email || !password) {
|
||||
throw new Error('Usage: setup "<venue name>" <admin email> <admin password>');
|
||||
}
|
||||
if (password.length < 10) throw new Error('The admin password must be at least 10 characters');
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
try {
|
||||
const venues = await prisma.venue.count();
|
||||
if (venues > 0) {
|
||||
console.log(`The database already has ${venues} venue(s); nothing to do.`);
|
||||
return;
|
||||
}
|
||||
const venue = await prisma.$transaction(async (tx) => {
|
||||
const created = await tx.venue.create({ data: { name: venueName } });
|
||||
await ensureDefaultCustomers(tx, created.id);
|
||||
await tx.user.create({
|
||||
data: { venueId: created.id, email: email.trim().toLowerCase(), name: 'Admin', role: 'admin', passwordHash: await hashPassword(password) },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
console.log(`Created ${venue.name} with admin ${email}. Sign in to the dashboard with those credentials.`);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`Setup failed: ${error.message ?? error}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Superadmins run KoolTap itself, above the venues. There is no other way to get the first one: the dashboard only lets a
|
||||
* superadmin hand out the role.
|
||||
*
|
||||
* node dist/cli/superadmin.js <email> makes an existing user a superadmin
|
||||
* node dist/cli/superadmin.js <email> --create "<name>" <password> adds a new superadmin
|
||||
* node dist/cli/superadmin.js <email> --revoke <venue id> makes a superadmin an admin of that venue
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { hashPassword } from '../auth/auth.service';
|
||||
import { createPrismaClient } from '../prisma/prisma.service';
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
async function main() {
|
||||
const [rawEmail, flag, ...rest] = process.argv.slice(2);
|
||||
if (!rawEmail) throw new Error('Usage: superadmin <email> [--create "<name>" <password> | --revoke <venue id>]');
|
||||
const email = rawEmail.trim().toLowerCase();
|
||||
|
||||
const prisma = createPrismaClient();
|
||||
try {
|
||||
if (flag === '--create') {
|
||||
const [name, password] = rest;
|
||||
if (!name || !password || password.length < 10) throw new Error('Give a name and a password of at least 10 characters');
|
||||
if (await prisma.user.findUnique({ where: { email } })) throw new Error(`${email} exists already: leave out --create to promote them`);
|
||||
await prisma.user.create({ data: { email, name, role: 'superadmin', venueId: null, passwordHash: await hashPassword(password) } });
|
||||
console.log(`Added superadmin ${email}`);
|
||||
} else if (flag === '--revoke') {
|
||||
const [venueId] = rest;
|
||||
if (!venueId) throw new Error('Give the venue the user becomes an admin of');
|
||||
const venue = UUID.test(venueId) ? await prisma.venue.findUnique({ where: { id: venueId } }) : null;
|
||||
if (!venue) throw new Error(`No venue with id ${venueId}`);
|
||||
const { count } = await prisma.user.updateMany({ where: { email, role: 'superadmin' }, data: { role: 'admin', venueId } });
|
||||
if (count === 0) throw new Error(`No superadmin with email ${email}`);
|
||||
console.log(`${email} is now an admin of ${venue.name}`);
|
||||
} else {
|
||||
// Leaves their venue: a superadmin is above the venues
|
||||
const { count } = await prisma.user.updateMany({ where: { email }, data: { role: 'superadmin', venueId: null } });
|
||||
if (count === 0) throw new Error(`No user with email ${email}`);
|
||||
console.log(`${email} is now a superadmin`);
|
||||
}
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.message ?? error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||
import { CommandsService } from './commands.service';
|
||||
import { DevicesService } from '../devices/devices.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { VenueAccessGuard } from '../common/guards/venue-access.guard';
|
||||
import { CreateCommandDto } from './dto/create-command.dto';
|
||||
|
||||
@ApiTags('Commands')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, VenueAccessGuard)
|
||||
@Controller('venues/:venueId/commands')
|
||||
export class CommandsController {
|
||||
constructor(
|
||||
private commandsService: CommandsService,
|
||||
private devicesService: DevicesService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a remote command' })
|
||||
async create(
|
||||
@Param('venueId') venueId: string,
|
||||
@Body() dto: CreateCommandDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.commandsService.create(venueId, dto, req.user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List command history' })
|
||||
@ApiQuery({ name: 'status', required: false })
|
||||
@ApiQuery({ name: 'limit', required: false })
|
||||
@ApiQuery({ name: 'offset', required: false })
|
||||
async findAll(
|
||||
@Param('venueId') venueId: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
return this.commandsService.findAll(venueId, {
|
||||
status,
|
||||
limit: Math.min(Math.max(parseInt(limit ?? '', 10) || 50, 1), 200),
|
||||
offset: Math.max(parseInt(offset ?? '', 10) || 0, 0),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('devices/:deviceId/revoke')
|
||||
@UseGuards(RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiOperation({ summary: 'Revoke a device (admin only)' })
|
||||
@ApiResponse({ status: 200, description: 'Device revoked' })
|
||||
async revoke(
|
||||
@Param('venueId') venueId: string,
|
||||
@Param('deviceId') deviceId: string,
|
||||
) {
|
||||
return this.devicesService.revoke(venueId, deviceId);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DevicesModule } from '../devices/devices.module';
|
||||
import { CommandsController } from './commands.controller';
|
||||
import { CommandsService } from './commands.service';
|
||||
|
||||
@Module({
|
||||
imports: [DevicesModule],
|
||||
controllers: [CommandsController],
|
||||
providers: [CommandsService],
|
||||
exports: [CommandsService],
|
||||
})
|
||||
export class CommandsModule {}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { CommandsService } from './commands.service';
|
||||
import { createFakePrisma } from '../testing/fake-prisma';
|
||||
|
||||
const user = { id: 'u1', name: 'Staff', email: 'staff@example.com' };
|
||||
const dto = { tabId: 't1', type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash' };
|
||||
|
||||
const unit = (id: string, removed = false) => (
|
||||
{ id, addedAt: '2026-09-24T20:00:00.000Z', removedAt: removed ? '2026-09-24T20:01:00.000Z' : null });
|
||||
|
||||
function createService({ lines = [] as unknown[] } = {}) {
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: new Date('2026-10-01') });
|
||||
prisma.tab.rows.push({
|
||||
venueId: 'v1', id: 't1', customerName: 'Jan', openedAt: new Date('2026-09-24T19:00:00Z'), version: 3, lines,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const realtime = { notify: jest.fn() };
|
||||
const cashPayments = { notify: jest.fn() };
|
||||
const service = new CommandsService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
|
||||
return { service, prisma, realtime, cashPayments };
|
||||
}
|
||||
|
||||
const colaLine = {
|
||||
id: 'line-1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250,
|
||||
units: [unit('u1'), unit('u2'), unit('u3', true)],
|
||||
};
|
||||
|
||||
describe('CommandsService.create', () => {
|
||||
it('settles everything on the tab at once and records it as completed', async () => {
|
||||
const { service, prisma, realtime, cashPayments } = createService({ lines: [colaLine] });
|
||||
|
||||
const result = await service.create('v1', dto, user);
|
||||
|
||||
expect(result).toMatchObject({ status: 'completed', amountInCents: 500 });
|
||||
expect(prisma.tab.rows[0]).toMatchObject({ version: 4, lines: [] });
|
||||
expect(prisma.settlement.rows).toHaveLength(1);
|
||||
expect(prisma.settlement.rows[0]).toMatchObject({
|
||||
tabId: 't1', customerName: 'Jan', paymentMethod: 'cash', amountInCents: 500, requestedBy: 'Staff', deviceId: null,
|
||||
});
|
||||
// The struck-through drink goes along, so the history shows it
|
||||
expect(prisma.settlement.rows[0].lines).toMatchObject([{ quantity: 2, units: [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }] }]);
|
||||
expect(prisma.remoteCommand.rows[0]).toMatchObject({
|
||||
id: result.id, deviceId: null, status: 'completed', customerName: 'Jan', amountInCents: 500,
|
||||
});
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'commands', 'tabs');
|
||||
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ paymentMethod: 'cash', amountInCents: 500 })], 'Staff');
|
||||
});
|
||||
|
||||
it('rejects a tab that is not open', async () => {
|
||||
const { service } = createService({ lines: [colaLine] });
|
||||
|
||||
await expect(service.create('v1', { ...dto, tabId: 't9' }, user)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects a tab that changed since it was loaded, changing nothing', async () => {
|
||||
const { service, prisma } = createService({ lines: [colaLine] });
|
||||
|
||||
await expect(service.create('v1', { ...dto, expectedVersion: 2 }, user))
|
||||
.rejects.toMatchObject({ response: { code: 'version_mismatch' } });
|
||||
expect(prisma.settlement.rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a tab with nothing on it', async () => {
|
||||
const { service } = createService({ lines: [] });
|
||||
|
||||
await expect(service.create('v1', dto, user)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects an unknown payment method or command type', async () => {
|
||||
const { service } = createService({ lines: [colaLine] });
|
||||
|
||||
await expect(service.create('v1', { ...dto, paymentMethod: 'bitcoin' }, user))
|
||||
.rejects.toMatchObject({ response: { code: 'invalid_payment_method' } });
|
||||
await expect(service.create('v1', { ...dto, type: 'open_tab' }, user))
|
||||
.rejects.toMatchObject({ response: { code: 'invalid_command_type' } });
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { CreateCommandDto } from './dto/create-command.dto';
|
||||
import { VenueLedger } from '../tabs/venue-ledger';
|
||||
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
|
||||
|
||||
@Injectable()
|
||||
export class CommandsService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private cashPayments: CashPaymentNotifier,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Settles a tab on the server, at once, also when every terminal is offline: everything still on the tab is
|
||||
* settled, and the terminals see the lines disappear on their next sync. Recorded as a completed command for the
|
||||
* history.
|
||||
*/
|
||||
async create(venueId: string, dto: CreateCommandDto, user: any) {
|
||||
if (!dto.paymentMethod) {
|
||||
throw new BadRequestException({
|
||||
code: 'payment_method_required',
|
||||
message: 'Payment method must be explicitly selected',
|
||||
});
|
||||
}
|
||||
|
||||
const validMethods = ['cash', 'payconiq', 'payment_pending'];
|
||||
if (!validMethods.includes(dto.paymentMethod)) {
|
||||
throw new BadRequestException({
|
||||
code: 'invalid_payment_method',
|
||||
message: `Payment method must be one of: ${validMethods.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const type = dto.type || 'close_tab';
|
||||
if (type !== 'close_tab') {
|
||||
throw new BadRequestException({ code: 'invalid_command_type', message: `Unknown command type: ${type}` });
|
||||
}
|
||||
|
||||
const requestedBy = user.name || user.email;
|
||||
const { result, settlements } = await this.prisma.$transaction(async (tx) => {
|
||||
const now = new Date();
|
||||
const ledger = await VenueLedger.open(tx, venueId);
|
||||
|
||||
const tab = ledger.tabs.get(dto.tabId);
|
||||
if (!tab) {
|
||||
throw new NotFoundException({ code: 'tab_not_found', message: 'This tab is no longer open' });
|
||||
}
|
||||
if (tab.version !== dto.expectedVersion) {
|
||||
throw new ConflictException({
|
||||
code: 'version_mismatch',
|
||||
message: 'The tab has changed since it was loaded. Refresh and try again.',
|
||||
});
|
||||
}
|
||||
|
||||
const id = uuidv4();
|
||||
const settlement = ledger.tabs.settleAll(dto.tabId, { id, settledAt: now, paymentMethod: dto.paymentMethod });
|
||||
if (!settlement) {
|
||||
throw new ConflictException({ code: 'tab_empty', message: 'There is nothing on this tab to settle' });
|
||||
}
|
||||
ledger.settlements.push({ ...settlement, commandId: id, requestedBy });
|
||||
await ledger.save(tx, now);
|
||||
|
||||
const command = await tx.remoteCommand.create({
|
||||
data: {
|
||||
id,
|
||||
venueId,
|
||||
deviceId: null,
|
||||
targetTabId: dto.tabId,
|
||||
customerName: settlement.customerName,
|
||||
amountInCents: settlement.amountInCents,
|
||||
type,
|
||||
expectedVersion: dto.expectedVersion,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
requestedBy,
|
||||
status: 'completed',
|
||||
acknowledgedAt: now,
|
||||
expiresAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
id: uuidv4(),
|
||||
venueId,
|
||||
actorType: 'client_user',
|
||||
actorId: user.id,
|
||||
action: 'tab_settled',
|
||||
details: JSON.stringify({
|
||||
commandId: command.id,
|
||||
tabId: dto.tabId,
|
||||
paymentMethod: dto.paymentMethod,
|
||||
amountInCents: settlement.amountInCents,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
result: { id: command.id, status: command.status, amountInCents: settlement.amountInCents },
|
||||
settlements: ledger.settlements,
|
||||
};
|
||||
});
|
||||
|
||||
this.realtime.notify(venueId, 'commands', 'tabs');
|
||||
this.cashPayments.notify(venueId, settlements, requestedBy);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async findAll(
|
||||
venueId: string,
|
||||
options: { status?: string; limit: number; offset: number },
|
||||
) {
|
||||
const where: any = { venueId };
|
||||
if (options.status) {
|
||||
where.status = options.status;
|
||||
}
|
||||
|
||||
const [commands, total] = await Promise.all([
|
||||
this.prisma.remoteCommand.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
targetTabId: true,
|
||||
customerName: true,
|
||||
amountInCents: true,
|
||||
type: true,
|
||||
status: true,
|
||||
paymentMethod: true,
|
||||
requestedBy: true,
|
||||
expectedVersion: true,
|
||||
createdAt: true,
|
||||
acknowledgedAt: true,
|
||||
result: true,
|
||||
deviceId: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options.limit,
|
||||
skip: options.offset,
|
||||
}),
|
||||
this.prisma.remoteCommand.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
commands: commands.map((c) => ({
|
||||
id: c.id,
|
||||
tabId: c.targetTabId,
|
||||
customerName: c.customerName,
|
||||
amountInCents: c.amountInCents,
|
||||
type: c.type,
|
||||
status: c.status,
|
||||
paymentMethod: c.paymentMethod,
|
||||
requestedBy: c.requestedBy,
|
||||
expectedVersion: c.expectedVersion,
|
||||
createdAt: c.createdAt,
|
||||
processedAt: c.acknowledgedAt,
|
||||
result: c.result,
|
||||
deviceId: c.deviceId,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { IsString, IsIn, IsInt, Min, IsUUID } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CreateCommandDto {
|
||||
@ApiProperty({ example: 'local-tab-uuid' })
|
||||
@IsString()
|
||||
tabId: string;
|
||||
|
||||
@ApiProperty({ example: 'close_tab', default: 'close_tab' })
|
||||
@IsString()
|
||||
type: string = 'close_tab';
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
expectedVersion: number;
|
||||
|
||||
@ApiProperty({ example: 'cash', enum: ['cash', 'payconiq', 'payment_pending'] })
|
||||
@IsString()
|
||||
@IsIn(['cash', 'payconiq', 'payment_pending'])
|
||||
paymentMethod: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
CanActivate, createParamDecorator, ExecutionContext, ForbiddenException, Injectable, SetMetadata,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Role } from '@kooltap/shared';
|
||||
import { UserAuthGuard } from '../auth/user-auth.guard';
|
||||
|
||||
/** The signed-in dashboard user, as the database has them now. A superadmin has no venue. */
|
||||
export type AuthUser = { id: string; email: string; name: string; role: Role; venueId: string | null };
|
||||
|
||||
/** The terminal a request comes from. */
|
||||
export type AuthDevice = { id: string; venueId: string; name: string; lastSeenAt: Date };
|
||||
|
||||
export const CurrentUser = createParamDecorator((_: unknown, context: ExecutionContext): AuthUser => (
|
||||
context.switchToHttp().getRequest().user));
|
||||
|
||||
export const CurrentDevice = createParamDecorator((_: unknown, context: ExecutionContext): AuthDevice => (
|
||||
context.switchToHttp().getRequest().device));
|
||||
|
||||
const ROLES_KEY = 'roles';
|
||||
|
||||
/** A superadmin may do everything an admin or manager may, in every venue. */
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const roles = this.reflector.getAllAndOverride<Role[] | undefined>(ROLES_KEY, [context.getHandler(), context.getClass()]);
|
||||
const user: AuthUser | undefined = context.switchToHttp().getRequest().user;
|
||||
if (!roles || user?.role === 'superadmin' || (user && roles.includes(user.role))) return true;
|
||||
throw new ForbiddenException({ code: 'forbidden', message: 'Your role does not allow this' });
|
||||
}
|
||||
}
|
||||
|
||||
/** A user only reaches their own venue's routes (`:venueId`); a superadmin reaches every venue's. */
|
||||
@Injectable()
|
||||
export class VenueAccessGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const venueId: string | undefined = request.params?.venueId;
|
||||
const user: AuthUser | undefined = request.user;
|
||||
if (!venueId || user?.role === 'superadmin' || user?.venueId === venueId) return true;
|
||||
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Signed in, with access to the route's venue, and one of the @Roles when the route has them. */
|
||||
export const Authorized = () => UseGuards(UserAuthGuard, VenueAccessGuard, RolesGuard);
|
||||
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -1 +0,0 @@
|
||||
export { Roles, ROLES_KEY } from './roles.decorator';
|
||||
@@ -1,4 +0,0 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
@@ -1,38 +0,0 @@
|
||||
import { RolesGuard } from './roles.guard';
|
||||
|
||||
function context(role: string | undefined) {
|
||||
return {
|
||||
getHandler: () => null,
|
||||
getClass: () => null,
|
||||
switchToHttp: () => ({ getRequest: () => ({ user: role ? { role } : undefined }) }),
|
||||
} as any;
|
||||
}
|
||||
|
||||
function guard(required: string[] | undefined) {
|
||||
return new RolesGuard({ getAllAndOverride: () => required } as any);
|
||||
}
|
||||
|
||||
describe('RolesGuard', () => {
|
||||
it('lets managers do what managers may, and keeps admin-only actions for the admin', () => {
|
||||
expect(guard(['admin', 'manager']).canActivate(context('manager'))).toBe(true);
|
||||
expect(guard(['admin']).canActivate(context('manager'))).toBe(false);
|
||||
expect(guard(['admin']).canActivate(context('admin'))).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a token from before the rename ("viewer") as a manager', () => {
|
||||
expect(guard(['admin', 'manager']).canActivate(context('viewer'))).toBe(true);
|
||||
expect(guard(['admin']).canActivate(context('viewer'))).toBe(false);
|
||||
});
|
||||
|
||||
it('lets a superadmin do everything, and keeps the admin page for superadmins', () => {
|
||||
expect(guard(['admin']).canActivate(context('superadmin'))).toBe(true);
|
||||
expect(guard(['admin', 'manager']).canActivate(context('superadmin'))).toBe(true);
|
||||
expect(guard(['superadmin']).canActivate(context('superadmin'))).toBe(true);
|
||||
expect(guard(['superadmin']).canActivate(context('admin'))).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a request without a user when a role is required', () => {
|
||||
expect(guard(['manager']).canActivate(context(undefined))).toBe(false);
|
||||
expect(guard(undefined).canActivate(context(undefined))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Injectable, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ROLES_KEY } from '../decorators/roles.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!requiredRoles) {
|
||||
return true;
|
||||
}
|
||||
const { user } = context.switchToHttp().getRequest();
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
// "viewer" was the old name of a manager; tokens issued before the rename still carry it
|
||||
const role = user.role === 'viewer' ? 'manager' : user.role;
|
||||
// A superadmin may do everything an admin or manager may, in every venue
|
||||
return role === 'superadmin' || requiredRoles.includes(role);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { VenueAccessGuard } from './venue-access.guard';
|
||||
|
||||
function contextFor(params: Record<string, string>, user?: { venueId: string; role?: string }): ExecutionContext {
|
||||
return {
|
||||
switchToHttp: () => ({ getRequest: () => ({ params, user }) }),
|
||||
} as unknown as ExecutionContext;
|
||||
}
|
||||
|
||||
describe('VenueAccessGuard', () => {
|
||||
const guard = new VenueAccessGuard();
|
||||
|
||||
it('allows routes without a venueId param', () => {
|
||||
expect(guard.canActivate(contextFor({}, { venueId: 'a' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows access to the user\'s own venue', () => {
|
||||
expect(guard.canActivate(contextFor({ venueId: 'a' }, { venueId: 'a' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects access to another venue', () => {
|
||||
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a' }))).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('lets a superadmin into every venue', () => {
|
||||
expect(guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'superadmin' }))).toBe(true);
|
||||
expect(() => guard.canActivate(contextFor({ venueId: 'b' }, { venueId: 'a', role: 'admin' }))).toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('rejects when no user is attached', () => {
|
||||
expect(() => guard.canActivate(contextFor({ venueId: 'a' }))).toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Injectable, ExecutionContext, CanActivate, ForbiddenException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* Ensures the authenticated user can only access the venue they belong to. A superadmin can access every venue: that
|
||||
* is how they view the dashboard as a venue. Must run after JwtAuthGuard so that `request.user` is populated.
|
||||
*/
|
||||
@Injectable()
|
||||
export class VenueAccessGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const venueId = request.params?.venueId;
|
||||
|
||||
if (!venueId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (request.user?.role === 'superadmin') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!request.user || request.user.venueId !== venueId) {
|
||||
throw new ForbiddenException({ code: 'venue_forbidden', message: 'You do not have access to this venue' });
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const { method, url } = request;
|
||||
const start = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const duration = Date.now() - start;
|
||||
const userId = request.user?.id || request.device?.id || 'anonymous';
|
||||
const venueId = request.user?.venueId || request.device?.venueId || '-';
|
||||
console.log(`[${method}] ${url} | user=${userId} venue=${venueId} | ${duration}ms`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class LoggingInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger('HTTP');
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const start = Date.now();
|
||||
return next.handle().pipe(
|
||||
tap(() => {
|
||||
const actor = request.user?.id ?? request.device?.id ?? 'anonymous';
|
||||
const venueId = request.user?.venueId ?? request.device?.venueId ?? '-';
|
||||
this.logger.log(`${request.method} ${request.url} | actor=${actor} venue=${venueId} | ${Date.now() - start}ms`);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BadRequestException, PipeTransform } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Validates and converts a body or query with a schema from @kooltap/shared: `@Body(zod(schema))`. */
|
||||
export class ZodPipe<S extends z.ZodType> implements PipeTransform<unknown, z.output<S>> {
|
||||
constructor(private readonly schema: S) {}
|
||||
|
||||
transform(value: unknown): z.output<S> {
|
||||
const result = this.schema.safeParse(value);
|
||||
if (!result.success) {
|
||||
throw new BadRequestException({ code: 'validation_error', message: z.prettifyError(result.error) });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const zod = <S extends z.ZodType>(schema: S) => new ZodPipe(schema);
|
||||
@@ -0,0 +1,4 @@
|
||||
/** The dashboard's origin(s), comma separated in CORS_ORIGIN. */
|
||||
export function corsOrigin(): string[] {
|
||||
return (process.env.CORS_ORIGIN || 'http://localhost:3000').split(',').map((origin) => origin.trim()).filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { Customer, customerSchema, ImportResult, importCustomersSchema } from '@kooltap/shared';
|
||||
import { zod } from '../common/zod.pipe';
|
||||
import { Authorized, Roles } from '../common/auth';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Controller('venues/:venueId/customers')
|
||||
@Authorized()
|
||||
export class CustomersController {
|
||||
constructor(private customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Param('venueId') venueId: string): Promise<Customer[]> {
|
||||
return this.customers.findAll(venueId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('admin', 'manager')
|
||||
create(@Param('venueId') venueId: string, @Body(zod(customerSchema)) dto: z.output<typeof customerSchema>): Promise<Customer> {
|
||||
return this.customers.create(venueId, dto);
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Roles('admin', 'manager')
|
||||
import(@Param('venueId') venueId: string, @Body(zod(importCustomersSchema)) dto: z.output<typeof importCustomersSchema>): Promise<ImportResult> {
|
||||
return this.customers.import(venueId, dto.entries, dto.mode);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('admin', 'manager')
|
||||
remove(@Param('venueId') venueId: string, @Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.customers.remove(venueId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Customer, ImportResult } from '@kooltap/shared';
|
||||
import { Prisma, PrismaService } from '../prisma/prisma.service';
|
||||
import { RealtimeService } from '../realtime/realtime.service';
|
||||
import { customerNameKey, normalizeCustomerName } from './customer-name';
|
||||
|
||||
const customerSelect = { id: true, name: true, email: true, position: true, createdAt: true, tabOpenedAt: true } as const;
|
||||
|
||||
function toCustomer(row: { id: string; name: string; email: string | null; position: number; createdAt: Date; tabOpenedAt: Date | null }): Customer {
|
||||
return { ...row, createdAt: row.createdAt.toISOString(), tabOpenedAt: row.tabOpenedAt?.toISOString() ?? null };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private realtime: RealtimeService,
|
||||
) {}
|
||||
|
||||
async findAll(venueId: string): Promise<Customer[]> {
|
||||
const customers = await this.prisma.customer.findMany({ where: { venueId }, orderBy: { position: 'asc' }, select: customerSelect });
|
||||
return customers.map(toCustomer);
|
||||
}
|
||||
|
||||
/** Adds a customer at the end of the list; the next sync opens their tab. */
|
||||
async create(venueId: string, dto: { name: string; email?: string | null }): Promise<Customer> {
|
||||
const name = normalizeCustomerName(dto.name);
|
||||
try {
|
||||
const customer = await this.prisma.$transaction(async (tx) => {
|
||||
const last = await tx.customer.aggregate({ where: { venueId }, _max: { position: true } });
|
||||
return tx.customer.create({
|
||||
data: { venueId, name, nameKey: customerNameKey(name), email: dto.email ?? null, position: (last._max.position ?? -1) + 1 },
|
||||
select: customerSelect,
|
||||
});
|
||||
});
|
||||
this.realtime.notify(venueId, 'customers');
|
||||
return toCustomer(customer);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new ConflictException({ code: 'name_exists', message: 'This customer name already exists' });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk import of the customer list (e.g. from the venue's Excel file). Names match case-insensitively and the
|
||||
* existing spelling is kept: tabs are matched by name and a respelled name would open a second tab. A missing email
|
||||
* never clears an existing one. Customers whose tab was opened before get one again on the next sync when they have
|
||||
* none (it may have been deleted since).
|
||||
*/
|
||||
async import(venueId: string, entries: { name: string; email?: string | null }[], mode: 'merge' | 'replace'): Promise<ImportResult> {
|
||||
const incoming = new Map<string, { name: string; email: string | null }>();
|
||||
for (const entry of entries) {
|
||||
const name = normalizeCustomerName(entry.name);
|
||||
const key = customerNameKey(name);
|
||||
incoming.set(key, { name, email: entry.email || incoming.get(key)?.email || null });
|
||||
}
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.customer.findMany({ where: { venueId }, orderBy: { position: 'asc' } });
|
||||
const existingByKey = new Map(existing.map((c) => [c.nameKey, c]));
|
||||
let position = (existing.at(-1)?.position ?? -1) + 1;
|
||||
const counts: ImportResult = { created: 0, updated: 0, unchanged: 0, removed: 0 };
|
||||
|
||||
const created = [...incoming].filter(([key]) => !existingByKey.has(key));
|
||||
await tx.customer.createMany({
|
||||
data: created.map(([nameKey, entry]) => ({ venueId, name: entry.name, nameKey, email: entry.email, position: position++ })),
|
||||
});
|
||||
counts.created = created.length;
|
||||
|
||||
for (const [key, entry] of incoming) {
|
||||
const current = existingByKey.get(key);
|
||||
if (!current) continue;
|
||||
const newEmail = entry.email && entry.email !== current.email ? entry.email : undefined;
|
||||
if (current.tabOpenedAt || newEmail) {
|
||||
await tx.customer.update({ where: { id: current.id }, data: { tabOpenedAt: null, ...(newEmail ? { email: newEmail } : {}) } });
|
||||
counts.updated++;
|
||||
} else {
|
||||
counts.unchanged++;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'replace') {
|
||||
const removed = existing.filter((c) => !incoming.has(c.nameKey)).map((c) => c.id);
|
||||
counts.removed = (await tx.customer.deleteMany({ where: { id: { in: removed } } })).count;
|
||||
}
|
||||
return counts;
|
||||
}, { timeout: 30_000 });
|
||||
|
||||
this.realtime.notify(venueId, 'customers', 'tabs');
|
||||
return result;
|
||||
}
|
||||
|
||||
async remove(venueId: string, id: string) {
|
||||
const { count } = await this.prisma.customer.deleteMany({ where: { id, venueId } });
|
||||
if (count === 0) throw new NotFoundException({ code: 'customer_not_found', message: 'Customer not found' });
|
||||
this.realtime.notify(venueId, 'customers', 'tabs');
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Tx } from '../prisma/prisma.service';
|
||||
import { customerNameKey } from './customer-name';
|
||||
|
||||
/** The tab free drinks are put on, so they still count in the stock and history. A normal customer without email. */
|
||||
export const FREE_DRINKS_CUSTOMER = 'Free Drinks';
|
||||
|
||||
/**
|
||||
* Adds the customers every venue starts with; the next sync opens their tabs. A customer that was removed, or whose
|
||||
* tab was wiped, comes back: run when a venue is created or reset.
|
||||
*/
|
||||
export async function ensureDefaultCustomers(tx: Tx, venueId: string) {
|
||||
const nameKey = customerNameKey(FREE_DRINKS_CUSTOMER);
|
||||
await tx.customer.upsert({
|
||||
where: { venueId_nameKey: { venueId, nameKey } },
|
||||
create: { venueId, name: FREE_DRINKS_CUSTOMER, nameKey },
|
||||
// Opened again when its tab is gone; an open tab with this name is kept
|
||||
update: { tabOpenedAt: null },
|
||||
});
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Param,
|
||||
Body,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Req,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { SkipThrottle, Throttle } from '@nestjs/throttler';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ImagesService } from '../images/images.service';
|
||||
import { MAX_UPLOAD_BYTES } from '../images/image-processing';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { DeviceAuthGuard } from '../auth/guards/device-auth.guard';
|
||||
import { AppVersionGuard } from './app-version.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||
import { SyncDto } from './dto/sync.dto';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
|
||||
@ApiTags('Devices')
|
||||
@Controller('devices')
|
||||
export class DevicesController {
|
||||
constructor(
|
||||
private devicesService: DevicesService,
|
||||
private authService: AuthService,
|
||||
private images: ImagesService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@UseGuards(AppVersionGuard)
|
||||
@ApiOperation({ summary: 'Register a new device' })
|
||||
@ApiResponse({ status: 201, description: 'Device registered successfully' })
|
||||
async register(@Body() dto: RegisterDeviceDto) {
|
||||
return this.authService.registerDevice(dto.venueId, dto.name, dto.registrationCode);
|
||||
}
|
||||
|
||||
// Authenticated by the device token; the till must never be rate-limited out by other
|
||||
// traffic from the venue's shared IP
|
||||
@Post(':deviceId/sync')
|
||||
@SkipThrottle()
|
||||
@UseGuards(AppVersionGuard, DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Sync device state with server' })
|
||||
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
|
||||
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
|
||||
return this.devicesService.sync(req.user, dto);
|
||||
}
|
||||
|
||||
// A picture taken on a terminal. Only stored: the terminal links it to its product with a product_updated event,
|
||||
// so an offline edit and its picture follow the same rules as every other change
|
||||
@Post(':deviceId/images')
|
||||
@SkipThrottle()
|
||||
@UseGuards(AppVersionGuard, DeviceAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
|
||||
async uploadImage(@UploadedFile() file: Express.Multer.File | undefined) {
|
||||
return { imageId: await this.images.store(file?.buffer) };
|
||||
}
|
||||
|
||||
@Post(':deviceId/commands/:commandId/ack')
|
||||
@SkipThrottle()
|
||||
@UseGuards(AppVersionGuard, DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Acknowledge a command result' })
|
||||
@ApiResponse({ status: 200, description: 'Command acknowledged' })
|
||||
async ackCommand(
|
||||
@Param('deviceId') deviceId: string,
|
||||
@Param('commandId') commandId: string,
|
||||
@Body() dto: AckCommandDto,
|
||||
@Req() req: any,
|
||||
) {
|
||||
return this.devicesService.ackCommand(req.user, commandId, dto);
|
||||
}
|
||||
|
||||
@Get('generation-code')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Generate a registration code for pairing' })
|
||||
@ApiResponse({ status: 200, description: 'Registration code generated' })
|
||||
async generateCode(@Req() req: any, @Query('venueId') viewing?: string) {
|
||||
// A superadmin pairs terminals for the venue they're viewing
|
||||
const venueId = req.user.role === 'superadmin' && viewing ? viewing : req.user.venueId;
|
||||
return this.authService.generateRegistrationCode(venueId);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { DevicesController } from './devices.controller';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { ImagesModule } from '../images/images.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, ImagesModule],
|
||||
controllers: [DevicesController],
|
||||
providers: [DevicesService],
|
||||
exports: [DevicesService],
|
||||
})
|
||||
export class DevicesModule {}
|
||||
@@ -1,694 +0,0 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { DevicesService } from './devices.service';
|
||||
import { SyncDto } from './dto/sync.dto';
|
||||
import { createFakePrisma, FakePrisma } from '../testing/fake-prisma';
|
||||
import { StockService } from '../stock/stock.service';
|
||||
|
||||
type Device = { id: string; venueId: string; name?: string; revokedAt: null; lastSeenAt: Date };
|
||||
type SyncResult = Awaited<ReturnType<DevicesService['sync']>>;
|
||||
|
||||
// Yesterday at 20:00 UTC. In the past, as a time ahead of the server clock would be capped, and recent, as a new
|
||||
// tablet's first settlements feed only goes back a few days
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const at = (minutes: number) => new Date(Date.UTC(
|
||||
yesterday.getUTCFullYear(), yesterday.getUTCMonth(), yesterday.getUTCDate(), 20, minutes,
|
||||
)).toISOString();
|
||||
|
||||
function setup({ seeded = true } = {}) {
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', tabsSeededAt: seeded ? new Date('2026-10-01') : null });
|
||||
const tabletA: Device = { id: 'd1', venueId: 'v1', name: 'Bar tablet', revokedAt: null, lastSeenAt: new Date() };
|
||||
const tabletB: Device = { id: 'd2', venueId: 'v1', revokedAt: null, lastSeenAt: new Date() };
|
||||
prisma.device.rows.push({ ...tabletA }, { ...tabletB });
|
||||
const realtime = { notify: jest.fn() };
|
||||
const cashPayments = { notify: jest.fn() };
|
||||
const service = new DevicesService(prisma as any, { log: jest.fn() } as any, realtime as any, cashPayments as any);
|
||||
|
||||
const sync = (device: Device, body: Partial<SyncDto> = {}) =>
|
||||
service.sync(device, { deviceId: device.id, openTabs: [], ...body } as SyncDto);
|
||||
|
||||
return { prisma, service, realtime, cashPayments, sync, tabletA, tabletB };
|
||||
}
|
||||
|
||||
/** Builds the events a terminal sends, with fresh ids. */
|
||||
const events = {
|
||||
opened: (tabId: string, customerName: string, minutes = 0) => (
|
||||
{ id: uuidv4(), type: 'tab_opened', tabId, customerName, occurredAt: at(minutes), openedAt: at(minutes) }),
|
||||
renamed: (tabId: string, customerName: string, minutes = 0) => (
|
||||
{ id: uuidv4(), type: 'tab_renamed', tabId, customerName, occurredAt: at(minutes) }),
|
||||
deleted: (tabId: string, customerName: string, minutes = 0) => (
|
||||
{ id: uuidv4(), type: 'tab_deleted', tabId, customerName, occurredAt: at(minutes) }),
|
||||
line: (type: 'line_added' | 'line_removed', tabId: string, quantity: number, {
|
||||
minutes = 0, lineId = undefined as string | undefined, productId = 'cola', productName = 'Cola', price = 250,
|
||||
} = {}) => ({
|
||||
id: uuidv4(), type, tabId, customerName: 'Alice', occurredAt: at(minutes),
|
||||
lineId, productId, productName, unitPriceInCents: price, quantity,
|
||||
}),
|
||||
settled: (tabId: string, quantity: number, minutes = 0) => ({
|
||||
id: uuidv4(), type: 'settled', tabId, customerName: 'Alice', occurredAt: at(minutes),
|
||||
amountInCents: quantity * 250, paymentMethod: 'payconiq',
|
||||
items: [{ productId: 'cola', productName: 'Cola', quantity, unitPriceInCents: 250 }],
|
||||
}),
|
||||
product: (type: string, productId: string, product: Record<string, unknown> | undefined, minutes = 0) => (
|
||||
{ id: uuidv4(), type, productId, ...(product ? { product } : {}), occurredAt: at(minutes) }),
|
||||
stockSet: (productId: string, quantity: number | null, minutes = 0) => (
|
||||
{ id: uuidv4(), type: 'stock_set', productId, quantity, occurredAt: at(minutes) }),
|
||||
};
|
||||
|
||||
const catalog = (stock: number | null = 0) => [
|
||||
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock },
|
||||
];
|
||||
|
||||
function tab(result: SyncResult, tabId: string) {
|
||||
return result.tabs.find((t) => t.id === tabId);
|
||||
}
|
||||
|
||||
function colaStock(result: SyncResult) {
|
||||
return result.stock.find((s) => s.productId === 'cola')?.stock;
|
||||
}
|
||||
|
||||
describe('DevicesService.sync: the server holds the tabs', () => {
|
||||
it('shows a newly paired tablet the open tabs of the venue', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
|
||||
|
||||
const result = await sync(tabletB);
|
||||
|
||||
expect(result.tabs).toHaveLength(1);
|
||||
expect(result.tabs[0]).toMatchObject({ id: 't1', customerName: 'Alice', version: 2 });
|
||||
expect(result.tabs[0].lines).toMatchObject([{ productId: 'cola', productName: 'Cola', quantity: 2 }]);
|
||||
});
|
||||
|
||||
it('gives units the ids the tablets give them when replaying', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const added = events.line('line_added', 't1', 2, { lineId: 'line-1' });
|
||||
|
||||
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
|
||||
|
||||
expect(tab(result, 't1')!.lines[0]).toMatchObject({
|
||||
id: 'line-1',
|
||||
units: [
|
||||
{ id: `${added.id}:0`, addedAt: at(0), removedAt: null },
|
||||
{ id: `${added.id}:1`, addedAt: at(0), removedAt: null },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('adds what two tablets put on one tab', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'line-1' })] });
|
||||
|
||||
// Tablet B replayed its drink onto the server's line, so it uses that line id
|
||||
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { lineId: 'line-1', minutes: 1 })] });
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(tab(result, 't1')!.lines).toMatchObject([{ id: 'line-1', quantity: 3 }]);
|
||||
});
|
||||
|
||||
it('applies an event once, also when it arrives again in a later sync', async () => {
|
||||
const { sync, tabletA, prisma } = setup();
|
||||
const added = events.line('line_added', 't1', 1);
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), added] });
|
||||
|
||||
const result = await sync(tabletA, { events: [added, added] });
|
||||
|
||||
expect(result.acknowledgedEventIds).toEqual([added.id, added.id]);
|
||||
expect(tab(result, 't1')!.lines[0].quantity).toBe(1);
|
||||
expect(prisma.tabEvent.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('catches up with everything a tablet did offline, in order', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletB, { events: [events.opened('bob', 'Bob')] });
|
||||
|
||||
await sync(tabletA, {
|
||||
events: [
|
||||
events.opened('t1', 'Alice', 1),
|
||||
events.line('line_added', 't1', 3, { minutes: 2 }),
|
||||
events.line('line_removed', 't1', 1, { minutes: 3 }),
|
||||
events.renamed('t1', 'Alice V.', 4),
|
||||
events.deleted('bob', 'Bob', 5),
|
||||
],
|
||||
});
|
||||
const result = await sync(tabletB);
|
||||
|
||||
expect(result.tabs.map((t) => t.customerName)).toEqual(['Alice V.']);
|
||||
const [line] = tab(result, 't1')!.lines;
|
||||
expect(line.quantity).toBe(2);
|
||||
// The latest drink was taken back
|
||||
expect(line.units.map((u) => u.removedAt)).toEqual([null, null, at(3)]);
|
||||
});
|
||||
|
||||
it('settles only what the tablet saw, leaving a drink added elsewhere on the tab', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
|
||||
await sync(tabletB, { events: [events.line('line_added', 't1', 1, { minutes: 5 })] });
|
||||
|
||||
// Tablet A was offline and settled the 2 Cola it knew about
|
||||
const result = await sync(tabletA, { events: [events.settled('t1', 2, 3)] });
|
||||
|
||||
expect(tab(result, 't1')!.lines).toMatchObject([{ quantity: 1, units: [{ addedAt: at(5) }] }]);
|
||||
expect(result.settlements).toHaveLength(1);
|
||||
expect(result.settlements[0]).toMatchObject({ tabId: 't1', paymentMethod: 'payconiq', amountInCents: 500 });
|
||||
expect(result.settlements[0].lines).toMatchObject([{ productId: 'cola', quantity: 2 }]);
|
||||
expect(result.settlements[0].lines[0].units.map((u) => u.addedAt)).toEqual([at(0), at(0)]);
|
||||
});
|
||||
|
||||
it('brings a tab deleted elsewhere back with the drink that was put on it', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletA, { events: [events.opened('bob', 'Bob')] });
|
||||
await sync(tabletB, { events: [events.deleted('bob', 'Bob', 1)] });
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
events: [{ ...events.line('line_added', 'bob', 1, { minutes: 2, productId: 'beer', productName: 'Beer', price: 400 }), customerName: 'Bob' }],
|
||||
});
|
||||
|
||||
expect(tab(result, 'bob')).toMatchObject({ customerName: 'Bob', lines: [{ productName: 'Beer', quantity: 1 }] });
|
||||
});
|
||||
|
||||
it('matches events from older app versions by product name and price', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const { lineId, productId, ...old } = events.line('line_added', 't1', 2);
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), old] });
|
||||
|
||||
const { lineId: _, productId: __, ...removed } = events.line('line_removed', 't1', 1, { minutes: 1 });
|
||||
const result = await sync(tabletA, { events: [removed] });
|
||||
|
||||
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: null, quantity: 1 }]);
|
||||
});
|
||||
|
||||
it('does not apply a rejected event, and still syncs the rest', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const invalid = { ...events.line('line_added', 't1', 1), quantity: 0 };
|
||||
|
||||
const result = await sync(tabletA, { events: [events.opened('t1', 'Alice'), invalid] });
|
||||
|
||||
expect(result.rejectedEvents).toEqual([{ id: invalid.id, reason: expect.any(String) }]);
|
||||
expect(tab(result, 't1')!.lines).toEqual([]);
|
||||
});
|
||||
|
||||
it('opens planned customers as tabs once, instead of on every tablet', async () => {
|
||||
const { sync, tabletA, tabletB, prisma } = setup();
|
||||
prisma.plannedTab.rows.push(
|
||||
{ id: 'p1', venueId: 'v1', customerName: 'Els', customerNameKey: 'els', deliveredAt: null, position: 0 },
|
||||
{ id: 'p2', venueId: 'v1', customerName: 'alice', customerNameKey: 'alice', deliveredAt: null, position: 1 },
|
||||
);
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice')] });
|
||||
|
||||
const result = await sync(tabletB);
|
||||
|
||||
expect(result.plannedTabNames).toEqual([]);
|
||||
expect(result.tabs.map((t) => t.customerName).sort()).toEqual(['Alice', 'Els']);
|
||||
expect(prisma.plannedTab.rows.every((p) => p.deliveredAt !== null)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: taking over a tablet once', () => {
|
||||
it("takes over the tablet's tabs and stock without applying that request's events again", async () => {
|
||||
const { sync, tabletA, prisma } = setup({ seeded: false });
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
openTabs: [{
|
||||
id: 't1', customerName: 'Alice', version: 4, totalInCents: 750, itemCount: 3,
|
||||
items: [{ productName: 'Cola', quantity: 3, unitPriceInCents: 250 }],
|
||||
}],
|
||||
// Already in the tabs and stock above
|
||||
events: [events.line('line_added', 't1', 1)],
|
||||
products: catalog(45),
|
||||
});
|
||||
|
||||
expect(tab(result, 't1')).toMatchObject({ customerName: 'Alice', version: 4, lines: [{ productName: 'Cola', quantity: 3 }] });
|
||||
expect(colaStock(result)).toBe(45);
|
||||
expect(prisma.venue.rows[0].tabsSeededAt).not.toBeNull();
|
||||
|
||||
const next = await sync(tabletA, {
|
||||
openTabs: [],
|
||||
events: [events.line('line_added', 't1', 1, { productId: 'cola', minutes: 1 })],
|
||||
products: catalog(44),
|
||||
});
|
||||
expect(tab(next, 't1')!.lines[0].quantity).toBe(4);
|
||||
expect(colaStock(next)).toBe(44);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: settlements feed', () => {
|
||||
it("sends every tablet the venue's settlements once, after its cursor", async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
const settled = events.settled('t1', 1, 2);
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled] });
|
||||
|
||||
const first = await sync(tabletB);
|
||||
expect(first.settlements.map((s) => s.id)).toEqual([settled.id]);
|
||||
|
||||
const second = await sync(tabletB, { settlementsCursor: first.settlementsCursor });
|
||||
expect(second.settlements).toEqual([]);
|
||||
expect(second.settlementsCursor).toBe(first.settlementsCursor);
|
||||
});
|
||||
|
||||
it('hands a settlement to the cash payment emails once, with the tablet that settled it', async () => {
|
||||
const { sync, tabletA, cashPayments } = setup();
|
||||
const settled = { ...events.settled('t1', 1, 2), paymentMethod: 'cash' };
|
||||
const batch = [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1), settled];
|
||||
|
||||
await sync(tabletA, { events: batch });
|
||||
expect(cashPayments.notify).toHaveBeenCalledWith('v1', [expect.objectContaining({ id: settled.id, paymentMethod: 'cash' })], 'Bar tablet');
|
||||
|
||||
// Resent because the acknowledgement was lost: already applied, so nothing new to email
|
||||
await sync(tabletA, { events: batch });
|
||||
expect(cashPayments.notify).toHaveBeenLastCalledWith('v1', [], 'Bar tablet');
|
||||
});
|
||||
|
||||
it('starts an empty feed after the latest settlement', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(result.settlements).toEqual([]);
|
||||
expect(result.settlementsCursor).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
/** A product added on the dashboard, untracked. */
|
||||
function addProduct(prisma: FakePrisma, productId = 'cola', fields: Record<string, unknown> = {}) {
|
||||
prisma.product.rows.push({
|
||||
venueId: 'v1', productId, name: 'Cola', category: 'Frisdrank', priceInCents: 250, stock: null, countedAt: null,
|
||||
lowStockThreshold: null, archivedAt: null, reportedAt: new Date(), ...fields,
|
||||
});
|
||||
}
|
||||
|
||||
describe('DevicesService.sync: the dashboard manages the catalog', () => {
|
||||
it('sends every product, archived ones marked, and ignores what a tablet reports', async () => {
|
||||
const { sync, tabletA, prisma } = setup();
|
||||
addProduct(prisma, 'cola', { lowStockThreshold: 6 });
|
||||
addProduct(prisma, 'old', { name: 'Old', category: null, archivedAt: new Date() });
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
products: [
|
||||
{ id: 'cola', name: 'Renamed on the tablet', category: null, priceInCents: 999, stock: 3 },
|
||||
{ id: 'local', name: 'Seeded on the tablet', category: null, priceInCents: 100, stock: 24 },
|
||||
],
|
||||
});
|
||||
|
||||
// By category, products without one last (as Postgres sorts)
|
||||
expect(result.catalog).toEqual([
|
||||
{ id: 'cola', name: 'Cola', category: 'Frisdrank', priceInCents: 250, lowStockThreshold: 6, archived: false },
|
||||
{ id: 'old', name: 'Old', category: null, priceInCents: 250, lowStockThreshold: null, archived: true },
|
||||
]);
|
||||
expect(prisma.product.rows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("takes over the tablet's catalog when the venue is taken over", async () => {
|
||||
const { sync, tabletA } = setup({ seeded: false });
|
||||
|
||||
const result = await sync(tabletA, { products: catalog(12) });
|
||||
|
||||
expect(result.catalog).toMatchObject([{ id: 'cola', name: 'Cola', archived: false }]);
|
||||
expect(colaStock(result)).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: the server holds the stock', () => {
|
||||
async function trackedCola(sync: ReturnType<typeof setup>['sync'], tablet: Device, stock = 48) {
|
||||
return sync(tablet, { events: [events.stockSet('cola', stock, -10)] });
|
||||
}
|
||||
|
||||
/** Every test here starts with Cola on the dashboard. */
|
||||
function setupWithCola() {
|
||||
const venue = setup();
|
||||
addProduct(venue.prisma);
|
||||
return venue;
|
||||
}
|
||||
|
||||
it('ignores a count for a product the venue does not have', async () => {
|
||||
const { sync, tabletA } = setupWithCola();
|
||||
|
||||
const result = await sync(tabletA, { events: [events.stockSet('local', 24)] });
|
||||
|
||||
expect(result.stock).toEqual([{ productId: 'cola', stock: null, countedAt: null }]);
|
||||
});
|
||||
|
||||
it('counts the sales of every tablet once', async () => {
|
||||
const { sync, tabletA, tabletB } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
const sale = events.line('line_added', 't1', 2, { minutes: 1 });
|
||||
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), sale] });
|
||||
await sync(tabletA, { events: [sale] });
|
||||
const result = await sync(tabletB, { events: [events.line('line_added', 't1', 2, { minutes: 2 })] });
|
||||
|
||||
expect(colaStock(result)).toBe(44);
|
||||
});
|
||||
|
||||
it('gives stock back for drinks taken off, but not for a settlement', async () => {
|
||||
const { sync, tabletA } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
events: [
|
||||
events.opened('t1', 'Alice'),
|
||||
events.line('line_added', 't1', 3, { minutes: 1 }),
|
||||
events.line('line_removed', 't1', 1, { minutes: 2 }),
|
||||
events.settled('t1', 2, 3),
|
||||
],
|
||||
});
|
||||
|
||||
expect(colaStock(result)).toBe(46);
|
||||
});
|
||||
|
||||
it('keeps a count when sales from before it arrive later', async () => {
|
||||
const { sync, tabletA, tabletB } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
await sync(tabletB, { events: [events.stockSet('cola', 30, 60)] });
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
events: [
|
||||
events.opened('t1', 'Alice'),
|
||||
// Rung up offline before the count: already in it
|
||||
events.line('line_added', 't1', 2, { minutes: 50 }),
|
||||
// An older count from an offline tablet doesn't overwrite the newer one
|
||||
events.stockSet('cola', 70, 55),
|
||||
events.line('line_added', 't1', 3, { minutes: 70 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(colaStock(result)).toBe(27);
|
||||
expect(result.stock[0].countedAt).toBe(at(60));
|
||||
});
|
||||
|
||||
it('applies a pending dashboard adjustment once and no longer sends adjustments', async () => {
|
||||
const { sync, tabletA, tabletB, prisma } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
prisma.stockAdjustment.rows.push({
|
||||
id: 'adj-1', venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24,
|
||||
status: 'pending', createdAt: new Date(),
|
||||
});
|
||||
|
||||
const first = await sync(tabletA);
|
||||
const second = await sync(tabletB);
|
||||
|
||||
expect(first.stockAdjustments).toEqual([]);
|
||||
expect(colaStock(first)).toBe(72);
|
||||
expect(colaStock(second)).toBe(72);
|
||||
expect(prisma.stockAdjustment.rows[0]).toMatchObject({ status: 'applied', stockAfter: 72 });
|
||||
});
|
||||
|
||||
it('records results from older tablets that applied an adjustment themselves', async () => {
|
||||
const { sync, tabletA, prisma } = setupWithCola();
|
||||
await trackedCola(sync, tabletA);
|
||||
const id = uuidv4();
|
||||
prisma.stockAdjustment.rows.push({
|
||||
id, venueId: 'v1', productId: 'cola', productName: 'Cola', type: 'add', quantity: 24, status: 'pending', createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await sync(tabletA, { stockAdjustmentResults: [{ id, status: 'applied', stockAfter: 72 }] });
|
||||
|
||||
expect(result.acknowledgedStockAdjustmentResultIds).toEqual([id]);
|
||||
// Applied by the tablet, so not again by the server
|
||||
expect(colaStock(result)).toBe(48);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('DevicesService.sync live update signals', () => {
|
||||
it('sends nothing when the sync changed nothing', async () => {
|
||||
const { sync, tabletA, realtime } = setup();
|
||||
await sync(tabletA, { products: catalog(), events: [events.opened('t1', 'Alice')] });
|
||||
realtime.notify.mockClear();
|
||||
|
||||
await sync(tabletA, { products: catalog() });
|
||||
|
||||
expect(realtime.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signals "tabs" and "stock" when they changed', async () => {
|
||||
const { sync, tabletA, realtime, prisma } = setup();
|
||||
addProduct(prisma);
|
||||
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.stockSet('cola', 5)] });
|
||||
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'tabs', 'stock');
|
||||
});
|
||||
|
||||
it('signals "device" when a terminal comes back online', async () => {
|
||||
const { sync, realtime } = setup();
|
||||
|
||||
await sync({ id: 'd1', venueId: 'v1', revokedAt: null, lastSeenAt: new Date(Date.now() - 3_600_000) });
|
||||
|
||||
expect(realtime.notify).toHaveBeenCalledWith('v1', 'device');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: website product changes reach the tablets', () => {
|
||||
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
|
||||
|
||||
function setupWithWebsite() {
|
||||
const venue = setup();
|
||||
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
|
||||
return { ...venue, stock };
|
||||
}
|
||||
|
||||
it('sends a new price to every tablet in its next sync', async () => {
|
||||
const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite();
|
||||
addProduct(prisma);
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
|
||||
const a = await sync(tabletA);
|
||||
const b = await sync(tabletB);
|
||||
|
||||
for (const result of [a, b]) {
|
||||
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', priceInCents: 270 })]);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps old drinks at their price and puts new ones on a new line', async () => {
|
||||
const { sync, tabletA, tabletB, stock, prisma } = setupWithWebsite();
|
||||
addProduct(prisma);
|
||||
await sync(tabletA, {
|
||||
events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2, { lineId: 'old-line' })],
|
||||
});
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
|
||||
// The tablet charges the new price on a line of its own
|
||||
const result = await sync(tabletB, {
|
||||
events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })],
|
||||
});
|
||||
|
||||
expect(tab(result, 't1')!.lines.map((l) => [l.id, l.unitPriceInCents, l.quantity])).toEqual([
|
||||
['old-line', 250, 2],
|
||||
['new-line', 270, 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('settles every line at its own price', async () => {
|
||||
const { sync, tabletA, prisma } = setupWithWebsite();
|
||||
addProduct(prisma);
|
||||
const settled = {
|
||||
...events.settled('t1', 0, 10),
|
||||
amountInCents: 2 * 250 + 270,
|
||||
items: [
|
||||
{ productId: 'cola', productName: 'Cola', quantity: 2, unitPriceInCents: 250 },
|
||||
{ productId: 'cola', productName: 'Cola', quantity: 1, unitPriceInCents: 270 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = await sync(tabletA, {
|
||||
events: [
|
||||
events.opened('t1', 'Alice'),
|
||||
events.line('line_added', 't1', 2, { lineId: 'old-line' }),
|
||||
events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 }),
|
||||
settled,
|
||||
],
|
||||
});
|
||||
|
||||
expect(tab(result, 't1')!.lines).toEqual([]);
|
||||
expect(result.settlements[0].lines.map((l) => [l.unitPriceInCents, l.quantity])).toEqual([[250, 2], [270, 1]]);
|
||||
});
|
||||
|
||||
it('counts both price lines down from the one product stock', async () => {
|
||||
const { sync, tabletA, stock, prisma } = setupWithWebsite();
|
||||
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
|
||||
|
||||
await sync(tabletA, { events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 2)] });
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
|
||||
const result = await sync(tabletA, {
|
||||
events: [events.line('line_added', 't1', 1, { lineId: 'new-line', price: 270, minutes: 5 })],
|
||||
});
|
||||
|
||||
expect(colaStock(result)).toBe(17);
|
||||
});
|
||||
|
||||
it('sends a product added on the website with its starting stock', async () => {
|
||||
const { sync, tabletA, stock } = setupWithWebsite();
|
||||
|
||||
const created = await stock.createProduct('v1', { name: 'Ice Tea', category: 'Frisdrank', priceInCents: 280, stock: 24 }, admin);
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(result.catalog).toEqual([expect.objectContaining({ id: created.productId, name: 'Ice Tea', priceInCents: 280 })]);
|
||||
expect(result.stock).toEqual([expect.objectContaining({ productId: created.productId, stock: 24 })]);
|
||||
});
|
||||
|
||||
it('never sends the buy price or desired stock to the tablets', async () => {
|
||||
const { sync, tabletA, stock } = setupWithWebsite();
|
||||
|
||||
await stock.createProduct('v1', { name: 'Ice Tea', priceInCents: 280, buyPriceInCents: 110, stock: 4, desiredStock: 24 }, admin);
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(result.catalog![0]).not.toHaveProperty('buyPriceInCents');
|
||||
expect(result.catalog![0]).not.toHaveProperty('desiredStock');
|
||||
});
|
||||
|
||||
it('marks an archived product, and still counts a sale from a tablet that had not synced yet', async () => {
|
||||
const { sync, tabletA, stock, prisma } = setupWithWebsite();
|
||||
addProduct(prisma, 'cola', { stock: 20, countedAt: new Date('2026-01-01') });
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { archived: true }, admin);
|
||||
const result = await sync(tabletA, {
|
||||
events: [events.opened('t1', 'Alice'), events.line('line_added', 't1', 1, { minutes: 1 })],
|
||||
});
|
||||
|
||||
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', archived: true })]);
|
||||
expect(tab(result, 't1')!.lines).toMatchObject([{ productId: 'cola', quantity: 1 }]);
|
||||
expect(colaStock(result)).toBe(19);
|
||||
});
|
||||
|
||||
it('a renamed product keeps its id, so tablets update it instead of adding one', async () => {
|
||||
const { sync, tabletA, stock, prisma } = setupWithWebsite();
|
||||
addProduct(prisma);
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { name: 'Coca-Cola' }, admin);
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(result.catalog).toEqual([expect.objectContaining({ id: 'cola', name: 'Coca-Cola' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DevicesService.sync: products created and changed on a tablet', () => {
|
||||
const admin = { id: 'u1', email: 'admin@example.com', name: 'Admin' };
|
||||
|
||||
function setupWithWebsite() {
|
||||
const venue = setup();
|
||||
const stock = new StockService(venue.prisma as any, { log: jest.fn() } as any, { notify: jest.fn() } as any);
|
||||
return { ...venue, stock };
|
||||
}
|
||||
|
||||
const catalogEntry = (result: SyncResult, id: string) => result.catalog.find((p) => p.id === id);
|
||||
|
||||
it('creates a product made offline, and sells and counts it in the same sync', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
|
||||
await sync(tabletA, {
|
||||
events: [
|
||||
events.product('product_created', 'iced', { name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3 }, 1),
|
||||
events.stockSet('iced', 12, 2),
|
||||
events.opened('t1', 'Alice', 3),
|
||||
events.line('line_added', 't1', 2, { minutes: 4, productId: 'iced', productName: 'Ice Coffee', price: 350 }),
|
||||
],
|
||||
});
|
||||
const result = await sync(tabletB);
|
||||
|
||||
expect(catalogEntry(result, 'iced')).toEqual(
|
||||
{ id: 'iced', name: 'Ice Coffee', category: 'Koffie', priceInCents: 350, lowStockThreshold: 3, archived: false, imageId: null });
|
||||
expect(result.stock.find((s) => s.productId === 'iced')?.stock).toBe(10);
|
||||
});
|
||||
|
||||
it('keeps both a website price change and an offline rename of the same product', async () => {
|
||||
const { sync, tabletA, stock, prisma } = setupWithWebsite();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 270 }, admin);
|
||||
// Renamed offline before the price change, synced after it
|
||||
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 5)] });
|
||||
|
||||
expect(catalogEntry(result, 'cola')).toMatchObject({ name: 'Coca-Cola', priceInCents: 270 });
|
||||
expect(prisma.product.rows[0].fieldUpdatedAt).toMatchObject({ name: at(5) });
|
||||
});
|
||||
|
||||
it('lets the latest change of a field win, also when the older one arrives last', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
await sync(tabletB, { events: [events.product('product_updated', 'cola', { priceInCents: 300 }, 20)] });
|
||||
// Tablet A was offline: its older price change arrives later and loses
|
||||
const result = await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 10)] });
|
||||
|
||||
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(300);
|
||||
});
|
||||
|
||||
it('a website change made after a tablet change wins', async () => {
|
||||
const { sync, tabletA, stock } = setupWithWebsite();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
await sync(tabletA, { events: [events.product('product_updated', 'cola', { priceInCents: 280 }, 1)] });
|
||||
|
||||
await stock.updateProduct('v1', 'cola', { priceInCents: 260 }, admin);
|
||||
const result = await sync(tabletA);
|
||||
|
||||
expect(catalogEntry(result, 'cola')!.priceInCents).toBe(260);
|
||||
});
|
||||
|
||||
it('archives and restores a product from a tablet', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
const archived = await sync(tabletA, { events: [events.product('product_archived', 'cola', undefined, 1)] });
|
||||
expect(catalogEntry(archived, 'cola')!.archived).toBe(true);
|
||||
|
||||
const restored = await sync(tabletA, { events: [events.product('product_restored', 'cola', undefined, 2)] });
|
||||
expect(catalogEntry(restored, 'cola')!.archived).toBe(false);
|
||||
});
|
||||
|
||||
it('only links a picture that was uploaded', async () => {
|
||||
const { sync, tabletA, prisma } = setup();
|
||||
prisma.productImage.rows.push({ id: 'a'.repeat(64), contentType: 'image/webp', width: 512, height: 512, bytes: Buffer.from('x') });
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
const unknown = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'b'.repeat(64) }, 1)] });
|
||||
expect(catalogEntry(unknown, 'cola')!.imageId).toBeNull();
|
||||
|
||||
const known = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: 'a'.repeat(64) }, 2)] });
|
||||
expect(catalogEntry(known, 'cola')!.imageId).toBe('a'.repeat(64));
|
||||
|
||||
const removed = await sync(tabletA, { events: [events.product('product_updated', 'cola', { imageId: null }, 3)] });
|
||||
expect(catalogEntry(removed, 'cola')!.imageId).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts two products with the same name from two offline tablets', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
|
||||
await sync(tabletA, { events: [events.product('product_created', 'cola-a', { name: 'Cola', priceInCents: 250 }, 0)] });
|
||||
const result = await sync(tabletB, { events: [events.product('product_created', 'cola-b', { name: 'cola', priceInCents: 250 }, 0)] });
|
||||
|
||||
expect(result.catalog.map((p) => p.id).sort()).toEqual(['cola-a', 'cola-b']);
|
||||
});
|
||||
|
||||
it('acknowledges a change to a product the venue does not have, without creating it', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const change = events.product('product_updated', 'gone', { name: 'Gone' }, 0);
|
||||
|
||||
const result = await sync(tabletA, { events: [change] });
|
||||
|
||||
expect(result.acknowledgedEventIds).toEqual([change.id]);
|
||||
expect(result.catalog).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a new product without a price', async () => {
|
||||
const { sync, tabletA } = setup();
|
||||
const incomplete = events.product('product_created', 'x', { name: 'X' }, 0);
|
||||
|
||||
const result = await sync(tabletA, { events: [incomplete] });
|
||||
|
||||
expect(result.rejectedEvents).toEqual([{ id: incomplete.id, reason: 'a new product needs a name and a price' }]);
|
||||
});
|
||||
|
||||
it('applies a product event once, also when it is sent again', async () => {
|
||||
const { sync, tabletA, tabletB } = setup();
|
||||
const created = events.product('product_created', 'cola', { name: 'Cola', priceInCents: 250 }, 0);
|
||||
const renamed = events.product('product_updated', 'cola', { name: 'Coca-Cola' }, 1);
|
||||
await sync(tabletA, { events: [created, renamed] });
|
||||
await sync(tabletB, { events: [events.product('product_updated', 'cola', { name: 'Cola Classic' }, 2)] });
|
||||
|
||||
// The old rename again, with its id: already applied, so it must not undo the newer name
|
||||
const result = await sync(tabletA, { events: [renamed] });
|
||||
|
||||
expect(catalogEntry(result, 'cola')!.name).toBe('Cola Classic');
|
||||
});
|
||||
});
|
||||
@@ -1,349 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RealtimeService, RealtimeTopic } from '../realtime/realtime.service';
|
||||
import { DEVICE_ONLINE_THRESHOLD_MS } from '@kooltap/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { SyncDto } from './dto/sync.dto';
|
||||
import { AckCommandDto } from './dto/ack-command.dto';
|
||||
import { eventTime, parseTabEvents, tabEventRow } from './tab-events';
|
||||
import { ParsedProduct, parseProducts, parseStockResults } from './stock-sync';
|
||||
import { VenueLedger } from '../tabs/venue-ledger';
|
||||
import { seedTabs, tabJson } from '../tabs/tab-state';
|
||||
import { openPlannedTabs, settlementsAfter } from '../tabs/tab-sync';
|
||||
import { applyPendingAdjustments } from '../stock/stock-adjustments';
|
||||
import { applyProductEvent } from '../stock/product-events';
|
||||
import { isProductEvent } from './dto/tab-event.dto';
|
||||
import { CashPaymentNotifier } from '../email/cash-payment-notifier';
|
||||
|
||||
@Injectable()
|
||||
export class DevicesService {
|
||||
private readonly logger = new Logger(DevicesService.name);
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private auditService: AuditService,
|
||||
private realtime: RealtimeService,
|
||||
private cashPayments: CashPaymentNotifier,
|
||||
) {}
|
||||
|
||||
async sync(device: any, dto: SyncDto) {
|
||||
if (!device) {
|
||||
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
|
||||
}
|
||||
if (device.revokedAt) {
|
||||
throw new ForbiddenException({ code: 'device_revoked', message: 'Device has been revoked' });
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const venueId: string = device.venueId;
|
||||
const events = parseTabEvents(dto.events ?? []);
|
||||
for (const { id, reason } of events.rejected) {
|
||||
this.logger.warn(`Device ${device.id} sent an invalid tab event ${id ?? '(no id)'}: ${reason}`);
|
||||
}
|
||||
// Omitted by app versions without stock
|
||||
const reported = dto.products ? parseProducts(dto.products) : null;
|
||||
for (const { id, reason } of reported?.rejected ?? []) {
|
||||
this.logger.warn(`Device ${device.id} sent an invalid product ${id ?? '(no id)'}: ${reason}`);
|
||||
}
|
||||
const stockResults = parseStockResults(dto.stockAdjustmentResults ?? []);
|
||||
for (const { id, reason } of stockResults.rejected) {
|
||||
this.logger.warn(`Device ${device.id} sent an invalid stock adjustment result ${id ?? '(no id)'}: ${reason}`);
|
||||
}
|
||||
|
||||
const synced = await this.prisma.$transaction(async (tx) => {
|
||||
// Locks the venue: other terminals' syncs wait, so no change is lost
|
||||
const ledger = await VenueLedger.open(tx, venueId);
|
||||
|
||||
// 1. The dashboard manages the catalog: a terminal's own catalog is only taken over once, with the tabs
|
||||
let productsChanged = reported && !ledger.seeded
|
||||
? await this.saveProducts(tx, ledger, reported.products, now)
|
||||
: false;
|
||||
|
||||
// 2. Events, each applied once: a terminal resends an event until its acknowledgement arrives
|
||||
const unique = [...new Map(events.accepted.map((event) => [event.id, event])).values()];
|
||||
const stored = unique.length === 0 ? [] : await tx.tabEvent.findMany({
|
||||
where: { id: { in: unique.map((event) => event.id) } },
|
||||
select: { id: true },
|
||||
});
|
||||
const storedIds = new Set(stored.map((event) => event.id));
|
||||
const fresh = unique.filter((event) => !storedIds.has(event.id));
|
||||
if (fresh.length > 0) {
|
||||
await tx.tabEvent.createMany({ data: fresh.map((event) => tabEventRow(event, venueId, device.id, now)) });
|
||||
}
|
||||
|
||||
if (!ledger.seeded) {
|
||||
// The first sync since the server holds the tabs and stock: the terminal's own tabs and stock already
|
||||
// include the events of this request, so they are stored but not applied again
|
||||
ledger.seed(seedTabs(dto.openTabs ?? [], now));
|
||||
for (const product of reported?.products ?? []) ledger.stock.seed(product.id, product.stock);
|
||||
} else {
|
||||
for (const event of fresh) {
|
||||
const occurredAt = eventTime(event, now);
|
||||
// Products first: a sale or count of a product created offline arrives in the same request
|
||||
if (isProductEvent(event)) {
|
||||
if (await applyProductEvent(tx, ledger, event, occurredAt)) productsChanged = true;
|
||||
continue;
|
||||
}
|
||||
// Only drinks that were still on the tab go back into the stock: one settled or deleted meanwhile was sold
|
||||
const stockEvent = event.type === 'line_removed' ? { ...event, quantity: ledger.tabs.removable(event) } : event;
|
||||
const settlement = ledger.tabs.apply(event, occurredAt);
|
||||
if (settlement) ledger.settlements.push({ ...settlement, deviceId: device.id });
|
||||
ledger.stock.apply(stockEvent, occurredAt);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Outcomes from app versions that applied dashboard adjustments themselves. The terminal's outcome is the
|
||||
// truth, even for an adjustment cancelled while it was on its way
|
||||
let stockResultsRecorded = 0;
|
||||
for (const result of stockResults.accepted) {
|
||||
const { count } = await tx.stockAdjustment.updateMany({
|
||||
where: { id: result.id, venueId, status: { in: ['pending', 'cancelled'] } },
|
||||
data: {
|
||||
status: result.status,
|
||||
stockAfter: result.status === 'applied' ? result.stockAfter : null,
|
||||
result: result.reason ?? null,
|
||||
processedAt: now,
|
||||
},
|
||||
});
|
||||
stockResultsRecorded += count;
|
||||
}
|
||||
// Requested before the server applied adjustments itself, and never applied by a terminal
|
||||
const adjustmentsApplied = await applyPendingAdjustments(tx, ledger, now);
|
||||
|
||||
// 4. Planned customers become tabs here, once, instead of on every terminal
|
||||
const plannedDelivered = await openPlannedTabs(tx, ledger, now);
|
||||
|
||||
await ledger.save(tx, now);
|
||||
await tx.device.update({ where: { id: device.id }, data: { lastSeenAt: now } });
|
||||
|
||||
const feed = await settlementsAfter(tx, venueId, dto.settlementsCursor, now);
|
||||
const catalog = await tx.product.findMany({
|
||||
where: { venueId },
|
||||
orderBy: [{ category: 'asc' }, { name: 'asc' }],
|
||||
select: {
|
||||
productId: true, name: true, category: true, priceInCents: true, lowStockThreshold: true, archivedAt: true,
|
||||
imageId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ledger,
|
||||
feed,
|
||||
catalog,
|
||||
topics: {
|
||||
tabs: ledger.changedTabs,
|
||||
plannedTabs: plannedDelivered > 0,
|
||||
stock: productsChanged || ledger.stock.changed.size > 0 || stockResultsRecorded > 0 || adjustmentsApplied > 0,
|
||||
},
|
||||
};
|
||||
}, { timeout: 15000 });
|
||||
|
||||
// Expire old commands
|
||||
const expired = await this.prisma.remoteCommand.updateMany({
|
||||
where: { deviceId: device.id, status: 'pending', expiresAt: { lte: now } },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
|
||||
// close_tab commands are no longer created (the server settles tabs itself); ones from before that are
|
||||
// still delivered. Every pending command is returned on each sync; devices dedupe by command id
|
||||
const cursor = Buffer.from(`${now.toISOString()}:${uuidv4().substring(0, 8)}`).toString('base64url');
|
||||
const commands = await this.prisma.remoteCommand.findMany({
|
||||
where: { deviceId: device.id, status: 'pending' },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
targetTabId: true,
|
||||
expectedVersion: true,
|
||||
paymentMethod: true,
|
||||
requestedBy: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
const topics: RealtimeTopic[] = [];
|
||||
if (synced.topics.tabs) topics.push('tabs');
|
||||
if (synced.topics.plannedTabs) topics.push('planned-tabs');
|
||||
if (expired.count > 0) topics.push('commands');
|
||||
if (synced.topics.stock) topics.push('stock');
|
||||
// Only when the device comes back online; dashboards poll the device status themselves
|
||||
if (now.getTime() - new Date(device.lastSeenAt).getTime() > DEVICE_ONLINE_THRESHOLD_MS) topics.push('device');
|
||||
if (topics.length > 0) this.realtime.notify(venueId, ...topics);
|
||||
|
||||
const { ledger, feed, catalog } = synced;
|
||||
this.cashPayments.notify(venueId, ledger.settlements, device.name);
|
||||
return {
|
||||
cursor,
|
||||
commands: commands.map((c) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
tabId: c.targetTabId,
|
||||
expectedVersion: c.expectedVersion,
|
||||
paymentMethod: c.paymentMethod,
|
||||
requestedBy: c.requestedBy,
|
||||
createdAt: c.createdAt,
|
||||
})),
|
||||
// The server opens planned tabs itself; terminals must not open them as well
|
||||
plannedTabNames: [],
|
||||
// Applied (or already applied earlier): the terminal can drop these from its outbox
|
||||
acknowledgedEventIds: events.accepted.map((e) => e.id),
|
||||
// Invalid and never applied: the terminal should log and drop these too, re-sending won't help
|
||||
rejectedEvents: events.rejected,
|
||||
// The server applies dashboard adjustments itself
|
||||
stockAdjustments: [],
|
||||
// Recorded (or already recorded earlier): the terminal can drop these results from its outbox
|
||||
acknowledgedStockAdjustmentResultIds: stockResults.accepted.map((r) => r.id),
|
||||
// Invalid and never recorded: drop these too, re-sending won't help
|
||||
rejectedStockAdjustmentResults: stockResults.rejected,
|
||||
// The venue's complete state after this request's events: the terminal replaces its own with it
|
||||
tabs: ledger.tabs.all().map(tabJson),
|
||||
// Every product of the venue; an archived one is hidden on the terminal (tabs and history refer to it)
|
||||
catalog: catalog.map((p) => ({
|
||||
id: p.productId,
|
||||
name: p.name,
|
||||
category: p.category,
|
||||
priceInCents: p.priceInCents,
|
||||
lowStockThreshold: p.lowStockThreshold,
|
||||
archived: p.archivedAt !== null,
|
||||
// The picture is at /images/<imageId>; null: none
|
||||
imageId: p.imageId,
|
||||
})),
|
||||
stock: ledger.stock.entries().map(([productId, { stock, countedAt }]) => (
|
||||
{ productId, stock, countedAt: countedAt?.toISOString() ?? null })),
|
||||
settlements: feed.settlements,
|
||||
settlementsCursor: feed.settlementsCursor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes over the terminal's catalog on the first sync since the server holds the tabs; the dashboard manages it
|
||||
* from then on. Never removes a product. Returns whether anything the dashboard shows changed.
|
||||
*/
|
||||
private async saveProducts(tx: Prisma.TransactionClient, ledger: VenueLedger, products: ParsedProduct[], now: Date) {
|
||||
const stored = await tx.product.findMany({
|
||||
where: { venueId: ledger.venueId },
|
||||
select: { productId: true, name: true, category: true, priceInCents: true },
|
||||
});
|
||||
const storedById = new Map(stored.map((p) => [p.productId, p]));
|
||||
|
||||
let changed = false;
|
||||
const unchanged: string[] = [];
|
||||
for (const product of products) {
|
||||
const previous = storedById.get(product.id);
|
||||
if (previous && previous.name === product.name && previous.category === product.category
|
||||
&& previous.priceInCents === product.priceInCents) {
|
||||
unchanged.push(product.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = { name: product.name, category: product.category, priceInCents: product.priceInCents, reportedAt: now };
|
||||
await tx.product.upsert({
|
||||
where: { venueId_productId: { venueId: ledger.venueId, productId: product.id } },
|
||||
// Not tracked until counted: its starting stock arrives as a stock_set event
|
||||
create: { venueId: ledger.venueId, productId: product.id, ...data, stock: null },
|
||||
update: data,
|
||||
});
|
||||
ledger.stock.addProduct(product.id);
|
||||
changed = true;
|
||||
}
|
||||
if (unchanged.length > 0) {
|
||||
await tx.product.updateMany({
|
||||
where: { venueId: ledger.venueId, productId: { in: unchanged } },
|
||||
data: { reportedAt: now },
|
||||
});
|
||||
}
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
async ackCommand(device: any, commandId: string, dto: AckCommandDto) {
|
||||
if (!device) {
|
||||
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
|
||||
}
|
||||
const command = await this.prisma.remoteCommand.findUnique({ where: { id: commandId } });
|
||||
|
||||
if (!command) {
|
||||
throw new NotFoundException({ code: 'command_not_found', message: 'Command not found' });
|
||||
}
|
||||
|
||||
if (command.deviceId !== device.id) {
|
||||
throw new ForbiddenException({ code: 'command_not_for_device', message: 'Command does not belong to this device' });
|
||||
}
|
||||
|
||||
if (command.venueId !== device.venueId) {
|
||||
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Command belongs to a different venue' });
|
||||
}
|
||||
|
||||
// Idempotent: the first acknowledgement wins, retries and late duplicates are no-ops
|
||||
if (command.acknowledgedAt) {
|
||||
return { ok: true, status: command.status };
|
||||
}
|
||||
|
||||
const updated = await this.prisma.remoteCommand.updateMany({
|
||||
where: { id: commandId, acknowledgedAt: null },
|
||||
data: {
|
||||
status: dto.status,
|
||||
result: dto.result || null,
|
||||
acknowledgedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (updated.count === 0) {
|
||||
// A concurrent ack got there first
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
await this.auditService.log(
|
||||
device.venueId,
|
||||
'device',
|
||||
device.id,
|
||||
'command_acknowledged',
|
||||
{ commandId, status: dto.status, result: dto.result },
|
||||
);
|
||||
|
||||
this.realtime.notify(device.venueId, 'commands', 'tabs');
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async revoke(venueId: string, deviceId: string) {
|
||||
const device = await this.prisma.device.findUnique({ where: { id: deviceId } });
|
||||
|
||||
if (!device) {
|
||||
throw new NotFoundException({ code: 'device_not_found', message: 'Device not found' });
|
||||
}
|
||||
|
||||
if (device.venueId !== venueId) {
|
||||
throw new ForbiddenException({ code: 'venue_mismatch', message: 'Device belongs to a different venue' });
|
||||
}
|
||||
|
||||
if (device.revokedAt) {
|
||||
return { ok: true }; // Idempotent
|
||||
}
|
||||
|
||||
await this.prisma.device.update({
|
||||
where: { id: deviceId },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
|
||||
// Expire pending commands
|
||||
await this.prisma.remoteCommand.updateMany({
|
||||
where: { deviceId, status: 'pending' },
|
||||
data: { status: 'expired' },
|
||||
});
|
||||
|
||||
await this.auditService.log(venueId, 'system', deviceId, 'device_revoked', { deviceName: device.name });
|
||||
|
||||
this.realtime.notify(venueId, 'device', 'commands');
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { IsString, IsIn, IsOptional } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class AckCommandDto {
|
||||
@ApiProperty({ example: 'cmd-uuid' })
|
||||
@IsString()
|
||||
commandId: string;
|
||||
|
||||
@ApiProperty({ example: 'completed', enum: ['completed', 'failed', 'conflict', 'expired'] })
|
||||
@IsString()
|
||||
@IsIn(['completed', 'failed', 'conflict', 'expired'])
|
||||
status: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Tab closed successfully' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
result?: string;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { IsString, IsUUID, IsNotEmpty } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class RegisterDeviceDto {
|
||||
@ApiProperty({ example: 'uuid' })
|
||||
@IsUUID()
|
||||
venueId: string;
|
||||
|
||||
@ApiProperty({ example: 'Bar terminal 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@ApiProperty({ example: 'ABC123' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
registrationCode: string;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { IsString, IsIn, IsInt, Min, IsNotEmpty, MaxLength, IsOptional, IsUUID, ValidateIf } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ReportedProductDto {
|
||||
@ApiProperty({ example: 'local-product-id', description: "The terminal's own id for the product" })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(100)
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ example: 'Cola' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Frisdrank' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
category?: string | null;
|
||||
|
||||
@ApiProperty({ example: 250 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
priceInCents: number;
|
||||
|
||||
@ApiProperty({ example: 48, nullable: true, description: 'Null when stock is not tracked; may be negative' })
|
||||
@ValidateIf((product: ReportedProductDto) => product.stock !== null)
|
||||
@IsInt()
|
||||
stock: number | null;
|
||||
}
|
||||
|
||||
export const STOCK_RESULT_STATUSES = ['applied', 'rejected'] as const;
|
||||
|
||||
export class StockAdjustmentResultDto {
|
||||
@ApiProperty({ description: 'The stock adjustment id from the sync response' })
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@ApiProperty({ enum: STOCK_RESULT_STATUSES })
|
||||
@IsIn(STOCK_RESULT_STATUSES)
|
||||
status: (typeof STOCK_RESULT_STATUSES)[number];
|
||||
|
||||
@ApiPropertyOptional({ example: 72, description: 'applied: the stock right after applying it' })
|
||||
@ValidateIf((result: StockAdjustmentResultDto) => result.status === 'applied')
|
||||
@IsInt()
|
||||
stockAfter?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'unknown_product', description: 'rejected: why' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
reason?: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync } from 'class-validator';
|
||||
import { ReportedProductDto, StockAdjustmentResultDto } from './dto/stock-sync.dto';
|
||||
import { describe } from './tab-events';
|
||||
|
||||
export type Rejected = { id: string | null; reason: string };
|
||||
|
||||
/**
|
||||
* Validates entries one by one, like tab events: one bad product or result is skipped on its own
|
||||
* instead of failing the sync (and with it the open tabs and sales).
|
||||
*/
|
||||
function parseEach<T extends object>(cls: new () => T, raw: unknown[]): { accepted: T[]; rejected: Rejected[] } {
|
||||
const accepted: T[] = [];
|
||||
const rejected: Rejected[] = [];
|
||||
|
||||
for (const value of raw) {
|
||||
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
rejected.push({ id, reason: 'must be an object' });
|
||||
continue;
|
||||
}
|
||||
const entry = plainToInstance(cls, value);
|
||||
const errors = validateSync(entry, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
rejected.push({ id, reason: describe(errors) });
|
||||
continue;
|
||||
}
|
||||
accepted.push(entry);
|
||||
}
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
export function parseProducts(raw: unknown[]) {
|
||||
const { accepted, rejected } = parseEach(ReportedProductDto, raw);
|
||||
// A duplicated id would make the upserts fight; the last one reported wins
|
||||
const byId = new Map(accepted.map((product) => [product.id, product]));
|
||||
return {
|
||||
products: [...byId.values()].map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name.trim(),
|
||||
category: p.category?.trim() || null,
|
||||
priceInCents: p.priceInCents,
|
||||
stock: p.stock ?? null,
|
||||
})),
|
||||
rejected,
|
||||
};
|
||||
}
|
||||
|
||||
export type ParsedProduct = ReturnType<typeof parseProducts>['products'][number];
|
||||
|
||||
export function parseStockResults(raw: unknown[]) {
|
||||
return parseEach(StockAdjustmentResultDto, raw);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validateSync, ValidationError } from 'class-validator';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { isProductEvent, TabEventDto } from './dto/tab-event.dto';
|
||||
|
||||
// A terminal clock that runs ahead must not put sales in the future
|
||||
const MAX_CLOCK_AHEAD_MS = 5 * 60 * 1000;
|
||||
|
||||
export type RejectedTabEvent = { id: string | null; reason: string };
|
||||
|
||||
export function parseTabEvents(raw: unknown[]): { accepted: TabEventDto[]; rejected: RejectedTabEvent[] } {
|
||||
const accepted: TabEventDto[] = [];
|
||||
const rejected: RejectedTabEvent[] = [];
|
||||
|
||||
for (const value of raw) {
|
||||
const id = typeof (value as { id?: unknown })?.id === 'string' ? (value as { id: string }).id : null;
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
rejected.push({ id, reason: 'event must be an object' });
|
||||
continue;
|
||||
}
|
||||
const event = plainToInstance(TabEventDto, value);
|
||||
// Unknown fields are ignored so a newer app version adding one never loses sales
|
||||
const errors = validateSync(event, { whitelist: true });
|
||||
if (errors.length > 0) {
|
||||
rejected.push({ id, reason: describe(errors) });
|
||||
continue;
|
||||
}
|
||||
// Validated here: 0 is a valid stock count, but not a valid number of drinks
|
||||
if ((event.type === 'line_added' || event.type === 'line_removed') && event.quantity! < 1) {
|
||||
rejected.push({ id, reason: 'quantity must not be less than 1' });
|
||||
continue;
|
||||
}
|
||||
if (event.type === 'product_created' && (!event.product?.name || event.product.priceInCents === undefined)) {
|
||||
rejected.push({ id, reason: 'a new product needs a name and a price' });
|
||||
continue;
|
||||
}
|
||||
accepted.push(event);
|
||||
}
|
||||
|
||||
return { accepted, rejected };
|
||||
}
|
||||
|
||||
/** When the event happened, as stored and applied: a terminal clock running ahead is capped at [now]. */
|
||||
export function eventTime(event: TabEventDto, now: Date): Date {
|
||||
const occurredAt = new Date(event.occurredAt);
|
||||
return occurredAt.getTime() > now.getTime() + MAX_CLOCK_AHEAD_MS ? now : occurredAt;
|
||||
}
|
||||
|
||||
export function tabEventRow(event: TabEventDto, venueId: string, deviceId: string, now: Date): Prisma.TabEventCreateManyInput {
|
||||
const base = {
|
||||
id: event.id,
|
||||
venueId,
|
||||
deviceId,
|
||||
tabId: event.tabId ?? null,
|
||||
customerName: event.customerName ?? null,
|
||||
type: event.type,
|
||||
occurredAt: eventTime(event, now),
|
||||
receivedAt: now,
|
||||
};
|
||||
|
||||
if (event.type === 'stock_set') {
|
||||
return { ...base, productId: event.productId, stockSetTo: event.quantity ?? null };
|
||||
}
|
||||
if (isProductEvent(event)) {
|
||||
return {
|
||||
...base,
|
||||
productId: event.productId,
|
||||
productData: event.product ? ({ ...event.product } as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
};
|
||||
}
|
||||
if (event.type === 'tab_opened' || event.type === 'tab_renamed' || event.type === 'tab_deleted') {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (event.type === 'settled') {
|
||||
return {
|
||||
...base,
|
||||
amountInCents: event.amountInCents,
|
||||
paymentMethod: event.paymentMethod,
|
||||
commandId: event.commandId ?? null,
|
||||
items: event.items
|
||||
? event.items.map(({ productId, productName, quantity, unitPriceInCents }) => (
|
||||
{ productId: productId ?? null, productName, quantity, unitPriceInCents }))
|
||||
: Prisma.JsonNull,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
lineId: event.lineId ?? null,
|
||||
productId: event.productId ?? null,
|
||||
productName: event.productName,
|
||||
unitPriceInCents: event.unitPriceInCents,
|
||||
quantityDelta: event.type === 'line_removed' ? -event.quantity! : event.quantity,
|
||||
orderId: event.orderId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function describe(errors: ValidationError[]): string {
|
||||
return errors
|
||||
.flatMap((error) => [
|
||||
...Object.values(error.constraints ?? {}),
|
||||
...(error.children?.length ? [`${error.property}: ${describe(error.children)}`] : []),
|
||||
])
|
||||
.join('; ');
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { CashPaymentNotifier } from './cash-payment-notifier';
|
||||
import { createFakePrisma } from '../testing/fake-prisma';
|
||||
|
||||
const settlement = (paymentMethod: string, id = 's1') => ({
|
||||
id, tabId: 't1', customerName: 'Jan', settledAt: new Date('2026-10-27T20:15:00Z'), paymentMethod, amountInCents: 500,
|
||||
commandId: null,
|
||||
lines: [{ id: 'l1', productId: 'cola', productName: 'Cola', unitPriceInCents: 250, quantity: 2, units: [] }],
|
||||
});
|
||||
|
||||
function setup(emails: string[]) {
|
||||
process.env.RESEND_API_KEY = 'test-key';
|
||||
const prisma = createFakePrisma();
|
||||
prisma.venue.rows.push({ id: 'v1', name: 'Café De Kroon', cashPaymentEmails: emails });
|
||||
const render = jest.fn().mockResolvedValue({ subject: 'Cash', html: '<p>Cash</p>' });
|
||||
const notifier = new CashPaymentNotifier(prisma as any, { render } as any);
|
||||
const fetchMock = jest.fn().mockResolvedValue({ ok: true, text: async () => '' });
|
||||
global.fetch = fetchMock as any;
|
||||
return { notifier, render, fetchMock };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.RESEND_API_KEY;
|
||||
});
|
||||
|
||||
describe('CashPaymentNotifier', () => {
|
||||
it('emails every address about a cash payment, once per settlement', async () => {
|
||||
const { notifier, render, fetchMock } = setup(['a@bar.be', 'b@bar.be']);
|
||||
|
||||
await notifier.send('v1', [settlement('cash')], 'Bar tablet');
|
||||
|
||||
expect(render).toHaveBeenCalledWith('cash-payment', expect.objectContaining({
|
||||
customerName: 'Jan', venueName: 'Café De Kroon', settledBy: 'Bar tablet', hasItems: true,
|
||||
items: [expect.objectContaining({ quantity: 2, productName: 'Cola' })],
|
||||
}));
|
||||
const [url, request] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe('https://api.resend.com/emails/batch');
|
||||
expect(request.headers['Idempotency-Key']).toBe('cash-payment-s1');
|
||||
expect(JSON.parse(request.body).map((m: { to: string }) => m.to)).toEqual(['a@bar.be', 'b@bar.be']);
|
||||
});
|
||||
|
||||
it('sends nothing for other payment methods or without addresses', async () => {
|
||||
const withAddress = setup(['a@bar.be']);
|
||||
withAddress.notifier.notify('v1', [settlement('payconiq')], 'Bar tablet');
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(withAddress.fetchMock).not.toHaveBeenCalled();
|
||||
|
||||
const withoutAddress = setup([]);
|
||||
await withoutAddress.notifier.send('v1', [settlement('cash')], 'Bar tablet');
|
||||
expect(withoutAddress.fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EmailTemplatesService } from './email-templates.service';
|
||||
import { RecordedSettlement } from '../tabs/venue-ledger';
|
||||
|
||||
// Belgian notation, as on the dashboard: € 25,00
|
||||
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
|
||||
const formatEuro = (cents: number) => euro.format(cents / 100);
|
||||
import { formatEuro, Resend } from './resend';
|
||||
import { SettlementDraft } from '../ledger/tab-state';
|
||||
|
||||
const dateTime = new Intl.DateTimeFormat('nl-BE', {
|
||||
timeZone: 'Europe/Brussels', day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
@@ -18,16 +15,15 @@ const dateTime = new Intl.DateTimeFormat('nl-BE', {
|
||||
@Injectable()
|
||||
export class CashPaymentNotifier {
|
||||
private readonly logger = new Logger(CashPaymentNotifier.name);
|
||||
private readonly resendApiKey = process.env.RESEND_API_KEY || '';
|
||||
private readonly fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private emailTemplates: EmailTemplatesService,
|
||||
private templates: EmailTemplatesService,
|
||||
private resend: Resend,
|
||||
) {}
|
||||
|
||||
/** Sends in the background; [settlements] may hold other payment methods, which are skipped. */
|
||||
notify(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
|
||||
notify(venueId: string, settlements: SettlementDraft[], settledBy: string) {
|
||||
const cash = settlements.filter((s) => s.paymentMethod === 'cash');
|
||||
if (cash.length === 0) return;
|
||||
this.send(venueId, cash, settledBy).catch((error) => {
|
||||
@@ -35,22 +31,18 @@ export class CashPaymentNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
async send(venueId: string, settlements: RecordedSettlement[], settledBy: string) {
|
||||
const venue = await this.prisma.venue.findUnique({
|
||||
where: { id: venueId },
|
||||
select: { name: true, cashPaymentEmails: true },
|
||||
});
|
||||
const recipients = venue?.cashPaymentEmails ?? [];
|
||||
if (recipients.length === 0) return;
|
||||
if (!this.resendApiKey) {
|
||||
async send(venueId: string, settlements: SettlementDraft[], settledBy: string) {
|
||||
const venue = await this.prisma.venue.findUnique({ where: { id: venueId }, select: { name: true, cashPaymentEmails: true } });
|
||||
if (!venue || venue.cashPaymentEmails.length === 0) return;
|
||||
if (!this.resend.configured) {
|
||||
this.logger.warn(`Not emailing ${settlements.length} cash payment(s) of venue ${venueId}: RESEND_API_KEY is not set`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const settlement of settlements) {
|
||||
const items = settlement.lines.filter((line) => line.quantity > 0);
|
||||
const email = await this.emailTemplates.render('cash-payment', {
|
||||
venueName: venue!.name,
|
||||
const email = await this.templates.render('cash-payment', {
|
||||
venueName: venue.name,
|
||||
customerName: settlement.customerName,
|
||||
amount: formatEuro(settlement.amountInCents),
|
||||
settledAt: dateTime.format(settlement.settledAt),
|
||||
@@ -62,20 +54,9 @@ export class CashPaymentNotifier {
|
||||
lineTotal: formatEuro(line.quantity * line.unitPriceInCents),
|
||||
})),
|
||||
});
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails/batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.resendApiKey}`,
|
||||
// One settlement is emailed once, even when this request is retried
|
||||
'Idempotency-Key': `cash-payment-${settlement.id}`,
|
||||
},
|
||||
body: JSON.stringify(recipients.map((to) => ({ from: this.fromEmail, to, ...email }))),
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${await res.text()}`);
|
||||
}
|
||||
// One settlement is emailed once, even when this is retried
|
||||
const error = await this.resend.sendBatch(venue.cashPaymentEmails.map((to) => ({ to, ...email })), `cash-payment-${settlement.id}`);
|
||||
if (error) this.logger.error(`Cash payment email for settlement ${settlement.id} failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { RenderedEmail } from './email-templates.service';
|
||||
|
||||
export type OutgoingEmail = RenderedEmail & { to: string };
|
||||
|
||||
/** Sends email through Resend (RESEND_API_KEY, RESEND_FROM_EMAIL). */
|
||||
@Injectable()
|
||||
export class Resend {
|
||||
private readonly apiKey = process.env.RESEND_API_KEY || '';
|
||||
private readonly from = process.env.RESEND_FROM_EMAIL || 'KoolTap <onboarding@resend.dev>';
|
||||
|
||||
/** Whether sending is set up, without sending anything. */
|
||||
get configured(): boolean {
|
||||
return this.apiKey !== '';
|
||||
}
|
||||
|
||||
/** Throws the error the dashboard shows when sending is not set up. */
|
||||
ensureConfigured() {
|
||||
if (!this.configured) {
|
||||
throw new BadRequestException({ code: 'resend_not_configured', message: 'Email is not set up: set RESEND_API_KEY' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends up to 100 emails in one request. Resend ignores a request with the same [idempotencyKey] for 24 hours, so a
|
||||
* retry never emails twice. Returns Resend's error text, or null when sent.
|
||||
*/
|
||||
async sendBatch(emails: OutgoingEmail[], idempotencyKey?: string): Promise<string | null> {
|
||||
const res = await fetch('https://api.resend.com/emails/batch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
|
||||
},
|
||||
body: JSON.stringify(emails.map((email) => ({ from: this.from, ...email }))),
|
||||
});
|
||||
return res.ok ? null : await res.text();
|
||||
}
|
||||
}
|
||||
|
||||
// Belgian notation, as on the dashboard: € 25,00
|
||||
const euro = new Intl.NumberFormat('nl-BE', { style: 'currency', currency: 'EUR' });
|
||||
|
||||
export const formatEuro = (cents: number) => euro.format(cents / 100);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user