diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 361a22fc7..1b5ce284b 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -2,184 +2,102 @@ ## Tech Stack (Accepted) -- Server: Fastify 5.x (ADR-001) -- Client: React 19 + React Router 7 (ADR-002) -- DB: SQLite via better-sqlite3 + Drizzle ORM (ADR-003) -- Bundler: Webpack 5.x (ADR-004) -- Tests: Jest 30.x + Playwright (ADR-005) -- Styling: CSS Modules (ADR-006) -- Structure: npm workspaces monorepo (ADR-007) -- TypeScript ~5.9 -- Node.js 24 LTS +- Server: Fastify 5.x (ADR-001), Client: React 19 + React Router 7 (ADR-002) +- DB: SQLite via better-sqlite3 + Drizzle ORM (ADR-003), Bundler: Webpack 5.x (ADR-004) +- Tests: Jest 30.x + Playwright (ADR-005), Styling: CSS Modules (ADR-006) +- Structure: npm workspaces monorepo (ADR-007), TypeScript ~5.9, Node.js 24 LTS ## Project Layout -- `shared/` (@cornerstone/shared) - types, must build first, composite:true in tsconfig -- `server/` (@cornerstone/server) - Fastify app, Drizzle schema -- `client/` (@cornerstone/client) - React SPA, Webpack, CSS Modules -- Build order: shared -> client -> server +- `shared/` -> `client/` -> `server/` (build order) +- All plugins use `fastify-plugin` (fp), registration: config -> errorHandler -> compress -> cookie -> db -> auth -> routes -> static ## Key Patterns -- All API endpoints under `/api/` prefix -- Standard error shape: `{ error: { code, message, details? } }` -- Server serves SPA static files in production (from client/dist) -- Webpack dev server proxies /api/\* to Fastify in development -- Graceful shutdown handlers in server.ts (SIGTERM/SIGINT) -- Database at configurable path via DATABASE_URL env var - -## Fastify Plugin Pattern - -- All plugins use `fastify-plugin` (fp) to break encapsulation for global visibility -- Type augmentation: `declare module 'fastify'` adds properties to `FastifyInstance` -- Registration order: config -> errorHandler -> compress -> cookie -> db -> auth -> routes -> static -- Config plugin: `fastify.config: AppConfig` (sessionDuration, secureCookies included) -- DB plugin: `fastify.db` (Drizzle + better-sqlite3), WAL mode, migrations on startup -- Error handler: AppError subclasses, AJV validation mapping, production sanitization -- Auth plugin: preValidation hook, public route exemption Set, hourly session cleanup - -## EPIC-01 Auth Architecture (ADR-010) - -### Schema - -- `users` table: TEXT PK (UUID), email (UNIQUE), display_name, role, auth_provider, password_hash, oidc_subject, deactivated_at, created_at, updated_at -- `sessions` table: TEXT PK (256-bit random hex), user_id FK, expires_at, created_at -- Migration: `0001_create_users_and_sessions.sql` - -### Auth Decisions - -- Server-side sessions in SQLite + HttpOnly cookie (`cornerstone_session`) -- Session token: crypto.randomBytes(32).toString('hex') (256-bit) -- Password hashing: scrypt (N=16384,r=8,p=1,keylen=64) via node:crypto -- PHC format storage -- OIDC: openid-client v6 (pure JS, OpenID Certified) -- Route protection: global preValidation hook + requireRole('admin') decorator -- Cookie: HttpOnly, SameSite=Strict, Secure (configurable), Path=/ -- Session cleanup: hourly interval, not per-request -- Timing attack prevention: dummy scrypt verify on non-existent user login - -### Initial Setup Flow (documented in Architecture wiki) - -- First launch: GET /api/auth/me returns `setupRequired: true` when no users exist -- Client redirects to /setup automatically (AuthGuard) -- POST /api/auth/setup creates initial admin account (only works when DB is empty) -- After setup: returns 403 SETUP_COMPLETE; client redirects to /login -- Setup page only accessible when setupRequired is true - -### Plugin Registration Order - -- config -> errorHandler -> compress -> cookie -> db -> auth -> routes -> static - -### EPIC-01 Complete (promoted to main via PR #82) - -- All 8 stories + refinement merged. 614 unit/integration + 402 E2E tests. -- COOKIE_NAME in `server/src/constants.ts`, isSafeRedirect() for open redirect, TRUST_PROXY env var -- Future: OIDC discovery cache no TTL, /api/health/ready scrypt overhead - -## EPIC-03 Work Items Architecture (ADR-012) - -### Story 3.1 Complete (PR #97) — Schema Foundation - -- Migration 0002 created and runs successfully -- All 6 Drizzle ORM table definitions match migration SQL exactly -- Shared types fully defined: WorkItem, WorkItemSummary, WorkItemDetail, Tag, Subtask, Note, Dependency, pagination wrapper -- 30 integration tests pass, covering migration structure, FK cascades, CHECK constraints, index verification -- **Critical fix**: `created_by` is nullable (NOT NULL was contradictory with ON DELETE SET NULL) -- Error codes added: CIRCULAR_DEPENDENCY, DUPLICATE_DEPENDENCY - -### Schema (migration 0002) - -- 6 tables: work_items, tags, work_item_tags, work_item_notes, work_item_subtasks, work_item_dependencies -- No `priority` column -- tags used instead (per requirements) -- 3 statuses: not_started, in_progress, completed (blocked removed in Issue #296 / migration 0008) -- Dependencies: composite PK (predecessor_id, successor_id), 4 types (FS/SS/FF/SF) +- All API endpoints under `/api/` prefix, error shape: `{ error: { code, message, details? } }` +- Offset pagination: page (1-indexed), pageSize (default 25, max 100) +- Tags/users NOT paginated (small collections) +- PATCH with tagIds replaces entire tag set (set-semantics) - Junction tables use composite PKs (no surrogate id) -- `created_by` on work_items: FK to users ON DELETE SET NULL (nullable) -- `assigned_user_id` on work_items: FK to users ON DELETE SET NULL -- Indexes: status, assigned_user_id, created_at on work_items; work_item_id on notes/subtasks; tag_id on tags; successor_id on dependencies - -### API Contract (19 endpoints) - -- Work Items: GET list, POST create, GET detail, PATCH update, DELETE -- Tags: GET list, POST create, PATCH update, DELETE -- Notes: GET list, POST create, PATCH update, DELETE (author/admin check) -- Subtasks: GET list, POST create, PATCH update, DELETE, PATCH reorder -- Dependencies: GET, POST (with cycle detection), DELETE by predecessorId -- New error codes: CIRCULAR_DEPENDENCY, DUPLICATE_DEPENDENCY -### Pagination Convention (ADR-012) - -- Offset-based: page (1-indexed, default 1), pageSize (default 25, max 100) -- Response: { items: [...], pagination: { page, pageSize, totalItems, totalPages } } -- Tags and users NOT paginated (small collections) - -### Design Decisions - -- Notes NOT embedded in work item detail response (fetched separately) -- Tags, subtasks, dependencies ARE embedded in detail response -- PATCH /work-items/:id with tagIds replaces entire tag set (set-semantics) -- Subtask reorder accepts ALL subtask IDs (no partial reorder) -- Dependency delete uses /:id/dependencies/:predecessorId path - -### Story 3.2 Complete (PR #98) — Work Items CRUD API +## Naming Conventions -- 5 endpoints, service layer pattern, 96 tests (740 total) -- Pagination, filtering (status/assignedUserId/tagId/q), sorting (6 fields) -- Tags: set-semantics on update. Response shapes match Wiki spec. +- DB: snake_case | TS vars: camelCase | TS types: PascalCase | Files: camelCase.ts (React: PascalCase.tsx) | API: kebab-case | Env: UPPER_SNAKE_CASE + +## Migrations (10 total) + +- 0001: users + sessions (EPIC-01) +- 0002: work_items + tags + notes + subtasks + dependencies (EPIC-03) +- 0003: budget_categories + vendors + invoices + budget_sources + subsidy_programs + junctions (EPIC-05) +- 0004: flat budget fields on work_items (SUPERSEDED by 0005) +- 0005: work_item_budgets table (budget rework) (EPIC-05) +- 0006: milestones + milestone_work_items + lead_lag_days (EPIC-06) +- 0007: work_item_milestone_deps (EPIC-06) -- NOT documented in Schema.md wiki +- 0008: actual_start/end_date, blocked->not_started migration (EPIC-06) +- 0009: document_links polymorphic table (EPIC-08) +- 0010: household_items + 5 supporting tables (EPIC-04) -- DESIGNED, not yet implemented + +## ADRs (ADR-001 through ADR-016) + +- ADR-001-009: Tech stack + error handling +- ADR-010: Auth (sessions + OIDC + scrypt) +- ADR-011: E2E (Playwright + Testcontainers) +- ADR-012: Pagination conventions +- ADR-013: Gantt chart (custom SVG) +- ADR-014: Scheduling engine (server-side CPM) +- ADR-015: Paperless-ngx integration (proxy + polymorphic links) +- ADR-016: Household items (separate entity with parallel structure) + +## EPIC Status + +- EPIC-01 Auth: Complete (promoted to main) +- EPIC-03 Work Items: Complete (promoted to main) +- EPIC-05 Budget: Complete (promoted to main, v1.9.0) +- EPIC-06 Timeline/Gantt: Complete (promoted to main, v1.10.0) +- EPIC-08 Documents: Complete (promoted to main, v1.11.0) +- EPIC-04 Household Items: Architecture designed (PR #395), implementation pending ## GitHub Wiki -- Wiki repo: `GIT_SSL_NO_VERIFY=1 git clone https://github.com/steilerDev/cornerstone.wiki.git /tmp/cornerstone-wiki` -- REST API 404s for wiki -- must use git clone/push workflow -- ADR-001 through ADR-014, Architecture, Schema, API-Contract, Home, ADR-Index +- Wiki is git submodule at `wiki/`. Sync: `git submodule update --init wiki && git -C wiki pull origin master` +- ADR-001 through ADR-016, Architecture, Schema, API-Contract, Home, ADR-Index, Style-Guide, Security-Audit +- **Always push wiki before creating PR** -- submodule ref must be committed in feature branch -### Wiki Update Discipline (CRITICAL — recurring gap) +### Wiki Update Discipline (CRITICAL) -The wiki **MUST be updated as part of story implementation**, not caught at review time. This lag has caused deviations in EVERY epic so far. +The wiki MUST be updated as part of story implementation, not caught at review time. -**When to update wiki:** +- New endpoint -> API-Contract.md +- New/changed table/column -> Schema.md +- Architectural decision -> ADR-NNN-\*.md + ADR-Index.md -- Any new API endpoint → update `wiki/API-Contract.md` -- Any new/changed DB column, table, or constraint → update `wiki/Schema.md` -- Any new shared type that's part of the API → update `wiki/API-Contract.md` -- Any architectural decision → create or update `wiki/ADR-NNN-*.md` + `wiki/ADR-Index.md` +## EPIC-04 Household Items (Latest Work) -**How to update:** +See `epic04-household-items.md` for full details. -```bash -git -C wiki checkout master && git -C wiki pull --rebase origin master -# edit wiki/*.md -git -C wiki add -A && git -C wiki commit -m "docs: ..." -git -C wiki push origin master -git add wiki # update submodule ref in parent repo -``` +- 6 new tables, 20 API endpoints, ADR-016 +- Reuses shared tags, vendors, budget_categories, budget_sources, subsidy_programs +- document_links already supports household_item entity_type +- Budget overview must aggregate both work_item_budgets and household_item_budgets -**Always push wiki before creating the PR.** The submodule ref must be committed in the feature branch so reviewers see up-to-date docs. +## Known Wiki Documentation Gaps -### N+1 Queries (Accepted at Current Scale) +- Migration 0007 (work_item_milestone_deps) not documented in Schema.md +- Wiki sandbox worktrees have persistent permission issues with `.git/objects/pack/` -| Service | Query | N+1 Where | Acceptable Until | -| ------------------ | ------------------------------------------------ | ------------------- | ---------------- | -| `getAllMilestones` | Per-row: countLinkedWorkItems + getCreatedByUser | milestoneService.ts | >50 milestones | -| `listAllInvoices` | Per-row vendor join (now uses JOIN, not N+1) | invoiceService.ts | — | +## Sandbox Limitations (not real project issues) -If milestone count grows beyond 50 in production use, create a EPIC-10 story to rewrite `getAllMilestones` with a single JOIN query. +- esbuild SIGILL on emulated aarch64; Docker build fails due to TLS firewall +- 4GB RAM: Jest OOM mitigated with --maxWorkers=2, --max-old-space-size=2048 -## Sandbox Limitations (not real project issues) +## N+1 Queries (Accepted at Current Scale) -- esbuild native binaries SIGILL on emulated aarch64 sandbox -- Docker build fails due to TLS firewall blocking Alpine repos -- 4GB RAM limit causes Jest OOM -- mitigated with --maxWorkers=2, --max-old-space-size=2048, --workerIdleMemoryLimit=300MB in root package.json test scripts. Documented in Architecture wiki "Development Environment Constraints" section. Reversal plan documented there too. -- These all work fine on real hardware / CI +- `getAllMilestones`: per-row countLinkedWorkItems + getCreatedByUser (acceptable <50 milestones) -## Naming Conventions +## PR Review Notes -- DB columns: snake_case -- TS vars/functions: camelCase -- TS types: PascalCase -- Files (modules): camelCase.ts -- Files (React): PascalCase.tsx -- API routes: kebab-case (/api/work-items) -- Env vars: UPPER_SNAKE_CASE +- Cannot `gh pr review --approve` own PRs -- use `--comment` instead +- Root `typecheck` script builds shared first ## Config Files @@ -189,228 +107,12 @@ If milestone count grows beyond 50 in production use, create a EPIC-10 story to ## CI/CD (ADR-008) -- GitHub Actions + semantic-release + GitHub Flow + Docker Hub + Docker Scout + Dependabot -- Beta pre-releases from `beta` branch, stable from `main` -- Feature PR -> beta: squash merge; beta -> main: merge commit - -## PR Review Notes - -- Cannot `gh pr review --approve` own PRs -- use `--comment` instead -- Root `typecheck` script builds shared first -- ADR-004/005/006 updated to current tech (PR #95 + wiki push) - -## Pending Housekeeping - -- Update Architecture wiki "Static Asset Serving" section - -## E2E Test Architecture (ADR-011) - -- Testcontainers for programmatic container management (not Docker Compose) -- 3 managed services: Cornerstone app, mock OIDC server, upstream nginx proxy -- Playwright 5 viewport projects: desktop-lg(1920x1080), desktop-md(1440x900), tablet(iPad), mobile-iphone, mobile-android -- `e2e/` workspace at project root (@cornerstone/e2e), separate from unit tests -- Test files: `*.spec.ts` (distinct from Jest's `.test.ts`) -- API-based seeding (through running app) + SQL fixture only for initial admin setup -- CI: separate job after quality-gates+docker, uploads traces+screenshots+HTML report -- Page Object Model pattern, organized by feature in e2e/tests/ -- Chromium only (cross-browser as future opt-in) -- globalSetup/globalTeardown for container lifecycle - -## EPIC-05 Budget Management - -- Migration 0003: 8 tables (budget_categories, vendors, invoices, budget_sources, subsidy_programs, 3 junctions) -- Migration 0004: flat budget fields on work_items (SUPERSEDED by 0005) -- Migration 0005 (Story 5.9): Budget system rework -- see `epic05-budget.md` -- API: 5 budget-categories, 5 vendor, 4 invoice, 4 budget line, 5 budget source, 1 budget overview endpoints -- Error codes: CATEGORY_IN_USE, VENDOR_IN_USE, BUDGET_LINE_IN_USE, BUDGET_SOURCE_IN_USE, SUBSIDY_PROGRAM_IN_USE -- Wiki API-Contract: budget source CRUD + overview fully documented (Story 5.11) -- See `epic05-budget.md` for full details - -## EPIC-05 Story 5.9 Budget System Rework - -- `work_item_budgets` table replaces flat budget cols on work_items + work_item_vendors junction -- Confidence enum: own_estimate(20%), professional_estimate(10%), quote(5%), invoice(0%) -- Invoices gain `work_item_budget_id` FK (SET NULL), status changed: overdue -> claimed -- Budget lines embedded in WorkItemDetail response as `budgets: WorkItemBudgetLine[]` -- 4 endpoints: GET/POST/PATCH/DELETE at /api/work-items/:workItemId/budgets -- Vendor delete checks work_item_budgets.vendor_id (details: budgetLineCount) -- Category delete checks work_item_budgets.budget_category_id (details: budgetLineCount) -- Wiki Schema + API Contract updated and pushed -- **PR #187 review findings**: Missing CHECK(planned_amount >= 0) in migration; unlinkVendorFromWorkItem deletes all budget lines (not just placeholders) - -## EPIC-05 Story 5.11 Budget Overview Rework - -- BudgetOverview gains: projectedMin, projectedMax, remainingVsProjectedMin, remainingVsProjectedMax -- CategoryBudgetSummary gains: projectedMin, projectedMax (blended per-category) -- BudgetSource gains: claimedAmount, actualAvailableAmount (actual drawdown perspective) -- Existing fields (usedAmount, availableAmount) represent planned allocation perspective -- Blended projected model: invoiced budget lines use actual invoice total, non-invoiced use planned range -- All new fields are computed at API layer (no schema changes) -- Wiki API-Contract + Schema updated and pushed - -## PR #203 Review (Standalone Invoices - Feb 2026) - -- `vendorName` added to `Invoice` shared type -- wiki API Contract must be updated -- New endpoints: `GET /api/invoices` (paginated), `GET /api/invoices/:id` (cross-vendor) -- N+1 pattern: `toInvoice()` does per-row vendor query -- should accept optional vendorName param -- `listAllInvoices()` correctly uses JOIN but duplicates mapping instead of reusing `toInvoice()` -- `InvoiceDetailResponse` exported but not used in client API (uses inline type) -- formatCurrency/formatDate duplicated across 3+ pages -- extraction candidate -- CSS module duplication growing across page modules (buttons, modals, forms) - -## EPIC-06 Timeline Architecture (ADR-013, ADR-014) - -- ADR-013: Custom SVG Gantt chart (no third-party lib -- project policy bans native frontend deps) -- ADR-014: Server-side CPM scheduling engine, on-demand (user triggers, reviews, accepts) -- Migration 0006: milestones (INTEGER PK AUTOINCREMENT), milestone_work_items junction, lead_lag_days on work_item_dependencies -- Milestones: exception to UUID PK pattern (INTEGER AUTOINCREMENT for simpler joins, less sensitive entity) -- lead_lag_days: positive = lag (waiting), negative = lead (overlap), default 0 -- POST /api/schedule?dryRun=true|false: dryRun defaults to false (persists dates). Updated Feb 2026 for Story 6.2. -- ScheduledItem includes all 4 CPM dates: scheduledStartDate (ES), scheduledEndDate (EF), latestStartDate (LS), latestFinishDate (LF) -- GET /api/timeline: aggregated view for Gantt (work items + deps + milestones + critical path) -- Timeline query params: from, to (date range filter), milestoneId (scope to milestone's items) -- milestoneId and from/to are mutually exclusive -- TimelineResponse includes dateRange: { earliest, latest } | null -- TimelineWorkItem includes startAfter/startBefore scheduling constraints -- TimelineMilestone includes completedAt (timestamp, not just boolean) -- Critical path always computed over full dataset regardless of filters -- Critical path computed on each timeline request (acceptable for <200 items) -- Dependencies have no surrogate ID -- composite key (predecessorId, successorId) is sufficient -- PATCH /api/work-items/:id/dependencies/:predecessorId: new endpoint for updating dependency type/lead_lag_days -- Household item delivery dates will be added to timeline in EPIC-04 - -## PR #247 Review (Story 6.1 Milestones Backend) - -- Wiki Schema.md deviation: `milestones.created_by` documented as NOT NULL but implementation is nullable (correct for ON DELETE SET NULL). Flagged for wiki fix. -- N+1 query in `getAllMilestones` (countLinkedWorkItems + getCreatedByUser per row) -- acceptable for <50 milestones -- Cannot `gh pr review --request-changes` on own PRs -- must use `--comment` instead -- All 7 milestone endpoints + PATCH dependency update match API-Contract.md exactly -- Shared types (milestone.ts, dependency.ts updates) match contract interfaces - -## PR #248 Review (Story 6.2 Scheduling Engine) - -- Implementation matches ADR-014 exactly: pure function, Kahn's topological sort, forward/backward pass, all 4 dep types, lead/lag, float clamping -- Shared types in `shared/src/types/schedule.ts` match API Contract response shape -- `CircularDependencyError` reuses existing error code from shared types -- Engine (`server/src/services/schedulingEngine.ts`): 515 lines, pure function, injectable `today` param -- Route handler (`server/src/routes/schedule.ts`): thin 113-line adapter, maps DB data to engine types -- 865 unit tests + 825 integration tests -- **Deviation flagged**: API Contract notes say orphan items excluded; impl includes all items in full mode. Track for refinement wiki update. -- Topological sort uses `queue.sort()` for deterministic output -- O(n log n) per iteration, fine for <200 items - -## EPIC-06 Story 6.4 Gantt Chart Frontend Architecture - -- Component dir: `client/src/components/GanttChart/` (matches existing PascalCase convention) -- Sub-components: GanttChart (orchestrator), GanttGrid, GanttBar, GanttSidebar, GanttHeader, ganttUtils.ts -- HTML+SVG hybrid: sidebar is HTML (text quality/a11y), chart area is SVG -- Scroll sync: onScroll handler + ref assignment, NOT CSS sticky (unreliable with overflow-x) -- SVG: explicit width/height, no viewBox (1:1 pixel mapping for bars/grid) -- No virtualization needed (<500 items) -- Performance: useMemo for date calculations, React.memo for bars, rAF for scroll sync -- Bar colors: use `--color-status-*` semantic tokens -- Today marker: `var(--color-danger)` red line -- Row stripes: alternate `--color-bg-primary` / `--color-bg-secondary` -- Grid lines: `var(--color-border)` -- Default zoom: month -- Data hook: `useTimeline` in `client/src/hooks/useTimeline.ts` -> calls existing `getTimeline()` -- No schema changes, no API changes, no shared type changes needed -- Route `/timeline` already exists in App.tsx - -## EPIC-06 Story 6.5 Dependency Arrows Architecture - -- Frontend-only story -- no backend/schema/API changes needed -- `TimelineResponse` already provides `dependencies[]` and `criticalPath[]` -- New files: `GanttArrows.tsx`, `GanttArrows.module.css`, `arrowUtils.ts` -- Modified files: `GanttChart.tsx` (add arrow layer + criticalPathSet), `GanttBar.tsx` (isCritical prop), `TimelinePage.tsx` (showArrows toggle) -- SVG layer order: Grid -> Arrows -> Bars (arrows behind bars so bars remain interactive) -- Arrow routing: orthogonal (horizontal-first) with 12px standoff from bar edges -- Arrow is critical if both predecessor and successor are in criticalPath set -- Critical bar styling: 2px stroke (non-color cue for a11y) -- Critical arrow styling: 2.5px stroke + full opacity (vs 1.5px + 0.5 opacity for normal) -- New CSS tokens needed: `--color-gantt-arrow`, `--color-gantt-arrow-critical`, `--color-gantt-bar-critical-stroke` -- SVG markers (``) for arrowheads, use `fill="currentColor"` to inherit from path `color` prop - -## EPIC-06 Story 6.6 Gantt Interactive Features Architecture - -- Drag-and-drop: custom `useGanttDrag` hook using Pointer Events API (no library) -- Edge detection: 8px EDGE_THRESHOLD constant for left/right zones; center for whole-bar drag -- `setPointerCapture()` on pointerdown to prevent drag loss outside SVG -- Grid snapping: `snapToGrid(date, zoom)` pure function in ganttUtils.ts -- New `xToDate()` inverse function in ganttUtils.ts (mirrors `dateToX()` logic) -- Ghost bar: semi-transparent dashed SVG rect overlaid during drag; original bar dims to 0.3 opacity -- Cursor feedback: col-resize on edges, grab/grabbing on center; set via JS not CSS (pixel-based zones) -- Optimistic update: `useTimeline.updateItemDates()` mutates cached data, reverts on API failure -- Toast system: new `client/src/components/Toast/` (Toast.tsx, Toast.module.css, ToastContext.tsx) - - Portal-based, bottom-right fixed, z-index: var(--z-modal), max 3 visible - - `` wraps app in App.tsx; `useToast().showToast(variant, message)` API -- Gantt tooltip: separate component (not reusing existing Tooltip -- SVG elements can't be wrapped in spans) - - Portal to document.body, positioned via getBoundingClientRect() - - Shows: title, status, dates, duration, assigned user - - Suppressed during drag -- Auto-schedule button: in TimelinePage toolbar; calls POST /api/schedule (read-only), then applies changes via individual PATCH calls, then refetches timeline -- Schedule API client: `client/src/lib/scheduleApi.ts` -- `runSchedule(request)` -- No backend/schema/shared-type changes needed - -## PR #254 Review (Story 6.7 Milestones Frontend) - -- Approved: all architecture patterns followed correctly -- API client (`milestonesApi.ts`): 7 functions mapping to all wiki API Contract endpoints -- `useMilestones` hook: follows `useTimeline` pattern exactly (fetchCount, cancelled, error discrimination) -- Discriminated union for GanttTooltip: `kind: 'work-item' | 'milestone'` -- clean polymorphic approach -- GanttMilestones: SVG diamonds, memo-ized, keyboard accessible, touch-expanded hit areas -- MilestonePanel: portal-based modal, layered Escape dismissal, delete sub-dialog at z-modal+1 -- 6 new milestone tokens in tokens.css (Layer 2 + Layer 3 dark mode) -- Client-side filtering via `useMemo` + `TimelineMilestone.workItemIds` -- Non-blocking: autoScheduleButton class reused for milestone button (semantic mismatch); MilestonePanel.module.css at 801 lines (dialog/button duplication); `linkedIds` Set dependency not memoized in useCallback - -## PR #255 Review (Story 6.8 Calendar View) - -- Approved: all architecture patterns followed correctly -- Component structure mirrors GanttChart pattern: CalendarView (orchestrator) + MonthGrid/WeekGrid + CalendarItem/CalendarMilestone + calendarUtils.ts -- URL state: `?view=calendar` toggles Gantt/Calendar, `?calendarMode=month|week` persists calendar sub-mode, `{ replace: true }` avoids history pollution -- Date handling: UTC midnight (`Date.UTC()`) throughout -- consistent with Gantt approach -- CSS: Zero hardcoded hex values, all `--color-status-*` and `--color-milestone-*` semantic tokens used -- ARIA: `role="grid"` on calendar grids, `aria-live="polite"` on nav label, `aria-pressed` on toggles -- Responsive: 44px touch targets on tablet/mobile, narrow day initials on mobile, icon-only buttons -- Non-blocking: `getShortMonthName()` exported but unused (dead code); 6-row month grid is intentional (prevents layout shift) -- Cannot approve own PRs -- must use `--comment` review type - -## PR #308 Review (Issue #296 -- Actual Dates, Delay Tracking, Status Simplification) - -- Migration 0008: adds actual_start_date, actual_end_date; migrates blocked -> not_started -- SQLite CHECK constraint still includes blocked (cannot ALTER); app-layer enforces 3-value enum -- WorkItemStatus: 'not_started' | 'in_progress' | 'completed' (blocked removed) -- Scheduling engine: actualStartDate overrides ES; actualEndDate overrides EF (only when actualStartDate also set) -- Today floor: all not_started items get ES = max(computedES, today) -- Status transition auto-population: not_started->in_progress sets actualStartDate; in_progress->completed sets actualEndDate; direct skip sets both -- Explicit values in same PATCH request take precedence over auto-population -- **Wiki deviation resolved**: Schema.md and API-Contract.md updated to reflect actual dates and status simplification. Wiki pushed as commit `be2c6d2`. Deviation Log entries added to both pages. -- Approved from architecture perspective - -## EPIC-08 Paperless-ngx Integration Architecture (ADR-015, PR #361) - -- **Proxy pattern**: Server proxies all Paperless-ngx API calls; token never reaches browser -- **Schema**: Single `document_links` table (migration 0009) with polymorphic `entity_type` discriminator (work_item, household_item, invoice) -- **No FK on entity_id**: Referential integrity enforced at application layer (entity existence check on insert, cascade-delete on entity deletion) -- **Env vars**: `PAPERLESS_URL` + `PAPERLESS_API_TOKEN` -- integration enabled when both set -- **API version pin**: `Accept: application/json; version=5` header on all upstream requests -- **No cache Phase 1**: Direct upstream calls; in-memory LRU can be added later without API contract changes -- **Proxy endpoints**: `/api/paperless/{status,documents,documents/:id,documents/:id/thumb,documents/:id/preview,tags}` -- **Link endpoints**: `POST/GET/DELETE /api/document-links` -- **Error codes**: PAPERLESS_NOT_CONFIGURED (503), PAPERLESS_UNREACHABLE (502), PAPERLESS_ERROR (502), DUPLICATE_DOCUMENT_LINK (409) -- **EPIC-04 not yet implemented**: household_items table doesn't exist; application layer should guard against household_item entity type until EPIC-04 lands -- **Shared types**: `shared/src/types/document.ts` (PaperlessDocument, PaperlessTag, DocumentLink, DocumentLinkWithMetadata, etc.) -- **Config**: `paperlessUrl`, `paperlessApiToken`, `paperlessEnabled` added to AppConfig -- **Next migration**: 0010 - -## Known Wiki Documentation Gaps - -- `work_item_milestone_deps` table (migration 0007) not yet documented in Schema.md -- Migration 0007 section entirely missing from Schema.md -- Wiki sandbox worktrees have persistent permission issues with `.git/objects/pack/` -- workaround: clone to `/tmp`, push from there, move back +- GitHub Actions + semantic-release + Docker Hub + Docker Scout + Dependabot +- Feature PR -> beta (squash merge); beta -> main (merge commit) ## Topic Files - `story-reviews.md` -- detailed review notes per story -- `epic03-refinement.md` -- 40 consolidated refinement items from EPIC-03 PR reviews +- `epic03-refinement.md` -- 40 consolidated refinement items from EPIC-03 - `epic05-budget.md` -- EPIC-05 budget management details +- `epic04-household-items.md` -- EPIC-04 household items architecture diff --git a/.claude/agent-memory/product-architect/epic04-household-items.md b/.claude/agent-memory/product-architect/epic04-household-items.md new file mode 100644 index 000000000..3c99d9ae4 --- /dev/null +++ b/.claude/agent-memory/product-architect/epic04-household-items.md @@ -0,0 +1,82 @@ +# EPIC-04: Household Items Architecture + +## ADR-016: Separate Entity with Parallel Structure + +Decision: Household items are a separate `household_items` table (not STI on work_items, not EAV). + +### Rationale + +- Work items have scheduling/dependencies; household items have purchase status/delivery tracking +- Scheduling engine unaffected +- No nullable column confusion +- Budget overview aggregates both via UNION queries + +## Schema (Migration 0010) + +6 new tables: + +1. **household_items**: Main entity + - TEXT PK (UUID), name, description, category (CHECK 8 values), status (CHECK 4 values) + - vendor_id FK->vendors ON DELETE SET NULL + - url, room (free text), quantity (>=1), order_date, expected_delivery_date, actual_delivery_date + - created_by FK->users ON DELETE SET NULL, created_at, updated_at + - Indexes: category, status, room, vendor_id, created_at + +2. **household_item_tags**: M:N junction (composite PK) +3. **household_item_notes**: Per-item notes (TEXT PK, content, created_by FK) +4. **household_item_budgets**: Mirrors work_item_budgets exactly (no invoice linkage) +5. **household_item_work_items**: Simple M:N junction (no dep types like FS/SS/FF/SF) +6. **household_item_subsidies**: M:N with subsidy_programs (composite PK) + +### Category Values + +furniture, appliances, fixtures, decor, electronics, outdoor, storage, other + +### Status Values + +not_ordered, ordered, in_transit, delivered + +### Shared Tables Reused + +- tags (global resource) +- vendors (contractors + suppliers) +- budget_categories, budget_sources +- subsidy_programs +- document_links (already supports household_item entity_type) + +## API Contract (20 Endpoints) + +- Household Items: GET list (paginated/filterable/sortable), POST create, GET detail, PATCH update, DELETE +- Notes: GET list, POST create, PATCH update, DELETE (author/admin check) +- Budget Lines: GET list, POST create, PATCH update, DELETE (mirrors work item budget line endpoints) +- Work Item Links: GET list, POST link, DELETE unlink (simple M:N, 409 on duplicate) +- Subsidy Links: GET list, POST link, DELETE unlink (simple M:N, 409 on duplicate) +- Subsidy Payback: GET (same calculation as work item payback) + +### List Query Params + +- Filters: category, status, vendorId, room, tagId, q (full-text search on name/description/room) +- Sort: name, category, status, room, order_date, expected_delivery_date, created_at, updated_at + +### Response Shapes + +- HouseholdItemSummary: includes tagIds[], budgetLineCount, totalPlannedAmount +- HouseholdItemDetail: extends Summary with tags[], workItems[], subsidies[] +- Budget lines include actualCost/actualCostPaid/invoiceCount (always 0 for household items, shape consistency) + +### Budget Overview Update + +- GET /api/budget/overview must aggregate across BOTH work_item_budgets and household_item_budgets +- Response shape unchanged; backend uses UNION queries + +## No New Error Codes Needed + +- Existing codes cover all cases (NOT_FOUND, VALIDATION_ERROR, CONFLICT, UNAUTHORIZED, FORBIDDEN) + +## Wiki Pages Updated + +- Schema.md: EPIC-04 section with all 6 tables + migration SQL +- API-Contract.md: 20 endpoints (~1050 lines) +- ADR-016-Household-Items-Architecture.md: New ADR +- ADR-Index.md: Added ADR-016 entry +- PR #395: docs(epic-04) targeting beta diff --git a/.claude/agent-memory/product-owner/MEMORY.md b/.claude/agent-memory/product-owner/MEMORY.md index e682f40e6..dffe0e9a0 100644 --- a/.claude/agent-memory/product-owner/MEMORY.md +++ b/.claude/agent-memory/product-owner/MEMORY.md @@ -1,12 +1,12 @@ # Product Owner Agent Memory -## Backlog State (as of 2026-03-01) +## Backlog State (as of 2026-03-02) -- 12 epics, 60+ user stories across all epics +- 12 epics, 75+ user stories across all epics - Sprint 1 COMPLETE: EPIC-01, EPIC-02, EPIC-11 - Sprint 2 COMPLETE: EPIC-03, EPIC-12 - Sprint 3 COMPLETE: EPIC-05 (v1.9.0), EPIC-06 (v1.10.0) -- Sprint 4 PLANNING: EPIC-08 stories created (#354-#360), EPIC-04 not yet decomposed +- Sprint 4 IN PROGRESS: EPIC-08 COMPLETE (v1.11.0), EPIC-04 stories created (#387-#394) - Security hygiene backlog: Issue #315 (rate limiting, headers, lockout, etc.) - GitHub Projects board: "Cornerstone Backlog" (project #4, owner steilerDev) @@ -19,6 +19,7 @@ - EPIC-12 (#115): Design System Bootstrap -- CLOSED 2026-02-18, promoted with EPIC-03 via PR #110 - EPIC-05 (#5): Budget Management -- CLOSED (v1.9.0), all 12 stories - EPIC-06 (#6): Timeline & Gantt -- CLOSED (v1.10.0), all 9 stories +- EPIC-08 (#8): Paperless-ngx Document Integration -- CLOSED (v1.11.0), all 6 stories ## Epic Quick Reference @@ -50,13 +51,16 @@ All 8 stories merged to beta. Promotion PR #110 (beta -> main) merged. See [epic All 9 stories + refinement + E2E tests + UAT fixes merged and promoted to main. -## EPIC-08 Stories (Paperless-ngx Document Integration) — IN PROGRESS +## EPIC-08 — COMPLETE (v1.11.0, promoted 2026-03-02) -7 stories created (#354-#360). See [epic-08-planning.md](epic-08-planning.md) for full details. -Story 8.1 (#354): ACCEPTED (PR #362, round 2 review). All 12 ACs passed. +All 6 stories merged and promoted to main. Story 8.6 (#359) remains open, blocked by EPIC-04. See [epic-08-planning.md](epic-08-planning.md). -- Acceptance criteria text corrected post-review: PAPERLESS_TOKEN -> PAPERLESS_API_TOKEN, removed version from status, /thumbnail -> /thumb - Remaining stories in Backlog. Story 8.6 blocked by EPIC-04. +## EPIC-04 Stories (Household Items & Furniture) — PLANNING COMPLETE + +8 stories created (#387-#394). See [epic-04-planning.md](epic-04-planning.md) for full details. +Stories 4.1 (#387) and 4.2 (#388) set to Todo. Remaining stories in Backlog. +Story 8.6 (#359, EPIC-08) linked as sub-issue, blocked by #391 (detail page). +Total: 85 ACs, 103 UAT scenarios across 8 stories. ## EPIC-05 — COMPLETE (v1.9.0, promoted) diff --git a/.claude/agent-memory/product-owner/epic-04-planning.md b/.claude/agent-memory/product-owner/epic-04-planning.md new file mode 100644 index 000000000..22795562b --- /dev/null +++ b/.claude/agent-memory/product-owner/epic-04-planning.md @@ -0,0 +1,67 @@ +# EPIC-04: Household Items & Furniture Management -- Planning Notes + +## Stories + +| # | Issue | Title | Priority | Status | Depends On | +| --- | ----- | ----------------------------------------------- | ----------- | ------- | ---------------- | +| 4.1 | #387 | Household Items Schema & Migration | Must Have | Todo | (none) | +| 4.2 | #388 | Household Items CRUD API | Must Have | Todo | #387 | +| 4.3 | #389 | Household Items List Page | Must Have | Backlog | #388 | +| 4.4 | #390 | Household Item Create & Edit Form | Must Have | Backlog | #388 | +| 4.5 | #391 | Household Item Detail Page | Must Have | Backlog | #388 | +| 4.6 | #392 | Household Items Budget Integration | Must Have | Backlog | #387, #388 | +| 4.7 | #393 | Work Item Linking for Installation Coordination | Must Have | Backlog | #387, #388, #391 | +| 4.8 | #394 | Responsive & Accessibility Polish | Should Have | Backlog | #389, #390, #391 | + +Story 8.6 (#359, EPIC-08) is also a sub-issue of EPIC-04, blocked by #391 (detail page). + +## Dependency Chain + +``` +4.1 (schema) ─── > 4.2 (CRUD API) ──┬─> 4.3 (list page) ───────┬─> 4.8 (polish) + ├─> 4.4 (create/edit form) ─┤ + ├─> 4.5 (detail page) ──────┤ + │ └─> 8.6 (doc linking)* + ├─> 4.6 (budget integration) + └─> 4.7 (work item linking, also needs 4.5) +``` + +## Key Design Decisions + +1. **Distinct entity**: Household items are NOT work items (Section 5, Key Decisions). Separate table, separate routes, separate pages. +2. **Shared resources**: Reuses existing `tags` table (new junction `household_item_tags`), existing `vendors` table, existing budget categories/sources/subsidies. +3. **Budget pattern**: `household_item_budgets` mirrors `work_item_budgets` exactly (same columns, same confidence enum, same FK pattern). +4. **Work item linking**: M:N junction table `household_item_work_items` for coordination. Informational relationship, NOT a scheduling dependency in the Gantt engine. +5. **Purchase status workflow**: not_ordered -> ordered -> in_transit -> delivered (4 states, no backward transitions enforced at DB level). +6. **Category enum**: furniture, appliances, fixtures, decor, other. +7. **Room**: Free-text field (no predefined enum). Dynamic filter populated from distinct values. +8. **Document linking**: Already supported via EPIC-08's `document_links` table with `entity_type='household_item'`. Story 8.6 handles the UI. +9. **Budget overview integration**: Story 4.6 ensures household item budget lines contribute to project-wide totals (category sums, source usage, overall budget). + +## Requirements Coverage + +| Requirement Section | Covered By | +| ---------------------------------------- | -------------------------- | +| 2.3 Item Management | 4.1, 4.2, 4.3, 4.4, 4.5 | +| 2.3 Budget Integration | 4.6 | +| 2.3 Timeline Integration (data model) | 4.7 | +| 2.3 Timeline Integration (visualization) | EPIC-06 (future extension) | +| 2.3 Document Links | 8.6 (#359, EPIC-08) | +| 4 User Stories - track purchases | 4.2, 4.3, 4.4, 4.5 | +| 4 User Stories - delivery dates | 4.2, 4.4, 4.5 | +| 4 User Stories - link to work items | 4.7 | +| 4 User Stories - link documents | 8.6 | +| 4 User Stories - timeline delivery dates | EPIC-06 extension | +| 5 Key Decisions - NOT work items | All (separate entity) | + +## Acceptance Criteria Counts + +- Story 4.1: 8 ACs, 9 UAT scenarios +- Story 4.2: 10 ACs, 17 UAT scenarios +- Story 4.3: 11 ACs, 13 UAT scenarios +- Story 4.4: 12 ACs, 13 UAT scenarios +- Story 4.5: 12 ACs, 15 UAT scenarios +- Story 4.6: 10 ACs, 12 UAT scenarios +- Story 4.7: 10 ACs, 11 UAT scenarios +- Story 4.8: 12 ACs, 13 UAT scenarios +- **Total**: 85 ACs, 103 UAT scenarios diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md index ef6e0ac1e..98e14b531 100644 --- a/.claude/agent-memory/qa-integration-tester/MEMORY.md +++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md @@ -9,6 +9,11 @@ Worktrees have no `node_modules`. To run tests from a worktree: 1. Create symlinks: `ln -sf /main/node_modules /worktree/node_modules` and `ln -sf /main/server/node_modules /worktree/server/node_modules` 2. Run from the WORKTREE directory: `node --experimental-vm-modules /main/node_modules/.bin/jest "path/to/test.ts" --no-coverage` +3. **This worktree already has node_modules** — node_modules are present in the worktree directly. Run jest directly without symlink step. + +## Schema Quirk: tags table has NO updated_at + +The `tags` table (migration 0002) only has: `id, name, color, created_at` — NO `updated_at`. `TagResponse` also has no `updatedAt`. Do not include this field in test inserts or type assertions. - Do NOT cast `mockGet.mock.calls[0] as [string]` — TypeScript strict mode rejects empty arrays cast to tuple. Use `expect(mockGet).not.toHaveBeenCalledWith(expect.stringContaining(...))` pattern instead. diff --git a/server/src/db/migrations/0010_household_items.sql b/server/src/db/migrations/0010_household_items.sql new file mode 100644 index 000000000..b0ae57e60 --- /dev/null +++ b/server/src/db/migrations/0010_household_items.sql @@ -0,0 +1,113 @@ +-- Migration 0010: Create household items tables +-- +-- EPIC-04: Household Items & Furniture Management +-- +-- Creates the household_items entity and all supporting tables: +-- - household_item_tags (M:N with shared tags table) +-- - household_item_notes (comments/notes) +-- - household_item_budgets (budget lines, mirrors work_item_budgets) +-- - household_item_work_items (M:N link to work items for coordination) +-- - household_item_subsidies (M:N with subsidy programs) +-- +-- See ADR-016 for design rationale. +-- +-- ROLLBACK: +-- DROP TABLE IF EXISTS household_item_subsidies; +-- DROP TABLE IF EXISTS household_item_work_items; +-- DROP TABLE IF EXISTS household_item_budgets; +-- DROP TABLE IF EXISTS household_item_notes; +-- DROP TABLE IF EXISTS household_item_tags; +-- DROP TABLE IF EXISTS household_items; + +-- ── household_items ───────────────────────────────────────────────────────── + +CREATE TABLE household_items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + category TEXT NOT NULL DEFAULT 'other' + CHECK(category IN ('furniture', 'appliances', 'fixtures', 'decor', 'electronics', 'outdoor', 'storage', 'other')), + status TEXT NOT NULL DEFAULT 'not_ordered' + CHECK(status IN ('not_ordered', 'ordered', 'in_transit', 'delivered')), + vendor_id TEXT REFERENCES vendors(id) ON DELETE SET NULL, + url TEXT, + room TEXT, + quantity INTEGER NOT NULL DEFAULT 1 CHECK(quantity >= 1), + order_date TEXT, + expected_delivery_date TEXT, + actual_delivery_date TEXT, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_household_items_category ON household_items(category); +CREATE INDEX idx_household_items_status ON household_items(status); +CREATE INDEX idx_household_items_room ON household_items(room); +CREATE INDEX idx_household_items_vendor_id ON household_items(vendor_id); +CREATE INDEX idx_household_items_created_at ON household_items(created_at); + +-- ── household_item_tags ───────────────────────────────────────────────────── + +CREATE TABLE household_item_tags ( + household_item_id TEXT NOT NULL REFERENCES household_items(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (household_item_id, tag_id) +); + +CREATE INDEX idx_household_item_tags_tag_id ON household_item_tags(tag_id); + +-- ── household_item_notes ──────────────────────────────────────────────────── + +CREATE TABLE household_item_notes ( + id TEXT PRIMARY KEY, + household_item_id TEXT NOT NULL REFERENCES household_items(id) ON DELETE CASCADE, + content TEXT NOT NULL, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_household_item_notes_household_item_id ON household_item_notes(household_item_id); + +-- ── household_item_budgets ────────────────────────────────────────────────── + +CREATE TABLE household_item_budgets ( + id TEXT PRIMARY KEY, + household_item_id TEXT NOT NULL REFERENCES household_items(id) ON DELETE CASCADE, + description TEXT, + planned_amount REAL NOT NULL DEFAULT 0 CHECK(planned_amount >= 0), + confidence TEXT NOT NULL DEFAULT 'own_estimate' + CHECK(confidence IN ('own_estimate', 'professional_estimate', 'quote', 'invoice')), + budget_category_id TEXT REFERENCES budget_categories(id) ON DELETE SET NULL, + budget_source_id TEXT REFERENCES budget_sources(id) ON DELETE SET NULL, + vendor_id TEXT REFERENCES vendors(id) ON DELETE SET NULL, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_household_item_budgets_household_item_id ON household_item_budgets(household_item_id); +CREATE INDEX idx_household_item_budgets_vendor_id ON household_item_budgets(vendor_id); +CREATE INDEX idx_household_item_budgets_budget_category_id ON household_item_budgets(budget_category_id); +CREATE INDEX idx_household_item_budgets_budget_source_id ON household_item_budgets(budget_source_id); + +-- ── household_item_work_items ─────────────────────────────────────────────── + +CREATE TABLE household_item_work_items ( + household_item_id TEXT NOT NULL REFERENCES household_items(id) ON DELETE CASCADE, + work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE, + PRIMARY KEY (household_item_id, work_item_id) +); + +CREATE INDEX idx_household_item_work_items_work_item_id ON household_item_work_items(work_item_id); + +-- ── household_item_subsidies ──────────────────────────────────────────────── + +CREATE TABLE household_item_subsidies ( + household_item_id TEXT NOT NULL REFERENCES household_items(id) ON DELETE CASCADE, + subsidy_program_id TEXT NOT NULL REFERENCES subsidy_programs(id) ON DELETE CASCADE, + PRIMARY KEY (household_item_id, subsidy_program_id) +); + +CREATE INDEX idx_household_item_subsidies_subsidy_program_id ON household_item_subsidies(subsidy_program_id); diff --git a/server/src/db/migrations/0010_household_items.test.ts b/server/src/db/migrations/0010_household_items.test.ts new file mode 100644 index 000000000..a784bd919 --- /dev/null +++ b/server/src/db/migrations/0010_household_items.test.ts @@ -0,0 +1,765 @@ +/** + * Migration integration tests for 0010_household_items.sql + * + * Tests that the household items schema is created correctly with all + * constraints, indexes, and cascade behavior as defined in the migration. + * + * EPIC-04: Household Items & Furniture Management + */ + +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import Database from 'better-sqlite3'; +import { runMigrations } from '../migrate.js'; + +describe('Migration 0010: Household Items', () => { + let sqlite: Database.Database; + + function createTestDb() { + const db = new Database(':memory:'); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + runMigrations(db); + return db; + } + + /** + * Insert a minimal user row required by FK constraints. + */ + function insertUser(db: Database.Database, id: string) { + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO users (id, email, display_name, role, auth_provider, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ).run(id, `${id}@example.com`, `User ${id}`, 'member', 'local', now, now); + } + + /** + * Insert a minimal vendor row required by FK constraints. + */ + function insertVendor(db: Database.Database, id: string) { + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO vendors (id, name, created_at, updated_at) + VALUES (?, ?, ?, ?)`, + ).run(id, `Vendor ${id}`, now, now); + } + + /** + * Insert a minimal household item row. + */ + function insertHouseholdItem( + db: Database.Database, + id: string, + overrides: Record = {}, + ) { + const now = new Date().toISOString(); + const defaults = { + id, + name: `Item ${id}`, + category: 'other', + status: 'not_ordered', + quantity: 1, + created_at: now, + updated_at: now, + }; + const row = { ...defaults, ...overrides }; + db.prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (@id, @name, @category, @status, @quantity, @created_at, @updated_at)`, + ).run(row); + } + + /** + * Insert a minimal work item row required by FK constraints. + */ + function insertWorkItem(db: Database.Database, id: string) { + const now = new Date().toISOString(); + db.prepare( + `INSERT INTO work_items (id, title, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + ).run(id, `Work Item ${id}`, 'not_started', now, now); + } + + /** + * Insert a minimal tag row required by FK constraints. + * Note: tags table has no updated_at column (see migration 0002). + */ + function insertTag(db: Database.Database, id: string) { + const now = new Date().toISOString(); + db.prepare(`INSERT INTO tags (id, name, created_at) VALUES (?, ?, ?)`).run( + id, + `Tag ${id}`, + now, + ); + } + + beforeEach(() => { + sqlite = createTestDb(); + }); + + afterEach(() => { + sqlite.close(); + }); + + // ── 1. Migration runs successfully ───────────────────────────────────────── + + describe('migration execution', () => { + it('applies all prior migrations (0001-0009) without error', () => { + // The runMigrations() call in createTestDb() applies all migrations. + // If it reaches here the migration runner succeeded. + const applied = sqlite.prepare('SELECT name FROM _migrations ORDER BY name').all() as Array<{ + name: string; + }>; + + const names = applied.map((r) => r.name); + expect(names).toContain('0001_create_users_and_sessions.sql'); + expect(names).toContain('0009_document_links.sql'); + expect(names).toContain('0010_household_items.sql'); + }); + + it('creates all 6 household item tables', () => { + const tables = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'household_item%'`, + ) + .all() as Array<{ name: string }>; + + const tableNames = tables.map((t) => t.name).sort(); + expect(tableNames).toEqual([ + 'household_item_budgets', + 'household_item_notes', + 'household_item_subsidies', + 'household_item_tags', + 'household_item_work_items', + 'household_items', + ]); + }); + }); + + // ── 2. household_items table structure ──────────────────────────────────── + + describe('household_items table structure', () => { + it('has correct columns with expected types and nullability', () => { + const columns = sqlite.prepare("PRAGMA table_info('household_items')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + dflt_value: string | null; + }>; + + const colMap = new Map(columns.map((c) => [c.name, c])); + + // Primary key + expect(colMap.get('id')?.pk).toBe(1); + + // Required NOT NULL columns + expect(colMap.get('name')?.notnull).toBe(1); + expect(colMap.get('category')?.notnull).toBe(1); + expect(colMap.get('status')?.notnull).toBe(1); + expect(colMap.get('quantity')?.notnull).toBe(1); + expect(colMap.get('created_at')?.notnull).toBe(1); + expect(colMap.get('updated_at')?.notnull).toBe(1); + + // Nullable optional columns + expect(colMap.get('description')?.notnull).toBe(0); + expect(colMap.get('vendor_id')?.notnull).toBe(0); + expect(colMap.get('url')?.notnull).toBe(0); + expect(colMap.get('room')?.notnull).toBe(0); + expect(colMap.get('order_date')?.notnull).toBe(0); + expect(colMap.get('expected_delivery_date')?.notnull).toBe(0); + expect(colMap.get('actual_delivery_date')?.notnull).toBe(0); + expect(colMap.get('created_by')?.notnull).toBe(0); + + // All expected columns are present + const expectedColumns = [ + 'id', + 'name', + 'description', + 'category', + 'status', + 'vendor_id', + 'url', + 'room', + 'quantity', + 'order_date', + 'expected_delivery_date', + 'actual_delivery_date', + 'created_by', + 'created_at', + 'updated_at', + ]; + for (const col of expectedColumns) { + expect(colMap.has(col)).toBe(true); + } + }); + + it('has quantity typed as INTEGER', () => { + const columns = sqlite.prepare("PRAGMA table_info('household_items')").all() as Array<{ + name: string; + type: string; + }>; + const qtyCol = columns.find((c) => c.name === 'quantity'); + expect(qtyCol?.type).toBe('INTEGER'); + }); + + it('has planned_amount typed as REAL in household_item_budgets', () => { + const columns = sqlite.prepare("PRAGMA table_info('household_item_budgets')").all() as Array<{ + name: string; + type: string; + }>; + const amtCol = columns.find((c) => c.name === 'planned_amount'); + expect(amtCol?.type).toBe('REAL'); + }); + }); + + // ── 3. Category CHECK constraint ────────────────────────────────────────── + + describe('category CHECK constraint', () => { + const validCategories = [ + 'furniture', + 'appliances', + 'fixtures', + 'decor', + 'electronics', + 'outdoor', + 'storage', + 'other', + ]; + + it.each(validCategories)('accepts valid category: %s', (category) => { + const now = new Date().toISOString(); + expect(() => { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(`item-${category}`, `Test ${category}`, category, 'not_ordered', 1, now, now); + }).not.toThrow(); + + const row = sqlite + .prepare('SELECT category FROM household_items WHERE id = ?') + .get(`item-${category}`) as { category: string }; + expect(row.category).toBe(category); + }); + + it('rejects invalid category value', () => { + const now = new Date().toISOString(); + let error: Error | undefined; + try { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-bad-cat', 'Test Item', 'invalid_category', 'not_ordered', 1, now, now); + } catch (err) { + error = err as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toMatch(/CHECK constraint failed/); + }); + }); + + // ── 4. Status CHECK constraint ──────────────────────────────────────────── + + describe('status CHECK constraint', () => { + const validStatuses = ['not_ordered', 'ordered', 'in_transit', 'delivered']; + + it.each(validStatuses)('accepts valid status: %s', (status) => { + const now = new Date().toISOString(); + expect(() => { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run(`item-${status}`, `Test ${status}`, 'other', status, 1, now, now); + }).not.toThrow(); + }); + + it('rejects invalid status value', () => { + const now = new Date().toISOString(); + let error: Error | undefined; + try { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-bad-status', 'Test Item', 'other', 'unknown_status', 1, now, now); + } catch (err) { + error = err as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toMatch(/CHECK constraint failed/); + }); + }); + + // ── 5. Quantity CHECK constraint ────────────────────────────────────────── + + describe('quantity CHECK constraint', () => { + it('allows quantity of 1', () => { + const now = new Date().toISOString(); + expect(() => { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-qty-1', 'Test Item', 'other', 'not_ordered', 1, now, now); + }).not.toThrow(); + }); + + it('allows quantity greater than 1', () => { + const now = new Date().toISOString(); + expect(() => { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-qty-5', 'Test Item', 'other', 'not_ordered', 5, now, now); + }).not.toThrow(); + }); + + it('rejects quantity of 0', () => { + const now = new Date().toISOString(); + let error: Error | undefined; + try { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-qty-0', 'Test Item', 'other', 'not_ordered', 0, now, now); + } catch (err) { + error = err as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toMatch(/CHECK constraint failed/); + }); + + it('rejects negative quantity', () => { + const now = new Date().toISOString(); + let error: Error | undefined; + try { + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-qty-neg', 'Test Item', 'other', 'not_ordered', -1, now, now); + } catch (err) { + error = err as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toMatch(/CHECK constraint failed/); + }); + }); + + // ── 6. household_item_budgets planned_amount CHECK ──────────────────────── + + describe('household_item_budgets planned_amount CHECK constraint', () => { + it('allows planned_amount of 0', () => { + insertHouseholdItem(sqlite, 'item-budget-1'); + const now = new Date().toISOString(); + expect(() => { + sqlite + .prepare( + `INSERT INTO household_item_budgets (id, household_item_id, planned_amount, confidence, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run('budget-1', 'item-budget-1', 0, 'own_estimate', now, now); + }).not.toThrow(); + }); + + it('allows positive planned_amount', () => { + insertHouseholdItem(sqlite, 'item-budget-2'); + const now = new Date().toISOString(); + expect(() => { + sqlite + .prepare( + `INSERT INTO household_item_budgets (id, household_item_id, planned_amount, confidence, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run('budget-2', 'item-budget-2', 1500.5, 'quote', now, now); + }).not.toThrow(); + }); + + it('rejects negative planned_amount', () => { + insertHouseholdItem(sqlite, 'item-budget-3'); + const now = new Date().toISOString(); + let error: Error | undefined; + try { + sqlite + .prepare( + `INSERT INTO household_item_budgets (id, household_item_id, planned_amount, confidence, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run('budget-3', 'item-budget-3', -1, 'own_estimate', now, now); + } catch (err) { + error = err as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toMatch(/CHECK constraint failed/); + }); + }); + + // ── 7. household_item_tags composite PK ─────────────────────────────────── + + describe('household_item_tags composite primary key', () => { + it('prevents duplicate (household_item_id, tag_id) pairs', () => { + insertHouseholdItem(sqlite, 'item-tag-pk'); + insertTag(sqlite, 'tag-pk-1'); + + // First insert succeeds + sqlite + .prepare(`INSERT INTO household_item_tags (household_item_id, tag_id) VALUES (?, ?)`) + .run('item-tag-pk', 'tag-pk-1'); + + // Duplicate insert should fail + let error: Error | undefined; + try { + sqlite + .prepare(`INSERT INTO household_item_tags (household_item_id, tag_id) VALUES (?, ?)`) + .run('item-tag-pk', 'tag-pk-1'); + } catch (err) { + error = err as Error; + } + expect(error).toBeDefined(); + expect(error?.message).toMatch(/UNIQUE constraint failed/); + }); + + it('allows same item linked to different tags', () => { + insertHouseholdItem(sqlite, 'item-multi-tag'); + insertTag(sqlite, 'tag-a'); + insertTag(sqlite, 'tag-b'); + + expect(() => { + sqlite + .prepare(`INSERT INTO household_item_tags (household_item_id, tag_id) VALUES (?, ?)`) + .run('item-multi-tag', 'tag-a'); + sqlite + .prepare(`INSERT INTO household_item_tags (household_item_id, tag_id) VALUES (?, ?)`) + .run('item-multi-tag', 'tag-b'); + }).not.toThrow(); + + const links = sqlite + .prepare('SELECT * FROM household_item_tags WHERE household_item_id = ?') + .all('item-multi-tag'); + expect(links).toHaveLength(2); + }); + }); + + // ── 8. CASCADE on household item delete ─────────────────────────────────── + + describe('CASCADE delete from household_items', () => { + it('removes tag links when household item is deleted', () => { + insertHouseholdItem(sqlite, 'item-cascade-1'); + insertTag(sqlite, 'tag-cascade-1'); + sqlite + .prepare(`INSERT INTO household_item_tags (household_item_id, tag_id) VALUES (?, ?)`) + .run('item-cascade-1', 'tag-cascade-1'); + + sqlite.prepare('DELETE FROM household_items WHERE id = ?').run('item-cascade-1'); + + const links = sqlite + .prepare('SELECT * FROM household_item_tags WHERE household_item_id = ?') + .all('item-cascade-1'); + expect(links).toHaveLength(0); + }); + + it('removes notes when household item is deleted', () => { + insertHouseholdItem(sqlite, 'item-cascade-notes'); + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO household_item_notes (id, household_item_id, content, created_at, updated_at) + VALUES (?, ?, ?, ?, ?)`, + ) + .run('note-1', 'item-cascade-notes', 'A note', now, now); + + sqlite.prepare('DELETE FROM household_items WHERE id = ?').run('item-cascade-notes'); + + const notes = sqlite + .prepare('SELECT * FROM household_item_notes WHERE household_item_id = ?') + .all('item-cascade-notes'); + expect(notes).toHaveLength(0); + }); + + it('removes budget lines when household item is deleted', () => { + insertHouseholdItem(sqlite, 'item-cascade-budgets'); + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO household_item_budgets (id, household_item_id, planned_amount, confidence, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run('bud-1', 'item-cascade-budgets', 500, 'quote', now, now); + + sqlite.prepare('DELETE FROM household_items WHERE id = ?').run('item-cascade-budgets'); + + const budgets = sqlite + .prepare('SELECT * FROM household_item_budgets WHERE household_item_id = ?') + .all('item-cascade-budgets'); + expect(budgets).toHaveLength(0); + }); + + it('removes work item links when household item is deleted', () => { + insertHouseholdItem(sqlite, 'item-cascade-wi'); + insertWorkItem(sqlite, 'wi-cascade-1'); + sqlite + .prepare( + `INSERT INTO household_item_work_items (household_item_id, work_item_id) VALUES (?, ?)`, + ) + .run('item-cascade-wi', 'wi-cascade-1'); + + sqlite.prepare('DELETE FROM household_items WHERE id = ?').run('item-cascade-wi'); + + const links = sqlite + .prepare('SELECT * FROM household_item_work_items WHERE household_item_id = ?') + .all('item-cascade-wi'); + expect(links).toHaveLength(0); + }); + + it('removes subsidy links when household item is deleted', () => { + insertHouseholdItem(sqlite, 'item-cascade-sub'); + + // Need a subsidy_program row — insert via raw SQL to avoid deep FK chain + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO subsidy_programs (id, name, reduction_type, reduction_value, application_status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run('sp-1', 'Program A', 'percentage', 10, 'eligible', now, now); + + sqlite + .prepare( + `INSERT INTO household_item_subsidies (household_item_id, subsidy_program_id) VALUES (?, ?)`, + ) + .run('item-cascade-sub', 'sp-1'); + + sqlite.prepare('DELETE FROM household_items WHERE id = ?').run('item-cascade-sub'); + + const links = sqlite + .prepare('SELECT * FROM household_item_subsidies WHERE household_item_id = ?') + .all('item-cascade-sub'); + expect(links).toHaveLength(0); + }); + }); + + // ── 9. CASCADE on work item delete ──────────────────────────────────────── + + describe('CASCADE on work_item delete from household_item_work_items', () => { + it('removes junction rows when work item is deleted', () => { + insertHouseholdItem(sqlite, 'item-wi-cascade'); + insertWorkItem(sqlite, 'wi-to-delete'); + sqlite + .prepare( + `INSERT INTO household_item_work_items (household_item_id, work_item_id) VALUES (?, ?)`, + ) + .run('item-wi-cascade', 'wi-to-delete'); + + sqlite.prepare('DELETE FROM work_items WHERE id = ?').run('wi-to-delete'); + + const links = sqlite + .prepare('SELECT * FROM household_item_work_items WHERE work_item_id = ?') + .all('wi-to-delete'); + expect(links).toHaveLength(0); + }); + }); + + // ── 10. vendor_id SET NULL on vendor delete ─────────────────────────────── + + describe('vendor_id SET NULL on vendor delete', () => { + it('sets vendor_id to NULL when vendor is deleted', () => { + insertVendor(sqlite, 'vendor-setnull'); + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, vendor_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'item-vendor-setnull', + 'Test Item', + 'other', + 'not_ordered', + 1, + 'vendor-setnull', + now, + now, + ); + + // Verify vendor_id is set + const before = sqlite + .prepare('SELECT vendor_id FROM household_items WHERE id = ?') + .get('item-vendor-setnull') as { vendor_id: string | null }; + expect(before.vendor_id).toBe('vendor-setnull'); + + // Delete the vendor + sqlite.prepare('DELETE FROM vendors WHERE id = ?').run('vendor-setnull'); + + // vendor_id should be NULL now + const after = sqlite + .prepare('SELECT vendor_id FROM household_items WHERE id = ?') + .get('item-vendor-setnull') as { vendor_id: string | null }; + expect(after.vendor_id).toBeNull(); + }); + }); + + // ── 11. created_by SET NULL on user delete ──────────────────────────────── + + describe('created_by SET NULL on user delete', () => { + it('sets created_by to NULL when user is deleted', () => { + insertUser(sqlite, 'user-setnull'); + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO household_items (id, name, category, status, quantity, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run('item-user-setnull', 'Test Item', 'other', 'not_ordered', 1, 'user-setnull', now, now); + + // Verify created_by is set + const before = sqlite + .prepare('SELECT created_by FROM household_items WHERE id = ?') + .get('item-user-setnull') as { created_by: string | null }; + expect(before.created_by).toBe('user-setnull'); + + // Delete the user — need to clear session FK first if any + sqlite.prepare('DELETE FROM users WHERE id = ?').run('user-setnull'); + + // created_by should be NULL now + const after = sqlite + .prepare('SELECT created_by FROM household_items WHERE id = ?') + .get('item-user-setnull') as { created_by: string | null }; + expect(after.created_by).toBeNull(); + }); + }); + + // ── 12. Indexes on household_items ──────────────────────────────────────── + + describe('indexes on household_items', () => { + it('creates all 5 required indexes on household_items table', () => { + const indexes = sqlite + .prepare(`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='household_items'`) + .all() as Array<{ name: string }>; + + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain('idx_household_items_category'); + expect(indexNames).toContain('idx_household_items_status'); + expect(indexNames).toContain('idx_household_items_room'); + expect(indexNames).toContain('idx_household_items_vendor_id'); + expect(indexNames).toContain('idx_household_items_created_at'); + }); + + it('creates index on household_item_tags tag_id', () => { + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='household_item_tags'`, + ) + .all() as Array<{ name: string }>; + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain('idx_household_item_tags_tag_id'); + }); + + it('creates index on household_item_notes household_item_id', () => { + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='household_item_notes'`, + ) + .all() as Array<{ name: string }>; + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain('idx_household_item_notes_household_item_id'); + }); + + it('creates all 4 required indexes on household_item_budgets', () => { + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='household_item_budgets'`, + ) + .all() as Array<{ name: string }>; + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain('idx_household_item_budgets_household_item_id'); + expect(indexNames).toContain('idx_household_item_budgets_vendor_id'); + expect(indexNames).toContain('idx_household_item_budgets_budget_category_id'); + expect(indexNames).toContain('idx_household_item_budgets_budget_source_id'); + }); + + it('creates index on household_item_work_items work_item_id', () => { + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='household_item_work_items'`, + ) + .all() as Array<{ name: string }>; + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain('idx_household_item_work_items_work_item_id'); + }); + + it('creates index on household_item_subsidies subsidy_program_id', () => { + const indexes = sqlite + .prepare( + `SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='household_item_subsidies'`, + ) + .all() as Array<{ name: string }>; + const indexNames = indexes.map((i) => i.name); + expect(indexNames).toContain('idx_household_item_subsidies_subsidy_program_id'); + }); + }); + + // ── 13. Successful full insert ──────────────────────────────────────────── + + describe('full record insertion', () => { + it('can insert a household item with all optional fields populated', () => { + insertVendor(sqlite, 'vendor-full'); + insertUser(sqlite, 'user-full'); + + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO household_items + (id, name, description, category, status, vendor_id, url, room, quantity, + order_date, expected_delivery_date, actual_delivery_date, created_by, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + 'item-full', + 'Leather Sofa', + 'A beautiful 3-seat leather sofa', + 'furniture', + 'ordered', + 'vendor-full', + 'https://example.com/sofa', + 'Living Room', + 2, + '2025-01-15', + '2025-02-15', + null, + 'user-full', + now, + now, + ); + + const row = sqlite + .prepare('SELECT * FROM household_items WHERE id = ?') + .get('item-full') as Record; + + expect(row.name).toBe('Leather Sofa'); + expect(row.category).toBe('furniture'); + expect(row.status).toBe('ordered'); + expect(row.quantity).toBe(2); + expect(row.room).toBe('Living Room'); + expect(row.vendor_id).toBe('vendor-full'); + expect(row.url).toBe('https://example.com/sofa'); + expect(row.order_date).toBe('2025-01-15'); + expect(row.expected_delivery_date).toBe('2025-02-15'); + expect(row.actual_delivery_date).toBeNull(); + expect(row.created_by).toBe('user-full'); + }); + }); +}); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index a40d9ac2e..5ed7abc07 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -498,3 +498,183 @@ export const documentLinks = sqliteTable( paperlessDocIdx: index('idx_document_links_paperless_doc').on(table.paperlessDocumentId), }), ); + +// ─── EPIC-04: Household Items & Furniture Management ─────────────────────── + +/** + * Household items table - stores furniture, appliances, and other items for purchase. + * Tracks ordering status, delivery, and optional vendor association. + * EPIC-04: Central entity for household item management. + */ +export const householdItems = sqliteTable( + 'household_items', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + description: text('description'), + category: text('category', { + enum: [ + 'furniture', + 'appliances', + 'fixtures', + 'decor', + 'electronics', + 'outdoor', + 'storage', + 'other', + ], + }) + .notNull() + .default('other'), + status: text('status', { + enum: ['not_ordered', 'ordered', 'in_transit', 'delivered'], + }) + .notNull() + .default('not_ordered'), + vendorId: text('vendor_id').references(() => vendors.id, { onDelete: 'set null' }), + url: text('url'), + room: text('room'), + quantity: integer('quantity').notNull().default(1), + orderDate: text('order_date'), + expectedDeliveryDate: text('expected_delivery_date'), + actualDeliveryDate: text('actual_delivery_date'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => ({ + categoryIdx: index('idx_household_items_category').on(table.category), + statusIdx: index('idx_household_items_status').on(table.status), + roomIdx: index('idx_household_items_room').on(table.room), + vendorIdIdx: index('idx_household_items_vendor_id').on(table.vendorId), + createdAtIdx: index('idx_household_items_created_at').on(table.createdAt), + }), +); + +/** + * Household item tags junction table - many-to-many relationship between household items and tags. + */ +export const householdItemTags = sqliteTable( + 'household_item_tags', + { + householdItemId: text('household_item_id') + .notNull() + .references(() => householdItems.id, { onDelete: 'cascade' }), + tagId: text('tag_id') + .notNull() + .references(() => tags.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.householdItemId, table.tagId] }), + tagIdIdx: index('idx_household_item_tags_tag_id').on(table.tagId), + }), +); + +/** + * Household item notes table - stores notes/comments on household items. + * Notes are ordered by creation time descending (newest first). + */ +export const householdItemNotes = sqliteTable( + 'household_item_notes', + { + id: text('id').primaryKey(), + householdItemId: text('household_item_id') + .notNull() + .references(() => householdItems.id, { onDelete: 'cascade' }), + content: text('content').notNull(), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => ({ + householdItemIdIdx: index('idx_household_item_notes_household_item_id').on( + table.householdItemId, + ), + }), +); + +/** + * Household item budget lines table - tracks individual budget estimates/allocations for household items. + * Mirrors the work_item_budgets structure for consistency. + * Each line can reference a vendor, budget category, and budget source. + */ +export const householdItemBudgets = sqliteTable( + 'household_item_budgets', + { + id: text('id').primaryKey(), + householdItemId: text('household_item_id') + .notNull() + .references(() => householdItems.id, { onDelete: 'cascade' }), + description: text('description'), + plannedAmount: real('planned_amount').notNull().default(0), + confidence: text('confidence', { + enum: ['own_estimate', 'professional_estimate', 'quote', 'invoice'], + }) + .notNull() + .default('own_estimate'), + budgetCategoryId: text('budget_category_id').references(() => budgetCategories.id, { + onDelete: 'set null', + }), + budgetSourceId: text('budget_source_id').references(() => budgetSources.id, { + onDelete: 'set null', + }), + vendorId: text('vendor_id').references(() => vendors.id, { onDelete: 'set null' }), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => ({ + householdItemIdIdx: index('idx_household_item_budgets_household_item_id').on( + table.householdItemId, + ), + vendorIdIdx: index('idx_household_item_budgets_vendor_id').on(table.vendorId), + budgetCategoryIdIdx: index('idx_household_item_budgets_budget_category_id').on( + table.budgetCategoryId, + ), + budgetSourceIdIdx: index('idx_household_item_budgets_budget_source_id').on( + table.budgetSourceId, + ), + }), +); + +/** + * Household item work items junction table - M:N relationship between household items and work items. + * Represents coordination between household items and construction work items. + */ +export const householdItemWorkItems = sqliteTable( + 'household_item_work_items', + { + householdItemId: text('household_item_id') + .notNull() + .references(() => householdItems.id, { onDelete: 'cascade' }), + workItemId: text('work_item_id') + .notNull() + .references(() => workItems.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.householdItemId, table.workItemId] }), + workItemIdIdx: index('idx_household_item_work_items_work_item_id').on(table.workItemId), + }), +); + +/** + * Household item subsidies junction table - M:N relationship between household items and subsidy programs. + * Links household items to applicable subsidy programs for cost reduction. + */ +export const householdItemSubsidies = sqliteTable( + 'household_item_subsidies', + { + householdItemId: text('household_item_id') + .notNull() + .references(() => householdItems.id, { onDelete: 'cascade' }), + subsidyProgramId: text('subsidy_program_id') + .notNull() + .references(() => subsidyPrograms.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.householdItemId, table.subsidyProgramId] }), + subsidyProgramIdIdx: index('idx_household_item_subsidies_subsidy_program_id').on( + table.subsidyProgramId, + ), + }), +); diff --git a/shared/src/index.ts b/shared/src/index.ts index e7c18dce5..aa561ea1c 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -196,3 +196,20 @@ export type { DocumentLinkResponse, DocumentLinkListResponse, } from './types/document.js'; + +// Household Items +export type { + HouseholdItemCategory, + HouseholdItemStatus, + HouseholdItemVendorSummary, + HouseholdItemWorkItemSummary, + HouseholdItemSubsidySummary, + HouseholdItem, + HouseholdItemSummary, + HouseholdItemDetail, + CreateHouseholdItemRequest, + UpdateHouseholdItemRequest, + HouseholdItemListQuery, + HouseholdItemListResponse, + HouseholdItemResponse, +} from './types/householdItem.js'; diff --git a/shared/src/types/householdItem.test.ts b/shared/src/types/householdItem.test.ts new file mode 100644 index 000000000..3896cf7ca --- /dev/null +++ b/shared/src/types/householdItem.test.ts @@ -0,0 +1,638 @@ +/** + * Type-level tests for shared household item types. + * + * These tests verify that the TypeScript interfaces are correctly shaped + * and that all enum values are present. Because these are compile-time types, + * tests construct valid objects and assert their runtime values. + * + * EPIC-04: Household Items & Furniture Management + */ + +import { describe, it, expect } from '@jest/globals'; +import type { + HouseholdItemCategory, + HouseholdItemStatus, + HouseholdItemVendorSummary, + HouseholdItemWorkItemSummary, + HouseholdItemSubsidySummary, + HouseholdItem, + HouseholdItemSummary, + HouseholdItemDetail, + CreateHouseholdItemRequest, + UpdateHouseholdItemRequest, + HouseholdItemListQuery, + HouseholdItemListResponse, + HouseholdItemResponse, +} from './householdItem.js'; +import type { PaginatedResponse } from './pagination.js'; + +describe('HouseholdItemCategory type', () => { + it('accepts all 8 valid category values', () => { + const categories: HouseholdItemCategory[] = [ + 'furniture', + 'appliances', + 'fixtures', + 'decor', + 'electronics', + 'outdoor', + 'storage', + 'other', + ]; + + expect(categories).toHaveLength(8); + expect(categories).toContain('furniture'); + expect(categories).toContain('appliances'); + expect(categories).toContain('fixtures'); + expect(categories).toContain('decor'); + expect(categories).toContain('electronics'); + expect(categories).toContain('outdoor'); + expect(categories).toContain('storage'); + expect(categories).toContain('other'); + }); + + it('each category value is a distinct string', () => { + const categories: HouseholdItemCategory[] = [ + 'furniture', + 'appliances', + 'fixtures', + 'decor', + 'electronics', + 'outdoor', + 'storage', + 'other', + ]; + + const unique = new Set(categories); + expect(unique.size).toBe(8); + }); +}); + +describe('HouseholdItemStatus type', () => { + it('accepts all 4 valid status values', () => { + const statuses: HouseholdItemStatus[] = ['not_ordered', 'ordered', 'in_transit', 'delivered']; + + expect(statuses).toHaveLength(4); + expect(statuses).toContain('not_ordered'); + expect(statuses).toContain('ordered'); + expect(statuses).toContain('in_transit'); + expect(statuses).toContain('delivered'); + }); + + it('each status value is a distinct string', () => { + const statuses: HouseholdItemStatus[] = ['not_ordered', 'ordered', 'in_transit', 'delivered']; + const unique = new Set(statuses); + expect(unique.size).toBe(4); + }); +}); + +describe('HouseholdItemVendorSummary interface', () => { + it('constructs a valid vendor summary with all fields', () => { + const vendor: HouseholdItemVendorSummary = { + id: 'vendor-1', + name: 'IKEA', + specialty: 'Furniture', + }; + + expect(vendor.id).toBe('vendor-1'); + expect(vendor.name).toBe('IKEA'); + expect(vendor.specialty).toBe('Furniture'); + }); + + it('allows specialty to be null', () => { + const vendor: HouseholdItemVendorSummary = { + id: 'vendor-2', + name: 'Generic Supplier', + specialty: null, + }; + + expect(vendor.specialty).toBeNull(); + }); +}); + +describe('HouseholdItemWorkItemSummary interface', () => { + it('constructs a valid work item summary', () => { + const workItem: HouseholdItemWorkItemSummary = { + id: 'wi-1', + title: 'Install Kitchen Cabinets', + status: 'in_progress', + }; + + expect(workItem.id).toBe('wi-1'); + expect(workItem.title).toBe('Install Kitchen Cabinets'); + expect(workItem.status).toBe('in_progress'); + }); +}); + +describe('HouseholdItemSubsidySummary interface', () => { + it('constructs a valid subsidy summary with percentage reduction', () => { + const subsidy: HouseholdItemSubsidySummary = { + id: 'sp-1', + name: 'Green Energy Subsidy', + reductionType: 'percentage', + reductionValue: 15, + applicationStatus: 'approved', + }; + + expect(subsidy.id).toBe('sp-1'); + expect(subsidy.reductionType).toBe('percentage'); + expect(subsidy.reductionValue).toBe(15); + expect(subsidy.applicationStatus).toBe('approved'); + }); + + it('constructs a valid subsidy summary with fixed reduction', () => { + const subsidy: HouseholdItemSubsidySummary = { + id: 'sp-2', + name: 'Home Improvement Grant', + reductionType: 'fixed', + reductionValue: 500, + applicationStatus: 'eligible', + }; + + expect(subsidy.reductionType).toBe('fixed'); + expect(subsidy.reductionValue).toBe(500); + }); +}); + +describe('HouseholdItemSummary interface', () => { + it('constructs a valid summary object with all required fields', () => { + const summary: HouseholdItemSummary = { + id: 'item-1', + name: 'Leather Sofa', + description: 'A 3-seat leather sofa', + category: 'furniture', + status: 'ordered', + vendor: { + id: 'vendor-1', + name: 'IKEA', + specialty: null, + }, + room: 'Living Room', + quantity: 1, + orderDate: '2025-01-15', + expectedDeliveryDate: '2025-02-15', + actualDeliveryDate: null, + url: 'https://example.com/sofa', + tagIds: ['tag-1', 'tag-2'], + budgetLineCount: 2, + totalPlannedAmount: 1200.0, + createdBy: { + id: 'user-1', + displayName: 'Alice', + email: 'alice@example.com', + }, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-15T00:00:00Z', + }; + + expect(summary.id).toBe('item-1'); + expect(summary.name).toBe('Leather Sofa'); + expect(summary.category).toBe('furniture'); + expect(summary.status).toBe('ordered'); + expect(summary.vendor?.name).toBe('IKEA'); + expect(summary.tagIds).toHaveLength(2); + expect(summary.budgetLineCount).toBe(2); + expect(summary.totalPlannedAmount).toBe(1200.0); + }); + + it('allows vendor to be null', () => { + const summary: HouseholdItemSummary = { + id: 'item-2', + name: 'Dining Table', + description: null, + category: 'furniture', + status: 'not_ordered', + vendor: null, + room: null, + quantity: 1, + orderDate: null, + expectedDeliveryDate: null, + actualDeliveryDate: null, + url: null, + tagIds: [], + budgetLineCount: 0, + totalPlannedAmount: 0, + createdBy: null, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }; + + expect(summary.vendor).toBeNull(); + expect(summary.description).toBeNull(); + expect(summary.room).toBeNull(); + expect(summary.tagIds).toHaveLength(0); + }); +}); + +describe('HouseholdItemDetail interface', () => { + it('extends HouseholdItemSummary with additional detail fields', () => { + const detail: HouseholdItemDetail = { + // Fields from HouseholdItemSummary (url and createdBy are now part of summary) + id: 'item-detail-1', + name: 'Smart TV', + description: '65-inch 4K OLED television', + category: 'electronics', + status: 'delivered', + vendor: null, + room: 'Living Room', + quantity: 1, + orderDate: '2025-01-01', + expectedDeliveryDate: '2025-01-20', + actualDeliveryDate: '2025-01-18', + url: 'https://example.com/tv', + tagIds: ['tag-electronics'], + budgetLineCount: 1, + totalPlannedAmount: 1999.99, + createdBy: { + id: 'user-1', + displayName: 'Alice', + email: 'alice@example.com', + }, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-18T00:00:00Z', + // Additional HouseholdItemDetail fields + tags: [ + { + id: 'tag-electronics', + name: 'electronics', + color: '#0000ff', + createdAt: '2025-01-01T00:00:00Z', + }, + ], + workItems: [ + { + id: 'wi-1', + title: 'Mount TV', + status: 'not_started', + }, + ], + subsidies: [ + { + id: 'sp-1', + name: 'Electronics Grant', + reductionType: 'fixed', + reductionValue: 200, + applicationStatus: 'applied', + }, + ], + }; + + // Summary fields present + expect(detail.id).toBe('item-detail-1'); + expect(detail.category).toBe('electronics'); + expect(detail.totalPlannedAmount).toBe(1999.99); + + // Detail-specific fields present + expect(detail.url).toBe('https://example.com/tv'); + expect(detail.createdBy?.displayName).toBe('Alice'); + expect(detail.tags).toHaveLength(1); + expect(detail.workItems).toHaveLength(1); + expect(detail.subsidies).toHaveLength(1); + expect(detail.subsidies[0].applicationStatus).toBe('applied'); + }); + + it('allows url and createdBy to be null', () => { + const detail: HouseholdItemDetail = { + id: 'item-detail-2', + name: 'Bookshelf', + description: null, + category: 'storage', + status: 'not_ordered', + vendor: null, + room: null, + quantity: 1, + orderDate: null, + expectedDeliveryDate: null, + actualDeliveryDate: null, + tagIds: [], + budgetLineCount: 0, + totalPlannedAmount: 0, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + url: null, + createdBy: null, + tags: [], + workItems: [], + subsidies: [], + }; + + expect(detail.url).toBeNull(); + expect(detail.createdBy).toBeNull(); + expect(detail.tags).toHaveLength(0); + expect(detail.workItems).toHaveLength(0); + expect(detail.subsidies).toHaveLength(0); + }); +}); + +describe('CreateHouseholdItemRequest interface', () => { + it('requires only name field', () => { + const request: CreateHouseholdItemRequest = { + name: 'Sofa', + }; + + expect(request.name).toBe('Sofa'); + expect(request.description).toBeUndefined(); + expect(request.category).toBeUndefined(); + expect(request.status).toBeUndefined(); + expect(request.vendorId).toBeUndefined(); + expect(request.url).toBeUndefined(); + expect(request.room).toBeUndefined(); + expect(request.quantity).toBeUndefined(); + expect(request.orderDate).toBeUndefined(); + expect(request.expectedDeliveryDate).toBeUndefined(); + expect(request.actualDeliveryDate).toBeUndefined(); + }); + + it('accepts all optional fields', () => { + const request: CreateHouseholdItemRequest = { + name: 'Sofa', + description: 'A comfortable sofa', + category: 'furniture', + status: 'ordered', + vendorId: 'vendor-1', + url: 'https://example.com/sofa', + room: 'Living Room', + quantity: 2, + orderDate: '2025-01-15', + expectedDeliveryDate: '2025-02-15', + actualDeliveryDate: null, + }; + + expect(request.category).toBe('furniture'); + expect(request.status).toBe('ordered'); + expect(request.quantity).toBe(2); + expect(request.actualDeliveryDate).toBeNull(); + }); + + it('accepts null for nullable optional fields', () => { + const request: CreateHouseholdItemRequest = { + name: 'Chair', + description: null, + vendorId: null, + url: null, + room: null, + orderDate: null, + expectedDeliveryDate: null, + actualDeliveryDate: null, + }; + + expect(request.description).toBeNull(); + expect(request.vendorId).toBeNull(); + expect(request.url).toBeNull(); + expect(request.room).toBeNull(); + }); +}); + +describe('UpdateHouseholdItemRequest interface', () => { + it('allows single field update', () => { + const request: UpdateHouseholdItemRequest = { + status: 'delivered', + }; + + expect(request.status).toBe('delivered'); + expect(request.name).toBeUndefined(); + expect(request.category).toBeUndefined(); + }); + + it('allows updating name only', () => { + const request: UpdateHouseholdItemRequest = { + name: 'Updated Sofa Name', + }; + + expect(request.name).toBe('Updated Sofa Name'); + }); + + it('allows updating multiple fields at once', () => { + const request: UpdateHouseholdItemRequest = { + status: 'in_transit', + expectedDeliveryDate: '2025-03-01', + room: 'Bedroom', + }; + + expect(request.status).toBe('in_transit'); + expect(request.expectedDeliveryDate).toBe('2025-03-01'); + expect(request.room).toBe('Bedroom'); + }); + + it('allows all fields to be undefined (empty update)', () => { + const request: UpdateHouseholdItemRequest = {}; + expect(Object.keys(request)).toHaveLength(0); + }); +}); + +describe('HouseholdItemListQuery interface', () => { + it('allows all optional query parameters', () => { + const query: HouseholdItemListQuery = { + page: 2, + pageSize: 20, + q: 'sofa', + category: 'furniture', + status: 'ordered', + room: 'Living Room', + sortBy: 'name', + sortOrder: 'asc', + }; + + expect(query.page).toBe(2); + expect(query.pageSize).toBe(20); + expect(query.q).toBe('sofa'); + expect(query.category).toBe('furniture'); + expect(query.status).toBe('ordered'); + expect(query.room).toBe('Living Room'); + expect(query.sortBy).toBe('name'); + expect(query.sortOrder).toBe('asc'); + }); + + it('accepts vendorId and tagId filter parameters', () => { + const queryWithVendor: HouseholdItemListQuery = { vendorId: 'vendor-1' }; + expect(queryWithVendor.vendorId).toBe('vendor-1'); + + const queryWithTag: HouseholdItemListQuery = { tagId: 'tag-abc' }; + expect(queryWithTag.tagId).toBe('tag-abc'); + + const queryWithBoth: HouseholdItemListQuery = { + vendorId: 'vendor-2', + tagId: 'tag-xyz', + }; + expect(queryWithBoth.vendorId).toBe('vendor-2'); + expect(queryWithBoth.tagId).toBe('tag-xyz'); + }); + + it('accepts all sortBy values', () => { + const sortByValues: NonNullable[] = [ + 'name', + 'category', + 'status', + 'room', + 'order_date', + 'expected_delivery_date', + 'created_at', + 'updated_at', + ]; + + for (const sortBy of sortByValues) { + const query: HouseholdItemListQuery = { sortBy }; + expect(query.sortBy).toBe(sortBy); + } + }); + + it('accepts desc sortOrder', () => { + const query: HouseholdItemListQuery = { sortOrder: 'desc' }; + expect(query.sortOrder).toBe('desc'); + }); + + it('allows empty query (all fields optional)', () => { + const query: HouseholdItemListQuery = {}; + expect(Object.keys(query)).toHaveLength(0); + }); +}); + +describe('HouseholdItemListResponse type', () => { + it('is a PaginatedResponse of HouseholdItemSummary', () => { + const response: HouseholdItemListResponse = { + items: [ + { + id: 'item-1', + name: 'Sofa', + description: null, + category: 'furniture', + status: 'not_ordered', + vendor: null, + room: null, + quantity: 1, + orderDate: null, + expectedDeliveryDate: null, + actualDeliveryDate: null, + url: null, + tagIds: [], + budgetLineCount: 0, + totalPlannedAmount: 0, + createdBy: null, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }, + ], + pagination: { + page: 1, + pageSize: 20, + totalItems: 1, + totalPages: 1, + }, + }; + + // Verify structural compatibility with PaginatedResponse + const paginated: PaginatedResponse<(typeof response.items)[0]> = response; + expect(paginated.items).toHaveLength(1); + expect(paginated.pagination.totalItems).toBe(1); + expect(response.items[0].name).toBe('Sofa'); + }); + + it('handles empty list correctly', () => { + const response: HouseholdItemListResponse = { + items: [], + pagination: { + page: 1, + pageSize: 20, + totalItems: 0, + totalPages: 0, + }, + }; + + expect(response.items).toHaveLength(0); + expect(response.pagination.totalItems).toBe(0); + expect(response.pagination.totalPages).toBe(0); + }); +}); + +describe('HouseholdItemResponse interface', () => { + it('wraps a HouseholdItemDetail in the householdItem field', () => { + const response: HouseholdItemResponse = { + householdItem: { + id: 'item-resp-1', + name: 'Armchair', + description: null, + category: 'furniture', + status: 'not_ordered', + vendor: null, + room: null, + quantity: 1, + orderDate: null, + expectedDeliveryDate: null, + actualDeliveryDate: null, + tagIds: [], + budgetLineCount: 0, + totalPlannedAmount: 0, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + url: null, + createdBy: null, + tags: [], + workItems: [], + subsidies: [], + }, + }; + + expect(response.householdItem.id).toBe('item-resp-1'); + expect(response.householdItem.name).toBe('Armchair'); + expect(response.householdItem.category).toBe('furniture'); + expect(response.householdItem.tags).toHaveLength(0); + }); +}); + +describe('HouseholdItem entity interface', () => { + it('constructs a valid HouseholdItem with all required fields', () => { + const item: HouseholdItem = { + id: 'item-entity-1', + name: 'Coffee Table', + description: 'Oak wood coffee table', + category: 'furniture', + status: 'delivered', + vendorId: 'vendor-1', + url: 'https://example.com/table', + room: 'Living Room', + quantity: 1, + orderDate: '2025-01-01', + expectedDeliveryDate: '2025-02-01', + actualDeliveryDate: '2025-01-28', + createdBy: 'user-1', + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-28T00:00:00Z', + }; + + expect(item.id).toBe('item-entity-1'); + expect(item.name).toBe('Coffee Table'); + expect(item.category).toBe('furniture'); + expect(item.status).toBe('delivered'); + expect(item.vendorId).toBe('vendor-1'); + expect(item.quantity).toBe(1); + expect(item.actualDeliveryDate).toBe('2025-01-28'); + }); + + it('allows all nullable fields to be null', () => { + const item: HouseholdItem = { + id: 'item-entity-2', + name: 'Lamp', + description: null, + category: 'decor', + status: 'not_ordered', + vendorId: null, + url: null, + room: null, + quantity: 1, + orderDate: null, + expectedDeliveryDate: null, + actualDeliveryDate: null, + createdBy: null, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', + }; + + expect(item.description).toBeNull(); + expect(item.vendorId).toBeNull(); + expect(item.url).toBeNull(); + expect(item.room).toBeNull(); + expect(item.orderDate).toBeNull(); + expect(item.expectedDeliveryDate).toBeNull(); + expect(item.actualDeliveryDate).toBeNull(); + expect(item.createdBy).toBeNull(); + }); +}); diff --git a/shared/src/types/householdItem.ts b/shared/src/types/householdItem.ts new file mode 100644 index 000000000..27d9ae656 --- /dev/null +++ b/shared/src/types/householdItem.ts @@ -0,0 +1,187 @@ +/** + * Household item types and interfaces. + * Household items are furniture, appliances, and other items for purchase, + * tracked separately from construction work items. + * + * EPIC-04: Household Items & Furniture Management + */ + +import type { TagResponse } from './tag.js'; +import type { PaginatedResponse } from './pagination.js'; +import type { SubsidyApplicationStatus } from './subsidyProgram.js'; +import type { UserSummary } from './workItem.js'; + +/** + * Household item category enum - type of household item. + */ +export type HouseholdItemCategory = + | 'furniture' + | 'appliances' + | 'fixtures' + | 'decor' + | 'electronics' + | 'outdoor' + | 'storage' + | 'other'; + +/** + * Household item status enum - lifecycle status of a purchase. + */ +export type HouseholdItemStatus = 'not_ordered' | 'ordered' | 'in_transit' | 'delivered'; + +/** + * Vendor summary shape used in household item responses. + */ +export interface HouseholdItemVendorSummary { + id: string; + name: string; + specialty: string | null; +} + +/** + * Work item summary shape used in household item detail responses. + */ +export interface HouseholdItemWorkItemSummary { + id: string; + title: string; + status: string; +} + +/** + * Subsidy program summary shape used in household item detail responses. + */ +export interface HouseholdItemSubsidySummary { + id: string; + name: string; + reductionType: 'percentage' | 'fixed'; + reductionValue: number; + applicationStatus: SubsidyApplicationStatus; +} + +/** + * Household item entity as stored in the database. + */ +export interface HouseholdItem { + id: string; + name: string; + description: string | null; + category: HouseholdItemCategory; + status: HouseholdItemStatus; + vendorId: string | null; + url: string | null; + room: string | null; + quantity: number; + orderDate: string | null; + expectedDeliveryDate: string | null; + actualDeliveryDate: string | null; + createdBy: string | null; + createdAt: string; + updatedAt: string; +} + +/** + * Household item summary (used in list responses). + */ +export interface HouseholdItemSummary { + id: string; + name: string; + description: string | null; + category: HouseholdItemCategory; + status: HouseholdItemStatus; + vendor: HouseholdItemVendorSummary | null; + room: string | null; + quantity: number; + orderDate: string | null; + expectedDeliveryDate: string | null; + actualDeliveryDate: string | null; + url: string | null; + tagIds: string[]; + budgetLineCount: number; + totalPlannedAmount: number; + createdBy: UserSummary | null; + createdAt: string; + updatedAt: string; +} + +/** + * Household item detail (used in single-item responses). + */ +export interface HouseholdItemDetail extends HouseholdItemSummary { + tags: TagResponse[]; + workItems: HouseholdItemWorkItemSummary[]; + subsidies: HouseholdItemSubsidySummary[]; +} + +/** + * Request body for creating a new household item. + */ +export interface CreateHouseholdItemRequest { + name: string; + description?: string | null; + category?: HouseholdItemCategory; + status?: HouseholdItemStatus; + vendorId?: string | null; + url?: string | null; + room?: string | null; + quantity?: number; + orderDate?: string | null; + expectedDeliveryDate?: string | null; + actualDeliveryDate?: string | null; + tagIds?: string[]; +} + +/** + * Request body for updating a household item. + * All fields are optional; at least one must be provided. + * Sending tagIds replaces the entire tag set (set-semantics). + */ +export interface UpdateHouseholdItemRequest { + name?: string; + description?: string | null; + category?: HouseholdItemCategory; + status?: HouseholdItemStatus; + vendorId?: string | null; + url?: string | null; + room?: string | null; + quantity?: number; + orderDate?: string | null; + expectedDeliveryDate?: string | null; + actualDeliveryDate?: string | null; + tagIds?: string[]; +} + +/** + * Query parameters for GET /api/household-items. + */ +export interface HouseholdItemListQuery { + page?: number; + pageSize?: number; + q?: string; + category?: HouseholdItemCategory; + status?: HouseholdItemStatus; + room?: string; + vendorId?: string; + tagId?: string; + sortBy?: + | 'name' + | 'category' + | 'status' + | 'room' + | 'order_date' + | 'expected_delivery_date' + | 'created_at' + | 'updated_at'; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Response for GET /api/household-items (paginated list). + */ +export type HouseholdItemListResponse = PaginatedResponse; + +/** + * Response for single household item endpoints (POST, GET by ID, PATCH). + */ +export interface HouseholdItemResponse { + householdItem: HouseholdItemDetail; +}