From 95c1f61c8822ff7af8fff99d26d0f966f1aa0895 Mon Sep 17 00:00:00 2001 From: grabowski Date: Tue, 21 Apr 2026 15:53:10 +0700 Subject: [PATCH] Add README with setup, npm scripts, layout, roadmap Covers prerequisites, .env setup, database bootstrap, user creation, all npm commands, project layout, auth/storage models, phase roadmap, locked design decisions, sibling-app references, and troubleshooting. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 186 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e90bc7f --- /dev/null +++ b/README.md @@ -0,0 +1,186 @@ +# 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 20+ (24.x tested) +- PostgreSQL 16+ running locally or reachable by URL +- `git` with SSH access to `gitssh.b4l.co.th` + +### 2. Install + +```bash +npm install +``` + +### 3. 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 + +### 4. Create the database + +```bash +createdb buildfor_life_ops # or use your tool of choice +``` + +### 5. Apply migrations + +```bash +npm run db:migrate +``` + +### 6. Bootstrap the first admin user + +```bash +npm run create-user -- \ + --email you@b4l.co.th \ + --password 'a-long-password' \ + --name 'Your Name' \ + --company 'B4L' \ + --role admin +``` + +### 7. Run the dev server + +```bash +npm run dev +``` + +Open http://localhost:5173 and log in. + +## npm scripts + +| Command | What it does | +| --- | --- | +| `npm run dev` | Start the Vite dev server with HMR | +| `npm run build` | Production build (outputs to `build/`) | +| `npm run preview` | Preview the production build locally | +| `npm run check` | Typecheck: `svelte-kit sync` then `svelte-check --threshold warning` | +| `npm run validate` | `check` + `build` — use this as a pre-commit smoke test | +| `npm run db:generate` | Diff the Drizzle schema against the last snapshot and emit a new migration under `drizzle/` | +| `npm run db:migrate` | Apply pending migrations against `$DATABASE_URL` | +| `npm run db:push` | Skip migration files and sync the schema directly — **dev only** | +| `npm run db:studio` | Open Drizzle Studio (web UI at localhost for inspecting data) | +| `npm run db:seed` | Seed the system catalog of asset types (wired when the assets schema lands in Phase 1) | +| `npm 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 + +| 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 `npm 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