feat: Phase 6 — SMS Twilio, PDF reports, QR join page, verifyQr endpoint
This commit is contained in:
parent
6103279dd4
commit
9bd134fb5d
7 changed files with 717 additions and 1 deletions
|
|
@ -37,6 +37,14 @@ VITE_STRIPE_PRO_PRICE_ID=
|
|||
# Must live on a Docker volume in production so sessions survive restarts.
|
||||
WHATSAPP_SESSION_DIR=/app/data/whatsapp-sessions
|
||||
|
||||
# ─── Twilio SMS ─────────────────────────────────────────────────────────────
|
||||
# All Twilio vars are OPTIONAL. If any is missing, SMS sending is disabled
|
||||
# and the app logs a warning instead of failing. Each clinic also has a
|
||||
# `smsEnabled` opt-in flag; SMS is never sent without both.
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_PHONE_NUMBER=
|
||||
|
||||
# ─── Docker compose only ────────────────────────────────────────────────────
|
||||
MYSQL_ROOT_PASSWORD=replace_me_with_a_strong_password
|
||||
MYSQL_DATABASE=queuemed
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import DoctorClinics from "@/pages/DoctorClinics";
|
|||
import QueueManagement from "@/pages/QueueManagement";
|
||||
import Analytics from "@/pages/Analytics";
|
||||
import PatientQueue from "@/pages/PatientQueue";
|
||||
import QrJoin from "@/pages/QrJoin";
|
||||
import DisplayScreen from "@/pages/DisplayScreen";
|
||||
import SubscriptionPage from "@/pages/SubscriptionPage";
|
||||
import PrintTicket from "@/pages/PrintTicket";
|
||||
|
|
@ -58,7 +59,7 @@ export default function App() {
|
|||
<Route path="/queue/:token" component={PatientQueue} />
|
||||
<Route path="/display/:clinicId" component={DisplayScreen} />
|
||||
<Route path="/ticket/:entryId" component={PrintTicket} />
|
||||
<Route path="/q/:clinicId/:qrToken" component={PatientQueue} />
|
||||
<Route path="/q/:clinicId/:qrToken" component={QrJoin} />
|
||||
|
||||
{/* Authenticated routes (wrapped in Layout) */}
|
||||
<Route path="/dashboard">
|
||||
|
|
|
|||
194
client/src/pages/QrJoin.tsx
Normal file
194
client/src/pages/QrJoin.tsx
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import { useState } from "react";
|
||||
import { useParams, useLocation } from "wouter";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Stethoscope, QrCode, Smartphone, Loader2, XCircle, User, Phone, MessageCircle,
|
||||
} from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const VISIT_REASONS = [
|
||||
"consultation", "urgence", "certificat_scolaire", "certificat_sportif",
|
||||
"arret_travail", "administratif", "autre",
|
||||
] as const;
|
||||
|
||||
export default function QrJoin() {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams<{ clinicId: string; qrToken: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const clinicId = parseInt(params.clinicId ?? "0", 10);
|
||||
const qrToken = params.qrToken ?? "";
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [whatsappPhone, setWhatsappPhone] = useState("");
|
||||
const [reason, setReason] = useState<string>("consultation");
|
||||
const [useSamePhone, setUseSamePhone] = useState(true);
|
||||
|
||||
// Verify QR is valid first
|
||||
const verifyQuery = trpc.queue.verifyQr.useQuery(
|
||||
{ clinicId, qrToken },
|
||||
{ enabled: !!qrToken && clinicId > 0, retry: false }
|
||||
);
|
||||
|
||||
const joinMutation = trpc.queue.join.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(t("patient.toastJoined"));
|
||||
navigate(`/queue/${data.patientToken}`);
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(e.message);
|
||||
},
|
||||
});
|
||||
|
||||
if (verifyQuery.isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Helmet><title>QueueMed</title></Helmet>
|
||||
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (verifyQuery.error || !verifyQuery.data) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet><title>QueueMed</title></Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<XCircle className="w-12 h-12 text-red-400 mx-auto mb-4" />
|
||||
<h1 className="font-bold text-2xl mb-2">{t("patient.ticketNotFound")}</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
{t("patient.ticketNotFoundDesc")}
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { clinicName, isQueueOpen, whatsappConnected } = verifyQuery.data;
|
||||
|
||||
if (!isQueueOpen) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet><title>QueueMed — {clinicName}</title></Helmet>
|
||||
<div className="glass-card rounded-3xl p-10 text-center max-w-md">
|
||||
<XCircle className="w-12 h-12 text-orange-400 mx-auto mb-4" />
|
||||
<h1 className="font-bold text-2xl mb-2">File d'attente fermée</h1>
|
||||
<p className="text-slate-500 text-sm mb-6">
|
||||
Le cabinet {clinicName} n'accepte plus de patients pour le moment.
|
||||
</p>
|
||||
<Button variant="gradient" onClick={() => navigate("/")}>{t("common.backToHome")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
joinMutation.mutate({
|
||||
clinicId,
|
||||
qrToken,
|
||||
patientName: name || undefined,
|
||||
patientPhone: phone || undefined,
|
||||
whatsappPhone: useSamePhone ? phone : whatsappPhone || undefined,
|
||||
visitReason: reason as any,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Helmet><title>QueueMed — {clinicName}</title></Helmet>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="glass-card rounded-3xl p-8 max-w-md w-full"
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-emerald-500 to-cyan-500 flex items-center justify-center">
|
||||
<QrCode className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="font-bold text-2xl text-slate-800">{clinicName}</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">Rejoignez la salle d'attente virtuelle</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Nom (optionnel)</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Votre nom"
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Téléphone (optionnel)</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+594..."
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{whatsappConnected && phone && (
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useSamePhone}
|
||||
onChange={(e) => setUseSamePhone(e.target.checked)}
|
||||
className="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500"
|
||||
/>
|
||||
<MessageCircle className="w-4 h-4 text-emerald-500" />
|
||||
Recevoir les notifications WhatsApp
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">Motif de visite</label>
|
||||
<select
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-slate-200 bg-white/80 focus:outline-none focus:ring-2 focus:ring-emerald-500/30 focus:border-emerald-500 transition"
|
||||
>
|
||||
{VISIT_REASONS.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{t(`visitReasons.${r}`, r)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="gradient"
|
||||
className="w-full"
|
||||
disabled={joinMutation.isPending}
|
||||
>
|
||||
{joinMutation.isPending ? (
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Stethoscope className="w-5 h-5 mr-2" />
|
||||
Prendre un ticket
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
298
server/services/pdfReport.ts
Normal file
298
server/services/pdfReport.ts
Normal 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
73
server/services/sms.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue