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

19
shared/_core/errors.ts Normal file
View file

@ -0,0 +1,19 @@
/**
* Base HTTP error class with status code.
* Throw this from route handlers to send specific HTTP errors.
*/
export class HttpError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "HttpError";
}
}
// Convenience constructors
export const BadRequestError = (msg: string) => new HttpError(400, msg);
export const UnauthorizedError = (msg: string) => new HttpError(401, msg);
export const ForbiddenError = (msg: string) => new HttpError(403, msg);
export const NotFoundError = (msg: string) => new HttpError(404, msg);

5
shared/const.ts Normal file
View file

@ -0,0 +1,5 @@
export const COOKIE_NAME = "qm_auth";
export const ONE_YEAR_MS = 1000 * 60 * 60 * 24 * 365;
export const AXIOS_TIMEOUT_MS = 30_000;
export const UNAUTHED_ERR_MSG = "Veuillez vous connecter (10001)";
export const NOT_ADMIN_ERR_MSG = "Vous n'avez pas la permission requise (10002)";

104
shared/countryCodes.ts Normal file
View file

@ -0,0 +1,104 @@
/**
* Liste complète des indicatifs téléphoniques internationaux.
* Utilisée pour le seed initial et comme référence statique côté client.
*
* enabled: true activé par défaut (configurable en DB)
* sortOrder: 1-9 affiché en premier dans les sélecteurs
*/
export type CountryCodeEntry = {
code: string; // ISO 3166-1 alpha-2
dialCode: string; // sans le + (ex: "33")
nameFr: string; // nom en français
flag: string; // emoji drapeau
enabled: boolean; // activé par défaut
sortOrder: number; // ordre d'affichage
};
export const ALL_COUNTRY_CODES: CountryCodeEntry[] = [
// ── Pays activés par défaut (sortOrder 1-3) ──────────────────────────────
{ code: "FR", dialCode: "33", nameFr: "France", flag: "🇫🇷", enabled: true, sortOrder: 1 },
{ code: "GF", dialCode: "594", nameFr: "Guyane française", flag: "🇬🇫", enabled: true, sortOrder: 2 },
{ code: "BR", dialCode: "55", nameFr: "Brésil", flag: "🇧🇷", enabled: true, sortOrder: 3 },
// ── Territoires et DOM-TOM français ──────────────────────────────────────
{ code: "GP", dialCode: "590", nameFr: "Guadeloupe", flag: "🇬🇵", enabled: false, sortOrder: 10 },
{ code: "MQ", dialCode: "596", nameFr: "Martinique", flag: "🇲🇶", enabled: false, sortOrder: 11 },
{ code: "RE", dialCode: "262", nameFr: "La Réunion", flag: "🇷🇪", enabled: false, sortOrder: 12 },
{ code: "PM", dialCode: "508", nameFr: "Saint-Pierre-et-Miquelon", flag: "🇵🇲", enabled: false, sortOrder: 13 },
{ code: "YT", dialCode: "262", nameFr: "Mayotte", flag: "🇾🇹", enabled: false, sortOrder: 14 },
{ code: "NC", dialCode: "687", nameFr: "Nouvelle-Calédonie", flag: "🇳🇨", enabled: false, sortOrder: 15 },
{ code: "PF", dialCode: "689", nameFr: "Polynésie française", flag: "🇵🇫", enabled: false, sortOrder: 16 },
{ code: "WF", dialCode: "681", nameFr: "Wallis-et-Futuna", flag: "🇼🇫", enabled: false, sortOrder: 17 },
// ── Europe ────────────────────────────────────────────────────────────────
{ code: "BE", dialCode: "32", nameFr: "Belgique", flag: "🇧🇪", enabled: false, sortOrder: 20 },
{ code: "CH", dialCode: "41", nameFr: "Suisse", flag: "🇨🇭", enabled: false, sortOrder: 21 },
{ code: "LU", dialCode: "352", nameFr: "Luxembourg", flag: "🇱🇺", enabled: false, sortOrder: 22 },
{ code: "MC", dialCode: "377", nameFr: "Monaco", flag: "🇲🇨", enabled: false, sortOrder: 23 },
{ code: "DE", dialCode: "49", nameFr: "Allemagne", flag: "🇩🇪", enabled: false, sortOrder: 24 },
{ code: "ES", dialCode: "34", nameFr: "Espagne", flag: "🇪🇸", enabled: false, sortOrder: 25 },
{ code: "IT", dialCode: "39", nameFr: "Italie", flag: "🇮🇹", enabled: false, sortOrder: 26 },
{ code: "PT", dialCode: "351", nameFr: "Portugal", flag: "🇵🇹", enabled: false, sortOrder: 27 },
{ code: "GB", dialCode: "44", nameFr: "Royaume-Uni", flag: "🇬🇧", enabled: false, sortOrder: 28 },
{ code: "NL", dialCode: "31", nameFr: "Pays-Bas", flag: "🇳🇱", enabled: false, sortOrder: 29 },
{ code: "PL", dialCode: "48", nameFr: "Pologne", flag: "🇵🇱", enabled: false, sortOrder: 30 },
{ code: "SE", dialCode: "46", nameFr: "Suède", flag: "🇸🇪", enabled: false, sortOrder: 31 },
{ code: "NO", dialCode: "47", nameFr: "Norvège", flag: "🇳🇴", enabled: false, sortOrder: 32 },
{ code: "DK", dialCode: "45", nameFr: "Danemark", flag: "🇩🇰", enabled: false, sortOrder: 33 },
{ code: "FI", dialCode: "358", nameFr: "Finlande", flag: "🇫🇮", enabled: false, sortOrder: 34 },
{ code: "AT", dialCode: "43", nameFr: "Autriche", flag: "🇦🇹", enabled: false, sortOrder: 35 },
{ code: "GR", dialCode: "30", nameFr: "Grèce", flag: "🇬🇷", enabled: false, sortOrder: 36 },
{ code: "RO", dialCode: "40", nameFr: "Roumanie", flag: "🇷🇴", enabled: false, sortOrder: 37 },
{ code: "HU", dialCode: "36", nameFr: "Hongrie", flag: "🇭🇺", enabled: false, sortOrder: 38 },
{ code: "CZ", dialCode: "420", nameFr: "République tchèque", flag: "🇨🇿", enabled: false, sortOrder: 39 },
{ code: "TR", dialCode: "90", nameFr: "Turquie", flag: "🇹🇷", enabled: false, sortOrder: 40 },
// ── Afrique francophone ───────────────────────────────────────────────────
{ code: "MA", dialCode: "212", nameFr: "Maroc", flag: "🇲🇦", enabled: false, sortOrder: 50 },
{ code: "DZ", dialCode: "213", nameFr: "Algérie", flag: "🇩🇿", enabled: false, sortOrder: 51 },
{ code: "TN", dialCode: "216", nameFr: "Tunisie", flag: "🇹🇳", enabled: false, sortOrder: 52 },
{ code: "SN", dialCode: "221", nameFr: "Sénégal", flag: "🇸🇳", enabled: false, sortOrder: 53 },
{ code: "CI", dialCode: "225", nameFr: "Côte d'Ivoire", flag: "🇨🇮", enabled: false, sortOrder: 54 },
{ code: "CM", dialCode: "237", nameFr: "Cameroun", flag: "🇨🇲", enabled: false, sortOrder: 55 },
{ code: "CD", dialCode: "243", nameFr: "Congo (RDC)", flag: "🇨🇩", enabled: false, sortOrder: 56 },
{ code: "CG", dialCode: "242", nameFr: "Congo (Brazzaville)", flag: "🇨🇬", enabled: false, sortOrder: 57 },
{ code: "MG", dialCode: "261", nameFr: "Madagascar", flag: "🇲🇬", enabled: false, sortOrder: 58 },
{ code: "ML", dialCode: "223", nameFr: "Mali", flag: "🇲🇱", enabled: false, sortOrder: 59 },
{ code: "BF", dialCode: "226", nameFr: "Burkina Faso", flag: "🇧🇫", enabled: false, sortOrder: 60 },
{ code: "NE", dialCode: "227", nameFr: "Niger", flag: "🇳🇪", enabled: false, sortOrder: 61 },
{ code: "TD", dialCode: "235", nameFr: "Tchad", flag: "🇹🇩", enabled: false, sortOrder: 62 },
{ code: "GN", dialCode: "224", nameFr: "Guinée", flag: "🇬🇳", enabled: false, sortOrder: 63 },
{ code: "BJ", dialCode: "229", nameFr: "Bénin", flag: "🇧🇯", enabled: false, sortOrder: 64 },
{ code: "TG", dialCode: "228", nameFr: "Togo", flag: "🇹🇬", enabled: false, sortOrder: 65 },
{ code: "MR", dialCode: "222", nameFr: "Mauritanie", flag: "🇲🇷", enabled: false, sortOrder: 66 },
{ code: "GA", dialCode: "241", nameFr: "Gabon", flag: "🇬🇦", enabled: false, sortOrder: 67 },
{ code: "GQ", dialCode: "240", nameFr: "Guinée équatoriale", flag: "🇬🇶", enabled: false, sortOrder: 68 },
{ code: "CF", dialCode: "236", nameFr: "Centrafrique", flag: "🇨🇫", enabled: false, sortOrder: 69 },
{ code: "KM", dialCode: "269", nameFr: "Comores", flag: "🇰🇲", enabled: false, sortOrder: 70 },
{ code: "DJ", dialCode: "253", nameFr: "Djibouti", flag: "🇩🇯", enabled: false, sortOrder: 71 },
{ code: "MU", dialCode: "230", nameFr: "Maurice", flag: "🇲🇺", enabled: false, sortOrder: 72 },
{ code: "SC", dialCode: "248", nameFr: "Seychelles", flag: "🇸🇨", enabled: false, sortOrder: 73 },
{ code: "EG", dialCode: "20", nameFr: "Égypte", flag: "🇪🇬", enabled: false, sortOrder: 74 },
// ── Amériques ─────────────────────────────────────────────────────────────
{ code: "US", dialCode: "1", nameFr: "États-Unis", flag: "🇺🇸", enabled: false, sortOrder: 80 },
{ code: "CA", dialCode: "1", nameFr: "Canada", flag: "🇨🇦", enabled: false, sortOrder: 81 },
{ code: "MX", dialCode: "52", nameFr: "Mexique", flag: "🇲🇽", enabled: false, sortOrder: 82 },
{ code: "AR", dialCode: "54", nameFr: "Argentine", flag: "🇦🇷", enabled: false, sortOrder: 83 },
{ code: "CO", dialCode: "57", nameFr: "Colombie", flag: "🇨🇴", enabled: false, sortOrder: 84 },
{ code: "CL", dialCode: "56", nameFr: "Chili", flag: "🇨🇱", enabled: false, sortOrder: 85 },
{ code: "PE", dialCode: "51", nameFr: "Pérou", flag: "🇵🇪", enabled: false, sortOrder: 86 },
{ code: "VE", dialCode: "58", nameFr: "Venezuela", flag: "🇻🇪", enabled: false, sortOrder: 87 },
{ code: "EC", dialCode: "593", nameFr: "Équateur", flag: "🇪🇨", enabled: false, sortOrder: 88 },
{ code: "BO", dialCode: "591", nameFr: "Bolivie", flag: "🇧🇴", enabled: false, sortOrder: 89 },
{ code: "PY", dialCode: "595", nameFr: "Paraguay", flag: "🇵🇾", enabled: false, sortOrder: 90 },
{ code: "UY", dialCode: "598", nameFr: "Uruguay", flag: "🇺🇾", enabled: false, sortOrder: 91 },
{ code: "HT", dialCode: "509", nameFr: "Haïti", flag: "🇭🇹", enabled: false, sortOrder: 92 },
// ── Asie & Océanie ────────────────────────────────────────────────────────
{ code: "IN", dialCode: "91", nameFr: "Inde", flag: "🇮🇳", enabled: false, sortOrder: 100 },
{ code: "CN", dialCode: "86", nameFr: "Chine", flag: "🇨🇳", enabled: false, sortOrder: 101 },
{ code: "JP", dialCode: "81", nameFr: "Japon", flag: "🇯🇵", enabled: false, sortOrder: 102 },
{ code: "AU", dialCode: "61", nameFr: "Australie", flag: "🇦🇺", enabled: false, sortOrder: 103 },
{ code: "LB", dialCode: "961", nameFr: "Liban", flag: "🇱🇧", enabled: false, sortOrder: 104 },
];

