0225b204a2
Pin Node 24 via .node-version/.nvmrc and pnpm 9.15.0 via package.json#packageManager. Regenerate lockfile as pnpm-lock.yaml. Rewrite README setup + scripts table around pnpm, and add a production deployment guide covering systemd, nginx, upgrades, rollback, and backups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
203 lines
9.7 KiB
Markdown
203 lines
9.7 KiB
Markdown
# 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/<uuid>__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
|
|
|
|
| Phase | Scope | State |
|
|
| --- | --- | --- |
|
|
| 0 | Scaffold: stack wiring + auth + layout shell + storage interface + tenancy schema + git remote | ✅ shipped |
|
|
| 1 | Properties + Assets with typed custom fields + mobility history + asset logs + document upload per scope | next |
|
|
| 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 | |
|
|
| 5 *(later)* | QR label generation, email/in-app notifications, reports, S3 storage adapter, cross-app APIs | |
|
|
|
|
## 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
|