import { Router } from "express"; import { db } from "../db.ts"; import type { AuthedRequest } from "../lib/auth.ts"; import { err } from "../lib/auth.ts"; import { bizDayBoundsSqlite } from "../lib/time.ts"; export const notificationsRouter = Router(); /** Build a human message for an appointment reminder/confirmation. */ function buildMessage(biz: any, appt: any, kind: string): string { const when = new Date(appt.start_at).toLocaleString("es-MX", { weekday: "long", day: "numeric", month: "long", hour: "2-digit", minute: "2-digit", timeZone: biz.timezone || "America/Mexico_City", }); const svc = appt.service_name || "tu servicio"; const emp = appt.employee_name ? ` con ${appt.employee_name}` : ""; if (kind === "confirmation") return `¡Hola ${appt.client_name}! Tu cita en ${biz.name} está confirmada: ${svc}${emp}, ${when}.`; if (kind === "cancellation") return `Hola ${appt.client_name}, tu cita en ${biz.name} (${svc}) fue cancelada. Para reagendar responde a este mensaje.`; if (kind === "review") return `¡Gracias por tu visita a ${biz.name}, ${appt.client_name}! ¿Nos calificas? Tu opinión nos ayuda a mejorar.`; return `Recordatorio ${biz.name}: tienes ${svc}${emp} el ${when}. ¡Te esperamos!`; } /** (Re)generate pending notifications for the upcoming appointments of a business. */ export function scheduleReminders(businessId: number) { const biz = db.prepare(`SELECT * FROM businesses WHERE id = ?`).get(businessId) as any; if (!biz) return; // upcoming scheduled appointments in next 7 days (UTC instants — the window is // instant-relative, so we bind clean UTC values instead of SQLite's date('now')). const nowUtc = new Date().toISOString().slice(0, 19) + "Z"; const plus7Utc = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19) + "Z"; const appts = db .prepare( `SELECT a.id, a.start_at, a.status, a.client_id, c.name client_name, c.phone, c.email, s.name service_name, e.name employee_name FROM appointments a JOIN clients c ON c.id = a.client_id JOIN services s ON s.id = a.service_id LEFT JOIN employees e ON e.id = a.employee_id WHERE a.business_id = ? AND a.status = 'scheduled' AND a.start_at >= ? AND a.start_at <= ? ORDER BY a.start_at ASC` ) .all(businessId, nowUtc, plus7Utc) as any[]; // delete pending reminders that no longer have a matching upcoming scheduled appt db.prepare( `DELETE FROM notifications WHERE business_id = ? AND status = 'pending' AND kind IN ('reminder','confirmation')` ).run(businessId); for (const a of appts) { const start = new Date(a.start_at); const channel = a.phone ? "whatsapp" : a.email ? "email" : "whatsapp"; // confirmation (immediate) const confSend = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); db.prepare( `INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message) VALUES (?, ?, ?, ?, 'confirmation', ?, 'pending', ?)` ).run(businessId, a.id, a.client_id, channel, confSend, buildMessage(biz, a, "confirmation")); // reminder 24h before const reminderAt = new Date(start.getTime() - 24 * 3600000); if (reminderAt.getTime() > Date.now()) { db.prepare( `INSERT INTO notifications (business_id, appointment_id, client_id, channel, kind, send_at, status, message) VALUES (?, ?, ?, ?, 'reminder', ?, 'pending', ?)` ).run( businessId, a.id, a.client_id, channel, reminderAt.toISOString().replace(/\.\d{3}Z$/, "Z"), buildMessage(biz, a, "reminder") ); } } } notificationsRouter.get("/", (req: AuthedRequest, res) => { const bid = req.user!.business_id as number; const status = req.query.status as string | undefined; let rows: any[]; if (status && status !== "all") { rows = db .prepare( `SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name FROM notifications n LEFT JOIN clients c ON c.id = n.client_id LEFT JOIN appointments a ON a.id = n.appointment_id LEFT JOIN services s ON s.id = a.service_id WHERE n.business_id = ? AND n.status = ? ORDER BY n.send_at DESC LIMIT 200` ) .all(bid, status); } else { rows = db .prepare( `SELECT n.*, c.name client_name, c.phone, c.email, s.name service_name FROM notifications n LEFT JOIN clients c ON c.id = n.client_id LEFT JOIN appointments a ON a.id = n.appointment_id LEFT JOIN services s ON s.id = a.service_id WHERE n.business_id = ? ORDER BY n.send_at DESC LIMIT 200` ) .all(bid); } res.json({ notifications: rows }); }); notificationsRouter.post("/regenerate", (req: AuthedRequest, res) => { scheduleReminders(req.user!.business_id as number); res.json({ ok: true }); }); /** "Send" a notification — in this demo it simulates sending (marks as sent). */ notificationsRouter.post("/:id/send", (req: AuthedRequest, res) => { const bid = req.user!.business_id as number; const id = Number(req.params.id); const n = db.prepare(`SELECT * FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid) as any; if (!n) return err(res, 404, "Notificación no encontrada"); db.prepare(`UPDATE notifications SET status='sent' WHERE id = ?`).run(id); res.json({ ok: true, simulated: true }); }); notificationsRouter.post("/:id/cancel", (req: AuthedRequest, res) => { const bid = req.user!.business_id as number; const id = Number(req.params.id); const n = db.prepare(`SELECT id FROM notifications WHERE id = ? AND business_id = ?`).get(id, bid); if (!n) return err(res, 404, "Notificación no encontrada"); db.prepare(`UPDATE notifications SET status='canceled' WHERE id = ?`).run(id); res.json({ ok: true }); }); notificationsRouter.get("/stats", (req: AuthedRequest, res) => { const bid = req.user!.business_id as number; const tz = (db.prepare(`SELECT timezone t FROM businesses WHERE id = ?`).get(bid) as { t?: string } | undefined)?.t || "America/Mexico_City"; // tz-correct 30-day window (business-local). created_at is mixed-format, so wrap with datetime(). const since30 = bizDayBoundsSqlite(tz, -30).start; const scalar = (sql: string, ...args: any[]) => (db.prepare(sql).get(bid, ...args) as any)?.v ?? 0; res.json({ pending: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending'`), sent: scalar(`SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='sent'`), upcoming_reminders: scalar( `SELECT COUNT(*) v FROM notifications WHERE business_id = ? AND status='pending' AND kind='reminder' AND send_at >= datetime('now')` ), no_show_rate: scalar( `SELECT COALESCE(ROUND(100.0 * SUM(CASE WHEN status='no_show' THEN 1 ELSE 0 END) / NULLIF(COUNT(*),0),1),0) v FROM appointments WHERE business_id = ? AND datetime(created_at) >= ?`, since30 ), }); });