189
shared/openingHours.ts Normal file
View file

@ -0,0 +1,189 @@
/**
* shared/openingHours.ts
* Helpers for clinic opening hours validation.
* Used by both server (queue.join validation) and client (PatientQueue display).
*/
export type DayKey = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday";
export interface DaySchedule {
open: string; // "HH:MM" 24h format, e.g. "08:30"
close: string; // "HH:MM" 24h format, e.g. "18:00"
closed: boolean;
}
export type OpeningHours = Partial<Record<DayKey, DaySchedule>>;
export const DAY_KEYS: DayKey[] = [
"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday",
];
export const DAY_LABELS_FR: Record<DayKey, string> = {
monday: "Lundi",
tuesday: "Mardi",
wednesday: "Mercredi",
thursday: "Jeudi",
friday: "Vendredi",
saturday: "Samedi",
sunday: "Dimanche",
};
/** Map JS Date.getDay() (0=Sunday) to DayKey */
const JS_DAY_TO_KEY: DayKey[] = [
"sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
];
/** Parse "HH:MM" into { hours, minutes } */
function parseTime(time: string): { hours: number; minutes: number } {
const [h, m] = time.split(":").map(Number);
return { hours: h ?? 0, minutes: m ?? 0 };
}
/** Convert HH:MM to total minutes since midnight */
function toMinutes(time: string): number {
const { hours, minutes } = parseTime(time);
return hours * 60 + minutes;
}
/** Format HH:MM to human-readable "8h30" or "18h00" */
export function formatTime(time: string): string {
const { hours, minutes } = parseTime(time);
return minutes === 0 ? `${hours}h` : `${hours}h${String(minutes).padStart(2, "0")}`;
}
/**
* Check if the clinic is currently open based on its opening hours.
* @param openingHours - The clinic's opening hours configuration (from DB)
* @param now - Optional Date to check against (defaults to current time)
* @returns true if the clinic is currently open
*/
export function isClinicOpen(
openingHours: OpeningHours | null | undefined,
now?: Date
): boolean {
// If no hours configured → always open
if (!openingHours || Object.keys(openingHours).length === 0) return true;
const date = now ?? new Date();
const dayKey = JS_DAY_TO_KEY[date.getDay()];
const schedule = openingHours[dayKey];
// Day not configured → closed
if (!schedule) return false;
if (schedule.closed) return false;
const currentMinutes = date.getHours() * 60 + date.getMinutes();
const openMinutes = toMinutes(schedule.open);
const closeMinutes = toMinutes(schedule.close);
return currentMinutes >= openMinutes && currentMinutes < closeMinutes;
}
/**
* Get today's schedule for the clinic.
*/
export function getTodaySchedule(
openingHours: OpeningHours | null | undefined,
now?: Date
): { dayKey: DayKey; schedule: DaySchedule | null } {
const date = now ?? new Date();
const dayKey = JS_DAY_TO_KEY[date.getDay()];
if (!openingHours) return { dayKey, schedule: null };
const schedule = openingHours[dayKey] ?? null;
return { dayKey, schedule };
}
/**
* Get the next opening time (day + time) from now.
* Returns null if no hours are configured.
*/
export function getNextOpeningTime(
openingHours: OpeningHours | null | undefined,
now?: Date
): { dayKey: DayKey; dayLabel: string; openTime: string } | null {
if (!openingHours || Object.keys(openingHours).length === 0) return null;
const date = now ?? new Date();
const todayJsDay = date.getDay(); // 0=Sunday
// Check next 7 days (including today if not yet open)
for (let offset = 0; offset < 7; offset++) {
const jsDay = (todayJsDay + offset) % 7;
const dayKey = JS_DAY_TO_KEY[jsDay];
const schedule = openingHours[dayKey];
if (!schedule || schedule.closed) continue;
const openMinutes = toMinutes(schedule.open);
const currentMinutes = date.getHours() * 60 + date.getMinutes();
// Today: only if opening time is in the future
if (offset === 0 && currentMinutes >= openMinutes) continue;
return {
dayKey,
dayLabel: DAY_LABELS_FR[dayKey],
openTime: formatTime(schedule.open),
};
}
return null;
}
/**
* Build a human-readable error message when the clinic is closed.
*/
export function buildClosedMessage(
openingHours: OpeningHours | null | undefined,
clinicName: string,
now?: Date
): string {
const next = getNextOpeningTime(openingHours, now);
const { dayKey, schedule } = getTodaySchedule(openingHours, now);
const todayLabel = DAY_LABELS_FR[dayKey];
if (!schedule || schedule.closed) {
if (next) {
return `${clinicName} est fermé aujourd'hui (${todayLabel}). Prochaine ouverture : ${next.dayLabel} à ${next.openTime}.`;
}
return `${clinicName} est actuellement fermé.`;
}
// Today has hours but we're outside them
const currentMinutes = (now ?? new Date()).getHours() * 60 + (now ?? new Date()).getMinutes();
const openMinutes = toMinutes(schedule.open);
const closeMinutes = toMinutes(schedule.close);
if (currentMinutes < openMinutes) {
return `${clinicName} ouvre à ${formatTime(schedule.open)} aujourd'hui. Revenez plus tard.`;
}
if (currentMinutes >= closeMinutes) {
if (next) {
return `${clinicName} est fermé pour aujourd'hui. Prochaine ouverture : ${next.dayLabel} à ${next.openTime}.`;
}
return `${clinicName} est fermé pour aujourd'hui.`;
}
return `${clinicName} est actuellement fermé.`;
}
/**
* Format opening hours for display in a weekly schedule.
*/
export function formatWeeklySchedule(
openingHours: OpeningHours | null | undefined
): Array<{ dayKey: DayKey; dayLabel: string; hours: string }> {
return DAY_KEYS.map((dayKey) => {
const schedule = openingHours?.[dayKey];
if (!schedule || schedule.closed) {
return { dayKey, dayLabel: DAY_LABELS_FR[dayKey], hours: "Fermé" };
}
return {
dayKey,
dayLabel: DAY_LABELS_FR[dayKey],
hours: `${formatTime(schedule.open)} ${formatTime(schedule.close)}`,
};
});
}

