PPHub
Multi-company property preservation SaaS: office web portal + offline-first Android app. Plan and specs live in docs/.
Layout
| Path | What |
|---|---|
apps/web | Next.js office portal and API (/api/uploads/* for the field app) |
apps/field | Expo (SDK 58) Android app for contractors and crews |
packages/shared | Zod schemas and rules both apps use (photo metadata, QA flags, upload contract, order status rules) |
packages/db | Drizzle 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→/onboardingcreates 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.
- Airplane mode → take 50 photos → kill the app → reopen → all 50 listed as Waiting.
- Turn network on → all reach "Sent to office" without touching anything.
- Kill the app mid-upload → reopen → the interrupted photo resends; no duplicates in R2.
- 500 photos across 2 orders on mobile data; note time and battery used.
- Weak network (one bar / throttled hotspot) → failures retry with growing delay, none stuck.
- Leave photos waiting, lock the phone for 30+ min with network on → background task sends them.
- Mock-location app on → photo is flagged
mock_location.
Record results in docs/ before starting phase 2.