← Back to projects

StaffClock

Live · staffclock.app

Multi-vertical HR suite on the Cloudflare edge — born as a clock-in app for one restaurant (fast clock-in, estimated salaries, card-tip distribution exact to the cent), pivoted into a multi-tenant SaaS now covering 8 industries (restaurant, healthcare, bakery, retail, security, construction, home care + team mode). French labour-law engine (paid leave, overtime, compliance alerts), KPI dashboards, tip distribution in 3 modes, real-time push notifications (web push + in-app center), scheduled payroll email (Cron). Strict isolation by establishment_id + TenantContext, per-employee private scope, full audit log, France GDPR. Single Worker: React 19, Hono 4, D1 + Drizzle, R2. Deployed to production.

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

Why I built it

A restaurant was tracking hours on a slow Android app — the round time picker, impossible to use quickly during a rush. Card tips were split by hand, salaries estimated by gut feel. I built StaffClock for that exact need: big-button clock-in, automatic front-of-house tip distribution to the cent, estimated salaries, formal closures. Once the tool was solid for one restaurant, I pivoted it into a real multi-tenant SaaS — super-admin → establishments → admins by invitation — then widened it into a multi-vertical HR suite: today 8 industries (restaurant, healthcare, bakery, retail, security, construction, home care + team mode), each a CCN preset over generic engines. On top of clock-in: a French labour-law engine (paid leave, overtime, compliance alerts), KPI dashboards, a Cron-scheduled payroll email. All on Cloudflare edge (Workers + D1 + R2), deployed to production at staffclock.app.

Structural technical decisions

01
Multi-tenancy retrofit with strict isolation + adversarial audit

The single-restaurant → multi-tenant SaaS pivot added an establishment_id column on every table and a TenantContext carried by the session, never the URL. The per-establishment slug (`/<slug>/…`) is purely cosmetic: login is global and the server never trusts it to decide the tenant. Super-admin with impersonation ("Enter" an establishment → "Leave" banner). Isolation was validated by an adversarial audit of 8 agents hunting for a cross-establishment leak: zero leaks found.

02
Tip distribution exact to the cent, in 3 modes

Three distribution modes per establishment: per-point pro-rata, each-for-self, or per-service. Whatever the mode, the sum of the shares must land exactly on the entered total — no rounding that creates or destroys a cent. Implemented via the largest-remainder method: compute integer shares in cents, then distribute leftover cents to the largest remainders. Day / Week / Month views, and no amount visible in read-only consultation.

03
Per-establishment feature toggles, all server-gated

Each establishment has a JSON `settings` column (toggles A–G + identity) driven from a super-admin Settings card. Iron rule: no option is ever decided client-side — a toggle hidden in the UI is also refused by the server. Cash tips are opt-in and private (separate table, visible to employee + super-admin only, never a normal admin), with a dual admin/employee role and an "employee view" toggle.

04
Hardened crypto under the Cloudflare Workers CPU cap

Non-obvious launch-day production bug: `/setup` returned a false 409 on an empty database. Cause: the password hash ran at 210k PBKDF2 iterations while Cloudflare Workers caps `deriveBits` at 100k — the call threw, masked by a catch. Brought down to 100k. The trap: it passed every Node test locally and only broke in production on the edge. Lesson reused on my other Workers projects.

05
Real-time on the edge: best-effort web push + origin-aware emails

V7 makes the platform real-time with no native dependency: the VAPID crypto (ES256 + aes128gcm, RFC 8291) is implemented in pure Web Crypto to run inside a Worker. Fan-out is best-effort by construction — the notification service never throws, sends go out via `waitUntil` after the response, and a 404/410 deletes the stale subscription: a push crash never breaks the underlying tip or leave action. Alongside it, moving to a 2nd domain (staffclock.app) required origin-aware email links: the request origin is trusted only if it's on an allowlist, otherwise it falls back to the default domain — no forging a phishing link via a header.

The non-trivial challenge

Guaranteeing isolation between establishments when the super-admin can impersonate and the URL carries a slug

Three mechanisms could leak data between restaurants: (1) the slug in the URL (`/<slug>/…`) — solved by making it non-authoritative, the tenant always comes from the server session; (2) super-admin impersonation ("Enter" an establishment) — a post-deploy bug meant "Enter" didn't refresh auth before navigating, so the router bounced back to /superadmin; fixed with refresh-before-navigate; (3) every DB query — each repository filters on establishment_id from the TenantContext, never a client parameter. All verified by an adversarial audit of 8 parallel agents, each hunting for a leak path: none found.

Lesson learned

A well-designed single-client tool pivots into a multi-tenant SaaS — then a multi-vertical suite — without a rewrite, given two things. First, NEVER trust the client to decide the tenant: the slug is cosmetic, the tenant comes from the session, and every repository hard-codes its filter on establishment_id. Second, keep the engines generic and derive each industry only through a CCN preset — that's what let me go from one restaurant to 8 verticals without forking the code. Second technical lesson, reused everywhere: crypto that passes locally can break on the edge — Cloudflare Workers caps PBKDF2 at 100k iterations, and a throw masked by a catch becomes a phantom bug that only shows in prod. Test on the target, not just in Node.

Features

🏢
8 business verticals

Restaurant, healthcare, bakery, retail, security, construction, home care + team mode — each a CCN preset over generic engines, not a code fork

🔔
Real-time push notifications

Web push (VAPID / RFC 8291, crypto implemented in pure Web Crypto inside the Worker) + in-app notification center: employees and managers notified the moment a tip is entered or a leave request is waiting. Best-effort fan-out — a failed send never breaks the business action, stale subscriptions are cleaned up

⚖️
French labour-law engine

Paid leave (acquisition + ouvrés/ouvrables decount), overtime (25/50 tiers, CCN-overridable), compliance alerts (daily rest, max duration)

📋
Leave: workflow + designated validators

Request → decision (approve / refuse), per-establishment designated validators with a safe fallback to admins/managers, unified "Leave" tab, and a signed daily/weekly hour-balance counter — a lone paid-leave day stays at balance, not −8h

📊
KPI dashboards

Per-establishment dashboard (hours, payroll mass, tips, attendance) + super-admin charts with stacked cash, all from pure SQL aggregation (no derived tables)

💶
Tips in 3 modes

Per-point pro-rata / each-for-self / per-service, sum exact to the cent via the largest-remainder method, day/week/month recap

🧪
Isolated online demo

Dedicated demo environment (its own subdomain + D1 + R2), deterministic 2-month seed, no cron or payroll — explore the suite without touching real data

📄
Scheduled payroll email

Monthly Cron that generates and sends the payroll recap as a PDF (pdf-lib) via Resend

🔐
Isolated multi-tenant

Global super-admin → establishments → admins (email invite), tenant carried by the session (establishment_id + TenantContext), never the URL slug, per-employee private scope in private verticals

🛡️
Security & audit

Journal of every action with the identity (email/name) of the account, PBKDF2-HMAC-SHA-256 password hash at 100k iterations, employee-side error reporting

Tech Stack

Runtime
Cloudflare Workers (edge)Cronnodejs_compat
Backend
Hono 4TypeScript strictZod 4241 endpoints, 26 routers
Data
Cloudflare D1 (SQLite)Drizzle ORM26 tables30 migrations
Media / Email
R2 (photos)pdf-lib (payroll PDF)Resend (invites + payroll + demo)
Frontend
React 19React Router 7Tailwind CSS 4Vite 8
Tests / CI
Vitest 4 (2,725 tests)@testing-library/reactjsdom