150
shared/phoneValidation.ts Normal file
View file

@ -0,0 +1,150 @@
/**
* Règles de validation des numéros de téléphone locaux par indicatif pays.
* Le numéro local est celui saisi par l'utilisateur SANS le 0 initial et SANS l'indicatif.
* Exemples : France 06 12 34 56 78 local = "612345678" (9 chiffres)
* Brésil 11 9 1234-5678 local = "11912345678" (11 chiffres)
*/
export type PhoneRule = {
/** Longueur minimale du numéro local (chiffres uniquement) */
minLength: number;
/** Longueur maximale du numéro local (chiffres uniquement) */
maxLength: number;
/** Regex optionnelle pour validation de format */
pattern?: RegExp;
/** Exemple de numéro local valide (pour le placeholder) */
example: string;
/** Hint affiché sous le champ */
hint: string;
};
/** Règles par dialCode (indicatif sans le +) */
export const PHONE_RULES: Record<string, PhoneRule> = {
// ── France & DOM-TOM ──────────────────────────────────────────────────────
"33": { minLength: 9, maxLength: 9, pattern: /^[67]\d{8}$/, example: "612345678", hint: "9 chiffres, commençant par 6 ou 7 (ex : 612 345 678)" },
"590": { minLength: 9, maxLength: 9, example: "690123456", hint: "9 chiffres (Guadeloupe)" },
"596": { minLength: 9, maxLength: 9, example: "696123456", hint: "9 chiffres (Martinique)" },
"262": { minLength: 9, maxLength: 9, example: "692123456", hint: "9 chiffres (Réunion / Mayotte)" },
"594": { minLength: 9, maxLength: 9, example: "694123456", hint: "9 chiffres (Guyane)" },
"687": { minLength: 6, maxLength: 6, example: "123456", hint: "6 chiffres (Nouvelle-Calédonie)" },
"689": { minLength: 8, maxLength: 8, example: "87123456", hint: "8 chiffres (Polynésie française)" },
"508": { minLength: 6, maxLength: 6, example: "551234", hint: "6 chiffres (Saint-Pierre-et-Miquelon)" },
"681": { minLength: 6, maxLength: 6, example: "721234", hint: "6 chiffres (Wallis-et-Futuna)" },
// ── Europe ────────────────────────────────────────────────────────────────
"32": { minLength: 8, maxLength: 9, example: "470123456", hint: "8-9 chiffres (Belgique)" },
"41": { minLength: 9, maxLength: 9, example: "791234567", hint: "9 chiffres (Suisse)" },
"352": { minLength: 6, maxLength: 9, example: "621123456", hint: "6-9 chiffres (Luxembourg)" },
"377": { minLength: 8, maxLength: 8, example: "61234567", hint: "8 chiffres (Monaco)" },
"49": { minLength: 10, maxLength: 11, example: "15123456789", hint: "10-11 chiffres (Allemagne)" },
"34": { minLength: 9, maxLength: 9, example: "612345678", hint: "9 chiffres (Espagne)" },
"39": { minLength: 9, maxLength: 10, example: "3123456789", hint: "9-10 chiffres (Italie)" },
"351": { minLength: 9, maxLength: 9, example: "912345678", hint: "9 chiffres (Portugal)" },
"44": { minLength: 10, maxLength: 10, example: "7911123456", hint: "10 chiffres (Royaume-Uni)" },
"31": { minLength: 9, maxLength: 9, example: "612345678", hint: "9 chiffres (Pays-Bas)" },
"48": { minLength: 9, maxLength: 9, example: "512345678", hint: "9 chiffres (Pologne)" },
"46": { minLength: 7, maxLength: 9, example: "701234567", hint: "7-9 chiffres (Suède)" },
"47": { minLength: 8, maxLength: 8, example: "91234567", hint: "8 chiffres (Norvège)" },
"45": { minLength: 8, maxLength: 8, example: "20123456", hint: "8 chiffres (Danemark)" },
"358": { minLength: 9, maxLength: 10, example: "412345678", hint: "9-10 chiffres (Finlande)" },
"43": { minLength: 10, maxLength: 11, example: "6641234567", hint: "10-11 chiffres (Autriche)" },
"30": { minLength: 10, maxLength: 10, example: "6912345678", hint: "10 chiffres (Grèce)" },
"40": { minLength: 9, maxLength: 9, example: "712345678", hint: "9 chiffres (Roumanie)" },
"36": { minLength: 9, maxLength: 9, example: "201234567", hint: "9 chiffres (Hongrie)" },
"420": { minLength: 9, maxLength: 9, example: "601234567", hint: "9 chiffres (Rép. tchèque)" },
"90": { minLength: 10, maxLength: 10, example: "5321234567", hint: "10 chiffres (Turquie)" },
// ── Afrique ───────────────────────────────────────────────────────────────
"212": { minLength: 9, maxLength: 9, example: "612345678", hint: "9 chiffres (Maroc)" },
"213": { minLength: 9, maxLength: 9, example: "551234567", hint: "9 chiffres (Algérie)" },
"216": { minLength: 8, maxLength: 8, example: "20123456", hint: "8 chiffres (Tunisie)" },
"221": { minLength: 9, maxLength: 9, example: "771234567", hint: "9 chiffres (Sénégal)" },
"225": { minLength: 10, maxLength: 10, example: "0712345678", hint: "10 chiffres (Côte d'Ivoire)" },
"237": { minLength: 9, maxLength: 9, example: "612345678", hint: "9 chiffres (Cameroun)" },
"243": { minLength: 9, maxLength: 9, example: "812345678", hint: "9 chiffres (Congo RDC)" },
"242": { minLength: 9, maxLength: 9, example: "061234567", hint: "9 chiffres (Congo Brazzaville)" },
"261": { minLength: 9, maxLength: 9, example: "321234567", hint: "9 chiffres (Madagascar)" },
"223": { minLength: 8, maxLength: 8, example: "70123456", hint: "8 chiffres (Mali)" },
"226": { minLength: 8, maxLength: 8, example: "70123456", hint: "8 chiffres (Burkina Faso)" },
"227": { minLength: 8, maxLength: 8, example: "90123456", hint: "8 chiffres (Niger)" },
"235": { minLength: 8, maxLength: 8, example: "63123456", hint: "8 chiffres (Tchad)" },
"224": { minLength: 9, maxLength: 9, example: "621234567", hint: "9 chiffres (Guinée)" },
"229": { minLength: 8, maxLength: 8, example: "97123456", hint: "8 chiffres (Bénin)" },
"228": { minLength: 8, maxLength: 8, example: "90123456", hint: "8 chiffres (Togo)" },
"222": { minLength: 8, maxLength: 8, example: "22123456", hint: "8 chiffres (Mauritanie)" },
"241": { minLength: 7, maxLength: 8, example: "6123456", hint: "7-8 chiffres (Gabon)" },
"240": { minLength: 9, maxLength: 9, example: "222123456", hint: "9 chiffres (Guinée équatoriale)" },
"236": { minLength: 8, maxLength: 8, example: "72123456", hint: "8 chiffres (Centrafrique)" },
"269": { minLength: 7, maxLength: 7, example: "3212345", hint: "7 chiffres (Comores)" },
"253": { minLength: 8, maxLength: 8, example: "77123456", hint: "8 chiffres (Djibouti)" },
"230": { minLength: 8, maxLength: 8, example: "52123456", hint: "8 chiffres (Maurice)" },
"248": { minLength: 7, maxLength: 7, example: "2512345", hint: "7 chiffres (Seychelles)" },
"20": { minLength: 10, maxLength: 10, example: "1012345678", hint: "10 chiffres (Égypte)" },
// ── Amériques ─────────────────────────────────────────────────────────────
"1": { minLength: 10, maxLength: 10, example: "2125551234", hint: "10 chiffres (US/Canada, sans le 1 initial)" },
"52": { minLength: 10, maxLength: 10, example: "5512345678", hint: "10 chiffres (Mexique)" },
"55": { minLength: 10, maxLength: 11, example: "11912345678", hint: "10-11 chiffres (Brésil, avec DDD)" },
"54": { minLength: 10, maxLength: 11, example: "91123456789", hint: "10-11 chiffres (Argentine)" },
"57": { minLength: 10, maxLength: 10, example: "3001234567", hint: "10 chiffres (Colombie)" },
"56": { minLength: 9, maxLength: 9, example: "912345678", hint: "9 chiffres (Chili)" },
"51": { minLength: 9, maxLength: 9, example: "912345678", hint: "9 chiffres (Pérou)" },
"58": { minLength: 10, maxLength: 10, example: "4121234567", hint: "10 chiffres (Venezuela)" },
"593": { minLength: 9, maxLength: 9, example: "991234567", hint: "9 chiffres (Équateur)" },
"591": { minLength: 8, maxLength: 8, example: "71234567", hint: "8 chiffres (Bolivie)" },
"595": { minLength: 9, maxLength: 9, example: "981234567", hint: "9 chiffres (Paraguay)" },
"598": { minLength: 8, maxLength: 9, example: "91234567", hint: "8-9 chiffres (Uruguay)" },
"509": { minLength: 8, maxLength: 8, example: "36123456", hint: "8 chiffres (Haïti)" },
// ── Asie & Océanie ────────────────────────────────────────────────────────
"91": { minLength: 10, maxLength: 10, example: "9123456789", hint: "10 chiffres (Inde)" },
"86": { minLength: 11, maxLength: 11, example: "13912345678", hint: "11 chiffres (Chine)" },
"81": { minLength: 10, maxLength: 11, example: "9012345678", hint: "10-11 chiffres (Japon)" },
"61": { minLength: 9, maxLength: 9, example: "412345678", hint: "9 chiffres (Australie)" },
"961": { minLength: 7, maxLength: 8, example: "3123456", hint: "7-8 chiffres (Liban)" },
};
/** Règle par défaut si le dialCode n'est pas dans la liste */
export const DEFAULT_RULE: PhoneRule = {
minLength: 6,
maxLength: 15,
example: "123456789",
hint: "6-15 chiffres",
};
/**
* Valide un numéro local (sans indicatif, sans 0 initial).
* @returns null si valide, message d'erreur sinon
*/
export function validateLocalPhone(dialCode: string, localNumber: string): string | null {
const digits = localNumber.replace(/\D/g, "");
const rule = PHONE_RULES[dialCode] ?? DEFAULT_RULE;
if (digits.length === 0) return "Veuillez saisir votre numéro.";
if (digits.length < rule.minLength) {
return `Numéro trop court (minimum ${rule.minLength} chiffres). ${rule.hint}`;
}
if (digits.length > rule.maxLength) {
return `Numéro trop long (maximum ${rule.maxLength} chiffres). ${rule.hint}`;
}
if (rule.pattern && !rule.pattern.test(digits)) {
return `Format invalide. ${rule.hint}`;
}
return null;
}
/**
* Retourne le placeholder adapté au pays sélectionné.
*/
export function getPhonePlaceholder(dialCode: string): string {
const rule = PHONE_RULES[dialCode] ?? DEFAULT_RULE;
return rule.example;
}
/**
* Retourne le hint d'aide pour un dialCode.
*/
export function getPhoneHint(dialCode: string): string {
const rule = PHONE_RULES[dialCode] ?? DEFAULT_RULE;
return rule.hint;
}

