Files

185 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 02 — Data Model (PostgreSQL)
Complete schema, written as golang-migrate migrations under `internal/db/migrations/`. Conventions from [01-architecture.md](01-architecture.md) apply (identity PKs, timestamptz, soft delete, `ON DELETE RESTRICT` FKs). Extensions: `citext`.
Statuses/priorities are **fixed** product-wide, so native Postgres enums are safe and self-documenting. Estimation is **story points**, Fibonacci subset.
Deviations from the first iteration's SQLite schema are marked *(deviation)* with reasons; the full rationale is in [17-decisions.md](17-decisions.md).
## Enum types
- [ ] `project_status`: `backlog | planned | in_progress | completed | canceled`
- [ ] `epic_status`: `backlog | planned | in_progress | completed | canceled`
- [ ] `issue_status`: `backlog | planned | in_progress | ready_for_review | done | canceled | duplicate`
- [ ] `priority`: `low | medium | high | urgent | frantic` (nullable column = "none")
- [ ] `member_role`: `owner | member`
- [ ] `auth_provider`: `github | gitea`
- [ ] `resource_kind`: `project | issue | epic | comment | wiki_page | user | label | attachment | link | member | webhook | auth`
- [ ] `job_status`: `pending | running | done | failed | dead`
- [ ] `receipt_status`: `pending | processed | failed | ignored`
- [ ] `delivery_status`: `pending | success | failed`
- [ ] `email_mode`: `off | instant | daily_digest`
## Tables
### Identity & auth
- [ ] **users**
- [ ] `id` bigint identity PK
- [ ] `username` citext UNIQUE NOT NULL; `name` text NOT NULL; `email` citext UNIQUE NOT NULL
- [ ] `password_hash` text NULL (bcrypt cost 12; NULL = OAuth-only account)
- [ ] `avatar_url` text NULL; `is_admin` boolean NOT NULL DEFAULT false
- [ ] `email_mode` email_mode NOT NULL DEFAULT 'instant'
- [ ] `created_at`, `updated_at` NULL, `deleted_at` NULL
- [ ] Seeds: id 1 `system` ("System"), id 2 `deleted-user` ("Deleted User") — both `password_hash` NULL, excluded from login, member pickers, and search; identity sequence restarted at 3
- [ ] **oauth_accounts** *(deviation: replaces the first iteration's single `provider` column on users — allows local+GitHub+Gitea linked to one account, links by verified email at callback)*
- [ ] `id` PK; `user_id` FK→users NOT NULL; `provider` auth_provider NOT NULL; `provider_user_id` text NOT NULL; `created_at`
- [ ] UNIQUE(`provider`, `provider_user_id`); UNIQUE(`user_id`, `provider`)
- [ ] **sessions** — exact scs pgxstore schema
- [ ] `token` text PK; `data` bytea NOT NULL; `expiry` timestamptz NOT NULL; index on `expiry`
- [ ] **api_tokens**
- [ ] `id` PK; `user_id` FK NOT NULL; `name` text NOT NULL
- [ ] `token_hash` text UNIQUE NOT NULL (SHA-256 hex of full token `solopm_<40 random base62>`)
- [ ] `prefix` text NOT NULL (first 12 chars, display only)
- [ ] `last_used_at` NULL; `expires_at` NULL; `revoked_at` NULL; `created_at`
### Projects
- [ ] **projects**
- [ ] `id` PK; `creator_id` FK→users NOT NULL; `owner_id` FK→users NOT NULL
- [ ] `title` text NOT NULL; `description` text NOT NULL DEFAULT '' (markdown)
- [ ] `status` project_status NOT NULL DEFAULT 'backlog'; `priority` priority NULL
- [ ] `start_date` date NULL; `target_date` date NULL; `completed_at` timestamptz NULL (set when status→completed)
- [ ] `git_repository_url` text NULL (used to route incoming webhooks)
- [ ] `next_issue_number` int NOT NULL DEFAULT 1 *(deviation: explicit counter for safe concurrent `#N` allocation)*
- [ ] `created_at`, `updated_at`, `deleted_at`
- [ ] Indexes: (`owner_id`); partial (`id`) WHERE `deleted_at IS NULL`
- [ ] **project_members**
- [ ] `id` PK; `project_id` FK NOT NULL; `user_id` FK NOT NULL; `role` member_role NOT NULL DEFAULT 'member'
- [ ] `invited_by` FK→users NULL; `created_at`
- [ ] UNIQUE(`project_id`, `user_id`); index (`user_id`)
- [ ] Invariant (service-enforced): exactly one row with role `owner` per project, matching `projects.owner_id`
- [ ] **project_dependencies** — "this project is blocked by that project"
- [ ] `id` PK; `project_id` FK NOT NULL; `depends_on_project_id` FK→projects NOT NULL; `created_by` FK NOT NULL; `created_at`
- [ ] UNIQUE(`project_id`, `depends_on_project_id`); CHECK(`project_id <> depends_on_project_id`)
- [ ] Cycle detection at service layer (DFS over edges) → `409 cycle_detected`
- [ ] **project_favorites**
- [ ] composite PK(`user_id`, `project_id`); `created_at` (dashboard sorts favorites first)
- [ ] **labels** *(deviation: renamed from `project_labels`)*
- [ ] `id` PK; `project_id` FK NOT NULL; `name` text NOT NULL; `color` text NOT NULL (hex)
- [ ] `created_by` FK NOT NULL; `created_at`; `deleted_at`
- [ ] Partial UNIQUE(`project_id`, `name`) WHERE `deleted_at IS NULL`
- [ ] Seeded on project creation: enhancement, bug, chore, task, feature, documentation (colors from the design-system palette, [14-design-system.md](14-design-system.md))
### Work items
- [ ] **epics**
- [ ] `id` PK; `project_id` FK NOT NULL; `creator_id` FK NOT NULL
- [ ] `title` text NOT NULL; `description` text NOT NULL DEFAULT ''; `status` epic_status NOT NULL DEFAULT 'backlog'
- [ ] `color` text NOT NULL (hex, from palette)
- [ ] `created_at`, `updated_at`, `deleted_at`
- [ ] Partial index (`project_id`) WHERE `deleted_at IS NULL`
- [ ] **issues**
- [ ] `id` PK; `project_id` FK NOT NULL; `project_issue_id` int NOT NULL — UNIQUE(`project_id`, `project_issue_id`)
- [ ] `creator_id` FK NOT NULL; `assignee_id` FK→users NULL
- [ ] `epic_id` FK→epics NULL; `parent_issue_id` FK→issues NULL (sub-issues; one level of UI nesting, arbitrary depth allowed in data)
- [ ] `title` text NOT NULL; `description` text NOT NULL DEFAULT '' (markdown)
- [ ] `status` issue_status NOT NULL DEFAULT 'backlog'; `priority` priority NULL
- [ ] `estimate` smallint NULL CHECK (`estimate` IN (1,2,3,5,8,13)) — story points
- [ ] `start_date` date NULL; `target_date` date NULL; `completed_at` timestamptz NULL (set automatically when status→done, cleared when leaving done)
- [ ] `board_rank` text NOT NULL — fractional lexicographic rank within status column (kanban order)
- [ ] `epic_rank` text NULL — order within its epic
- [ ] `created_at`, `updated_at`, `deleted_at`
- [ ] Indexes: (`project_id`, `status`), (`assignee_id`), (`epic_id`), (`parent_issue_id`)
- [ ] Number allocation inside the insert tx: `UPDATE projects SET next_issue_number = next_issue_number + 1 WHERE id = $1 RETURNING next_issue_number - 1`
- [ ] **issue_blockers** — "issue X is blocked by issue Y" (same project only)
- [ ] `id` PK; `issue_id` FK NOT NULL; `blocked_by_issue_id` FK→issues NOT NULL; `created_by` FK; `created_at`
- [ ] UNIQUE(`issue_id`, `blocked_by_issue_id`); CHECK(`issue_id <> blocked_by_issue_id`); service-layer cycle detection → 409
- [ ] **issue_labels** *(deviation: hard-delete pivot, no `deleted_at` — audit log preserves history)*
- [ ] `id` PK; `issue_id` FK NOT NULL; `label_id` FK NOT NULL; `applied_by` FK NOT NULL; `created_at`
- [ ] UNIQUE(`issue_id`, `label_id`)
- [ ] **issue_watchers**
- [ ] composite PK(`issue_id`, `user_id`); `muted` boolean NOT NULL DEFAULT false; `created_at`
- [ ] `muted=true` = explicitly unsubscribed; auto-watch must never overwrite it
### Collaboration
- [ ] **comments**
- [ ] `id` PK; `issue_id` FK NOT NULL; `author_id` FK NOT NULL; `body` text NOT NULL (markdown)
- [ ] `resolved_at` NULL; `resolved_by` FK→users NULL (resolvable threads)
- [ ] `created_at`, `updated_at`, `deleted_at`
- [ ] Index (`issue_id`, `created_at`)
- [ ] **links** *(deviation: one polymorphic table replaces project_links/issue_links/comment_links — culling handles integrity, API surface shrinks 3×)*
- [ ] `id` PK; `resource_type` resource_kind NOT NULL (allowed: project|issue|comment); `resource_id` bigint NOT NULL
- [ ] `url` text NOT NULL; `title` text NULL; `created_by` FK NOT NULL; `created_at`; `deleted_at`
- [ ] Index (`resource_type`, `resource_id`)
- [ ] **attachments** *(same polymorphic deviation)*
- [ ] `id` PK; `resource_type` resource_kind NOT NULL (project|issue|comment); `resource_id` bigint NOT NULL
- [ ] `uploader_id` FK NOT NULL; `file_path` text NOT NULL — relative to `UPLOAD_DIR`, format `project_{pid}/{uploader_id}_{yyyymmddhhmmss}_{rand16}.{ext}`
- [ ] `original_filename` text NOT NULL; `mime_type` text NOT NULL; `size_bytes` bigint NOT NULL
- [ ] `created_at`; `deleted_at`
- [ ] Index (`resource_type`, `resource_id`)
- [ ] **wiki_pages**
- [ ] `id` PK; `project_id` FK NOT NULL; `slug` text NOT NULL (kebab-case from title); `title` text NOT NULL; `body` text NOT NULL DEFAULT '' (markdown)
- [ ] `created_by` FK NOT NULL; `updated_by` FK NOT NULL
- [ ] `created_at`, `updated_at`, `deleted_at`
- [ ] Partial UNIQUE(`project_id`, `slug`) WHERE `deleted_at IS NULL`
### Audit, notifications, events, jobs
- [ ] **audit_log** — immutable; never UPDATEd; culled only by retention policy
- [ ] `id` PK; `created_at` NOT NULL DEFAULT now()
- [ ] `initiated_by` FK→users NOT NULL (system user id 1 for automation); `via_webhook` boolean NOT NULL DEFAULT false
- [ ] `project_id` bigint NULL *(deviation: denormalized so project activity feed is one indexed query)*
- [ ] `resource_type` resource_kind NOT NULL; `resource_id` bigint NOT NULL; `action_type` text NOT NULL (matches event taxonomy, [03-domain-events.md](03-domain-events.md))
- [ ] `previous_values` jsonb NULL; `new_values` jsonb NULL (changed fields only)
- [ ] Indexes: (`resource_type`, `resource_id`), (`initiated_by`, `created_at`), (`created_at`), (`project_id`, `created_at` DESC)
- [ ] Display text is generated app-side from action_type + values (no stored `summary` prose)
- [ ] **notifications**
- [ ] `id` PK; `user_id` FK NOT NULL (recipient); `actor_id` FK NOT NULL
- [ ] `type` text NOT NULL: `assigned | mentioned | commented | added_to_project | status_changed`
- [ ] `project_id` FK NULL; `resource_type` resource_kind NOT NULL; `resource_id` bigint NOT NULL
- [ ] `payload` jsonb NOT NULL (denormalized title/snippet/refs for rendering without joins)
- [ ] `read_at` NULL; `created_at`
- [ ] Index (`user_id`, `read_at`, `created_at` DESC)
- [ ] **events** — transactional outbox ([03-domain-events.md](03-domain-events.md))
- [ ] `id` bigserial PK (ordering matters); `event_type` text NOT NULL; `actor_id` bigint NOT NULL; `project_id` bigint NULL
- [ ] `resource_type` resource_kind NOT NULL; `resource_id` bigint NOT NULL
- [ ] `payload` jsonb NOT NULL (prev/new snapshots, mention lists, etc.); `via_webhook` boolean NOT NULL DEFAULT false
- [ ] `created_at` NOT NULL DEFAULT now(); `processed_at` NULL; `attempts` int NOT NULL DEFAULT 0
- [ ] Partial index WHERE `processed_at IS NULL`
- [ ] **jobs** — retryable async work
- [ ] `id` PK; `kind` text NOT NULL: `send_email | webhook_delivery | search_sync | daily_digest | cull`
- [ ] `payload` jsonb NOT NULL; `status` job_status NOT NULL DEFAULT 'pending'
- [ ] `run_at` timestamptz NOT NULL DEFAULT now(); `attempts` int NOT NULL DEFAULT 0; `max_attempts` int NOT NULL DEFAULT 8; `last_error` text NULL
- [ ] `created_at`, `updated_at`
- [ ] Index (`status`, `run_at`)
### Webhooks
- [ ] **incoming_webhook_receipts**
- [ ] `id` PK; `project_id` FK NULL (NULL when repo URL didn't resolve to a project)
- [ ] `source` auth_provider NOT NULL; `event_type` text NOT NULL; `delivery_id` text NOT NULL — UNIQUE(`source`, `delivery_id`) for redelivery idempotency
- [ ] `payload` text NOT NULL (raw body, replayable); `signature_valid` boolean NOT NULL
- [ ] `status` receipt_status NOT NULL DEFAULT 'pending'; `failure_reason` text NULL
- [ ] `received_at` NOT NULL DEFAULT now(); `processed_at` NULL
- [ ] **outgoing_webhooks**
- [ ] `id` PK; `project_id` FK NOT NULL; `url` text NOT NULL; `secret` text NOT NULL
- [ ] `active` boolean NOT NULL DEFAULT true; `event_types` text[] NOT NULL DEFAULT '{}' (empty = all events)
- [ ] `created_by` FK NOT NULL; `created_at`; `deleted_at`
- [ ] **outgoing_webhook_deliveries**
- [ ] `id` PK; `webhook_id` FK NOT NULL; `event_id` FK→events NOT NULL — UNIQUE(`webhook_id`, `event_id`) (idempotent fan-out)
- [ ] `event_type` text NOT NULL; `payload` jsonb NOT NULL
- [ ] `status` delivery_status NOT NULL DEFAULT 'pending'; `response_status` int NULL; `response_body` text NULL (truncated 4 KB); `attempts` int NOT NULL DEFAULT 0
- [ ] `delivered_at` NULL; `created_at`
- [ ] Index (`webhook_id`, `created_at` DESC)
## Migration & seed checklist
- [ ] Migration 0001: extensions (`citext`) + all enum types
- [ ] Migrations grouped per domain in dependency order (users → oauth/sessions/tokens → projects family → epics/issues family → collaboration → audit/notifications/events/jobs → webhooks)
- [ ] Every migration has a working `.down.sql`
- [ ] Seed migration: system user (id 1) + zombie user (id 2), sequence restart at 3
- [ ] Label seeding on project creation implemented in the projects service (not a DB trigger)
- [ ] sqlc config covers all tables; generated code compiles; `make sqlc` is idempotent