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 { 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 { 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 { 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 { 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 { 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 { const db = await getDb(); await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, userId)); } export async function setUserResetToken( userId: number, resetToken: string | null, resetTokenExpiry: Date | null ): Promise { const db = await getDb(); await db.update(users).set({ resetToken, resetTokenExpiry }).where(eq(users.id, userId)); } export async function getUserByResetToken(token: string): Promise { const db = await getDb(); const rows = await db.select().from(users).where(eq(users.resetToken, token)).limit(1); return rows[0] ?? null; } export async function updateUserPassword(userId: number, passwordHash: string): Promise { const db = await getDb(); await db .update(users) .set({ passwordHash, resetToken: null, resetTokenExpiry: null }) .where(eq(users.id, userId)); } // ─── Subscriptions ─────────────────────────────────────────────────────────── const TRIAL_DAYS = 30; export async function createTrialSubscription(userId: number): Promise { 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 { 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 ): Promise { const db = await getDb(); await db.update(subscriptions).set(patch).where(eq(subscriptions.userId, userId)); } export async function isSubscriptionActive(userId: number): Promise { 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 { 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 { 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 { 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 ): 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 ): Promise { const db = await getDb(); await db.update(clinics).set(patch).where(eq(clinics.id, id)); } export async function deleteClinic(id: number): Promise { 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 { 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 { 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 { const db = await getDb(); return db .select() .from(queueEntries) .where(eq(queueEntries.clinicId, clinicId)) .orderBy(queueEntries.position); } export async function getQueueEntry(id: number): Promise { 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 { 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 ): Promise { const db = await getDb(); await db.update(queueEntries).set(patch).where(eq(queueEntries.id, id)); } export async function reorderQueue(clinicId: number): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 ): Promise { 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`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`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 = {}; 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 }; }