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
19
shared/_core/errors.ts
Normal file
19
shared/_core/errors.ts
Normal 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
5
shared/const.ts
Normal 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
104
shared/countryCodes.ts
Normal 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
189
shared/openingHours.ts
Normal 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
150
shared/phoneValidation.ts
Normal 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
105
shared/types.ts
Normal 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
150
shared/whatsappTemplates.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue