grabowski ad155d6344 create-user: look up company by slug and heal corrupted name
If a previous run stored the company name with literal quotes
('B4L'), the current run's name-based select missed the row and
the insert collided on the unique slug. Looking up by slug is the
natural idempotency key here: slug is derived deterministically
from name, so a new run and the corrupted row produce the same
slug and therefore resolve to the same company row. If the stored
name differs from the new one, heal it with an UPDATE and log the
rename.

Also tightens the membership lookup to (user_id, company_id)
instead of first-match on user_id so re-running on a user with
multiple companies does the right thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 16:40:40 +07:00

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 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

npm install

3. Configure environment

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_BACKENDlocal (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

createdb buildfor_life_ops     # or use your tool of choice

5. Apply migrations

npm run db:migrate

6. Bootstrap the first admin user

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

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/<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 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
S
Description
No description provided
Readme 1.1 MiB
Languages
HTML 35.4%
TypeScript 27.1%
Svelte 24.7%
Python 12.2%
PLpgSQL 0.5%