105
shared/types.ts Normal file
View file

@ -0,0 +1,105 @@
// Shared TypeScript types between client and server.
// Type-only — safe to import from both sides without bundling runtime code.
export type UserRole = "user" | "admin";
export type SubscriptionPlan = "trial" | "basic" | "pro";
export type SubscriptionStatus =
| "trialing"
| "active"
| "past_due"
| "canceled"
| "expired";
export type QueueEntryStatus =
| "waiting"
| "called"
| "in_consultation"
| "done"
| "absent"
| "canceled";
export type AnalyticsEventType =
| "patient_joined"
| "patient_called"
| "patient_done"
| "patient_absent"
| "queue_opened"
| "queue_closed";
export interface PublicUser {
id: number;
email: string;
name: string | null;
role: UserRole;
}
export interface ClinicSummary {
id: number;
name: string;
color: string | null;
isQueueOpen: boolean;
avgConsultationMinutes: number | null;
}
export interface PublicQueueEntry {
id: number;
ticketNumber: number;
status: QueueEntryStatus;
position: number;
estimatedWaitMinutes: number | null;
patientName: string | null;
}
// ─── Socket.io event payloads ────────────────────────────────────────────────
export interface QueueUpdatePayload {
clinic: ClinicSummary | unknown;
queue: PublicQueueEntry[];
callingNow: { ticketNumber: number; patientName: string | null } | null;
waitingCount: number;
}
export interface PatientUpdatePayload {
entry: {
id: number;
ticketNumber: number;
status: QueueEntryStatus;
position: number;
estimatedWaitMinutes: number | null;
};
position: number;
estimatedWaitMinutes: number | null;
callingNow: { ticketNumber: number; patientName: string | null } | null;
waitingCount: number;
}
export interface PatientCalledPayload {
ticketNumber: number;
clinicId: number;
}
export interface QrRotatedPayload {
qrToken: string;
qrTokenExpiresAt: string | Date | null;
}
export const SOCKET_EVENTS = {
// Doctor / display rooms
QUEUE_UPDATE: "queue:update",
QR_ROTATED: "qr:rotated",
// Patient room
PATIENT_UPDATE: "patient:update",
PATIENT_CALLED: "patient:called",
PATIENT_APPROACHING: "patient:approaching",
PATIENT_ABSENT: "patient:absent",
PATIENT_DONE: "patient:done",
// Subscription topics
CLINIC_SUBSCRIBE: "clinic:subscribe",
CLINIC_UNSUBSCRIBE: "clinic:unsubscribe",
DISPLAY_SUBSCRIBE: "display:subscribe",
DISPLAY_UNSUBSCRIBE: "display:unsubscribe",
PATIENT_SUBSCRIBE: "patient:subscribe",
PATIENT_UNSUBSCRIBE: "patient:unsubscribe",
} as const;
export type SocketEventName = (typeof SOCKET_EVENTS)[keyof typeof SOCKET_EVENTS];

