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

This commit is contained in:
2026-10-29 11:21:52 +01:00
parent 6342b8451e
commit 314717ba3a
244 changed files with 11615 additions and 15292 deletions
-75
View File
@@ -1,75 +0,0 @@
/**
* Applies database migrations on container start.
*
* Databases created before migrations were used (via `prisma db push`) have no
* `_prisma_migrations` table. Those are brought up to date with a non-destructive
* `db push` (which refuses to drop data) and then baselined, so every later deploy
* can use plain `prisma migrate deploy`.
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { PrismaClient } = require('@prisma/client');
// Migrations that were first created with dates in the future, renamed to when they were written.
// The SQL is unchanged, so databases that already applied them only need the recorded name updated.
const RENAMED_MIGRATIONS = {
'20261020120000_tab_events': '20261019212336_tab_events',
'20261021120000_stock': '20261020124522_stock',
'20261022120000_server_tabs_and_stock': '20261020141836_server_tabs_and_stock',
'20261023120000_products_on_dashboard': '20261020215540_products_on_dashboard',
'20261024120000_product_images_and_events': '20261021104519_product_images_and_events',
'20261025120000_sold_unit_view': '20261021225808_sold_unit_view',
'20261026120000_drop_tab_snapshots': '20261021233219_drop_tab_snapshots',
};
function run(command) {
console.log(`[deploy] ${command}`);
execSync(command, { stdio: 'inherit' });
}
async function main() {
const prisma = new PrismaClient();
let hasMigrationsTable;
let hasTables;
try {
const [{ migrations, tables }] = await prisma.$queryRaw`
SELECT
to_regclass('public._prisma_migrations') IS NOT NULL AS migrations,
EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public') AS tables`;
hasMigrationsTable = migrations;
hasTables = tables;
if (hasMigrationsTable) {
for (const [oldName, newName] of Object.entries(RENAMED_MIGRATIONS)) {
const renamed = await prisma.$executeRaw`
UPDATE "_prisma_migrations" SET "migration_name" = ${newName} WHERE "migration_name" = ${oldName}`;
if (renamed > 0) console.log(`[deploy] Renamed migration ${oldName} -> ${newName}`);
}
}
} finally {
await prisma.$disconnect();
}
if (!hasMigrationsTable && hasTables) {
console.log('[deploy] Existing database without migration history, baselining');
run('npx prisma db push --skip-generate');
const migrationsDir = path.join(__dirname, 'migrations');
const migrations = fs
.readdirSync(migrationsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const name of migrations) {
run(`npx prisma migrate resolve --applied ${name}`);
}
}
run('npx prisma migrate deploy');
}
main().catch((error) => {
console.error('[deploy] Migration failed:', error);
process.exit(1);
});
@@ -1,189 +0,0 @@
-- CreateTable
CREATE TABLE "Venue" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"tokenExpiresAt" TIMESTAMP(3) NOT NULL,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ClientUser" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL DEFAULT 'viewer',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ClientUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationCode" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OpenTabSnapshot" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"totalInCents" INTEGER NOT NULL,
"itemCount" INTEGER NOT NULL,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OpenTabSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabOwnership" (
"tabId" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"lastReportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "TabOwnership_pkey" PRIMARY KEY ("venueId","tabId")
);
-- CreateTable
CREATE TABLE "RemoteCommand" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"targetTabId" TEXT NOT NULL,
"type" TEXT NOT NULL DEFAULT 'close_tab',
"expectedVersion" INTEGER NOT NULL,
"paymentMethod" TEXT NOT NULL,
"requestedBy" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"result" TEXT,
"acknowledgedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RemoteCommand_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"details" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Device_venueId_key" ON "Device"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
-- CreateIndex
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "ClientUser_email_key" ON "ClientUser"("email");
-- CreateIndex
CREATE INDEX "ClientUser_venueId_idx" ON "ClientUser"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
-- CreateIndex
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_deviceId_idx" ON "OpenTabSnapshot"("deviceId");
-- CreateIndex
CREATE INDEX "OpenTabSnapshot_tabId_reportedAt_idx" ON "OpenTabSnapshot"("tabId", "reportedAt");
-- CreateIndex
CREATE UNIQUE INDEX "OpenTabSnapshot_deviceId_tabId_key" ON "OpenTabSnapshot"("deviceId", "tabId");
-- CreateIndex
CREATE INDEX "RemoteCommand_deviceId_status_createdAt_idx" ON "RemoteCommand"("deviceId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "RemoteCommand_venueId_status_createdAt_idx" ON "RemoteCommand"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ClientUser" ADD CONSTRAINT "ClientUser_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OpenTabSnapshot" ADD CONSTRAINT "OpenTabSnapshot_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabOwnership" ADD CONSTRAINT "TabOwnership_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,19 +0,0 @@
-- CreateTable
CREATE TABLE "PlannedTab" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"position" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PlannedTab_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PlannedTab_venueId_idx" ON "PlannedTab"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "PlannedTab_venueId_customerName_key" ON "PlannedTab"("venueId", "customerName");
-- AddForeignKey
ALTER TABLE "PlannedTab" ADD CONSTRAINT "PlannedTab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,25 +0,0 @@
-- AlterTable
ALTER TABLE "PlannedTab" ADD COLUMN "email" TEXT;
-- CreateTable
CREATE TABLE "TabReminder" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"amountInCents" INTEGER NOT NULL,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"status" TEXT NOT NULL DEFAULT 'sent',
CONSTRAINT "TabReminder_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TabReminder_venueId_sentAt_idx" ON "TabReminder"("venueId", "sentAt");
-- CreateIndex
CREATE INDEX "TabReminder_venueId_tabId_idx" ON "TabReminder"("venueId", "tabId");
-- AddForeignKey
ALTER TABLE "TabReminder" ADD CONSTRAINT "TabReminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "PlannedTab" ADD COLUMN "deliveredAt" TIMESTAMP(3);
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "OpenTabSnapshot" ADD COLUMN "items" JSONB;
@@ -1,27 +0,0 @@
-- PlannedTab: match customers case-insensitively
ALTER TABLE "PlannedTab" ADD COLUMN "customerNameKey" TEXT;
UPDATE "PlannedTab" SET "customerNameKey" = lower(trim(regexp_replace("customerName", '\s+', ' ', 'g')));
-- Names that only differ in case or spacing keep the oldest row as the match; later ones get a unique key
UPDATE "PlannedTab" p SET "customerNameKey" = p."customerNameKey" || '#' || p."id"
WHERE EXISTS (
SELECT 1 FROM "PlannedTab" o
WHERE o."venueId" = p."venueId"
AND o."customerNameKey" = p."customerNameKey"
AND (o."createdAt", o."id") < (p."createdAt", p."id")
);
ALTER TABLE "PlannedTab" ALTER COLUMN "customerNameKey" SET NOT NULL;
DROP INDEX "PlannedTab_venueId_customerName_key";
CREATE UNIQUE INDEX "PlannedTab_venueId_customerNameKey_key" ON "PlannedTab"("venueId", "customerNameKey");
-- RemoteCommand: keep who the tab belonged to, the tab itself disappears once closed
ALTER TABLE "RemoteCommand" ADD COLUMN "customerName" TEXT,
ADD COLUMN "amountInCents" INTEGER;
UPDATE "RemoteCommand" c SET "customerName" = s."customerName", "amountInCents" = s."totalInCents"
FROM "OpenTabSnapshot" s
WHERE s."deviceId" = c."deviceId" AND s."tabId" = c."targetTabId";
@@ -1,33 +0,0 @@
-- CreateTable
CREATE TABLE "TabEvent" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"productName" TEXT,
"unitPriceInCents" INTEGER,
"quantityDelta" INTEGER,
"amountInCents" INTEGER,
"paymentMethod" TEXT,
"commandId" TEXT,
"items" JSONB,
"occurredAt" TIMESTAMP(3) NOT NULL,
"receivedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt");
-- CreateIndex
CREATE INDEX "TabEvent_venueId_type_occurredAt_idx" ON "TabEvent"("venueId", "type", "occurredAt");
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,45 +0,0 @@
-- CreateTable
CREATE TABLE "Product" (
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"priceInCents" INTEGER NOT NULL,
"stock" INTEGER,
"lowStockThreshold" INTEGER,
"reportedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("venueId","productId")
);
-- CreateTable
CREATE TABLE "StockAdjustment" (
"id" TEXT NOT NULL,
"venueId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"note" TEXT,
"requestedBy" TEXT NOT NULL,
"requestedById" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"stockAfter" INTEGER,
"result" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"processedAt" TIMESTAMP(3),
CONSTRAINT "StockAdjustment_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_status_createdAt_idx" ON "StockAdjustment"("venueId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "StockAdjustment_venueId_productId_createdAt_idx" ON "StockAdjustment"("venueId", "productId", "createdAt");
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockAdjustment" ADD CONSTRAINT "StockAdjustment_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -1,75 +0,0 @@
-- DropForeignKey
ALTER TABLE "RemoteCommand" DROP CONSTRAINT "RemoteCommand_deviceId_fkey";
-- DropIndex
DROP INDEX "Device_venueId_key";
-- AlterTable
ALTER TABLE "Venue" ADD COLUMN "tabsSeededAt" TIMESTAMP(3);
-- AlterTable
ALTER TABLE "RemoteCommand" ALTER COLUMN "deviceId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "TabEvent" ADD COLUMN "lineId" TEXT,
ADD COLUMN "productId" TEXT,
ADD COLUMN "stockSetTo" INTEGER,
ALTER COLUMN "tabId" DROP NOT NULL,
ALTER COLUMN "customerName" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "countedAt" TIMESTAMP(3);
-- CreateTable
CREATE TABLE "Tab" (
"venueId" TEXT NOT NULL,
"id" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"openedAt" TIMESTAMP(3) NOT NULL,
"version" INTEGER NOT NULL DEFAULT 0,
"lines" JSONB NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Tab_pkey" PRIMARY KEY ("venueId","id")
);
-- CreateTable
CREATE TABLE "Settlement" (
"id" TEXT NOT NULL,
"sequence" SERIAL NOT NULL,
"venueId" TEXT NOT NULL,
"tabId" TEXT NOT NULL,
"customerName" TEXT NOT NULL,
"settledAt" TIMESTAMP(3) NOT NULL,
"paymentMethod" TEXT NOT NULL,
"amountInCents" INTEGER NOT NULL,
"lines" JSONB NOT NULL,
"deviceId" TEXT,
"requestedBy" TEXT,
"commandId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
-- AddForeignKey
ALTER TABLE "RemoteCommand" ADD CONSTRAINT "RemoteCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "archivedAt" TIMESTAMP(3);
@@ -1,28 +0,0 @@
-- AlterTable
ALTER TABLE "ClientUser" ALTER COLUMN "role" SET DEFAULT 'manager';
-- AlterTable
ALTER TABLE "TabEvent" ADD COLUMN "productData" JSONB;
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "fieldUpdatedAt" JSONB,
ADD COLUMN "imageId" TEXT;
-- CreateTable
CREATE TABLE "ProductImage" (
"id" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"bytes" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- "viewer" was the name for the people running the venue: they are managers now
UPDATE "ClientUser" SET "role" = 'manager' WHERE "role" = 'viewer';
@@ -1,42 +0,0 @@
-- One row per drink sold: every unit that was not taken back, whether it is still on an open tab or already
-- settled. Tab.lines and Settlement.lines hold the same unit shape ({ id, addedAt, removedAt }); a unit is in
-- exactly one of them, so nothing is counted twice. Change this view whenever that JSON shape changes.
CREATE VIEW "SoldUnit" AS
SELECT
t."venueId",
u->>'id' AS "unitId",
t."id" AS "tabId",
t."customerName",
l->>'productId' AS "productId",
l->>'productName' AS "productName",
(l->>'unitPriceInCents')::int AS "unitPriceInCents",
(u->>'addedAt')::timestamptz AS "addedAt",
-- A night runs until 06:00
date_trunc('day', (u->>'addedAt')::timestamptz - interval '6 hours', 'Europe/Brussels') AS "night",
NULL::text AS "settlementId",
NULL::timestamptz AS "settledAt",
NULL::text AS "paymentMethod"
FROM "Tab" t,
jsonb_array_elements(t."lines") l,
jsonb_array_elements(l->'units') u
WHERE u->>'removedAt' IS NULL
UNION ALL
SELECT
s."venueId",
u->>'id',
s."tabId",
s."customerName",
l->>'productId',
l->>'productName',
(l->>'unitPriceInCents')::int,
(u->>'addedAt')::timestamptz,
date_trunc('day', (u->>'addedAt')::timestamptz - interval '6 hours', 'Europe/Brussels'),
s."id",
s."settledAt" AT TIME ZONE 'UTC',
s."paymentMethod"
FROM "Settlement" s,
jsonb_array_elements(s."lines") l,
jsonb_array_elements(l->'units') u
WHERE u->>'removedAt' IS NULL;
@@ -1,4 +0,0 @@
-- The terminals used to own the tabs and report them as snapshots; the server holds them in "Tab" now
DROP TABLE "OpenTabSnapshot";
DROP TABLE "TabOwnership";
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "ClientUser" ADD COLUMN "sessionsRevokedAt" TIMESTAMP(3);
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "buyPriceInCents" INTEGER;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "desiredStock" INTEGER;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Venue" ADD COLUMN "cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[];
@@ -0,0 +1,387 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('admin', 'manager', 'superadmin');
-- CreateEnum
CREATE TYPE "PaymentMethod" AS ENUM ('cash', 'payconiq', 'other');
-- CreateEnum
CREATE TYPE "StockMovementType" AS ENUM ('add', 'count');
-- CreateEnum
CREATE TYPE "ActorType" AS ENUM ('user', 'device', 'system');
-- CreateEnum
CREATE TYPE "TerminalEventType" AS ENUM ('tab_opened', 'tab_renamed', 'tab_deleted', 'line_added', 'line_removed', 'settled', 'stock_set', 'product_created', 'product_updated', 'product_archived', 'product_restored');
-- CreateTable
CREATE TABLE "Venue" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"cashPaymentEmails" TEXT[] DEFAULT ARRAY[]::TEXT[],
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Venue_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "User" (
"id" UUID NOT NULL,
"venueId" UUID,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'manager',
"sessionsRevokedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RefreshToken" (
"id" UUID NOT NULL,
"userId" UUID NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
"revokedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RefreshToken_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"name" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"tokenPrefix" TEXT NOT NULL,
"tokenExpiresAt" TIMESTAMPTZ(3) NOT NULL,
"lastSeenAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"revokedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RegistrationCode" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"code" TEXT NOT NULL,
"expiresAt" TIMESTAMPTZ(3) NOT NULL,
"usedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationCode_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Customer" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"name" TEXT NOT NULL,
"nameKey" TEXT NOT NULL,
"email" TEXT,
"position" INTEGER NOT NULL DEFAULT 0,
"tabOpenedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Tab" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"customerName" TEXT NOT NULL,
"openedAt" TIMESTAMPTZ(3) NOT NULL,
"version" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Tab_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Settlement" (
"id" UUID NOT NULL,
"sequence" SERIAL NOT NULL,
"venueId" UUID NOT NULL,
"tabId" UUID NOT NULL,
"customerName" TEXT NOT NULL,
"settledAt" TIMESTAMPTZ(3) NOT NULL,
"paymentMethod" "PaymentMethod" NOT NULL,
"amountInCents" INTEGER NOT NULL,
"deviceId" UUID,
"settledByUserId" UUID,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Settlement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabUnit" (
"id" TEXT NOT NULL,
"venueId" UUID NOT NULL,
"tabId" UUID,
"settlementId" UUID,
"lineId" TEXT NOT NULL,
"productId" UUID,
"productName" TEXT NOT NULL,
"unitPriceInCents" INTEGER NOT NULL,
"orderId" TEXT,
"addedAt" TIMESTAMPTZ(3) NOT NULL,
"removedAt" TIMESTAMPTZ(3),
CONSTRAINT "TabUnit_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"name" TEXT NOT NULL,
"category" TEXT,
"priceInCents" INTEGER NOT NULL,
"buyPriceInCents" INTEGER,
"stock" INTEGER,
"countedAt" TIMESTAMPTZ(3),
"lowStockThreshold" INTEGER,
"desiredStock" INTEGER,
"imageId" TEXT,
"archivedAt" TIMESTAMPTZ(3),
"fieldUpdatedAt" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProductImage" (
"id" TEXT NOT NULL,
"contentType" TEXT NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"bytes" INTEGER NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StockMovement" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"productId" UUID NOT NULL,
"type" "StockMovementType" NOT NULL,
"quantity" INTEGER NOT NULL,
"stockAfter" INTEGER NOT NULL,
"note" TEXT,
"userId" UUID,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StockMovement_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TabEvent" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"deviceId" UUID NOT NULL,
"type" "TerminalEventType" NOT NULL,
"payload" JSONB NOT NULL,
"occurredAt" TIMESTAMPTZ(3) NOT NULL,
"receivedAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "TabEvent_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Reminder" (
"id" UUID NOT NULL,
"venueId" UUID NOT NULL,
"customerId" UUID,
"customerName" TEXT NOT NULL,
"email" TEXT NOT NULL,
"tabId" UUID NOT NULL,
"amountInCents" INTEGER NOT NULL,
"sentAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" UUID NOT NULL,
"venueId" UUID,
"actorType" "ActorType" NOT NULL,
"actorId" UUID,
"action" TEXT NOT NULL,
"details" JSONB,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_venueId_idx" ON "User"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "RefreshToken_tokenHash_key" ON "RefreshToken"("tokenHash");
-- CreateIndex
CREATE INDEX "RefreshToken_userId_idx" ON "RefreshToken"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_tokenHash_key" ON "Device"("tokenHash");
-- CreateIndex
CREATE INDEX "Device_venueId_revokedAt_idx" ON "Device"("venueId", "revokedAt");
-- CreateIndex
CREATE UNIQUE INDEX "RegistrationCode_code_key" ON "RegistrationCode"("code");
-- CreateIndex
CREATE INDEX "RegistrationCode_venueId_idx" ON "RegistrationCode"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Customer_venueId_nameKey_key" ON "Customer"("venueId", "nameKey");
-- CreateIndex
CREATE INDEX "Tab_venueId_idx" ON "Tab"("venueId");
-- CreateIndex
CREATE UNIQUE INDEX "Settlement_sequence_key" ON "Settlement"("sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_sequence_idx" ON "Settlement"("venueId", "sequence");
-- CreateIndex
CREATE INDEX "Settlement_venueId_settledAt_idx" ON "Settlement"("venueId", "settledAt");
-- CreateIndex
CREATE INDEX "TabUnit_tabId_idx" ON "TabUnit"("tabId");
-- CreateIndex
CREATE INDEX "TabUnit_settlementId_idx" ON "TabUnit"("settlementId");
-- CreateIndex
CREATE INDEX "TabUnit_venueId_addedAt_idx" ON "TabUnit"("venueId", "addedAt");
-- CreateIndex
CREATE INDEX "TabUnit_productId_idx" ON "TabUnit"("productId");
-- CreateIndex
CREATE INDEX "Product_venueId_idx" ON "Product"("venueId");
-- CreateIndex
CREATE INDEX "StockMovement_venueId_createdAt_idx" ON "StockMovement"("venueId", "createdAt");
-- CreateIndex
CREATE INDEX "StockMovement_productId_createdAt_idx" ON "StockMovement"("productId", "createdAt");
-- CreateIndex
CREATE INDEX "TabEvent_venueId_occurredAt_idx" ON "TabEvent"("venueId", "occurredAt");
-- CreateIndex
CREATE INDEX "TabEvent_receivedAt_idx" ON "TabEvent"("receivedAt");
-- CreateIndex
CREATE INDEX "Reminder_venueId_sentAt_idx" ON "Reminder"("venueId", "sentAt");
-- CreateIndex
CREATE INDEX "Reminder_customerId_idx" ON "Reminder"("customerId");
-- CreateIndex
CREATE INDEX "AuditLog_venueId_createdAt_idx" ON "AuditLog"("venueId", "createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RefreshToken" ADD CONSTRAINT "RefreshToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationCode" ADD CONSTRAINT "RegistrationCode_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Tab" ADD CONSTRAINT "Tab_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_settledByUserId_fkey" FOREIGN KEY ("settledByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_tabId_fkey" FOREIGN KEY ("tabId") REFERENCES "Tab"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_settlementId_fkey" FOREIGN KEY ("settlementId") REFERENCES "Settlement"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "ProductImage"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StockMovement" ADD CONSTRAINT "StockMovement_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TabEvent" ADD CONSTRAINT "TabEvent_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Reminder" ADD CONSTRAINT "Reminder_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_venueId_fkey" FOREIGN KEY ("venueId") REFERENCES "Venue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- A drink is on an open tab or in a settlement, never both or neither
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_tab_or_settlement" CHECK (("tabId" IS NULL) <> ("settlementId" IS NULL));
-- A superadmin is above the venues; everyone else belongs to one
ALTER TABLE "User" ADD CONSTRAINT "User_superadmin_has_no_venue" CHECK (("role" = 'superadmin') = ("venueId" IS NULL));
-- Amounts and counts that can't be negative (stock can: a sale is never blocked)
ALTER TABLE "Settlement" ADD CONSTRAINT "Settlement_amount_positive" CHECK ("amountInCents" >= 0);
ALTER TABLE "TabUnit" ADD CONSTRAINT "TabUnit_price_positive" CHECK ("unitPriceInCents" >= 0);
ALTER TABLE "Product" ADD CONSTRAINT "Product_prices_positive" CHECK ("priceInCents" >= 0 AND ("buyPriceInCents" IS NULL OR "buyPriceInCents" >= 0));
@@ -1,5 +0,0 @@
-- Every venue gets a "Free Drinks" customer; the next sync opens its tab (or keeps one that is already open)
INSERT INTO "PlannedTab" ("id", "venueId", "customerName", "customerNameKey", "position", "createdAt")
SELECT gen_random_uuid()::text, "id", 'Free Drinks', 'free drinks', 0, CURRENT_TIMESTAMP
FROM "Venue"
ON CONFLICT ("venueId", "customerNameKey") DO NOTHING;
@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "TabEvent" ADD COLUMN "orderId" TEXT;
@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+304 -278
View File
@@ -1,331 +1,357 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "cjs"
// Compiled to CommonJS by tsc: imports without an extension. Set, not inferred: the client is generated during
// install, before the tsconfig it would be inferred from is there
importFileExtension = ""
}
generator client {
provider = "prisma-client-js"
datasource db {
provider = "postgresql"
}
enum Role {
admin
manager
superadmin
}
enum PaymentMethod {
cash
payconiq
other
}
enum StockMovementType {
add
count
}
enum ActorType {
user
device
system
}
enum TerminalEventType {
tab_opened
tab_renamed
tab_deleted
line_added
line_removed
settled
stock_set
product_created
product_updated
product_archived
product_restored
}
model Venue {
id String @id @default(uuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
clientUsers ClientUser[]
remoteCommands RemoteCommand[]
auditLogs AuditLog[]
regCodes RegistrationCode[]
plannedTabs PlannedTab[]
tabReminders TabReminder[]
tabEvents TabEvent[]
products Product[]
stockAdjustments StockAdjustment[]
tabs Tab[]
settlements Settlement[]
// Set on the first sync after the server started holding the tabs: the tabs were then taken over from the
// terminal's own open tabs, once
tabsSeededAt DateTime?
// Get an email for every tab paid in cash, on a terminal or on the dashboard
id String @id @default(uuid()) @db.Uuid
name String
// Who gets an email for every tab paid in cash, on a terminal or on the dashboard
cashPaymentEmails String[] @default([])
createdAt DateTime @default(now()) @db.Timestamptz(3)
users User[]
devices Device[]
registrationCodes RegistrationCode[]
customers Customer[]
tabs Tab[]
settlements Settlement[]
units TabUnit[]
products Product[]
stockMovements StockMovement[]
tabEvents TabEvent[]
reminders Reminder[]
auditLog AuditLog[]
}
model Device {
id String @id @default(uuid())
// A venue can have several terminals
venueId String
name String
tokenHash String @unique
tokenPrefix String
tokenExpiresAt DateTime
lastSeenAt DateTime @default(now())
revokedAt DateTime?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
remoteCommands RemoteCommand[]
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
model ClientUser {
id String @id @default(uuid())
venueId String
email String @unique
passwordHash String
name String
// admin (the owner: users and terminals) | manager (runs the venue: products, stock, tabs, reminders) |
// superadmin (runs KoolTap itself: every venue, and the admin page)
role String @default("manager")
createdAt DateTime @default(now())
// A venue's admin or manager, or a superadmin, who runs KoolTap itself and belongs to no venue: they view a venue to
// act in it as its admin
model User {
id String @id @default(uuid()) @db.Uuid
// Null exactly for a superadmin
venueId String? @db.Uuid
// Stored lowercased
email String @unique
passwordHash String
name String
role Role @default(manager)
// Access tokens issued before this are refused, so a forced logout applies at once instead of when they expire
sessionsRevokedAt DateTime?
sessionsRevokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
refreshTokens RefreshToken[]
settlements Settlement[]
stockMovements StockMovement[]
@@index([venueId])
}
model RegistrationCode {
id String @id @default(uuid())
venueId String
code String @unique
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId])
}
// A request from the dashboard. close_tab used to be executed by the owning terminal; the server now settles
// the tab itself and records the command as completed right away, for the history.
model RemoteCommand {
id String @id @default(uuid())
venueId String
// Null for a command the server carried out itself
deviceId String?
targetTabId String
customerName String?
amountInCents Int?
type String @default("close_tab")
expectedVersion Int
paymentMethod String
requestedBy String
status String @default("pending")
result String?
acknowledgedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
@@index([deviceId, status, createdAt])
@@index([venueId, status, createdAt])
}
model AuditLog {
id String @id @default(uuid())
venueId String
actorType String
actorId String
action String
details String?
createdAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, createdAt])
}
model RefreshToken {
id String @id @default(uuid())
userId String
id String @id @default(uuid()) @db.Uuid
userId String @db.Uuid
tokenHash String @unique
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
expiresAt DateTime @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model PlannedTab {
id String @id @default(uuid())
venueId String
customerName String
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
customerNameKey String
email String?
position Int @default(0)
createdAt DateTime @default(now())
// Set once the terminal reports an open tab with this name; it is then no longer sent to devices
deliveredAt DateTime?
// A bar terminal, paired with a registration code
model Device {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
name String
tokenHash String @unique
tokenPrefix String
tokenExpiresAt DateTime @db.Timestamptz(3)
lastSeenAt DateTime @default(now()) @db.Timestamptz(3)
revokedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
tabEvents TabEvent[]
settlements Settlement[]
@@index([venueId, revokedAt])
}
model RegistrationCode {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
code String @unique
expiresAt DateTime @db.Timestamptz(3)
usedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
@@unique([venueId, customerNameKey])
@@index([venueId])
}
model TabReminder {
id String @id @default(uuid())
venueId String
tabId String
customerName String
email String
amountInCents Int
sentAt DateTime @default(now())
status String @default("sent")
// A regular of the venue. Tabs are matched to customers by name: the next sync opens a tab for a customer that has
// none, and the customer's email is used for reminders
model Customer {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
name String
// Lowercased, whitespace-collapsed name: customers are matched on this everywhere
nameKey String
email String?
position Int @default(0)
// Null until the next sync opens their tab
tabOpenedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
reminders Reminder[]
@@index([venueId, sentAt])
@@index([venueId, tabId])
@@unique([venueId, nameKey])
}
// What happened on a tab, as reported by the terminal. The id is generated by the terminal so a re-sent event is
// stored, and applied to the tab state and stock, only once.
model TabEvent {
id String @id
venueId String
deviceId String
// Null for stock_set, which is not about a tab
tabId String?
customerName String?
// tab_opened | tab_renamed | tab_deleted | line_added | line_removed | settled | stock_set
type String
lineId String?
// line_added / line_removed: the order on the terminal the drinks belong to
orderId String?
productId String?
productName String?
unitPriceInCents Int?
// Signed: positive for line_added, negative for line_removed
quantityDelta Int?
// settled only
amountInCents Int?
paymentMethod String?
commandId String?
items Json?
// stock_set: the counted stock, null to stop tracking
stockSetTo Int?
// product_created / product_updated: the product's fields as sent by the terminal
productData Json?
occurredAt DateTime
receivedAt DateTime @default(now())
venue Venue @relation(fields: [venueId], references: [id])
device Device @relation(fields: [deviceId], references: [id])
@@index([venueId, occurredAt])
@@index([venueId, type, occurredAt])
}
// The venue's products and their stock. The dashboard manages the catalog and the terminals get it on every sync;
// the first terminal's own catalog was taken over once. The stock follows the terminals' line events and stock
// counts and the dashboard's StockAdjustments.
model Product {
venueId String
// Generated by the server, or by the terminal that reported it before the dashboard managed the catalog
productId String
name String
category String?
// The sell price: what the terminals charge
priceInCents Int
// What the bar pays for one; null when not entered. Kept on the dashboard only, the terminals never get it
buyPriceInCents Int?
// Null when stock is not tracked for this product. Can be negative: a sale is never blocked.
stock Int?
// When the stock was last counted; line events that happened before it are already in the count
countedAt DateTime?
// Warn when the stock drops to this level
lowStockThreshold Int?
// How many the bar wants in stock; the dashboard shows what to order to get there. Not sent to the terminals
desiredStock Int?
// When it was created or last changed
reportedAt DateTime
// Deleted on the dashboard: hidden on the terminals, kept because tabs and history refer to it
archivedAt DateTime?
imageId String?
// When each field was last changed ({ name: ISO date, ... }): a change from the dashboard or an (offline)
// terminal only wins over a later one per field, so edits of different fields never overwrite each other
fieldUpdatedAt Json?
image ProductImage? @relation(fields: [imageId], references: [id])
venue Venue @relation(fields: [venueId], references: [id])
@@id([venueId, productId])
}
// A stock change made on the dashboard. The server applies it right away. Older ones may still have been applied
// by a terminal, which reported the outcome.
model StockAdjustment {
id String @id @default(uuid())
venueId String
productId String
productName String
// add (signed: a delivery, or breakage when negative) | set (a stock count)
type String
quantity Int
note String?
requestedBy String
requestedById String
// pending | applied | rejected | cancelled
status String @default("pending")
// The stock right after applying it
stockAfter Int?
result String?
createdAt DateTime @default(now())
processedAt DateTime?
venue Venue @relation(fields: [venueId], references: [id])
@@index([venueId, status, createdAt])
@@index([venueId, productId, createdAt])
}
// An open tab of the venue: the source of truth the terminals replace their own tabs with on every sync. Tabs are
// never closed, only emptied by a settlement; tab_deleted removes one.
// An open tab: the source of truth the terminals replace their own tabs with on every sync. Tabs are never closed,
// only emptied by settlements; deleting one drops the drinks still on it
model Tab {
venueId String
// Generated by the terminal that opened it
id String
// Generated by the terminal that opened it, or by the server for a customer's tab
id String @id @db.Uuid
venueId String @db.Uuid
customerName String
openedAt DateTime
openedAt DateTime @db.Timestamptz(3)
// Bumped by every change, so the dashboard can refuse to settle a tab that changed since it was shown
version Int @default(0)
// [{ id, productId, productName, unitPriceInCents, units: [{ id, addedAt, removedAt }] }]
lines Json
updatedAt DateTime @updatedAt
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
units TabUnit[]
@@id([venueId, id])
@@index([venueId])
}
// A settlement made on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number.
// A paid tab, settled on a terminal (id = the settled event's id) or on the dashboard. Terminals read them as a feed
// after their cursor, which is the sequence number
model Settlement {
id String @id
sequence Int @unique @default(autoincrement())
venueId String
tabId String
customerName String
settledAt DateTime
// cash | payconiq | payment_pending | other
paymentMethod String
amountInCents Int
// The settled lines, in the shape of Tab.lines with each line's settled and removed units
lines Json
id String @id @db.Uuid
sequence Int @unique @default(autoincrement())
venueId String @db.Uuid
// The tab may be deleted since
tabId String @db.Uuid
customerName String
settledAt DateTime @db.Timestamptz(3)
paymentMethod PaymentMethod
// What was charged; the units are what the server had on the tab
amountInCents Int
// Who settled it: a terminal, or a dashboard user
deviceId String?
requestedBy String?
commandId String?
createdAt DateTime @default(now())
deviceId String? @db.Uuid
settledByUserId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id])
device Device? @relation(fields: [deviceId], references: [id])
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
settledBy User? @relation(fields: [settledByUserId], references: [id], onDelete: SetNull)
units TabUnit[]
@@index([venueId, sequence])
@@index([venueId, settledAt])
}
// A product picture, resized and cropped on upload. The id is the SHA-256 of the stored bytes, so the picture at
// an address never changes and can be cached forever, and the same picture is stored once.
// One drink ever rung up. It is on an open tab until a settlement takes it; a drink taken off again keeps its
// removedAt and moves with the settlement too. Lines are the units sharing a lineId
model TabUnit {
// `<line_added event id>:<index>`, derived the same way on the terminals
id String @id
venueId String @db.Uuid
// Exactly one of tabId and settlementId is set
tabId String? @db.Uuid
settlementId String? @db.Uuid
lineId String
productId String? @db.Uuid
// As rung up: a later change to the product doesn't change what was sold
productName String
unitPriceInCents Int
// The order on the terminal it was rung up in; null only for drinks settled on a terminal the server didn't have
orderId String?
addedAt DateTime @db.Timestamptz(3)
removedAt DateTime? @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
tab Tab? @relation(fields: [tabId], references: [id], onDelete: Cascade)
settlement Settlement? @relation(fields: [settlementId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([tabId])
@@index([settlementId])
@@index([venueId, addedAt])
@@index([productId])
}
// The venue's catalog and stock. The dashboard and the terminals both change products; per field the latest change
// wins. The stock follows the drinks rung up, the terminals' counts and the dashboard's stock movements
model Product {
// Generated by the server, or by the terminal that created it offline
id String @id @db.Uuid
venueId String @db.Uuid
name String
category String?
// The sell price: what the terminals charge
priceInCents Int
// What the bar pays for one. Dashboard only, the terminals never get it
buyPriceInCents Int?
// Null when stock is not tracked. Can be negative: a sale is never blocked
stock Int?
// When the stock was last counted; drinks rung up before it are already in the count
countedAt DateTime? @db.Timestamptz(3)
// Warn when the stock drops to this level
lowStockThreshold Int?
// How many the bar wants in stock. Dashboard only
desiredStock Int?
imageId String?
// Deleted: hidden on the terminals, kept because tabs and history refer to it
archivedAt DateTime? @db.Timestamptz(3)
// When each field was last changed ({ name: ISO date, ... }), so edits of different fields never overwrite each other
fieldUpdatedAt Json @default("{}")
createdAt DateTime @default(now()) @db.Timestamptz(3)
// The last change to what the terminals get (not the stock): part of the catalog version
updatedAt DateTime @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
units TabUnit[]
stockMovements StockMovement[]
@@index([venueId])
}
// A product picture, resized and cropped on upload and stored on disk as <id>.webp. The id is the SHA-256 of the
// bytes, so a picture's address never changes and the same picture is stored once
model ProductImage {
id String @id
contentType String
width Int
height Int
bytes Bytes
createdAt DateTime @default(now())
bytes Int
createdAt DateTime @default(now()) @db.Timestamptz(3)
products Product[]
}
// A stock change made on the dashboard: a delivery, breakage or count
model StockMovement {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
productId String @db.Uuid
type StockMovementType
quantity Int
stockAfter Int
note String?
userId String? @db.Uuid
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
@@index([venueId, createdAt])
@@index([productId, createdAt])
}
// What a terminal reported, as it sent it. The id is generated by the terminal, so an event that is sent again is
// stored, and applied, only once
model TabEvent {
id String @id @db.Uuid
venueId String @db.Uuid
deviceId String @db.Uuid
type TerminalEventType
// The event as sent, after validation
payload Json
occurredAt DateTime @db.Timestamptz(3)
receivedAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
@@index([venueId, occurredAt])
@@index([receivedAt])
}
model Reminder {
id String @id @default(uuid()) @db.Uuid
venueId String @db.Uuid
// Null when the customer was removed since
customerId String? @db.Uuid
customerName String
email String
tabId String @db.Uuid
amountInCents Int
sentAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue @relation(fields: [venueId], references: [id], onDelete: Cascade)
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
@@index([venueId, sentAt])
@@index([customerId])
}
model AuditLog {
id String @id @default(uuid()) @db.Uuid
// Null for actions that are not about one venue (purging, signing everyone out)
venueId String? @db.Uuid
actorType ActorType
// A user or device id; null for the system
actorId String? @db.Uuid
action String
details Json?
createdAt DateTime @default(now()) @db.Timestamptz(3)
venue Venue? @relation(fields: [venueId], references: [id], onDelete: Cascade)
@@index([venueId, createdAt])
@@index([createdAt])
}
-173
View File
@@ -1,173 +0,0 @@
const bcrypt = require('bcrypt');
const { PrismaClient } = require('@prisma/client');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const prisma = new PrismaClient();
// One line with itemCount units, priced so the tab adds up to about totalInCents
async function createTab(venueId, tab) {
const lineId = uuidv4();
const addedAt = tab.reportedAt.toISOString();
await prisma.tab.create({
data: {
venueId,
id: tab.tabId,
customerName: tab.customerName,
openedAt: tab.reportedAt,
version: tab.version,
lines: [{
id: lineId,
productId: null,
productName: 'Pils',
unitPriceInCents: Math.round(tab.totalInCents / tab.itemCount),
units: Array.from({ length: tab.itemCount }, (_, i) => ({ id: `${lineId}:${i}`, addedAt, removedAt: null })),
}],
},
});
}
async function main() {
console.log('Seeding database...');
await prisma.auditLog.deleteMany();
await prisma.tabEvent.deleteMany();
await prisma.tabReminder.deleteMany();
await prisma.plannedTab.deleteMany();
await prisma.remoteCommand.deleteMany();
await prisma.settlement.deleteMany();
await prisma.tab.deleteMany();
await prisma.stockAdjustment.deleteMany();
await prisma.product.deleteMany();
await prisma.productImage.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.registrationCode.deleteMany();
await prisma.device.deleteMany();
await prisma.clientUser.deleteMany();
await prisma.venue.deleteMany();
console.log('Cleared existing data');
const venueId = uuidv4();
await prisma.venue.create({
data: { id: venueId, name: 'KoolTap Demo Bar' },
});
console.log(`Created venue: KoolTap Demo Bar (${venueId})`);
const adminHash = await bcrypt.hash('Admin123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'admin@example.com',
passwordHash: adminHash,
name: 'Eigenaar',
role: 'admin',
},
});
console.log('Created admin user: admin@example.com / Admin123!');
const viewerHash = await bcrypt.hash('Viewer123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'viewer@example.com',
passwordHash: viewerHash,
name: 'Bartender',
role: 'viewer',
},
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
const device1Id = uuidv4();
const device2Id = uuidv4();
const token1 = 'dev_' + crypto.randomBytes(48).toString('base64url');
const token2 = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({
data: {
id: device1Id,
venueId,
name: 'Bar terminal 1',
tokenHash: crypto.createHash('sha256').update(token1).digest('hex'),
tokenPrefix: token1.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 1 * 60 * 1000),
},
});
await prisma.device.create({
data: {
id: device2Id,
venueId,
name: 'Bar terminal 2',
tokenHash: crypto.createHash('sha256').update(token2).digest('hex'),
tokenPrefix: token2.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 15 * 60 * 1000),
},
});
console.log(`Created device: Bar terminal 1 (${device1Id})`);
console.log(`Created device: Bar terminal 2 (${device2Id})`);
const tabsData = [
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Marie', version: 3, totalInCents: 1800, itemCount: 2, reportedAt: new Date(Date.now() - 5 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Thomas', version: 7, totalInCents: 4200, itemCount: 5, reportedAt: new Date(Date.now() - 3 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Lisa', version: 2, totalInCents: 900, itemCount: 1, reportedAt: new Date(Date.now() - 4 * 60 * 1000) },
];
for (const tab of tabsData) {
await createTab(venueId, tab);
}
const staleTabs = [
{ tabId: uuidv4(), customerName: 'Emma', version: 4, totalInCents: 3200, itemCount: 4, reportedAt: new Date(Date.now() - 16 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'David', version: 1, totalInCents: 1500, itemCount: 2, reportedAt: new Date(Date.now() - 20 * 60 * 1000) },
];
for (const tab of staleTabs) {
await createTab(venueId, tab);
}
console.log('Created 6 open tabs');
const now = new Date();
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device1Id, targetTabId: tabsData[1].tabId, type: 'close_tab', expectedVersion: 3, paymentMethod: 'cash', requestedBy: 'Eigenaar', status: 'completed', result: 'Tab closed successfully', acknowledgedAt: new Date(now.getTime() - 30 * 60 * 1000), createdAt: new Date(now.getTime() - 35 * 60 * 1000), expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000) },
});
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device1Id, targetTabId: tabsData[0].tabId, type: 'close_tab', expectedVersion: 5, paymentMethod: 'payconiq', requestedBy: 'Eigenaar', status: 'pending', createdAt: new Date(now.getTime() - 5 * 60 * 1000), expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000) },
});
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device2Id, targetTabId: staleTabs[0].tabId, type: 'close_tab', expectedVersion: 4, paymentMethod: 'payment_pending', requestedBy: 'Bartender', status: 'failed', result: 'Tab could not be closed', acknowledgedAt: new Date(now.getTime() - 60 * 60 * 1000), createdAt: new Date(now.getTime() - 65 * 60 * 1000), expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000) },
});
await prisma.remoteCommand.create({
data: { id: uuidv4(), venueId, deviceId: device1Id, targetTabId: tabsData[2].tabId, type: 'close_tab', expectedVersion: 7, paymentMethod: 'cash', requestedBy: 'Eigenaar', status: 'conflict', result: 'Version mismatch: expected 7, got 8', acknowledgedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000 - 5 * 60 * 1000), expiresAt: new Date(now.getTime() + 22 * 60 * 60 * 1000) },
});
console.log('Created sample commands: completed, pending, failed, conflict');
await prisma.auditLog.create({
data: { id: uuidv4(), venueId, actorType: 'client_user', actorId: 'admin', action: 'command_created', details: JSON.stringify({ commandId: 'sample', tabId: tabsData[0].tabId }) },
});
console.log('Seeding complete!');
console.log('');
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(`\nDevice tokens (for simulator):`);
console.log(` Bar terminal 1: ${token1}`);
console.log(` Bar terminal 2: ${token2}`);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
+126 -227
View File
@@ -1,249 +1,148 @@
import * as bcrypt from 'bcrypt';
import { PrismaClient } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import { ensureDefaultCustomers } from '../src/planned-tabs/default-customers';
/**
* Demo data for local development: two venues, a user of every role, a paired terminal, a catalog with stock, open
* tabs and some paid ones. Wipes the database first.
*
* pnpm db:seed
*/
import 'dotenv/config';
import { createHash, randomUUID } from 'crypto';
import { hashPassword } from '../src/auth/auth.service';
import { createPrismaClient } from '../src/prisma/prisma.service';
import { ensureDefaultCustomers } from '../src/customers/default-customers';
import { createdFieldTimes } from '../src/ledger/product-fields';
const prisma = new PrismaClient();
const prisma = createPrismaClient();
const minutesAgo = (minutes: number) => new Date(Date.now() - minutes * 60 * 1000);
const CATALOG = [
{ name: 'Jupiler', category: 'Bier', priceInCents: 280, buyPriceInCents: 110, stock: 96, desiredStock: 144 },
{ name: 'Duvel', category: 'Bier', priceInCents: 450, buyPriceInCents: 190, stock: 24, desiredStock: 48 },
{ name: 'Cola', category: 'Frisdrank', priceInCents: 250, buyPriceInCents: 80, stock: 6, desiredStock: 48 },
{ name: 'Spa rood', category: 'Frisdrank', priceInCents: 230, buyPriceInCents: 70, stock: 30, desiredStock: 48 },
{ name: 'Koffie', category: 'Warm', priceInCents: 250, buyPriceInCents: null, stock: null, desiredStock: null },
{ name: 'Chips', category: null, priceInCents: 200, buyPriceInCents: 60, stock: 18, desiredStock: 30 },
];
/** A tab's drinks: [catalog index, quantity, minutes ago] per order line. */
const TABS: { customerName: string; lines: [number, number, number][] }[] = [
{ customerName: 'Jan', lines: [[0, 4, 90], [5, 1, 90], [1, 2, 20]] },
{ customerName: 'Marie', lines: [[2, 2, 45], [3, 1, 45]] },
{ customerName: 'Thomas', lines: [[1, 3, 120], [0, 2, 60]] },
{ customerName: 'Lisa', lines: [[4, 1, 10]] },
];
async function main() {
console.log('Seeding database...');
// Clean existing data so seed is idempotent
await prisma.auditLog.deleteMany();
await prisma.tabEvent.deleteMany();
await prisma.tabReminder.deleteMany();
await prisma.plannedTab.deleteMany();
await prisma.remoteCommand.deleteMany();
await prisma.settlement.deleteMany();
await prisma.tab.deleteMany();
await prisma.stockAdjustment.deleteMany();
await prisma.product.deleteMany();
await prisma.productImage.deleteMany();
await prisma.refreshToken.deleteMany();
await prisma.registrationCode.deleteMany();
await prisma.device.deleteMany();
await prisma.clientUser.deleteMany();
await prisma.venue.deleteMany();
console.log('Cleared existing data');
// Create venue
const venueId = uuidv4();
await prisma.venue.create({
data: { id: venueId, name: 'KoolTap Demo Bar' },
});
await ensureDefaultCustomers(prisma, venueId);
console.log(`Created venue: KoolTap Demo Bar (${venueId})`);
const venue = await prisma.venue.create({ data: { name: 'KoolTap Demo Bar', cashPaymentEmails: [] } });
await ensureDefaultCustomers(prisma, venue.id);
const festival = await prisma.venue.create({ data: { name: 'KoolTap Festival Bar' } });
await ensureDefaultCustomers(prisma, festival.id);
// Create admin user
const adminHash = await bcrypt.hash('Admin123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'admin@example.com',
passwordHash: adminHash,
name: 'Eigenaar',
role: 'admin',
},
});
console.log('Created admin user: admin@example.com / Admin123!');
// Create viewer user
const viewerHash = await bcrypt.hash('Viewer123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'viewer@example.com',
passwordHash: viewerHash,
name: 'Bartender',
role: 'manager',
},
});
console.log('Created viewer user: viewer@example.com / Viewer123!');
// Runs KoolTap itself: every venue, and the admin page
const superadminHash = await bcrypt.hash('Super123!', 10);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: 'superadmin@example.com',
passwordHash: superadminHash,
name: 'KoolTap',
role: 'superadmin',
},
});
console.log('Created superadmin user: superadmin@example.com / Super123!');
// A second venue, so the admin page has more than one
const secondVenueId = uuidv4();
await prisma.venue.create({ data: { id: secondVenueId, name: 'KoolTap Festival Bar' } });
await ensureDefaultCustomers(prisma, secondVenueId);
await prisma.clientUser.create({
data: {
id: uuidv4(),
venueId: secondVenueId,
email: 'festival@example.com',
passwordHash: adminHash,
name: 'Festival',
role: 'admin',
},
});
console.log('Created venue: KoolTap Festival Bar, admin festival@example.com / Admin123!');
// Create one device
const deviceId = uuidv4();
const token = 'dev_' + crypto.randomBytes(48).toString('base64url');
await prisma.device.create({
data: {
id: deviceId,
venueId,
name: 'Bar terminal 1',
tokenHash: crypto.createHash('sha256').update(token).digest('hex'),
tokenPrefix: token.substring(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: new Date(Date.now() - 1 * 60 * 1000),
},
});
console.log(`Created device: Bar terminal 1 (${deviceId})`);
const tabsData = [
{ tabId: uuidv4(), customerName: 'Jan', version: 5, totalInCents: 2450, itemCount: 3, reportedAt: new Date(Date.now() - 2 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Marie', version: 3, totalInCents: 1800, itemCount: 2, reportedAt: new Date(Date.now() - 5 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Thomas', version: 7, totalInCents: 4200, itemCount: 5, reportedAt: new Date(Date.now() - 3 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Lisa', version: 2, totalInCents: 900, itemCount: 1, reportedAt: new Date(Date.now() - 4 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'Emma', version: 4, totalInCents: 3200, itemCount: 4, reportedAt: new Date(Date.now() - 16 * 60 * 1000) },
{ tabId: uuidv4(), customerName: 'David', version: 1, totalInCents: 1500, itemCount: 2, reportedAt: new Date(Date.now() - 20 * 60 * 1000) },
const users = [
{ venueId: venue.id, email: 'admin@example.com', password: 'Admin123!', name: 'Eigenaar', role: 'admin' as const },
{ venueId: venue.id, email: 'manager@example.com', password: 'Manager123!', name: 'Bartender', role: 'manager' as const },
// Above the venues: views one to act in it as its admin
{ venueId: null, email: 'superadmin@example.com', password: 'Super123!', name: 'KoolTap', role: 'superadmin' as const },
{ venueId: festival.id, email: 'festival@example.com', password: 'Admin123!', name: 'Festival', role: 'admin' as const },
];
for (const { password, ...user } of users) {
await prisma.user.create({ data: { ...user, passwordHash: await hashPassword(password) } });
}
const manager = await prisma.user.findUniqueOrThrow({ where: { email: 'manager@example.com' } });
for (const tab of tabsData) {
// One line with itemCount units, priced so the tab adds up to about totalInCents
const lineId = uuidv4();
const addedAt = tab.reportedAt.toISOString();
await prisma.tab.create({
// A known token, so `scripts/terminal-simulator.ts` can sync as this terminal without pairing
const token = 'kt_demo-terminal-token';
const device = await prisma.device.create({
data: {
venueId: venue.id,
name: 'Bar terminal 1',
tokenHash: createHash('sha256').update(token).digest('hex'),
tokenPrefix: token.slice(0, 8),
tokenExpiresAt: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000),
lastSeenAt: minutesAgo(1),
},
});
const now = new Date();
const products = [];
for (const { stock, ...fields } of CATALOG) {
const product = await prisma.product.create({
data: {
venueId,
id: tab.tabId,
customerName: tab.customerName,
openedAt: tab.reportedAt,
version: tab.version,
lines: [{
id: lineId,
productId: null,
productName: 'Pils',
unitPriceInCents: Math.round(tab.totalInCents / tab.itemCount),
units: Array.from({ length: tab.itemCount }, (_, i) => ({ id: `${lineId}:${i}`, addedAt, removedAt: null })),
}],
id: randomUUID(),
venueId: venue.id,
...fields,
stock,
countedAt: stock === null ? null : minutesAgo(24 * 60),
lowStockThreshold: stock === null ? null : 12,
fieldUpdatedAt: createdFieldTimes({ ...fields, archived: false }, now),
updatedAt: now,
},
});
products.push(product);
if (stock !== null) {
await prisma.stockMovement.create({
data: { venueId: venue.id, productId: product.id, type: 'count', quantity: stock, stockAfter: stock, userId: manager.id, createdAt: minutesAgo(24 * 60) },
});
}
}
console.log('Created 6 open tabs');
// Create sample commands
const now = new Date();
// Completed command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[1].tabId,
type: 'close_tab',
expectedVersion: 3,
paymentMethod: 'cash',
requestedBy: 'Eigenaar',
status: 'completed',
result: 'Tab closed successfully',
acknowledgedAt: new Date(now.getTime() - 30 * 60 * 1000),
createdAt: new Date(now.getTime() - 35 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
await prisma.customer.createMany({
data: ['Jan', 'Marie', 'Thomas'].map((name, index) => ({
venueId: venue.id, name, nameKey: name.toLowerCase(), email: `${name.toLowerCase()}@example.com`, position: index + 1, tabOpenedAt: now,
})),
});
// Pending command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[0].tabId,
type: 'close_tab',
expectedVersion: 5,
paymentMethod: 'payconiq',
requestedBy: 'Eigenaar',
status: 'pending',
createdAt: new Date(now.getTime() - 5 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
});
// Open tabs, and for each customer one paid tab from last week
for (const [index, { customerName, lines }] of TABS.entries()) {
const tab = await prisma.tab.create({
data: { id: randomUUID(), venueId: venue.id, customerName, openedAt: minutesAgo(180), version: lines.length, updatedAt: now },
});
await prisma.tabUnit.createMany({ data: lines.flatMap(([productIndex, quantity, ago]) => units(venue.id, products[productIndex], quantity, minutesAgo(ago), randomUUID(), { tabId: tab.id })) });
// Failed command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[4].tabId,
type: 'close_tab',
expectedVersion: 4,
paymentMethod: 'payment_pending',
requestedBy: 'Bartender',
status: 'failed',
result: 'Tab could not be closed',
acknowledgedAt: new Date(now.getTime() - 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 65 * 60 * 1000),
expiresAt: new Date(now.getTime() + 23 * 60 * 60 * 1000),
},
});
const settledAt = minutesAgo((index + 1) * 24 * 60 * 2);
const product = products[index % products.length];
const settlement = await prisma.settlement.create({
data: {
id: randomUUID(), venueId: venue.id, tabId: tab.id, customerName, settledAt, paymentMethod: index % 2 === 0 ? 'cash' : 'payconiq',
amountInCents: 3 * product.priceInCents, deviceId: device.id,
},
});
await prisma.tabUnit.createMany({ data: units(venue.id, product, 3, minutesAgo((index + 1) * 24 * 60 * 2 + 60), randomUUID(), { settlementId: settlement.id }) });
}
// Conflict command
await prisma.remoteCommand.create({
data: {
id: uuidv4(),
venueId,
deviceId,
targetTabId: tabsData[2].tabId,
type: 'close_tab',
expectedVersion: 7,
paymentMethod: 'cash',
requestedBy: 'Eigenaar',
status: 'conflict',
result: 'Version mismatch: expected 7, got 8',
acknowledgedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000 - 5 * 60 * 1000),
expiresAt: new Date(now.getTime() + 22 * 60 * 60 * 1000),
},
});
console.log('Seeded the demo database. Sign in with:');
for (const user of users) console.log(` ${user.role.padEnd(10)} ${user.email} / ${user.password}`);
console.log(`Terminal token for the simulator: ${token}`);
}
console.log('Created sample commands: completed, pending, failed, conflict');
// Create audit log entries
await prisma.auditLog.create({
data: {
id: uuidv4(),
venueId,
actorType: 'client_user',
actorId: 'admin',
action: 'command_created',
details: JSON.stringify({ commandId: 'sample', tabId: tabsData[0].tabId }),
},
});
console.log('Seeding complete!');
console.log('');
console.log('Demo credentials:');
console.log(' Admin: admin@example.com / Admin123!');
console.log(' Viewer: viewer@example.com / Viewer123!');
console.log(' Superadmin: superadmin@example.com / Super123!');
console.log(`\nDevice token (for simulator): ${token}`);
function units(
venueId: string,
product: { id: string; name: string; priceInCents: number },
quantity: number,
addedAt: Date,
lineId: string,
where: { tabId?: string; settlementId?: string },
) {
const eventId = randomUUID();
return Array.from({ length: quantity }, (_, n) => ({
id: `${eventId}:${n}`,
venueId,
...where,
lineId,
productId: product.id,
productName: product.name,
unitPriceInCents: product.priceInCents,
orderId: `order-${addedAt.getTime()}`,
addedAt,
}));
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-74
View File
@@ -1,74 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcrypt');
const { v4: uuidv4 } = require('uuid');
const prisma = new PrismaClient();
const args = process.argv.slice(2).filter((arg) => arg !== '--force');
const FORCE = process.argv.includes('--force');
const VENUE_NAME = process.env.SETUP_VENUE_NAME || args[0] || 'My Venue';
const ADMIN_EMAIL = process.env.SETUP_ADMIN_EMAIL || args[1];
const ADMIN_PASSWORD = process.env.SETUP_ADMIN_PASSWORD || args[2];
async function main() {
console.log('=== KoolTap Production Setup ===\n');
const venues = await prisma.venue.count();
if (venues > 0 && !FORCE) {
console.log(`The database already has ${venues} venue(s); nothing to do. Pass --force to wipe ALL data and start over.`);
return;
}
if (!ADMIN_EMAIL || !ADMIN_PASSWORD) {
throw new Error('Usage: node prisma/setup.js "<venue name>" <admin email> <admin password> [--force]');
}
if (ADMIN_PASSWORD.length < 10) {
throw new Error('The admin password must be at least 10 characters');
}
const venueId = uuidv4();
const hash = await bcrypt.hash(ADMIN_PASSWORD, 10);
await prisma.$transaction(async (tx) => {
await tx.auditLog.deleteMany();
await tx.tabEvent.deleteMany();
await tx.tabReminder.deleteMany();
await tx.plannedTab.deleteMany();
await tx.remoteCommand.deleteMany();
await tx.settlement.deleteMany();
await tx.tab.deleteMany();
await tx.stockAdjustment.deleteMany();
await tx.product.deleteMany();
await tx.productImage.deleteMany();
await tx.refreshToken.deleteMany();
await tx.registrationCode.deleteMany();
await tx.device.deleteMany();
await tx.clientUser.deleteMany();
await tx.venue.deleteMany();
await tx.venue.create({ data: { id: venueId, name: VENUE_NAME } });
await tx.clientUser.create({
data: {
id: uuidv4(),
venueId,
email: ADMIN_EMAIL,
passwordHash: hash,
name: 'Admin',
role: 'admin',
},
});
});
console.log('=== Setup complete ===');
console.log(` Venue: ${VENUE_NAME}`);
console.log(` Venue ID: ${venueId}`);
console.log(` Admin: ${ADMIN_EMAIL}`);
console.log('\nLog in to the dashboard with the admin credentials you provided.');
}
main()
.catch((err) => {
console.error('Setup failed:', err.message ?? err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
-31
View File
@@ -1,31 +0,0 @@
import { PrismaClient } from '@prisma/client';
/**
* Makes an existing dashboard user a superadmin, or an admin again with --revoke. There is no other way to become
* the first superadmin: the dashboard only lets a superadmin hand out the role.
*
* pnpm --filter @kooltap/api run superadmin user@example.com [--revoke]
*/
async function main() {
const [email, flag] = process.argv.slice(2);
if (!email) {
console.error('Usage: superadmin <email> [--revoke]');
process.exit(1);
}
const role = flag === '--revoke' ? 'admin' : 'superadmin';
const prisma = new PrismaClient();
try {
const user = await prisma.clientUser.findUnique({ where: { email } });
if (!user) {
console.error(`No user with email ${email}`);
process.exit(1);
}
await prisma.clientUser.update({ where: { id: user.id }, data: { role } });
console.log(`${email} is now ${role === 'superadmin' ? 'a superadmin' : 'an admin'}`);
} finally {
await prisma.$disconnect();
}
}
main();