Files
kooltab-backoffice/apps/api/src/reminders/reminders.service.ts
T
brammie15 8b1e38cb4d 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
2026-10-07 23:18:37 +02:00

204 lines
6.5 KiB
TypeScript

import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
// Tabs stay open per customer (closing only settles them), so a customer can be reminded again
// once this many days have passed since their last reminder.
const REMINDER_COOLDOWN_DAYS = 7;
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
@Injectable()
export class RemindersService {
private resendApiKey: string;
private fromEmail: string;
constructor(private prisma: PrismaService) {
this.resendApiKey = process.env.RESEND_API_KEY || '';
this.fromEmail = process.env.RESEND_FROM_EMAIL || 'KoolTab <onboarding@resend.dev>';
}
async listCandidates(venueId: string) {
const ownerships = await this.prisma.tabOwnership.findMany({
where: { venueId },
});
// Only the owning device's snapshot is authoritative for a tab
const snapshots = ownerships.length === 0 ? [] : await this.prisma.openTabSnapshot.findMany({
where: {
OR: ownerships.map((o) => ({ deviceId: o.deviceId, tabId: o.tabId })),
},
});
const latestByTab = new Map(snapshots.map((s) => [s.tabId, s]));
const emailMap = new Map<string, string>();
try {
const plannedTabs = await this.prisma.plannedTab.findMany({
where: { venueId, email: { not: null } },
select: { customerName: true, email: true },
});
for (const pt of plannedTabs) {
if (pt.email) emailMap.set(pt.customerName.toLowerCase(), pt.email);
}
} catch {
// email column may not exist yet if migration hasn't been applied
}
let sentTabIds = new Set<string>();
try {
const alreadySent = await this.prisma.tabReminder.findMany({
where: { venueId, sentAt: { gt: new Date(Date.now() - REMINDER_COOLDOWN_DAYS * 24 * 60 * 60 * 1000) } },
select: { tabId: true },
});
sentTabIds = new Set(alreadySent.map((r) => r.tabId));
} catch {
// Table may not exist yet if migration hasn't been applied
}
const candidates = [];
for (const snap of latestByTab.values()) {
if (snap.totalInCents <= 0) continue;
if (sentTabIds.has(snap.tabId)) continue;
const email = emailMap.get(snap.customerName.toLowerCase());
if (!email) continue;
candidates.push({
tabId: snap.tabId,
customerName: snap.customerName,
email,
amountInCents: snap.totalInCents,
itemCount: snap.itemCount,
});
}
return { candidates };
}
async sendReminders(venueId: string, requestorEmail: string) {
const { candidates } = await this.listCandidates(venueId);
if (candidates.length === 0) {
return { sent: 0, message: 'No candidates to remind' };
}
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
let sent = 0;
const errors: { customerName: string; error: string }[] = [];
for (const candidate of candidates) {
try {
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
},
body: JSON.stringify({
from: this.fromEmail,
to: candidate.email,
subject: `Payment reminder for your tab at the bar`,
html: `<p>Hi ${escapeHtml(candidate.customerName)},</p>
<p>This is a friendly reminder that you have an open tab of <strong>&euro;${(candidate.amountInCents / 100).toFixed(2)}</strong> (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).</p>
<p>Please settle your tab at the bar at your earliest convenience.</p>
<p>Thank you!</p>`,
}),
});
if (!res.ok) {
const errBody = await res.text();
errors.push({ customerName: candidate.customerName, error: errBody });
continue;
}
await this.prisma.tabReminder.create({
data: {
venueId,
tabId: candidate.tabId,
customerName: candidate.customerName,
email: candidate.email,
amountInCents: candidate.amountInCents,
},
});
sent++;
} catch (err: any) {
errors.push({ customerName: candidate.customerName, error: err.message });
}
}
return { sent, total: candidates.length, errors: errors.length > 0 ? errors : undefined };
}
async history(venueId: string) {
try {
const reminders = await this.prisma.tabReminder.findMany({
where: { venueId },
orderBy: { sentAt: 'desc' },
take: 100,
});
return { reminders };
} catch {
return { reminders: [] };
}
}
async sendTestEmail(venueId: string, plannedTabId: string) {
if (!this.resendApiKey) {
throw new BadRequestException({
code: 'resend_not_configured',
message: 'Resend API key is not configured. Set RESEND_API_KEY environment variable.',
});
}
const tab = await this.prisma.plannedTab.findFirst({
where: { id: plannedTabId, venueId },
});
if (!tab) {
throw new NotFoundException({ code: 'not_found', message: 'Planned customer not found' });
}
if (!tab.email) {
throw new BadRequestException({ code: 'no_email', message: 'This customer has no email address set' });
}
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.resendApiKey}`,
},
body: JSON.stringify({
from: this.fromEmail,
to: tab.email,
subject: `Test email from KoolTab`,
html: `<p>Hi ${escapeHtml(tab.customerName)},</p>
<p>This is a test email from KoolTab to verify that email reminders are working correctly.</p>
<p>If you received this, the email configuration is working!</p>
<hr>
<p style="color: #888; font-size: 12px;">This is a test — no action needed.</p>`,
}),
});
if (!res.ok) {
const errBody = await res.text();
throw new BadRequestException({
code: 'email_failed',
message: `Failed to send test email: ${errBody}`,
});
}
return { ok: true, email: tab.email, customerName: tab.customerName };
}
}