From f3b9b636fb25e3802d4df973d0fccb6f773916c6 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 25 Apr 2026 16:15:55 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=203=20backend=20=E2=80=94=20admin?= =?UTF-8?q?=20procedures,=20multi-practitioner=20schema,=20advanced=20anal?= =?UTF-8?q?ytics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 1 + package.json | 1 + server/_core/trpc.ts | 23 +++ server/db.ts | 281 +++++++++++++++++++++++++++++++++++- server/routers.ts | 253 +++++++++++++++++++++++++++++++- server/schema.ts | 25 ++++ server/services/whatsapp.ts | 9 ++ 7 files changed, 589 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 66871a2..de29e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "vite-plugin-pwa": "^1.2.0", + "workbox-window": "^7.4.0", "wouter": "^3.3.5", "zod": "^3.24.1" }, diff --git a/package.json b/package.json index 8bf25ff..dfa1ec1 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "tailwind-merge": "^2.6.0", "tailwindcss": "^4.0.0", "vite-plugin-pwa": "^1.2.0", + "workbox-window": "^7.4.0", "wouter": "^3.3.5", "zod": "^3.24.1" }, diff --git a/server/_core/trpc.ts b/server/_core/trpc.ts index df962da..ff2208b 100644 --- a/server/_core/trpc.ts +++ b/server/_core/trpc.ts @@ -16,6 +16,9 @@ const isAuthed = t.middleware(({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); } + if (ctx.user.disabled) { + throw new TRPCError({ code: "FORBIDDEN", message: "Compte désactivé" }); + } return next({ ctx: { ...ctx, @@ -26,6 +29,26 @@ const isAuthed = t.middleware(({ ctx, next }) => { export const protectedProcedure = t.procedure.use(isAuthed); +const isAdmin = t.middleware(({ ctx, next }) => { + if (!ctx.user) { + throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); + } + if (ctx.user.disabled) { + throw new TRPCError({ code: "FORBIDDEN", message: "Compte désactivé" }); + } + if (ctx.user.role !== "admin") { + throw new TRPCError({ code: "FORBIDDEN", message: "Accès réservé aux administrateurs" }); + } + return next({ + ctx: { + ...ctx, + user: ctx.user, + }, + }); +}); + +export const adminProcedure = t.procedure.use(isAdmin); + const requireActiveSubscription = t.middleware(async ({ ctx, next }) => { if (!ctx.user) { throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" }); diff --git a/server/db.ts b/server/db.ts index 9d485e6..53ee3bc 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,6 +1,6 @@ import { drizzle, type MySql2Database } from "drizzle-orm/mysql2"; import mysql from "mysql2/promise"; -import { and, desc, eq, gte, inArray, lt, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, like, lt, or, sql } from "drizzle-orm"; import crypto from "node:crypto"; import { users, @@ -10,15 +10,18 @@ import { analyticsEvents, whatsappCountryCodes, whatsappLogs, + clinicMembers, type User, type Subscription, type Clinic, type QueueEntry, type AnalyticsEvent, + type ClinicMember, type InsertUser, type InsertClinic, type InsertQueueEntry, type InsertWhatsappLog, + type InsertClinicMember, } from "./schema.js"; // ─── Connection pool (singleton) ───────────────────────────────────────────── @@ -31,6 +34,7 @@ let dbInstance: MySql2Database<{ analyticsEvents: typeof analyticsEvents; whatsappCountryCodes: typeof whatsappCountryCodes; whatsappLogs: typeof whatsappLogs; + clinicMembers: typeof clinicMembers; }> | null = null; export async function getDb() { @@ -50,7 +54,7 @@ export async function getDb() { }); dbInstance = drizzle(pool, { - schema: { users, subscriptions, clinics, queueEntries, analyticsEvents, whatsappCountryCodes, whatsappLogs }, + schema: { users, subscriptions, clinics, queueEntries, analyticsEvents, whatsappCountryCodes, whatsappLogs, clinicMembers }, mode: "default", }); @@ -680,3 +684,276 @@ export async function getConsultationStats( return { totalConsultations, avgDurationMinutes, presenceRate, topReasons }; } + +// ─── Admin: users management ───────────────────────────────────────────────── +export async function listAllUsers(opts: { + page?: number; + perPage?: number; + role?: "user" | "admin"; + search?: string; +}): Promise<{ users: User[]; total: number }> { + const db = await getDb(); + const { page = 1, perPage = 20, role, search } = opts; + const offset = (page - 1) * perPage; + + const conditions = [] as ReturnType[]; + if (role) conditions.push(eq(users.role, role)); + if (search) { + const term = `%${search}%`; + conditions.push( + or(like(users.email, term), like(users.name, term)) as ReturnType + ); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; + + const [rows, countRows] = await Promise.all([ + where + ? db.select().from(users).where(where).orderBy(desc(users.createdAt)).limit(perPage).offset(offset) + : db.select().from(users).orderBy(desc(users.createdAt)).limit(perPage).offset(offset), + where + ? db.select({ count: sql`COUNT(*)` }).from(users).where(where) + : db.select({ count: sql`COUNT(*)` }).from(users), + ]); + + return { users: rows, total: Number(countRows[0]?.count ?? 0) }; +} + +export async function setUserRole(userId: number, role: "user" | "admin"): Promise { + const db = await getDb(); + await db.update(users).set({ role }).where(eq(users.id, userId)); +} + +export async function setUserDisabled(userId: number, disabled: boolean): Promise { + const db = await getDb(); + await db.update(users).set({ disabled }).where(eq(users.id, userId)); +} + +// ─── Admin: aggregate stats ────────────────────────────────────────────────── +export async function getAdminOverview(): Promise<{ + totalUsers: number; + totalAdmins: number; + totalDisabled: number; + totalClinics: number; + totalActiveClinics: number; + totalQueueEntriesToday: number; + totalQueueEntriesAllTime: number; +}> { + const db = await getDb(); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + + const [ + [usersCount], + [adminsCount], + [disabledCount], + [clinicsCount], + [activeClinicsCount], + [queueTodayCount], + [queueAllTimeCount], + ] = await Promise.all([ + db.select({ count: sql`COUNT(*)` }).from(users), + db.select({ count: sql`COUNT(*)` }).from(users).where(eq(users.role, "admin")), + db.select({ count: sql`COUNT(*)` }).from(users).where(eq(users.disabled, true)), + db.select({ count: sql`COUNT(*)` }).from(clinics), + db.select({ count: sql`COUNT(*)` }).from(clinics).where(eq(clinics.isActive, true)), + db + .select({ count: sql`COUNT(*)` }) + .from(queueEntries) + .where(gte(queueEntries.joinedAt, startOfDay)), + db.select({ count: sql`COUNT(*)` }).from(queueEntries), + ]); + + return { + totalUsers: Number(usersCount?.count ?? 0), + totalAdmins: Number(adminsCount?.count ?? 0), + totalDisabled: Number(disabledCount?.count ?? 0), + totalClinics: Number(clinicsCount?.count ?? 0), + totalActiveClinics: Number(activeClinicsCount?.count ?? 0), + totalQueueEntriesToday: Number(queueTodayCount?.count ?? 0), + totalQueueEntriesAllTime: Number(queueAllTimeCount?.count ?? 0), + }; +} + +export async function listAllClinicsWithStats(): Promise< + Array<{ + id: number; + name: string; + ownerId: number; + ownerEmail: string | null; + ownerName: string | null; + isActive: boolean; + isQueueOpen: boolean; + patientCountToday: number; + createdAt: Date; + }> +> { + const db = await getDb(); + const startOfDay = new Date(); + startOfDay.setHours(0, 0, 0, 0); + + const allClinics = await db.select().from(clinics).orderBy(desc(clinics.createdAt)); + const ownerIds = Array.from(new Set(allClinics.map((c) => c.userId))); + const owners = ownerIds.length + ? await db.select().from(users).where(inArray(users.id, ownerIds)) + : []; + const ownerById = new Map(owners.map((u) => [u.id, u])); + + const todayEntries = allClinics.length + ? await db + .select({ + clinicId: queueEntries.clinicId, + count: sql`COUNT(*)`, + }) + .from(queueEntries) + .where( + and( + inArray(queueEntries.clinicId, allClinics.map((c) => c.id)), + gte(queueEntries.joinedAt, startOfDay) + ) + ) + .groupBy(queueEntries.clinicId) + : []; + const countByClinic = new Map(todayEntries.map((row) => [row.clinicId, Number(row.count ?? 0)])); + + return allClinics.map((c) => { + const owner = ownerById.get(c.userId); + return { + id: c.id, + name: c.name, + ownerId: c.userId, + ownerEmail: owner?.email ?? null, + ownerName: owner?.name ?? null, + isActive: c.isActive, + isQueueOpen: c.isQueueOpen, + patientCountToday: countByClinic.get(c.id) ?? 0, + createdAt: c.createdAt, + }; + }); +} + +// ─── Clinic members (multi-practitioner) ───────────────────────────────────── +export async function listClinicMembers(clinicId: number): Promise< + Array +> { + const db = await getDb(); + const members = await db + .select() + .from(clinicMembers) + .where(eq(clinicMembers.clinicId, clinicId)) + .orderBy(asc(clinicMembers.createdAt)); + if (members.length === 0) return []; + const userIds = members.map((m) => m.userId); + const userRows = await db.select().from(users).where(inArray(users.id, userIds)); + const byId = new Map(userRows.map((u) => [u.id, u])); + return members.map((m) => { + const u = byId.get(m.userId); + return { ...m, email: u?.email ?? null, name: u?.name ?? null }; + }); +} + +export async function getClinicMember( + clinicId: number, + userId: number +): Promise { + const db = await getDb(); + const rows = await db + .select() + .from(clinicMembers) + .where(and(eq(clinicMembers.clinicId, clinicId), eq(clinicMembers.userId, userId))) + .limit(1); + return rows[0] ?? null; +} + +export async function addClinicMember(data: InsertClinicMember): Promise { + const db = await getDb(); + const [result] = await db.insert(clinicMembers).values(data); + const insertId = (result as { insertId: number }).insertId; + const rows = await db.select().from(clinicMembers).where(eq(clinicMembers.id, insertId)).limit(1); + if (!rows[0]) throw new Error("Failed to add clinic member"); + return rows[0]; +} + +export async function removeClinicMember(clinicId: number, memberId: number): Promise { + const db = await getDb(); + await db + .delete(clinicMembers) + .where(and(eq(clinicMembers.id, memberId), eq(clinicMembers.clinicId, clinicId))); +} + +export async function updateClinicMember( + memberId: number, + patch: Partial +): Promise { + const db = await getDb(); + await db.update(clinicMembers).set(patch).where(eq(clinicMembers.id, memberId)); +} + +// ─── Advanced analytics ────────────────────────────────────────────────────── +export async function getAdvancedAnalytics( + userId: number, + options: { days?: number; clinicId?: number } = {} +): Promise<{ + byHour: number[]; + byDayOfWeek: number[]; + noShowRate: number; + totalServed: number; + totalAbsent: number; + totalJoined: number; + busiestDayOfWeek: number; + peakHour: number; + avgWaitByDay: Array<{ date: string; avgWaitMinutes: number; count: number }>; +}> { + const events = await getAnalytics(userId, options); + + const byHour = new Array(24).fill(0); + const byDayOfWeek = new Array(7).fill(0); + let totalServed = 0; + let totalAbsent = 0; + let totalJoined = 0; + + // wait-time aggregation by ISO day (YYYY-MM-DD) + const waitByDay: Map = new Map(); + + for (const ev of events) { + if (typeof ev.hourOfDay === "number") byHour[ev.hourOfDay] += 1; + if (typeof ev.dayOfWeek === "number") byDayOfWeek[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" && ev.eventType === "patient_called") { + const d = ev.createdAt instanceof Date ? ev.createdAt : new Date(ev.createdAt); + const key = d.toISOString().slice(0, 10); + const cur = waitByDay.get(key) ?? { total: 0, count: 0 }; + cur.total += ev.waitMinutes; + cur.count += 1; + waitByDay.set(key, cur); + } + } + + const totalCompleted = totalServed + totalAbsent; + const noShowRate = totalCompleted > 0 ? totalAbsent / totalCompleted : 0; + + const peakHour = byHour.indexOf(Math.max(...byHour)); + const busiestDayOfWeek = byDayOfWeek.indexOf(Math.max(...byDayOfWeek)); + + const avgWaitByDay = Array.from(waitByDay.entries()) + .map(([date, { total, count }]) => ({ + date, + avgWaitMinutes: count ? Math.round(total / count) : 0, + count, + })) + .sort((a, b) => (a.date < b.date ? -1 : 1)); + + return { + byHour, + byDayOfWeek, + noShowRate, + totalServed, + totalAbsent, + totalJoined, + busiestDayOfWeek, + peakHour, + avgWaitByDay, + }; +} diff --git a/server/routers.ts b/server/routers.ts index 093b7d0..03ec0c7 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -9,6 +9,7 @@ import { publicProcedure, protectedProcedure, subscriptionProcedure, + adminProcedure, } from "./_core/trpc.js"; import { getDb, @@ -43,6 +44,17 @@ import { getAnalyticsForClinic, getConsultationHistory, getConsultationStats, + listAllUsers, + setUserRole, + setUserDisabled, + getAdminOverview, + listAllClinicsWithStats, + listClinicMembers, + getClinicMember, + addClinicMember, + removeClinicMember, + updateClinicMember, + getAdvancedAnalytics, } from "./db.js"; import { whatsappCountryCodes } from "./schema.js"; import { @@ -57,6 +69,7 @@ import { connectWhatsApp, disconnectWhatsApp, getWhatsAppStatus, + getActiveWhatsAppSessionsCount, sendWhatsAppMessage, buildJoinMessage, buildSoonMessage, @@ -131,6 +144,12 @@ const authRouter = router({ .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", @@ -175,6 +194,12 @@ const authRouter = router({ 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); @@ -400,6 +425,112 @@ const clinicRouter = router({ }); 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 = {}; + if (input.displayName !== undefined) patch.displayName = input.displayName; + if (input.color !== undefined) patch.color = input.color; + if (Object.keys(patch).length > 0) { + await updateClinicMember(input.memberId, patch as Parameters[1]); + } + return { success: true }; + }), + + removeMember: subscriptionProcedure + .input( + z.object({ + clinicId: z.number().int().positive(), + memberId: z.number().int().positive(), + }) + ) + .mutation(async ({ input, ctx }) => { + const clinic = await getClinicById(input.clinicId); + if (!clinic || clinic.userId !== ctx.user.id) { + throw new TRPCError({ code: "NOT_FOUND", message: "Cabinet introuvable" }); + } + await removeClinicMember(input.clinicId, input.memberId); + return { success: true }; + }), }); // ─── Queue router ──────────────────────────────────────────────────────────── @@ -653,7 +784,12 @@ const queueRouter = router({ }), callNext: subscriptionProcedure - .input(z.object({ clinicId: z.number().int().positive() })) + .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) { @@ -680,6 +816,7 @@ const queueRouter = router({ status: "called", calledAt: now, notificationSent: true, + ...(input.practitionerId ? { practitionerId: input.practitionerId } : {}), }); const waitMinutes = diffMinutes(now, next.joinedAt); @@ -746,7 +883,12 @@ const queueRouter = router({ }), callSpecific: subscriptionProcedure - .input(z.object({ entryId: z.number().int().positive() })) + .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) { @@ -770,6 +912,7 @@ const queueRouter = router({ status: "called", calledAt: now, notificationSent: true, + ...(input.practitionerId ? { practitionerId: input.practitionerId } : {}), }); const waitMinutes = diffMinutes(now, entry.joinedAt); @@ -1009,6 +1152,20 @@ const analyticsRouter = router({ }; }), + 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({ @@ -1388,6 +1545,97 @@ const historyRouter = router({ }), }); +// ─── 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, @@ -1398,6 +1646,7 @@ export const appRouter = router({ whatsapp: whatsappRouter, clinicSettings: clinicSettingsRouter, history: historyRouter, + admin: adminRouter, }); export type AppRouter = typeof appRouter; diff --git a/server/schema.ts b/server/schema.ts index 9d09802..dad8ec5 100644 --- a/server/schema.ts +++ b/server/schema.ts @@ -22,6 +22,7 @@ export const users = mysqlTable( openId: varchar("openId", { length: 64 }), loginMethod: varchar("loginMethod", { length: 64 }).default("password").notNull(), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + disabled: boolean("disabled").default(false).notNull(), resetToken: varchar("resetToken", { length: 255 }), resetTokenExpiry: timestamp("resetTokenExpiry"), createdAt: timestamp("createdAt").defaultNow().notNull(), @@ -152,6 +153,8 @@ export const queueEntries = mysqlTable( "autre", ]).default("consultation"), visitNote: text("visitNote"), + // Praticien assigné (multi-praticiens par cabinet) + practitionerId: int("practitionerId"), // Timing consultation supplémentaire (pour stats) consultationStartedAt: timestamp("consultationStartedAt"), // Notifications WhatsApp @@ -251,3 +254,25 @@ export const whatsappLogs = mysqlTable( export type WhatsappLog = typeof whatsappLogs.$inferSelect; export type InsertWhatsappLog = typeof whatsappLogs.$inferInsert; + +// ─── Clinic Members (multi-praticiens par cabinet) ─────────────────────────── +export const clinicMembers = mysqlTable( + "clinic_members", + { + id: int("id").autoincrement().primaryKey(), + clinicId: int("clinicId").notNull(), + userId: int("userId").notNull(), + role: mysqlEnum("role", ["owner", "practitioner"]).default("practitioner").notNull(), + color: varchar("color", { length: 7 }).default("#10b981").notNull(), + displayName: varchar("displayName", { length: 128 }), + createdAt: timestamp("createdAt").defaultNow().notNull(), + }, + (table) => ({ + clinicIdx: index("clinic_members_clinicId_idx").on(table.clinicId), + userIdx: index("clinic_members_userId_idx").on(table.userId), + uniqueMember: uniqueIndex("clinic_members_unique_idx").on(table.clinicId, table.userId), + }) +); + +export type ClinicMember = typeof clinicMembers.$inferSelect; +export type InsertClinicMember = typeof clinicMembers.$inferInsert; diff --git a/server/services/whatsapp.ts b/server/services/whatsapp.ts index 8ee506b..0b56e32 100644 --- a/server/services/whatsapp.ts +++ b/server/services/whatsapp.ts @@ -211,6 +211,15 @@ export function getWhatsAppStatus(clinicId: number): { }; } +/** Returns the number of currently connected WhatsApp sessions across all clinics. */ +export function getActiveWhatsAppSessionsCount(): number { + let count = 0; + for (const session of sessions.values()) { + if (session.status === "connected") count += 1; + } + return count; +} + // ─── Send message ───────────────────────────────────────────────────────────── /** * Send a WhatsApp text message to a phone number.