A staged engineering plan to build a professional-grade HR + Operations platform for a land surveying company — capable of competing with Jobber, ServiceTitan, BambooHR, and Gusto, built specifically for survey workflows. Includes a concrete sprint-by-sprint breakdown of Phase 1.
Before a line of code, we agree on what "professional and competitive" means in practice. These become the non-negotiable acceptance bar for every screen — the difference between an internal tool and a product that rivals ServiceTitan.
Crews work on phones in trucks with poor signal; the office works on large screens. The same app must excel at both, and tolerate going offline.
Every piece of data lives in exactly one place and is referenced everywhere else. No duplicated job info, ever.
A user only ever sees what their role permits — enforced on the server, reflected in the UI. Pay and financials are gated.
Loading (skeletons), empty (helpful guidance), error (recoverable), populated. Plus WCAG 2.1 AA accessibility and sub-second navigation.
A typed, containerized, Python-native stack — chosen for speed of development today and the Phase 5 AI work tomorrow.
Typed boundary: the frontend client is generated from FastAPI's OpenAPI schema, so the two halves never drift.
| Concern | Decision | Why |
|---|---|---|
| API style | REST + OpenAPI | Auto-generated schema & typed client; simpler than GraphQL for this team |
| Type safety | openapi-typescript / orval | Frontend and backend can't drift; compile-time safety across the wire |
| ORM + migrations | SQLAlchemy 2.0 + Alembic | Explicit, version-controlled schema changes |
| Server state (FE) | TanStack Query | Caching, optimistic updates, background refetch |
| Forms | react-hook-form + zod | Performant, type-safe, schemas shared with validation |
| Auth | Supabase issues JWT; FastAPI verifies | Offload password/session security; keep authorization in our API |
| Authorization | Central RBAC dependency in FastAPI | One enforcement point, fully testable |
| Background jobs | Arq (async) + Redis | Notifications, payroll exports, AI processing, scheduled tasks |
| Files | Signed URLs to Storage/S3 | Never proxy large files through the API — scalable & cheap |
| Search | Postgres FTS → Meilisearch later | Don't over-build; upgrade when Phase 5 "intelligent search" demands it |
Jobs are the central entity; everything else hangs off identity, operations, and the ledger of time/assets/money.
users, roles, user_rolesemployees, pay_records (restricted)certifications → expiry alertspto_balances, pto_requestsclientsjobs (central), job_assignmentsdocuments, job_notesfield_reports, checkliststimecards, equipment, vehiclesinvoices, job_costsaudit_log (immutable)notifications, announcementsaudit_log · money in integer cents · all timestamps UTC,
displayed in the user's timezone.
Each phase ships real, deployable value before the next begins. We start with the highest-pain problems.
Repo, environments, CI/CD, design tokens, auth/RBAC skeleton, observability. No user features — the spine everything hangs on.
Replace PDF email distribution; one home for job info. Auth, jobs, documents, notes, search. Detailed below in §05.
Mobile PWA, offline-tolerant photo & report capture, GPS, custom safety checklists, push notifications.
Clock in/out, timecards, PTO, My Pay portal, equipment/vehicle inventory, certification expiry alerts, payroll export.
Executive dashboard, visual dispatch board, job costing, utilization analytics, historical search, client portal.
AI PDF extraction, semantic search, voice-to-report, document summaries, scheduling assist — built on the latest Claude models via the Python backend.
Four two-week sprints take us from an empty repo to a usable MVP in users' hands. Each sprint is independently deployable to staging and ends in a demoable milestone. Tasks are grouped by track so backend, frontend, and platform work can run in parallel.
Goal: a deployable skeleton — repo, environments, CI, design tokens — so every later sprint is fast.
apps/web, apps/api, packages/pydantic-settings, structured loggingusers, roles, user_roles)tailwind.config.tsdocker compose up runs the full stack locally; a stub login page is deployed to staging; CI is green on every MR.Goal: secure login, roles, and a role-guarded shell — the security spine before any business data exists.
require_permission() dependency guarding endpointsemployees model + read endpoints; audit_log write infrastructuredef require_permission(permission: Permission):
# FastAPI dependency: resolves the JWT, loads the user's
# roles, and rejects the request if the grant is missing.
async def _guard(user: User = Depends(current_user)) -> User:
if permission not in user.permissions:
raise HTTPException(status_code=403,
detail="forbidden")
return user
return _guard
@router.get("/jobs", dependencies=[Depends(
require_permission(Permission.JOBS_READ))])
async def list_jobs(): ...
<Can permission="…"> gate to hide unauthorized UIGoal: create, assign, and track jobs through their status lifecycle — the heart of the platform.
clients, jobs, job_assignments models + migrationaudit_log# Assigned → In Progress → Waiting → Completed
ALLOWED: dict[Status, set[Status]] = {
Status.ASSIGNED: {Status.IN_PROGRESS},
Status.IN_PROGRESS: {Status.WAITING, Status.COMPLETED},
Status.WAITING: {Status.IN_PROGRESS},
Status.COMPLETED: set(),
}
def transition(job: Job, target: Status) -> Job:
if target not in ALLOWED[job.status]:
raise InvalidTransition(job.status, target)
job.status = target
return job
Goal: kill PDF-by-email, capture field knowledge, and harden the MVP for a real pilot.
documents model tied to jobs (PDF / CAD / photo / permit) with version historyjob_notes (immutable, timestamped, attachments) + announcementsHow the architecture is structured to stay maintainable as it grows from MVP to a five-module platform.
Each layer has one reason to change: SQLAlchemy models own persistence, Pydantic schemas own the API contract, service modules own business rules (e.g. jobs/status.py only governs transitions), and React components own presentation. A pay-rate change never touches job code.
New features extend rather than modify. Adding Phase 3's PTO module means new routers, models, and permissions — the auth and RBAC core is untouched. New roles are added to a registry, not by editing every endpoint.
Storage is accessed through one interface, so Supabase Storage and Amazon S3 are interchangeable behind a common abstraction — the VPS-to-AWS migration swaps an implementation without changing callers. The same holds for the notification channel (email / push).
The OpenAPI surface is split per domain (jobs, documents, HR, payroll) and the generated TS client mirrors it, so the field-crew app imports only the job/document endpoints it uses — never the payroll surface. Role-scoped schemas keep clients lean.
FastAPI's dependency injection means high-level handlers depend on abstractions (a Repository, an AuthProvider, a FileStore), not concretes. Tests inject fakes; production injects Postgres/Supabase/S3 — the business logic doesn't know the difference.
You'll hold pay rates, possibly SSNs/EINs for tax forms, and PLS license data. Treat it accordingly — built in continuously, not as a phase.
The server is the source of truth; UI gating is convenience only. Consider Postgres Row-Level Security for defense in depth on the most sensitive tables.
TLS in transit, encryption at rest; tokenize SSN/EIN. Secrets are env-based, rotated, scoped per environment, never committed.
Immutable audit log on sensitive access; automated nightly offsite backups with tested restores. A backup you've never restored is a guess.
WCAG 2.1 AA with axe checks in CI; code-splitting, query caching, DB indexing, paginate everything. Lighthouse mobile ≥ 90.
Strongly prefer linking out to a payroll provider over storing W-2s/1099s yourself; if you must store them, isolate and encrypt.
Automated CVE scanning in CI; Renovate/Dependabot for updates; an internal security review of auth + RBAC + file access before go-live.