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:
2026-10-07 23:18:37 +02:00
parent 58eb8ddb8e
commit 8b1e38cb4d
56 changed files with 3168 additions and 376 deletions
@@ -0,0 +1,218 @@
'use client';
import { useMemo, useState } from 'react';
import { Upload } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from '@/components/ui/toast';
import { plannedTabsApi } from '@/lib/api/client';
import { useLang } from '@/lib/i18n';
import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import';
const PREVIEW_ROWS = 8;
const selectClassName =
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring';
interface Props {
venueId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
const { t } = useLang();
const [fileName, setFileName] = useState<string | null>(null);
const [sheet, setSheet] = useState<SheetData | null>(null);
const [mapping, setMapping] = useState<ColumnMapping | null>(null);
const [mode, setMode] = useState<'merge' | 'replace'>('merge');
const [submitting, setSubmitting] = useState(false);
const preview = useMemo(() => (sheet && mapping ? buildPreview(sheet, mapping) : null), [sheet, mapping]);
function reset() {
setFileName(null);
setSheet(null);
setMapping(null);
setMode('merge');
}
function handleOpenChange(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleFile(file: File | undefined) {
if (!file) return;
try {
const data = await readSheet(file);
if (data.rows.length === 0) {
toast({ title: t('import.failed'), description: t('import.empty_file'), variant: 'destructive' });
return;
}
setFileName(file.name);
setSheet(data);
setMapping(detectMapping(data));
} catch {
toast({ title: t('import.failed'), description: t('import.unreadable'), variant: 'destructive' });
}
}
async function submit() {
if (!preview || preview.entries.length === 0) return;
setSubmitting(true);
try {
const result = await plannedTabsApi.import(venueId, preview.entries, mode);
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
onImported();
handleOpenChange(false);
} catch (err: any) {
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' });
} finally {
setSubmitting(false);
}
}
const header = sheet && mapping?.hasHeader ? sheet.rows[0] : null;
const columnOptions = sheet
? Array.from({ length: sheet.columnCount }, (_, i) => ({
value: i,
label: header?.[i] ? `${columnLabel(i)} – ${header[i]}` : columnLabel(i),
}))
: [];
function columnSelect(value: number | null, onChange: (value: number | null) => void, optional: boolean) {
return (
<select
className={selectClassName}
value={value ?? ''}
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
>
{optional && <option value="">{t('import.column_none')}</option>}
{columnOptions.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
);
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('import.title')}</DialogTitle>
<DialogDescription>{t('import.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<label className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm cursor-pointer hover:bg-accent">
<Upload className="h-5 w-5 text-muted-foreground" />
<span>{fileName ?? t('import.choose_file')}</span>
<span className="text-xs text-muted-foreground">.xlsx, .xls, .csv</span>
<input
type="file"
accept=".xlsx,.xls,.csv"
className="sr-only"
onChange={(e) => {
handleFile(e.target.files?.[0]);
e.target.value = '';
}}
/>
</label>
{sheet && mapping && preview && (
<>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t('import.name_column')}</Label>
{columnSelect(mapping.nameColumn, (v) => setMapping({ ...mapping, nameColumn: v ?? 0 }), false)}
</div>
<div className="space-y-1">
<Label>{t('import.last_name_column')}</Label>
{columnSelect(mapping.lastNameColumn, (v) => setMapping({ ...mapping, lastNameColumn: v }), true)}
</div>
<div className="space-y-1">
<Label>{t('import.email_column')}</Label>
{columnSelect(mapping.emailColumn, (v) => setMapping({ ...mapping, emailColumn: v }), true)}
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={mapping.hasHeader}
onChange={(e) => setMapping({ ...mapping, hasHeader: e.target.checked })}
/>
{t('import.has_header')}
</label>
<div className="space-y-2">
<p className="text-sm">
{t('import.summary', {
count: preview.entries.length,
emails: preview.entries.filter((e) => e.email).length,
})}
</p>
{(preview.skippedRows > 0 || preview.invalidEmails > 0) && (
<p className="text-xs text-muted-foreground">
{t('import.warnings', { skipped: preview.skippedRows, invalid: preview.invalidEmails })}
</p>
)}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('tabs.customer')}</TableHead>
<TableHead>{t('import.email')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => (
<TableRow key={i}>
<TableCell className="font-medium">{entry.customerName}</TableCell>
<TableCell className="text-muted-foreground">{entry.email ?? '—'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{preview.entries.length > PREVIEW_ROWS && (
<p className="text-xs text-muted-foreground">
{t('import.more', { count: preview.entries.length - PREVIEW_ROWS })}
</p>
)}
</div>
<div className="space-y-2">
<Label className="text-base font-semibold">{t('import.mode')}</Label>
<RadioGroup value={mode} onValueChange={(v) => setMode(v as 'merge' | 'replace')}>
<div className="flex items-start space-x-2">
<RadioGroupItem value="merge" id="import-merge" className="mt-0.5" />
<Label htmlFor="import-merge" className="font-normal">{t('import.mode_merge')}</Label>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem value="replace" id="import-replace" className="mt-0.5" />
<Label htmlFor="import-replace" className="font-normal">{t('import.mode_replace')}</Label>
</div>
</RadioGroup>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
{t('tabs.cancel')}
</Button>
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}