Skip to content
Balwan Bamniya, home
Menu
Back to work

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.

#TypeScript#Hono#Cloudflare Workers#D1 (SQLite)#R2#React 19#Drizzle#Playwright
The ClientFlow inquiry wizard on step one of five, asking what kind of project the prospect is planning.

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

  1. Inquiry
  2. Qualification
  3. Discovery
  4. Proposal
  5. Acceptance
  6. Client created
  7. Project kickoff
  8. Milestone review
  9. Revisions
  10. Delivery
  11. 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

  1. Browsers

    • ProspectNo account, proposal token
    • ClientMagic-link session
    • OwnerMagic-link session

    Static SPAs on Workers

  2. Frontends

    • start appReact 19 · inquiry + proposals
    • client appReact 19 · portal + console

    Typed Hono RPC · exact-origin CORS · Origin-checked writes

  3. API Worker

    • MiddlewareRequest context, security headers, logging
    • ServicesLeads, proposals, projects, files, notifications
    • Rate limits11 Workers bindings, fail closed

    Bindings

  4. Platform

    • D119 tables, 18 migrations
    • R2Private, API-served files
    • ResendTransactional email
    • TurnstileBot checks on public forms
Three Workers on custom domains. Frontends can only import the API's contract module — dependency-cruiser fails CI if they reach into implementation code.

Key decisions

ClientFlow sign-in page asking for a provisioned email address to receive a one-time sign-in link.
Client portal sign-in: provisioned emails only, one-time links, no passwords.

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.

Owner console Leads page with pipeline totals, search filters and a lead whose status is Client created.
Project workspace with an approved milestone, uploaded files, a client feedback thread and the activity log, ready to deliver.

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
Test cases counted in the repository; bundle and query figures from the Phase 5 hardening report, verified with EXPLAIN QUERY PLAN.
  • 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.

What I’d change