import crypto from "node:crypto"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import QRCode from "qrcode"; import { Server as SocketIOServer } from "socket.io"; import { asc, eq } from "drizzle-orm"; import { router, publicProcedure, protectedProcedure, subscriptionProcedure, adminProcedure, } from "./_core/trpc.js"; import { getDb, getUserByEmail, getUserById, createUser, touchUserLogin, createTrialSubscription, getSubscription, isSubscriptionActive, getClinics, getClinicById, getClinicByQrToken, createClinic, updateClinic, deleteClinic, rotateQrToken, ensureFreshQrToken, getActiveQueue, getQueueEntry, getQueueEntryByToken, setUserResetToken, getUserByResetToken, updateUserPassword, addToQueue, updateQueueEntry, reorderQueue, setQueueOrder, resetQueue, logAnalyticsEvent, getAnalytics, getAnalyticsForClinic, getConsultationHistory, getConsultationStats, listAllUsers, setUserRole, setUserDisabled, getAdminOverview, listAllClinicsWithStats, listClinicMembers, getClinicMember, addClinicMember, removeClinicMember, updateClinicMember, getAdvancedAnalytics, listAllConfig, setConfigValue, deleteConfigValue, } from "./db.js"; import { whatsappCountryCodes } from "./schema.js"; import { hashPassword, verifyPassword, createToken, setAuthCookie, clearAuthCookie, } from "./auth.js"; import { sendMail, buildResetEmail } from "./services/email.js"; import { connectWhatsApp, disconnectWhatsApp, getWhatsAppStatus, getActiveWhatsAppSessionsCount, sendWhatsAppMessage, buildJoinMessage, buildSoonMessage, buildCalledMessage, buildWithdrawnMessage, } from "./services/whatsapp.js"; import { isClinicOpen, buildClosedMessage, getTodaySchedule, getNextOpeningTime, formatWeeklySchedule, type OpeningHours, } from "../shared/openingHours.js"; import { createCheckoutSession, createPortalSession, isStripeConfigured, getStripe, } 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"); // ─── Socket.io accessor ────────────────────────────────────────────────────── function getIo(): SocketIOServer | null { return (globalThis as unknown as { __socketIo?: SocketIOServer }).__socketIo ?? null; } function emitClinic(clinicId: number, event: string, payload: unknown): void { const io = getIo(); if (!io) return; io.to(`clinic:${clinicId}`).emit(event, payload); io.to(`display:${clinicId}`).emit(event, payload); } function emitPatient(patientToken: string, event: string, payload: unknown): void { const io = getIo(); if (!io) return; io.to(`patient:${patientToken}`).emit(event, payload); } async function broadcastQueueState(clinicId: number): Promise { const [clinic, queue] = await Promise.all([ getClinicById(clinicId), getActiveQueue(clinicId), ]); if (!clinic) return; const callingNow = queue.find((e) => e.status === "called") ?? null; emitClinic(clinicId, "queue:update", { clinic, queue, callingNow, waitingCount: queue.filter((e) => e.status === "waiting").length, }); for (const entry of queue) { emitPatient(entry.patientToken, "patient:update", { entry, position: entry.position, estimatedWaitMinutes: entry.estimatedWaitMinutes, callingNow, waitingCount: queue.filter((e) => e.status === "waiting").length, }); } } function diffMinutes(later: Date, earlier: Date): number { return Math.max(0, Math.round((later.getTime() - earlier.getTime()) / 60000)); } // ─── Auth router ───────────────────────────────────────────────────────────── const authRouter = router({ register: publicProcedure .input( z.object({ email: z.string().email().max(320), password: z.string().min(8).max(128), name: z.string().min(1).max(128).optional(), }) ) .mutation(async ({ input, ctx }) => { const existing = await getUserByEmail(input.email.toLowerCase()); if (existing) { if (existing.disabled) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce compte a été désactivé. Contactez un administrateur.", }); } throw new TRPCError({ code: "CONFLICT", message: "Un compte existe déjà avec cet email", }); } const passwordHash = await hashPassword(input.password); const user = await createUser({ email: input.email.toLowerCase(), passwordHash, name: input.name ?? null, loginMethod: "password", role: "user", }); await createTrialSubscription(user.id); const token = createToken(user); setAuthCookie(ctx.res, token); return { success: true, user: { id: user.id, email: user.email, name: user.name, role: user.role }, }; }), login: publicProcedure .input( z.object({ email: z.string().email().max(320), password: z.string().min(1).max(128), }) ) .mutation(async ({ input, ctx }) => { const user = await getUserByEmail(input.email.toLowerCase()); if (!user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect", }); } const ok = await verifyPassword(input.password, user.passwordHash); if (!ok) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Email ou mot de passe incorrect", }); } if (user.disabled) { throw new TRPCError({ code: "FORBIDDEN", message: "Ce compte a été désactivé. Contactez un administrateur.", }); } await touchUserLogin(user.id); const sub = await getSubscription(user.id); if (!sub) await createTrialSubscription(user.id); const token = createToken(user); setAuthCookie(ctx.res, token); return { success: true, user: { id: user.id, email: user.email, name: user.name, role: user.role }, }; }), logout: publicProcedure.mutation(({ ctx }) => { clearAuthCookie(ctx.res); return { success: true }; }), me: publicProcedure.query(async ({ ctx }) => { if (!ctx.user) return null; const fresh = await getUserById(ctx.user.id); if (!fresh) return null; return { id: fresh.id, email: fresh.email, name: fresh.name, role: fresh.role, createdAt: fresh.createdAt, lastSignedIn: fresh.lastSignedIn, }; }), forgotPassword: publicProcedure .input(z.object({ email: z.string().email().max(320) })) .mutation(async ({ input }) => { const user = await getUserByEmail(input.email.toLowerCase()); if (user) { const token = crypto.randomBytes(6).toString("hex"); const expiry = new Date(Date.now() + 60 * 60 * 1000); await setUserResetToken(user.id, token, expiry); const baseUrl = process.env.PUBLIC_BASE_URL ?? ""; const resetUrl = `${baseUrl}/reset-password/${token}`; const { subject, html, text } = buildResetEmail(resetUrl); try { await sendMail({ to: user.email, subject, html, text }); } catch (err) { authLog.error({ err, userId: user.id }, "forgotPassword sendMail failed"); } } return { success: true }; }), resetPassword: publicProcedure .input( z.object({ token: z.string().min(8).max(255), newPassword: z.string().min(8).max(128), }) ) .mutation(async ({ input }) => { const user = await getUserByResetToken(input.token); if (!user || !user.resetTokenExpiry || user.resetTokenExpiry.getTime() < Date.now()) { throw new TRPCError({ code: "BAD_REQUEST", message: "Lien invalide ou expiré", }); } const passwordHash = await hashPassword(input.newPassword); await updateUserPassword(user.id, passwordHash); return { success: true }; }), }); // ─── Clinic router ─────────────────────────────────────────────────────────── const clinicRouter = router({ list: protectedProcedure.query(async ({ ctx }) => { return getClinics(ctx.user.id); }), getById: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input, ctx }) => { const clinic = await getClinicById(input.id); if (!clinic || clinic.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } return clinic; }), // Public lookup used by display screen and patient pages getPublicByToken: publicProcedure .input(z.object({ token: z.string().min(8).max(64) })) .query(async ({ input }) => { const clinic = await getClinicByQrToken(input.token); if (!clinic || !clinic.isActive) { throw new TRPCError({ code: "NOT_FOUND", message: "Lien expiré ou invalide" }); } return { id: clinic.id, name: clinic.name, address: clinic.address, color: clinic.color, avgConsultationMinutes: clinic.avgConsultationMinutes, isQueueOpen: clinic.isQueueOpen, }; }), getPublicById: publicProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input }) => { const clinic = await getClinicById(input.id); if (!clinic || !clinic.isActive) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } return { id: clinic.id, name: clinic.name, color: clinic.color, avgConsultationMinutes: clinic.avgConsultationMinutes, isQueueOpen: clinic.isQueueOpen, }; }), create: subscriptionProcedure .input( z.object({ name: z.string().min(2).max(255), address: z.string().max(500).optional(), phone: z.string().max(32).optional(), whatsappPhone: z.string().max(32).optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), avgConsultationMinutes: z.number().int().min(1).max(180).optional(), maxQueueSize: z.number().int().min(1).max(500).optional(), qrRotationMinutes: z.number().int().min(0).max(1440).optional(), }) ) .mutation(async ({ input, ctx }) => { const limit = await checkPlanLimit(ctx.user.id, "maxClinics"); if (!limit.ok) { throw new TRPCError({ code: "FORBIDDEN", message: limit.reason }); } const result = await createClinic(ctx.user.id, { name: input.name, address: input.address ?? null, phone: input.phone ?? null, whatsappPhone: input.whatsappPhone ?? null, color: input.color ?? "#10b981", avgConsultationMinutes: input.avgConsultationMinutes ?? 15, maxQueueSize: input.maxQueueSize ?? 50, qrRotationMinutes: input.qrRotationMinutes ?? 30, }); return { success: true, id: result.insertId, qrToken: result.qrToken }; }), update: subscriptionProcedure .input( z.object({ id: z.number().int().positive(), name: z.string().min(2).max(255).optional(), address: z.string().max(500).nullable().optional(), phone: z.string().max(32).nullable().optional(), whatsappPhone: z.string().max(32).nullable().optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), isActive: z.boolean().optional(), avgConsultationMinutes: z.number().int().min(1).max(180).optional(), maxQueueSize: z.number().int().min(1).max(500).optional(), qrRotationMinutes: z.number().int().min(0).max(1440).optional(), autoAbsentMinutes: z.number().int().min(0).max(60).optional(), isQueueOpen: z.boolean().optional(), }) ) .mutation(async ({ input, ctx }) => { const clinic = await getClinicById(input.id); if (!clinic || clinic.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const wasOpen = clinic.isQueueOpen; const { id, ...patch } = input; await updateClinic(id, patch); if (typeof input.isQueueOpen === "boolean" && input.isQueueOpen !== wasOpen) { await logAnalyticsEvent({ clinicId: id, eventType: input.isQueueOpen ? "queue_opened" : "queue_closed", }); await broadcastQueueState(id); } return { success: true }; }), delete: protectedProcedure .input(z.object({ id: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { const clinic = await getClinicById(input.id); if (!clinic || clinic.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } await deleteClinic(input.id); 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 && !(await 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 }) => { const clinic = await getClinicById(input.id); if (!clinic || clinic.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const { qrToken, qrTokenExpiresAt } = await rotateQrToken(input.id); emitClinic(input.id, "qr:rotated", { qrToken, qrTokenExpiresAt }); return { success: true, qrToken, qrTokenExpiresAt }; }), qrDataUrl: protectedProcedure .input(z.object({ id: z.number().int().positive(), baseUrl: z.string().url().optional() })) .query(async ({ input, ctx }) => { const clinic = await getClinicById(input.id); if (!clinic || clinic.userId !== ctx.user.id) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const fresh = await ensureFreshQrToken(clinic); const base = input.baseUrl ?? ""; const url = `${base}/q/${fresh.id}/${fresh.qrToken}`; const dataUrl = await QRCode.toDataURL(url, { width: 512, margin: 2, color: { dark: "#0f766e", light: "#ffffff" }, }); return { url, dataUrl, qrToken: fresh.qrToken, qrTokenExpiresAt: fresh.qrTokenExpiresAt }; }), // ─── Members (multi-praticiens) ─────────────────────────────────────────── listMembers: protectedProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } return listClinicMembers(input.clinicId); }), // Public-ish: practitioners visible to display screen (only display name + color) listMembersPublic: publicProcedure .input(z.object({ clinicId: z.number().int().positive() })) .query(async ({ input }) => { const clinic = await getClinicById(input.clinicId); if (!clinic || !clinic.isActive) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const members = await listClinicMembers(input.clinicId); return members.map((m) => ({ id: m.id, userId: m.userId, role: m.role, color: m.color, displayName: m.displayName ?? m.name ?? null, })); }), addMember: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), email: z.string().email().max(320), displayName: z.string().min(1).max(128).optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), }) ) .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" }); } const limit = await checkPlanLimit(ctx.user.id, "multiPractitioner"); if (!limit.ok) { throw new TRPCError({ code: "FORBIDDEN", message: limit.reason }); } const memberUser = await getUserByEmail(input.email.toLowerCase()); if (!memberUser) { throw new TRPCError({ code: "NOT_FOUND", message: "Aucun utilisateur QueueMed avec cet email", }); } const existing = await getClinicMember(input.clinicId, memberUser.id); if (existing) { throw new TRPCError({ code: "CONFLICT", message: "Ce praticien fait déjà partie du cabinet", }); } const member = await addClinicMember({ clinicId: input.clinicId, userId: memberUser.id, role: "practitioner", color: input.color ?? "#10b981", displayName: input.displayName ?? memberUser.name ?? null, }); return { success: true, memberId: member.id }; }), updateMember: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), memberId: z.number().int().positive(), displayName: z.string().min(1).max(128).optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional(), }) ) .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" }); } const patch: Record = {}; if (input.displayName !== undefined) patch.displayName = input.displayName; if (input.color !== undefined) patch.color = input.color; if (Object.keys(patch).length > 0) { await updateClinicMember(input.memberId, patch as Parameters[1]); } return { success: true }; }), removeMember: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), memberId: z.number().int().positive(), }) ) .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" }); } await removeClinicMember(input.clinicId, input.memberId); return { success: true }; }), }); // ─── Queue router ──────────────────────────────────────────────────────────── const queueRouter = router({ // Public — get queue snapshot for display screen / patient getPublic: publicProcedure .input(z.object({ clinicId: z.number().int().positive() })) .query(async ({ input }) => { const [clinic, queue] = await Promise.all([ getClinicById(input.clinicId), getActiveQueue(input.clinicId), ]); if (!clinic || !clinic.isActive) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const callingNow = queue.find((e) => e.status === "called") ?? null; return { clinic: { id: clinic.id, name: clinic.name, color: clinic.color, isQueueOpen: clinic.isQueueOpen, avgConsultationMinutes: clinic.avgConsultationMinutes, }, queue: queue.map((e) => ({ id: e.id, ticketNumber: e.ticketNumber, status: e.status, position: e.position, estimatedWaitMinutes: e.estimatedWaitMinutes, patientName: e.patientName, })), callingNow: callingNow ? { id: callingNow.id, ticketNumber: callingNow.ticketNumber, patientName: callingNow.patientName, practitionerId: callingNow.practitionerId } : null, waitingCount: queue.filter((e) => e.status === "waiting").length, }; }), // Authenticated doctor view — full data getForDoctor: protectedProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } const queue = await getActiveQueue(input.clinicId); return { clinic, queue, callingNow: queue.find((e) => e.status === "called") ?? null, waitingCount: queue.filter((e) => e.status === "waiting").length, }; }), // 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 = getWhatsAppStatus(clinic.id); 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 }) => { const entry = await getQueueEntryByToken(input.patientToken); if (!entry) { throw new TRPCError({ code: "NOT_FOUND", message: "Ticket introuvable" }); } const queue = await getActiveQueue(entry.clinicId); const clinic = await getClinicById(entry.clinicId); const callingNow = queue.find((e) => e.status === "called") ?? null; return { entry, clinic: clinic ? { id: clinic.id, name: clinic.name, color: clinic.color, avgConsultationMinutes: clinic.avgConsultationMinutes, } : null, callingNow: callingNow ? { ticketNumber: callingNow.ticketNumber, patientName: callingNow.patientName, practitionerId: callingNow.practitionerId } : null, waitingCount: queue.filter((e) => e.status === "waiting").length, }; }), getEntryById: publicProcedure .input(z.object({ id: z.number().int().positive() })) .query(async ({ input }) => { const entry = await getQueueEntry(input.id); if (!entry) { throw new TRPCError({ code: "NOT_FOUND", message: "Ticket introuvable" }); } const clinic = await getClinicById(entry.clinicId); return { entry, clinic: clinic ? { id: clinic.id, name: clinic.name, address: clinic.address } : null, }; }), // Patient joins queue via QR token join: publicProcedure .input( z.object({ clinicId: z.number().int().positive(), qrToken: z.string().min(8).max(64), patientName: z.string().min(1).max(128).optional(), patientPhone: z.string().min(3).max(32).optional(), whatsappPhone: z.string().min(3).max(32).optional(), visitReason: z .enum([ "consultation", "urgence", "certificat_scolaire", "certificat_sportif", "arret_travail", "administratif", "autre", ]) .optional(), visitNote: z.string().max(200).optional(), }) ) .mutation(async ({ input }) => { const clinic = await getClinicById(input.clinicId); if (!clinic || !clinic.isActive) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const dailyLimit = await checkPlanLimit(clinic.userId, "maxQueueEntriesPerDay", { clinicId: clinic.id, }); if (!dailyLimit.ok) { throw new TRPCError({ code: "FORBIDDEN", message: dailyLimit.reason }); } if (clinic.qrToken !== input.qrToken) { throw new TRPCError({ code: "FORBIDDEN", message: "QR code expiré, veuillez le rescanner", }); } if ( clinic.qrTokenExpiresAt && clinic.qrTokenExpiresAt.getTime() < Date.now() && clinic.qrRotationMinutes && clinic.qrRotationMinutes > 0 ) { // Auto-refresh expired QR token so patients can always join const fresh = await rotateQrToken(clinic.id); clinic = (await getClinicById(clinic.id)) ?? clinic; } if (!clinic.isQueueOpen) { throw new TRPCError({ code: "FORBIDDEN", message: "La file est fermée" }); } // Vérifier les horaires d'ouverture si configurés const openingHours = clinic.openingHours as OpeningHours | null; if (openingHours && Object.keys(openingHours).length > 0) { if (!isClinicOpen(openingHours)) { throw new TRPCError({ code: "FORBIDDEN", message: buildClosedMessage(openingHours, clinic.name), }); } } const { entry, ticketNumber, patientToken } = await addToQueue({ clinicId: clinic.id, patientName: input.patientName, patientPhone: input.patientPhone, whatsappPhone: input.whatsappPhone, visitReason: input.visitReason, visitNote: input.visitNote, }); const queue = await getActiveQueue(clinic.id); await logAnalyticsEvent({ clinicId: clinic.id, eventType: "patient_joined", ticketNumber, queueSizeAtEvent: queue.length, }); await broadcastQueueState(clinic.id); // Notification WhatsApp "joined" si numéro fourni et session connectée if (input.whatsappPhone) { const waStatus = getWhatsAppStatus(clinic.id); if (waStatus.status === "connected") { const customTemplates = { joined: clinic.whatsappTemplateJoined, soon: clinic.whatsappTemplateSoon, called: clinic.whatsappTemplateCalled, withdrawn: clinic.whatsappTemplateWithdrawn, }; const msg = buildJoinMessage( clinic.name, ticketNumber, entry.position, entry.estimatedWaitMinutes ?? 0, customTemplates ); sendWhatsAppMessage(clinic.id, input.whatsappPhone, msg) .then(() => updateQueueEntry(entry.id, { whatsappSentJoined: true })) .catch(() => { /* silent */ }); } } return { success: true, entryId: entry.id, ticketNumber, patientToken, position: entry.position, estimatedWaitMinutes: entry.estimatedWaitMinutes, }; }), // Doctor prints a ticket for a patient without smartphone joinPrinted: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), patientName: z.string().min(1).max(128).optional(), }) ) .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 (!clinic.isQueueOpen) { throw new TRPCError({ code: "FORBIDDEN", message: "La file est fermée" }); } const { entry, ticketNumber, patientToken } = await addToQueue({ clinicId: clinic.id, patientName: input.patientName, isPrinted: true, }); const queue = await getActiveQueue(clinic.id); await logAnalyticsEvent({ clinicId: clinic.id, eventType: "patient_joined", ticketNumber, queueSizeAtEvent: queue.length, metadata: { printed: true }, }); await broadcastQueueState(clinic.id); return { success: true, entryId: entry.id, ticketNumber, patientToken, position: entry.position, estimatedWaitMinutes: entry.estimatedWaitMinutes, }; }), callNext: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), practitionerId: z.number().int().positive().optional(), }) ) .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" }); } const queue = await getActiveQueue(input.clinicId); const previouslyCalled = queue.filter((e) => e.status === "called"); for (const prev of previouslyCalled) { await updateQueueEntry(prev.id, { status: "in_consultation", consultationStartAt: new Date(), }); } const next = queue.find((e) => e.status === "waiting"); if (!next) { await broadcastQueueState(input.clinicId); return { success: true, called: null }; } const now = new Date(); await updateQueueEntry(next.id, { status: "called", calledAt: now, notificationSent: true, ...(input.practitionerId ? { practitionerId: input.practitionerId } : {}), }); const waitMinutes = diffMinutes(now, next.joinedAt); await logAnalyticsEvent({ clinicId: clinic.id, eventType: "patient_called", ticketNumber: next.ticketNumber, waitMinutes, queueSizeAtEvent: queue.length, }); await reorderQueue(clinic.id); await broadcastQueueState(clinic.id); emitPatient(next.patientToken, "patient:called", { ticketNumber: next.ticketNumber, clinicId: clinic.id, }); const upcoming = (await getActiveQueue(clinic.id)).find((e) => e.status === "waiting"); if (upcoming) { emitPatient(upcoming.patientToken, "patient:approaching", { ticketNumber: upcoming.ticketNumber, }); } // Notifications WhatsApp pour le patient appelé + le suivant const waStatus = getWhatsAppStatus(clinic.id); if (waStatus.status === "connected") { const customTemplates = { joined: clinic.whatsappTemplateJoined, soon: clinic.whatsappTemplateSoon, called: clinic.whatsappTemplateCalled, withdrawn: clinic.whatsappTemplateWithdrawn, }; if (next.whatsappPhone && !next.whatsappSentCalled) { const msg = buildCalledMessage(clinic.name, next.ticketNumber, customTemplates); sendWhatsAppMessage(clinic.id, next.whatsappPhone, msg) .then(() => updateQueueEntry(next.id, { whatsappSentCalled: true })) .catch(() => { /* silent */ }); } if (upcoming && upcoming.whatsappPhone && !upcoming.whatsappSentSoon) { const minutesLeft = clinic.avgConsultationMinutes ?? 15; const msg = buildSoonMessage( clinic.name, upcoming.ticketNumber, minutesLeft, customTemplates ); sendWhatsAppMessage(clinic.id, upcoming.whatsappPhone, msg) .then(() => updateQueueEntry(upcoming.id, { whatsappSentSoon: true })) .catch(() => { /* silent */ }); } } return { success: true, called: { id: next.id, ticketNumber: next.ticketNumber, patientName: next.patientName }, }; }), callSpecific: subscriptionProcedure .input( z.object({ entryId: z.number().int().positive(), practitionerId: z.number().int().positive().optional(), }) ) .mutation(async ({ input, ctx }) => { 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" }); } const queue = await getActiveQueue(entry.clinicId); for (const prev of queue.filter((e) => e.status === "called" && e.id !== entry.id)) { await updateQueueEntry(prev.id, { status: "in_consultation", consultationStartAt: new Date(), }); } const now = new Date(); await updateQueueEntry(entry.id, { status: "called", calledAt: now, notificationSent: true, ...(input.practitionerId ? { practitionerId: input.practitionerId } : {}), }); const waitMinutes = diffMinutes(now, entry.joinedAt); await logAnalyticsEvent({ clinicId: entry.clinicId, eventType: "patient_called", ticketNumber: entry.ticketNumber, waitMinutes, queueSizeAtEvent: queue.length, }); await reorderQueue(entry.clinicId); await broadcastQueueState(entry.clinicId); emitPatient(entry.patientToken, "patient:called", { ticketNumber: entry.ticketNumber, clinicId: entry.clinicId, }); return { success: true }; }), markAbsent: subscriptionProcedure .input(z.object({ entryId: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { 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" }); } await updateQueueEntry(entry.id, { status: "absent" }); await logAnalyticsEvent({ clinicId: entry.clinicId, eventType: "patient_absent", ticketNumber: entry.ticketNumber, }); await reorderQueue(entry.clinicId); await broadcastQueueState(entry.clinicId); emitPatient(entry.patientToken, "patient:absent", { entryId: entry.id }); return { success: true }; }), markDone: subscriptionProcedure .input(z.object({ entryId: z.number().int().positive() })) .mutation(async ({ input, ctx }) => { 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" }); } const now = new Date(); const startedAt = entry.consultationStartAt ?? entry.calledAt ?? now; const consultationMinutes = diffMinutes(now, startedAt); await updateQueueEntry(entry.id, { status: "done", consultationEndAt: now, consultationStartAt: entry.consultationStartAt ?? entry.calledAt, }); const waitMinutes = entry.calledAt ? diffMinutes(entry.calledAt, entry.joinedAt) : null; await logAnalyticsEvent({ clinicId: entry.clinicId, eventType: "patient_done", ticketNumber: entry.ticketNumber, waitMinutes, consultationMinutes, }); await reorderQueue(entry.clinicId); await broadcastQueueState(entry.clinicId); emitPatient(entry.patientToken, "patient:done", { entryId: entry.id }); return { success: true }; }), cancel: publicProcedure .input(z.object({ patientToken: z.string().min(8).max(64) })) .mutation(async ({ input }) => { const entry = await getQueueEntryByToken(input.patientToken); if (!entry) { throw new TRPCError({ code: "NOT_FOUND", message: "Ticket introuvable" }); } if (entry.status !== "waiting" && entry.status !== "called") { return { success: true }; } await updateQueueEntry(entry.id, { status: "canceled" }); await reorderQueue(entry.clinicId); await broadcastQueueState(entry.clinicId); // Notification WhatsApp "withdrawn" si numéro + session connectée const clinic = await getClinicById(entry.clinicId); if (clinic && entry.whatsappPhone) { const waStatus = getWhatsAppStatus(entry.clinicId); if (waStatus.status === "connected") { const customTemplates = { joined: clinic.whatsappTemplateJoined, soon: clinic.whatsappTemplateSoon, called: clinic.whatsappTemplateCalled, withdrawn: clinic.whatsappTemplateWithdrawn, }; const msg = buildWithdrawnMessage(clinic.name, entry.ticketNumber, customTemplates); sendWhatsAppMessage(entry.clinicId, entry.whatsappPhone, msg).catch(() => { /* silent */ }); } } return { success: true }; }), reorder: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), orderedEntryIds: z.array(z.number().int().positive()).min(1), }) ) .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" }); } await setQueueOrder(input.clinicId, input.orderedEntryIds); await broadcastQueueState(input.clinicId); return { success: true }; }), reset: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } await resetQueue(input.clinicId); await broadcastQueueState(input.clinicId); return { success: true }; }), }); // ─── Analytics router ──────────────────────────────────────────────────────── const analyticsRouter = router({ getAll: protectedProcedure .input( z.object({ days: z.number().int().min(1).max(365).optional(), clinicId: z.number().int().positive().optional(), }) ) .query(async ({ input, ctx }) => { return getAnalytics(ctx.user.id, { days: input.days, clinicId: input.clinicId, }); }), summary: protectedProcedure .input( z.object({ days: z.number().int().min(1).max(365).default(30), clinicId: z.number().int().positive().optional(), }) ) .query(async ({ input, ctx }) => { const events = await getAnalytics(ctx.user.id, { days: input.days, clinicId: input.clinicId, }); const byHour = new Array(24).fill(0); const byDay = new Array(7).fill(0); let totalWait = 0; let waitCount = 0; let totalConsult = 0; let consultCount = 0; let totalServed = 0; let totalAbsent = 0; let totalJoined = 0; for (const ev of events) { if (typeof ev.hourOfDay === "number") byHour[ev.hourOfDay] += 1; if (typeof ev.dayOfWeek === "number") byDay[ev.dayOfWeek] += 1; if (ev.eventType === "patient_joined") totalJoined += 1; if (ev.eventType === "patient_done") totalServed += 1; if (ev.eventType === "patient_absent") totalAbsent += 1; if (typeof ev.waitMinutes === "number") { totalWait += ev.waitMinutes; waitCount += 1; } if (typeof ev.consultationMinutes === "number") { totalConsult += ev.consultationMinutes; consultCount += 1; } } const peakHour = byHour.indexOf(Math.max(...byHour)); const peakDay = byDay.indexOf(Math.max(...byDay)); const recommendations: string[] = []; if (waitCount > 0 && totalWait / waitCount > 30) { recommendations.push( "Le temps d'attente moyen dépasse 30 minutes — augmentez la cadence ou ouvrez la file plus tôt." ); } if (totalJoined > 0 && totalAbsent / totalJoined > 0.15) { recommendations.push( "Plus de 15% d'absences — pensez à activer les notifications SMS pour les patients." ); } if (Math.max(...byHour) > 0) { recommendations.push( `Pic d'affluence à ${peakHour}h — prévoyez du renfort ou ouvrez la file 30 min avant.` ); } return { totalJoined, totalServed, totalAbsent, avgWaitMinutes: waitCount ? Math.round(totalWait / waitCount) : 0, avgConsultationMinutes: consultCount ? Math.round(totalConsult / consultCount) : 0, byHour, byDay, peakHour, peakDay, recommendations, }; }), getAdvanced: protectedProcedure .input( z.object({ days: z.number().int().min(1).max(365).default(30), clinicId: z.number().int().positive().optional(), }) ) .query(async ({ input, ctx }) => { return getAdvancedAnalytics(ctx.user.id, { days: input.days, clinicId: input.clinicId, }); }), exportCsv: protectedProcedure .input( z.object({ clinicId: z.number().int().positive(), days: z.number().int().min(1).max(365).default(90), }) ) .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" }); } const events = await getAnalyticsForClinic(input.clinicId, input.days); const header = [ "createdAt", "eventType", "ticketNumber", "waitMinutes", "consultationMinutes", "queueSizeAtEvent", "hourOfDay", "dayOfWeek", ]; const escape = (v: unknown): string => { if (v === null || v === undefined) return ""; const s = String(v); if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; return s; }; const rows = events.map((e) => [ e.createdAt instanceof Date ? e.createdAt.toISOString() : String(e.createdAt), e.eventType, e.ticketNumber, e.waitMinutes, e.consultationMinutes, e.queueSizeAtEvent, e.hourOfDay, e.dayOfWeek, ] .map(escape) .join(",") ); const csv = [header.join(","), ...rows].join("\n"); const filename = `queuemed_${clinic.name.replace(/[^a-z0-9]+/gi, "_")}_${new Date() .toISOString() .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(async () => { return { configured: await isSmsConfigured() }; }), sendSms: subscriptionProcedure .input( z.object({ entryId: z.number().int().positive(), message: z.string().max(500).optional(), }) ) .mutation(async ({ input, ctx }) => { if (!(await 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 ───────────────────────────────────────────────────── const subscriptionRouter = router({ get: protectedProcedure.query(async ({ ctx }) => { return getSubscription(ctx.user.id); }), check: protectedProcedure.query(async ({ ctx }) => { const sub = await getSubscription(ctx.user.id); const active = await isSubscriptionActive(ctx.user.id); let daysRemaining = 0; if (sub) { if (sub.status === "trialing" && sub.trialEndsAt) { daysRemaining = Math.max( 0, Math.ceil((sub.trialEndsAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000)) ); } else if (sub.status === "active" && sub.currentPeriodEnd) { daysRemaining = Math.max( 0, Math.ceil((sub.currentPeriodEnd.getTime() - Date.now()) / (24 * 60 * 60 * 1000)) ); } } return { active, plan: sub?.plan ?? null, status: sub?.status ?? null, trialEndsAt: sub?.trialEndsAt ?? null, currentPeriodEnd: sub?.currentPeriodEnd ?? null, daysRemaining, }; }), getCurrentPlan: protectedProcedure.query(async ({ ctx }) => { const sub = await getSubscription(ctx.user.id); const { plan, limits } = await getPlanLimitsForUser(ctx.user.id); const stripeReady = await isStripeConfigured(); return { plan, status: sub?.status ?? null, trialEndsAt: sub?.trialEndsAt ?? null, currentPeriodEnd: sub?.currentPeriodEnd ?? null, hasStripeCustomer: Boolean(sub?.stripeCustomerId), hasActiveSubscription: Boolean(sub?.stripeSubscriptionId), stripeConfigured: stripeReady, limits: { maxClinics: limits.maxClinics === Infinity ? null : limits.maxClinics, maxQueueEntriesPerDay: limits.maxQueueEntriesPerDay === Infinity ? null : limits.maxQueueEntriesPerDay, multiPractitioner: limits.multiPractitioner, analyticsExport: limits.analyticsExport, }, }; }), createCheckoutSession: protectedProcedure .input(z.object({ priceId: z.string().min(1).max(255) })) .mutation(async ({ input, ctx }) => { if (!isStripeConfigured()) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Le paiement n'est pas encore configuré. Contactez l'administrateur.", }); } try { const { url } = await createCheckoutSession(ctx.user.id, input.priceId); return { url }; } catch (err) { const message = err instanceof Error ? err.message : "Stripe Checkout failed"; throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message }); } }), createPortalSession: protectedProcedure.mutation(async ({ ctx }) => { if (!isStripeConfigured()) { throw new TRPCError({ code: "PRECONDITION_FAILED", message: "Le paiement n'est pas encore configuré. Contactez l'administrateur.", }); } try { const { url } = await createPortalSession(ctx.user.id); return { url }; } catch (err) { const message = err instanceof Error ? err.message : "Stripe Portal failed"; throw new TRPCError({ code: "BAD_REQUEST", message }); } }), }); // ─── WhatsApp router ───────────────────────────────────────────────────────── const whatsappRouter = router({ status: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } return getWhatsAppStatus(input.clinicId); }), connect: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } await connectWhatsApp(input.clinicId); // Petit délai pour laisser le QR se générer await new Promise((r) => setTimeout(r, 2000)); return getWhatsAppStatus(input.clinicId); }), disconnect: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } await disconnectWhatsApp(input.clinicId); return { success: true }; }), sendTest: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive(), phone: z.string().min(3).max(32) })) .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" }); } return sendWhatsAppMessage( input.clinicId, input.phone, `✅ *Test QueueMed*\n\nVotre connexion WhatsApp fonctionne correctement pour le cabinet *${clinic.name}*.` ); }), // Public — list enabled country codes for patient form listCountryCodes: publicProcedure.query(async () => { const db = await getDb(); return db .select() .from(whatsappCountryCodes) .where(eq(whatsappCountryCodes.enabled, true)) .orderBy(asc(whatsappCountryCodes.sortOrder), asc(whatsappCountryCodes.nameFr)); }), // Admin — list all country codes listAllCountryCodes: protectedProcedure.query(async ({ ctx }) => { if (ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux administrateurs" }); } const db = await getDb(); return db .select() .from(whatsappCountryCodes) .orderBy(asc(whatsappCountryCodes.sortOrder), asc(whatsappCountryCodes.nameFr)); }), toggleCountryCode: protectedProcedure .input(z.object({ code: z.string().min(2).max(4), enabled: z.boolean() })) .mutation(async ({ input, ctx }) => { if (ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } const db = await getDb(); await db .update(whatsappCountryCodes) .set({ enabled: input.enabled }) .where(eq(whatsappCountryCodes.code, input.code)); return { success: true }; }), bulkToggleCountryCodes: protectedProcedure .input(z.object({ codes: z.array(z.string()), enabled: z.boolean() })) .mutation(async ({ input, ctx }) => { if (ctx.user.role !== "admin") { throw new TRPCError({ code: "FORBIDDEN" }); } const db = await getDb(); for (const code of input.codes) { await db .update(whatsappCountryCodes) .set({ enabled: input.enabled }) .where(eq(whatsappCountryCodes.code, code)); } return { success: true, count: input.codes.length }; }), }); // ─── Clinic enriched settings router ───────────────────────────────────────── const clinicSettingsRouter = router({ get: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } return { id: clinic.id, name: clinic.name, address: clinic.address, phone: clinic.phone, whatsappPhone: clinic.whatsappPhone, color: clinic.color, avgConsultationMinutes: clinic.avgConsultationMinutes, maxQueueSize: clinic.maxQueueSize, qrRotationMinutes: clinic.qrRotationMinutes, autoAbsentMinutes: clinic.autoAbsentMinutes ?? 0, welcomeMessage: clinic.welcomeMessage ?? "", openingHours: clinic.openingHours as Record< string, { open: string; close: string; closed: boolean } > | null, patientLanguage: clinic.patientLanguage ?? "fr", whatsappTemplateJoined: clinic.whatsappTemplateJoined ?? null, whatsappTemplateSoon: clinic.whatsappTemplateSoon ?? null, whatsappTemplateCalled: clinic.whatsappTemplateCalled ?? null, whatsappTemplateWithdrawn: clinic.whatsappTemplateWithdrawn ?? null, smsEnabled: clinic.smsEnabled ?? false, }; }), update: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), welcomeMessage: z.string().max(500).optional(), openingHours: z .record( z.string(), z.object({ open: z.string(), close: z.string(), closed: z.boolean() }) ) .optional(), patientLanguage: z.enum(["fr", "en", "ar", "pt", "es"]).optional(), autoAbsentMinutes: z.number().int().min(0).max(60).optional(), avgConsultationMinutes: z.number().int().min(1).max(180).optional(), maxQueueSize: z.number().int().min(1).max(500).optional(), whatsappPhone: z.string().max(32).nullable().optional(), }) ) .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" }); } const { clinicId, ...patch } = input; const filtered = Object.fromEntries( Object.entries(patch).filter(([, v]) => v !== undefined) ); if (Object.keys(filtered).length > 0) { await updateClinic(clinicId, filtered as Parameters[1]); } return { success: true }; }), getTemplates: subscriptionProcedure .input(z.object({ clinicId: z.number().int().positive() })) .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" }); } return { joined: clinic.whatsappTemplateJoined ?? null, soon: clinic.whatsappTemplateSoon ?? null, called: clinic.whatsappTemplateCalled ?? null, withdrawn: clinic.whatsappTemplateWithdrawn ?? null, }; }), updateTemplates: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), joined: z.string().max(1000).nullable().optional(), soon: z.string().max(1000).nullable().optional(), called: z.string().max(1000).nullable().optional(), withdrawn: z.string().max(1000).nullable().optional(), }) ) .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" }); } const updateData: Record = {}; if (input.joined !== undefined) updateData.whatsappTemplateJoined = input.joined; if (input.soon !== undefined) updateData.whatsappTemplateSoon = input.soon; if (input.called !== undefined) updateData.whatsappTemplateCalled = input.called; if (input.withdrawn !== undefined) updateData.whatsappTemplateWithdrawn = input.withdrawn; if (Object.keys(updateData).length > 0) { await updateClinic(input.clinicId, updateData as Parameters[1]); } return { success: true }; }), // État d'ouverture du cabinet selon les horaires configurés openingStatus: publicProcedure .input(z.object({ clinicId: z.number().int().positive() })) .query(async ({ input }) => { const clinic = await getClinicById(input.clinicId); if (!clinic) { throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); } const openingHours = clinic.openingHours as OpeningHours | null; const hasHours = openingHours && Object.keys(openingHours).length > 0; if (!hasHours) { return { isOpen: true, hasHours: false, todaySchedule: null, nextOpening: null, weeklySchedule: [], clinicName: clinic.name, }; } const isOpen = isClinicOpen(openingHours); const { dayKey, schedule } = getTodaySchedule(openingHours); const nextOpening = isOpen ? null : getNextOpeningTime(openingHours); const weeklySchedule = formatWeeklySchedule(openingHours); return { isOpen, hasHours: true, todaySchedule: schedule ? { dayKey, open: schedule.open, close: schedule.close, closed: schedule.closed } : null, nextOpening, weeklySchedule, clinicName: clinic.name, }; }), toggleSms: protectedProcedure .input(z.object({ clinicId: z.number().int().positive(), enabled: 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" }); } await updateClinic(input.clinicId, { smsEnabled: input.enabled }); return { success: true, smsEnabled: input.enabled }; }), }); // ─── Consultation history router ───────────────────────────────────────────── const historyRouter = router({ list: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), page: z.number().int().min(1).optional(), perPage: z.number().int().min(5).max(100).optional(), dateFrom: z.string().optional(), dateTo: z.string().optional(), visitReason: z.string().optional(), }) ) .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" }); } return getConsultationHistory(input.clinicId, { page: input.page, perPage: input.perPage, dateFrom: input.dateFrom ? new Date(input.dateFrom) : undefined, dateTo: input.dateTo ? new Date(input.dateTo) : undefined, visitReason: input.visitReason, }); }), stats: subscriptionProcedure .input( z.object({ clinicId: z.number().int().positive(), days: z.number().int().min(1).max(365).optional(), }) ) .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" }); } return getConsultationStats(input.clinicId, input.days ?? 30); }), }); // ─── Admin router ──────────────────────────────────────────────────────────── const adminRouter = router({ listUsers: adminProcedure .input( z.object({ page: z.number().int().min(1).default(1), perPage: z.number().int().min(5).max(100).default(20), role: z.enum(["user", "admin"]).optional(), search: z.string().max(120).optional(), }) ) .query(async ({ input }) => { const { users: rows, total } = await listAllUsers({ page: input.page, perPage: input.perPage, role: input.role, search: input.search, }); return { users: rows.map((u) => ({ id: u.id, email: u.email, name: u.name, role: u.role, disabled: u.disabled, createdAt: u.createdAt, lastSignedIn: u.lastSignedIn, })), total, page: input.page, perPage: input.perPage, }; }), updateUserRole: adminProcedure .input( z.object({ userId: z.number().int().positive(), role: z.enum(["user", "admin"]), }) ) .mutation(async ({ input, ctx }) => { if (input.userId === ctx.user.id && input.role !== "admin") { throw new TRPCError({ code: "BAD_REQUEST", message: "Vous ne pouvez pas retirer votre propre rôle administrateur", }); } const target = await getUserById(input.userId); if (!target) { throw new TRPCError({ code: "NOT_FOUND", message: "Utilisateur introuvable" }); } await setUserRole(input.userId, input.role); return { success: true }; }), disableUser: adminProcedure .input( z.object({ userId: z.number().int().positive(), disabled: z.boolean(), }) ) .mutation(async ({ input, ctx }) => { if (input.userId === ctx.user.id && input.disabled) { throw new TRPCError({ code: "BAD_REQUEST", message: "Vous ne pouvez pas désactiver votre propre compte", }); } const target = await getUserById(input.userId); if (!target) { throw new TRPCError({ code: "NOT_FOUND", message: "Utilisateur introuvable" }); } await setUserDisabled(input.userId, input.disabled); return { success: true }; }), listAllClinics: adminProcedure.query(async () => { return listAllClinicsWithStats(); }), getOverview: adminProcedure.query(async () => { const overview = await getAdminOverview(); return { ...overview, activeWhatsAppSessions: getActiveWhatsAppSessionsCount(), }; }), // --- Admin Config (integrations & secrets) --- listConfig: adminProcedure .query(async () => { const rows = await listAllConfig(); return rows.map((r) => ({ key: r.key, value: r.isSecret ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : r.value, isSecret: Boolean(r.isSecret), category: r.category, description: r.description, updatedAt: r.updatedAt, })); }), setConfig: adminProcedure .input( z.object({ key: z.string().max(100), value: z.string(), isSecret: z.boolean().default(false), category: z.string().max(50), description: z.string().max(255).optional(), }) ) .mutation(async ({ input }) => { await setConfigValue(input.key, input.value, { isSecret: input.isSecret, category: input.category, description: input.description, }); return { success: true }; }), deleteConfig: adminProcedure .input(z.object({ key: z.string().max(100) })) .mutation(async ({ input }) => { await deleteConfigValue(input.key); return { success: true }; }), testStripeConnection: adminProcedure .mutation(async () => { try { const configured = await isStripeConfigured(); if (!configured) return { success: false, error: "Cle Stripe non configuree" }; const stripe = await getStripe(); await stripe.products.list({ limit: 1 }); return { success: true }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { success: false, error: msg }; } }), testSmsConnection: adminProcedure .mutation(async () => { try { const configured = await isSmsConfigured(); if (!configured) return { success: false, error: "Twilio non configure" }; return { success: true }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); return { success: false, error: msg }; } }), }); // ─── App router ────────────────────────────────────────────────────────────── export const appRouter = router({ auth: authRouter, clinic: clinicRouter, queue: queueRouter, analytics: analyticsRouter, subscription: subscriptionRouter, whatsapp: whatsappRouter, notification: notificationRouter, clinicSettings: clinicSettingsRouter, history: historyRouter, admin: adminRouter, }); export type AppRouter = typeof appRouter;