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
204 lines
6.5 KiB
TypeScript
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
@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>€${(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 };
|
|
}
|
|
} |