initial: QueueMed v1.0 MVP — file d'attente, WhatsApp, auth, dashboard

This commit is contained in:
Hermes 2026-04-25 12:52:35 +00:00
parent d24d0c3e70
commit 1dbb131d24
112 changed files with 27911 additions and 0 deletions

18
server/_core/context.ts Normal file
View file

@ -0,0 +1,18 @@
import type { CreateExpressContextOptions } from "@trpc/server/adapters/express";
import { getUserFromRequest } from "../auth.js";
import type { User } from "../schema.js";
export async function createContext({ req, res }: CreateExpressContextOptions) {
const user = await getUserFromRequest(req);
return {
req,
res,
user,
};
}
export type TrpcContext = {
req: CreateExpressContextOptions["req"];
res: CreateExpressContextOptions["res"];
user: User | null;
};

148
server/_core/index.ts Normal file
View file

@ -0,0 +1,148 @@
import express from "express";
import http from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import cors from "cors";
import cookieParser from "cookie-parser";
import { Server as SocketIOServer } from "socket.io";
import { createExpressMiddleware } from "@trpc/server/adapters/express";
import { appRouter } from "../routers.js";
import { createContext } from "./context.js";
import { authMiddleware } from "../auth.js";
import { getDb } from "../db.js";
import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const PORT = Number(process.env.PORT ?? 5000);
const NODE_ENV = process.env.NODE_ENV ?? "development";
const IS_PROD = NODE_ENV === "production";
async function bootstrap() {
// Eagerly initialize database connection (warns early if DATABASE_URL missing)
try {
await getDb();
// eslint-disable-next-line no-console
console.log("[db] connected");
} catch (err) {
// eslint-disable-next-line no-console
console.error("[db] connection failed:", err);
}
const app = express();
const httpServer = http.createServer(app);
// ── Socket.io ────────────────────────────────────────────────────────────
const io = new SocketIOServer(httpServer, {
cors: {
origin: IS_PROD ? false : true,
credentials: true,
},
path: "/socket.io",
});
(globalThis as unknown as { __socketIo: SocketIOServer }).__socketIo = io;
io.on("connection", (socket) => {
socket.on("clinic:subscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.join(`clinic:${clinicId}`);
});
socket.on("display:subscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.join(`display:${clinicId}`);
});
socket.on("patient:subscribe", (patientToken: string) => {
if (typeof patientToken === "string" && patientToken.length > 0) {
socket.join(`patient:${patientToken}`);
}
});
socket.on("clinic:unsubscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.leave(`clinic:${clinicId}`);
});
socket.on("display:unsubscribe", (clinicId: number) => {
if (typeof clinicId === "number") socket.leave(`display:${clinicId}`);
});
socket.on("patient:unsubscribe", (patientToken: string) => {
if (typeof patientToken === "string") socket.leave(`patient:${patientToken}`);
});
});
// ── Middlewares ──────────────────────────────────────────────────────────
app.use(
cors({
origin: IS_PROD ? false : ["http://localhost:5173", "http://127.0.0.1:5173"],
credentials: true,
})
);
app.use(express.json({ limit: "1mb" }));
app.use(cookieParser());
app.use(authMiddleware);
// ── Health check ─────────────────────────────────────────────────────────
app.get("/api/health", (_req, res) => {
res.json({ status: "ok", env: NODE_ENV, ts: Date.now() });
});
// ── tRPC ─────────────────────────────────────────────────────────────────
app.use(
"/api/trpc",
createExpressMiddleware({
router: appRouter,
createContext,
onError({ error, path }) {
if (error.code === "INTERNAL_SERVER_ERROR") {
// eslint-disable-next-line no-console
console.error(`[trpc] ${path}:`, error);
}
},
})
);
// ── Static client (production) ───────────────────────────────────────────
if (IS_PROD) {
const clientDist = path.resolve(ROOT, "dist", "client");
const indexHtml = path.resolve(clientDist, "index.html");
if (fs.existsSync(clientDist)) {
app.use(express.static(clientDist, { maxAge: "1h", index: false }));
app.get("*", (req, res, next) => {
if (req.path.startsWith("/api") || req.path.startsWith("/socket.io")) return next();
if (!fs.existsSync(indexHtml)) return next();
res.sendFile(indexHtml);
});
} else {
// eslint-disable-next-line no-console
console.warn(`[static] dist/client not found at ${clientDist}`);
}
}
// ── Error handler ────────────────────────────────────────────────────────
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
// eslint-disable-next-line no-console
console.error("[express] error:", err);
res.status(500).json({ error: "Internal Server Error" });
});
httpServer.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log(`[server] listening on http://0.0.0.0:${PORT} (${NODE_ENV})`);
// Démarre le job qui marque les patients absents après N minutes sans réponse
startAutoAbsentJob();
});
const shutdown = (signal: string) => {
// eslint-disable-next-line no-console
console.log(`[server] received ${signal}, shutting down`);
stopAutoAbsentJob();
io.close();
httpServer.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
}
bootstrap().catch((err) => {
// eslint-disable-next-line no-console
console.error("[server] failed to start:", err);
process.exit(1);
});

