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, '''); } @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 '; } 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(); 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(); 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: `

Hi ${escapeHtml(candidate.customerName)},

This is a friendly reminder that you have an open tab of €${(candidate.amountInCents / 100).toFixed(2)} (${candidate.itemCount} item${candidate.itemCount !== 1 ? 's' : ''}).

Please settle your tab at the bar at your earliest convenience.

Thank you!

`, }), }); 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: `

Hi ${escapeHtml(tab.customerName)},

This is a test email from KoolTab to verify that email reminders are working correctly.

If you received this, the email configuration is working!


This is a test — no action needed.

`, }), }); 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 }; } }