911898507af7a16c302e6df1c9fe14b56d88f101
- lib/server/csv-parse.ts: RFC 4180 parser (quoted fields, embedded commas and quotes, CRLF/LF, BOM), no extra dep - services/expenses.ts: importExpenses() is transactional + all-or-nothing — if any row fails Zod-style validation, nothing is inserted - /properties/[id]/expenses/import: upload page with per-line error list, sample CSV download at /template.csv, 5 MB cap - 'Import CSV' button wired on the expenses tab
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%