feat: Phase 3 backend — admin procedures, multi-practitioner schema, advanced analytics
This commit is contained in:
parent
a7ffcaa181
commit
f3b9b636fb
7 changed files with 589 additions and 4 deletions
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
281
server/db.ts
281
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<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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue