grabowski f8478f5019 fix(ui): cap date inputs to 4-digit years via min/max
Without min/max, native <input type="date"> lets you type arbitrarily-many
year digits (YYYYYY-MM-DD). Add min="2000-01-01" max="2099-12-31" to every
date input (and bounded range on the single datetime-local) across expenses,
projects, tasks, assets, decisions, and maintenance.
2026-04-23 15:45:15 +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%