adding claude file and some plans

This commit is contained in:
2026-08-12 18:55:18 -06:00
parent b206964417
commit 4da16c7573
19 changed files with 1294 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
# SoloPM — Overview & Master Index
SoloPM is a self-hostable project management system for **solo developers and small teams (fewer than 10 users per instance/team)**. It is deliberately small: no sprints, no custom workflows, no permission matrices, no multi-tenancy. Any user in the instance can be added to any project; the whole instance *is* the team.
This `plans/` directory is the complete build specification. It was designed from two references: a prior partial attempt ("first iteration": schema + prose specs + Vue shell) and Taiga (feature-mined, enterprise surface dropped). All product decisions here are final — build agents must not relitigate them (rationale lives in [17-decisions.md](17-decisions.md)).
## How to use these plans
- Every buildable/verifiable item in these files is a markdown checkbox (`- [ ]`).
- **When you complete an item, edit the file and mark it `- [x]`.** Both AI build agents and the human owner use these checkboxes as the single source of progress truth.
- Do not check items you did not verify. Partial work stays unchecked.
- Work proceeds in phases ([16-phases.md](16-phases.md)). Each phase names the spec files it needs; read those plus `00`, `01`, `02` before starting.
## Locked stack
- **Backend**: Go — chi (router), pgx (Postgres driver), sqlc (type-safe queries from SQL), golang-migrate (migrations), alexedwards/scs (sessions). No ORM, no framework.
- **Frontend**: Vue 3 + TypeScript, Vite, Pinia, Vue Router, Tailwind CSS, pnpm. Soft UI design system ([14-design-system.md](14-design-system.md)).
- **Database**: PostgreSQL 16+. **Search**: Meilisearch. **Email**: SMTP.
- **Deployment**: one Go binary embedding the built SPA via `go:embed`; runs bare with env config, or via reference docker-compose (app + postgres + meilisearch). See [15-deployment.md](15-deployment.md).
## Reading order
| Order | File | What it covers |
|---|---|---|
| 1 | [00-overview.md](00-overview.md) | This file — product scope, index, progress |
| 2 | [01-architecture.md](01-architecture.md) | Repo layout, layering, config, conventions |
| 3 | [02-data-model.md](02-data-model.md) | Complete Postgres schema |
| 4 | [03-domain-events.md](03-domain-events.md) | Event outbox, jobs, consumers |
| 5 | [04-api.md](04-api.md) | API conventions + full route index |
| 6 | [05-auth.md](05-auth.md) | Sessions, OAuth, personal access tokens |
| 7 | [06-projects.md](06-projects.md) | Projects, members, labels, dependencies |
| 8 | [07-issues-epics.md](07-issues-epics.md) | Issues, sub-issues, blockers, epics, kanban ranks |
| 9 | [08-collaboration.md](08-collaboration.md) | Comments, attachments, links, wiki, markdown, watchers |
| 10 | [09-notifications-realtime.md](09-notifications-realtime.md) | Bell/inbox, email, SSE |
| 11 | [10-search.md](10-search.md) | Meilisearch indexes + sync |
| 12 | [11-integrations.md](11-integrations.md) | Incoming/outgoing webhooks, export |
| 13 | [12-retention.md](12-retention.md) | Soft deletes, culling job |
| 14 | [13-frontend.md](13-frontend.md) | Routes, pages, stores, components |
| 15 | [14-design-system.md](14-design-system.md) | Visual tokens + component recipes |
| 16 | [15-deployment.md](15-deployment.md) | Build pipeline, compose, ops |
| 17 | [16-phases.md](16-phases.md) | Phased roadmap + acceptance criteria |
| 18 | [17-decisions.md](17-decisions.md) | Decision log (rationale, non-negotiables) |
## Glossary
- **Issue** — the unit of work. Has a per-project sequential number (`#N`), optional parent issue (sub-issue), optional epic, optional assignee.
- **Epic** — a project-scoped grouping of issues for larger initiatives, with color, status, and progress (issue counts + story-point rollup).
- **Watcher** — a user subscribed to an issue's changes. Auto-watch happens on create/assign/comment/mention; **muted** means "never auto-rewatch me".
- **System user** — reserved user id 1; performs automated actions (webhook automation). Cannot log in.
- **Zombie user** — reserved user id 2 ("Deleted User"); inherits records orphaned by user culling.
- **Audit log** — immutable record of every mutation; source of all activity feeds.
- **Outbox / events table** — transactional record of domain events, fanned out to notifications, SSE, search sync, and outgoing webhooks.
- **Culling** — the scheduled hard-delete job that erases soft-deleted rows (and their disk files) after `CULL_AFTER_DAYS`.
- **PAT** — personal access token (`solopm_…`) for API/CLI auth.
## What "done" looks like
A user runs `docker compose up` (or the bare binary against their own Postgres/Meilisearch), registers (first account becomes admin), creates a project, invites teammates, and manages epics and issues through a list view and a drag-and-drop kanban board. Comments, mentions, attachments, and wiki pages work with markdown; `@mention` and `#N` cross-links resolve. A second browser sees changes live via SSE, the bell shows notifications, emails arrive per user preference. Search (Cmd+K) finds anything in the user's projects. Merged PRs on GitHub/Gitea that say `closes #N` complete issues automatically; outgoing webhooks notify external URLs; projects export to JSON/CSV. Soft-deleted data is culled on schedule, files included.
## Progress
### Phases (see [16-phases.md](16-phases.md) for detail)
- [ ] Phase 0 — Foundation (scaffold, config, migrations wiring, SPA shell, compose, healthz)
- [ ] Phase 1 — Auth & users
- [ ] Phase 2 — Event backbone & audit log
- [ ] Phase 3 — Projects core
- [ ] Phase 4 — Issues & epics
- [ ] Phase 5 — Views (frontend core)
- [ ] Phase 6 — Collaboration
- [ ] Phase 7 — Notifications & real-time
- [ ] Phase 8 — Search
- [ ] Phase 9 — Integrations
- [ ] Phase 10 — Retention & hardening
### Spec files authored
- [x] 00-overview.md
- [x] 01-architecture.md
- [x] 02-data-model.md
- [x] 03-domain-events.md
- [x] 04-api.md
- [x] 05-auth.md
- [x] 06-projects.md
- [x] 07-issues-epics.md
- [x] 08-collaboration.md
- [x] 09-notifications-realtime.md
- [x] 10-search.md
- [x] 11-integrations.md
- [x] 12-retention.md
- [x] 13-frontend.md
- [x] 14-design-system.md
- [x] 15-deployment.md
- [x] 16-phases.md
- [x] 17-decisions.md
+75
View File
@@ -0,0 +1,75 @@
# 01 — Architecture & Conventions
Backend is a single Go module `solopm.com/solopm-server`, one binary, serving the JSON API and the embedded Vue SPA. Layering is strict: **HTTP handler → domain service → sqlc-generated store**. Handlers never touch the database; services never parse HTTP. All mutations run inside a transaction and emit domain events ([03-domain-events.md](03-domain-events.md)).
## Repository layout
- [ ] Create the repo skeleton:
```
/ (repo root; /app in this workspace)
├── cmd/solopm/main.go — flag/env parsing, wiring, serve + CLI subcommands (migrate, reindex, cull)
├── internal/
│ ├── config/ — env config loader + validation
│ ├── httpserver/ — chi router, middleware, handler registration, SPA fallback
│ ├── domain/
│ │ ├── users/ — service + handlers per domain (users, projects, issues, epics,
│ │ ├── projects/ comments, wiki, notifications, search, webhooks, export, retention)
│ │ └── ...
│ ├── db/
│ │ ├── migrations/ — golang-migrate SQL files (NNNN_name.up.sql / .down.sql)
│ │ ├── queries/ — sqlc input .sql files, one file per domain
│ │ └── sqlcgen/ — generated code (checked in)
│ ├── events/ — Emit(), outbox dispatcher, consumer registry
│ ├── jobs/ — job queue workers (email, webhook delivery, search sync, digest, cull)
│ ├── search/ — Meilisearch client + index definitions
│ ├── mail/ — SMTP sender + templates
│ ├── auth/ — session/PAT middleware, OAuth clients, bcrypt helpers
│ └── files/ — attachment storage on disk under UPLOAD_DIR
├── web/ — Vue app (see 13-frontend.md); web/dist embedded via go:embed
├── plans/ — these specs
├── Makefile — fmt, vet, test, sqlc, migrate, build (frontend then backend)
├── sqlc.yaml
├── docker-compose.yml
└── .env.example — every env var with sane defaults (never commit .env)
```
## Configuration (env vars)
- [ ] Implement `internal/config` reading these variables (fail fast at startup on missing required ones):
- [ ] `DATABASE_URL` (required) — pgx connection string
- [ ] `HTTP_PORT` (default `4080`)
- [ ] `BASE_URL` (required) — external URL, used in emails/OAuth callbacks/webhook payloads
- [ ] `SESSION_SECRET` (required) — 32+ bytes
- [ ] `UPLOAD_DIR` (default `./uploads`) — attachment storage root
- [ ] `MAX_UPLOAD_MB` (default `25`)
- [ ] `MEILI_URL`, `MEILI_KEY` (required for search; app runs degraded without them — search endpoints return 503)
- [ ] `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM` (optional; email disabled when unset)
- [ ] `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` (optional — hides the button when unset)
- [ ] `GITEA_URL`, `GITEA_CLIENT_ID`, `GITEA_CLIENT_SECRET` (optional — same)
- [ ] `CULL_AFTER_DAYS` (default `30`)
- [ ] `LOG_LEVEL` (default `info`)
## Cross-cutting conventions
- [ ] **IDs**: `bigint GENERATED ALWAYS AS IDENTITY` primary keys everywhere.
- [ ] **Timestamps**: `timestamptz`; `created_at NOT NULL DEFAULT now()`; `updated_at` nullable, set by the service on update; never trust client clocks.
- [ ] **Soft delete**: nullable `deleted_at` on user-facing content tables. Every list/get query filters `deleted_at IS NULL` (enforce via sqlc query text). Hard deletion happens only in the culling job ([12-retention.md](12-retention.md)).
- [ ] **Foreign keys**: declared with `ON DELETE RESTRICT`. Cascades are the culling job's responsibility (it must also delete disk files — a DB cascade can't).
- [ ] **Transactions**: one per request mutation; service receives `pgx.Tx`; `events.Emit` is called inside the same tx.
- [ ] **Errors**: JSON `{"error": {"code": "<machine_code>", "message": "<human>", "fields": {"title": "required"}}}`. Codes: `validation_failed`, `not_found`, `forbidden`, `unauthorized`, `conflict`, `cycle_detected`, `rate_limited`, `internal`. Non-members receive **404, not 403**, for projects they can't see (don't leak existence).
- [ ] **Logging**: `log/slog`, JSON in production, request-id middleware; log every request (method, path, status, duration, user id).
- [ ] **Validation**: in services, not handlers; titles trimmed/non-empty; markdown bodies size-capped (64 KB).
- [ ] **Time/date fields**: `start_date`/`target_date` are `date` (no time component); `completed_at` is `timestamptz` set by status automation.
- [ ] **Graceful shutdown**: on SIGINT/SIGTERM stop accepting requests, close SSE hub, drain dispatcher + job workers (10s cap), close pgx pool.
- [ ] **SPA serving**: `go:embed web/dist`; any non-`/api`, non-`/webhooks`, non-`/files` GET serves `index.html` (deep links work).
- [ ] **Makefile targets**: `make dev` (Vite + Go with reload), `make build` (pnpm build → go build), `make test`, `make sqlc`, `make migrate-up/down`, `make lint`.
- [ ] **Tests**: table-driven unit tests for services (cycle detection, numbering, fan-out rules); integration tests against a real Postgres (dockertest or `TEST_DATABASE_URL`); no mocking of sqlc stores.
## Middleware chain (chi)
- [ ] Order: RequestID → RealIP → slog logger → Recoverer → CORS (same-origin default; permissive only in dev) → session loader (scs) → auth resolver (session cookie **or** `Authorization: Bearer solopm_…` PAT → sets user in context) → per-route guards:
- [ ] `RequireAuth` — 401 if no user
- [ ] `RequireAdmin` — 403 if not `is_admin`
- [ ] `RequireProjectMember` / `RequireProjectOwner` — loads project from URL, 404 if absent/soft-deleted/not a member, 403 if member-but-not-owner where owner is required
- [ ] Rate-limit login/register/password endpoints (per-IP token bucket, e.g. 10/min).
+184
View File
@@ -0,0 +1,184 @@
# 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
+65
View File
@@ -0,0 +1,65 @@
# 03 — Domain Events, Outbox & Jobs
Every mutation in SoloPM emits a domain event. One mechanism feeds five consumers: **audit log, notifications, SSE, Meilisearch sync, outgoing webhooks**. The design is a **hand-rolled transactional outbox in Postgres with a single in-process dispatcher** — no external queue, no River. Rationale in [17-decisions.md](17-decisions.md); the explicit assumption is **one app instance** (`FOR UPDATE SKIP LOCKED` makes even accidental multi-instance mostly safe, but the SSE hub is per-process).
## Emit contract
- [ ] Implement `events.Emit(ctx, tx, Event{Type, ActorID, ProjectID, ResourceType, ResourceID, Payload, ViaWebhook})` which, **inside the caller's transaction**:
- [ ] Inserts the `audit_log` row (exactly-once, atomic with the mutation — an audit entry can never be lost or orphaned)
- [ ] Inserts the `events` outbox row
- [ ] `Payload` includes: changed-field map `{field: {prev, new}}`, denormalized display data (issue title, project title, `#N`), and server-parsed `@mention` usernames where applicable ([08-collaboration.md](08-collaboration.md))
- [ ] After commit, the service fires `pg_notify('solopm_events', '')` — best-effort wake-up; polling is the safety net
- [ ] Every service mutation calls `Emit`; a mutation without an event is a spec violation
## Event taxonomy
Format `resource.action`, past tense. `action_type` in audit_log uses the same strings.
- [ ] `project.created` / `project.updated` / `project.status_changed` / `project.deleted`
- [ ] `project.member_added` / `project.member_removed` / `project.role_changed` / `project.ownership_transferred`
- [ ] `project.dependency_added` / `project.dependency_removed`
- [ ] `project.exported` (audit-only; no notification/SSE/search/webhook fan-out)
- [ ] `label.created` / `label.updated` / `label.deleted`
- [ ] `epic.created` / `epic.updated` / `epic.status_changed` / `epic.deleted`
- [ ] `issue.created` / `issue.updated` / `issue.status_changed` / `issue.assigned` / `issue.unassigned` / `issue.moved` (rank/epic/parent changes)
- [ ] `issue.blocker_added` / `issue.blocker_removed`
- [ ] `issue.label_added` / `issue.label_removed`
- [ ] `comment.created` / `comment.updated` / `comment.resolved` / `comment.unresolved` / `comment.deleted`
- [ ] `attachment.uploaded` / `attachment.deleted`
- [ ] `link.added` / `link.removed`
- [ ] `wiki.created` / `wiki.updated` / `wiki.deleted`
- [ ] `user.created` / `user.updated` / `user.deleted`
- [ ] `auth.login_succeeded` / `auth.login_failed` / `auth.token_created` / `auth.token_revoked`
- [ ] `webhook.received` / `webhook.processed` / `webhook.failed` (incoming-webhook lifecycle)
## Dispatcher
- [ ] One goroutine started in `main`, stopped on shutdown: `LISTEN solopm_events` + 2-second poll fallback
- [ ] Claims batches: `SELECT … FROM events WHERE processed_at IS NULL ORDER BY id LIMIT 50 FOR UPDATE SKIP LOCKED`
- [ ] For each event, run the fan-out router (below), then `UPDATE events SET processed_at = now()`
- [ ] Crash mid-fan-out → event re-processed on restart; **all consumers must be idempotent** (see guarantees table)
- [ ] `attempts` incremented per processing try; events failing 8 times are marked processed with an error log (never wedge the queue)
## Consumers & guarantees
| Consumer | Mechanism | Guarantee |
|---|---|---|
| Audit log | synchronous inside `Emit` (same tx) | exactly-once |
| Notifications | inline in dispatcher → `notifications` inserts + `send_email` jobs | at-least-once, deduped |
| SSE | inline publish to in-memory hub | best-effort (clients refetch on reconnect) |
| Meilisearch | `search_sync` job | at-least-once, idempotent upsert/delete by doc id |
| Outgoing webhooks | delivery row + `webhook_delivery` job | at-least-once; UNIQUE(webhook_id, event_id) dedupes |
- [ ] **Notifications consumer**: compute recipients per the rules in [09-notifications-realtime.md](09-notifications-realtime.md); skip the actor themself; insert `notifications` rows (dedupe: skip if an identical `(user_id, type, resource, event)` row was already created for this event id); for each recipient with `email_mode = instant`, enqueue a `send_email` job; `daily_digest` users are handled by the nightly digest job
- [ ] **SSE consumer**: build the envelope ([09-notifications-realtime.md](09-notifications-realtime.md)) and publish to the hub; hub delivers `data` frames only to connected users who are members of `event.project_id`
- [ ] **Search consumer**: map event → `{index, doc_id, op: upsert|delete}` per [10-search.md](10-search.md); enqueue `search_sync` job; skip event types with no search impact
- [ ] **Webhook consumer**: for each active `outgoing_webhooks` row of the project whose `event_types` is empty or contains the event type: insert `outgoing_webhook_deliveries` (pending) + `webhook_delivery` job
## Job queue
- [ ] Worker pool: 3 goroutines polling `jobs` every 1s: `SELECT … WHERE status = 'pending' AND run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED`, set `running`, execute, set `done`
- [ ] On failure: `attempts++`, `last_error` recorded, `run_at = now() + 30s × 2^attempts` (capped at 1h), back to `pending`
- [ ] After `max_attempts` (8): status `dead`; dead jobs visible in admin (and webhook deliveries surface as `failed` in the delivery log)
- [ ] Job kinds: `send_email`, `webhook_delivery`, `search_sync`, `daily_digest` (scheduled nightly), `cull` (scheduled daily; see [12-retention.md](12-retention.md))
- [ ] Scheduled jobs: on startup and every hour, ensure the next `daily_digest` and `cull` jobs exist (idempotent upsert by kind + date)
- [ ] Email and webhook delivery never block an HTTP request; search sync typically lands < 2s after the mutation
+115
View File
@@ -0,0 +1,115 @@
# 04 — API Surface
## Conventions
- [ ] Base path `/api/v1`; JSON in/out; UTF-8
- [ ] Auth: session cookie (scs) **or** `Authorization: Bearer solopm_…` PAT — same middleware resolves both ([01-architecture.md](01-architecture.md))
- [ ] Pagination: `?page=1&per_page=50` (max 100); list responses `{items: [...], page, per_page, total}`
- [ ] Filtering: documented per endpoint below; multiple values comma-separated (`status=backlog,planned`)
- [ ] Sorting: `?sort=field` / `?sort=-field` (descending)
- [ ] Errors: `{"error": {"code", "message", "fields?"}}`; non-member project access → **404**
- [ ] Issues are addressed by `project_issue_id` (`{num}`) within their project, never by global id, in all `/projects/{id}/issues/{num}` routes
- [ ] Mutating endpoints emit domain events per [03-domain-events.md](03-domain-events.md)
## Auth & account
- [ ] `POST /api/v1/auth/register` — email, username, name, password; first user becomes admin
- [ ] `POST /api/v1/auth/login` — email + password → session cookie
- [ ] `POST /api/v1/auth/logout` — destroy session
- [ ] `GET /api/v1/auth/oauth/{provider}` — redirect to GitHub/Gitea authorize (`provider ∈ github|gitea`)
- [ ] `GET /api/v1/auth/oauth/{provider}/callback` — code exchange → upsert oauth_accounts → session cookie → redirect to SPA
- [ ] `GET /api/v1/me` — current user + memberships summary + unread notification count
- [ ] `PATCH /api/v1/me` — name, avatar_url, email_mode
- [ ] `PUT /api/v1/me/password` — requires current password
- [ ] `GET /api/v1/me/tokens` — list PATs (prefix, name, last_used_at; never the token)
- [ ] `POST /api/v1/me/tokens` — create PAT; plaintext token returned exactly once
- [ ] `DELETE /api/v1/me/tokens/{id}` — revoke
- [ ] `GET /api/v1/users` — pickable users for member/assignee selectors (excludes system, zombie, deleted)
## Admin (RequireAdmin)
- [ ] `GET /api/v1/admin/users` — all users incl. soft-deleted
- [ ] `PATCH /api/v1/admin/users/{id}` — toggle is_admin
- [ ] `DELETE /api/v1/admin/users/{id}` — soft-delete user (blocked with `conflict` if sole owner of any project — transfer first)
- [ ] `POST /api/v1/admin/cull?dry_run=true|false` — run retention job; dry-run returns would-delete report ([12-retention.md](12-retention.md))
- [ ] `POST /api/v1/admin/search/reindex` — full Meilisearch rebuild
- [ ] `GET /api/v1/admin/jobs?status=dead` — inspect job queue
## Projects
- [ ] `GET /api/v1/projects` — my projects, favorites first then recent activity; filters: `status`, `q`
- [ ] `POST /api/v1/projects` — creates project + owner membership + seeded labels
- [ ] `GET /api/v1/projects/{id}` — detail incl. members, labels, dependencies, my-favorite flag
- [ ] `PATCH /api/v1/projects/{id}` — fields incl. status (sets/clears completed_at)
- [ ] `DELETE /api/v1/projects/{id}` — soft delete (owner only)
- [ ] `PUT /api/v1/projects/{id}/owner` — transfer ownership to another member (owner only)
- [ ] `PUT /api/v1/projects/{id}/favorite` / `DELETE …/favorite`
- [ ] `GET /api/v1/projects/{id}/members` / `POST` (owner only; body: user_id, role) / `PATCH …/members/{userId}` (role) / `DELETE …/members/{userId}` (owner only; owner cannot remove self without transfer)
- [ ] `GET /api/v1/projects/{id}/dependencies` / `POST` (409 on cycle) / `DELETE …/dependencies/{depId}`
- [ ] `GET /api/v1/projects/{id}/labels` / `POST` / `PATCH …/labels/{labelId}` / `DELETE …/labels/{labelId}` (soft)
- [ ] `GET /api/v1/projects/{id}/activity` — paged audit feed for the project
- [ ] `GET /api/v1/dashboard/activity` — cross-project feed over my projects
- [ ] `GET /api/v1/projects/{id}/export?format=json|csv` — streaming download ([11-integrations.md](11-integrations.md))
## Epics
- [ ] `GET /api/v1/projects/{id}/epics` — list with progress (issue counts by status + story-point totals); filter `status`
- [ ] `POST /api/v1/projects/{id}/epics`
- [ ] `GET /api/v1/projects/{id}/epics/{epicId}` — detail incl. ordered issues
- [ ] `PATCH /api/v1/projects/{id}/epics/{epicId}` / `DELETE` (soft; issues keep existing but epic_id set NULL at cull time, not delete time)
- [ ] `PUT /api/v1/projects/{id}/epics/{epicId}/issues/{num}` — attach/move issue into epic (body: `after_rank` for position)
- [ ] `DELETE /api/v1/projects/{id}/epics/{epicId}/issues/{num}` — detach
## Issues
- [ ] `GET /api/v1/projects/{id}/issues` — filters: `status`, `assignee`, `label`, `priority`, `epic`, `parent`, `q` (title substring); sort: any column; `?view=board` returns grouped-by-status arrays ordered by board_rank
- [ ] `POST /api/v1/projects/{id}/issues` — mints `project_issue_id`; optional epic_id, parent, assignee, labels
- [ ] `GET /api/v1/projects/{id}/issues/{num}` — full detail (labels, watchers, blockers, sub-issues, epic, attachment/link lists)
- [ ] `PATCH /api/v1/projects/{id}/issues/{num}` — any field; status→done sets completed_at; board moves send `{status, after_rank}`
- [ ] `DELETE /api/v1/projects/{id}/issues/{num}` — soft delete
- [ ] `GET /api/v1/projects/{id}/issues/{num}/activity` — audit entries comments, ascending
- [ ] `GET /api/v1/projects/{id}/issues/{num}/blockers` / `POST` (409 on cycle) / `DELETE …/blockers/{blockerId}`
- [ ] `PUT /api/v1/projects/{id}/issues/{num}/labels/{labelId}` / `DELETE`
- [ ] `PUT /api/v1/projects/{id}/issues/{num}/watch` — watch (clears muted) / `DELETE` — unwatch (sets muted)
- [ ] `GET /api/v1/projects/{id}/issues/{num}/comments` / `POST`
- [ ] `PATCH /api/v1/comments/{commentId}` (author only) / `DELETE` (soft; author or project owner)
- [ ] `PUT /api/v1/comments/{commentId}/resolve` / `DELETE …/resolve`
## Links & attachments (project | issue | comment)
- [ ] `POST /api/v1/projects/{id}/links` · `POST /api/v1/projects/{id}/issues/{num}/links` · `POST /api/v1/comments/{commentId}/links`
- [ ] `DELETE /api/v1/links/{linkId}` (membership checked via owning resource)
- [ ] `POST /api/v1/projects/{id}/attachments` · `POST /api/v1/projects/{id}/issues/{num}/attachments` · `POST /api/v1/comments/{commentId}/attachments` — multipart, size ≤ `MAX_UPLOAD_MB`
- [ ] `DELETE /api/v1/attachments/{attachmentId}` (soft; file removed at cull)
- [ ] `GET /files/{path}` — auth-checked attachment serving (verify requester is member of the owning project; no directory listing; `Content-Disposition` from original_filename)
## Wiki
- [ ] `GET /api/v1/projects/{id}/wiki` — page list (title, slug, updated_by, updated_at)
- [ ] `POST /api/v1/projects/{id}/wiki` — create (slug from title; 409 on duplicate)
- [ ] `GET /api/v1/projects/{id}/wiki/{slug}` / `PATCH` / `DELETE` (soft)
## Notifications & real-time
- [ ] `GET /api/v1/notifications?unread=true` — paged inbox
- [ ] `GET /api/v1/notifications/unread_count`
- [ ] `PUT /api/v1/notifications/{id}/read` / `PUT /api/v1/notifications/read_all`
- [ ] `GET /api/v1/stream` — SSE; frames `notification`, `data`, `ping` ([09-notifications-realtime.md](09-notifications-realtime.md))
## Search
- [ ] `GET /api/v1/search?q=&types=issue,epic,project,comment,wiki&project_id=` — Meilisearch proxy, results filtered to caller's memberships ([10-search.md](10-search.md)); 503 when Meilisearch unconfigured
## Webhooks ([11-integrations.md](11-integrations.md))
- [ ] `POST /webhooks/incoming/{provider}` — unauthenticated endpoint, HMAC-SHA256 verified, writes receipt, processes (outside `/api/v1`)
- [ ] `GET /api/v1/projects/{id}/webhook_receipts` — paged receipts
- [ ] `POST /api/v1/projects/{id}/webhook_receipts/{rid}/replay`
- [ ] `GET /api/v1/projects/{id}/webhooks` / `POST` / `PATCH …/webhooks/{whId}` / `DELETE` (all owner only)
- [ ] `POST /api/v1/projects/{id}/webhooks/{whId}/test` — send a signed test payload now
- [ ] `GET /api/v1/projects/{id}/webhooks/{whId}/deliveries` — delivery log
## Ops
- [ ] `GET /healthz` — 200 with `{db: ok, meilisearch: ok|absent, smtp: configured|absent}`
- [ ] All non-`/api`, non-`/webhooks`, non-`/files` GETs → embedded SPA `index.html`
+47
View File
@@ -0,0 +1,47 @@
# 05 — Auth, Sessions, OAuth & Tokens
Two credential paths, one identity: browser SPA uses **server-side sessions in HttpOnly cookies** (alexedwards/scs, Postgres store); scripts/CLI use **personal access tokens**. Local email/password and GitHub/Gitea OAuth all resolve to the same `users` row via `oauth_accounts`.
## Registration & login
- [ ] `POST /auth/register`: validate email format, username `[a-z0-9-_]{3,32}` lowercase-unique (citext), password ≥ 10 chars; bcrypt cost 12
- [ ] **First registered user gets `is_admin = true`** (instance bootstrap; ids 12 are reserved seeds, so the first human is id 3)
- [ ] `POST /auth/login`: verify bcrypt; on success `scs.RenewToken` (session fixation) then store user id; emit `auth.login_succeeded` / `auth.login_failed` (failed: no user enumeration in the response — generic message)
- [ ] Rate-limit register/login/password endpoints per IP (10/min)
- [ ] `POST /auth/logout`: destroy session
- [ ] Session config: Postgres store (`sessions` table), 30-day lifetime with idle-timeout 7 days, cookie `HttpOnly`, `SameSite=Lax`, `Secure` when BASE_URL is https
## OAuth (GitHub + Gitea)
- [ ] Standard authorization-code flow; `state` parameter stored in session, verified at callback (CSRF)
- [ ] GitHub: fixed endpoints; Gitea: endpoints derived from `GITEA_URL` (self-hosted friendly)
- [ ] Provider buttons hidden in the SPA when the corresponding env vars are unset (`/api/v1/me`-adjacent bootstrap config endpoint or injected at index render)
- [ ] Callback logic, in order:
- [ ] Existing `oauth_accounts(provider, provider_user_id)` row → log that user in
- [ ] Else: fetch the provider's **verified** primary email; if a user with that email exists → **link**: insert oauth_accounts row for that user, log in
- [ ] Else: create a new user (username derived from provider login, de-duplicated with numeric suffix; `password_hash` NULL) + oauth_accounts row, log in
- [ ] A linked user may later set a password via account settings (password change with no current password required only when `password_hash` IS NULL)
- [ ] Never auto-link on an **unverified** provider email (account-takeover vector) — fall through to create-new-user
## Personal access tokens
- [ ] Format `solopm_<40 chars base62>`; store SHA-256 hex in `token_hash`; show plaintext exactly once at creation
- [ ] `prefix` (first 12 chars) stored for display in the token list
- [ ] Bearer auth middleware: constant-time hash lookup; reject revoked/expired; update `last_used_at` (throttled to once/minute per token)
- [ ] Tokens act as the full user (no scopes in v1 — recorded in [17-decisions.md](17-decisions.md))
- [ ] Emit `auth.token_created` / `auth.token_revoked`
## Authorization model
- [ ] `is_admin` (system-wide): manage users, run cull/reindex, see admin endpoints. Admins are **not** implicit members of every project — they see only their own projects in normal UI (admin endpoints are separate)
- [ ] Project `owner`: everything a member can, plus: edit project settings, manage members/labels/webhooks, transfer ownership, delete project
- [ ] Project `member`: full read/write on the project's issues, epics, comments, wiki, attachments, links; manage own watches/favorites
- [ ] Non-member: **404** on all project-scoped routes
- [ ] System user (id 1) and zombie user (id 2): cannot log in (no password, no oauth rows, login explicitly rejects ids 12), never listed in pickers
## User deletion edge cases
- [ ] A user who is the sole owner of any project cannot be deleted (409 `conflict`, message lists the projects) — transfer ownership first
- [ ] Deletion is a soft delete; sessions destroyed and PATs revoked immediately
- [ ] At cull time the zombie user inherits authored records ([12-retention.md](12-retention.md)); audit_log keeps the original user id (immutable)
- [ ] Self-service account deletion follows the same rules as admin deletion
+51
View File
@@ -0,0 +1,51 @@
# 06 — Projects, Members, Labels, Dependencies
The project is the permission boundary and the container for everything else. Fields and enums in [02-data-model.md](02-data-model.md); routes in [04-api.md](04-api.md).
## Project lifecycle
- [ ] Create: requires title; creator becomes `creator_id`, `owner_id`, and the sole `owner`-role member; seed the 6 default labels (enhancement, bug, chore, task, feature, documentation) with palette colors
- [ ] Status field: `backlog → planned → in_progress → completed | canceled` — transitions are *not* enforced as a state machine (any status settable), but `completed_at` automation is:
- [ ] status → `completed` sets `completed_at = now()`
- [ ] leaving `completed` clears it
- [ ] Priority: optional, `low…frantic`; UI color mapping in [14-design-system.md](14-design-system.md)
- [ ] `git_repository_url`: normalized on save (trim, strip trailing `.git` and `/`); used to route incoming webhooks ([11-integrations.md](11-integrations.md))
- [ ] Update/delete emit `project.updated` / `project.status_changed` / `project.deleted`; delete is soft and owner-only
- [ ] Project description is markdown with mention/#ref support ([08-collaboration.md](08-collaboration.md))
## Ownership & membership
- [ ] Exactly one owner per project — invariant maintained in the service (`projects.owner_id` ↔ the single `role='owner'` membership row)
- [ ] Transfer: `PUT /projects/{id}/owner` (owner only, target must be an existing member); atomically swap roles; emit `project.ownership_transferred`
- [ ] Add member: owner picks an existing instance user (`GET /users`) + role; emits `project.member_added` → triggers `added_to_project` notification; no email-invitation flow in v1 (small instance, users already exist)
- [ ] Remove member: owner only; removing a member clears their assignee slots in the project? **No** — assignments persist (history matters); UI shows them as non-members. Their watches are deleted.
- [ ] Owner cannot leave/be removed without transferring first (409)
- [ ] Role change: owner only; only `member` role assignable via PATCH (ownership moves only through transfer)
## Favorites
- [ ] `PUT/DELETE /projects/{id}/favorite` toggles a `project_favorites` row
- [ ] `GET /projects` sorts favorites first (by favorited_at desc), then the rest by latest activity
- [ ] Favorites are personal — no events, no audit entries
## Labels
- [ ] Project-scoped; name unique per project (case-sensitive match on citext-free `text`, uniqueness enforced by partial unique index)
- [ ] CRUD is member-accessible except delete (owner only); color must be a valid `#rrggbb`
- [ ] Soft delete; a deleted label disappears from issues immediately (join filters `deleted_at IS NULL`) but rows persist until cull
- [ ] Emit `label.created/updated/deleted`
## Project dependencies
- [ ] Edge `project_id` **depends on** `depends_on_project_id` (i.e. blocked by it)
- [ ] Both endpoints must be projects the caller is a member of
- [ ] Cycle detection before insert: DFS from `depends_on_project_id` following existing `depends_on` edges; reaching `project_id` → 409 `cycle_detected`
- [ ] Project detail shows both directions: "depends on" and "blocks" (reverse lookup)
- [ ] Emit `project.dependency_added/removed`
## Activity feed
- [ ] `GET /projects/{id}/activity`: `audit_log WHERE project_id = $1 ORDER BY created_at DESC` paged; render display text app-side from `action_type` + `previous_values`/`new_values` + actor
- [ ] `GET /dashboard/activity`: same over `project_id IN (my projects)`, capped at 50 per page
- [ ] Feed entries link to their resource (`#N` issues resolve via `project_issue_id` kept in event payloads)
- [ ] Hidden from feeds: pure rank moves (`issue.moved` with only rank changes) — noise; still audited
+46
View File
@@ -0,0 +1,46 @@
# 07 — Issues & Epics
The two work-item levels. Schema in [02-data-model.md](02-data-model.md); routes in [04-api.md](04-api.md); board/list UI in [13-frontend.md](13-frontend.md).
## Issues
- [ ] Create: title required; mints `project_issue_id` via the counter UPDATE inside the insert tx (concurrent creates never duplicate or skip visibly); initial `board_rank` = top of its status column
- [ ] Fields editable via PATCH: title, description, status, priority, estimate, assignee_id, epic_id, parent_issue_id, start_date, target_date
- [ ] Status automation:
- [ ]`done` sets `completed_at = now()`; leaving `done` clears it
- [ ] `duplicate` and `canceled` count as "closed" (with `done`) for epic progress and UI dimming
- [ ] Assignment: single assignee; assigning emits `issue.assigned` (notification + auto-watch); unassigning emits `issue.unassigned`
- [ ] Estimate: story points ∈ {1, 2, 3, 5, 8, 13}, nullable; displayed as a badge; summed on epics
- [ ] Sub-issues: `parent_issue_id`, same project only; UI nests one level (data allows deeper); parent detail lists children with status; deleting a parent does **not** delete children (they keep the dangling parent until cull nulls it)
- [ ] Blockers: `issue_blockers` with same-project constraint and DFS cycle detection (409); blocked issues show a blocked indicator in list/board/detail
- [ ] Labels: apply/remove from the project's label set; emit `issue.label_added/removed`
- [ ] Soft delete; emits `issue.deleted`
## Kanban ordering (board_rank)
- [ ] `board_rank` is a **fractional lexicographic rank** (LexoRank-style base-36 strings, e.g. `"hzzz"`, midpoint insertion `between(a, b)`); implement `rank.Between/Before/After` helpers with unit tests
- [ ] Move = PATCH with `{status?, after_rank?}`: server computes the new rank between neighbors; ties never occur because ranks are always unique strings per column (append-suffix on exhaustion)
- [ ] `?view=board` returns issues grouped by status, each group ordered by board_rank; the 7 fixed statuses are the columns
- [ ] Periodic rebalance is NOT needed in v1 (rank strings grow slowly at this scale); note as future work if strings exceed 64 chars
- [ ] Rank-only moves emit `issue.moved` (audited, hidden from activity feeds, still broadcast over SSE for live board sync)
## List view semantics
- [ ] Filters (combinable): status, assignee, label, priority, epic, parent, free-text `q` over title
- [ ] Sort: project_issue_id (default desc), title, status, priority, estimate, assignee, target_date, updated_at
- [ ] Pagination per API conventions; the board view is not paginated (whole project board ≤ a few hundred issues at target scale)
## Epics
- [ ] Fields: title, description (markdown), status (`backlog…canceled`), color (palette hex)
- [ ] Status automation mirrors projects (`completed``completed_at` is not stored for epics — progress is computed, keep it simple)
- [ ] Issue membership: `issues.epic_id` + `epic_rank` orders issues within the epic
- [ ] `PUT /epics/{epicId}/issues/{num}` attaches (or moves, with `after_rank`); `DELETE` detaches (epic_id NULL)
- [ ] An issue belongs to at most one epic; same project only
- [ ] Progress (computed in the list/detail queries, no denormalized counters):
- [ ] issue counts: total, closed (`done`+`canceled`+`duplicate`), open
- [ ] story points: total estimated, closed estimated (unestimated issues counted separately as "unestimated: N")
- [ ] percent = closed/total issues; UI progress bar shows both counts and points
- [ ] Epic list sortable by status/title/progress; filter by status
- [ ] Soft delete: epic disappears, issues keep `epic_id` until cull nulls it; UI treats a soft-deleted epic reference as none
- [ ] Emit `epic.created/updated/status_changed/deleted`
+52
View File
@@ -0,0 +1,52 @@
# 08 — Collaboration: Comments, Attachments, Links, Wiki, Markdown, Watchers
## Comments
- [ ] Flat list per issue, chronological (no threading); markdown bodies
- [ ] Edit: author only; sets `updated_at`; UI shows "(edited)"
- [ ] Delete: soft; author or project owner; UI shows a "comment deleted" tombstone (author + timestamp, no body)
- [ ] **Resolvable**: any member can resolve/unresolve (`resolved_at`, `resolved_by`); resolved comments render collapsed with a "resolved by X" header; issue detail shows resolved count
- [ ] Emit `comment.created/updated/resolved/unresolved/deleted`; `comment.created` payload carries parsed mentions
## Markdown pipeline
- [ ] **Client**: markdown-it + DOMPurify sanitization; single shared `MarkdownView` component; supported: CommonMark + tables, strikethrough, task lists, fenced code with highlight.js, autolink
- [ ] **Server** (on save of any markdown field — issue/project/epic descriptions, comments, wiki bodies):
- [ ] Parse `@username` mentions (word-boundary, against existing usernames) → into the event payload for notification fan-out; mentions inside code blocks/spans are ignored
- [ ] Parse `#N` references (this project's issues) → validated list into event payload; used for cross-link rendering and future relation hints
- [ ] Server does NOT render HTML (client renders); server only extracts entities
- [ ] Client renders `@username` as a profile-ish chip and `#N` as a router-link to the issue (resolve via a lightweight `GET /projects/{id}/issues/{num}` title lookup, cached)
- [ ] Paste/drag an image into `MarkdownEditor` → uploads as attachment → inserts `![name](/files/{path})`
## Attachments
- [ ] Upload targets: project, issue, comment (multipart; ≤ `MAX_UPLOAD_MB`; any mime type, but SVG served with `Content-Type: text/plain` nosniff to avoid stored XSS)
- [ ] Disk path: `UPLOAD_DIR/project_{pid}/{uploader_id}_{yyyymmddhhmmss}_{rand16}.{ext}` — path stored relative in DB; never trust client filenames for the path (original name kept separately for download)
- [ ] Serving: `GET /files/{path}` checks membership of the owning project (lookup by attachments row, not by path prefix), sets `Content-Disposition`
- [ ] Delete: soft; **file stays on disk until cull** ([12-retention.md](12-retention.md) deletes row + file together)
- [ ] Image attachments get a thumbnail treatment client-side only (no server thumbnailing in v1)
- [ ] Emit `attachment.uploaded/deleted`
## Links
- [ ] Simple URL + optional title on project/issue/comment; validated absolute http(s) URL
- [ ] Emit `link.added/removed`
## Wiki
- [ ] Per-project markdown pages; slug generated from title (kebab-case, deduplicated with `-2` suffix); slug is stable after creation (title edits don't re-slug)
- [ ] Page list + view + edit (single editor, last-write-wins with `updated_at` conflict warning: PATCH carries `expect_updated_at`, 409 on mismatch)
- [ ] `updated_by` tracked; no page history in v1 ([17-decisions.md](17-decisions.md))
- [ ] Wiki bodies participate in mentions/#refs parsing and search indexing
- [ ] Emit `wiki.created/updated/deleted`
## Watchers
- [ ] Any member can watch/unwatch any issue in their projects; watcher list shown on issue detail
- [ ] **Auto-watch** (insert if no row exists; never flip an existing `muted=true` row):
- [ ] creator on issue create
- [ ] assignee on assign
- [ ] commenter on comment
- [ ] mentioned user on mention (issue description or comment)
- [ ] **Unwatch = mute**: `DELETE /watch` sets `muted = true` (row kept) so no future auto-watch resurrects the subscription; `PUT /watch` clears muted
- [ ] Watchers feed notification fan-out ([09-notifications-realtime.md](09-notifications-realtime.md)); muted watchers receive nothing
+47
View File
@@ -0,0 +1,47 @@
# 09 — Notifications & Real-time (SSE)
Three delivery surfaces from one fan-out pass in the event dispatcher ([03-domain-events.md](03-domain-events.md)): the **in-app inbox/bell**, **email**, and **SSE live updates**.
## Fan-out rules (recipient computation per event)
- [ ] `issue.assigned` → notification type `assigned` to the new assignee
- [ ] mention parsed in issue/epic/project/wiki description or comment → type `mentioned` to each mentioned user (must be a project member; non-members are ignored)
- [ ] `comment.created` → type `commented` to all unmuted watchers of the issue
- [ ] `project.member_added` → type `added_to_project` to the added user
- [ ] `issue.status_changed` → type `status_changed` to all unmuted watchers
- [ ] Never notify the actor about their own action
- [ ] Precedence per event per user (no double-notify): `mentioned` > `assigned` > `commented` > `status_changed`
- [ ] Dedupe on redelivery: skip insert if a notification for the same (user, event id) already exists
## In-app inbox
- [ ] `notifications` rows carry a denormalized `payload` (actor name, project title, issue `#N` + title, comment snippet ≤ 140 chars) so the inbox renders without joins
- [ ] Bell shows unread count (from `/notifications/unread_count`, kept live via SSE)
- [ ] Inbox page: paged list, unread highlighted, click → mark read + navigate to resource; "mark all read"
- [ ] Notifications are exempt from soft-delete conventions: hard-deleted by cull after 90 days regardless of read state
## Email
- [ ] Per-user `email_mode`: `off | instant | daily_digest` (settings page; default instant)
- [ ] Instant: `send_email` job per notification; plain, single-purpose emails (subject `[SoloPM] {actor} {verb} {resource}`, body = snippet + deep link via `BASE_URL`); text/plain + minimal HTML
- [ ] Daily digest: nightly `daily_digest` job batches the last 24h of unread notifications per user into one email grouped by project
- [ ] Email silently disabled when SMTP env vars are unset (log once at startup)
- [ ] Retry via job queue backoff; failures land in `jobs.last_error`
## SSE
- [ ] Endpoint `GET /api/v1/stream` (session or PAT auth): `Content-Type: text/event-stream`, `X-Accel-Buffering: no`, heartbeat `ping` frame every 25s
- [ ] **One firehose stream per user** ([17-decisions.md](17-decisions.md)): the hub delivers every event from projects the user is a member of; the client decides what's relevant to the current page
- [ ] Frame types (SSE `event:` field, JSON `data:`):
- [ ] `notification``{id, type, payload, created_at}` → bell increments, toast shown
- [ ] `data``{event_type, project_id, resource_type, resource_id, issue_num?}` — a *pointer*, not the changed data; client stores refetch or patch state for the affected resource if it's on screen
- [ ] `ping` — keepalive, no body
- [ ] Hub: in-memory `map[userID][]chan frame`; register on connect, deregister on disconnect; membership resolved at connect time and re-resolved on `project.member_added/removed` frames for that user; non-blocking sends (drop frame to a slow client — they self-heal on reconnect)
- [ ] Client contract ([13-frontend.md](13-frontend.md)): auto-reconnect with backoff; **on reconnect, refetch the current page's data and the unread count** (missed frames are lost by design — no replay)
- [ ] Graceful shutdown closes all streams (clients reconnect to the new process)
## Acceptance sketch
- [ ] Two browsers, same project: assign an issue in A → B's bell increments and B's open board updates the card within 2 seconds, no reload
- [ ] Muted watcher receives neither bell nor email for subsequent comments
- [ ] SMTP configured with MailHog in dev compose: instant email arrives; digest job produces one grouped email
+38
View File
@@ -0,0 +1,38 @@
# 10 — Full-text Search (Meilisearch)
Meilisearch holds five indexes kept in sync by domain events (`search_sync` jobs). Postgres remains the source of truth; the index is always rebuildable.
## Indexes & documents
- [ ] `projects``{id, title, description, status, member_ids[]}` — filterable: `member_ids`
- [ ] `issues``{id, project_id, project_issue_id, title, description, status, priority, labels[], assignee, epic_title}` — filterable: `project_id`
- [ ] `epics``{id, project_id, title, description, status}` — filterable: `project_id`
- [ ] `comments``{id, project_id, issue_id, issue_num, issue_title, body, author}` — filterable: `project_id`
- [ ] `wiki_pages``{id, project_id, slug, title, body}` — filterable: `project_id`
- [ ] Searchable attributes ordered (title > description/body > rest); typo tolerance default; doc ids = Postgres ids
- [ ] Index settings applied idempotently at startup (create-if-missing, update settings)
## Sync
- [ ] Event → sync mapping in the dispatcher's search consumer:
- [ ] `*.created` / `*.updated` (incl. label/status/assignee changes on issues) → `{index, doc_id, op: upsert}` job
- [ ] `*.deleted` (soft delete!) → `{op: delete}` job — soft-deleted content must leave the index immediately
- [ ] `project.member_added/removed` → upsert the project doc (member_ids changed)
- [ ] `search_sync` job handler loads current row from Postgres (not the event payload — always index latest state); row gone or soft-deleted → delete op
- [ ] Jobs idempotent (upsert/delete by id); retries via queue backoff
- [ ] `POST /admin/search/reindex` + CLI `solopm reindex`: drop-and-rebuild all five indexes from Postgres, streaming in batches of 1000
## Query path
- [ ] `GET /api/v1/search?q=&types=&project_id=`:
- [ ] Resolve caller's project memberships once
- [ ] `projects` index filtered `member_ids CONTAINS user`; other indexes filtered `project_id IN (memberships)` (narrowed to `project_id=` param when present)
- [ ] Multi-index federated query; merge by Meilisearch ranking score; cap 50 results
- [ ] Response items carry enough to render + navigate: type, project, title/snippet (highlighted), route params (`issue_num`, `slug`…)
- [ ] Permission guarantee: a user must never see results (even titles) from projects they're not a member of — enforced by the filter, verified by an integration test
- [ ] Meilisearch down/unconfigured → 503 with `{"error":{"code":"search_unavailable"}}`; rest of the app unaffected
## Frontend
- [ ] **Command palette** (Cmd/Ctrl+K, [13-frontend.md](13-frontend.md)): debounced search-as-you-type against `/search`, grouped by type, arrow-key navigation, Enter → route; recent visits shown when query empty
- [ ] `/search` page: same query with type filter tabs and full result list
+33
View File
@@ -0,0 +1,33 @@
# 11 — Integrations: Incoming Webhooks, Outgoing Webhooks, Export
## Incoming webhooks (GitHub & Gitea)
- [ ] Endpoint `POST /webhooks/incoming/{provider}` (`github|gitea`), outside `/api/v1`, unauthenticated but **HMAC-verified**
- [ ] Signature: `X-Hub-Signature-256: sha256=<hmac>` (both providers); the per-project secret is configured on the incoming side of project settings; verification is constant-time; invalid → **401**, receipt recorded with `signature_valid=false`, body NOT processed
- [ ] Project routing: match the payload's repository URL (clone/html URL, normalized like `git_repository_url`) against projects; no match → receipt stored with `project_id NULL`, status `ignored`
- [ ] Idempotency: `UNIQUE(source, delivery_id)` (GitHub `X-GitHub-Delivery`, Gitea `X-Gitea-Delivery`); duplicate delivery → 200, no reprocessing
- [ ] Receipts: every request stored raw (`payload` text) with event_type, status (`pending → processed | failed | ignored`), `failure_reason`
- [ ] **v1 automation — PR merged closes issues**:
- [ ] On `pull_request` event with `action=closed` + `merged=true`: scan PR title + body for `close[sd]? #(\d+)` / `fix(e[sd])? #(\d+)` / `resolve[sd]? #(\d+)` (case-insensitive)
- [ ] Each `#N` resolving to an open issue in the routed project → set status `done` **as system user (id 1)** with `via_webhook=true`; emits normal `issue.status_changed` (so notifications/SSE/search/outgoing webhooks all fire); comment-like audit trail shows the PR URL in the event payload
- [ ] Issues already closed → skipped, noted in receipt
- [ ] Replay: `POST /projects/{id}/webhook_receipts/{rid}/replay` re-runs processing on the stored payload (owner only)
- [ ] Push events: recorded as receipts, `ignored` in v1 (future: commit-message automation)
## Outgoing webhooks
- [ ] Per-project registrations (owner-managed): `url`, `secret`, `active`, `event_types[]` (empty = all)
- [ ] Delivery payload (JSON): `{event: "issue.status_changed", timestamp, project: {id, title}, actor: {id, username}, resource: {type, id, issue_num?, title?}, changes: {field: {prev, new}}}`
- [ ] Signing: `X-SoloPM-Signature: sha256=<hex hmac-sha256(secret, raw body)>` + `X-SoloPM-Event` + `X-SoloPM-Delivery` (delivery id) headers
- [ ] Delivery via `webhook_delivery` jobs: 10s timeout, any 2xx = success; failure → queue backoff (30s·2^n, 8 attempts) → delivery row `failed`
- [ ] Delivery log per webhook: status, response code, truncated response body, attempts, timestamps; visible in project settings
- [ ] `POST …/webhooks/{whId}/test` — sends a `{event: "webhook.test"}` payload immediately, records a delivery row
- [ ] Never deliver to internal targets: resolve the URL host and reject private/loopback ranges (SSRF guard), both at registration and at delivery time
## Export
- [ ] `GET /projects/{id}/export?format=json` — streaming download `solopm-{project}-{date}.json`:
- [ ] Full project dump: project, members (user refs by username/email), labels, epics, issues (with sub-issue/blocker/epic relations by `project_issue_id`), comments, links, wiki pages, attachment *metadata* (files not embedded; paths listed for manual copy)
- [ ] Versioned envelope `{solopm_export: 1, exported_at, data: {...}}` — designed to be re-importable later (import itself is out of scope v1, [17-decisions.md](17-decisions.md))
- [ ] `GET /projects/{id}/export?format=csv` — issues as CSV: `number,title,status,priority,estimate,assignee,epic,labels,parent,start_date,target_date,completed_at,created_at,updated_at` (RFC 4180 quoting)
- [ ] Both owner-or-member accessible, streamed (no buffering whole dump in memory), audit-logged as `project.exported` ([03-domain-events.md](03-domain-events.md))
+44
View File
@@ -0,0 +1,44 @@
# 12 — Soft Deletes, Retention & Culling
Deletes in SoloPM are soft (`deleted_at`) so mistakes are recoverable. The **culling job** is the only code path that hard-deletes, and it is responsible for cascades **and disk files** — which is why DB-level `ON DELETE CASCADE` is deliberately absent.
## Lifecycle
- [ ] Soft-deleted rows are invisible to every normal query (`deleted_at IS NULL` filters, [01-architecture.md](01-architecture.md)) and leave the search index immediately ([10-search.md](10-search.md))
- [ ] Rows older than `CULL_AFTER_DAYS` (default 30) since `deleted_at` are hard-deleted by the cull job
- [ ] Restore before cull = clearing `deleted_at` (no UI in v1; document the SQL in ops notes)
## Cull job
- [ ] Runs as a `cull` job scheduled daily ([03-domain-events.md](03-domain-events.md)) + CLI `solopm cull [--dry-run]` + `POST /admin/cull?dry_run=`
- [ ] **Dry-run mode**: full traversal, returns/logs the would-delete report `{resource_type: count}` + file list, deletes nothing — required before trusting the job
- [ ] Deletion order respects FK dependencies (children first)
- [ ] Cascade scope per culled resource:
- [ ] **project** → its epics, issues (and their cascade), labels, members, favorites, dependencies (both directions), wiki pages, links, attachments (+files), webhooks + deliveries, receipts
- [ ] **issue** → its comments (and their cascade), issue_labels, watchers, blocker edges (both directions), sub-issue links (children's `parent_issue_id` → NULL), links, attachments (+files)
- [ ] **epic** → member issues' `epic_id`/`epic_rank` → NULL (issues survive)
- [ ] **comment** → its links, attachments (+files)
- [ ] **label** → its issue_labels pivots
- [ ] **wiki page** → row only
- [ ] **attachment** → row **and the file on disk** — file deletion and row deletion happen together; a failed file unlink (other than not-exists) aborts that row's cull and is logged
- [ ] **User culling** (soft-deleted users past retention):
- [ ] Reassign authored content to the **zombie user (id 2)**: issues.creator_id/assignee_id, comments.author_id, epics.creator_id, labels.created_by, attachments.uploader_id, links.created_by, wiki created_by/updated_by, project creator_id
- [ ] Precondition (already enforced at delete time): not sole owner of any live project
- [ ] Delete their favorites, watches, notifications, oauth_accounts, api_tokens, sessions
- [ ] `audit_log.initiated_by` keeps the original user id — audit is immutable; UI renders unknown ids as "Deleted User"
- [ ] Non-soft-delete retention in the same job:
- [ ] `notifications` older than 90 days → hard delete
- [ ] `events` processed > 30 days ago → hard delete
- [ ] `jobs` done/dead > 30 days ago → hard delete
- [ ] `incoming_webhook_receipts` > 90 days → hard delete
- [ ] `outgoing_webhook_deliveries` > 90 days → hard delete
- [ ] `audit_log`**kept forever in v1** ([17-decisions.md](17-decisions.md))
- [ ] expired `sessions` (scs handles) and revoked/expired `api_tokens` > 30 days → hard delete
- [ ] The job logs a summary line per run (counts per resource type, duration, dry-run flag) and records a `cull` job row with the report in `payload`
## Safety checklist
- [ ] Dry-run output reviewed before first real run in any environment
- [ ] File deletions restricted to paths under `UPLOAD_DIR` matching the stored relative path (no traversal)
- [ ] Whole-project cull wrapped in one transaction per project (files unlinked after commit; unlink failures logged for manual sweep)
- [ ] Integration tests: cull a project → zero orphan rows (assert per table) and zero orphan files; cull a user → zombie owns their content, audit rows untouched
+71
View File
@@ -0,0 +1,71 @@
# 13 — Frontend (Vue 3 SPA)
Vue 3 + TypeScript + Vite + Pinia + Vue Router + Tailwind, pnpm, in `web/`. Styling authority is [14-design-system.md](14-design-system.md). All API access through one typed client; all live updates through one SSE module.
## Structure
- [ ] `web/src/api/client.ts` — typed fetch wrapper (base `/api/v1`, JSON, error envelope → typed `ApiError`, cookie credentials)
- [ ] `web/src/api/sse.ts` — EventSource wrapper: connect after login, auto-reconnect with backoff, dispatch frames to the `realtime` store, expose `onReconnect` hooks (pages refetch)
- [ ] `web/src/router/index.ts` — routes below; global guard redirects unauthenticated → `/login` (bootstraps via `GET /me`)
- [ ] Layout: `AppShell` (fixed sidebar + navbar + `<router-view>`), auth pages bare
- [ ] Vite dev proxy: `/api`, `/files`, `/webhooks` → Go server port
## Routes & pages
- [ ] `/login` — email/password + OAuth buttons (hidden per config)
- [ ] `/register` — sign-up (notes that first user becomes admin)
- [ ] `/oauth/callback` — post-OAuth landing, bootstraps session, redirects
- [ ] `/`**Dashboard**: favorite + recent projects, "assigned to me" issue list, cross-project activity feed
- [ ] `/inbox` — notification inbox (unread filter, mark read/all)
- [ ] `/search` — full search page (type tabs)
- [ ] `/projects` — project cards (favorites first, status/priority badges, favorite toggle)
- [ ] `/projects/:id`**Project overview**: description (markdown), members, links, attachments, dependencies (both directions), activity feed
- [ ] `/projects/:id/issues`**Issue table**: sortable columns, FilterBar, URL-synced filters/sort, row click → detail
- [ ] `/projects/:id/board`**Kanban**: 7 fixed status columns, drag-and-drop (optimistic), card = `#N`, title, labels, assignee avatar, points badge, blocked indicator, epic color strip
- [ ] `/projects/:id/issues/:num`**Issue detail**: editable title/description, sidebar (status, priority, points, assignee, epic, dates, labels, watchers, watch toggle), sub-issues list (+create), blockers list, attachments, links, activity feed comments with resolve, MarkdownEditor for comments
- [ ] `/projects/:id/epics` — epic list with progress bars (counts + points), status filter
- [ ] `/projects/:id/epics/:epicId` — epic detail: description, status, color, ordered issue list (drag to reorder, attach existing / create new)
- [ ] `/projects/:id/wiki` — page list; `/projects/:id/wiki/:slug` — view; `/projects/:id/wiki/:slug/edit` + `/projects/:id/wiki/new` — edit/create with preview
- [ ] `/projects/:id/settings` — tabs: general (fields, status, git URL, transfer, delete) | members | labels | webhooks (incoming secret, outgoing CRUD + delivery log) | export | danger zone — owner-gated tabs hidden for members
- [ ] `/settings` — tabs: profile | security (password) | tokens (PAT create/reveal-once/revoke) | notifications (email_mode)
- [ ] `/admin/users` — admin only: user list, admin toggle, delete
- [ ] 404 page; project-scope 404s render "not found or no access"
## Pinia stores
- [ ] `auth` — me, bootstrap, login/logout, guards
- [ ] `projects` — list + current project (members, labels, dependencies)
- [ ] `issues` — table query state (filters/sort/page, URL-synced), detail cache
- [ ] `board` — per-status ordered lists, optimistic move (rank computed client-side between neighbors, PATCH sent, rollback on error)
- [ ] `epics` — list + detail + ordering
- [ ] `comments` — per-issue comments + resolve state
- [ ] `wiki` — page list + current page
- [ ] `notifications` — unread count, inbox, mark-read
- [ ] `realtime` — SSE connection state; routes `data` frames: if the pointed-at resource is in an active store (current board/table/detail/feed), patch or refetch it; `notification` frames → `notifications` store + toast
- [ ] `search` — palette state, recents (localStorage)
- [ ] `ui` — toasts, modals, sidebar collapse
## Shared components
- [ ] `KanbanBoard` / `KanbanColumn` / `IssueCard` — drag via vuedraggable or a small pointer-events implementation; column headers show count
- [ ] `IssueTable` + `FilterBar` (status/assignee/label/priority/epic pickers + text search) + `SortHeader`
- [ ] `MarkdownEditor` — textarea + write/preview tabs, `@` mention autocomplete (project members), `#` issue autocomplete, paste/drop image → attachment upload
- [ ] `MarkdownView` — markdown-it + DOMPurify, mention chips, `#N` router-links, code highlight
- [ ] `ActivityFeed` — renders audit entries (icon + actor + verb + target + relative time)
- [ ] `NotificationBell` — unread badge, dropdown of latest 10, link to `/inbox`
- [ ] `CommandPalette` — Cmd/Ctrl+K overlay ([10-search.md](10-search.md))
- [ ] Badges/pills: `StatusBadge` (per-status colors), `PriorityBadge`, `PointsBadge`, `LabelPill` (label color), `EpicPill` (epic color)
- [ ] `UserAvatar` (initials fallback) + `AssigneePicker` + `MemberPicker`
- [ ] `EpicProgressBar` (counts + points tooltip)
- [ ] `WatchButton`, `FavoriteStar`
- [ ] `AttachmentUploader` (drag-drop zone) + `AttachmentList` (image preview, download, delete)
- [ ] `DatePicker`, `ConfirmDialog`, `ToastHost`, `EmptyState` (illustrated, per design system)
## Behaviors
- [ ] Optimistic updates for board drags and inline status/assignee edits; rollback + toast on API error
- [ ] URL is the source of truth for table filters/sort (shareable, refresh-safe)
- [ ] SSE reconnect → refetch active page data + unread count ([09-notifications-realtime.md](09-notifications-realtime.md))
- [ ] Keyboard: Cmd/Ctrl+K palette; `c` new issue on board/table pages; Esc closes modals
- [ ] Every list has loading skeletons + designed empty states; destructive actions use `ConfirmDialog`
- [ ] Route titles set per page (`SoloPM — {project} — {page}`)
+52
View File
@@ -0,0 +1,52 @@
# 14 — Design System (Soft UI Tailwind)
The styling authority is the **Soft UI Dashboard Tailwind** extraction from the first iteration, located at `/references/first-iteration/ai/design-system/` (files: `design-system.md`, `conformity.md`, `tokens.json`, `lint/stylelint.json`, `lint/tokens-schema.json`, `setup/` with `INIT.md`, `tailwind.config.js`, `base.html`, self-hosted fonts + `soft-ui-dashboard-tailwind.css` + Nucleo icon assets). UX inspiration: Linear and Notion (screenshots in `/references/first-iteration/ai/examples/ui/`).
- [ ] **Phase 0**: copy the entire `ai/design-system/` directory into this repo at `web/design-system/` so the build no longer depends on `/references` being mounted; wire `setup/tailwind.config.js` and the CSS/font assets into the Vite app per `setup/INIT.md`
- [ ] Keep `conformity.md` as the UI review checklist — run it against each new page before checking off frontend items
The essentials are inlined below so this spec stands alone if the reference copy is unavailable.
## Core tokens
- [ ] Colors (custom palette, Bootstrap-style names with non-standard values):
- `slate-700 #344767` primary text/headings · `slate-800 #3a416f` sidebar gradient end
- `cyan-500 #17c1e8` info/primary action · `blue-600 #2152ff` blue accent
- `purple-700 #7928ca``pink-500 #ff0080` **brand gradient** · `fuchsia-500 #cb0c9f`
- `green-600 #17ad37` success · `lime-500 #82d616` · `red-600 #ea0606` danger · `amber-500 #f59e0b` warning
- `gray-50 #f8f9fa` page bg · `gray-100 #ebeff4` card bg · `gray-800 #252f40` dark bg
- [ ] Typography: Open Sans (sans, self-hosted), sizes xs .75rem / sm .875rem / base 1rem / lg 1.125rem / xl 1.25rem; weights 400/600/700
- [ ] Shadows: `shadow-soft-{sm,md,lg,xl,2xl}` (soft layered rgba shadows) — **never** standard Tailwind shadows
- [ ] Radii: `rounded-lg .5rem`, `rounded-xl .75rem`, `rounded-2xl 1rem` (cards)
- [ ] Breakpoints (Bootstrap-compatible, differ from Tailwind defaults): sm 576 / md 768 / lg 992 / xl 1200 / 2xl 1320
- [ ] Layout: sidebar `max-w-62.5` (250px) fixed, `xl:left-0`; main content offset `xl:ml-68.5`; easing tokens `ease-soft-in(-out)`, `ease-nav-brand`
## Component classes (defined once in `web/src/styles/main.css` via `@layer components`)
- [ ] `card`, `card-header`, `card-body` (cards are `rounded-2xl shadow-soft-xl`; `border-0` for content cards)
- [ ] `btn-primary` (brand gradient), `btn-white`, `btn-outlined` — add display/padding as utilities (`inline-block px-6 py-3 btn-primary`)
- [ ] `form-label`, `form-input` (includes `block w-full` — don't repeat)
- [ ] `nav-item(-active|-inactive)`, `nav-icon(-active|-inactive)` for sidebar entries
## The 11 code-generation rules (hard constraints)
- [ ] 1. Use the component classes above — never repeat their raw utility strings
- [ ] 2. Only `shadow-soft-*` shadow tokens
- [ ] 3. Cards use `.card`; `border-0` variant for content cards with headers
- [ ] 4. Sidebar icons: `nav-icon nav-icon-active` (gradient bg) / `nav-icon-inactive`
- [ ] 5. Buttons: display + padding as utilities alongside the class
- [ ] 6. Open Sans loaded (self-hosted, not Google Fonts CDN)
- [ ] 7. Transitions use the soft easing tokens
- [ ] 8. Fixed sidebar layout with `xl:ml-68.5` content offset
- [ ] 9. Primary brand gradient = `from-purple-700 to-pink-500`
- [ ] 10. Respect the custom breakpoints
- [ ] 11. Icons: Font Awesome + Nucleo (self-hosted) — never Heroicons/Lucide
## SoloPM semantic mappings
- [ ] Priority colors: `low` blue-600 · `medium` green-600 · `high` amber-500 · `urgent` orange (#fb6340-family from palette) · `frantic` red-600
- [ ] Issue status badges: `backlog` gray · `planned` cyan-500 · `in_progress` blue-600 · `ready_for_review` purple-700 · `done` green-600 · `canceled` gray-800 · `duplicate` gray strikethrough
- [ ] Project/epic status badges reuse the same hues for the shared names
- [ ] Default label seed colors drawn from the palette (bug red-600, feature cyan-500, enhancement purple-700, documentation blue-600, chore gray, task lime-500)
- [ ] Epic colors: user-pickable from an 8-swatch palette subset
- [ ] Brand gradient reserved for primary CTAs, active nav icon, login screen accent
+45
View File
@@ -0,0 +1,45 @@
# 15 — Build, Deployment & Ops
Two supported deployment paths from day one: **bare binary** (user supplies Postgres/Meilisearch/SMTP via env) and **reference docker-compose**. One artifact serves both: a single Go binary with the SPA embedded.
## Build pipeline
- [ ] `make build`: `cd web && pnpm install && pnpm build``go build -o solopm ./cmd/solopm` with `//go:embed web/dist` (embed directive lives in a package that fails the build if `web/dist` is missing)
- [ ] Version stamped via `-ldflags "-X main.version=$(git describe)"`; shown in `/healthz` and a footer
- [ ] `make dev`: Vite dev server (proxying `/api`) + `go run` with a file-watcher (air or watchexec)
- [ ] CI recipe (GitHub Actions or equivalent): lint (golangci-lint, eslint), `go test ./...`, frontend build, binary artifact
- [ ] Release: multi-arch binaries (linux/amd64, linux/arm64, darwin/arm64) + a container image (distroless or alpine, non-root user, `UPLOAD_DIR` volume)
## Runtime
- [ ] CLI subcommands on the same binary: `solopm serve` (default), `solopm migrate up|down`, `solopm reindex`, `solopm cull [--dry-run]`
- [ ] `serve` runs pending migrations at startup by default (`--no-migrate` to disable)
- [ ] First-run bootstrap: empty users table (beyond seeds) → registration page advertises that the first account becomes admin ([05-auth.md](05-auth.md))
- [ ] `/healthz`: 200 when DB reachable; reports meilisearch/smtp as ok/absent (absent is not unhealthy)
- [ ] Full env var reference lives in [01-architecture.md](01-architecture.md); ship `.env.example` with every variable, commented
## docker-compose (reference)
- [ ] `docker-compose.yml` services:
- [ ] `app` — the SoloPM image; ports `4080`; env wired to the two services; volumes: `uploads:/data/uploads`; depends_on healthy postgres + meilisearch
- [ ] `postgres` — postgres:16-alpine; volume `pgdata`; healthcheck `pg_isready`
- [ ] `meilisearch` — getmeili/meilisearch; volume `meilidata`; `MEILI_MASTER_KEY` from env
- [ ] (dev profile) `mailhog` — SMTP capture for local testing
- [ ] `docker compose up` from a fresh clone + `.env` copied from `.env.example` reaches the login page with zero other steps
- [ ] Compose file pins image versions; README documents upgrade = pull new app image, migrations run on start
## Reverse proxy & SSE notes
- [ ] Document (README/ops doc): disable proxy buffering for `/api/v1/stream` (nginx `proxy_buffering off`, `X-Accel-Buffering: no` is already sent); read timeout ≥ 60s; `client_max_body_size``MAX_UPLOAD_MB`
- [ ] Cookie `Secure` requires https at the proxy; set `BASE_URL` accordingly
## Backup & restore
- [ ] Documented procedure: `pg_dump` + tar of `UPLOAD_DIR` = complete backup (Meilisearch is derived — rebuild with `solopm reindex`)
- [ ] Restore drill documented and tested once: restore dump, restore uploads, start app, reindex
## Operational acceptance
- [ ] Fresh `docker compose up` → register → create project → create issue → board drag works, all on first try following only the README
- [ ] Bare-binary path verified against external Postgres + Meilisearch using only `.env.example` guidance
- [ ] Binary restarts cleanly under systemd example unit (provided in ops doc)
+176
View File
@@ -0,0 +1,176 @@
# 16 — Phased Roadmap
Eleven phases, each independently buildable and verifiable. A build agent starting a phase should read: `00`, `01`, `02`, the files listed for the phase, and nothing else. **Check off tasks and acceptance criteria here (and the phase box in `00-overview.md`) as they are completed and verified.**
Dependency graph: `0 → 1 → 2 → 3 → 4 → 5 → 6 → {7, 8} → 9 → 10` (7 and 8 are parallel-safe after 6).
## Phase 0 — Foundation
Spec files: [01-architecture.md](01-architecture.md), [14-design-system.md](14-design-system.md), [15-deployment.md](15-deployment.md)
- [ ] Repo skeleton per architecture layout; Go module `solopm.com/solopm-server`
- [ ] chi server + middleware chain (minus auth resolver), slog, request logging, graceful shutdown
- [ ] Config loader with full env var set + `.env.example`
- [ ] golang-migrate wired (embedded migrations) + migration 0001 (extensions + enums); sqlc configured and generating
- [ ] Vue app scaffolded (Vite, TS, Pinia, Router, Tailwind) with design system copied to `web/design-system/` and wired; AppShell (sidebar/navbar) renders
- [ ] `go:embed` SPA serving with deep-link fallback; Vite dev proxy
- [ ] docker-compose (app + postgres + meilisearch + mailhog dev profile); Makefile targets
- [ ] `/healthz`
Acceptance:
- [ ] `docker compose up` serves the SPA shell at `:4080`; `/healthz` reports db ok
- [ ] `make migrate-up && make migrate-down` cycles cleanly
- [ ] Shell passes the design-system conformity checklist
## Phase 1 — Auth & users
Spec files: [05-auth.md](05-auth.md) · Depends: 0
- [ ] Migrations: users (+seeds), oauth_accounts, sessions, api_tokens
- [ ] Register/login/logout with scs sessions; first-user-becomes-admin; rate limiting
- [ ] GitHub + Gitea OAuth (linking rules incl. verified-email guard)
- [ ] PAT create/list/revoke + bearer middleware
- [ ] `/me` endpoints, `/users` picker, admin user endpoints
- [ ] Frontend: login/register/oauth-callback pages, auth store + guard, settings→profile/security/tokens tabs, admin users page
Acceptance (scriptable with curl):
- [ ] Register → is_admin true; second register → false
- [ ] Cookie-authed and PAT-authed `GET /me` both succeed; revoked PAT → 401
- [ ] system/zombie users absent from `/users`; login as ids 12 impossible
- [ ] OAuth flow against a real or mocked provider produces a linked account (verified-email path unit-tested)
## Phase 2 — Event backbone & audit
Spec files: [03-domain-events.md](03-domain-events.md) · Depends: 1
- [ ] Migrations: audit_log, events, jobs
- [ ] `events.Emit` (audit + outbox in-tx), pg_notify, dispatcher goroutine, job worker pool with backoff/dead states
- [ ] `auth.*` and `user.*` events emitting through the pipeline
- [ ] Admin jobs inspection endpoint
Acceptance:
- [ ] Login writes an audit row and a processed event row
- [ ] `kill -9` during a pending job → job retried after restart (integration test)
- [ ] A job failing 8 times lands in `dead` and is visible via admin endpoint
## Phase 3 — Projects core
Spec files: [06-projects.md](06-projects.md) · Depends: 2
- [ ] Migrations: projects, project_members, project_dependencies, project_favorites, labels
- [ ] Project CRUD + status/completed_at automation + owner transfer + membership + favorites + label CRUD/seeding + dependencies with cycle detection
- [ ] `RequireProjectMember`/`RequireProjectOwner` middleware; non-member 404s
- [ ] Activity feed endpoints (project + dashboard)
- [ ] All mutations emit events per taxonomy
Acceptance:
- [ ] Non-member GET on another's project → 404
- [ ] Dependency cycle attempt → 409 `cycle_detected`
- [ ] Every mutation appears in `/projects/{id}/activity` with correct actor
- [ ] Sole-owner cannot be removed/deleted without transfer (409)
## Phase 4 — Issues & epics
Spec files: [07-issues-epics.md](07-issues-epics.md) · Depends: 3
- [ ] Migrations: epics, issues, issue_blockers, issue_labels, issue_watchers
- [ ] Issue CRUD, numbering, sub-issues, blockers + cycles, labels, points, completed_at automation
- [ ] board_rank helpers (`Between/Before/After`) with unit tests; `?view=board`; list filters/sort
- [ ] Epic CRUD, attach/detach/reorder issues, computed progress (counts + points)
Acceptance:
- [ ] Two projects both mint `#1`; 20 parallel creates in one project yield 20 unique consecutive numbers
- [ ] Status→done sets completed_at; back to in_progress clears it
- [ ] Blocker cycle → 409; epic progress matches a hand-computed fixture
- [ ] Board view returns rank-ordered groups; a move lands between its neighbors
## Phase 5 — Views (frontend core)
Spec files: [13-frontend.md](13-frontend.md) (+ [14-design-system.md](14-design-system.md)) · Depends: 4
- [ ] Dashboard, project list/overview, project settings (general/members/labels tabs)
- [ ] Issue table with FilterBar + URL-synced state
- [ ] Kanban board with optimistic drag-and-drop
- [ ] Issue detail page (all sidebar fields editable, sub-issues, blockers)
- [ ] Epic list/detail pages
- [ ] Stores: projects, issues, board, epics; badges/pickers/dialog components
Acceptance:
- [ ] Drag card between columns → persists after reload (status + position)
- [ ] Filters/sort survive refresh via URL; shareable link reproduces the view
- [ ] Failed PATCH rolls back the optimistic move with a toast
- [ ] New pages pass the conformity checklist
## Phase 6 — Collaboration
Spec files: [08-collaboration.md](08-collaboration.md) · Depends: 5
- [ ] Migrations: comments, links, attachments, wiki_pages
- [ ] Comments CRUD + resolve; polymorphic links/attachments + `/files/` auth-checked serving; wiki CRUD with slug rules + conflict warning
- [ ] Server-side mention/#ref extraction into event payloads; watcher auto-watch/mute semantics
- [ ] Frontend: MarkdownEditor/MarkdownView (mention + #ref autocomplete/rendering, paste-to-attach), comments UI with resolve, attachments/links UI, wiki pages, WatchButton + watcher list
Acceptance:
- [ ] `@user` in a comment produces a mention entry in the event payload; `#3` renders as a link to issue 3
- [ ] Resolving collapses the thread; unresolve restores it
- [ ] Upload lands at the specified disk path; non-member GET on `/files/…` → 404; oversized upload → 413
- [ ] Unwatch (mute) then comment again → no re-auto-watch
## Phase 7 — Notifications & real-time
Spec files: [09-notifications-realtime.md](09-notifications-realtime.md) · Depends: 6
- [ ] Migration: notifications
- [ ] Fan-out consumer (rules + precedence + dedupe), inbox/bell endpoints
- [ ] SSE hub + `/stream` + heartbeats; frontend sse.ts + realtime store + in-place updates
- [ ] SMTP sender, instant emails, daily digest job, email_mode setting UI
Acceptance:
- [ ] Two-browser test: assign in A → B's bell increments and B's board card moves within 2s, no reload
- [ ] Actor never notified of own action; `mentioned` wins precedence over `commented`
- [ ] MailHog shows instant email; digest job groups a day of notifications into one email
- [ ] Muted watcher gets neither bell nor email
## Phase 8 — Search (parallel-safe with 7)
Spec files: [10-search.md](10-search.md) · Depends: 6
- [ ] Meilisearch client, index bootstrap, event→sync mapping, `search_sync` job handler
- [ ] `/search` endpoint with membership filtering; reindex CLI + admin endpoint
- [ ] CommandPalette (Cmd+K) + `/search` page
Acceptance:
- [ ] Created issue findable in < 5s; soft-deleted issue disappears from results
- [ ] Non-member's content never appears (integration test with two users)
- [ ] `solopm reindex` rebuilds equal counts from Postgres truth
- [ ] Palette opens with Cmd+K, arrows navigate, Enter routes
## Phase 9 — Integrations
Spec files: [11-integrations.md](11-integrations.md) · Depends: 7
- [ ] Migrations: incoming_webhook_receipts, outgoing_webhooks, outgoing_webhook_deliveries
- [ ] Incoming endpoint: HMAC verify, receipts, repo routing, closes-#N automation (system user, via_webhook), replay
- [ ] Outgoing: CRUD, signed delivery jobs, delivery log, test endpoint, SSRF guard
- [ ] Export JSON + CSV streaming
- [ ] Frontend: settings→webhooks tab (secret, receipts, outgoing CRUD + deliveries), export tab
Acceptance:
- [ ] Replayed GitHub PR-merged fixture with valid signature closes `#N`; audit shows system user with via_webhook=true; bad signature → 401 with receipt logged invalid
- [ ] Duplicate delivery id → 200 without reprocessing
- [ ] Outgoing delivery retries against a flaky test receiver, then records success with correct `X-SoloPM-Signature`
- [ ] JSON export contains every entity family; CSV opens with correct columns
## Phase 10 — Retention & hardening
Spec files: [12-retention.md](12-retention.md) · Depends: 9
- [ ] Cull job + CLI + admin endpoint with dry-run; cascade scopes incl. disk files; zombie reassignment; auxiliary retention (notifications/events/jobs/receipts/deliveries)
- [ ] Backup/restore docs + drill; systemd unit example; reverse-proxy doc
- [ ] Final pass: rate limits verified, error envelope consistency, `/healthz` complete, README quickstart
Acceptance:
- [ ] Dry-run reports the exact would-delete set with zero deletions
- [ ] Culling a project removes all its rows **and** files (orphan-check test passes)
- [ ] Culled user's comments render as "Deleted User"; audit keeps original id
- [ ] Fresh-clone `docker compose up` walkthrough from the README works end-to-end
+39
View File
@@ -0,0 +1,39 @@
# 17 — Decision Log
Judgment calls made during planning, with rationale. Build agents: **do not relitigate these.** If implementation reveals a hard blocker, note it here and surface it to the owner instead of silently deviating. (Prose by design — these aren't work items.)
## Product decisions (owner-confirmed)
1. **Two work-item levels (epics + issues), not Taiga's four.** Taiga's Epic→Story→Task + Issue split is its single biggest complexity driver (4 status sets, 4 custom-field sets, promotion paths). Sub-issues + epics cover the same needs at small scale.
2. **Fixed statuses, no per-project workflow customization.** Consistent UX, no admin screens, kanban columns are stable. Labels absorb remaining taxonomy needs.
3. **No sprints, backlog view, swimlanes, WIP limits, custom roles, or discovery/social features.** Deliberately out of scope for <10-user teams; Taiga's enterprise surface was surveyed and dropped.
4. **Owner/member + instance admin only.** A viewer role and permission matrices were considered and rejected.
5. **PostgreSQL over SQLite** (owner choice; first iteration used SQLite). Compose file keeps ops one-command.
6. **Meilisearch for search** (owner choice) rather than Postgres tsvector — better typo-tolerant UX, at the cost of a second service and event-driven sync.
7. **Story points (1,2,3,5,8,13) over t-shirt sizes.** Owner preference; points also make epic rollups meaningful (points sums shown on epic progress).
8. **Sessions + PATs** (not JWT). Server-side scs sessions are simplest/safest for a same-origin SPA; PATs cover scripting. PATs have **no scopes in v1** — they act as the user; revisit if the API gains external consumers.
9. **`oauth_accounts` table over a single `provider` column** (owner-approved). Kills the duplicate-account problem documented in the first iteration; enables local+GitHub+Gitea on one identity. Link only on **verified** provider email.
10. **SSE over WebSockets.** One-way push is all the app needs; SSE is stdlib-friendly, auto-reconnecting, proxy-tolerant.
## Architecture decisions
11. **Hand-rolled transactional outbox + Postgres job queue, not River / external brokers / fire-and-forget goroutines.** Goroutines lose work on crash (unacceptable for notifications/webhooks/search); River adds dependency weight for a <10-user single-instance app. ~300 LOC buys at-least-once delivery surviving restarts. **Explicit constraint: one app instance** (`SKIP LOCKED` keeps accidental multi-instance safe, but the SSE hub is per-process).
12. **Audit log written synchronously in the mutation transaction** (exactly-once), while everything else is async via the outbox. Activity feeds are therefore never stale or lossy.
13. **Single firehose SSE stream per user**, client filters. Per-page topic subscriptions would cut noise but add protocol complexity; at <10 users the firehose is trivially cheap.
14. **Polymorphic `links`/`attachments` tables** (resource_type + resource_id) replacing three tables each. Loses FK integrity on the target; acceptable because hard deletes flow exclusively through the culling job, which handles cascades (and must anyway, for disk files).
15. **Native Postgres enums for statuses/priorities.** Safe because sets are fixed by product decision (#2). Adding a value later is one `ALTER TYPE … ADD VALUE` migration.
16. **Fractional lexicographic ranks (`board_rank`/`epic_rank` text)** for drag ordering — O(1) writes per move, no renumbering transactions; rebalancing deferred (not needed at target scale).
17. **`ON DELETE RESTRICT` everywhere + app-level culling cascades** (carried from first iteration). A DB cascade cannot delete attachment files from disk; one deletion path (the cull job) keeps rows and files consistent.
18. **Denormalized `project_id` on audit_log** so activity feeds are one indexed query. Zero-maintenance denormalization (audit rows are immutable).
19. **Per-project issue numbers via a `next_issue_number` counter** updated in the insert transaction — no sequence-per-project, no gaps from rollbacks visible in practice, concurrency-safe.
20. **Server extracts mentions/#refs; client renders markdown.** Rendering in one place (client) avoids double-sanitization drift; the server only needs entities for notifications, which must not depend on client honesty.
## Scope cuts (v1) — revisit only when the owner asks
21. **No project import** (export format is versioned and designed to be importable later).
22. **No wiki page history**, no comment edit-history; `updated_at` + conflict warning only.
23. **No email invitations** — members are picked from existing instance users (registration is open on the instance).
24. **No server-side thumbnails**, no image processing.
25. **No custom fields, votes/likes, due-date buckets, CSV-feed tokens, videoconferencing, telemetry** — Taiga features consciously excluded.
26. **Audit log kept forever** (first iteration left retention open; forever is the safe default at this scale — revisit if instances grow).
27. **Incoming push-event automation** (commit messages moving issues) recorded but ignored in v1; only PR-merged `closes #N` acts.