# buildfor_life_ops Internal B4L ops tool combining **project management** and **property / asset management**. Siblings: [`buildfor_life_budget`](https://git.b4l.co.th/B4L/buildfor_life_budget) (expense tracking) and [`buildfor_life_repair`](https://git.b4l.co.th/B4L/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 inline` tokens, 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 `StorageAdapter` interface: `LocalDiskStorage` today, S3 drop-in later ## First-time setup ### 1. Prerequisites - **Node 24** pinned via `.node-version` (installed with [`fnm`](https://github.com/Schniz/fnm)) - **pnpm 9** as the package manager (declared in `package.json#packageManager`) - PostgreSQL 16+ running locally or reachable by URL - `git` with SSH access to `gitssh.b4l.co.th` ### 2. Install fnm + pnpm ```bash # 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 ```bash 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 ```bash cp .env.example .env ``` Edit `.env`: - `DATABASE_URL` — point at your Postgres instance and target DB - `SESSION_SECRET` — at least 32 chars of random hex (`openssl rand -hex 32`) - `STORAGE_SIGNING_SECRET` — another 32+ chars of random hex (independent from `SESSION_SECRET`) - `PUBLIC_BASE_URL` — the URL the app is served on (`http://localhost:5173` in 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=false` unless you're wiring SSO ### 5. Create the database ```bash createdb buildfor_life_ops # or use your tool of choice ``` ### 6. Apply migrations ```bash pnpm run db:migrate ``` ### 7. Bootstrap the first admin user ```bash 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 ```bash 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 /login` validates email + password, creates a session in Postgres, sets the `ops_session` cookie 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 /logout` invalidates the session row and clears the cookie - `src/routes/(app)/+layout.server.ts` is the gate — any route under `(app)` redirects to `/login?next=...` when `locals.user` is null - Companies: a user joins companies via `company_users` with 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_key` is an opaque string (e.g. `2026/04/__filename.pdf`) that only the active `StorageAdapter` knows how to resolve - `LocalDiskStorage` writes under `STORAGE_LOCAL_ROOT` and produces HMAC-signed short-lived URLs served by `/api/files?...` with a timing-safe signature check - Swapping to S3 later: add `S3Storage` implementing `StorageAdapter`, set `STORAGE_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_ops` to siblings `buildfor_life_budget` and `buildfor_life_repair` so 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_assigned` notifications when `next_due_at` crosses 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_action` enum + 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()` - **`timestamptz`** everywhere, 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_history` preserves every hop with `from/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 the `storage` event ## 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 group - `buildfor_life_repair` — sidebar pixel reference, `MarkdownEditor.svelte`, `DocumentUpload.svelte`, `TagInput.svelte`, `AutocompleteInput.svelte`, QR label generation (port in Phase 5) ## Troubleshooting - **`Environment validation failed`** on boot — `.env` is missing or incomplete; compare against `.env.example` - **`relation "sessions" does not exist`** — you skipped `pnpm run db:migrate` after `db:generate` - **Session cookie never sets** — in dev, make sure `PUBLIC_BASE_URL` includes `localhost` so the hook sets `secure: false`; in production use HTTPS - **`sha256 mismatch on upload`** — the client-computed sha256 disagrees with the server's stream hash; retry the upload