- Rebrand all user-facing text to AgendaMax - Fix sidebar scrolling: card container no longer overflows html (flex h-full min-h-0) - Fix calendar toolbar hover: active buttons keep readable contrast - Sticky calendar toolbar (month/week/day always visible on scroll) - Seed guarantees 2-4 appointments per day for current week (Mon-Sat)
159 lines
6.9 KiB
TypeScript
159 lines
6.9 KiB
TypeScript
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
|
|
),
|
|
});
|
|
});
|