queue-med/server/routers.ts

1652 lines
57 KiB
TypeScript

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,
} 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";
// ─── 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<void> {
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) {
console.error("[auth.forgotPassword] sendMail failed", err);
}
}
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 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 };
}),
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 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<string, unknown> = {};
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<typeof updateClinicMember>[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
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" });
}
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
) {
throw new TRPCError({ code: "FORBIDDEN", message: "QR code expiré" });
}
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 };
}),
});
// ─── 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,
};
}),
});
// ─── 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,
};
}),
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<typeof updateClinic>[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<string, unknown> = {};
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<typeof updateClinic>[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,
};
}),
});
// ─── 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(),
};
}),
});
// ─── App router ──────────────────────────────────────────────────────────────
export const appRouter = router({
auth: authRouter,
clinic: clinicRouter,
queue: queueRouter,
analytics: analyticsRouter,
subscription: subscriptionRouter,
whatsapp: whatsappRouter,
clinicSettings: clinicSettingsRouter,
history: historyRouter,
admin: adminRouter,
});
export type AppRouter = typeof appRouter;