150
shared/whatsappTemplates.ts Normal file
View file

@ -0,0 +1,150 @@
/**
* WhatsApp message templates with dynamic variable interpolation.
*
* Variables disponibles :
* {{nom}} Nom du patient
* {{ticket}} Numéro de ticket
* {{position}} Position dans la file
* {{attente}} Temps d'attente estimé (en minutes)
* {{cabinet}} Nom du cabinet
*/
// ─── Types ───────────────────────────────────────────────────────────────────
export type TemplateType = "joined" | "soon" | "called" | "withdrawn";
export interface TemplateVariable {
key: string; // e.g. "{{nom}}"
label: string; // e.g. "Nom du patient"
description: string;
}
export interface TemplateContext {
nom: string;
ticket: number | string;
position: number | string;
attente: number | string;
cabinet: string;
}
// ─── Available variables ─────────────────────────────────────────────────────
export const TEMPLATE_VARIABLES: TemplateVariable[] = [
{ key: "{{nom}}", label: "Nom du patient", description: "Prénom ou nom complet du patient" },
{ key: "{{ticket}}", label: "N° de ticket", description: "Numéro de ticket attribué" },
{ key: "{{position}}", label: "Position", description: "Position actuelle dans la file" },
{ key: "{{attente}}", label: "Temps d'attente", description: "Estimation en minutes" },
{ key: "{{cabinet}}", label: "Nom du cabinet", description: "Nom du cabinet médical" },
];
// ─── Default templates (French) ──────────────────────────────────────────────
export const DEFAULT_TEMPLATES: Record<TemplateType, string> = {
joined:
`🏥 *Salle d'attente {{cabinet}}*\n\n` +
`✅ Vous êtes inscrit(e) dans la file d'attente.\n\n` +
`🎫 Numéro de ticket : *{{ticket}}*\n` +
`📍 Position : *{{position}}*\n` +
`⏱️ Attente estimée : *~{{attente}} min*\n\n` +
`Vous recevrez un message quand votre tour approche.\n` +
`_Ne perdez pas votre position restez à proximité._`,
soon:
`🏥 *Salle d'attente {{cabinet}}*\n\n` +
`⚡ *Votre tour approche !*\n\n` +
`🎫 Ticket n° *{{ticket}}*\n` +
`⏳ Environ *{{attente}} minutes* restantes\n\n` +
`Merci de vous rendre en salle d'attente maintenant.`,
called:
`🏥 *Salle d'attente {{cabinet}}*\n\n` +
`🔔 *C'est votre tour !*\n\n` +
`🎫 Ticket n° *{{ticket}}* Veuillez vous présenter au cabinet.\n\n` +
`_Si vous n'êtes pas disponible, vous serez marqué(e) absent(e) après 5 minutes._`,
withdrawn:
`🏥 *Salle d'attente {{cabinet}}*\n\n` +
`✅ Votre désistement a bien été enregistré.\n\n` +
`🎫 Ticket n° *{{ticket}}* annulé.\n\n` +
`Merci et à bientôt !`,
};
// ─── Template labels ─────────────────────────────────────────────────────────
export const TEMPLATE_LABELS: Record<TemplateType, { title: string; description: string; icon: string }> = {
joined: {
title: "Inscription",
description: "Envoyé quand le patient rejoint la file d'attente",
icon: "✅",
},
soon: {
title: "Tour approche",
description: "Envoyé quand le patient est bientôt appelé (position ≤ 2)",
icon: "⚡",
},
called: {
title: "Appel",
description: "Envoyé quand c'est le tour du patient",
icon: "🔔",
},
withdrawn: {
title: "Désistement",
description: "Envoyé quand le patient se désiste",
icon: "👋",
},
};
// ─── Interpolation engine ────────────────────────────────────────────────────
/**
* Replace all {{variable}} placeholders in a template string with actual values.
*/
export function interpolateTemplate(template: string, context: TemplateContext): string {
return template
.replace(/\{\{nom\}\}/g, String(context.nom))
.replace(/\{\{ticket\}\}/g, String(context.ticket))
.replace(/\{\{position\}\}/g, String(context.position))
.replace(/\{\{attente\}\}/g, String(context.attente))
.replace(/\{\{cabinet\}\}/g, String(context.cabinet));
}
/**
* Get the effective template for a given type: custom if set, otherwise default.
*/
export function getEffectiveTemplate(
type: TemplateType,
customTemplates?: Partial<Record<TemplateType, string | null>>
): string {
const custom = customTemplates?.[type];
if (custom && custom.trim().length > 0) return custom;
return DEFAULT_TEMPLATES[type];
}
/**
* Build a final message by resolving the template and interpolating variables.
*/
export function buildMessage(
type: TemplateType,
context: TemplateContext,
customTemplates?: Partial<Record<TemplateType, string | null>>
): string {
const template = getEffectiveTemplate(type, customTemplates);
return interpolateTemplate(template, context);
}
// ─── Preview with sample data ────────────────────────────────────────────────
export const SAMPLE_CONTEXT: TemplateContext = {
nom: "Marie Dupont",
ticket: 42,
position: 3,
attente: 12,
cabinet: "Cabinet Dr Martin",
};
/**
* Generate a preview of a template with sample data.
*/
export function previewTemplate(template: string): string {
return interpolateTemplate(template, SAMPLE_CONTEXT);
}