feat: Phase 3 backend — admin procedures, multi-practitioner schema, advanced analytics

This commit is contained in:
Hermes 2026-04-25 16:15:55 +00:00
parent a7ffcaa181
commit f3b9b636fb
7 changed files with 589 additions and 4 deletions

1
package-lock.json generated
View file

@ -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"
},

View file

@ -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"
},

View file

@ -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" });

View file

@ -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<typeof eq>[];
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<typeof eq>
);
}
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<number>`COUNT(*)` }).from(users).where(where)
: db.select({ count: sql<number>`COUNT(*)` }).from(users),
]);
return { users: rows, total: Number(countRows[0]?.count ?? 0) };
}
export async function setUserRole(userId: number, role: "user" | "admin"): Promise<void> {
const db = await getDb();
await db.update(users).set({ role }).where(eq(users.id, userId));
}
export async function setUserDisabled(userId: number, disabled: boolean): Promise<void> {
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<number>`COUNT(*)` }).from(users),
db.select({ count: sql<number>`COUNT(*)` }).from(users).where(eq(users.role, "admin")),
db.select({ count: sql<number>`COUNT(*)` }).from(users).where(eq(users.disabled, true)),
db.select({ count: sql<number>`COUNT(*)` }).from(clinics),
db.select({ count: sql<number>`COUNT(*)` }).from(clinics).where(eq(clinics.isActive, true)),
db
.select({ count: sql<number>`COUNT(*)` })
.from(queueEntries)
.where(gte(queueEntries.joinedAt, startOfDay)),
db.select({ count: sql<number>`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<number>`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<ClinicMember & { email: string | null; name: string | null }>
> {
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<ClinicMember | null> {
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<ClinicMember> {
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<void> {
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<InsertClinicMember>
): Promise<void> {
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<string, { total: number; count: number }> = 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,
};
}

View file

@ -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<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 ────────────────────────────────────────────────────────────
@ -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;

View file

@ -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;

View file

@ -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.