-
Notifications
You must be signed in to change notification settings - Fork 2
Schema
The database schema evolves incrementally as each epic is implemented. This page documents entity descriptions, relationships, indexes, and rationale for all tables.
- All table and column names use snake_case
- Primary keys use TEXT type with application-generated UUIDs (
crypto.randomUUID()), namedid - Foreign keys follow the pattern
<referenced_table_singular>_id(e.g.,user_id,budget_category_id) - All entities include
created_atandupdated_attimestamps (ISO 8601, stored as TEXT in SQLite) - Soft deletes are used selectively: the
userstable usesdeactivated_atfor audit trail and referential integrity; most other entities use hard deletes - Boolean values are stored as INTEGER (0/1) per SQLite convention
- Foreign keys enforce
ON DELETE CASCADEwhere appropriate - Junction tables use composite primary keys (no surrogate
idcolumn)
Stores all user accounts (both local and OIDC-authenticated).
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
email |
TEXT | UNIQUE NOT NULL | User's email address (unique across all auth providers) |
display_name |
TEXT | NOT NULL | User's display name |
role |
TEXT | NOT NULL DEFAULT 'member' CHECK(role IN ('admin','member')) | Authorization role |
auth_provider |
TEXT | NOT NULL CHECK(auth_provider IN ('local','oidc')) | Authentication method |
password_hash |
TEXT | nullable | Argon2id hash; only for local users |
oidc_subject |
TEXT | nullable | OIDC provider's subject claim; only for OIDC users |
deactivated_at |
TEXT | nullable | ISO 8601 timestamp; NULL = active, set = soft-deleted |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
sqlite_autoindex_users_1 |
email |
UNIQUE | Enforced by UNIQUE constraint; fast email lookups for login |
idx_users_oidc_lookup |
auth_provider, oidc_subject |
UNIQUE (partial: WHERE oidc_subject IS NOT NULL) | Fast OIDC user lookup; prevents duplicate OIDC subjects per provider |
Design rationale:
-
TEXT primary key (UUID): Avoids auto-increment integer IDs that could leak information about user count or creation order. UUIDs are generated at the application layer using Node.js
crypto.randomUUID()for portability. -
deactivated_atinstead ofis_active: Provides both the active/inactive state and the timestamp of deactivation for audit purposes. Active users are queried withWHERE deactivated_at IS NULL. -
auth_provider+oidc_subjectcomposite unique index: Allows efficient OIDC user lookup during login. The partial index (WHERE oidc_subject IS NOT NULL) avoids indexing local users who have NULL oidc_subject. -
emailis globally unique: A single email can only belong to one user, regardless of auth provider. This prevents confusion and simplifies the user model. If an OIDC user has the same email as a local user, the OIDC callback must handle this conflict explicitly (Story 1.5). -
Nullable
password_hash/oidc_subject: These are mutually exclusive by auth_provider. Local users have password_hash but no oidc_subject; OIDC users have oidc_subject but no password_hash. Application-layer validation enforces this invariant.
Stores server-side session data for authenticated users.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | 256-bit cryptographically random hex string (crypto.randomBytes(32).toString('hex')) |
user_id |
TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE | Owning user |
expires_at |
TEXT | NOT NULL | ISO 8601 timestamp; session invalid after this time |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_sessions_user_id |
user_id |
INDEX | Fast lookup of all sessions for a user (used during user deactivation to invalidate all sessions) |
idx_sessions_expires_at |
expires_at |
INDEX | Efficient cleanup of expired sessions |
Design rationale:
- TEXT primary key (random hex): 256-bit random tokens provide 2^256 possible values, making brute-force infeasible. Hex encoding produces a 64-character string that fits naturally in TEXT.
-
No
updated_at: Sessions are immutable after creation; they are either valid or deleted. There is no session renewal/extension. - ON DELETE CASCADE: When a user is deleted (or if hard-delete is ever used), all their sessions are automatically cleaned up.
-
expires_atindex: Supports periodic cleanup queries (DELETE FROM sessions WHERE expires_at < datetime('now')).
server/src/db/migrations/0001_create_users_and_sessions.sql
-- EPIC-01: Authentication & User Management
-- Creates the users and sessions tables for authentication.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('admin', 'member')),
auth_provider TEXT NOT NULL CHECK(auth_provider IN ('local', 'oidc')),
password_hash TEXT,
oidc_subject TEXT,
deactivated_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX idx_users_oidc_lookup
ON users (auth_provider, oidc_subject)
WHERE oidc_subject IS NOT NULL;
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_user_id ON sessions (user_id);
CREATE INDEX idx_sessions_expires_at ON sessions (expires_at);
-- Rollback:
-- DROP INDEX IF EXISTS idx_sessions_expires_at;
-- DROP INDEX IF EXISTS idx_sessions_user_id;
-- DROP TABLE IF EXISTS sessions;
-- DROP INDEX IF EXISTS idx_users_oidc_lookup;
-- DROP TABLE IF EXISTS users;The central entity of the application. Represents a construction task or activity in the home building project. Budget tracking is handled via the work_item_budgets child table (EPIC-05 Story 5.9). Document link fields (paperless_ngx references) will be added by EPIC-08.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
title |
TEXT | NOT NULL | Short title of the work item |
description |
TEXT | nullable | Detailed description (supports free-form text) |
status |
TEXT | NOT NULL DEFAULT 'not_started' CHECK(status IN ('not_started','in_progress','completed')) | Current work item status |
start_date |
TEXT | nullable | Planned start date (ISO 8601 date, e.g., 2026-03-15) |
end_date |
TEXT | nullable | Planned end date (ISO 8601 date, e.g., 2026-03-20) |
actual_start_date |
TEXT | nullable | Actual start date (ISO 8601 date); auto-set on status transition to in_progress
|
actual_end_date |
TEXT | nullable | Actual end date (ISO 8601 date); auto-set on status transition to completed
|
duration_days |
INTEGER | nullable | Duration in working days; can be set manually or computed from start_date/end_date |
start_after |
TEXT | nullable | Earliest possible start constraint (ISO 8601 date); used by the scheduling engine |
start_before |
TEXT | nullable | Latest possible start constraint (ISO 8601 date); used by the scheduling engine |
assigned_user_id |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User responsible for this work item |
created_by |
TEXT | NOT NULL, REFERENCES users(id) ON DELETE SET NULL | User who created this work item |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Status values:
| Value | Description |
|---|---|
not_started |
Work has not begun (default) |
in_progress |
Work is actively underway |
completed |
Work is finished |
Actual dates auto-population:
When the status field is updated, actual dates are automatically set to the current date (YYYY-MM-DD) unless an explicit value is provided in the same request:
| Transition | Auto-set field(s) |
|---|---|
not_started -> in_progress
|
actual_start_date = today |
in_progress -> completed
|
actual_end_date = today |
not_started -> completed
|
actual_start_date = today, actual_end_date = today |
If the request body explicitly includes actualStartDate or actualEndDate (even as null), the explicit value is used and auto-population is skipped for that field.
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_items_status |
status |
INDEX | Filter work items by status (most common filter) |
idx_work_items_assigned_user_id |
assigned_user_id |
INDEX | Filter work items by assignee |
idx_work_items_created_at |
created_at |
INDEX | Default sort order for list endpoint (newest first) |
Design rationale:
-
statusCHECK constraint: The three status values (not_started,in_progress,completed) cover all practical states for a construction task. Theblockedstatus was removed in migration 0008 (PR #308, Issue #296) because blocking is better represented through dependency relationships and tags rather than a status enum. "Cancelled" was omitted because deleting the work item achieves the same result for this small-scale application. -
actual_start_date/actual_end_date: These columns track when work actually began and finished, as opposed to the plannedstart_date/end_date. They are auto-populated on status transitions (see table above) but can be explicitly set or cleared by the user. The scheduling engine usesactual_start_dateas a fixed start date (overriding CPM forward pass), andactual_end_datealongsideactual_start_dateas a fixed finish date. -
duration_daysalongsidestart_date/end_date: Requirements specify duration can be "calculated or manual." Storing it allows users to set a duration before a start date is known. When both start_date and end_date are set, the frontend can compute duration for display; the stored value represents a manual override. The scheduling engine (EPIC-06) will use this field to auto-calculate end_date from start_date + duration when cascading updates. -
start_after/start_beforeconstraints: These are scheduling constraints separate from the actual start_date. They represent external constraints like vendor availability windows or weather requirements. The scheduling engine uses these to constrain automatic rescheduling. -
assigned_user_idON DELETE SET NULL: If a user is deactivated or deleted, the work item remains with an unassigned state rather than being deleted. This preserves project data. -
created_byON DELETE SET NULL: Preserves the work item even if the creating user is removed. The SET NULL FK means the creator is unknown but the work item survives. -
No
prioritycolumn: Priority was initially considered but not present in the requirements (Section 2.1 lists status but not priority). Instead, the requirements specify "Tags" for custom organization, which covers priority labeling (users can create "High Priority", "Critical" tags). This avoids an opinionated enum that may not match every homeowner's workflow. -
No budget columns on
work_items: Budget tracking was originally added as flat columns on work_items (migration 0004) but was reworked in Story 5.9 to use a separatework_item_budgetschild table. This allows multiple budget lines per work item (e.g., separate labor and materials estimates from different vendors), each with its own confidence level and budget source. Migration 0005 removes the flat budget columns and replaces them with the budget lines model.
User-defined labels for organizing work items. Tags are a shared resource available to all work items.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | UNIQUE NOT NULL | Tag display name (case-insensitive uniqueness enforced at application layer) |
color |
TEXT | nullable | Hex color code (e.g., #FF5733) for visual display |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
sqlite_autoindex_tags_1 |
name |
UNIQUE | Enforced by UNIQUE constraint; prevents duplicate tag names |
Design rationale:
-
No
updated_at: Tags are simple name+color entities. Updates are infrequent and the creation timestamp is sufficient for ordering. If update tracking becomes needed, this column can be added. -
nameUNIQUE: SQLite UNIQUE is case-sensitive by default. Case-insensitive uniqueness (e.g., "Electrical" vs "electrical") is enforced at the application layer by normalizing comparisons before insert/update. This avoids relying on SQLite collation which has limited case-folding for non-ASCII characters. -
Nullable
color: Tags without a color use a default display color chosen by the frontend. -
Tags will be reused by household items (EPIC-04): The
work_item_tagsjunction table is specific to work items, but ahousehold_item_tagsjunction table will be added later using the sametagstable.
Junction table linking work items to tags (many-to-many relationship).
| Column | Type | Constraints | Description |
|---|---|---|---|
work_item_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | The work item |
tag_id |
TEXT | NOT NULL, REFERENCES tags(id) ON DELETE CASCADE | The tag |
Primary key: Composite (work_item_id, tag_id)
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_item_tags_tag_id |
tag_id |
INDEX | Efficient lookup of all work items with a given tag (the composite PK already provides work_item_id lookup) |
Design rationale:
- Composite primary key: No surrogate ID needed. The combination of work_item_id + tag_id is naturally unique.
- CASCADE on both sides: Deleting a work item removes its tag associations. Deleting a tag removes it from all work items.
-
Index on
tag_id: The composite PK index is ordered (work_item_id, tag_id), which is efficient for "get all tags for a work item." The separate tag_id index supports the reverse query: "get all work items for a tag" (used in filtering).
Free-form notes attached to a work item, attributed to the user who wrote them. Supports an activity-log style display on the work item detail page.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
work_item_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | Parent work item |
content |
TEXT | NOT NULL | Note content (free-form text) |
created_by |
TEXT | NOT NULL, REFERENCES users(id) ON DELETE SET NULL | User who authored the note |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on edit |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_item_notes_work_item_id |
work_item_id |
INDEX | Fast retrieval of all notes for a work item |
Design rationale:
-
created_byON DELETE SET NULL: If the authoring user is removed, the note survives with an unknown author. This preserves project history. -
CASCADE from
work_item_id: When a work item is deleted, all its notes are deleted. Notes do not exist independently of their parent work item. -
updated_atincluded: Notes can be edited; tracking the last edit time helps users see if a note has been modified since they last read it.
Checklist items within a work item. Subtasks are ordered and can be individually completed.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
work_item_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | Parent work item |
title |
TEXT | NOT NULL | Subtask description |
is_completed |
INTEGER | NOT NULL DEFAULT 0 | Completion state (0 = incomplete, 1 = completed) |
sort_order |
INTEGER | NOT NULL DEFAULT 0 | Display order (ascending); supports drag-and-drop reordering |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_item_subtasks_work_item_id |
work_item_id |
INDEX | Fast retrieval of all subtasks for a work item |
Design rationale:
-
is_completedas INTEGER: SQLite has no native boolean type. INTEGER with 0/1 convention is standard. -
sort_orderfor drag-and-drop: Enables client-side reordering. The bulk reorder endpoint updates sort_order values for all subtasks in a single operation, avoiding per-item PATCH calls. -
CASCADE from
work_item_id: Subtasks are tightly coupled to their parent work item and are deleted when the work item is deleted.
Defines predecessor/successor relationships between work items for scheduling and Gantt chart visualization. Supports four standard dependency types used in project management. The lead_lag_days column (added by migration 0006 in EPIC-06) supports lead and lag time between dependent tasks.
| Column | Type | Constraints | Description |
|---|---|---|---|
predecessor_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | The work item that must complete (or start) first |
successor_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | The work item that depends on the predecessor |
dependency_type |
TEXT | NOT NULL DEFAULT 'finish_to_start' CHECK(dependency_type IN ('finish_to_start','start_to_start','finish_to_finish','start_to_finish')) | Type of scheduling dependency |
lead_lag_days |
INTEGER | NOT NULL DEFAULT 0 | Lead (negative) or lag (positive) days offset; e.g., +3 = 3-day gap, -2 = 2-day overlap |
Primary key: Composite (predecessor_id, successor_id)
Check constraint: predecessor_id != successor_id (prevents self-referencing dependencies)
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_item_dependencies_successor_id |
successor_id |
INDEX | Efficient lookup of predecessors for a given work item (the composite PK index covers predecessor_id lookups) |
Dependency types:
| Value | Abbreviation | Description |
|---|---|---|
finish_to_start |
FS | Successor cannot start until predecessor finishes (most common) |
start_to_start |
SS | Successor cannot start until predecessor starts |
finish_to_finish |
FF | Successor cannot finish until predecessor finishes |
start_to_finish |
SF | Successor cannot finish until predecessor starts (rare) |
Design rationale:
- Composite primary key: A dependency between two work items is uniquely identified by the pair. No surrogate ID needed. This also prevents duplicate dependencies between the same two items.
-
No separate
idcolumn: The delete endpoint usespredecessor_idas the identifier (within the context of the successor work item), which maps directly to the composite PK. This keeps the API clean. - Self-reference CHECK constraint: Prevents nonsensical self-dependencies at the database level.
- Circular dependency detection at application layer: SQLite cannot enforce acyclicity via constraints. The API layer performs a depth-first traversal before creating a dependency to detect and reject cycles.
- CASCADE on both FKs: When either work item in a dependency pair is deleted, the dependency is removed. This prevents orphaned dependencies and ensures the dependency graph stays valid.
-
finish_to_startas default: This is by far the most common dependency type in construction projects ("do this before that"). -
lead_lag_dayscolumn (added by migration 0006): Supports lead and lag time offsets between dependent tasks. Positive values add waiting time (lag); negative values allow overlap (lead). Default 0 means the dependency relationship has no time offset. This column is used by the scheduling engine (ADR-014) when computing earliest/latest start and finish dates.
+------------------+ +------------------+
| users | | sessions |
+------------------+ +------------------+
| id (PK, UUID) |<---------| id (PK, random) |
| email (UNIQUE) | 1:N | user_id (FK) |
| display_name | | expires_at |
| role | | created_at |
| auth_provider | +------------------+
| password_hash |
| oidc_subject |
| deactivated_at |
| created_at |
| updated_at |
+------------------+
| |
| 1:N | 1:N (assigned / created_by)
| |
| +----+----------------+
| | work_items |
| +---------------------+
| | id (PK, UUID) |
| | title |
| | description |
| | status |
| | start_date |
| | end_date |
| | duration_days |
| | start_after |
| | start_before |
| | assigned_user_id FK |
| | created_by FK |
| | created_at |
| | updated_at |
| +---------------------+
| | | |
| | 1:N | M:N | 1:N
| | | |
| +----+---+ +--+----+ +--------+----------+
| | notes | | w_i_ | | subtasks |
| +--------+ | tags | +-------------------+
| | id | +-------+ | id |
| | w_i_id | | w_i_id| | work_item_id FK |
| | content| | tag_id| | title |
| | c_by FK| +-------+ | is_completed |
| | c_at | | | sort_order |
| | u_at | | | created_at |
| +--------+ | | updated_at |
| +----+---+ +-------------------+
| | tags |
| +--------+
| | id |
| | name |
| | color |
| | c_at |
| +--------+
|
| work_item_dependencies
| +---------------------+
| | predecessor_id FK |---+
| | successor_id FK |---+---> work_items
| | dependency_type |
| +---------------------+
Key relationships:
-
users -> work_items: One user can be assigned to many work items (
assigned_user_id). One user can create many work items (created_by). Both use SET NULL on delete. - work_items -> work_item_tags -> tags: Many-to-many. A work item can have multiple tags; a tag can be on multiple work items.
- work_items -> work_item_notes: One-to-many. A work item can have many notes. Notes cascade-delete with their parent.
- work_items -> work_item_subtasks: One-to-many. A work item can have many subtasks. Subtasks cascade-delete with their parent.
- work_items -> work_item_dependencies -> work_items: Many-to-many self-referential. A work item can have multiple predecessors and multiple successors.
server/src/db/migrations/0002_create_work_items.sql
-- EPIC-03: Work Items Core CRUD & Properties
-- Creates the work items, tags, notes, subtasks, and dependencies tables.
CREATE TABLE work_items (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'not_started' CHECK(status IN ('not_started', 'in_progress', 'completed', 'blocked')),
start_date TEXT,
end_date TEXT,
duration_days INTEGER,
start_after TEXT,
start_before TEXT,
assigned_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_work_items_status ON work_items (status);
CREATE INDEX idx_work_items_assigned_user_id ON work_items (assigned_user_id);
CREATE INDEX idx_work_items_created_at ON work_items (created_at);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE work_item_tags (
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (work_item_id, tag_id)
);
CREATE INDEX idx_work_item_tags_tag_id ON work_item_tags (tag_id);
CREATE TABLE work_item_notes (
id TEXT PRIMARY KEY,
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_work_item_notes_work_item_id ON work_item_notes (work_item_id);
CREATE TABLE work_item_subtasks (
id TEXT PRIMARY KEY,
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
title TEXT NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_work_item_subtasks_work_item_id ON work_item_subtasks (work_item_id);
CREATE TABLE work_item_dependencies (
predecessor_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
successor_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
dependency_type TEXT NOT NULL DEFAULT 'finish_to_start' CHECK(dependency_type IN ('finish_to_start', 'start_to_start', 'finish_to_finish', 'start_to_finish')),
PRIMARY KEY (predecessor_id, successor_id),
CHECK (predecessor_id != successor_id)
);
CREATE INDEX idx_work_item_dependencies_successor_id ON work_item_dependencies (successor_id);
-- Rollback:
-- DROP INDEX IF EXISTS idx_work_item_dependencies_successor_id;
-- DROP TABLE IF EXISTS work_item_dependencies;
-- DROP INDEX IF EXISTS idx_work_item_subtasks_work_item_id;
-- DROP TABLE IF EXISTS work_item_subtasks;
-- DROP INDEX IF EXISTS idx_work_item_notes_work_item_id;
-- DROP TABLE IF EXISTS work_item_notes;
-- DROP INDEX IF EXISTS idx_work_item_tags_tag_id;
-- DROP TABLE IF EXISTS work_item_tags;
-- DROP TABLE IF EXISTS tags;
-- DROP INDEX IF EXISTS idx_work_items_created_at;
-- DROP INDEX IF EXISTS idx_work_items_assigned_user_id;
-- DROP INDEX IF EXISTS idx_work_items_status;
-- DROP TABLE IF EXISTS work_items;EPIC-05 adds budget tracking with categories, vendors, invoices, financing sources, and subsidy programs. All budget tables are created in a single foundation migration (0003) so that later stories only add API routes and UI.
Pre-defined and user-extensible categories for organizing construction costs (e.g., Materials, Labor, Permits). Seeded with 10 default categories on first migration.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | UNIQUE NOT NULL | Category name (e.g., "Materials", "Labor") |
description |
TEXT | nullable | Optional description of what this category covers |
color |
TEXT | nullable | Hex color code (e.g., #3B82F6) for UI display |
sort_order |
INTEGER | NOT NULL DEFAULT 0 | Display order (ascending); supports drag-and-drop reordering |
created_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
sqlite_autoindex_budget_categories_1 |
name |
UNIQUE | Enforced by UNIQUE constraint; prevents duplicate category names |
Design rationale:
- TEXT primary key (UUID): Consistent with all other entities in the schema. UUIDs prevent information leakage about category count.
-
sort_orderfor display ordering: Categories have a natural display order (e.g., Materials first, Other last). Users can reorder via the UI. Thesort_orderfield enables this without relying on alphabetical sorting. -
created_at/updated_atwith DEFAULT: Budget categories use SQLite'sdatetime('now')default unlike the application-generated timestamps in earlier tables. This is safe because budget categories are primarily managed through simple CRUD operations where the application will still set these explicitly on insert/update -- the DEFAULT acts as a safety net for seed data. - 10 default categories seeded in migration: The migration includes INSERT statements to seed default categories. These provide a useful starting point; users can rename, reorder, or delete them.
-
No
created_by: Budget categories are a shared organizational resource, not user-authored content. Tracking who created a category adds no value.
Vendor/contractor database for tracking companies and individuals involved in the construction project.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | NOT NULL | Vendor/contractor name |
specialty |
TEXT | nullable | Area of expertise (e.g., "Electrical", "Plumbing") |
phone |
TEXT | nullable | Contact phone number |
email |
TEXT | nullable | Contact email address |
address |
TEXT | nullable | Business address |
notes |
TEXT | nullable | Free-form notes about this vendor |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who added this vendor |
created_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_vendors_name |
name |
INDEX | Supports sorting and searching vendors by name |
Design rationale:
-
nameis NOT UNIQUE: Multiple vendors can share a name (e.g., "Smith Construction" in different cities). Users distinguish them by other fields. -
created_byON DELETE SET NULL: Preserves vendor records even if the creating user is removed. Consistent with work_items pattern. -
No separate address fields: A single
addressTEXT field is sufficient for this scale. Structured address parsing (street, city, state, zip) would add complexity without benefit for < 5 users.
Tracks invoices from vendors for payment management and cost tracking. Invoices can optionally be linked to a specific work item budget line for granular cost tracking.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
vendor_id |
TEXT | NOT NULL, REFERENCES vendors(id) ON DELETE CASCADE | The vendor who issued this invoice |
work_item_budget_id |
TEXT | nullable, REFERENCES work_item_budgets(id) ON DELETE SET NULL | Optional link to a budget line for cost tracking |
invoice_number |
TEXT | nullable | Vendor's invoice number/reference |
amount |
REAL | NOT NULL | Invoice amount in the project's currency |
date |
TEXT | NOT NULL | Invoice date (ISO 8601 date, e.g., 2026-04-15) |
due_date |
TEXT | nullable | Payment due date (ISO 8601 date) |
status |
TEXT | NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','paid','claimed')) | Payment status |
notes |
TEXT | nullable | Free-form notes about this invoice |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who recorded this invoice |
created_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_invoices_vendor_id |
vendor_id |
INDEX | Fast lookup of all invoices for a vendor |
idx_invoices_status |
status |
INDEX | Filter invoices by payment status |
idx_invoices_date |
date |
INDEX | Sort/filter invoices by date |
idx_invoices_work_item_budget_id |
work_item_budget_id |
INDEX | Fast lookup of all invoices linked to a budget line (used for actualCost computation) |
Invoice status values:
| Value | Description |
|---|---|
pending |
Invoice received, payment not yet made (default) |
paid |
Payment has been completed |
claimed |
Invoice has been claimed (e.g., submitted to a subsidy program or financing source for reimbursement) |
Design rationale:
-
amountas REAL: SQLite does not have a DECIMAL type. REAL (IEEE 754 double) provides sufficient precision for construction invoices (typically whole dollars or two decimal places). For a < 5 user home building app, floating-point rounding is acceptable. If exact decimal arithmetic were needed, amounts could be stored as INTEGER cents, but that adds conversion overhead throughout the application layer. -
CASCADE from
vendor_id: Deleting a vendor removes all its invoices. This is consistent with the "vendor owns their invoices" model. If invoice history must survive vendor deletion, this should be revisited. -
created_byON DELETE SET NULL: Preserves invoice records when the recording user is removed. -
work_item_budget_idFK with ON DELETE SET NULL: Invoices can optionally be linked to a budget line. If the budget line is deleted, the invoice survives with the link cleared. This allows invoices to exist independently (some may be for general project costs not tied to a specific work item budget line). -
Status changed from
overduetoclaimed: Theoverduestatus was replaced byclaimedin Story 5.9. Overdue detection is better handled as a computed state (comparingdue_dateto the current date) rather than a manually set status. Theclaimedstatus supports the subsidy/reimbursement workflow where an invoice has been submitted for payment by a third party.
Financing sources for the construction project (bank loans, credit lines, savings accounts, etc.).
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | NOT NULL | Source name (e.g., "ABC Bank Mortgage", "Home Savings") |
source_type |
TEXT | NOT NULL CHECK(source_type IN ('bank_loan','credit_line','savings','other','discretionary')) | Type of financing |
is_discretionary |
INTEGER | NOT NULL DEFAULT 0 | 1 for the system-managed discretionary funding source, 0 otherwise |
total_amount |
REAL | NOT NULL | Total amount available from this source |
interest_rate |
REAL | nullable | Annual interest rate as a decimal (e.g., 3.5 for 3.5%) |
terms |
TEXT | nullable | Loan terms description (e.g., "30-year fixed", "5-year revolving") |
notes |
TEXT | nullable | Free-form notes |
status |
TEXT | NOT NULL DEFAULT 'active' CHECK(status IN ('active','exhausted','closed')) | Current status of this funding source |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who added this source |
created_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, updated on every modification |
Budget source type values:
| Value | Description |
|---|---|
bank_loan |
Traditional bank loan or mortgage |
credit_line |
Revolving credit line |
savings |
Personal savings or cash reserves |
other |
Any other financing source |
discretionary |
System-managed source for unallocated invoice remainders (see ADR-019) |
Budget source status values:
| Value | Description |
|---|---|
active |
Source is available for use (default) |
exhausted |
All funds have been allocated/spent |
closed |
Source has been closed or terminated |
Design rationale:
-
interest_rateas REAL: Stored as a percentage value (e.g., 3.5 means 3.5%), not a fraction. This matches how users think about rates and avoids conversion errors. -
total_amountwithoutused_amountorclaimed_amount: BothusedAmount(planned allocation: sum of linked budget lines'planned_amount) andclaimedAmount(actual drawdown: sum of claimed invoices on linked budget lines) are computed at the API layer. Storing them would create data consistency risks. The API also exposes derived fieldsavailableAmount(totalAmount - usedAmount) andactualAvailableAmount(totalAmount - claimedAmount) for the two perspectives (planned vs actual). -
No payment schedule table: The requirements mention "Payment schedules to creditors" but for this scale (< 5 users), the
termsandnotesfields are sufficient to describe payment schedules. A dedicated payment schedule table would add significant complexity for minimal benefit. -
is_discretionarysentinel column: Theis_discretionaryflag identifies the system-managed "Discretionary Funding" row (seeded by migration 0021 with iddiscretionary-system). This source captures invoice remainder amounts (invoice total minus sum of itemized budget line amounts) and budget lines with NULLbudget_source_id. It cannot be deleted or have itssource_typechanged. Itstotal_amountis always 0 because all amounts are computed at the API layer. Thediscretionarysource type is not available in create/update schemas -- only system-seeded.
Government or institutional programs that reduce construction costs through percentage discounts or fixed reductions.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | NOT NULL | Program name (e.g., "Green Building Tax Credit") |
description |
TEXT | nullable | Program description |
eligibility |
TEXT | nullable | Eligibility requirements description |
reduction_type |
TEXT | NOT NULL CHECK(reduction_type IN ('percentage','fixed')) | How the subsidy reduces costs |
reduction_value |
REAL | NOT NULL | Reduction amount: percentage (e.g., 15.0 for 15%) or fixed currency amount |
application_status |
TEXT | NOT NULL DEFAULT 'eligible' CHECK(application_status IN ('eligible','applied','approved','received','rejected')) | Current application status |
application_deadline |
TEXT | nullable | Application deadline (ISO 8601 date) |
notes |
TEXT | nullable | Free-form notes |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who added this program |
created_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, updated on every modification |
Application status values:
| Value | Description |
|---|---|
eligible |
Program identified, not yet applied (default) |
applied |
Application has been submitted |
approved |
Application approved, awaiting funds |
received |
Subsidy funds/discount received |
rejected |
Application was rejected |
Design rationale:
-
reduction_valueinterpretation depends onreduction_type: Whenreduction_typeis'percentage',reduction_valueis a percentage (e.g., 15.0 means 15% off). Whenreduction_typeis'fixed',reduction_valueis a currency amount. The application layer handles the math based on the type. -
application_statusas a linear progression: The five statuses represent a typical subsidy application lifecycle. The progression is not enforced at the database level (application logic can allow any status transition) because real-world processes may skip steps. -
eligibilityas free text: Eligibility criteria vary wildly between programs. A structured approach (income limits, property type, etc.) would be over-engineering for this use case.
Junction table linking subsidy programs to the budget categories they apply to (many-to-many). A subsidy program may apply to multiple categories (e.g., a "Green Building" subsidy applies to both Materials and Equipment).
| Column | Type | Constraints | Description |
|---|---|---|---|
subsidy_program_id |
TEXT | NOT NULL, REFERENCES subsidy_programs(id) ON DELETE CASCADE | The subsidy program |
budget_category_id |
TEXT | NOT NULL, REFERENCES budget_categories(id) ON DELETE CASCADE | The applicable budget category |
Primary key: Composite (subsidy_program_id, budget_category_id)
Design rationale:
- CASCADE on both sides: Deleting a subsidy program removes its category associations. Deleting a budget category removes it from all subsidy programs.
- Composite primary key: No surrogate ID needed. Consistent with other junction tables in the schema.
Budget line items for a work item. Each budget line represents a cost estimate or allocation with its own confidence level, optional vendor, budget category, and budget source. This replaces the previous flat budget fields on work_items and the work_item_vendors junction table, enabling multiple budget lines per work item (e.g., separate labor and materials estimates).
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
work_item_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | Parent work item |
description |
TEXT | nullable | Description of what this budget line covers (max 500 chars) |
planned_amount |
REAL | NOT NULL DEFAULT 0, CHECK(planned_amount >= 0) | Planned/estimated cost amount |
confidence |
TEXT | NOT NULL DEFAULT 'own_estimate' CHECK(confidence IN ('own_estimate','professional_estimate','quote','invoice')) | Cost confidence level |
budget_category_id |
TEXT | nullable, REFERENCES budget_categories(id) ON DELETE SET NULL | Optional budget category for this line |
budget_source_id |
TEXT | nullable, REFERENCES budget_sources(id) ON DELETE SET NULL | Optional financing source for this line |
vendor_id |
TEXT | nullable, REFERENCES vendors(id) ON DELETE SET NULL | Optional vendor associated with this line |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who created this budget line |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_item_budgets_work_item_id |
work_item_id |
INDEX | Fast retrieval of all budget lines for a work item |
idx_work_item_budgets_vendor_id |
vendor_id |
INDEX | Fast lookup of budget lines by vendor (used by vendor delete check) |
idx_work_item_budgets_budget_category_id |
budget_category_id |
INDEX | Fast lookup of budget lines by category (used by category delete check) |
idx_work_item_budgets_budget_source_id |
budget_source_id |
INDEX | Fast lookup of budget lines by financing source |
Confidence levels:
| DB Value | Display Label | Margin | Description |
|---|---|---|---|
own_estimate |
Own Estimate | +/-20% | Homeowner's rough estimate (default) |
professional_estimate |
Professional Estimate | +/-10% | Estimate from a contractor or professional |
quote |
Quote | +/-5% | Formal quote from a vendor |
invoice |
Invoice | +/-0% | Final invoiced amount (most accurate) |
Design rationale:
-
Replaces flat budget columns on
work_items: The original design (migration 0004) addedplanned_budget,actual_cost,confidence_percent,budget_category_id, andbudget_source_iddirectly to thework_itemstable. This limited each work item to a single budget estimate with one vendor, one category, and one source. The budget lines model allows multiple estimates per work item, each with independent confidence, vendor, category, and source associations. -
Replaces
work_item_vendorsjunction table: The vendor relationship is now captured per-budget-line rather than as a simple many-to-many. This is more meaningful: instead of "this work item involves vendor X", it now says "vendor X quoted $5,000 for this specific aspect of the work item." -
planned_amount>= 0: Budget amounts cannot be negative. A CHECK constraint enforces this at the database level. -
confidenceas enum instead ofconfidence_percent: The original design used an integer percentage. The enum approach is more structured and maps directly to known confidence margins (20%, 10%, 5%, 0%). The application layer computes the margin range from the enum value. - ON DELETE SET NULL for vendor, category, source FKs: If a referenced entity is deleted, the budget line survives with the reference cleared. This preserves budget data even when reorganizing vendors or categories.
-
CASCADE from
work_item_id: Budget lines are tightly coupled to their parent work item and are deleted when the work item is deleted. -
created_byON DELETE SET NULL: Preserves budget line records when the creating user is removed. Consistent with other entities. -
Actual cost is computed, not stored: The actual cost for a budget line is the sum of all linked invoices (via
invoices.work_item_budget_id). Storing it would create a data consistency risk.
This table was dropped in migration 0005. The work item-to-vendor relationship is now captured through work_item_budgets.vendor_id, which provides a more meaningful association: each budget line can reference the vendor responsible for that specific cost estimate or work allocation.
Previously, this was a simple many-to-many junction table linking work items to vendors. The work_item_budgets model supersedes it by embedding the vendor reference in each budget line.
Junction table linking work items to subsidy programs (many-to-many). A work item can benefit from multiple subsidies; a subsidy program can apply to multiple work items.
| Column | Type | Constraints | Description |
|---|---|---|---|
work_item_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | The work item |
subsidy_program_id |
TEXT | NOT NULL, REFERENCES subsidy_programs(id) ON DELETE CASCADE | The subsidy program |
Primary key: Composite (work_item_id, subsidy_program_id)
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_work_item_subsidies_subsidy_program_id |
subsidy_program_id |
INDEX | Efficient lookup of all work items for a given subsidy program |
Design rationale:
- CASCADE on both sides: Deleting a work item removes its subsidy associations. Deleting a subsidy program removes it from all work items.
-
No
applied_amountcolumn: The subsidy reduction is computed from the subsidy program'sreduction_typeandreduction_valueapplied to the work item's budget. Storing a per-work-item amount would duplicate information and risk inconsistency.
+------------------+ +------------------+
| users | | sessions |
+------------------+ +------------------+
| id (PK, UUID) |<---------| id (PK, random) |
| email (UNIQUE) | 1:N | user_id (FK) |
| display_name | | expires_at |
| role | | created_at |
| auth_provider | +------------------+
| password_hash |
| oidc_subject |
| deactivated_at |
| created_at |
| updated_at |
+------------------+
| | |
| 1:N | 1:N | 1:N (created_by on vendors, invoices, budgets, etc.)
| | |
| +----+---------+------+
| | work_items | +---------------------+
| +---------------------+ | budget_categories |
| | id (PK, UUID) | +---------------------+
| | title | | id (PK, UUID) |
| | description | | name (UNIQUE) |
| | status | | description |
| | start_date | | color |
| | end_date | | sort_order |
| | duration_days | | created_at |
| | start_after | | updated_at |
| | start_before | +---------------------+
| | assigned_user_id FK | | |
| | created_by FK | | M:N | 0:N
| | created_at | | |
| | updated_at | +---------+----+ |
| +---------------------+ | subsidy_prog_| |
| | | | | | | categories | |
| | | | | | +--------------+ |
| | | | | | | sub_prog_id | |
| | | | | | | bud_cat_id | |
| | | | | | +--------------+ |
| | | | | | | |
| | | | | M:N +-------+--------+ |
| | | | | | | subsidy_programs| |
| | | | | | +-----------------+ |
| | | | +-+---++ | id (PK, UUID) | |
| | | | | w_i_ | | name | |
| | | | | subs | | reduction_type | |
| | | | +------+ | reduction_value | |
| | | | | w_i_id| | app_status | |
| | | | | sub_id| +-----------------+ |
| | | | +------+ |
| | | | |
| | | 1:N (work_item_budgets) |
| | | | |
| | | +-+-----------------------+ |
| | | | work_item_budgets | |
| | | +-------------------------+ |
| | | | id (PK, UUID) | |
| | | | work_item_id FK | |
| | | | description | |
| | | | planned_amount | |
| | | | confidence (enum) | |
| | | | budget_category_id FK --+------------+
| | | | budget_source_id FK ----+---> budget_sources
| | | | vendor_id FK -----------+---> vendors
| | | | created_by FK |
| | | +-------------------------+
| | | |
| | 1:N M:N | 0:N
| | | | |
| +----+--+ +--+---+ | +---------------------+
| | notes | | w_i_ | | | vendors |
| +-------+ | tags | | +---------------------+
| | id | +------+ | | id (PK, UUID) |
| | w_i_id| | w_i_id|| | name |
| | content| tag_id|| | specialty |
| | c_by | +------+| | phone, email |
| | c_at | | | | created_by FK |
| | u_at | | | +---------------------+
| +-------+ | | |
| +----+--+ | | 1:N
| | tags | | |
| +--------+ | +----+-----------+
| | id | | | invoices |
| | name | | +----------------+
| | color | | | id |
| | c_at | +--->| w_i_budget_id |
| +--------+ | vendor_id FK |
| | amount |
| work_item_deps | status |
| +----------------+ | date |
| | pred_id FK | | c_by FK |
| | succ_id FK | +----------------+
| | dep_type |
| | lead_lag_days |
| +----------------+ +---------------------+
| | budget_sources |
| work_item_subtasks +---------------------+
| +----------------+ | id (PK, UUID) |
| | id | | name |
| | work_item_id | | source_type |
| | title | | total_amount |
| | is_completed | | interest_rate |
| | sort_order | | terms, status |
| +----------------+ | created_by FK |
| +---------------------+
Key relationships (EPIC-05):
- work_items -> work_item_budgets: One-to-many. A work item can have multiple budget lines. Budget lines cascade-delete with their parent work item.
- work_item_budgets -> budget_categories: Many-to-one (optional). Each budget line can be assigned to one category. ON DELETE SET NULL preserves the budget line if the category is removed.
- work_item_budgets -> budget_sources: Many-to-one (optional). Each budget line can be linked to one financing source. ON DELETE SET NULL preserves the budget line if the source is removed.
-
work_item_budgets -> vendors: Many-to-one (optional). Each budget line can reference one vendor. ON DELETE SET NULL preserves the budget line if the vendor is removed. This replaces the old
work_item_vendorsmany-to-many junction table. -
work_item_budgets -> invoices (via
invoices.work_item_budget_id): One-to-many. A budget line can have multiple invoices linked to it. ON DELETE SET NULL preserves invoices if the budget line is removed. -
budget_categories: Referenced by
work_item_budgetsandsubsidy_program_categories. Delete is blocked if any budget lines or subsidy programs reference the category. - vendors -> invoices: One-to-many. A vendor can have many invoices. Invoices cascade-delete with their vendor.
- work_items -> work_item_subsidies -> subsidy_programs: Many-to-many. A work item can benefit from multiple subsidies; a subsidy can apply to multiple work items.
- subsidy_programs -> subsidy_program_categories -> budget_categories: Many-to-many. A subsidy program can apply to multiple budget categories; a category can be covered by multiple subsidy programs.
-
budget_sources: Referenced by
work_item_budgets. Linked to specific budget lines per work item. -
users -> vendors/invoices/budget_sources/subsidy_programs/work_item_budgets: One-to-many via
created_by. All use ON DELETE SET NULL to preserve records when users are removed.
server/src/db/migrations/0003_create_budget_tables.sql
-- EPIC-05: Budget Management
-- Creates all budget-related tables: categories, vendors, invoices,
-- budget sources, subsidy programs, and junction tables.
-- Budget categories for organizing construction costs
CREATE TABLE budget_categories (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Seed default budget categories
INSERT INTO budget_categories (id, name, description, color, sort_order, created_at, updated_at) VALUES
('bc-materials', 'Materials', 'Raw materials and building supplies', '#3B82F6', 0, datetime('now'), datetime('now')),
('bc-labor', 'Labor', 'Contractor and worker labor costs', '#EF4444', 1, datetime('now'), datetime('now')),
('bc-permits', 'Permits', 'Building permits and regulatory fees', '#F59E0B', 2, datetime('now'), datetime('now')),
('bc-design', 'Design', 'Architectural and design services', '#8B5CF6', 3, datetime('now'), datetime('now')),
('bc-equipment', 'Equipment', 'Tools and equipment rental or purchase', '#06B6D4', 4, datetime('now'), datetime('now')),
('bc-landscaping', 'Landscaping', 'Outdoor landscaping and hardscaping', '#22C55E', 5, datetime('now'), datetime('now')),
('bc-utilities', 'Utilities', 'Utility connections and installations', '#F97316', 6, datetime('now'), datetime('now')),
('bc-insurance', 'Insurance', 'Construction and builder risk insurance', '#6366F1', 7, datetime('now'), datetime('now')),
('bc-contingency', 'Contingency', 'Reserve funds for unexpected costs', '#EC4899', 8, datetime('now'), datetime('now')),
('bc-other', 'Other', 'Miscellaneous costs not covered by other categories', '#6B7280', 9, datetime('now'), datetime('now'));
-- Vendor/contractor database
CREATE TABLE vendors (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
specialty TEXT,
phone TEXT,
email TEXT,
address TEXT,
notes 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_vendors_name ON vendors (name);
-- Invoice tracking per vendor
CREATE TABLE invoices (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
invoice_number TEXT,
amount REAL NOT NULL,
date TEXT NOT NULL,
due_date TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'paid', 'overdue')),
notes 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_invoices_vendor_id ON invoices (vendor_id);
CREATE INDEX idx_invoices_status ON invoices (status);
CREATE INDEX idx_invoices_date ON invoices (date);
-- Financing sources (bank loans, credit lines, savings, etc.)
CREATE TABLE budget_sources (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source_type TEXT NOT NULL CHECK(source_type IN ('bank_loan', 'credit_line', 'savings', 'other', 'discretionary')),
total_amount REAL NOT NULL,
interest_rate REAL,
terms TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'exhausted', 'closed')),
is_discretionary INTEGER NOT NULL DEFAULT 0,
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'))
);
-- Subsidy/incentive programs
CREATE TABLE subsidy_programs (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
eligibility TEXT,
reduction_type TEXT NOT NULL CHECK(reduction_type IN ('percentage', 'fixed')),
reduction_value REAL NOT NULL,
application_status TEXT NOT NULL DEFAULT 'eligible' CHECK(application_status IN ('eligible', 'applied', 'approved', 'received', 'rejected')),
application_deadline TEXT,
notes 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'))
);
-- Junction: subsidy programs <-> budget categories (M:N)
CREATE TABLE subsidy_program_categories (
subsidy_program_id TEXT NOT NULL REFERENCES subsidy_programs(id) ON DELETE CASCADE,
budget_category_id TEXT NOT NULL REFERENCES budget_categories(id) ON DELETE CASCADE,
PRIMARY KEY (subsidy_program_id, budget_category_id)
);
-- Junction: work items <-> vendors (M:N)
CREATE TABLE work_item_vendors (
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
PRIMARY KEY (work_item_id, vendor_id)
);
CREATE INDEX idx_work_item_vendors_vendor_id ON work_item_vendors (vendor_id);
-- Junction: work items <-> subsidy programs (M:N)
CREATE TABLE work_item_subsidies (
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
subsidy_program_id TEXT NOT NULL REFERENCES subsidy_programs(id) ON DELETE CASCADE,
PRIMARY KEY (work_item_id, subsidy_program_id)
);
CREATE INDEX idx_work_item_subsidies_subsidy_program_id ON work_item_subsidies (subsidy_program_id);
-- Rollback:
-- DROP INDEX IF EXISTS idx_work_item_subsidies_subsidy_program_id;
-- DROP TABLE IF EXISTS work_item_subsidies;
-- DROP INDEX IF EXISTS idx_work_item_vendors_vendor_id;
-- DROP TABLE IF EXISTS work_item_vendors;
-- DROP TABLE IF EXISTS subsidy_program_categories;
-- DROP TABLE IF EXISTS subsidy_programs;
-- DROP TABLE IF EXISTS budget_sources;
-- DROP INDEX IF EXISTS idx_invoices_date;
-- DROP INDEX IF EXISTS idx_invoices_status;
-- DROP INDEX IF EXISTS idx_invoices_vendor_id;
-- DROP TABLE IF EXISTS invoices;
-- DROP INDEX IF EXISTS idx_vendors_name;
-- DROP TABLE IF EXISTS vendors;
-- DROP TABLE IF EXISTS budget_categories;server/src/db/migrations/0004_add_work_item_budget_fields.sql
This migration added flat budget columns (planned_budget, actual_cost, confidence_percent, budget_category_id, budget_source_id) to the work_items table. These columns are removed by migration 0005 which replaces them with the work_item_budgets child table model.
-- EPIC-05 Story #147: Add budget fields to work items (SUPERSEDED by migration 0005)
ALTER TABLE work_items ADD COLUMN planned_budget REAL;
ALTER TABLE work_items ADD COLUMN actual_cost REAL;
ALTER TABLE work_items ADD COLUMN confidence_percent INTEGER;
ALTER TABLE work_items ADD COLUMN budget_category_id TEXT REFERENCES budget_categories(id) ON DELETE SET NULL;
ALTER TABLE work_items ADD COLUMN budget_source_id TEXT REFERENCES budget_sources(id) ON DELETE SET NULL;
CREATE INDEX idx_work_items_budget_category ON work_items(budget_category_id);
CREATE INDEX idx_work_items_budget_source ON work_items(budget_source_id);server/src/db/migrations/0005_budget_system_rework.sql
This migration performs the budget system rework (Story 5.9):
- Creates the
work_item_budgetstable - Recreates
work_itemswithout the flat budget columns (SQLite requires table recreation for column removal) - Adds
work_item_budget_idFK toinvoicesand changes the status enum frompending|paid|overduetopending|paid|claimed(also requires table recreation) - Drops the
work_item_vendorsjunction table
Note: Because SQLite does not support DROP COLUMN or ALTER COLUMN, migrations 0005 recreates the work_items and invoices tables by:
- Creating a new table with the desired schema
- Copying data from the old table
- Dropping the old table
- Renaming the new table
- Recreating indexes and foreign key references
This is the standard SQLite pattern for schema changes that cannot be expressed as ALTER TABLE ADD COLUMN.
-- EPIC-05 Story 5.9: Budget System Rework
-- Replaces flat budget fields on work_items with work_item_budgets child table.
-- Adds work_item_budget_id to invoices, changes invoice status enum.
-- Drops work_item_vendors junction table.
-- 1. Create work_item_budgets table
CREATE TABLE work_item_budgets (
id TEXT PRIMARY KEY,
work_item_id TEXT NOT NULL REFERENCES work_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,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_work_item_budgets_work_item_id ON work_item_budgets(work_item_id);
CREATE INDEX idx_work_item_budgets_vendor_id ON work_item_budgets(vendor_id);
CREATE INDEX idx_work_item_budgets_budget_category_id ON work_item_budgets(budget_category_id);
CREATE INDEX idx_work_item_budgets_budget_source_id ON work_item_budgets(budget_source_id);
-- 2. Recreate work_items without budget columns
-- (SQLite does not support DROP COLUMN for columns with FK constraints)
CREATE TABLE work_items_new (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'not_started'
CHECK(status IN ('not_started', 'in_progress', 'completed', 'blocked')),
start_date TEXT,
end_date TEXT,
duration_days INTEGER,
start_after TEXT,
start_before TEXT,
assigned_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO work_items_new (id, title, description, status, start_date, end_date,
duration_days, start_after, start_before, assigned_user_id, created_by,
created_at, updated_at)
SELECT id, title, description, status, start_date, end_date,
duration_days, start_after, start_before, assigned_user_id, created_by,
created_at, updated_at
FROM work_items;
DROP TABLE work_items;
ALTER TABLE work_items_new RENAME TO work_items;
CREATE INDEX idx_work_items_status ON work_items(status);
CREATE INDEX idx_work_items_assigned_user_id ON work_items(assigned_user_id);
CREATE INDEX idx_work_items_created_at ON work_items(created_at);
-- 3. Recreate invoices with work_item_budget_id FK and updated status enum
CREATE TABLE invoices_new (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
work_item_budget_id TEXT REFERENCES work_item_budgets(id) ON DELETE SET NULL,
invoice_number TEXT,
amount REAL NOT NULL,
date TEXT NOT NULL,
due_date TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'paid', 'claimed')),
notes 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'))
);
INSERT INTO invoices_new (id, vendor_id, invoice_number, amount, date, due_date,
status, notes, created_by, created_at, updated_at)
SELECT id, vendor_id, invoice_number, amount, date, due_date,
CASE WHEN status = 'overdue' THEN 'pending' ELSE status END,
notes, created_by, created_at, updated_at
FROM invoices;
DROP TABLE invoices;
ALTER TABLE invoices_new RENAME TO invoices;
CREATE INDEX idx_invoices_vendor_id ON invoices(vendor_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_date ON invoices(date);
CREATE INDEX idx_invoices_work_item_budget_id ON invoices(work_item_budget_id);
-- 4. Drop work_item_vendors junction table (replaced by work_item_budgets.vendor_id)
DROP INDEX IF EXISTS idx_work_item_vendors_vendor_id;
DROP TABLE IF EXISTS work_item_vendors;
-- Rollback (requires reversing the table recreations):
-- This migration is not trivially reversible due to SQLite table recreation.
-- To rollback: restore from backup or rerun migrations 0001-0004 on a fresh database.EPIC-06 adds milestones, milestone-work-item associations, and lead/lag days for dependencies. These tables support the Gantt chart visualization, scheduling engine, and milestone tracking features.
Major project milestones that mark significant progress points in the construction project. Milestones have a target date and can be linked to work items that contribute to that milestone.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY AUTOINCREMENT | Auto-incrementing integer ID |
title |
TEXT | NOT NULL | Milestone title (e.g., "Foundation Complete") |
description |
TEXT | nullable | Detailed description of the milestone |
target_date |
TEXT | NOT NULL | Target completion date (ISO 8601 date, e.g., 2026-04-15) |
is_completed |
INTEGER | NOT NULL DEFAULT 0 | Completion state (0 = not completed, 1 = completed) |
completed_at |
TEXT | nullable | ISO 8601 timestamp when the milestone was marked complete |
color |
TEXT | nullable | Hex color code (e.g., #FF5733) for Gantt chart display |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who created this milestone |
created_at |
TEXT | NOT NULL DEFAULT CURRENT_TIMESTAMP | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT CURRENT_TIMESTAMP | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_milestones_target_date |
target_date |
INDEX | Efficient sorting and filtering by target date |
Design rationale:
-
INTEGER PRIMARY KEY AUTOINCREMENT: Unlike other entities in the schema that use TEXT UUIDs, milestones use auto-incrementing integer IDs. Milestones are a smaller, less sensitive entity (no information leakage concern from sequential IDs), and integer PKs provide simpler, more efficient joins for the
milestone_work_itemsjunction table. This also aligns with the task description specifying INTEGER PK. -
is_completed+completed_atpattern: The booleanis_completedprovides fast filtering, whilecompleted_atrecords the exact timestamp for audit/display purposes. Whenis_completedis set to 1,completed_atis set to the current timestamp by the application. When set back to 0,completed_atis cleared to NULL. -
colorfor Gantt display: Milestones appear as diamond markers on the Gantt chart. The optional color allows users to visually categorize milestones (e.g., red for critical, blue for permits, green for inspections). -
created_bynullable with ON DELETE SET NULL: Consistent with the budget tables pattern. The SET NULL FK preserves the milestone if the creating user is removed; nullable avoids constraint violations on user deletion. -
No
sort_ordercolumn: Milestones are naturally sorted bytarget_date. Unlike budget categories which have a custom display order, milestones are temporal and their position on the timeline is determined by their date.
Junction table linking milestones to work items (many-to-many). A milestone can be associated with multiple work items that contribute to it; a work item can be part of multiple milestones.
| Column | Type | Constraints | Description |
|---|---|---|---|
milestone_id |
INTEGER | NOT NULL, REFERENCES milestones(id) ON DELETE CASCADE | The milestone |
work_item_id |
TEXT | NOT NULL, REFERENCES work_items(id) ON DELETE CASCADE | The work item |
Primary key: Composite (milestone_id, work_item_id)
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_milestone_work_items_work_item_id |
work_item_id |
INDEX | Efficient lookup of all milestones for a given work item (the composite PK index covers milestone_id lookups) |
Design rationale:
- Composite primary key: No surrogate ID needed. The combination of milestone_id + work_item_id is naturally unique. Consistent with other junction tables in the schema.
- CASCADE on both sides: Deleting a milestone removes its work item associations. Deleting a work item removes it from all milestones. Neither cascades to the other entity itself.
-
Index on
work_item_id: The composite PK index is ordered (milestone_id, work_item_id), which is efficient for "get all work items for a milestone." The separate work_item_id index supports the reverse query: "get all milestones for a work item" (used in work item detail views and timeline rendering).
+---------------------+
| milestones |
+---------------------+
| id (PK, INTEGER AI) |
| title |
| description |
| target_date |
| is_completed |
| completed_at |
| color |
| created_by FK ------+---> users
| created_at |
| updated_at |
+---------------------+
|
| M:N
|
+------+-------------------+
| milestone_work_items |
+--------------------------+
| milestone_id FK |
| work_item_id FK ---------+---> work_items
+--------------------------+
work_item_dependencies (updated)
+---------------------+
| predecessor_id FK |---+
| successor_id FK |---+---> work_items
| dependency_type |
| lead_lag_days | <-- NEW (EPIC-06)
+---------------------+
Key relationships (EPIC-06):
- milestones -> milestone_work_items -> work_items: Many-to-many. A milestone can be associated with multiple work items; a work item can belong to multiple milestones. Both sides cascade on delete.
-
users -> milestones: One-to-many via
created_by. ON DELETE SET NULL preserves milestones when the creating user is removed. - work_item_dependencies.lead_lag_days: New column added to the existing dependencies table. Positive values represent lag (waiting time); negative values represent lead (overlap). Used by the scheduling engine (ADR-014) when computing the critical path and auto-scheduling dates.
server/src/db/migrations/0006_milestones.sql
-- EPIC-06: Timeline, Gantt Chart & Dependency Management
-- Creates milestones and milestone_work_items tables.
-- Adds lead_lag_days column to work_item_dependencies.
-- Milestones for tracking major project progress points
CREATE TABLE milestones (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
target_date TEXT NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
completed_at TEXT,
color TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_milestones_target_date ON milestones(target_date);
-- Junction: milestones <-> work items (M:N)
CREATE TABLE milestone_work_items (
milestone_id INTEGER NOT NULL REFERENCES milestones(id) ON DELETE CASCADE,
work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE,
PRIMARY KEY (milestone_id, work_item_id)
);
CREATE INDEX idx_milestone_work_items_work_item_id ON milestone_work_items(work_item_id);
-- Add lead/lag days to work item dependencies for scheduling offsets
ALTER TABLE work_item_dependencies ADD COLUMN lead_lag_days INTEGER NOT NULL DEFAULT 0;
-- Rollback:
-- ALTER TABLE work_item_dependencies DROP COLUMN lead_lag_days;
-- DROP INDEX IF EXISTS idx_milestone_work_items_work_item_id;
-- DROP TABLE IF EXISTS milestone_work_items;
-- DROP INDEX IF EXISTS idx_milestones_target_date;
-- DROP TABLE IF EXISTS milestones;EPIC-07 adds actual start/end date tracking to work items and simplifies the status enum by removing the blocked value.
server/src/db/migrations/0008_actual_dates_and_status.sql
Changes:
- Adds
actual_start_date(nullable TEXT) towork_itemsfor tracking when work actually started - Adds
actual_end_date(nullable TEXT) towork_itemsfor tracking when work actually finished - Migrates existing
blockedstatus rows tonot_started(soft migration -- SQLite cannot ALTER CHECK constraints, so the application layer enforces the new three-value enum)
-- Migration 0008: Add actual start/end date columns and simplify work item status enum
--
-- Changes:
-- 1. Add actual_start_date column to work_items (nullable TEXT, ISO date)
-- 2. Add actual_end_date column to work_items (nullable TEXT, ISO date)
-- 3. Migrate existing 'blocked' status rows to 'not_started'
-- (Note: SQLite does not support ALTER COLUMN or DROP CHECK, so we use a
-- soft migration — existing rows with 'blocked' are updated to 'not_started'.
-- The application layer enforces the new three-value enum.)
--
-- ROLLBACK:
-- ALTER TABLE work_items DROP COLUMN actual_start_date;
-- ALTER TABLE work_items DROP COLUMN actual_end_date;
-- (blocked status rollback is not reversible without original data)
ALTER TABLE work_items ADD COLUMN actual_start_date TEXT;
ALTER TABLE work_items ADD COLUMN actual_end_date TEXT;
-- Migrate any existing 'blocked' rows to 'not_started'
UPDATE work_items SET status = 'not_started' WHERE status = 'blocked';EPIC-08 adds document linking support, allowing users to reference Paperless-ngx documents from work items, household items, and invoices. See ADR-015 for the architectural rationale.
Polymorphic junction table linking Paperless-ngx documents to various Cornerstone entities. Uses entity_type discriminator + entity_id pattern instead of separate junction tables per entity type.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
entity_type |
TEXT | NOT NULL CHECK(entity_type IN ('work_item', 'household_item', 'invoice')) | Type of the linked Cornerstone entity |
entity_id |
TEXT | NOT NULL | ID of the linked entity (work item, household item, or invoice) |
paperless_document_id |
INTEGER | NOT NULL | Paperless-ngx document ID (integer, from the external system) |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who created the link |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_document_links_unique |
entity_type, entity_id, paperless_document_id |
UNIQUE | Prevents duplicate links (same document linked twice to same entity) |
idx_document_links_entity |
entity_type, entity_id |
INDEX | Fast lookup of all documents for a given entity |
idx_document_links_paperless_doc |
paperless_document_id |
INDEX | Reverse lookup: find all entities linked to a given document |
Design rationale:
-
Polymorphic pattern: A single table with
entity_typediscriminator is chosen over three separate junction tables (work_item_documents,household_item_documents,invoice_documents) to reduce schema duplication and simplify the linking CRUD logic. See ADR-015 for the full trade-off analysis. -
No foreign key on
entity_id: SQLite foreign keys cannot reference different tables conditionally based on another column's value. Referential integrity on the entity side is enforced at the application layer (entity existence check on insert; cascade-delete of links when an entity is deleted). -
INTEGER for
paperless_document_id: Paperless-ngx uses auto-incrementing integer IDs for documents, not UUIDs. -
No
updated_at: Document links are immutable once created. They can only be created or deleted, never modified. -
created_bywith ON DELETE SET NULL: Preserves link history even if the user who created the link is later deactivated/deleted.
Entity type values:
| Value | References | Description |
|---|---|---|
work_item |
work_items.id |
Construction work item |
household_item |
household_items.id (EPIC-04) |
Household item / furniture |
invoice |
invoices.id |
Vendor invoice |
Note: The household_item entity type is defined in the schema now but the household_items table does not exist until EPIC-04 is implemented. Application-layer validation should only allow household_item links when the household items feature is available.
server/src/db/migrations/0009_document_links.sql
Changes:
- Creates the
document_linkstable with polymorphic entity reference pattern - Creates composite unique index to prevent duplicate links
- Creates entity lookup index and reverse lookup index
-- Migration 0009: Create document_links table for Paperless-ngx integration
--
-- EPIC-08: Paperless-ngx Document Integration
--
-- Creates a single polymorphic table for linking Paperless-ngx documents to
-- various entities (work items, household items, invoices). Uses entity_type
-- discriminator + entity_id pattern instead of separate junction tables.
--
-- See ADR-015 for design rationale.
--
-- ROLLBACK:
-- DROP INDEX IF EXISTS idx_document_links_entity;
-- DROP INDEX IF EXISTS idx_document_links_paperless_doc;
-- DROP TABLE IF EXISTS document_links;
CREATE TABLE document_links (
id TEXT PRIMARY KEY,
entity_type TEXT NOT NULL CHECK(entity_type IN ('work_item', 'household_item', 'invoice')),
entity_id TEXT NOT NULL,
paperless_document_id INTEGER NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL
);
-- Composite unique constraint: prevent duplicate links
CREATE UNIQUE INDEX idx_document_links_unique
ON document_links (entity_type, entity_id, paperless_document_id);
-- Fast lookup: find all documents for a given entity
CREATE INDEX idx_document_links_entity
ON document_links (entity_type, entity_id);
-- Reverse lookup: find all entities linked to a given Paperless-ngx document
CREATE INDEX idx_document_links_paperless_doc
ON document_links (paperless_document_id);Household items and furniture purchases tracked alongside the construction project. A distinct entity from work items (see ADR-016), with its own purchase status workflow, category system, delivery tracking, and budget integration.
Lookup table for household item categories. Replaces the previous hard-coded CHECK constraint enum on household_items.category. Categories are user-manageable via CRUD API endpoints.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | Stable slug ID (e.g., hic-furniture) |
name |
TEXT | UNIQUE NOT NULL | Display name (1-100 chars) |
color |
TEXT | nullable | Hex color code (#RRGGBB) for UI |
sort_order |
INTEGER | NOT NULL DEFAULT 0 | Display ordering (ascending) |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp |
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_household_item_categories_sort_order |
sort_order |
Ordered category listing |
sqlite_autoindex_household_item_categories |
name |
Enforced by UNIQUE constraint |
Seeded values (migration 0016):
| ID | Name | Color | Sort Order | Description |
|---|---|---|---|---|
hic-furniture |
Furniture | #8B5CF6 |
0 | Tables, chairs, beds, sofas, shelving, etc. |
hic-appliances |
Appliances | #3B82F6 |
1 | Kitchen appliances, HVAC, washer/dryer, etc. |
hic-fixtures |
Fixtures | #06B6D4 |
2 | Faucets, lighting, door hardware, etc. |
hic-decor |
Decor | #EC4899 |
3 | Art, rugs, curtains, decorative accessories |
hic-electronics |
Electronics | #F59E0B |
4 | TVs, speakers, networking equipment, etc. |
hic-outdoor |
Outdoor | #22C55E |
5 | Patio furniture, garden tools, outdoor equipment |
hic-storage |
Storage | #F97316 |
6 | Closet systems, shelving units, bins, etc. |
hic-other |
Other | #6B7280 |
7 | Items that do not fit other categories |
Design rationale:
- Lookup table instead of CHECK constraint: Allows users to add, rename, recolor, and reorder categories without database migrations. The original 8 enum values are seeded as default rows.
-
ON DELETE RESTRICTon FK: Categories cannot be deleted while household items reference them. The API returns409 CATEGORY_IN_USEwith a count of referencing items. -
nameUNIQUE: Prevents duplicate category names. Uniqueness is checked case-insensitively at the application layer. -
Slug-style IDs: Seeded categories use
hic-prefixed slugs (e.g.,hic-furniture). User-created categories use UUIDs.
Main entity table for household items and furniture.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID v4 |
name |
TEXT | NOT NULL | Item name (1-500 chars) |
description |
TEXT | nullable | Detailed description/specs |
category_id |
TEXT | NOT NULL REFERENCES household_item_categories(id) ON DELETE RESTRICT | FK to household item category |
status |
TEXT | NOT NULL DEFAULT 'planned' CHECK(status IN ('planned','purchased','scheduled','arrived')) | Lifecycle status |
vendor_id |
TEXT | FK -> vendors(id) ON DELETE SET NULL | Supplier/vendor |
url |
TEXT | nullable | Product URL |
room |
TEXT | nullable | Delivery location/room |
quantity |
INTEGER | NOT NULL DEFAULT 1 CHECK(quantity >= 1) | Number of items |
order_date |
TEXT | nullable | Date ordered (YYYY-MM-DD) |
earliest_delivery_date |
TEXT | nullable | User-editable earliest delivery constraint (YYYY-MM-DD); used by scheduler to compute target_delivery_date |
latest_delivery_date |
TEXT | nullable | User-editable latest delivery constraint (YYYY-MM-DD); used by scheduler as hard deadline |
target_delivery_date |
TEXT | nullable | Computed target delivery date (YYYY-MM-DD); set by scheduling engine to balance dependencies and constraints |
actual_delivery_date |
TEXT | nullable | Actual delivery (YYYY-MM-DD) |
is_late |
INTEGER | NOT NULL DEFAULT 0 | Flag (0=on-time, 1=late); set when actual_delivery_date exceeds target_delivery_date |
created_by |
TEXT | FK -> users(id) ON DELETE SET NULL | Creating user |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp |
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_household_items_category_id |
category_id |
Filter by category (FK join) |
idx_household_items_status |
status |
Filter by purchase status |
idx_household_items_room |
room |
Filter by delivery room |
idx_household_items_vendor_id |
vendor_id |
Join to vendors |
idx_household_items_created_at |
created_at |
Sort by creation time |
idx_household_items_target_delivery |
target_delivery_date |
Sort by target delivery date (Gantt timeline view) |
Status values:
| Value | Description |
|---|---|
planned |
Item identified but not yet purchased |
purchased |
Order placed with vendor/supplier |
scheduled |
Delivery scheduled, en route or awaiting delivery |
arrived |
Item received at the delivery location |
Design rationale:
-
category_idFK tohousehold_item_categories: Replaced the previouscategoryCHECK constraint enum (migration 0016). Categories are now user-manageable via CRUD API, withON DELETE RESTRICTpreventing deletion of in-use categories. -
roomas free text: Rooms vary greatly between homes (e.g., "Kitchen", "Master Bedroom", "Garage", "Guest Bathroom 2"). An enum would be too restrictive. -
quantitycolumn: Allows tracking multiples (e.g., "6x dining chairs") without creating separate rows. Defaults to 1. -
urlcolumn: Product page links for reference during purchasing. -
vendor_idreuses EPIC-05 vendors table: Vendors serve as both contractors (work items) and suppliers (household items). - No status transition enforcement: Users can set any valid status at any time to correct mistakes. The application does not enforce a linear workflow.
-
Delivery date redesign (Migration 0015):
expected_delivery_datewas replaced with a three-part model:-
earliest_delivery_dateandlatest_delivery_dateare user-editable delivery constraints (e.g., "I want this by end of April, but not before March 15") -
target_delivery_dateis computed by the scheduling engine to balance dependency chains with user constraints -
is_lateflag indicates ifactual_delivery_dateexceededtarget_delivery_date - This enables the Gantt timeline to visualize household item delivery dates and detect late items alongside work items and milestones
-
Many-to-many junction between household items and the shared tags table (same tags used by work items).
| Column | Type | Constraints | Description |
|---|---|---|---|
household_item_id |
TEXT | NOT NULL FK -> household_items(id) ON DELETE CASCADE | Household item |
tag_id |
TEXT | NOT NULL FK -> tags(id) ON DELETE CASCADE | Tag |
Primary key: (household_item_id, tag_id)
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_household_item_tags_tag_id |
tag_id |
Reverse lookup: find items by tag |
Notes/comments on household items. Follows the work_item_notes pattern.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID v4 |
household_item_id |
TEXT | NOT NULL FK -> household_items(id) ON DELETE CASCADE | Parent item |
content |
TEXT | NOT NULL | Note text |
created_by |
TEXT | FK -> users(id) ON DELETE SET NULL | Author |
created_at |
TEXT | NOT NULL | ISO 8601 |
updated_at |
TEXT | NOT NULL | ISO 8601 |
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_household_item_notes_household_item_id |
household_item_id |
Fetch notes for a specific item |
Budget lines for household items. Mirrors work_item_budgets exactly.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID v4 |
household_item_id |
TEXT | NOT NULL FK -> household_items(id) ON DELETE CASCADE | Parent item |
description |
TEXT | nullable | Line description |
planned_amount |
REAL | NOT NULL DEFAULT 0 CHECK(planned_amount >= 0) | Planned cost |
confidence |
TEXT | NOT NULL DEFAULT 'own_estimate' CHECK(confidence IN ('own_estimate','professional_estimate','quote','invoice')) | Confidence level |
budget_category_id |
TEXT | FK -> budget_categories(id) ON DELETE SET NULL | Budget category |
budget_source_id |
TEXT | FK -> budget_sources(id) ON DELETE SET NULL | Financing source |
vendor_id |
TEXT | FK -> vendors(id) ON DELETE SET NULL | Vendor |
created_by |
TEXT | FK -> users(id) ON DELETE SET NULL | Creating user |
created_at |
TEXT | NOT NULL | ISO 8601 |
updated_at |
TEXT | NOT NULL | ISO 8601 |
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_household_item_budgets_household_item_id |
household_item_id |
Fetch budgets for an item |
idx_household_item_budgets_vendor_id |
vendor_id |
Join to vendors |
idx_household_item_budgets_budget_category_id |
budget_category_id |
Join to budget categories |
idx_household_item_budgets_budget_source_id |
budget_source_id |
Join to budget sources |
Design rationale:
- Same structure as
work_item_budgetsfor consistency. Budget lines support confidence levels, categories, sources, and vendor references. - Household item budget lines do not link to invoices. Invoice tracking is done through the vendor system (EPIC-05). The API response includes
actualCost,actualCostPaid,invoiceCount, andinvoicesfields for shape consistency, but they are always zero/empty. - Household item budget lines contribute to the project-level budget overview alongside work item budget lines.
This table was dropped in migration 0012. The household item-to-work item relationship is now captured through household_item_deps, which supports full dependency types (FS/SS/FF/SF), lead/lag days, and milestone dependencies.
Previously, this was a simple many-to-many junction table linking household items to work items. Existing rows were migrated to household_item_deps as finish_to_start dependencies with 0-day lag.
Dependency table linking household items to work items and milestones for delivery scheduling. Mirrors the work_item_dependencies structure but uses a polymorphic predecessor reference (work items or milestones). The scheduling engine uses these dependencies to compute earliest_delivery_date and latest_delivery_date on household items.
| Column | Type | Constraints | Description |
|---|---|---|---|
household_item_id |
TEXT | NOT NULL FK -> household_items(id) ON DELETE CASCADE | Household item (the dependent/successor) |
predecessor_type |
TEXT | NOT NULL CHECK(predecessor_type IN ('work_item', 'milestone')) | Type of predecessor entity |
predecessor_id |
TEXT | NOT NULL | ID of the predecessor (work item UUID or milestone integer ID) |
dependency_type |
TEXT | NOT NULL DEFAULT 'finish_to_start' CHECK(dependency_type IN ('finish_to_start','start_to_start','finish_to_finish','start_to_finish')) | Scheduling dependency type |
lead_lag_days |
INTEGER | NOT NULL DEFAULT 0 | Lead (negative) or lag (positive) days offset |
Primary key: (household_item_id, predecessor_type, predecessor_id)
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_hi_deps_predecessor |
predecessor_type, predecessor_id |
Reverse lookup: find household items by predecessor |
Dependency types: Same as work_item_dependencies (see EPIC-03 section).
Design rationale:
-
Polymorphic predecessor reference: Household items can depend on both work items and milestones. Using
predecessor_type+predecessor_idavoids two separate junction tables. No DB-level FK onpredecessor_idbecause SQLite cannot conditionally reference different tables. -
Full dependency types (FS/SS/FF/SF): Unlike the previous
household_item_work_itemsjunction, this table supports all four standard project management dependency types, enabling precise scheduling (e.g., "delivery must start when kitchen installation starts" = SS). -
lead_lag_days: Allows time offsets between the predecessor's date and the household item delivery window (e.g., +3 = order 3 days after work item finishes). -
Cascade on
household_item_id: Deleting a household item removes all its dependencies. -
No cascade on predecessor:
predecessor_idhas no DB-level FK. Application-layer cleanup is required when work items or milestones are deleted (delete orphanedhousehold_item_depsrows wherepredecessor_typeandpredecessor_idmatch the deleted entity). -
Three-column composite PK: A household item can depend on the same predecessor only once. The triple
(household_item_id, predecessor_type, predecessor_id)is naturally unique.
Links household items to subsidy programs (M:N). Follows the work_item_subsidies pattern.
| Column | Type | Constraints | Description |
|---|---|---|---|
household_item_id |
TEXT | NOT NULL FK -> household_items(id) ON DELETE CASCADE | Household item |
subsidy_program_id |
TEXT | NOT NULL FK -> subsidy_programs(id) ON DELETE CASCADE | Subsidy |
Primary key: (household_item_id, subsidy_program_id)
Indexes:
| Index | Columns | Rationale |
|---|---|---|
idx_household_item_subsidies_subsidy_program_id |
subsidy_program_id |
Reverse lookup: find items by subsidy |
server/src/db/migrations/0010_household_items.sql
Changes:
- Creates the
household_itemstable with category/status CHECK constraints (category CHECK superseded by migration 0016 which replaces it withcategory_idFK) - Creates
household_item_tagsjunction table (reuses sharedtagstable) - Creates
household_item_notestable - Creates
household_item_budgetstable (mirrorswork_item_budgets) - Creates
household_item_work_itemsjunction table (superseded by migration 0012) - Creates
household_item_subsidiesjunction table
-- 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 'planned'
CHECK(status IN ('planned', 'purchased', 'scheduled', 'arrived')),
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);server/src/db/migrations/0012_household_item_deps.sql
Changes:
- Adds
earliest_delivery_dateandlatest_delivery_datecolumns tohousehold_items - Creates
household_item_depstable (polymorphic predecessor references to work items and milestones) - Migrates existing
household_item_work_itemsrows asfinish_to_start/ 0-lag dependencies - Drops
household_item_work_itemsjunction table
-- Migration 0012: Replace household_item_work_items with household_item_deps dependency model
-- Adds CPM delivery date columns, creates deps table, migrates data, drops old table.
-- 1. Add computed delivery date columns to household_items
ALTER TABLE household_items ADD COLUMN earliest_delivery_date TEXT;
ALTER TABLE household_items ADD COLUMN latest_delivery_date TEXT;
-- 2. Create household_item_deps table
CREATE TABLE household_item_deps (
household_item_id TEXT NOT NULL
REFERENCES household_items(id) ON DELETE CASCADE,
predecessor_type TEXT NOT NULL CHECK (predecessor_type IN ('work_item', 'milestone')),
predecessor_id TEXT NOT NULL,
dependency_type TEXT NOT NULL DEFAULT 'finish_to_start'
CHECK (dependency_type IN ('finish_to_start','start_to_start','finish_to_finish','start_to_finish')),
lead_lag_days INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (household_item_id, predecessor_type, predecessor_id)
);
CREATE INDEX idx_hi_deps_predecessor ON household_item_deps(predecessor_type, predecessor_id);
-- 3. Migrate existing household_item_work_items rows as FS/0-lag work_item deps
INSERT INTO household_item_deps
(household_item_id, predecessor_type, predecessor_id, dependency_type, lead_lag_days)
SELECT
household_item_id,
'work_item',
work_item_id,
'finish_to_start',
0
FROM household_item_work_items;
-- 4. Drop old junction table
DROP TABLE household_item_work_items;
-- ROLLBACK:
-- ALTER TABLE household_items DROP COLUMN earliest_delivery_date;
-- ALTER TABLE household_items DROP COLUMN latest_delivery_date;
-- DROP TABLE IF EXISTS household_item_deps;
-- 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);server/src/db/migrations/0015_hi_delivery_date_redesign.sql
Changes:
- Adds
target_delivery_datecolumn (computed by scheduler from constraints and dependencies) - Adds
is_lateboolean flag (set when actual delivery exceeds target) - Migrates legacy
expected_delivery_datevalues toearliest_delivery_date - Rebuilds table to remove now-obsolete
expected_delivery_datecolumn
Rationale:
The single expected_delivery_date field was insufficient for scheduling household item delivery alongside work items and milestones. The new three-part model:
-
earliest_delivery_date&latest_delivery_date: User-editable constraints (e.g., "I want this by May 1, but not before April 15") -
target_delivery_date: Scheduler computes this to honor dependencies and user constraints (e.g., "Installation work finishes April 30, so deliver May 2") -
is_late: Flag set when actual delivery > target delivery, visible in Gantt chart
Legacy expected_delivery_date values are migrated to earliest_delivery_date to preserve user intent during the upgrade.
Stores per-user UI preferences as key-value pairs. Used for dashboard customization (e.g., hidden cards, theme selection) and any future user-specific settings.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INTEGER | PRIMARY KEY AUTOINCREMENT | Auto-incrementing integer ID |
user_id |
TEXT | NOT NULL REFERENCES users(id) ON DELETE CASCADE | Owning user |
key |
TEXT | NOT NULL | Preference key (e.g., dashboard.hiddenCards) |
value |
TEXT | NOT NULL | Preference value (JSON-encoded for complex types) |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Constraints:
| Constraint | Type | Columns | Description |
|---|---|---|---|
sqlite_autoindex_user_preferences_1 |
UNIQUE | user_id, key |
Each user can have at most one value per key |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_user_preferences_user_id |
user_id |
INDEX | Fast lookup of all preferences for a specific user |
Design rationale:
- INTEGER PRIMARY KEY (AUTOINCREMENT): Unlike most other tables that use TEXT UUIDs, preferences are internal records that don't need globally unique identifiers or appear in URLs. Auto-increment is simpler and more efficient for a high-write key-value table.
- Key-value design: A flexible schema that accommodates any preference without schema migrations. New preference keys can be added at the application layer without database changes.
-
TEXT
value: All values are stored as TEXT. Complex values (arrays, objects) are JSON-encoded by the application layer. This avoids the need for multiple typed columns. -
UNIQUE(user_id, key): Enforces at most one value per preference key per user. Upsert operations use
INSERT ... ON CONFLICT ... DO UPDATE. - ON DELETE CASCADE: When a user is deleted, all their preferences are automatically cleaned up.
-
No
deactivated_at: Preferences are hard-deleted when removed. There is no audit trail for preference changes.
server/src/db/migrations/0016_household_item_categories.sql
Changes:
- Creates the
household_item_categorieslookup table - Seeds the 8 original enum values as default categories (
hic-furniture,hic-appliances, etc.) - Adds
category_idFK column tohousehold_items - Backfills
category_idfrom the oldcategorystring values - Drops the old
categorycolumn and its index (idx_household_items_category) - Seeds
bc-household-itemsbudget category inbudget_categories - Migrates all existing
household_item_budgetsto usebc-household-itemsas theirbudget_category_id - Creates indexes on
household_item_categories(sort_order)andhousehold_items(category_id)
-- EPIC-09: Create household_item_categories lookup table
-- Story #509: Unified Tags & Categories Management Page
-- Migrates household_items.category from hard-coded enum to foreign key
-- Create household_item_categories lookup table
CREATE TABLE household_item_categories (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Seed the 8 original enum values
INSERT INTO household_item_categories (id, name, color, sort_order, created_at, updated_at) VALUES
('hic-furniture', 'Furniture', '#8B5CF6', 0, datetime('now'), datetime('now')),
('hic-appliances', 'Appliances', '#3B82F6', 1, datetime('now'), datetime('now')),
('hic-fixtures', 'Fixtures', '#06B6D4', 2, datetime('now'), datetime('now')),
('hic-decor', 'Decor', '#EC4899', 3, datetime('now'), datetime('now')),
('hic-electronics', 'Electronics', '#F59E0B', 4, datetime('now'), datetime('now')),
('hic-outdoor', 'Outdoor', '#22C55E', 5, datetime('now'), datetime('now')),
('hic-storage', 'Storage', '#F97316', 6, datetime('now'), datetime('now')),
('hic-other', 'Other', '#6B7280', 7, datetime('now'), datetime('now'));
-- Add new column category_id (nullable initially for migration)
ALTER TABLE household_items ADD COLUMN category_id TEXT REFERENCES household_item_categories(id) ON DELETE RESTRICT;
-- Backfill category_id from category string value
UPDATE household_items SET category_id = 'hic-furniture' WHERE category = 'furniture';
UPDATE household_items SET category_id = 'hic-appliances' WHERE category = 'appliances';
UPDATE household_items SET category_id = 'hic-fixtures' WHERE category = 'fixtures';
UPDATE household_items SET category_id = 'hic-decor' WHERE category = 'decor';
UPDATE household_items SET category_id = 'hic-electronics' WHERE category = 'electronics';
UPDATE household_items SET category_id = 'hic-outdoor' WHERE category = 'outdoor';
UPDATE household_items SET category_id = 'hic-storage' WHERE category = 'storage';
UPDATE household_items SET category_id = 'hic-other' WHERE category = 'other';
-- Set default for category_id (backfill any nulls to 'hic-other')
UPDATE household_items SET category_id = 'hic-other' WHERE category_id IS NULL;
-- Drop old category column (SQLite 3.35.0+ supports DROP COLUMN)
-- Must drop the index first since SQLite cannot drop a column that has an index
DROP INDEX IF EXISTS idx_household_items_category;
ALTER TABLE household_items DROP COLUMN category;
-- Seed "Household Items" budget category
INSERT INTO budget_categories (id, name, description, color, sort_order, created_at, updated_at) VALUES
('bc-household-items', 'Household Items', 'Furniture, appliances, and other household purchases', '#8B5CF6', 10, datetime('now'), datetime('now'));
-- Migrate ALL existing household_item_budgets to bc-household-items
UPDATE household_item_budgets SET budget_category_id = 'bc-household-items';
-- Indexes
CREATE INDEX idx_household_item_categories_sort_order ON household_item_categories(sort_order);
CREATE INDEX idx_household_items_category_id ON household_items(category_id);server/src/db/migrations/0018_user_preferences.sql
-- EPIC-09: Dashboard & Project Health Center
-- Creates the user_preferences table for storing per-user UI preferences
-- (e.g., hidden dashboard cards, theme selection).
CREATE TABLE user_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key TEXT NOT NULL,
value TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(user_id, key)
);
CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);EPIC-15 replaces the 1:1 FK model between invoices and budget lines with a many-to-many junction table (invoice_budget_lines). This enables a single invoice to be attributed across multiple budget categories with per-link itemized amounts. See ADR-018 for the full architectural rationale.
Junction table linking invoices to budget lines (work item or household item) with per-link itemized amounts. Each budget line can be linked to at most one invoice (exclusive linking). An invoice can have many budget line links.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
invoice_id |
TEXT | NOT NULL, REFERENCES invoices(id) ON DELETE CASCADE | The parent invoice |
work_item_budget_id |
TEXT | nullable, REFERENCES work_item_budgets(id) ON DELETE CASCADE | Link to a work item budget line (mutually exclusive with HI) |
household_item_budget_id |
TEXT | nullable, REFERENCES household_item_budgets(id) ON DELETE CASCADE | Link to a household item budget line (mutually exclusive with WI) |
itemized_amount |
REAL | NOT NULL, CHECK(itemized_amount > 0) | Dollar amount attributed to this budget line from this invoice |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
CHECK constraint (XOR): Exactly one of work_item_budget_id or household_item_budget_id must be non-null:
CHECK (
(work_item_budget_id IS NOT NULL AND household_item_budget_id IS NULL) OR
(work_item_budget_id IS NULL AND household_item_budget_id IS NOT NULL)
)Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_invoice_budget_lines_invoice_id |
invoice_id |
INDEX | Fast lookup of all budget lines for an invoice |
idx_invoice_budget_lines_work_item_budget_id |
work_item_budget_id |
INDEX | Fast lookup by WI budget line; supports actualCost aggregation |
idx_invoice_budget_lines_household_item_budget_id |
household_item_budget_id |
INDEX | Fast lookup by HI budget line; supports actualCost aggregation |
idx_invoice_budget_lines_unique_wib |
work_item_budget_id |
UNIQUE (partial: WHERE work_item_budget_id IS NOT NULL) | Exclusive linking: a WI budget line can appear in at most one invoice |
idx_invoice_budget_lines_unique_hib |
household_item_budget_id |
UNIQUE (partial: WHERE household_item_budget_id IS NOT NULL) | Exclusive linking: a HI budget line can appear in at most one invoice |
Design rationale:
-
Surrogate
idPK (TEXT UUID): Unlike other junction tables in the schema that use composite PKs, this table uses a surrogate ID. The junction rows carry meaningful data (itemized_amount) and need to be individually addressable for CRUD operations (PUT to update amount, DELETE to unlink). A composite PK is not viable because one of the budget FK columns is always NULL (XOR constraint). -
ON DELETE CASCADE from
invoice_id: When an invoice is deleted, all its budget line links are removed. The links exist only because of the invoice. - ON DELETE CASCADE from budget line FKs: When a budget line is deleted, the corresponding junction row is automatically removed. This avoids orphaned rows and is compatible with the XOR CHECK constraint (SET NULL would violate the XOR CHECK by nullifying the only non-null FK). See ADR-018 and Bug #611 for the rationale behind using CASCADE instead of SET NULL.
- Partial UNIQUE indexes for exclusive linking: SQLite supports partial indexes (since 3.8.0). This is the most robust approach to enforce that each budget line appears in at most one invoice, without relying on application-level enforcement alone.
-
itemized_amountCHECK > 0: A zero or negative itemized amount is meaningless. The application layer additionally enforces that the sum of all itemized amounts for an invoice does not exceed the invoice'samount. - XOR CHECK constraint: Ensures data integrity at the database level -- every junction row links to exactly one type of budget line. This is preferred over a polymorphic discriminator pattern because we have exactly two target tables with stable schemas, and typed FK columns provide stronger referential integrity.
The work_item_budget_id and household_item_budget_id columns are removed from the invoices table. Invoice-to-budget-line relationships are now exclusively managed through invoice_budget_lines.
Updated invoices table columns:
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
vendor_id |
TEXT | NOT NULL, REFERENCES vendors(id) ON DELETE CASCADE | The vendor who issued this invoice |
invoice_number |
TEXT | nullable | Vendor's invoice number/reference |
amount |
REAL | NOT NULL | Invoice amount in the project's currency |
date |
TEXT | NOT NULL | Invoice date (ISO 8601 date) |
due_date |
TEXT | nullable | Payment due date (ISO 8601 date) |
status |
TEXT | NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','paid','claimed')) | Payment status |
notes |
TEXT | nullable | Free-form notes about this invoice |
created_by |
TEXT | nullable, REFERENCES users(id) ON DELETE SET NULL | User who recorded this invoice |
created_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL DEFAULT (datetime('now')) | ISO 8601 timestamp, updated on every modification |
Updated indexes (removed idx_invoices_work_item_budget_id and idx_invoices_household_item_budget_id):
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_invoices_vendor_id |
vendor_id |
INDEX | Fast lookup of vendor's invoices |
idx_invoices_status |
status |
INDEX | Filter by payment status |
idx_invoices_date |
date |
INDEX | Sort/filter by date |
+---------------------+
| invoices |
+---------------------+
| id (PK, UUID) |
| vendor_id FK --------+---> vendors
| invoice_number |
| amount |
| date |
| due_date |
| status |
| notes |
| created_by FK |
+---------------------+
|
| 1:N
|
+------+---------------------+
| invoice_budget_lines |
+----------------------------+
| id (PK, UUID) |
| invoice_id FK |
| work_item_budget_id FK ----+---> work_item_budgets (UNIQUE, exclusive)
| household_item_budget_id --+---> household_item_budgets (UNIQUE, exclusive)
| itemized_amount |
| created_at |
| updated_at |
+----------------------------+
XOR: exactly one budget FK is non-null
Key relationships (EPIC-15):
- invoices -> invoice_budget_lines: One-to-many. An invoice can have multiple budget line links. Links cascade-delete with their parent invoice.
- invoice_budget_lines -> work_item_budgets: Many-to-one (optional, exclusive). Each junction row can reference one WI budget line. ON DELETE SET NULL preserves the junction row if the budget line is removed. The partial unique index ensures each WI budget line appears in at most one junction row.
- invoice_budget_lines -> household_item_budgets: Many-to-one (optional, exclusive). Same pattern as WI budgets.
-
Actual cost computation: Previously
SUM(invoices.amount) WHERE work_item_budget_id = ?. Now:SUM(invoice_budget_lines.itemized_amount) WHERE work_item_budget_id = ?joined with the invoice status for paid/claimed filtering.
server/src/db/migrations/0017_invoice_budget_lines.sql
-- Migration 0017: Invoice-Budget-Line Junction Table (EPIC-15)
--
-- Replaces the 1:1 FK model (invoices.work_item_budget_id and
-- invoices.household_item_budget_id) with a many-to-many junction table
-- (invoice_budget_lines) that links multiple budget lines to one invoice,
-- each with an itemized_amount.
-- 1. Create the junction table
CREATE TABLE invoice_budget_lines (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
work_item_budget_id TEXT REFERENCES work_item_budgets(id) ON DELETE SET NULL,
household_item_budget_id TEXT REFERENCES household_item_budgets(id) ON DELETE SET NULL,
itemized_amount REAL NOT NULL CHECK(itemized_amount > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (
(work_item_budget_id IS NOT NULL AND household_item_budget_id IS NULL) OR
(work_item_budget_id IS NULL AND household_item_budget_id IS NOT NULL)
)
);
CREATE INDEX idx_invoice_budget_lines_invoice_id
ON invoice_budget_lines(invoice_id);
CREATE INDEX idx_invoice_budget_lines_work_item_budget_id
ON invoice_budget_lines(work_item_budget_id);
CREATE INDEX idx_invoice_budget_lines_household_item_budget_id
ON invoice_budget_lines(household_item_budget_id);
CREATE UNIQUE INDEX idx_invoice_budget_lines_unique_wib
ON invoice_budget_lines(work_item_budget_id)
WHERE work_item_budget_id IS NOT NULL;
CREATE UNIQUE INDEX idx_invoice_budget_lines_unique_hib
ON invoice_budget_lines(household_item_budget_id)
WHERE household_item_budget_id IS NOT NULL;
-- 2. Migrate existing FK data into the junction table
INSERT INTO invoice_budget_lines (id, invoice_id, work_item_budget_id,
household_item_budget_id, itemized_amount, created_at, updated_at)
SELECT
lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' ||
substr(hex(randomblob(2)),2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
id, work_item_budget_id, NULL, amount, datetime('now'), datetime('now')
FROM invoices WHERE work_item_budget_id IS NOT NULL;
INSERT INTO invoice_budget_lines (id, invoice_id, work_item_budget_id,
household_item_budget_id, itemized_amount, created_at, updated_at)
SELECT
lower(hex(randomblob(4)) || '-' || hex(randomblob(2)) || '-4' ||
substr(hex(randomblob(2)),2) || '-' ||
substr('89ab', abs(random()) % 4 + 1, 1) ||
substr(hex(randomblob(2)),2) || '-' || hex(randomblob(6))),
id, NULL, household_item_budget_id, amount, datetime('now'), datetime('now')
FROM invoices WHERE household_item_budget_id IS NOT NULL;
-- 3. Recreate invoices table WITHOUT the budget FK columns
CREATE TABLE invoices_new (
id TEXT PRIMARY KEY,
vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE,
invoice_number TEXT,
amount REAL NOT NULL,
date TEXT NOT NULL,
due_date TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'paid', 'claimed')),
notes 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'))
);
INSERT INTO invoices_new (id, vendor_id, invoice_number, amount, date, due_date,
status, notes, created_by, created_at, updated_at)
SELECT id, vendor_id, invoice_number, amount, date, due_date,
status, notes, created_by, created_at, updated_at
FROM invoices;
DROP TABLE invoices;
ALTER TABLE invoices_new RENAME TO invoices;
CREATE INDEX idx_invoices_vendor_id ON invoices(vendor_id);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_invoices_date ON invoices(date);
-- ROLLBACK (non-trivial due to table recreation — restore from backup):
-- DROP TABLE IF EXISTS invoice_budget_lines;
-- Then recreate invoices with the old FK columns via full table recreationserver/src/db/migrations/0021_discretionary_budget_source.sql
PRAGMA foreign_keys = OFF;
-- 1. Recreate budget_sources to add is_discretionary column and 'discretionary' to source_type CHECK
CREATE TABLE budget_sources_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
source_type TEXT NOT NULL CHECK(source_type IN ('bank_loan', 'credit_line', 'savings', 'other', 'discretionary')),
total_amount REAL NOT NULL,
interest_rate REAL,
terms TEXT,
notes TEXT,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'exhausted', 'closed')),
is_discretionary INTEGER NOT NULL DEFAULT 0,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO budget_sources_new
SELECT id, name, source_type, total_amount, interest_rate, terms, notes, status,
0, created_by, created_at, updated_at
FROM budget_sources;
DROP TABLE budget_sources;
ALTER TABLE budget_sources_new RENAME TO budget_sources;
-- 2. Seed the Discretionary Funding system row
INSERT INTO budget_sources (id, name, source_type, total_amount, is_discretionary, status, created_at, updated_at)
SELECT
'discretionary-system',
'Discretionary Funding',
'discretionary',
0,
1,
'active',
datetime('now'),
datetime('now')
WHERE NOT EXISTS (
SELECT 1 FROM budget_sources WHERE is_discretionary = 1
);
PRAGMA foreign_keys = ON;- Recreates
budget_sourcesto addis_discretionarycolumn and expand thesource_typeCHECK constraint to include'discretionary' - Migrates existing rows with
is_discretionary = 0 - Seeds the system "Discretionary Funding" row with a well-known id (
discretionary-system) - Uses
WHERE NOT EXISTSguard to be idempotent
Stores construction diary entries — both manual entries created by users and automatic system events generated by state changes in the application. Type-specific structured fields are stored in a JSON metadata column, validated at the application layer.
See ADR-020 for the full architectural rationale.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
entry_type |
TEXT | NOT NULL CHECK(entry_type IN ('daily_log', 'site_visit', 'delivery', 'issue', 'general_note', 'work_item_status', 'invoice_status', 'milestone_delay', 'budget_breach', 'auto_reschedule', 'subsidy_status')) | Entry type — determines metadata shape and immutability rules |
entry_date |
TEXT | NOT NULL | ISO 8601 date (e.g., 2026-03-15) — the date the entry refers to |
title |
TEXT | nullable | Optional short title |
body |
TEXT | NOT NULL | Free-text content of the entry |
metadata |
TEXT | nullable | JSON string with type-specific structured fields |
is_automatic |
INTEGER | NOT NULL DEFAULT 0 | Boolean: 0 = manual, 1 = system-generated |
source_entity_type |
TEXT | nullable | For auto entries: type of entity that triggered the event |
source_entity_id |
TEXT | nullable | For auto entries: ID of the entity that triggered the event |
created_by |
TEXT | REFERENCES users(id) ON DELETE SET NULL | User who created the entry (NULL for system or deleted users) |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Entry types:
| Value | Category | Description |
|---|---|---|
daily_log |
Manual | Daily site activity log with weather, workers, signature |
site_visit |
Manual | Inspection or site visit record with outcome, signature |
delivery |
Manual | Material/equipment delivery record |
issue |
Manual | Problem or defect report with severity tracking |
general_note |
Manual | Free-form note (no required metadata) |
work_item_status |
Automatic | Work item status change event |
invoice_status |
Automatic | Invoice status change event |
milestone_delay |
Automatic | Milestone target date change event |
budget_breach |
Automatic | Budget threshold exceeded event |
auto_reschedule |
Automatic | Scheduling engine auto-reschedule event |
subsidy_status |
Automatic | Subsidy application status change event |
Metadata JSON shapes (per entry type):
-
daily_log:
{ weather?, temperatureCelsius?, workersOnSite?, hasSignature?, signatureDataUrl? } -
site_visit:
{ inspectorName?, outcome?, hasSignature?, signatureDataUrl? } -
delivery:
{ vendor?, materials?, deliveryConfirmed? } -
issue:
{ severity?, resolutionStatus? } - general_note: No required fields (any JSON object accepted)
-
Automatic entries:
{ changeSummary?, previousValue?, newValue?, ...additionalContext }
Source entity types (for automatic entries):
| Value | Referenced Table |
|---|---|
work_item |
work_items |
invoice |
invoices |
milestone |
milestones |
budget_source |
budget_sources |
subsidy_program |
subsidy_programs |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_diary_entries_entry_date |
entry_date DESC, created_at DESC |
INDEX | Timeline view: entries sorted by date (newest first) |
idx_diary_entries_entry_type |
entry_type |
INDEX | Filter entries by type |
idx_diary_entries_is_automatic |
is_automatic |
INDEX | Filter manual vs system-generated entries |
idx_diary_entries_source_entity |
source_entity_type, source_entity_id |
INDEX (partial: WHERE NOT NULL) | Find diary entries for a specific source entity |
Immutability rules:
- Manual entries are editable:
title,body,metadata,entry_datecan be updated.entry_typeandis_automaticcannot be changed after creation. - Automatic entries are completely immutable: cannot be updated or deleted. They serve as an audit trail.
Photo attachments:
Photos are attached to diary entries via the existing photos table using entity_type = 'diary_entry' and entity_id = diary_entries.id. No additional schema is needed. When a diary entry is deleted, its associated photos (records and files) are cascade-deleted via the photo service.
Design rationale:
- JSON metadata column: Avoids creating 11 separate tables or 15+ nullable columns. SQLite handles JSON well. New entry types or metadata fields can be added without schema migrations. Application-layer validation ensures data integrity. See ADR-020.
-
Polymorphic source entity references: Same pattern as
document_linksandphotos. No FK onsource_entity_id— referential integrity enforced at application layer. Diary entries intentionally survive source entity deletion (historical audit trail). -
is_automaticflag: Enables simple filtering and controls immutability rules at the application layer. Combined withentry_type, the CHECK constraint implicitly validates that only valid type/automatic combinations are used. -
entry_dateseparate fromcreated_at: The diary date a user assigns (e.g., "what happened on March 15") is distinct from when the record was created. Auto events setentry_dateto the current date.
server/src/db/migrations/0024_diary_entries.sql
-- Migration 0024: Create diary_entries table for construction diary (Bautagebuch)
--
-- EPIC-13: Construction Diary
CREATE TABLE diary_entries (
id TEXT PRIMARY KEY,
entry_type TEXT NOT NULL CHECK(entry_type IN (
'daily_log', 'site_visit', 'delivery', 'issue', 'general_note',
'work_item_status', 'invoice_status', 'milestone_delay',
'budget_breach', 'auto_reschedule', 'subsidy_status'
)),
entry_date TEXT NOT NULL,
title TEXT,
body TEXT NOT NULL,
metadata TEXT,
is_automatic INTEGER NOT NULL DEFAULT 0,
source_entity_type TEXT,
source_entity_id TEXT,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_diary_entries_entry_date
ON diary_entries (entry_date DESC, created_at DESC);
CREATE INDEX idx_diary_entries_entry_type
ON diary_entries (entry_type);
CREATE INDEX idx_diary_entries_is_automatic
ON diary_entries (is_automatic);
CREATE INDEX idx_diary_entries_source_entity
ON diary_entries (source_entity_type, source_entity_id)
WHERE source_entity_type IS NOT NULL;
-- Rollback:
-- DROP INDEX IF EXISTS idx_diary_entries_source_entity;
-- DROP INDEX IF EXISTS idx_diary_entries_is_automatic;
-- DROP INDEX IF EXISTS idx_diary_entries_entry_type;
-- DROP INDEX IF EXISTS idx_diary_entries_entry_date;
-- DROP TABLE IF EXISTS diary_entries;EPIC-18 replaces the generic tagging system with two purpose-built dimensions for organizing work items and household items. See ADR-028 for detailed rationale.
Hierarchical spatial organization of the construction project. Areas represent physical locations in the house (e.g., "Kitchen", "2nd Floor > Master Bedroom"). Supports arbitrary nesting depth via self-referencing parent_id.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | NOT NULL | Area display name (1-200 chars) |
parent_id |
TEXT | nullable, REFERENCES areas(id) ON DELETE CASCADE | Parent area for hierarchy; NULL = top-level area |
color |
TEXT | nullable | Hex color code (#RRGGBB) for UI display |
description |
TEXT | nullable | Optional description of the area |
sort_order |
INTEGER | NOT NULL DEFAULT 0 | Display order among siblings (ascending) |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Constraints:
-
UNIQUE(name, parent_id)— prevents duplicate sibling names under the same parent. Top-level areas (NULL parent) are enforced unique by application logic (SQLite treats NULLs as distinct in UNIQUE constraints).
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
idx_areas_parent_id |
parent_id |
INDEX | Fast child area lookups for tree construction |
Design rationale:
-
Self-referencing
parent_id: Enables arbitrary hierarchy depth without enforcing fixed levels. A kitchen island bench can be "Kitchen > Island > Bench" without schema changes. -
ON DELETE CASCADE for
parent_id: Deleting a parent area cascades to all descendants. The API prevents deletion if any area in the subtree is referenced by work items or household items (409 CONFLICT), so the cascade only fires for unreferenced subtrees. -
UNIQUE(name, parent_id)partial: Two areas under the same parent cannot share a name. Areas under different parents may share names (e.g., "Closet" in both "Master Bedroom" and "Guest Bedroom"). -
No
created_by: Areas are a shared organizational resource (same as budget categories), not user-authored content. -
sort_orderamong siblings: Controls display order within a parent. Separate from alphabetical sorting, enabling custom ordering.
Flat classification of craft/skill types. Trades represent the kind of work performed (e.g., "Plumbing", "Electrical", "Carpentry"). Assigned to vendors rather than directly to work items.
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
TEXT | PRIMARY KEY | UUID generated by crypto.randomUUID()
|
name |
TEXT | UNIQUE NOT NULL | Trade display name (1-200 chars) |
color |
TEXT | nullable | Hex color code (#RRGGBB) for UI display |
description |
TEXT | nullable | Optional description of the trade |
sort_order |
INTEGER | NOT NULL DEFAULT 0 | Display order (ascending) |
created_at |
TEXT | NOT NULL | ISO 8601 timestamp, set on insert |
updated_at |
TEXT | NOT NULL | ISO 8601 timestamp, updated on every modification |
Indexes:
| Index | Columns | Type | Rationale |
|---|---|---|---|
sqlite_autoindex_trades_1 |
name |
UNIQUE | Enforced by UNIQUE constraint; prevents duplicate trade names |
Seeded values (15 defaults):
| Name | Description |
|---|---|
| Plumbing | Water supply, drainage, gas piping |
| HVAC | Heating, ventilation, air conditioning |
| Electrical | Wiring, panels, fixtures, smart home |
| Drywall | Wall/ceiling installation and finishing |
| Carpentry | Framing, trim, cabinetry, woodwork |
| Masonry | Brick, stone, concrete block work |
| Painting | Interior/exterior painting and finishing |
| Roofing | Roof installation, repair, gutters |
| Flooring | Hardwood, tile, carpet, vinyl installation |
| Tiling | Wall and floor tile installation |
| Landscaping | Outdoor grading, planting, hardscape |
| Excavation | Site preparation, grading, trenching |
| General Contractor | Overall project coordination |
| Architect / Design | Architectural and interior design services |
| Other | Trades not covered by other categories |
Design rationale:
- Flat list (no hierarchy): Trades do not have a natural parent-child relationship. "Plumbing" is not a sub-trade of anything. A flat list is simpler to manage and query.
-
nameUNIQUE: Trade names must be globally unique. Case-insensitive uniqueness enforced at application layer. -
Assigned to vendors, not work items: A trade describes the craftsperson's skill, not the task. Work items inherit their trade transitively via
work_items.assigned_vendor_id -> vendors.trade_id. - 15 seeded defaults: Covers the most common construction trades. Users can add, rename, or delete them.
| Change | Column | Type | Constraints | Description |
|---|---|---|---|---|
| DROP | specialty |
TEXT | (was nullable) | Replaced by structured trade_id
|
| ADD | trade_id |
TEXT | nullable, REFERENCES trades(id) ON DELETE SET NULL | FK to the trades table |
New index: idx_vendors_trade_id on trade_id — supports filtering/joining vendors by trade.
Design rationale: The free-text specialty field provided no structure for aggregation or filtering. The trade_id FK enables structured trade-based queries. ON DELETE SET NULL preserves vendor records if a trade is deleted (though the API prevents trade deletion while vendors reference it).
| Change | Column | Type | Constraints | Description |
|---|---|---|---|---|
| ADD | area_id |
TEXT | nullable, REFERENCES areas(id) ON DELETE SET NULL | Spatial location of this work item |
| ADD | assigned_vendor_id |
TEXT | nullable, REFERENCES vendors(id) ON DELETE SET NULL | Vendor responsible for this work item |
CHECK constraint: CHECK(assigned_user_id IS NULL OR assigned_vendor_id IS NULL) — enforces mutual exclusivity. A work item can be assigned to a user OR a vendor, but not both simultaneously.
New indexes:
-
idx_work_items_area_idonarea_id— supports area-based filtering -
idx_work_items_assigned_vendor_idonassigned_vendor_id— supports vendor assignment filtering
Design rationale:
-
area_idON DELETE SET NULL: If an area is deleted, work items are unassigned from that area but preserved. However, the API prevents area deletion while items reference it (409 CONFLICT), so SET NULL is a safety net. -
assigned_vendor_idON DELETE SET NULL: Consistent withassigned_user_idbehavior. If a vendor is deleted, the work item loses its vendor assignment but survives. - Mutual exclusivity CHECK: Models the real-world distinction that either you (user) do the work, or a contractor (vendor) does. The API validates this before the database constraint fires, returning a descriptive error.
| Change | Column | Type | Constraints | Description |
|---|---|---|---|---|
| DROP | room |
TEXT | (was nullable) | Replaced by structured area_id
|
| ADD | area_id |
TEXT | nullable, REFERENCES areas(id) ON DELETE SET NULL | Spatial location of this household item |
New index: idx_household_items_area_id on area_id — replaces idx_household_items_room.
Dropped index: idx_household_items_room — no longer needed.
Design rationale: The free-text room field is replaced by the structured area_id FK, enabling consistent spatial organization between work items and household items. Users can represent any room (or sub-room) as an area in the hierarchy.
This table was dropped in migration 0018. Tags are replaced by the areas and trades tables, which provide structured dimensions instead of generic labels.
This table was dropped in migration 0018. The work item tagging relationship is replaced by work_items.area_id (spatial) and transitive trade lookup via work_items.assigned_vendor_id -> vendors.trade_id.
This table was dropped in migration 0018. The household item tagging relationship is replaced by household_items.area_id.
Budget categories:
- ADD:
bc-waste("Waste") — waste disposal and recycling costs - CONDITIONAL DELETE (only if 0 references):
bc-equipment,bc-landscaping,bc-utilities,bc-insurance,bc-contingency
Household item categories:
- ADD:
hic-equipment("Equipment") — tools and construction equipment - CONDITIONAL DELETE (only if 0 references):
hic-outdoor,hic-storage
+------------------+
| areas |
+------------------+
| id (PK, UUID) |<--+ (self-ref: parent_id)
| name | |
| parent_id FK ----+---+
| color |
| description |
| sort_order |
| created_at |
| updated_at |
+------------------+
| |
| 1:N | 1:N
| |
v v
work_items household_items
(area_id) (area_id)
+------------------+
| trades |
+------------------+
| id (PK, UUID) |
| name (UNIQUE) |
| color |
| description |
| sort_order |
| created_at |
| updated_at |
+------------------+
|
| 1:N
v
vendors (trade_id)
|
| 1:N (assigned_vendor_id)
v
work_items
Key relationships:
- areas -> work_items: One-to-many (optional). Each work item can belong to one area. ON DELETE SET NULL.
- areas -> household_items: One-to-many (optional). Each household item can belong to one area. ON DELETE SET NULL.
- areas -> areas: Self-referential one-to-many (parent-child). ON DELETE CASCADE for subtree cleanup.
- trades -> vendors: One-to-many (optional). Each vendor can have one trade. ON DELETE SET NULL.
-
vendors -> work_items: One-to-many via
assigned_vendor_id(optional). A vendor can be assigned to many work items. ON DELETE SET NULL. Mutually exclusive withassigned_user_id.
server/src/db/migrations/0018_areas_trades.sql
-- EPIC-18: Area & Trade Structured Dimensions
-- Replaces the generic tagging system with purpose-built areas and trades.
-- See ADR-028 for detailed rationale.
-- ── New tables ──────────────────────────────────────────────────────
CREATE TABLE areas (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
parent_id TEXT REFERENCES areas(id) ON DELETE CASCADE,
color TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(name, parent_id)
);
CREATE INDEX idx_areas_parent_id ON areas(parent_id);
CREATE TABLE trades (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
color TEXT,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- ── Seed default trades ─────────────────────────────────────────────
INSERT INTO trades (id, name, color, description, sort_order, created_at, updated_at) VALUES
('trade-plumbing', 'Plumbing', '#0EA5E9', 'Water supply, drainage, gas piping', 0, datetime('now'), datetime('now')),
('trade-hvac', 'HVAC', '#8B5CF6', 'Heating, ventilation, air conditioning', 1, datetime('now'), datetime('now')),
('trade-electrical', 'Electrical', '#F59E0B', 'Wiring, panels, fixtures, smart home', 2, datetime('now'), datetime('now')),
('trade-drywall', 'Drywall', '#A3A3A3', 'Wall/ceiling installation and finishing', 3, datetime('now'), datetime('now')),
('trade-carpentry', 'Carpentry', '#92400E', 'Framing, trim, cabinetry, woodwork', 4, datetime('now'), datetime('now')),
('trade-masonry', 'Masonry', '#78716C', 'Brick, stone, concrete block work', 5, datetime('now'), datetime('now')),
('trade-painting', 'Painting', '#EC4899', 'Interior/exterior painting and finishing', 6, datetime('now'), datetime('now')),
('trade-roofing', 'Roofing', '#DC2626', 'Roof installation, repair, gutters', 7, datetime('now'), datetime('now')),
('trade-flooring', 'Flooring', '#65A30D', 'Hardwood, tile, carpet, vinyl installation', 8, datetime('now'), datetime('now')),
('trade-tiling', 'Tiling', '#0891B2', 'Wall and floor tile installation', 9, datetime('now'), datetime('now')),
('trade-landscaping', 'Landscaping', '#22C55E', 'Outdoor grading, planting, hardscape', 10, datetime('now'), datetime('now')),
('trade-excavation', 'Excavation', '#A16207', 'Site preparation, grading, trenching', 11, datetime('now'), datetime('now')),
('trade-gc', 'General Contractor', '#3B82F6', 'Overall project coordination', 12, datetime('now'), datetime('now')),
('trade-design', 'Architect / Design', '#6366F1', 'Architectural and interior design services', 13, datetime('now'), datetime('now')),
('trade-other', 'Other', '#6B7280', 'Trades not covered by other categories', 14, datetime('now'), datetime('now'));
-- ── Modify vendors: drop specialty, add trade_id ────────────────────
ALTER TABLE vendors ADD COLUMN trade_id TEXT REFERENCES trades(id) ON DELETE SET NULL;
CREATE INDEX idx_vendors_trade_id ON vendors(trade_id);
-- Note: SQLite does not support DROP COLUMN in older versions.
-- The specialty column is left in place but ignored by the application.
-- A future migration can rebuild the table to physically remove it.
-- Application code stops reading/writing specialty after this migration.
-- ── Modify work_items: add area_id, assigned_vendor_id ──────────────
ALTER TABLE work_items ADD COLUMN area_id TEXT REFERENCES areas(id) ON DELETE SET NULL;
ALTER TABLE work_items ADD COLUMN assigned_vendor_id TEXT REFERENCES vendors(id) ON DELETE SET NULL;
CREATE INDEX idx_work_items_area_id ON work_items(area_id);
CREATE INDEX idx_work_items_assigned_vendor_id ON work_items(assigned_vendor_id);
-- Note: SQLite does not support ADD CHECK on existing tables via ALTER.
-- The CHECK(assigned_user_id IS NULL OR assigned_vendor_id IS NULL) constraint
-- is enforced at the application layer. New work_items created after this migration
-- will have the constraint in the Drizzle schema definition.
-- ── Modify household_items: add area_id ─────────────────────────────
ALTER TABLE household_items ADD COLUMN area_id TEXT REFERENCES areas(id) ON DELETE SET NULL;
CREATE INDEX idx_household_items_area_id ON household_items(area_id);
-- Note: The room column is left in place but ignored by the application.
-- A future migration can rebuild the table to physically remove it.
-- The idx_household_items_room index is also left in place (harmless).
-- ── Drop tag tables ─────────────────────────────────────────────────
DROP INDEX IF EXISTS idx_household_item_tags_tag_id;
DROP TABLE IF EXISTS household_item_tags;
DROP INDEX IF EXISTS idx_work_item_tags_tag_id;
DROP TABLE IF EXISTS work_item_tags;
DROP TABLE IF EXISTS tags;
-- ── Budget category default changes ─────────────────────────────────
INSERT OR IGNORE INTO budget_categories (id, name, description, color, sort_order, created_at, updated_at)
VALUES ('bc-waste', 'Waste', 'Waste disposal and recycling costs', '#A16207', 10, datetime('now'), datetime('now'));
-- Conditionally delete unused budget category defaults
DELETE FROM budget_categories WHERE id = 'bc-equipment'
AND NOT EXISTS (SELECT 1 FROM work_item_budgets WHERE budget_category_id = 'bc-equipment')
AND NOT EXISTS (SELECT 1 FROM household_item_budgets WHERE budget_category_id = 'bc-equipment');
DELETE FROM budget_categories WHERE id = 'bc-landscaping'
AND NOT EXISTS (SELECT 1 FROM work_item_budgets WHERE budget_category_id = 'bc-landscaping')
AND NOT EXISTS (SELECT 1 FROM household_item_budgets WHERE budget_category_id = 'bc-landscaping');
DELETE FROM budget_categories WHERE id = 'bc-utilities'
AND NOT EXISTS (SELECT 1 FROM work_item_budgets WHERE budget_category_id = 'bc-utilities')
AND NOT EXISTS (SELECT 1 FROM household_item_budgets WHERE budget_category_id = 'bc-utilities');
DELETE FROM budget_categories WHERE id = 'bc-insurance'
AND NOT EXISTS (SELECT 1 FROM work_item_budgets WHERE budget_category_id = 'bc-insurance')
AND NOT EXISTS (SELECT 1 FROM household_item_budgets WHERE budget_category_id = 'bc-insurance');
DELETE FROM budget_categories WHERE id = 'bc-contingency'
AND NOT EXISTS (SELECT 1 FROM work_item_budgets WHERE budget_category_id = 'bc-contingency')
AND NOT EXISTS (SELECT 1 FROM household_item_budgets WHERE budget_category_id = 'bc-contingency');
-- ── Household item category default changes ─────────────────────────
INSERT OR IGNORE INTO household_item_categories (id, name, color, sort_order, created_at, updated_at)
VALUES ('hic-equipment', 'Equipment', '#78716C', 8, datetime('now'), datetime('now'));
DELETE FROM household_item_categories WHERE id = 'hic-outdoor'
AND NOT EXISTS (SELECT 1 FROM household_items WHERE category_id = 'hic-outdoor');
DELETE FROM household_item_categories WHERE id = 'hic-storage'
AND NOT EXISTS (SELECT 1 FROM household_items WHERE category_id = 'hic-storage');
-- Rollback:
-- DELETE FROM household_item_categories WHERE id = 'hic-equipment';
-- INSERT OR IGNORE INTO household_item_categories (id, name, color, sort_order, created_at, updated_at)
-- VALUES ('hic-outdoor', 'Outdoor', '#22C55E', 5, datetime('now'), datetime('now'));
-- INSERT OR IGNORE INTO household_item_categories (id, name, color, sort_order, created_at, updated_at)
-- VALUES ('hic-storage', 'Storage', '#F97316', 6, datetime('now'), datetime('now'));
-- DELETE FROM budget_categories WHERE id = 'bc-waste';
-- DROP INDEX IF EXISTS idx_household_items_area_id;
-- ALTER TABLE household_items DROP COLUMN area_id; -- requires table rebuild in SQLite
-- DROP INDEX IF EXISTS idx_work_items_assigned_vendor_id;
-- DROP INDEX IF EXISTS idx_work_items_area_id;
-- ALTER TABLE work_items DROP COLUMN assigned_vendor_id; -- requires table rebuild
-- ALTER TABLE work_items DROP COLUMN area_id; -- requires table rebuild
-- DROP INDEX IF EXISTS idx_vendors_trade_id;
-- ALTER TABLE vendors DROP COLUMN trade_id; -- requires table rebuild
-- CREATE TABLE tags (...);
-- CREATE TABLE work_item_tags (...);
-- CREATE TABLE household_item_tags (...);
-- DROP TABLE IF EXISTS trades;
-- DROP INDEX IF EXISTS idx_areas_parent_id;
-- DROP TABLE IF EXISTS areas;Migration notes:
-
SQLite ALTER TABLE limitations: SQLite does not support
DROP COLUMNin versions before 3.35.0 orADD CHECKconstraints via ALTER. The migration leavesvendors.specialtyandhousehold_items.roomcolumns physically in place but the application ignores them. The CHECK constraint onwork_itemsfor mutual exclusivity is enforced at the application layer. - Tag table drops are safe: All tag data is permanently deleted. Since tags are being replaced by structured dimensions, there is no data migration path — the concepts are fundamentally different.
- Conditional category deletes: Budget categories and household item categories are only deleted if zero budget lines or household items reference them, preventing data integrity issues for active projects.
| Date | Page Section | Deviation | Resolution |
|---|---|---|---|
| 2026-02-27 |
work_items table, Status values, Migration File |
PR #308 added actual_start_date/actual_end_date columns and removed blocked status. Wiki was not updated alongside the implementation. |
Updated work_items table to include new columns, updated status CHECK constraint and status values table, added auto-population documentation, added Migration 0008 section. |
| 2026-03-03 |
household_items, household_item_work_items, EPIC-04 |
PR #416 replaced household_item_work_items with household_item_deps, added delivery date columns to household_items. Wiki was not updated alongside the implementation. |
Added earliest_delivery_date/latest_delivery_date to household_items table, replaced household_item_work_items docs with household_item_deps, added Migration 0012 section. |
| 2026-03-04 |
household_items table, Delivery Date Columns |
PR #456 replaced expected_delivery_date with three-part model: target_delivery_date (computed), earliest_delivery_date/latest_delivery_date (user-editable constraints), and is_late flag. Wiki had outdated column docs and was missing Migration 0015. |
Updated household_items table schema to reflect new columns, updated design rationale, added Migration 0015 section, updated indexes. |
| 2026-03-06 |
household_items table, Migration 0016, EPIC-09 |
PR #516 (Issue #509) replaced household_items.category CHECK constraint enum with category_id FK to new household_item_categories lookup table. Migration 0016 was previously documented as user_preferences but the actual 0016 is household_item_categories. Also seeds bc-household-items budget category. Wiki was not updated alongside the implementation. |
Added household_item_categories table, updated household_items to show category_id instead of category, replaced migration 0016 with household_item_categories, renumbered user_preferences to 0017 (not yet implemented). Added HI category CRUD endpoints to API Contract. |
| 2026-03-09 |
invoice_budget_lines FK constraints, Migration number |
Two deviations: (1) Schema.md documented ON DELETE SET NULL for work_item_budget_id and household_item_budget_id FKs, but actual migration 0017 and Drizzle schema use ON DELETE CASCADE (required due to XOR CHECK constraint incompatibility with SET NULL, see Bug #611). (2) user_preferences migration was documented as 0017 but 0017 is invoice_budget_lines; actual number is 0018. |
Fixed FK constraints to ON DELETE CASCADE with rationale. Renumbered user_preferences migration to 0018. |