feat: Phase 6 — SMS Twilio, PDF reports, QR join page, verifyQr endpoint

This commit is contained in:
Hermes 2026-04-25 18:05:15 +00:00
parent 6103279dd4
commit 9bd134fb5d
7 changed files with 717 additions and 1 deletions

View file

@ -90,6 +90,9 @@ import {
isStripeConfigured,
} from "./services/stripe.js";
import { checkPlanLimit, getPlanLimitsForUser } from "./services/planLimits.js";
import { isSmsConfigured, sendSms } from "./services/sms.js";
import { generateQueueReport } from "./services/pdfReport.js";
import { buildSmsMessage } from "../shared/smsTemplates.js";
import { childLogger } from "./_core/logger.js";
const authLog = childLogger("auth");
@ -409,6 +412,28 @@ const clinicRouter = router({
return { success: true };
}),
updateSmsSettings: subscriptionProcedure
.input(
z.object({
clinicId: z.number().int().positive(),
smsEnabled: z.boolean(),
})
)
.mutation(async ({ input, ctx }) => {
const clinic = await getClinicById(input.clinicId);
if (!clinic || clinic.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" });
}
if (input.smsEnabled && !isSmsConfigured()) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Twilio n'est pas configuré sur ce serveur.",
});
}
await updateClinic(input.clinicId, { smsEnabled: input.smsEnabled });
return { success: true, smsEnabled: input.smsEnabled };
}),
regenerateQr: subscriptionProcedure
.input(z.object({ id: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
@ -605,6 +630,35 @@ const queueRouter = router({
}),
// Patient self-tracking
// Verify QR token validity (for QR join page)
verifyQr: publicProcedure
.input(z.object({ clinicId: z.number().int().positive(), qrToken: z.string().min(8).max(64) }))
.query(async ({ input }) => {
let clinic = await getClinicById(input.clinicId);
if (!clinic || !clinic.isActive) {
throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" });
}
// Auto-refresh expired QR token
if (
clinic.qrTokenExpiresAt &&
clinic.qrTokenExpiresAt.getTime() < Date.now() &&
clinic.qrRotationMinutes &&
clinic.qrRotationMinutes > 0
) {
await rotateQrToken(clinic.id);
clinic = (await getClinicById(clinic.id)) ?? clinic;
}
if (clinic.qrToken !== input.qrToken) {
throw new TRPCError({ code: "FORBIDDEN", message: "QR code invalide" });
}
const waStatus = await getWhatsAppStatus(clinic.id).catch(() => null);
return {
clinicName: clinic.name,
isQueueOpen: clinic.isQueueOpen,
whatsappConnected: waStatus?.connected ?? false,
};
}),
getByToken: publicProcedure
.input(z.object({ patientToken: z.string().min(8).max(64) }))
.query(async ({ input }) => {
@ -1244,6 +1298,89 @@ const analyticsRouter = router({
.slice(0, 10)}.csv`;
return { csv, filename };
}),
exportPdf: protectedProcedure
.input(
z.object({
clinicId: z.number().int().positive(),
days: z.number().int().min(1).max(365).default(30),
})
)
.query(async ({ input, ctx }) => {
const clinic = await getClinicById(input.clinicId);
if (!clinic || clinic.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" });
}
try {
const buf = await generateQueueReport(input.clinicId, { days: input.days });
const filename = `queuemed_${clinic.name.replace(/[^a-z0-9]+/gi, "_")}_${new Date()
.toISOString()
.slice(0, 10)}.pdf`;
return {
base64: buf.toString("base64"),
filename,
};
} catch (err) {
const message = err instanceof Error ? err.message : "PDF generation failed";
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message });
}
}),
});
// ─── Notification router (SMS Twilio) ────────────────────────────────────────
const notificationRouter = router({
smsStatus: protectedProcedure.query(() => {
return { configured: isSmsConfigured() };
}),
sendSms: subscriptionProcedure
.input(
z.object({
entryId: z.number().int().positive(),
message: z.string().max(500).optional(),
})
)
.mutation(async ({ input, ctx }) => {
if (!isSmsConfigured()) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Twilio n'est pas configuré sur ce serveur.",
});
}
const entry = await getQueueEntry(input.entryId);
if (!entry) {
throw new TRPCError({ code: "NOT_FOUND", message: "Patient introuvable" });
}
const clinic = await getClinicById(entry.clinicId);
if (!clinic || clinic.userId !== ctx.user.id) {
throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" });
}
if (!clinic.smsEnabled) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Les notifications SMS ne sont pas activées pour ce cabinet.",
});
}
const phone = entry.patientPhone ?? entry.whatsappPhone ?? null;
if (!phone) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Aucun numéro de téléphone enregistré pour ce patient.",
});
}
const message =
input.message ??
buildSmsMessage("called", {
nom: entry.patientName ?? "",
ticket: entry.ticketNumber,
position: entry.position,
attente: entry.estimatedWaitMinutes ?? 0,
cabinet: clinic.name,
});
return sendSms(phone, message);
}),
});
// ─── Subscription router ─────────────────────────────────────────────────────
@ -1472,6 +1609,7 @@ const clinicSettingsRouter = router({
whatsappTemplateSoon: clinic.whatsappTemplateSoon ?? null,
whatsappTemplateCalled: clinic.whatsappTemplateCalled ?? null,
whatsappTemplateWithdrawn: clinic.whatsappTemplateWithdrawn ?? null,
smsEnabled: clinic.smsEnabled ?? false,
};
}),
@ -1728,6 +1866,7 @@ export const appRouter = router({
analytics: analyticsRouter,
subscription: subscriptionRouter,
whatsapp: whatsappRouter,
notification: notificationRouter,
clinicSettings: clinicSettingsRouter,
history: historyRouter,
admin: adminRouter,

View file

@ -102,6 +102,9 @@ export const clinics = mysqlTable(
whatsappTemplateSoon: text("whatsappTemplateSoon"),
whatsappTemplateCalled: text("whatsappTemplateCalled"),
whatsappTemplateWithdrawn: text("whatsappTemplateWithdrawn"),
// Activation des notifications SMS Twilio (opt-in par cabinet)
// Migration: ALTER TABLE clinics ADD COLUMN smsEnabled TINYINT(1) NOT NULL DEFAULT 0
smsEnabled: boolean("smsEnabled").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},

View file

@ -0,0 +1,298 @@
/**
* PDF report generator (queue analytics) using pdfkit.
*
* generateQueueReport returns a Buffer containing the binary PDF, ready to
* be base64-encoded and shipped to the client. All sections handle empty
* data sets gracefully so a freshly-onboarded clinic still gets a valid PDF.
*/
import PDFDocument from "pdfkit";
import {
getClinicById,
getAdvancedAnalytics,
getConsultationStats,
} from "../db.js";
import { childLogger } from "../_core/logger.js";
const log = childLogger("pdf-report");
// QueueMed medical theme
const COLOR_PRIMARY = "#10b981"; // emerald-500
const COLOR_ACCENT = "#06b6d4"; // cyan-500
const COLOR_TEAL = "#0d9488"; // teal-600
const COLOR_TEXT = "#0f172a"; // slate-900
const COLOR_MUTED = "#64748b"; // slate-500
const COLOR_BORDER = "#e2e8f0"; // slate-200
const COLOR_BG_SOFT = "#f0fdf4"; // green-50
const REASON_LABELS: Record<string, string> = {
consultation: "Consultation",
urgence: "Urgence",
certificat_scolaire: "Certificat scolaire",
certificat_sportif: "Certificat sportif",
arret_travail: "Arrêt de travail",
administratif: "Administratif",
autre: "Autre",
};
function formatDate(d: Date): string {
return d.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
export async function generateQueueReport(
clinicId: number,
dateRange: { days: number }
): Promise<Buffer> {
const days = Math.max(1, Math.min(dateRange.days ?? 30, 365));
const clinic = await getClinicById(clinicId);
if (!clinic) throw new Error(`Clinic ${clinicId} introuvable`);
// Fetch the same data the dashboard renders so the PDF matches the UI.
const [advanced, consult] = await Promise.all([
getAdvancedAnalytics(clinic.userId, { days, clinicId }),
getConsultationStats(clinicId, days),
]);
const now = new Date();
const since = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const doc = new PDFDocument({
size: "A4",
margin: 48,
info: {
Title: `QueueMed — Rapport ${clinic.name}`,
Author: "QueueMed",
Subject: "Rapport file d'attente",
Creator: "QueueMed",
},
});
const chunks: Buffer[] = [];
doc.on("data", (c: Buffer) => chunks.push(c));
const finished = new Promise<Buffer>((resolve, reject) => {
doc.on("end", () => resolve(Buffer.concat(chunks)));
doc.on("error", reject);
});
// ─── Header ────────────────────────────────────────────────────────────────
doc
.rect(0, 0, doc.page.width, 90)
.fill(COLOR_PRIMARY);
doc
.fillColor("#ffffff")
.fontSize(22)
.font("Helvetica-Bold")
.text("QueueMed — Rapport file d'attente", 48, 28);
doc
.fontSize(11)
.font("Helvetica")
.fillColor("#ecfeff")
.text(`Cabinet : ${clinic.name}`, 48, 58);
doc.text(
`Période : ${formatDate(since)}${formatDate(now)} (${days} j)`,
48,
72
);
doc.fillColor(COLOR_TEXT);
doc.y = 120;
doc.x = 48;
// ─── Summary table ─────────────────────────────────────────────────────────
doc
.fontSize(14)
.font("Helvetica-Bold")
.fillColor(COLOR_TEAL)
.text("Résumé", { underline: false });
doc.moveDown(0.4);
const totalJoined = advanced.totalJoined;
const totalServed = advanced.totalServed;
const totalAbsent = advanced.totalAbsent;
const noShowPct = Math.round((advanced.noShowRate ?? 0) * 100);
const totalConsults = consult.totalConsultations;
const avgConsultMin = consult.avgDurationMinutes;
const presenceRate = consult.presenceRate;
// Compute average wait from byHour aggregates is not direct — use avgWaitByDay
const allDays = advanced.avgWaitByDay;
const totalWait = allDays.reduce(
(acc, d) => acc + d.avgWaitMinutes * d.count,
0
);
const totalWaitCount = allDays.reduce((acc, d) => acc + d.count, 0);
const avgWaitMin = totalWaitCount > 0 ? Math.round(totalWait / totalWaitCount) : 0;
const summaryRows: Array<[string, string]> = [
["Patients inscrits", String(totalJoined)],
["Patients servis", String(totalServed)],
["Patients absents", String(totalAbsent)],
["Taux d'absence", `${noShowPct}%`],
["Taux de présence", `${presenceRate}%`],
["Attente moyenne", `${avgWaitMin} min`],
["Consultations terminées", String(totalConsults)],
["Durée moy. consultation", `${avgConsultMin} min`],
];
drawKeyValueTable(doc, summaryRows);
doc.moveDown(1);
// ─── By-hour breakdown ─────────────────────────────────────────────────────
doc
.fontSize(14)
.font("Helvetica-Bold")
.fillColor(COLOR_TEAL)
.text("Affluence par heure");
doc.moveDown(0.4);
const hourRows = advanced.byHour
.map((count, hour) => ({ hour, count }))
.filter((r) => r.count > 0);
if (hourRows.length === 0) {
doc
.fontSize(10)
.font("Helvetica-Oblique")
.fillColor(COLOR_MUTED)
.text("Aucune donnée d'affluence sur la période.");
} else {
const maxCount = Math.max(...hourRows.map((r) => r.count));
drawTableHeader(doc, ["Heure", "Patients", "Répartition"], [80, 80, 320]);
for (const r of hourRows) {
ensureSpace(doc, 22);
const y = doc.y;
doc
.font("Helvetica")
.fontSize(10)
.fillColor(COLOR_TEXT)
.text(`${r.hour.toString().padStart(2, "0")}h`, 48, y, { width: 80 });
doc.text(String(r.count), 128, y, { width: 80 });
// Bar
const barX = 208;
const barMaxW = 300;
const barW = Math.max(4, Math.round((r.count / maxCount) * barMaxW));
doc.rect(barX, y + 3, barW, 8).fill(COLOR_PRIMARY);
doc.fillColor(COLOR_TEXT);
doc.y = y + 18;
}
}
doc.moveDown(1);
// ─── Top visit reasons ─────────────────────────────────────────────────────
doc
.fontSize(14)
.font("Helvetica-Bold")
.fillColor(COLOR_TEAL)
.text("Motifs de consultation");
doc.moveDown(0.4);
if (consult.topReasons.length === 0) {
doc
.fontSize(10)
.font("Helvetica-Oblique")
.fillColor(COLOR_MUTED)
.text("Aucun motif de consultation enregistré.");
} else {
drawTableHeader(doc, ["Motif", "Nombre", "Part"], [240, 80, 120]);
const topTotal = consult.topReasons.reduce((s, r) => s + r.count, 0);
for (const r of consult.topReasons) {
ensureSpace(doc, 18);
const y = doc.y;
const label = REASON_LABELS[r.reason] ?? r.reason;
const pct = topTotal > 0 ? Math.round((r.count / topTotal) * 100) : 0;
doc
.font("Helvetica")
.fontSize(10)
.fillColor(COLOR_TEXT)
.text(label, 48, y, { width: 240 });
doc.text(String(r.count), 288, y, { width: 80 });
doc.text(`${pct}%`, 368, y, { width: 120 });
doc.y = y + 16;
}
}
// ─── Footer ────────────────────────────────────────────────────────────────
const footerY = doc.page.height - 36;
doc
.fontSize(9)
.font("Helvetica")
.fillColor(COLOR_MUTED)
.text(
`Généré par QueueMed le ${now.toLocaleString("fr-FR")}`,
48,
footerY,
{ align: "center", width: doc.page.width - 96 }
);
doc.end();
try {
return await finished;
} catch (err) {
log.error({ err, clinicId }, "PDF generation failed");
throw err;
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function ensureSpace(doc: PDFKit.PDFDocument, needed: number): void {
if (doc.y + needed > doc.page.height - 60) {
doc.addPage();
}
}
function drawKeyValueTable(
doc: PDFKit.PDFDocument,
rows: Array<[string, string]>
): void {
const colKeyW = 260;
const colValW = 240;
const x = 48;
for (const [k, v] of rows) {
ensureSpace(doc, 22);
const y = doc.y;
doc
.rect(x, y, colKeyW + colValW, 20)
.fill(COLOR_BG_SOFT)
.stroke(COLOR_BORDER);
doc
.font("Helvetica")
.fontSize(10)
.fillColor(COLOR_TEXT)
.text(k, x + 8, y + 6, { width: colKeyW - 16 });
doc
.font("Helvetica-Bold")
.fillColor(COLOR_TEAL)
.text(v, x + colKeyW, y + 6, { width: colValW - 8, align: "right" });
doc.y = y + 22;
}
doc.fillColor(COLOR_TEXT);
}
function drawTableHeader(
doc: PDFKit.PDFDocument,
headers: string[],
widths: number[]
): void {
ensureSpace(doc, 22);
const x = 48;
const y = doc.y;
doc.rect(x, y, widths.reduce((a, b) => a + b, 0), 20).fill(COLOR_PRIMARY);
let cx = x;
for (let i = 0; i < headers.length; i++) {
doc
.font("Helvetica-Bold")
.fontSize(10)
.fillColor("#ffffff")
.text(headers[i], cx + 8, y + 6, { width: widths[i] - 16 });
cx += widths[i];
}
doc.fillColor(COLOR_TEXT);
doc.y = y + 22;
}

73
server/services/sms.ts Normal file
View file

@ -0,0 +1,73 @@
/**
* SMS notification service using Twilio.
*
* Reads TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, TWILIO_PHONE_NUMBER from env.
* If any of those are missing, sendSms is a no-op that returns
* { success: false } and logs a warning. This keeps the app running in
* environments where Twilio has not been provisioned yet.
*/
import twilio, { type Twilio } from "twilio";
import { childLogger } from "../_core/logger.js";
const log = childLogger("sms");
let cachedClient: Twilio | null = null;
export function isSmsConfigured(): boolean {
return Boolean(
process.env.TWILIO_ACCOUNT_SID &&
process.env.TWILIO_AUTH_TOKEN &&
process.env.TWILIO_PHONE_NUMBER
);
}
function getClient(): Twilio | null {
if (cachedClient) return cachedClient;
if (!isSmsConfigured()) return null;
try {
cachedClient = twilio(
process.env.TWILIO_ACCOUNT_SID!,
process.env.TWILIO_AUTH_TOKEN!
);
return cachedClient;
} catch (err) {
log.error({ err }, "failed to initialise Twilio client");
return null;
}
}
function normalizePhone(input: string): string {
const trimmed = input.trim();
if (trimmed.startsWith("+")) return trimmed;
const digits = trimmed.replace(/[^0-9]/g, "");
return `+${digits}`;
}
export async function sendSms(
to: string,
body: string
): Promise<{ success: boolean; messageId?: string; error?: string }> {
const client = getClient();
if (!client) {
log.warn({ to: to.slice(0, 4) + "***" }, "Twilio not configured — SMS skipped");
return { success: false, error: "Twilio non configuré" };
}
const from = process.env.TWILIO_PHONE_NUMBER!;
const normalized = normalizePhone(to);
try {
const msg = await client.messages.create({
to: normalized,
from,
body,
});
log.info({ messageId: msg.sid, to: normalized.slice(0, 4) + "***" }, "SMS sent");
return { success: true, messageId: msg.sid };
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error({ err, to: normalized.slice(0, 4) + "***" }, "SMS send failed");
return { success: false, error: errorMessage };
}
}