ian.hwang / portfolio
available · full-stack roles
case · 01
~/cases/projectstobid
← back to all work
case study · construction saas · 2025—present

ProjectsToBid — a multi-party construction bidding platform with 11 GCs and subs onboarded for the first live bid cycles.

A SaaS platform replacing email + spreadsheet bid workflows for general contractors and subs. Founder, lead engineer, and on-call. End-to-end TypeScript on Node + Postgres, hardened with RBAC, 446 tests across 42 Jest suites, and zero-downtime migrations in CI.

role
founder · lead eng
timeline
2025 — present
stage
11 cos onboarded · pre-bid
stack
ts · node · postgres · next.js
team
scaleli team
01 / overview

The problem

Mid-size general contractors run bid cycles in email and Excel. A single industrial project can pull in 15+ trades, thousands of pages of drawings, and a half-dozen revisions — all coordinated by one PM with a spreadsheet of phone numbers. Bids get missed. Latest drawings get lost. Audit trails don't exist.

What we built

ProjectsToBid is the system of record for a bid cycle. GCs invite subs, publish drawings + scope, set due dates, and get back structured bids they can compare. Subs see only what they need. Admins see everything, including a full audit log.

app.projectstobid.com / dashboard
ProjectsToBid GC dashboard with project, bid, and RFP overview
02 / outcomes

Production outcomes.

11cos
onboarded
GCs & subs · live accounts
Feb2026
first project posted
industrial renovation listing
~3.0→ <1.0s
project detail endpoint
p95 · post-optimization
446tests
42 jest suites
CI green · ts strict
03 / architecture

System design.

Boring on purpose. Next.js front end and route handlers, Postgres for the system of record, Supabase Storage for plans & documents, and Resend for transactional email. RBAC enforced at the API boundary, then re-checked at the row level for sensitive data.

client · web
Next.js 16 · TS · React
api
Next.js route handlers · TS
notifications
Resend · email workflows
↓ ↓ ↓
auth
sessions · RBAC · audit
primary store
Postgres 15 · Prisma
documents
Supabase Storage · signed URLs
ci
GitHub Actions · ESLint · Jest 446
deploy
zero-downtime · feature flags
04 / rbac

Roles & access.

Every actor in a bid cycle sees a different slice of the same data. GCs see the full project, subs see only their invitation + their bid, and admin staff see everything across tenants. Permissions are declarative, versioned, and unit-tested.

# permissions/v3.policy.ts role gc.lead { project: read, write, archive bid: read.all, compare, award doc: read, upload, version member: invite, remove } role sub.estimator { project: read.invited bid: read.own, write.own, submit doc: read.scoped, attach.own member: read.self } role admin.platform { *: read, audit tenant.*: impersonate (logged) } # 42 jest suites · 446 tests · 100% policy coverage PASS __tests__/policy/gc-lead.spec.ts (89 tests) PASS __tests__/policy/sub-estimator.spec.ts (74 tests) PASS __tests__/policy/cross-tenant.spec.ts (51 tests) ✓ all policy assertions ok
05 / performance

Cutting 3.0s to under a second.

The project detail endpoint was the hottest path on the platform — hit on every navigation. p95 was sitting around 3 seconds, which felt awful and was getting worse as bid cycles grew.

Three changes, in order of impact: collapse a per-bid N+1 into a single joined query with jsonb_agg, paginate the document list (most cycles have 200+ revisions), and cache the role-scoped permission envelope per request instead of recomputing it for each related entity.

before
3.04 s
after
0.94 s
delta
−69% p95
# pre-fix · supabase logs · GET /api/projects/:id slow-query duration=3041ms query="SELECT bids ... 17 round trips" slow-query duration=2987ms query="SELECT documents ... no LIMIT" slow-query duration=3210ms query="SELECT permissions ... per-entity check" # post-fix ok duration= 912ms query="SELECT projects + jsonb_agg(bids)" ok duration= 103ms query="SELECT documents LIMIT 50 OFFSET ?" ok duration= 18ms query="SELECT cached_permission_envelope"
06 / delivery

Delivery practices.

Every change goes through the same pipeline: type-check, lint, 446 Jest tests, policy snapshot tests, and a migration dry-run on a copy of production data. Migrations are written in two phases (expand then contract) so we can deploy without locking tables or downtime, even on the bid_documents table that holds the bulk of cycle history.

> github actions · main · run #1247 ✓ install (28s) ✓ typecheck — strict, 0 errors (12s) ✓ lint — eslint, 0 warnings (9s) ✓ jest — 446 tests, 42 suites (1m 04s) ✓ policy snapshot — 0 drift (3s) ✓ migration dry-run — 4 steps, expand-only (18s) ✓ build — next.js + api (52s) ✓ deploy — zero-downtime, feature flag: bid_compare_v2=on (47s) total · 3m 53s · all green
2025
Build & v0 single-tenant alpha
Postgres schema · auth · RBAC v1 · storage · CI
2026 · 02
First project posted to the platform
Industrial renovation · listed for bid · invitations opened
2026 · 04
11 GCs and subs onboarded · live bidding about to begin
Pre-bid prep · 446 tests · CI hardening
07 / reflection

What I'd do differently.

  • Start with row-level security in Postgres from day one — moved to it later and re-tested every policy.
  • Design async notification boundaries earlier. Email and document workflows become product-critical faster than expected.
  • Treat the audit log as a product feature earlier — GCs ended up using it as a sales tool with subs.
  • Invest in seed data fixtures sooner; once we had realistic 50-bid fixtures, the perf bugs were obvious.
next case · 02

Kauboi BBQ — restaurant digital platform