Case study
ClientFlow
A production client-operations platform that takes a freelance engagement from first inquiry to signed-off delivery, built on Cloudflare Workers with atomic writes, passwordless auth, and private file storage.

The problem
A freelance engagement passes through about a dozen hand-offs: inquiry, qualification, discovery, proposal, acceptance, kickoff, milestone reviews, revisions, delivery. Most solo practices run that on email threads, shared documents and memory. Prospects get inconsistent answers, clients can’t see progress without asking, and the freelancer has no single record of what was agreed.
I needed this for my own practice, so I built it to the standard I’d hold a client’s product to. It’s also my most complete piece of production evidence: three live apps on my own domain, deployed and operated by me.
The engagement lifecycle ClientFlow enforces
- Inquiry
- Qualification
- Discovery
- Proposal
- Acceptance
- Client created
- Project kickoff
- Milestone review
- Revisions
- Delivery
- Completion
What I built
I owned all of it — scope, data model, UI, API, infrastructure and deployment runbook:
start.balwanbamniya.xyz— a no-account inquiry wizard and a token-secured proposal review page where prospects accept or request changes.client.balwanbamniya.xyz— a client portal (projects, milestones, comments, files) and an owner console (lead pipeline, proposals, delivery operations).api.balwanbamniya.xyz— a Hono API on Cloudflare Workers backed by D1, private R2, Resend email and Turnstile bot protection.
System architecture
Browsers
- ProspectNo account, proposal token
- ClientMagic-link session
- OwnerMagic-link session
Static SPAs on Workers
Frontends
- start appReact 19 · inquiry + proposals
- client appReact 19 · portal + console
Typed Hono RPC · exact-origin CORS · Origin-checked writes
API Worker
- MiddlewareRequest context, security headers, logging
- ServicesLeads, proposals, projects, files, notifications
- Rate limits11 Workers bindings, fail closed
Bindings
Platform
- D119 tables, 18 migrations
- R2Private, API-served files
- ResendTransactional email
- TurnstileBot checks on public forms
Key decisions

Hard problems
1. Atomic, idempotent writes without transactions
Accepting a proposal has to create a client, user, membership, project, milestones, activity events and a notification — all or nothing — and a double-click or two browser tabs must not create two projects. D1 has no BEGIN/COMMIT.
The solution is a durable idempotency claim. The first statement inserts a proposal_conversions row only if the proposal is still SENT, unexpired and its token hash matches. Every following statement in the same D1.batch() is gated on EXISTS (SELECT 1 FROM proposal_conversions …). The batch rolls back on any failure, and a losing contender recovers the winner’s committed result:
const claim = input.database.prepare(
`INSERT INTO proposal_conversions
(proposal_id, client_id, project_id, client_created, created_at)
SELECT ?, ?, ?, ?, ? FROM proposals
WHERE id = ? AND workspace_id = ? AND public_token_hash = ?
AND status = 'SENT' AND valid_until > ?
AND EXISTS (SELECT 1 FROM inquiries
WHERE id = ? AND workspace_id = ? AND status = 'PROPOSAL_SENT')`,
);
// …client, user, project, milestones, activity — each gated on the claim row
try {
const result = await input.database.batch(statements);
if (result[0]?.meta.changes !== 1 || result.find((entry) => !entry.success))
proposalUnavailable();
} catch (error) {
// A concurrent contender may have completed after our initial read. Its
// committed result is the correct idempotent response for this capability.
const recovered = await conversionFor(input.database, row.id);
if (recovered) return conversionResult(recovered);
throw error;
}
apps/api/src/services/proposal.service.ts
2. State machines that can’t be raced
Milestones move PLANNED → IN_PROGRESS → READY_FOR_REVIEW → APPROVED | CHANGES_REQUESTED, and a project can only be delivered once every milestone is approved. Each transition writes a fresh last_transition_id; the change-request comment and the activity event are inserted only if the milestone still carries that ID. If anyone else moved it first, meta.changes is 0 and the API returns a typed conflict instead of a half-applied state. The pattern is documented in migrations/README.md and pinned by dedicated Worker tests.
3. Private file uploads that never orphan
Uploads stream straight to a private R2 bucket. A files row is created as PENDING_UPLOAD, the stream is size-checked against the declared Content-Length, the user’s access is re-authorized after the stream ends, and only then does a batch flip the row to AVAILABLE and log the activity. Any failure removes both the row and the object. Downloads are only served through authorized API routes, never public URLs.
4. Defense in depth for a public API
Every business write requires the exact browser Origin for its app — public endpoints accept only the start app, everything else only the client app. On top of that, 11 Workers rate-limit bindings check both user and IP keys, and they fail closed: a missing binding rejects the request rather than silently allowing it.


Outcome
ClientFlow is live on three subdomains and ready for real engagements. Rather than usage numbers it doesn’t have yet, these are the verifiable engineering results:
- Vitest cases across API, apps and packages
- 200+
- Playwright journeys, with axe accessibility checks
- 16
- Client entry bundle after route-level splitting (161.7 → 124.1 KiB gzip)
- −23%
- D1 statements per paginated list, at any page size
- 1–2
- Worker tests apply all 18 migrations to an empty D1 and exercise concurrent writes, cross-workspace access attempts and proposal-token constraints.
- CI blocks merges on lint, dependency boundaries, type checks, tests and build.
- Deploys follow a written runbook: dry-run, migrate, deploy in dependency order, smoke test, with a D1 Time Travel bookmark for recovery.