Spécifications fonctionnelles
Spec fonctionnelle complète par module
Chaque module est une unité fonctionnelle autonome. Cette spec sert de contrat entre le produit et le développement.
Modules V1
| # | Module | Priorité | Description |
|---|---|---|---|
| 1 | Auth & Comptes | P0 | Gestion des comptes admin et établissement |
| 2 | Établissements | P0 | CRUD des établissements |
| 3 | WC (Toilets) | P0 | CRUD des WC par établissement |
| 4 | QR Codes | P0 | Génération, personnalisation, impression |
| 5 | Signalement | P0 | Page publique de signalement |
| 6 | Notifications | P0 | WhatsApp + Email + In-app (temps réel) |
| 7 | Dashboard établissement | P0 | Vue signalements, historique, analytics |
| 8 | Dashboard admin | P1 | Gestion globale des établissements |
| 9 | Anti-spam | P0 | Protection contre l'abus |
| 10 | Temps réel (SSE) | P0 | Push events vers le dashboard |
| 11 | Job queue | P0 | Notifications async, agrégation |
| 12 | Analytics | P2 | Statistiques avancées |
Module 1 — Auth & Comptes
Rôles
| Rôle | Scope | Création |
|---|---|---|
super_admin | Accès total, gestion des établissements | Manuel (seed) |
establishment_admin | Gestion de son établissement | Par le super_admin |
establishment_member | Réception alertes, changement statut signalements | Par l'establishment_admin |
Fonctionnalités
- Connexion magic link — L'admin Wcare envoie un lien par email. Clic → session créée.
- Connexion email/password — Alternative pour les connexions régulières.
- Session — Token de session via Better Auth, cookies httpOnly secure.
- Première connexion — Option de définir un mot de passe.
- Magic link expiré — Page "Lien expiré" avec bouton "Renvoyer un nouveau lien".
Isolation multi-tenant
Critique — Chaque requête authentifiée doit être scoped par
establishmentId.
// Middleware tRPC qui injecte le scope établissement
const scopedProcedure = protectedProcedure.use(({ ctx, next }) => {
const establishmentId = ctx.session.user.establishmentId;
if (!establishmentId && ctx.session.user.role !== "super_admin") {
throw new TRPCError({ code: "FORBIDDEN" });
}
return next({ ctx: { ...ctx, establishmentId } });
});Toutes les queries dashboard passent par scopedProcedure qui garantit l'isolation.
Module 2 — Établissements
Champs
| Champ | Type | Requis | Description |
|---|---|---|---|
name | string | ✅ | Nom de l'établissement |
type | enum | ✅ | hotel, restaurant, company, agency, other |
address | string | ✅ | Adresse postale |
city | string | ✅ | Ville |
postalCode | string | ✅ | Code postal |
country | string | ✅ | Pays (ISO 3166-1) |
phone | string | ❌ | Téléphone principal |
email | string | ✅ | Email de contact |
logo | string | ❌ | URL du logo (pour QR code) |
whatsappNumber | string | ❌ | Numéro WhatsApp pour notifications |
timezone | string | ✅ | Timezone IANA (ex: Europe/Paris) |
isActive | boolean | ✅ | Actif/désactivé |
Opérations
- Créer — Admin Wcare uniquement
- Modifier — Admin Wcare + establishment_admin
- Désactiver — Admin Wcare (soft delete). Les QR codes affichent "Service inactif"
- Lister — Admin Wcare (tous) / Établissement (le sien)
Edge case : désactivation
Quand un établissement est désactivé :
- Les QR codes redirigent vers une page "Ce service n'est plus disponible"
- Les signalements en cours restent consultables mais aucun nouveau n'est accepté
- Les notifications sont stoppées
- Le compte reste accessible en lecture seule
Module 3 — WC (Toilets)
Champs
| Champ | Type | Requis | Description |
|---|---|---|---|
name | string | ✅ | Identifiant interne ("WC Lobby", "WC 1er étage") |
location | string | ❌ | Localisation détaillée |
floor | string | ❌ | Étage |
establishmentId | FK | ✅ | Lien vers l'établissement |
isActive | boolean | ✅ | Actif/inactif |
Opérations
- Créer — establishment_admin (génère automatiquement le QR code)
- Modifier — establishment_admin
- Désactiver — establishment_admin (désactive aussi le QR code)
- Supprimer — establishment_admin (soft delete, archive les signalements)
Module 4 — QR Codes
Champs
| Champ | Type | Requis | Description |
|---|---|---|---|
toiletId | FK | ✅ | Lien vers le WC |
token | string | ✅ | Token unique rotatif (UUID v4) |
tokenExpiresAt | datetime | ✅ | Date d'expiration du token |
previousToken | string | ❌ | Ancien token (redirect graceful 24h) |
customization | jsonb | ❌ | Logo, couleurs, style |
url | string | ✅ | URL complète générée |
Personnalisation
{
"logo": "https://..../logo.png",
"foregroundColor": "#000000",
"backgroundColor": "#FFFFFF",
"style": "rounded",
"errorCorrection": "H"
}Export
- SVG — Aperçu web, scalable
- PNG — Téléchargement rapide
- PDF — Impression avec crop marks, format A6/A5/A4 au choix
- Bulk PDF — Tous les QR codes d'un établissement en un seul PDF
Opérations
- Générer — Auto à la création d'un WC
- Personnaliser — establishment_admin (preview live avant sauvegarde)
- Régénérer le token — Manuellement ou par cron (anti-spam)
- Télécharger — Export PNG/SVG/PDF
Module 5 — Signalement
Page publique (/report/{toiletId}/{token})
Aucune authentification requise. L'utilisateur arrive via le QR code.
Champs du signalement
| Champ | Type | Requis | Description |
|---|---|---|---|
toiletId | FK | ✅ | Auto (depuis l'URL) |
category | enum | ✅ | Catégorie du problème |
predefinedIssueId | FK | ❌ | Problème prédéfini sélectionné |
freeText | string | ❌ | Texte libre (max 200 chars) |
status | enum | ✅ | new, seen, in_progress, resolved, ignored |
fingerprint | string | ✅ | Hash du device (anti-spam) |
ipHash | string | ✅ | Hash de l'IP (anti-spam) |
idempotencyKey | string | ✅ | fingerprint + toiletId + timestamp(30s) |
UX de signalement
- L'utilisateur scanne → page avec le nom de l'établissement et du WC
- Grille de problèmes prédéfinis (icônes + texte, tap unique)
- Option "Autre" → champ texte libre (200 chars max)
- Bouton "Envoyer" → confirmation "Merci !"
- Pas de retour possible — une fois envoyé, c'est fini
Le parcours complet doit prendre moins de 10 secondes.
Edge cases signalement
| Cas | Comportement |
|---|---|
| Token expiré | Page "QR code expiré — scannez à nouveau sur place" |
previousToken (< 24h) | Redirect automatique vers le nouveau token |
| Établissement désactivé | Page "Ce service n'est plus disponible" |
| WC désactivé | Page "Ce WC n'est plus actif" |
| Doublon réseau (retry) | Rejet silencieux via idempotency key (même réponse 200) |
| Rate limit atteint | Page "Trop de signalements — réessayez plus tard" |
| Grace period token | Le POST accepte un token valide au moment du GET initial (1h) |
Module 6 — Notifications
Canaux
| Canal | Déclencheur | Contenu |
|---|---|---|
| In-app (SSE) | Chaque signalement | Badge + toast temps réel |
| Chaque signalement | Template HTML via Resend | |
| Agrégé (fenêtre de 5 min) | Message template Meta |
Configuration par utilisateur
Chaque membre de l'établissement configure ses propres préférences :
| Paramètre | Type | Défaut |
|---|---|---|
whatsappEnabled | boolean | false |
emailEnabled | boolean | true |
inAppEnabled | boolean | true |
whatsappNumber | string | — |
La config est par user, pas par établissement. Le chef veut le WhatsApp, l'agent de ménage veut juste l'in-app.
Agrégation WhatsApp
Si 3+ signalements pour le même WC en 5 minutes → un seul message groupé.
Fallback chain
WhatsApp → si échec (3 retries) → Email de fallback
Email → si échec → Log + alerte admin
In-app → toujours (pas de dépendance externe)Module 7 — Dashboard établissement
Pages et fonctionnalités
| Page | Fonctionnalités clés |
|---|---|
/dashboard | Compteur actifs, dernier signalement, graphique 7j, hot spots |
/dashboard/reports | Liste paginée (cursor), filtres, bulk actions, badge "X nouveaux" SSE |
/dashboard/reports/[id] | Détail + timeline audit trail + changement de statut |
/dashboard/toilets | CRUD des WC |
/dashboard/qr-codes | Preview live personnalisation, export PDF/PNG/SVG, bulk export |
/dashboard/settings | Paramètres établissement, notifications, équipe |
/dashboard/team | Gestion membres + leurs préférences de notification |
Vue d'ensemble (landing dashboard)
┌──────────────────────────────────────────────────┐
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ ACTIFS │ │ AUJOURD' │ │ RÉSOLUS │ │
│ │ 12 │ │ HUI │ │ AUJOURD │ │
│ │ │ │ 8 │ │ 5 │ │
│ └─────────┘ └─────────┘ └─────────┘ │
│ │
│ ┌─ Dernier signalement ────────────────────┐ │
│ │ 🚽 WC Lobby — Toilettes sales │ │
│ │ ⏱️ Il y a 3 minutes │ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌─ 7 derniers jours ───────────────────────┐ │
│ │ ▓▓▓▓▓▓▓▓░░░░ Lun │ │
│ │ ▓▓▓▓░░░░░░░░ Mar │ │
│ │ ... │ │
│ └───────────────────────────────────────────┘ │
│ │
│ ┌─ Hot spots ──────────────────────────────┐ │
│ │ 1. WC 1er étage — 15 signalements/sem │ │
│ │ 2. WC Lobby — 8 signalements/sem │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘Liste des signalements
- Pagination : Cursor-based (pas offset — performant sur gros volumes)
- Filtres : WC, statut, catégorie, plage de dates
- Tri : Par date (défaut), par statut, par WC
- Bulk actions : Sélectionner plusieurs → "Marquer comme résolu" / "Ignorer"
- Temps réel : Badge "X nouveaux signalements" via SSE, clic pour charger
- Recherche : Par texte libre du signalement
Détail signalement + audit trail
Chaque changement de statut est tracé :
NOUVEAU 16 avr 14:30 Signalement reçu
↓
VU 16 avr 14:32 Vu par Jean Dupont
↓
EN_COURS 16 avr 14:45 Passé en cours par Marie Martin
↓
RÉSOLU 16 avr 15:10 Résolu par Marie MartinChangement de statut concurrent
Si 2 membres modifient le même signalement en même temps :
// Optimistic locking via updatedAt
const updated = await db
.update(report)
.set({ status: newStatus, updatedAt: new Date() })
.where(
and(
eq(report.id, reportId),
eq(report.updatedAt, expectedUpdatedAt), // Optimistic lock
),
)
.returning();
if (updated.length === 0) {
throw new TRPCError({
code: "CONFLICT",
message: "Ce signalement a été modifié. Veuillez rafraîchir.",
});
}Mobile-first
Le gérant est sur son téléphone, pas devant un PC.
- Layout responsive, touch-friendly (h-14 min pour les targets)
- Swipe pour changer de statut sur la liste
- Bottom navigation sur mobile
- Notifications push (PWA) pour le badge
Module 8 — Dashboard admin Wcare
Pages
| Page | Description |
|---|---|
/admin | Vue globale : établissements actifs, signalements totaux, volume 30j |
/admin/establishments | CRUD des établissements |
/admin/establishments/[id] | Détail + WC + signalements + envoyer magic link |
/admin/establishments/new | Création + envoi automatique du magic link |
Module 9 — Anti-spam
Voir ADR-05 et Opérations anti-spam
5 couches : token rotatif, rate limit IP, fingerprint device, cooldown 10 min, détection patterns.
Module 10 — Temps réel (SSE)
Architecture
Server (Elysia) ──SSE──→ Dashboard (Next.js)Events
| Event | Payload | Déclencheur |
|---|---|---|
report:new | { reportId, toiletId, category, createdAt } | Nouveau signalement |
report:updated | { reportId, status, updatedBy } | Changement de statut |
notification:new | { count } | Nouvelle notification |
Implémentation
// Server — Elysia SSE endpoint
app.get("/events/:establishmentId", ({ params, set }) => {
set.headers["content-type"] = "text/event-stream";
set.headers["cache-control"] = "no-cache";
set.headers["connection"] = "keep-alive";
// Stream d'events scoped par establishmentId
return eventBus.subscribe(params.establishmentId);
});// Client — Hook React
function useReportEvents(establishmentId: string) {
useEffect(() => {
const source = new EventSource(`/events/${establishmentId}`);
source.addEventListener("report:new", (e) => {
const data = JSON.parse(e.data);
queryClient.invalidateQueries({ queryKey: ["reports"] });
toast.info(`Nouveau signalement — ${data.category}`);
});
return () => source.close();
}, [establishmentId]);
}Module 11 — Job queue (notifications async)
Pourquoi
- L'API WhatsApp peut être lente (1-3s) → le signalement ne doit pas attendre
- L'agrégation WhatsApp nécessite un timer/scheduler
- Les retries en cas d'échec doivent être gérés proprement
- L'email peut aussi échouer → retry
Architecture
POST /report → INSERT report → ENQUEUE notification job
↓
Job Runner (async)
├── In-app: INSERT + SSE push
├── Email: Resend API (retry x3)
└── WhatsApp: check aggregation window
├── window open → send (retry x3)
└── window closed → skip (agrégé)Implémentation
Deux options à évaluer :
- BullMQ + Redis — Robuste, battle-tested, mais dépendance Redis
- DB-based queue — Table
joben PostgreSQL, poll toutes les secondes. Simple, pas de dépendance
En V1, la DB-based queue suffit. Migration vers BullMQ si le volume l'exige.
Module 12 — Analytics (V2)
- Signalements par WC, par jour/semaine/mois
- Temps moyen de résolution
- Catégories les plus fréquentes
- Comparaison entre WC
- Heures de pointe
- Export CSV
- Taux de résolution vs ignoration