43
server/_core/trpc.ts Normal file
View file

@ -0,0 +1,43 @@
import { initTRPC, TRPCError } from "@trpc/server";
import type { TrpcContext } from "./context.js";
import { isSubscriptionActive } from "../db.js";
const t = initTRPC.context<TrpcContext>().create({
errorFormatter({ shape }) {
return shape;
},
});
export const router = t.router;
export const middleware = t.middleware;
export const publicProcedure = t.procedure;
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
return next({
ctx: {
...ctx,
user: ctx.user,
},
});
});
export const protectedProcedure = t.procedure.use(isAuthed);
const requireActiveSubscription = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Authentication required" });
}
const active = await isSubscriptionActive(ctx.user.id);
if (!active) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Subscription expired or inactive",
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
export const subscriptionProcedure = t.procedure.use(requireActiveSubscription);

113
server/auth.ts Normal file
View file

@ -0,0 +1,113 @@
import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
import { getUserById } from "./db.js";
import type { User } from "./schema.js";
const COOKIE_NAME = "qm_auth";
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) throw new Error("JWT_SECRET is not set");
return secret;
}
export interface JwtPayload {
sub: number;
email: string;
role: "user" | "admin";
}
// ─── Password hashing ────────────────────────────────────────────────────────
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ─── JWT ─────────────────────────────────────────────────────────────────────
export function createToken(user: Pick<User, "id" | "email" | "role">): string {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
};
return jwt.sign(payload, getJwtSecret(), { expiresIn: "30d" });
}
export function verifyToken(token: string): JwtPayload | null {
try {
const decoded = jwt.verify(token, getJwtSecret());
if (typeof decoded === "string") return null;
if (
typeof decoded.sub !== "number" ||
typeof decoded.email !== "string" ||
(decoded.role !== "user" && decoded.role !== "admin")
) {
return null;
}
return { sub: decoded.sub, email: decoded.email, role: decoded.role };
} catch {
return null;
}
}
// ─── Cookie helpers ──────────────────────────────────────────────────────────
export function setAuthCookie(res: Response, token: string): void {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: COOKIE_MAX_AGE_MS,
path: "/",
});
}
export function clearAuthCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, { path: "/" });
}
export function readAuthCookie(req: Request): string | null {
const cookies = (req as Request & { cookies?: Record<string, string> }).cookies;
if (cookies && typeof cookies[COOKIE_NAME] === "string") return cookies[COOKIE_NAME];
const header = req.headers.authorization;
if (header && header.startsWith("Bearer ")) return header.slice(7);
return null;
}
// ─── User from request ───────────────────────────────────────────────────────
export async function getUserFromRequest(req: Request): Promise<User | null> {
const token = readAuthCookie(req);
if (!token) return null;
const payload = verifyToken(token);
if (!payload) return null;
return getUserById(payload.sub);
}
// ─── Express middleware ──────────────────────────────────────────────────────
export interface AuthedRequest extends Request {
user?: User;
}
export async function authMiddleware(
req: AuthedRequest,
_res: Response,
next: NextFunction
): Promise<void> {
const user = await getUserFromRequest(req);
if (user) req.user = user;
next();
}
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
export const AUTH_COOKIE_NAME = COOKIE_NAME;

