feat: reminders, Excel customer import and security hardening
Features - Payment reminders via Resend (candidates, send, history, test email) - Bulk import of the customer list from Excel/CSV in the dashboard - Dashboard i18n (en/nl) with language selector Security - Enforce venue isolation on all /venues/:venueId routes - Require admin for planned-tab changes and pairing codes - Rate-limit login and device registration; opt-in TRUST_PROXY - Require JWT_SECRET in production; stop leaking internal errors - Escape customer names in reminder emails Sync and commands - Remove tabs the terminal no longer reports; validate nested sync payload - Return all pending commands on every sync (fixes missed-command race) - First command ack wins; atomic registration code claim - Reject commands for revoked devices, stale versions or duplicates - Remind only unpaid balances, with a 7-day cooldown Dashboard - Share a single token refresh across concurrent 401s (random logouts) - Keep the session alive for the refresh token lifetime Deployment - Replace `db push --accept-data-loss` with migrations (baselines legacy DBs) - Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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');
|
||||
|
||||
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;
|
||||
} 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);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 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;
|
||||
@@ -20,6 +20,7 @@ model Venue {
|
||||
auditLogs AuditLog[]
|
||||
regCodes RegistrationCode[]
|
||||
plannedTabs PlannedTab[]
|
||||
tabReminders TabReminder[]
|
||||
}
|
||||
|
||||
model Device {
|
||||
@@ -148,6 +149,7 @@ model PlannedTab {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
customerName String
|
||||
email String?
|
||||
position Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -155,4 +157,20 @@ model PlannedTab {
|
||||
|
||||
@@unique([venueId, customerName])
|
||||
@@index([venueId])
|
||||
}
|
||||
|
||||
model TabReminder {
|
||||
id String @id @default(uuid())
|
||||
venueId String
|
||||
tabId String
|
||||
customerName String
|
||||
email String
|
||||
amountInCents Int
|
||||
sentAt DateTime @default(now())
|
||||
status String @default("sent")
|
||||
|
||||
venue Venue @relation(fields: [venueId], references: [id])
|
||||
|
||||
@@index([venueId, sentAt])
|
||||
@@index([venueId, tabId])
|
||||
}
|
||||
Reference in New Issue
Block a user