ad155d63447a49d966f8e6469a198f9589cb48a8
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>
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 inlinetokens, 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
StorageAdapterinterface:LocalDiskStoragetoday, S3 drop-in later
First-time setup
1. Prerequisites
- Node 20+ (24.x tested)
- PostgreSQL 16+ running locally or reachable by URL
gitwith SSH access togitssh.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 DBSESSION_SECRET— at least 32 chars of random hex (openssl rand -hex 32)STORAGE_SIGNING_SECRET— another 32+ chars of random hex (independent fromSESSION_SECRET)PUBLIC_BASE_URL— the URL the app is served on (http://localhost:5173in 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=falseunless 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 /loginvalidates email + password, creates a session in Postgres, sets theops_sessioncookie 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 /logoutinvalidates the session row and clears the cookiesrc/routes/(app)/+layout.server.tsis the gate — any route under(app)redirects to/login?next=...whenlocals.useris null- Companies: a user joins companies via
company_userswith 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_keyis an opaque string (e.g.2026/04/<uuid>__filename.pdf) that only the activeStorageAdapterknows how to resolve LocalDiskStoragewrites underSTORAGE_LOCAL_ROOTand produces HMAC-signed short-lived URLs served by/api/files?...with a timing-safe signature check- Swapping to S3 later: add
S3StorageimplementingStorageAdapter, setSTORAGE_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() timestamptzeverywhere, 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_historypreserves every hop withfrom/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 thestorageevent
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 groupbuildfor_life_repair— sidebar pixel reference,MarkdownEditor.svelte,DocumentUpload.svelte,TagInput.svelte,AutocompleteInput.svelte, QR label generation (port in Phase 5)
Troubleshooting
Environment validation failedon boot —.envis missing or incomplete; compare against.env.examplerelation "sessions" does not exist— you skippednpm run db:migrateafterdb:generate- Session cookie never sets — in dev, make sure
PUBLIC_BASE_URLincludeslocalhostso the hook setssecure: false; in production use HTTPS sha256 mismatch on upload— the client-computed sha256 disagrees with the server's stream hash; retry the upload
Description
Languages
HTML
35.4%
TypeScript
27.1%
Svelte
24.7%
Python
12.2%
PLpgSQL
0.5%