initial: QueueMed v1.0 MVP — file d'attente, WhatsApp, auth, dashboard
This commit is contained in:
parent
d24d0c3e70
commit
1dbb131d24
112 changed files with 27911 additions and 0 deletions
18
server/_core/context.ts
Normal file
18
server/_core/context.ts
Normal 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
148
server/_core/index.ts
Normal 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
43
server/_core/trpc.ts
Normal 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
113
server/auth.ts
Normal 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
659
server/db.ts
Normal 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
1358
server/routers.ts
Normal file
File diff suppressed because it is too large
Load diff
251
server/schema.ts
Normal file
251
server/schema.ts
Normal 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;
|
||||
151
server/services/autoAbsent.ts
Normal file
151
server/services/autoAbsent.ts
Normal 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
285
server/services/whatsapp.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue