435bcb981f
The original table marked Phase 1 as "next" but Phases 1-5a have all shipped. Replaces the single table with three sections: Shipped (now includes RBAC, rooms/floors/accounts/expenses, the sub-property hierarchy, and the fnm/pnpm tooling switch — none of which were on the original roadmap), 5b-pending (reports, cross-app APIs to budget/repair), and a Phase 6 quality-of-life backlog covering maintenance-reminder cron, OIDC handlers, self-service password reset, permission inheritance, bulk CSV property import, audit log viewer, and project↔property linking. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
buildfor_life_ops
Internal B4L ops tool combining project management and property / asset management.
Siblings: buildfor_life_budget (expense tracking) and buildfor_life_repair (device inventory). Same stack, same look and feel, same auth model.
Stack
- SvelteKit 5 (
adapter-node) + Svelte 5 runes + TypeScript - Tailwind v4 with
@theme inlinetokens, dark-mode bootstrap identical to sibling apps - PostgreSQL 16+ via Drizzle ORM + Zod validation
- Argon2id sessions (
@node-rs/argon2+@oslojs/crypto) + optional OIDC - EasyMDE for markdown (wiki + decision rationale), Sharp for image thumbnails
- Storage abstracted behind a
StorageAdapterinterface:LocalDiskStoragetoday, S3 drop-in later
First-time setup
1. Prerequisites
- Node 24 pinned via
.node-version(installed withfnm) - pnpm 9 as the package manager (declared in
package.json#packageManager) - PostgreSQL 16+ running locally or reachable by URL
gitwith SSH access togitssh.b4l.co.th
2. Install fnm + pnpm
# fnm (one-time, per machine)
curl -fsSL https://fnm.vercel.app/install | bash # macOS / Linux
winget install Schniz.fnm # Windows (or: scoop install fnm)
# pnpm (one-time, via Corepack which ships with Node)
corepack enable
corepack prepare pnpm@9.15.0 --activate
3. Activate the pinned Node version and install deps
fnm use --install-if-missing
pnpm install
fnm use reads .node-version and drops you onto the correct Node. Add eval "$(fnm env --use-on-cd)" to your shell rc (bash/zsh) or the PowerShell equivalent so this happens automatically on cd into the repo.
4. Configure environment
cp .env.example .env
Edit .env:
DATABASE_URL— point at your Postgres instance and target DBSESSION_SECRET— at least 32 chars of random hex (openssl rand -hex 32)STORAGE_SIGNING_SECRET— another 32+ chars of random hex (independent fromSESSION_SECRET)PUBLIC_BASE_URL— the URL the app is served on (http://localhost:5173in dev)STORAGE_BACKEND—local(the only adapter wired today)STORAGE_LOCAL_ROOT— where uploaded blobs live on disk (default./storage)- OIDC block — leave
OIDC_ENABLED=falseunless you're wiring SSO
5. Create the database
createdb buildfor_life_ops # or use your tool of choice
6. Apply migrations
pnpm run db:migrate
7. Bootstrap the first admin user
pnpm run create-user -- \
--email you@b4l.co.th \
--password 'a-long-password' \
--name 'Your Name' \
--company 'B4L' \
--role admin
8. Run the dev server
pnpm dev
Open http://localhost:5173 and log in.
pnpm scripts
| Command | What it does |
|---|---|
pnpm dev |
Start the Vite dev server with HMR |
pnpm build |
Production build (outputs to build/) |
pnpm preview |
Preview the production build locally |
pnpm check |
Typecheck: svelte-kit sync then svelte-check --threshold warning |
pnpm validate |
check + build — use this as a pre-commit smoke test |
pnpm db:generate |
Diff the Drizzle schema against the last snapshot and emit a new migration under drizzle/ |
pnpm db:migrate |
Apply pending migrations against $DATABASE_URL |
pnpm db:push |
Skip migration files and sync the schema directly — dev only |
pnpm db:studio |
Open Drizzle Studio (web UI at localhost for inspecting data) |
pnpm db:seed |
Seed the system catalog of asset types |
pnpm run create-user -- --email ... --password ... --name ... [--company ...] [--role ...] |
Create or update a user; optionally attach them to a company with a role |
Project layout
src/
app.html dark-mode bootstrap (matches siblings exactly)
app.css Tailwind v4 + @theme tokens
app.d.ts App.Locals with user/company/sessionId
hooks.server.ts session validation + cookie refresh
lib/
components/ Sidebar, TopBar, ThemeToggle
server/
auth/ password (Argon2id), session, types
db/
client.ts pg.Pool + drizzle
schema/ _shared, tenancy (rest lands in Phase 1+)
storage/ StorageAdapter interface + LocalDiskStorage + factory
env.ts Zod-validated process.env
utils/ email normalization etc.
routes/
+layout.svelte root: pulls in app.css
+error.svelte error page
(app)/ authed group — Sidebar + TopBar shell
+layout.server.ts auto-redirects to /login when unauthed
+layout.svelte sidebar + top bar + content column
+page.svelte dashboard (placeholder cards)
(auth)/ centered login shell
login/ login form + action
logout/+server.ts destroys session, clears cookie
api/files/+server.ts verifies HMAC signature, streams local file
scripts/
create-user.ts bootstrap a user + optional company link
drizzle/
0000_init.sql generated initial migration
meta/ drizzle-kit snapshot/journal
README.md migration conventions
storage/ runtime blob root (gitignored except .gitkeep)
static/ public static assets (drop favicon here)
Auth model
POST /loginvalidates email + password, creates a session in Postgres, sets theops_sessioncookie with an opaque CSPRNG token- The cookie value is hashed (SHA-256) before DB lookup so a leaked session row does not leak the cookie
- Sessions live 30 days; if a request arrives in the last 15 days the hook extends the expiry (sliding renewal)
GET/POST /logoutinvalidates the session row and clears the cookiesrc/routes/(app)/+layout.server.tsis the gate — any route under(app)redirects to/login?next=...whenlocals.useris null- Companies: a user joins companies via
company_userswith a role (admin|manager|user|viewer); the sidebar shows the user's companies and the active one is stored on the session row (sessions.active_company_id)
Storage model
- Uploads are never addressed by filesystem path.
documents.storage_keyis an opaque string (e.g.2026/04/<uuid>__filename.pdf) that only the activeStorageAdapterknows how to resolve LocalDiskStoragewrites underSTORAGE_LOCAL_ROOTand produces HMAC-signed short-lived URLs served by/api/files?...with a timing-safe signature check- Swapping to S3 later: add
S3StorageimplementingStorageAdapter, setSTORAGE_BACKEND=s3, schema does not change
Roadmap
Shipped
| Phase | Scope |
|---|---|
| 0 | Scaffold: stack wiring + auth + layout shell + storage interface + tenancy schema + git remote |
| 1 | Properties + Assets with typed custom fields + mobility history + asset logs + document upload per scope |
| 2 | Checklist templates + maintenance schedules (time + usage) + maintenance events + usage readings |
| 3 | Projects + WorkPackages → Tasks → Subtasks + structured decision events (title, body, alternatives, cost_impact, approved_by, tags) |
| 4 | Wiki (global + project + property) with EasyMDE + revisions + FTS |
| 5a | QR label generation, in-app + email + Matrix notifications, S3 storage adapter |
| — | RBAC: user/company admin, role middleware, last-admin guards |
| — | Property structure: rooms + floors, utility account/meter records, expenses with CSV import + electricity/water chart |
| — | Sub-property hierarchy: parent_id self-FK, recursive-CTE descendant rollups, "Include sub-properties" toggle on Expenses/Assets/Maintenance/Todos tabs, depth-first nested list view, depth-cap warning at 5 levels |
| — | Tooling: switched to fnm + pnpm, Gitea CI deploy workflow with public-HTTPS clone, full DEPLOYMENT.md |
5b — pending Phase 5 items
- Reports — cross-cutting outputs (monthly building roll-up, annual asset summary). Per-domain CSV exports already exist; this is the aggregated/scheduled layer
- Cross-app APIs — wire
buildfor_life_opsto siblingsbuildfor_life_budgetandbuildfor_life_repairso decisions can link to budget line items and repair tickets
6 — quality-of-life backlog
Things that came up after the original roadmap was written. Not prioritised — pull whichever the next stakeholder ask depends on.
- Maintenance reminders — cron-style trigger that fires
maintenance_event_recorded/task_assignednotifications whennext_due_atcrosses a threshold. The notification fan-out (app + email + Matrix) is already there; only the scheduler is missing - OIDC SSO — env vars + schema are wired (
OIDC_ENABLED,OIDC_ISSUER, etc.), but the route handlers were never written - Self-service password reset — currently admin-initiated only via
pnpm run create-user - Permission inheritance for sub-properties — RBAC is per-company today; "manager of Building A" does not imply "manager of Apt 3B"
- Bulk property CSV import — "create N apartments under this building" in one upload, mirroring the existing expenses CSV importer
- "Move to parent" quick action — UI sugar over the existing edit form for re-parenting
- Audit log viewer —
audit_actionenum + audit infrastructure exists, but no UI to browse it - Project ↔ property linking — they're disjoint today; many real workflows want "this project is happening at this property"
Key design decisions
These are locked; see commit history + drizzle/ if you need to revisit them:
- UUID v7 primary keys via
gen_random_uuid() timestamptzeverywhere, UTC- Soft delete (
deleted_at) on user-facing entities; join/history tables hard-delete numeric(18,4)+char(3)currency for money- Asset custom fields in JSONB validated at runtime against
asset_field_defs(Zod schema derived, cached by(asset_type_id, schema_version)) - XOR location: an asset is at a project OR a property, never both — enforced by CHECK
- Assets are movable:
asset_location_historypreserves every hop withfrom/to/moved_by/moved_at/reason - Custom-field keys are immutable once referenced: rename = two-step data migration (JSONB key rename + version bump)
- Decisions scoped to
project | property | asset | work_package(widened from the initial draft) - Company default currency on
companies.settings_json, overridable on each decision event - Tabs = nested routes (
/projects/[id]/assets), not query-string state - Theme key is
localStorage['theme']— same as sibling apps so switching one tab propagates across all three apps via thestorageevent
Sibling apps
When in doubt, check the siblings before inventing:
buildfor_life_budget— auth flow, role middleware, company switcher pattern, Husky setup,(auth)/(app)route groupbuildfor_life_repair— sidebar pixel reference,MarkdownEditor.svelte,DocumentUpload.svelte,TagInput.svelte,AutocompleteInput.svelte, QR label generation (port in Phase 5)
Troubleshooting
Environment validation failedon boot —.envis missing or incomplete; compare against.env.examplerelation "sessions" does not exist— you skippedpnpm run db:migrateafterdb:generate- Session cookie never sets — in dev, make sure
PUBLIC_BASE_URLincludeslocalhostso the hook setssecure: false; in production use HTTPS sha256 mismatch on upload— the client-computed sha256 disagrees with the server's stream hash; retry the upload