StaffClock
Live · staffclock.appSuite RH multi-vertical sur Cloudflare edge — née comme appli de pointage pour un restaurant (pointage rapide, salaires estimés, répartition des pourboires CB exacte au centime), pivotée en SaaS multi-tenant couvrant 8 métiers (resto, santé, boulangerie, commerce, sécurité, BTP, aide à domicile + mode équipe). Moteur droit du travail français (congés payés, heures sup, alertes de conformité), tableaux de bord KPI, répartition des pourboires en 3 modes, notifications push temps réel (web push + centre in-app), email de paie planifié (Cron). Isolation stricte par establishment_id + TenantContext, scope privé par salarié, audit complet, RGPD France. Worker unique : React 19, Hono 4, D1 + Drizzle, R2. Déployée en production.
Pourquoi j'ai construit ça
Un restaurant comptait ses heures sur une appli Android lente — le sélecteur d'heure rond, impossible à utiliser vite en plein coup de feu. Les pourboires CB étaient répartis à la main, les salaires estimés au doigt mouillé. J'ai construit StaffClock pour ce besoin précis : pointage gros boutons, répartition salle automatique au centime près, salaires estimés, clôtures formelles. Une fois l'outil solide pour un resto, je l'ai pivoté en vrai SaaS multi-tenant — super-admin → établissements → admins par invitation — puis l'ai élargi en suite RH multi-vertical : aujourd'hui 8 métiers (resto, santé, boulangerie, commerce, sécurité, BTP, aide à domicile + mode équipe), chacun un preset CCN sur des moteurs génériques. Par-dessus le pointage : moteur droit du travail français (congés payés, heures sup, alertes de conformité), tableaux de bord KPI, email de paie planifié par Cron. Le tout sur Cloudflare edge (Workers + D1 + R2), déployé en production sur staffclock.app.
Décisions techniques structurantes
Le pivot mono-resto → SaaS multi-tenant a ajouté une colonne establishment_id sur chaque table et un TenantContext porté par la session, jamais par l'URL. Le slug par établissement (`/<slug>/…`) est purement cosmétique : la connexion est globale et le serveur ne lui fait jamais confiance pour décider du tenant. Super-admin avec impersonation (« Entrer » dans un établissement → bandeau « Quitter »). L'isolation a été validée par un audit adverse de 8 agents cherchant une fuite cross-établissement : zéro fuite trouvée.
Trois modes de répartition selon l'établissement : au prorata des points, chacun pour soi, ou par service. Quel que soit le mode, la somme des parts doit retomber exactement sur le total saisi — pas d'arrondi qui crée ou détruit un centime. Implémenté via la méthode du plus grand reste : on calcule les parts entières en centimes, puis on distribue les centimes restants aux plus grands restes. Vues Jour / Semaine / Mois, et aucun montant visible en consultation lecture seule.
Chaque établissement a une colonne JSON `settings` (toggles A–G + identité) pilotée depuis une carte Réglages super-admin. Règle de fer : aucune option n'est jamais décidée côté client — un toggle masqué dans l'UI est aussi refusé par le serveur. Pourboires espèces en opt-in privé (table séparée, visibles salarié + super-admin uniquement, jamais admin normal), double rôle admin/salarié avec bascule « vision salarié ».
Bug prod non-évident au lancement : `/setup` renvoyait un faux 409 sur base vide. Cause : le hash de mot de passe tournait à 210k itérations PBKDF2 alors que Cloudflare Workers plafonne `deriveBits` à 100k — l'appel throwait, masqué par un catch. Ramené à 100k. Le piège : ça passait tous les tests Node en local et ne cassait qu'en production sur l'edge. Leçon réutilisée sur mes autres projets Workers.
La V7 rend la plateforme temps réel sans dépendance native : la crypto VAPID (ES256 + aes128gcm, RFC 8291) est implémentée en Web Crypto pur pour tourner dans un Worker. Le fan-out est best-effort par construction — le service de notification ne throw jamais, les envois partent en `waitUntil` après la réponse, et un 404/410 supprime l'abonnement périmé : un crash push ne casse jamais l'action pourboire ou congé sous-jacente. En parallèle, le passage à un 2ᵉ domaine (staffclock.app) a imposé des liens d'email origin-aware : l'origine de la requête n'est utilisée que si elle figure dans une allowlist, sinon repli sur le domaine par défaut — impossible de forger un lien de phishing via un header.
Le challenge non-trivial
Trois mécanismes pouvaient fuiter des données d'un resto à l'autre : (1) le slug dans l'URL (`/<slug>/…`) — résolu en le rendant non-autoritaire, le tenant vient toujours de la session serveur ; (2) l'impersonation super-admin (« Entrer » dans un établissement) — un bug post-deploy faisait que « Entrer » ne rafraîchissait pas l'auth avant de naviguer, le routeur renvoyait sur /superadmin ; corrigé en refresh-avant-navigate ; (3) chaque requête DB — chaque repository filtre sur establishment_id issu du TenantContext, jamais d'un paramètre client. Le tout vérifié par un audit adverse de 8 agents en parallèle, chacun cherchant un chemin de fuite : aucune fuite.
Leçon retenue
Un outil mono-client bien conçu se pivote en SaaS multi-tenant — puis en suite multi-vertical — sans réécriture, à condition de deux choses. D'abord ne JAMAIS faire confiance au client pour décider du tenant : le slug est cosmétique, le tenant vient de la session, et chaque repository filtre sur establishment_id en dur. Ensuite garder des moteurs génériques et ne dériver chaque métier que par un preset CCN — c'est ce qui m'a permis de passer d'un resto à 8 verticaux sans forker le code. Deuxième leçon technique, réutilisée partout : la crypto qui passe en local peut casser sur l'edge — Cloudflare Workers plafonne PBKDF2 à 100k itérations, et un throw masqué par un catch se transforme en bug fantôme qui ne sort qu'en prod. Tester sur la cible, pas seulement en Node.
Fonctionnalités
Resto, santé, boulangerie, commerce, sécurité, BTP, aide à domicile + mode équipe — chacun un preset CCN posé sur des moteurs génériques, pas un fork de code
Web push (VAPID / RFC 8291, crypto implémentée en Web Crypto pur côté Worker) + centre de notifs in-app : salariés et managers prévenus dès qu'un pourboire est saisi ou qu'une demande de congé attend. Fan-out best-effort — un échec d'envoi ne casse jamais l'action métier, abonnements périmés nettoyés
Congés payés (acquisition + décompte ouvrés/ouvrables), heures sup (paliers 25/50, surchargeables par CCN), alertes de conformité (repos quotidien, durée max)
Demande → décision (approbation / refus), validateurs désignés par établissement avec repli sûr sur admins/managers, onglet « Congés » unifié, et compteur de solde horaire signé jour/semaine — un jour de CP seul reste à l'équilibre, pas −8 h
Dashboard par établissement (heures, masse salariale, pourboires, présence) + graphiques super-admin avec cash empilé, le tout en agrégation SQL pure (aucune table dérivée)
Au prorata des points / chacun pour soi / par service, somme exacte au centime via la méthode du plus grand reste, récap jour/semaine/mois
Environnement de démo dédié (sous-domaine + D1 + R2 propres), seed déterministe sur 2 mois, sans cron ni paie — pour explorer la suite sans toucher aux données réelles
Cron mensuel qui génère et envoie le récap de paie en PDF (pdf-lib) via Resend
Super-admin global → établissements → admins (invitation email), tenant porté par la session (establishment_id + TenantContext), jamais par le slug de l’URL, scope privé par salarié dans les verticaux privés
Journal de toutes les actions avec l'identité (email/nom) du compte, hash de mot de passe PBKDF2-HMAC-SHA-256 à 100k itérations, signalement d'erreurs côté salarié