659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
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 crypto from "node:crypto";
|
|
import {
|
|
users,
|
|
subscriptions,
|
|
clinics,
|
|
queueEntries,
|
|
analyticsEvents,
|
|
whatsappCountryCodes,
|
|
whatsappLogs,
|
|
type User,
|
|
type Subscription,
|
|
type Clinic,
|
|
type QueueEntry,
|
|
type AnalyticsEvent,
|
|
type InsertUser,
|
|
type InsertClinic,
|
|
type InsertQueueEntry,
|
|
type InsertWhatsappLog,
|
|
} from "./schema.js";
|
|
|
|
// ─── Connection pool (singleton) ─────────────────────────────────────────────
|
|
let pool: mysql.Pool | null = null;
|
|
let dbInstance: MySql2Database<{
|
|
users: typeof users;
|
|
subscriptions: typeof subscriptions;
|
|
clinics: typeof clinics;
|
|
queueEntries: typeof queueEntries;
|
|
analyticsEvents: typeof analyticsEvents;
|
|
whatsappCountryCodes: typeof whatsappCountryCodes;
|
|
whatsappLogs: typeof whatsappLogs;
|
|
}> | null = null;
|
|
|
|
export async function getDb() {
|
|
if (dbInstance) return dbInstance;
|
|
|
|
const url = process.env.DATABASE_URL;
|
|
if (!url) {
|
|
throw new Error("DATABASE_URL is not set");
|
|
}
|
|
|
|
pool = mysql.createPool({
|
|
uri: url,
|
|
connectionLimit: 10,
|
|
waitForConnections: true,
|
|
enableKeepAlive: true,
|
|
keepAliveInitialDelay: 10_000,
|
|
});
|
|
|
|
dbInstance = drizzle(pool, {
|
|
schema: { users, subscriptions, clinics, queueEntries, analyticsEvents, whatsappCountryCodes, whatsappLogs },
|
|
mode: "default",
|
|
});
|
|
|
|
return dbInstance;
|
|
}
|
|
|
|
export async function closeDb() {
|
|
if (pool) {
|
|
await pool.end();
|
|
pool = null;
|
|
dbInstance = null;
|
|
}
|
|
}
|
|
|
|
// ─── Users ───────────────────────────────────────────────────────────────────
|
|
export async function getUserByEmail(email: string): Promise<User | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function getUserById(id: number): Promise<User | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function getUserByOpenId(openId: string): Promise<User | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function createUser(data: InsertUser): Promise<User> {
|
|
const db = await getDb();
|
|
const [result] = await db.insert(users).values(data);
|
|
const id = (result as { insertId: number }).insertId;
|
|
const created = await getUserById(id);
|
|
if (!created) throw new Error("Failed to create user");
|
|
return created;
|
|
}
|
|
|
|
export async function upsertUser(data: InsertUser): Promise<User> {
|
|
const existing = await getUserByEmail(data.email);
|
|
if (existing) {
|
|
const db = await getDb();
|
|
await db
|
|
.update(users)
|
|
.set({ ...data, lastSignedIn: new Date() })
|
|
.where(eq(users.id, existing.id));
|
|
const refreshed = await getUserById(existing.id);
|
|
if (!refreshed) throw new Error("Failed to refresh user");
|
|
return refreshed;
|
|
}
|
|
return createUser(data);
|
|
}
|
|
|
|
export async function touchUserLogin(userId: number): Promise<void> {
|
|
const db = await getDb();
|
|
await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, userId));
|
|
}
|
|
|
|
// ─── Subscriptions ───────────────────────────────────────────────────────────
|
|
const TRIAL_DAYS = 30;
|
|
|
|
export async function createTrialSubscription(userId: number): Promise<Subscription> {
|
|
const db = await getDb();
|
|
const trialStart = new Date();
|
|
const trialEnd = new Date(trialStart.getTime() + TRIAL_DAYS * 24 * 60 * 60 * 1000);
|
|
await db.insert(subscriptions).values({
|
|
userId,
|
|
plan: "trial",
|
|
status: "trialing",
|
|
trialStartedAt: trialStart,
|
|
trialEndsAt: trialEnd,
|
|
});
|
|
const sub = await getSubscription(userId);
|
|
if (!sub) throw new Error("Failed to create trial subscription");
|
|
return sub;
|
|
}
|
|
|
|
export async function getSubscription(userId: number): Promise<Subscription | null> {
|
|
const db = await getDb();
|
|
const rows = await db
|
|
.select()
|
|
.from(subscriptions)
|
|
.where(eq(subscriptions.userId, userId))
|
|
.orderBy(desc(subscriptions.createdAt))
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function updateSubscription(
|
|
userId: number,
|
|
patch: Partial<typeof subscriptions.$inferInsert>
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.update(subscriptions).set(patch).where(eq(subscriptions.userId, userId));
|
|
}
|
|
|
|
export async function isSubscriptionActive(userId: number): Promise<boolean> {
|
|
const sub = await getSubscription(userId);
|
|
if (!sub) return false;
|
|
const now = Date.now();
|
|
if (sub.status === "canceled" || sub.status === "expired") return false;
|
|
if (sub.status === "trialing") {
|
|
return sub.trialEndsAt.getTime() > now;
|
|
}
|
|
if (sub.status === "active") {
|
|
if (!sub.currentPeriodEnd) return true;
|
|
return sub.currentPeriodEnd.getTime() > now;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// ─── Clinics ─────────────────────────────────────────────────────────────────
|
|
function generateQrToken(): string {
|
|
return crypto.randomBytes(24).toString("hex");
|
|
}
|
|
|
|
function computeQrExpiry(rotationMinutes: number | null | undefined): Date | null {
|
|
if (!rotationMinutes || rotationMinutes <= 0) return null;
|
|
return new Date(Date.now() + rotationMinutes * 60 * 1000);
|
|
}
|
|
|
|
export async function getClinics(userId: number): Promise<Clinic[]> {
|
|
const db = await getDb();
|
|
return db
|
|
.select()
|
|
.from(clinics)
|
|
.where(eq(clinics.userId, userId))
|
|
.orderBy(desc(clinics.createdAt));
|
|
}
|
|
|
|
export async function getClinicById(id: number): Promise<Clinic | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select().from(clinics).where(eq(clinics.id, id)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function getClinicByQrToken(token: string): Promise<Clinic | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select().from(clinics).where(eq(clinics.qrToken, token)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function createClinic(
|
|
userId: number,
|
|
data: Omit<InsertClinic, "userId" | "qrToken" | "qrTokenExpiresAt">
|
|
): Promise<{ insertId: number; qrToken: string }> {
|
|
const db = await getDb();
|
|
const qrToken = generateQrToken();
|
|
const qrTokenExpiresAt = computeQrExpiry(data.qrRotationMinutes ?? 30);
|
|
const [result] = await db.insert(clinics).values({
|
|
...data,
|
|
userId,
|
|
qrToken,
|
|
qrTokenExpiresAt,
|
|
});
|
|
const insertId = (result as { insertId: number }).insertId;
|
|
return { insertId, qrToken };
|
|
}
|
|
|
|
export async function updateClinic(
|
|
id: number,
|
|
patch: Partial<typeof clinics.$inferInsert>
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.update(clinics).set(patch).where(eq(clinics.id, id));
|
|
}
|
|
|
|
export async function deleteClinic(id: number): Promise<void> {
|
|
const db = await getDb();
|
|
await db.delete(queueEntries).where(eq(queueEntries.clinicId, id));
|
|
await db.delete(analyticsEvents).where(eq(analyticsEvents.clinicId, id));
|
|
await db.delete(clinics).where(eq(clinics.id, id));
|
|
}
|
|
|
|
export async function rotateQrToken(clinicId: number): Promise<{ qrToken: string; qrTokenExpiresAt: Date | null }> {
|
|
const db = await getDb();
|
|
const clinic = await getClinicById(clinicId);
|
|
if (!clinic) throw new Error("Clinic not found");
|
|
const qrToken = generateQrToken();
|
|
const qrTokenExpiresAt = computeQrExpiry(clinic.qrRotationMinutes);
|
|
await db
|
|
.update(clinics)
|
|
.set({ qrToken, qrTokenExpiresAt })
|
|
.where(eq(clinics.id, clinicId));
|
|
return { qrToken, qrTokenExpiresAt };
|
|
}
|
|
|
|
export async function ensureFreshQrToken(clinic: Clinic): Promise<Clinic> {
|
|
if (!clinic.qrRotationMinutes || clinic.qrRotationMinutes <= 0) return clinic;
|
|
if (clinic.qrTokenExpiresAt && clinic.qrTokenExpiresAt.getTime() > Date.now()) return clinic;
|
|
await rotateQrToken(clinic.id);
|
|
const refreshed = await getClinicById(clinic.id);
|
|
return refreshed ?? clinic;
|
|
}
|
|
|
|
// ─── Queue ───────────────────────────────────────────────────────────────────
|
|
type QueueStatus = (typeof queueEntries.$inferSelect)["status"];
|
|
const ACTIVE_STATUSES: QueueStatus[] = ["waiting", "called", "in_consultation"];
|
|
|
|
export async function getActiveQueue(clinicId: number): Promise<QueueEntry[]> {
|
|
const db = await getDb();
|
|
return db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(
|
|
and(
|
|
eq(queueEntries.clinicId, clinicId),
|
|
inArray(queueEntries.status, ACTIVE_STATUSES)
|
|
)
|
|
)
|
|
.orderBy(queueEntries.position);
|
|
}
|
|
|
|
export async function getAllQueueEntries(clinicId: number): Promise<QueueEntry[]> {
|
|
const db = await getDb();
|
|
return db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(eq(queueEntries.clinicId, clinicId))
|
|
.orderBy(queueEntries.position);
|
|
}
|
|
|
|
export async function getQueueEntry(id: number): Promise<QueueEntry | null> {
|
|
const db = await getDb();
|
|
const rows = await db.select().from(queueEntries).where(eq(queueEntries.id, id)).limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function getQueueEntryByToken(token: string): Promise<QueueEntry | null> {
|
|
const db = await getDb();
|
|
const rows = await db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(eq(queueEntries.patientToken, token))
|
|
.orderBy(desc(queueEntries.createdAt))
|
|
.limit(1);
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
export async function addToQueue(input: {
|
|
clinicId: number;
|
|
patientName?: string | null;
|
|
patientPhone?: string | null;
|
|
whatsappPhone?: string | null;
|
|
visitReason?:
|
|
| "consultation"
|
|
| "urgence"
|
|
| "certificat_scolaire"
|
|
| "certificat_sportif"
|
|
| "arret_travail"
|
|
| "administratif"
|
|
| "autre"
|
|
| null;
|
|
visitNote?: string | null;
|
|
isPrinted?: boolean;
|
|
}): Promise<{ entry: QueueEntry; ticketNumber: number; patientToken: string }> {
|
|
const db = await getDb();
|
|
const clinic = await getClinicById(input.clinicId);
|
|
if (!clinic) throw new Error("Clinic not found");
|
|
if (!clinic.isQueueOpen) throw new Error("Queue is closed");
|
|
|
|
const active = await getActiveQueue(input.clinicId);
|
|
if (clinic.maxQueueSize && active.length >= clinic.maxQueueSize) {
|
|
throw new Error("Queue is full");
|
|
}
|
|
|
|
const ticketNumber = (clinic.currentTicketNumber ?? 0) + 1;
|
|
const patientToken = crypto.randomBytes(24).toString("hex");
|
|
const position = active.length + 1;
|
|
const estimatedWaitMinutes = (clinic.avgConsultationMinutes ?? 15) * (position - 1);
|
|
|
|
const insertValues: InsertQueueEntry = {
|
|
clinicId: input.clinicId,
|
|
ticketNumber,
|
|
patientToken,
|
|
patientName: input.patientName ?? null,
|
|
patientPhone: input.patientPhone ?? null,
|
|
whatsappPhone: input.whatsappPhone ?? null,
|
|
visitReason: input.visitReason ?? "consultation",
|
|
visitNote: input.visitNote ?? null,
|
|
status: "waiting",
|
|
position,
|
|
estimatedWaitMinutes,
|
|
isPrinted: input.isPrinted ?? false,
|
|
};
|
|
|
|
const [result] = await db.insert(queueEntries).values(insertValues);
|
|
const insertId = (result as { insertId: number }).insertId;
|
|
|
|
await db
|
|
.update(clinics)
|
|
.set({ currentTicketNumber: ticketNumber })
|
|
.where(eq(clinics.id, input.clinicId));
|
|
|
|
const entry = await getQueueEntry(insertId);
|
|
if (!entry) throw new Error("Failed to create queue entry");
|
|
return { entry, ticketNumber, patientToken };
|
|
}
|
|
|
|
export async function updateQueueEntry(
|
|
id: number,
|
|
patch: Partial<typeof queueEntries.$inferInsert>
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
await db.update(queueEntries).set(patch).where(eq(queueEntries.id, id));
|
|
}
|
|
|
|
export async function reorderQueue(clinicId: number): Promise<QueueEntry[]> {
|
|
const db = await getDb();
|
|
const active = await db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(
|
|
and(
|
|
eq(queueEntries.clinicId, clinicId),
|
|
eq(queueEntries.status, "waiting")
|
|
)
|
|
)
|
|
.orderBy(queueEntries.position, queueEntries.joinedAt);
|
|
|
|
const clinic = await getClinicById(clinicId);
|
|
const avg = clinic?.avgConsultationMinutes ?? 15;
|
|
|
|
for (let i = 0; i < active.length; i++) {
|
|
const entry = active[i];
|
|
const newPosition = i + 1;
|
|
const newWait = avg * (newPosition - 1);
|
|
if (entry.position !== newPosition || entry.estimatedWaitMinutes !== newWait) {
|
|
await db
|
|
.update(queueEntries)
|
|
.set({ position: newPosition, estimatedWaitMinutes: newWait })
|
|
.where(eq(queueEntries.id, entry.id));
|
|
}
|
|
}
|
|
|
|
return getActiveQueue(clinicId);
|
|
}
|
|
|
|
export async function setQueueOrder(
|
|
clinicId: number,
|
|
orderedIds: number[]
|
|
): Promise<QueueEntry[]> {
|
|
const db = await getDb();
|
|
const clinic = await getClinicById(clinicId);
|
|
const avg = clinic?.avgConsultationMinutes ?? 15;
|
|
for (let i = 0; i < orderedIds.length; i++) {
|
|
const id = orderedIds[i];
|
|
const newPosition = i + 1;
|
|
const newWait = avg * (newPosition - 1);
|
|
await db
|
|
.update(queueEntries)
|
|
.set({ position: newPosition, estimatedWaitMinutes: newWait })
|
|
.where(and(eq(queueEntries.id, id), eq(queueEntries.clinicId, clinicId)));
|
|
}
|
|
return getActiveQueue(clinicId);
|
|
}
|
|
|
|
export async function resetQueue(clinicId: number): Promise<void> {
|
|
const db = await getDb();
|
|
await db
|
|
.update(queueEntries)
|
|
.set({ status: "canceled" })
|
|
.where(
|
|
and(
|
|
eq(queueEntries.clinicId, clinicId),
|
|
inArray(queueEntries.status, ACTIVE_STATUSES)
|
|
)
|
|
);
|
|
await db
|
|
.update(clinics)
|
|
.set({ currentTicketNumber: 0 })
|
|
.where(eq(clinics.id, clinicId));
|
|
}
|
|
|
|
// ─── Analytics ───────────────────────────────────────────────────────────────
|
|
export async function logAnalyticsEvent(
|
|
data: typeof analyticsEvents.$inferInsert
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
const now = new Date();
|
|
await db.insert(analyticsEvents).values({
|
|
...data,
|
|
hourOfDay: data.hourOfDay ?? now.getHours(),
|
|
dayOfWeek: data.dayOfWeek ?? now.getDay(),
|
|
});
|
|
}
|
|
|
|
export async function getAnalytics(
|
|
userId: number,
|
|
options: { days?: number; clinicId?: number } = {}
|
|
): Promise<AnalyticsEvent[]> {
|
|
const db = await getDb();
|
|
const days = options.days ?? 30;
|
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
|
|
const userClinics = await getClinics(userId);
|
|
if (userClinics.length === 0) return [];
|
|
|
|
const clinicIds = options.clinicId
|
|
? userClinics.filter((c) => c.id === options.clinicId).map((c) => c.id)
|
|
: userClinics.map((c) => c.id);
|
|
|
|
if (clinicIds.length === 0) return [];
|
|
|
|
return db
|
|
.select()
|
|
.from(analyticsEvents)
|
|
.where(
|
|
and(
|
|
inArray(analyticsEvents.clinicId, clinicIds),
|
|
gte(analyticsEvents.createdAt, since)
|
|
)
|
|
)
|
|
.orderBy(desc(analyticsEvents.createdAt));
|
|
}
|
|
|
|
export async function getAnalyticsForClinic(
|
|
clinicId: number,
|
|
days = 30
|
|
): Promise<AnalyticsEvent[]> {
|
|
const db = await getDb();
|
|
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
return db
|
|
.select()
|
|
.from(analyticsEvents)
|
|
.where(
|
|
and(
|
|
eq(analyticsEvents.clinicId, clinicId),
|
|
gte(analyticsEvents.createdAt, since)
|
|
)
|
|
)
|
|
.orderBy(desc(analyticsEvents.createdAt));
|
|
}
|
|
|
|
// ─── WhatsApp helpers ────────────────────────────────────────────────────────
|
|
export async function getWaitingEntriesWithPhone(clinicId: number): Promise<QueueEntry[]> {
|
|
const db = await getDb();
|
|
return db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(and(eq(queueEntries.clinicId, clinicId), eq(queueEntries.status, "waiting")))
|
|
.orderBy(queueEntries.position);
|
|
}
|
|
|
|
/** Masque un numéro de téléphone pour la confidentialité */
|
|
export function maskPhone(phone: string): string {
|
|
const cleaned = phone.replace(/[^\d+]/g, "");
|
|
if (cleaned.length <= 4) return "****";
|
|
const visibleStart = cleaned.slice(0, Math.min(4, cleaned.length - 2));
|
|
const visibleEnd = cleaned.slice(-2);
|
|
const hidden = "*".repeat(Math.max(0, cleaned.length - visibleStart.length - visibleEnd.length));
|
|
return `${visibleStart}${hidden}${visibleEnd}`;
|
|
}
|
|
|
|
export async function insertWhatsAppLog(
|
|
data: Omit<InsertWhatsappLog, "id" | "createdAt">
|
|
): Promise<void> {
|
|
const db = await getDb();
|
|
try {
|
|
await db.insert(whatsappLogs).values(data);
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("[WhatsApp Log] Failed to insert log:", err);
|
|
}
|
|
}
|
|
|
|
export async function getWhatsAppLogs(
|
|
clinicId: number,
|
|
options: {
|
|
limit?: number;
|
|
offset?: number;
|
|
messageType?: "joined" | "soon" | "called" | "withdrawn" | "test";
|
|
status?: "sent" | "failed";
|
|
} = {}
|
|
) {
|
|
const db = await getDb();
|
|
const { limit = 20, offset = 0, messageType, status } = options;
|
|
const conditions = [eq(whatsappLogs.clinicId, clinicId)];
|
|
if (messageType) conditions.push(eq(whatsappLogs.messageType, messageType));
|
|
if (status) conditions.push(eq(whatsappLogs.status, status));
|
|
|
|
const [rows, countRows] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(whatsappLogs)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(whatsappLogs.createdAt))
|
|
.limit(limit)
|
|
.offset(offset),
|
|
db.select({ count: sql<number>`COUNT(*)` }).from(whatsappLogs).where(and(...conditions)),
|
|
]);
|
|
|
|
return { logs: rows, total: countRows[0]?.count ?? 0 };
|
|
}
|
|
|
|
// ─── Consultation history & stats ────────────────────────────────────────────
|
|
export async function getConsultationHistory(
|
|
clinicId: number,
|
|
opts: {
|
|
page?: number;
|
|
perPage?: number;
|
|
dateFrom?: Date;
|
|
dateTo?: Date;
|
|
visitReason?: string;
|
|
} = {}
|
|
): Promise<{ entries: QueueEntry[]; total: number }> {
|
|
const db = await getDb();
|
|
const { page = 1, perPage = 20, dateFrom, dateTo, visitReason } = opts;
|
|
const offset = (page - 1) * perPage;
|
|
|
|
const conditions = [
|
|
eq(queueEntries.clinicId, clinicId),
|
|
inArray(queueEntries.status, ["done", "absent", "canceled"] as const),
|
|
];
|
|
if (dateFrom) conditions.push(gte(queueEntries.joinedAt, dateFrom));
|
|
if (dateTo) {
|
|
const endOfDay = new Date(dateTo);
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
conditions.push(lt(queueEntries.joinedAt, endOfDay));
|
|
}
|
|
if (visitReason) {
|
|
conditions.push(
|
|
eq(
|
|
queueEntries.visitReason,
|
|
visitReason as "consultation" | "urgence" | "certificat_scolaire" | "certificat_sportif" | "arret_travail" | "administratif" | "autre"
|
|
)
|
|
);
|
|
}
|
|
|
|
const where = and(...conditions);
|
|
|
|
const entries = await db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(where!)
|
|
.orderBy(desc(queueEntries.joinedAt))
|
|
.limit(perPage)
|
|
.offset(offset);
|
|
|
|
const countResult = await db
|
|
.select({ count: sql<number>`count(*)` })
|
|
.from(queueEntries)
|
|
.where(where!);
|
|
const total = Number(countResult[0]?.count ?? 0);
|
|
|
|
return { entries, total };
|
|
}
|
|
|
|
export async function getConsultationStats(
|
|
clinicId: number,
|
|
days = 30
|
|
): Promise<{
|
|
totalConsultations: number;
|
|
avgDurationMinutes: number;
|
|
presenceRate: number;
|
|
topReasons: { reason: string; count: number }[];
|
|
}> {
|
|
const db = await getDb();
|
|
const since = new Date();
|
|
since.setDate(since.getDate() - days);
|
|
|
|
const completed = await db
|
|
.select()
|
|
.from(queueEntries)
|
|
.where(
|
|
and(
|
|
eq(queueEntries.clinicId, clinicId),
|
|
inArray(queueEntries.status, ["done", "absent", "canceled"] as const),
|
|
gte(queueEntries.joinedAt, since)
|
|
)
|
|
);
|
|
|
|
const doneEntries = completed.filter((e) => e.status === "done");
|
|
const absentEntries = completed.filter((e) => e.status === "absent");
|
|
const totalConsultations = completed.length;
|
|
|
|
const durations = doneEntries
|
|
.filter((e) => e.consultationStartedAt && e.consultationEndAt)
|
|
.map(
|
|
(e) => (e.consultationEndAt!.getTime() - e.consultationStartedAt!.getTime()) / 60000
|
|
);
|
|
const avgDurationMinutes =
|
|
durations.length > 0
|
|
? Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)
|
|
: 0;
|
|
|
|
const presenceRate =
|
|
totalConsultations > 0
|
|
? Math.round(((totalConsultations - absentEntries.length) / totalConsultations) * 100)
|
|
: 100;
|
|
|
|
const reasonCounts: Record<string, number> = {};
|
|
completed.forEach((e) => {
|
|
const r = e.visitReason ?? "consultation";
|
|
reasonCounts[r] = (reasonCounts[r] ?? 0) + 1;
|
|
});
|
|
const topReasons = Object.entries(reasonCounts)
|
|
.map(([reason, count]) => ({ reason, count }))
|
|
.sort((a, b) => b.count - a.count);
|
|
|
|
return { totalConsultations, avgDurationMinutes, presenceRate, topReasons };
|
|
}
|