659
server/db.ts Normal file
View file

@ -0,0 +1,659 @@
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 };
}

1358
server/routers.ts Normal file

File diff suppressed because it is too large Load diff

251
server/schema.ts Normal file
View file

@ -0,0 +1,251 @@
import {
int,
mysqlEnum,
mysqlTable,
text,
timestamp,
varchar,
boolean,
json,
index,
uniqueIndex,
} from "drizzle-orm/mysql-core";
// ─── Users (médecins) ────────────────────────────────────────────────────────
export const users = mysqlTable(
"users",
{
id: int("id").autoincrement().primaryKey(),
email: varchar("email", { length: 320 }).notNull(),
passwordHash: varchar("passwordHash", { length: 255 }).notNull(),
name: text("name"),
openId: varchar("openId", { length: 64 }),
loginMethod: varchar("loginMethod", { length: 64 }).default("password").notNull(),
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
},
(table) => ({
emailIdx: uniqueIndex("users_email_idx").on(table.email),
openIdIdx: index("users_openId_idx").on(table.openId),
})
);
export type User = typeof users.$inferSelect;
export type InsertUser = typeof users.$inferInsert;
// ─── Subscriptions ───────────────────────────────────────────────────────────
export const subscriptions = mysqlTable(
"subscriptions",
{
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
stripeCustomerId: varchar("stripeCustomerId", { length: 128 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 128 }),
stripePriceId: varchar("stripePriceId", { length: 128 }),
plan: mysqlEnum("plan", ["trial", "basic", "pro"]).default("trial").notNull(),
status: mysqlEnum("status", ["trialing", "active", "past_due", "canceled", "expired"])
.default("trialing")
.notNull(),
trialStartedAt: timestamp("trialStartedAt").defaultNow().notNull(),
trialEndsAt: timestamp("trialEndsAt").notNull(),
currentPeriodStart: timestamp("currentPeriodStart"),
currentPeriodEnd: timestamp("currentPeriodEnd"),
canceledAt: timestamp("canceledAt"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
(table) => ({
userIdx: index("subscriptions_userId_idx").on(table.userId),
})
);
export type Subscription = typeof subscriptions.$inferSelect;
export type InsertSubscription = typeof subscriptions.$inferInsert;
// ─── Clinics (cabinets médicaux) ─────────────────────────────────────────────
export const clinics = mysqlTable(
"clinics",
{
id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(),
name: varchar("name", { length: 255 }).notNull(),
address: text("address"),
phone: varchar("phone", { length: 32 }),
color: varchar("color", { length: 16 }).default("#10b981"),
// Numéro WhatsApp Business du cabinet
whatsappPhone: varchar("whatsappPhone", { length: 32 }),
isActive: boolean("isActive").default(true).notNull(),
// QR code token rotatif anti-triche
qrToken: varchar("qrToken", { length: 64 }).notNull(),
qrTokenExpiresAt: timestamp("qrTokenExpiresAt"),
qrRotationMinutes: int("qrRotationMinutes").default(30),
// Paramètres file d'attente
avgConsultationMinutes: int("avgConsultationMinutes").default(15),
maxQueueSize: int("maxQueueSize").default(50),
isQueueOpen: boolean("isQueueOpen").default(false).notNull(),
currentTicketNumber: int("currentTicketNumber").default(0).notNull(),
// Timer absent automatique : marque absent après N minutes sans réponse (0 = désactivé)
autoAbsentMinutes: int("autoAbsentMinutes").default(0).notNull(),
// Paramètres enrichis cabinet
welcomeMessage: text("welcomeMessage"),
// Horaires d'ouverture JSON : { monday: { open, close, closed }, ... }
openingHours: json("openingHours"),
// Langue de l'interface patient : "fr" | "en" | "ar" | "pt" | "es"
patientLanguage: varchar("patientLanguage", { length: 8 }).default("fr"),
// Templates WhatsApp personnalisables (null = template par défaut)
whatsappTemplateJoined: text("whatsappTemplateJoined"),
whatsappTemplateSoon: text("whatsappTemplateSoon"),
whatsappTemplateCalled: text("whatsappTemplateCalled"),
whatsappTemplateWithdrawn: text("whatsappTemplateWithdrawn"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
(table) => ({
userIdx: index("clinics_userId_idx").on(table.userId),
qrTokenIdx: uniqueIndex("clinics_qrToken_idx").on(table.qrToken),
})
);
export type Clinic = typeof clinics.$inferSelect;
export type InsertClinic = typeof clinics.$inferInsert;
// ─── Queue Entries (patients en file) ────────────────────────────────────────
export const queueEntries = mysqlTable(
"queue_entries",
{
id: int("id").autoincrement().primaryKey(),
clinicId: int("clinicId").notNull(),
ticketNumber: int("ticketNumber").notNull(),
// Identifiant de session anonyme du patient
patientToken: varchar("patientToken", { length: 64 }).notNull(),
patientName: varchar("patientName", { length: 128 }),
patientPhone: varchar("patientPhone", { length: 32 }),
status: mysqlEnum("status", [
"waiting",
"called",
"in_consultation",
"done",
"absent",
"canceled",
])
.default("waiting")
.notNull(),
position: int("position").notNull(),
joinedAt: timestamp("joinedAt").defaultNow().notNull(),
calledAt: timestamp("calledAt"),
consultationStartAt: timestamp("consultationStartAt"),
consultationEndAt: timestamp("consultationEndAt"),
estimatedWaitMinutes: int("estimatedWaitMinutes"),
notificationSent: boolean("notificationSent").default(false).notNull(),
// Motif de visite patient
visitReason: mysqlEnum("visitReason", [
"consultation",
"urgence",
"certificat_scolaire",
"certificat_sportif",
"arret_travail",
"administratif",
"autre",
]).default("consultation"),
visitNote: text("visitNote"),
// Timing consultation supplémentaire (pour stats)
consultationStartedAt: timestamp("consultationStartedAt"),
// Notifications WhatsApp
whatsappPhone: varchar("whatsappPhone", { length: 32 }),
whatsappSentJoined: boolean("whatsappSentJoined").default(false).notNull(),
whatsappSentSoon: boolean("whatsappSentSoon").default(false).notNull(),
whatsappSentCalled: boolean("whatsappSentCalled").default(false).notNull(),
// Pour l'impression de ticket
isPrinted: boolean("isPrinted").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
(table) => ({
clinicIdx: index("queue_clinicId_idx").on(table.clinicId),
statusIdx: index("queue_status_idx").on(table.status),
tokenIdx: index("queue_patientToken_idx").on(table.patientToken),
})
);
export type QueueEntry = typeof queueEntries.$inferSelect;
export type InsertQueueEntry = typeof queueEntries.$inferInsert;
// ─── Analytics Events ─────────────────────────────────────────────────────────
export const analyticsEvents = mysqlTable(
"analytics_events",
{
id: int("id").autoincrement().primaryKey(),
clinicId: int("clinicId").notNull(),
eventType: mysqlEnum("eventType", [
"patient_joined",
"patient_called",
"patient_done",
"patient_absent",
"queue_opened",
"queue_closed",
]).notNull(),
ticketNumber: int("ticketNumber"),
waitMinutes: int("waitMinutes"),
consultationMinutes: int("consultationMinutes"),
queueSizeAtEvent: int("queueSizeAtEvent"),
hourOfDay: int("hourOfDay"),
dayOfWeek: int("dayOfWeek"),
metadata: json("metadata"),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
(table) => ({
clinicIdx: index("analytics_clinicId_idx").on(table.clinicId),
createdIdx: index("analytics_createdAt_idx").on(table.createdAt),
})
);
export type AnalyticsEvent = typeof analyticsEvents.$inferSelect;
export type InsertAnalyticsEvent = typeof analyticsEvents.$inferInsert;
// ─── WhatsApp Country Codes ──────────────────────────────────────────────────
// Indicatifs pays disponibles pour les notifications WhatsApp patients
export const whatsappCountryCodes = mysqlTable(
"whatsapp_country_codes",
{
id: int("id").autoincrement().primaryKey(),
code: varchar("code", { length: 4 }).notNull(),
dialCode: varchar("dialCode", { length: 8 }).notNull(),
nameFr: varchar("nameFr", { length: 128 }).notNull(),
flag: varchar("flag", { length: 8 }).notNull(),
enabled: boolean("enabled").default(false).notNull(),
sortOrder: int("sortOrder").default(100).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
},
(table) => ({
codeIdx: uniqueIndex("whatsapp_country_codes_code_idx").on(table.code),
})
);
export type WhatsappCountryCode = typeof whatsappCountryCodes.$inferSelect;
export type InsertWhatsappCountryCode = typeof whatsappCountryCodes.$inferInsert;
// ─── WhatsApp Logs ────────────────────────────────────────────────────────────
// Trace chaque message WhatsApp envoyé par cabinet
export const whatsappLogs = mysqlTable(
"whatsapp_logs",
{
id: int("id").autoincrement().primaryKey(),
clinicId: int("clinicId").notNull(),
phoneMasked: varchar("phoneMasked", { length: 32 }).notNull(),
messageType: mysqlEnum("messageType", ["joined", "soon", "called", "withdrawn", "test"]).notNull(),
status: mysqlEnum("status", ["sent", "failed"]).notNull(),
errorMessage: text("errorMessage"),
messagePreview: varchar("messagePreview", { length: 120 }),
createdAt: timestamp("createdAt").defaultNow().notNull(),
},
(table) => ({
clinicIdx: index("whatsapp_logs_clinicId_idx").on(table.clinicId),
createdIdx: index("whatsapp_logs_createdAt_idx").on(table.createdAt),
})
);
export type WhatsappLog = typeof whatsappLogs.$inferSelect;
export type InsertWhatsappLog = typeof whatsappLogs.$inferInsert;

View file

@ -0,0 +1,151 @@
/**
* Auto-Absent Timer Service
* Vérifie toutes les 30 secondes les patients en statut "called" depuis plus de N minutes.
* Si autoAbsentMinutes > 0 pour le cabinet, le patient est marqué absent et le suivant est appelé.
*/
import { getDb } from "../db.js";
import { clinics, queueEntries } from "../schema.js";
import { eq, and, inArray } from "drizzle-orm";
// Socket.io helpers (utilise le global injecté par le serveur principal)
function emitToClinic(clinicId: number, event: string, data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const io = (global as any).__socketIo;
if (io) io.to(`clinic:${clinicId}`).emit(event, data);
}
function emitToPatient(patientToken: string, event: string, data: unknown) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const io = (global as any).__socketIo;
if (io) io.to(`patient:${patientToken}`).emit(event, data);
}
let intervalId: ReturnType<typeof setInterval> | null = null;
/** Démarre le job de vérification des absents automatiques */
export function startAutoAbsentJob(): void {
if (intervalId) return; // already running
intervalId = setInterval(checkAutoAbsent, 30_000);
console.log("[AutoAbsent] Job démarré (intervalle 30s)");
}
/** Arrête le job */
export function stopAutoAbsentJob(): void {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
console.log("[AutoAbsent] Job arrêté");
}
}
async function checkAutoAbsent(): Promise<void> {
const db = await getDb();
if (!db) return;
try {
// Récupérer tous les cabinets avec autoAbsentMinutes > 0
const activeClinics = await db
.select({
id: clinics.id,
autoAbsentMinutes: clinics.autoAbsentMinutes,
avgConsultationMinutes: clinics.avgConsultationMinutes,
name: clinics.name,
})
.from(clinics)
.where(and(
eq(clinics.isActive, true),
eq(clinics.isQueueOpen, true),
));
const clinicsWithTimer = activeClinics.filter(
(c) => (c.autoAbsentMinutes ?? 0) > 0
);
if (clinicsWithTimer.length === 0) return;
const now = new Date();
for (const clinic of clinicsWithTimer) {
const thresholdMs = (clinic.autoAbsentMinutes ?? 3) * 60 * 1000;
// Récupérer les patients en statut "called" pour ce cabinet
const calledEntries = await db
.select()
.from(queueEntries)
.where(and(
eq(queueEntries.clinicId, clinic.id),
eq(queueEntries.status, "called"),
));
for (const entry of calledEntries) {
if (!entry.calledAt) continue;
const elapsed = now.getTime() - entry.calledAt.getTime();
if (elapsed >= thresholdMs) {
// Marquer absent
await db
.update(queueEntries)
.set({ status: "absent" })
.where(eq(queueEntries.id, entry.id));
console.log(
`[AutoAbsent] Cabinet ${clinic.id} — ticket #${entry.ticketNumber} marqué absent après ${Math.round(elapsed / 60000)} min`
);
// Notifier le patient (s'il est encore connecté)
if (entry.patientToken) {
emitToPatient(entry.patientToken, "patient:auto_absent", {
ticketNumber: entry.ticketNumber,
message: `Votre ticket n°${entry.ticketNumber} a été annulé car vous n'étiez pas présent.`,
});
}
// Reordonner la file et notifier le cabinet
await reorderAndNotify(clinic.id, db);
}
}
}
} catch (err) {
console.error("[AutoAbsent] Erreur lors de la vérification :", err);
}
}
async function reorderAndNotify(clinicId: number, db: Awaited<ReturnType<typeof getDb>>): Promise<void> {
if (!db) return;
// Récupérer les patients en attente pour ce cabinet
const waitingEntries = await db
.select()
.from(queueEntries)
.where(and(
eq(queueEntries.clinicId, clinicId),
eq(queueEntries.status, "waiting"),
));
// Renuméroter
let pos = 1;
for (const e of waitingEntries.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))) {
await db
.update(queueEntries)
.set({ position: pos })
.where(eq(queueEntries.id, e.id));
pos++;
}
// Calculer l'état de la file pour la notification WebSocket
const allActive = await db
.select()
.from(queueEntries)
.where(and(
eq(queueEntries.clinicId, clinicId),
inArray(queueEntries.status, ["waiting", "called", "in_consultation"]),
));
const state = {
totalWaiting: allActive.filter((e) => e.status === "waiting").length,
currentTicket: allActive.find((e) => e.status === "called")?.ticketNumber ?? null,
clinicId,
};
emitToClinic(clinicId, "queue:update", state);
emitToClinic(clinicId, "display:update", state);
}

285
server/services/whatsapp.ts Normal file
View file

@ -0,0 +1,285 @@
/**
* WhatsApp notification service using Baileys (WhatsApp Web unofficial API)
* Manages one WhatsApp session per clinic (identified by clinicId).
* Sessions are stored in /tmp/whatsapp-sessions/<clinicId>/
*
* Disclaimer: Baileys uses the WhatsApp Web protocol which is unofficial.
* Use at low volume (< 500 msg/day) to minimise ban risk.
*/
import makeWASocket, {
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom";
import * as fs from "fs";
import * as path from "path";
import pino from "pino";
import { EventEmitter } from "events";
import PQueue from "p-queue";
import { insertWhatsAppLog, maskPhone } from "../db.js";
// ─── Types ────────────────────────────────────────────────────────────────────
export type WAStatus = "disconnected" | "connecting" | "qr_ready" | "connected";
interface WASession {
socket: ReturnType<typeof makeWASocket> | null;
status: WAStatus;
qrCode: string | null; // base64 QR image data URL
qrRaw: string | null; // raw QR string for display
retryCount: number;
events: EventEmitter;
}
// ─── In-memory session store ──────────────────────────────────────────────────
const sessions = new Map<number, WASession>();
// ─── Rate limiting : une queue p-queue par cabinet ───────────────────────────
// Intervalle de 2.5s entre messages pour éviter les bans WhatsApp
const messageQueues = new Map<number, PQueue>();
function getMessageQueue(clinicId: number): PQueue {
if (!messageQueues.has(clinicId)) {
messageQueues.set(clinicId, new PQueue({
concurrency: 1,
interval: 2500,
intervalCap: 1,
}));
}
return messageQueues.get(clinicId)!;
}
const SESSION_DIR = "/tmp/whatsapp-sessions";
if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true });
// Silent logger to avoid spamming server logs
const logger = pino({ level: "silent" });
// ─── Helpers ──────────────────────────────────────────────────────────────────
function sessionPath(clinicId: number) {
return path.join(SESSION_DIR, String(clinicId));
}
function getOrCreateSession(clinicId: number): WASession {
if (!sessions.has(clinicId)) {
sessions.set(clinicId, {
socket: null,
status: "disconnected",
qrCode: null,
qrRaw: null,
retryCount: 0,
events: new EventEmitter(),
});
}
return sessions.get(clinicId)!;
}
// ─── QR code as base64 data URL ───────────────────────────────────────────────
async function qrToDataUrl(qrString: string): Promise<string> {
// Dynamically import qrcode to avoid circular deps
const QRCode = await import("qrcode");
return QRCode.default.toDataURL(qrString, {
width: 300,
margin: 2,
color: { dark: "#128C7E", light: "#FFFFFF" },
});
}
// ─── Connect / start session ──────────────────────────────────────────────────
export async function connectWhatsApp(clinicId: number): Promise<void> {
const session = getOrCreateSession(clinicId);
if (session.status === "connected" || session.status === "connecting") return;
session.status = "connecting";
session.qrCode = null;
session.qrRaw = null;
const authDir = sessionPath(clinicId);
fs.mkdirSync(authDir, { recursive: true });
const { state, saveCreds } = await useMultiFileAuthState(authDir);
const { version } = await fetchLatestBaileysVersion();
const sock = makeWASocket({
version,
logger,
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
printQRInTerminal: false,
generateHighQualityLinkPreview: false,
browser: ["Salle d'attente", "Chrome", "1.0.0"],
});
session.socket = sock;
// ── QR code event ──────────────────────────────────────────────────────────
sock.ev.on("connection.update", async (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
session.qrRaw = qr;
session.status = "qr_ready";
try {
session.qrCode = await qrToDataUrl(qr);
} catch {
session.qrCode = null;
}
session.events.emit("qr", { qrCode: session.qrCode, qrRaw: qr });
}
if (connection === "open") {
session.status = "connected";
session.qrCode = null;
session.qrRaw = null;
session.retryCount = 0;
session.events.emit("connected");
}
if (connection === "close") {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
session.status = "disconnected";
session.socket = null;
if (shouldReconnect && session.retryCount < 5) {
session.retryCount++;
setTimeout(() => connectWhatsApp(clinicId), 3000 * session.retryCount);
} else if (!shouldReconnect) {
// Logged out clear saved credentials
clearSession(clinicId);
}
session.events.emit("disconnected", { statusCode });
}
});
sock.ev.on("creds.update", saveCreds);
}
// ─── Disconnect / logout ──────────────────────────────────────────────────────
export async function disconnectWhatsApp(clinicId: number): Promise<void> {
const session = sessions.get(clinicId);
if (!session) return;
try {
await session.socket?.logout();
} catch {
// ignore
}
clearSession(clinicId);
}
function clearSession(clinicId: number) {
const session = sessions.get(clinicId);
if (session) {
session.socket = null;
session.status = "disconnected";
session.qrCode = null;
session.qrRaw = null;
}
// Remove saved auth files
const authDir = sessionPath(clinicId);
if (fs.existsSync(authDir)) {
fs.rmSync(authDir, { recursive: true, force: true });
}
}
// ─── Status getter ────────────────────────────────────────────────────────────
export function getWhatsAppStatus(clinicId: number): {
status: WAStatus;
qrCode: string | null;
qrRaw: string | null;
} {
const session = sessions.get(clinicId);
if (!session) return { status: "disconnected", qrCode: null, qrRaw: null };
return {
status: session.status,
qrCode: session.qrCode,
qrRaw: session.qrRaw,
};
}
// ─── Send message ─────────────────────────────────────────────────────────────
/**
* Send a WhatsApp text message to a phone number.
* @param clinicId The clinic whose WhatsApp session to use
* @param phone Phone number in international format without + (e.g. "33612345678")
* @param message Text content
*/
export async function sendWhatsAppMessage(
clinicId: number,
phone: string,
message: string
): Promise<{ success: boolean; error?: string }> {
const session = sessions.get(clinicId);
if (!session || session.status !== "connected" || !session.socket) {
return { success: false, error: "WhatsApp non connecté pour ce cabinet" };
}
// Normalize phone: strip leading +, spaces, dashes
const normalized = phone.replace(/[^0-9]/g, "");
const jid = `${normalized}@s.whatsapp.net`;
try {
await session.socket.sendMessage(jid, { text: message });
return { success: true };
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
return { success: false, error: errorMessage };
}
}
// ─── Template-based message builders ─────────────────────────────────────────
import {
buildMessage,
type TemplateContext,
type TemplateType,
} from "../../shared/whatsappTemplates.js";
export type { TemplateContext, TemplateType };
/**
* Custom templates from the clinic DB record (null = use default).
*/
export type ClinicTemplates = Partial<Record<TemplateType, string | null>>;
export function buildJoinMessage(
clinicName: string, ticketNumber: number, position: number, estimatedWait: number,
customTemplates?: ClinicTemplates
): string {
return buildMessage("joined", {
nom: "", ticket: ticketNumber, position, attente: estimatedWait, cabinet: clinicName,
}, customTemplates);
}
export function buildSoonMessage(
clinicName: string, ticketNumber: number, minutesLeft: number,
customTemplates?: ClinicTemplates
): string {
return buildMessage("soon", {
nom: "", ticket: ticketNumber, position: 0, attente: minutesLeft, cabinet: clinicName,
}, customTemplates);
}
export function buildCalledMessage(
clinicName: string, ticketNumber: number,
customTemplates?: ClinicTemplates
): string {
return buildMessage("called", {
nom: "", ticket: ticketNumber, position: 0, attente: 0, cabinet: clinicName,
}, customTemplates);
}
export function buildWithdrawnMessage(
clinicName: string, ticketNumber: number,
customTemplates?: ClinicTemplates
): string {
return buildMessage("withdrawn", {
nom: "", ticket: ticketNumber, position: 0, attente: 0, cabinet: clinicName,
}, customTemplates);
}