@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user