- Add AdminSettings page with 4 tabs: Integrations, WhatsApp, Notifications, General - Add tRPC admin endpoints: listConfig, setConfig, deleteConfig, testStripeConnection, testSmsConnection - Add clinicSettings.toggleSms endpoint for per-clinic SMS toggle - Add app_config table schema + DB helpers (listAllConfig, setConfigValue, deleteConfigValue) - Stripe and SMS services now read config from DB first, then env vars fallback - Add Settings nav item in sidebar (admin only) - Add /admin/settings route in App.tsx
1958 lines
67 KiB
TypeScript
1958 lines
67 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,
|
|
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<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) {
|
|
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<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
|
|
// 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<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,
|
|
};
|
|
}),
|
|
|
|
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;
|