PPHub

Multi-company property preservation SaaS: office web portal + offline-first Android app. Plan and specs live in docs/.

Layout

PathWhat
apps/webNext.js office portal and API (/api/uploads/* for the field app)
apps/fieldExpo (SDK 58) Android app for contractors and crews
packages/sharedZod schemas and rules both apps use (photo metadata, QA flags, upload contract, order status rules)
packages/dbDrizzle schema, migrations, row-level security, withCompany()

Setup

Requires Node 22+ and pnpm 10 (npm i -g pnpm@10).

pnpm install
cp apps/web/.env.example apps/web/.env.local     # fill in R2 keys and BETTER_AUTH_SECRET
cp apps/field/.env.example apps/field/.env       # your PC's LAN IP (or the live site URL)
cp .env.example .env                             # local database URLs
docker compose up -d                             # Postgres 17 on localhost:54329
pnpm --filter @pphub/db db:migrate

Database and tenant isolation

Every tenant table has company_id and a Postgres row-level security policy. The app connects as pp_app_login (member of pp_app), so it can only see rows of the company set for the current transaction:

await withCompany(db, companyId, (tx) => tx.select().from(schema.workOrders));

Migrations run as the owner role, which bypasses RLS. Never use DATABASE_URL in app code. packages/db/src/isolation.test.ts proves the isolation against the real database.

Login

Better Auth handles email + password sign-in (apps/web/src/lib/auth.ts). Login, signup, invites and the company switcher connect as pp_auth_login (member of pp_auth) through DATABASE_AUTH_URL. That role can read sessions, password hashes and invites across companies but no business data; pp_app cannot read sessions, accounts or verification tokens at all. packages/db/src/auth-roles.test.ts proves both.

  • /sign-up → /onboarding creates a company with you as owner.
  • Team → "Create invite link" makes a 7-day, single-use link (only its SHA-256 is stored).
  • The active company lives on the session and is re-checked under row-level security on every request.

The live site uses Neon (Vercel Marketplace, pphub-db, US East). Vercel holds DATABASE_APP_URL, DATABASE_AUTH_URL, BETTER_AUTH_SECRET and BETTER_AUTH_URL; the Neon-provided DATABASE_URL is the owner connection and is used only for migrations.

After adding a migration, update the live database before deploying:

pnpm --filter @pphub/db db:migrate:prod   # pulls prod settings to a temp file, migrates, deletes the file
npx vercel deploy --prod --yes

Fresh databases get pp_auth_login from packages/db/docker-init. On an existing local database, run once after migrating:

docker compose exec db psql -U pphub -c "CREATE ROLE pp_auth_login LOGIN PASSWORD 'pphub_auth' IN ROLE pp_auth"

Run

pnpm --filter @pphub/web dev --hostname 0.0.0.0      # API on :3000, reachable from the phone
pnpm --filter @pphub/field start                       # Expo dev server
pnpm typecheck && pnpm test

The field app uses native modules (camera, SQLite, background tasks), so test on a development build, not Expo Go:

cd apps/field
npx eas-cli@latest build --profile development --platform android   # installable APK

Phase 1 offline prototype: pass criteria

Run on a real low-end Android phone. Goal: no photo is ever lost.

  1. Airplane mode → take 50 photos → kill the app → reopen → all 50 listed as Waiting.
  2. Turn network on → all reach "Sent to office" without touching anything.
  3. Kill the app mid-upload → reopen → the interrupted photo resends; no duplicates in R2.
  4. 500 photos across 2 orders on mobile data; note time and battery used.
  5. Weak network (one bar / throttled hotspot) → failures retry with growing delay, none stuck.
  6. Leave photos waiting, lock the phone for 30+ min with network on → background task sends them.
  7. Mock-location app on → photo is flagged mock_location.

Record results in docs/ before starting phase 2.

README.md