← Retour aux projets

StaffClock

Live · staffclock.app

Suite 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.

FR EN
~56K Lignes TS
2 725 Tests
26 Tables DB
30 Migrations D1
241 Endpoints API
641 Commits

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

01
Multi-tenancy retrofit avec isolation stricte + audit adverse

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.

02
Répartition des pourboires exacte au centime, en 3 modes

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.

03
Feature-toggles par établissement, tous gated côté serveur

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é ».

04
Crypto durcie sous le plafond CPU de Cloudflare Workers

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.

05
Temps réel sur l’edge : web push best-effort + emails origin-aware

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

Garantir l'isolation entre établissements quand le super-admin peut impersonner et que l'URL porte un slug

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

🏢
8 verticaux métier

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

🔔
Notifications push temps réel

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

⚖️
Moteur droit du travail français

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)

📋
Congés : workflow + validateurs désignés

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

📊
Tableaux de bord KPI

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)

💶
Pourboires en 3 modes

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

🧪
Démo en ligne isolée

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

📄
Email de paie planifié

Cron mensuel qui génère et envoie le récap de paie en PDF (pdf-lib) via Resend

🔐
Multi-tenant isolé

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

🛡️
Sécurité & audit

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é

Stack technique

Runtime
Cloudflare Workers (edge)Cronnodejs_compat
Backend
Hono 4TypeScript strictZod 4241 endpoints, 26 routers
Données
Cloudflare D1 (SQLite)Drizzle ORM26 tables30 migrations
Médias / Email
R2 (photos)pdf-lib (paie PDF)Resend (invitations + paie + démo)
Frontend
React 19React Router 7Tailwind CSS 4Vite 8
Tests / CI
Vitest 4 (2 725 tests)@testing-library/reactjsdom