My App

ADR-06: Staff checks (rondes de propreté)

Comment authentifier un passage de nettoyage par le staff sans login complet

Contexte

Wcare permet aux clients de signaler des incidents sanitaires via QR code. Le besoin produit secondaire — soulevé par Maxime — est de tracer aussi les passages de nettoyage du personnel, pour deux raisons :

  1. Conformité hygiène — les établissements (resto, hôtel) tiennent souvent un registre papier "WC nettoyés à 14h32 par X". Wcare peut le remplacer.
  2. Double valeur produit — gestion des incidents (réactif) + suivi des rondes (proactif). Argument de vente fort, justifie l'abonnement.

Le débat a tourné autour du niveau de friction acceptable :

  • Maxime : zéro friction, sinon le staff n'utilisera pas. Bouton sur la page publique, sans login.
  • Issam : sans authentification, n'importe qui peut polluer la donnée.

Décision

Staff nominatif avec PIN par membre + device pairing 30 jours.

Modèle

  • Le gérant ajoute des membres du staff dans son dashboard (prénom + code PIN à 6 chiffres, généré aléatoirement, affiché une fois)
  • Aucun email, mot de passe, ou compte Better Auth pour le staff — juste un PIN par identité
  • Sur la page publique de signalement (/report/:toiletId/:token), une carte visuellement distincte "Personnel — passage de nettoyage"
  • Premier clic depuis ce device → modal "Qui es-tu ?" → liste des prénoms du staff de l'établissement → tap "Anne" → "Code Anne" (6 chiffres) → validation
  • Cookie HMAC signé staff_pair (30 jours) émis sur ce device, lié à (staff_member_id, establishment_id, fingerprint_hash)
  • Clics suivants depuis ce device : tap sur la carte → confirmation 1-tap, pas de PIN à re-saisir

Sécurité

  • PIN hashé en DB (bcrypt ou argon2)
  • Rate limit sur la route /report/staff-pair :
    • 3 tentatives / 15 min par IP+fingerprint
    • Lockout 1h après 5 échecs cumulés
  • Cookie staff signé HMAC avec BETTER_AUTH_SECRET — non-forgeable côté client
  • Rotation : le gérant peut révoquer un membre (supprime tous ses pairs) ou rotater son PIN (bumpe pin_version, invalide les cookies existants)
  • Détection d'anomalies (déjà présente dans l'anti-spam) : un device qui fait 50 staffCheck/jour sur le même WC → flag dans le dashboard gérant

Modèle de données

// packages/db/src/schema/staff.ts
export const staffMember = pgTable("staff_member", {
	id: text("id").primaryKey(),
	establishmentId: text("establishment_id")
		.notNull()
		.references(() => establishment.id, { onDelete: "cascade" }),
	name: text("name").notNull(),
	pinHash: text("pin_hash").notNull(),
	pinVersion: integer("pin_version").notNull().default(1),
	createdAt: timestamp("created_at").defaultNow().notNull(),
	lastUsedAt: timestamp("last_used_at"),
	revokedAt: timestamp("revoked_at"),
});

export const staffCheck = pgTable("staff_check", {
	id: text("id").primaryKey(),
	staffMemberId: text("staff_member_id")
		.notNull()
		.references(() => staffMember.id, { onDelete: "restrict" }),
	toiletId: text("toilet_id")
		.notNull()
		.references(() => toilet.id, { onDelete: "cascade" }),
	deviceHash: text("device_hash").notNull(),
	createdAt: timestamp("created_at").defaultNow().notNull(),
});

Routes

MéthodeRouteAuthRôle
POST/report/staff-pairpublic + rate-limitÉchange PIN → cookie pair
POST/report/staff-checkcookie staff_pairEnregistre un passage
GET/report/staff-list/:establishmentIdpublic (token QR)Liste prénoms staff
POST/dashboard/staff (tRPC)scopedProcedureCréer un membre
DELETE/dashboard/staff/:id (tRPC)scopedProcedureRévoquer un membre
POST/dashboard/staff/:id/rotate-pin (tRPC)scopedProcedureRotater PIN
GET/dashboard/staff-checks (tRPC)scopedProcedureRegistre paginé

Dashboard gérant

  • Page "Équipe nettoyage" : CRUD des membres, génération PIN, révocation
  • Page "Registre de propreté" : table filtrable par jour / WC / membre, export CSV pour audits hygiène

Alternatives écartées

AlternativeRaison du rejet
Login Better Auth complet pour le staffTrop lourd ; le staff restera loggé indéfiniment, donc la sécurité réelle = "le téléphone n'est jamais perdu". Sécurité-théâtre.
PIN unique partagé par établissementPas de traçabilité individuelle → inutile pour la conformité hygiène (le besoin n°1 de Maxime).
Pas de PIN, juste un bouton "j'ai nettoyé"N'importe qui (client mécontent, ado qui s'ennuie) peut polluer le registre. Issam a raison sur ce point.
Reconnaissance device sans pair initialPas de moyen de différencier deux téléphones sur le même réseau. Faut un secret partagé minimal (le PIN).
WebAuthn / passkeyExcellent en théorie, mais friction d'enrôlement trop élevée pour ce use case (~2-5 min par membre). À reconsidérer si la base utilisateurs grossit beaucoup.

Conséquences

Positif

  • UX fluide : 30s de pair une fois, puis 1-tap pour toujours sur ce device
  • Traçabilité nominative → registre HACCP-compatible exportable
  • Aucun compte Better Auth créé pour le staff → pas de surcharge sur la table user
  • Pas de dépendance email/SMS pour les membres

Négatif / risques

  • Le PIN à 6 chiffres reste brute-forçable en théorie — atténué par rate-limit
    • lockout, mais à monitorer
  • Si un membre du staff perd son téléphone, son cookie pair reste valide jusqu'à expiration (30j) ou révocation manuelle par le gérant. Ajouter une action "révoquer tous les devices de ce membre" dans le dashboard
  • Le gérant doit gérer le cycle de vie des PINs (onboarding/offboarding du staff). Acceptable pour un B2B avec turnover modéré

Phasing

  • Phase 1 (MVP) : tout le périmètre ci-dessus en une itération (~2-3 jours). Le surcoût de coder N membres vs 1 PIN unique est négligeable, et refactor plus tard coûterait plus cher.
  • Phase 2 (scale) : si une chaîne hôtelière demande la traçabilité multi-établissements (un membre du staff qui passe sur plusieurs sites), évoluer vers un compte Better Auth léger pour le staff, en gardant le PIN comme méthode rapide.

On this page