Feature #1544
closedFeature #1511: Replace ticket view with Kanban board for CTO and assigned developers
API: per-user board column preferences
0%
docs/superpowers/specs/2026-08-03-kanban-board-design.md
Description
**Surface:** backend (`api/`)
**Depends on:** nothing
**Blocks:** "Frontend: Kanban board replaces the ticketing list view"
The board's column show/hide filter must persist per user (epic AC #4). No per-user preference store exists in this repo today — the only server-persisted per-user setting is `clients.language` via `PATCH /auth/me/language`. This ticket creates the first real one, deliberately scoped to board columns rather than built as a general-purpose settings service.
Add an Alembic migration for a `(user, project)`-keyed table holding the hidden column status ids, plus read and write endpoints under the authenticated user's namespace. Validate incoming ids against the canonical `STAGE_ORDER` (`domain/tasks/value_objects.py:92`) — do not re-derive stage order.
**Default when no row exists:** Shipped (13) hidden, every other status column shown.
> **Open PO question, resolve before building:** the epic permits *"browser cache **or** backend storage"*. This ticket specifies the server-side store, which additionally gives cross-device persistence. A localStorage-only implementation would satisfy the same user-visible AC for a fraction of the work (precedent: `src/features/claude/fallbackPreference.ts`), at the cost of per-device preferences and this whole child. Confirm before starting.
## Acceptance criteria
- [ ] Alembic migration creates the table with a unique constraint on `(user_id, project_id)`; `alembic upgrade head` then `downgrade` both run clean against an ephemeral `pgvector/pgvector:pg16`.
- [ ] A GET endpoint returns the caller's hidden column ids for a project; with **no stored row** it returns the documented default (exactly the Shipped status id) with 200 — not a 404.
- [ ] A write endpoint persists the set and is idempotent: a repeated identical write leaves exactly one row.
- [ ] A status id absent from `STAGE_ORDER` is rejected with 422 and nothing is persisted.
- [ ] A stored preference referencing a status id later removed from `STAGE_ORDER` is ignored gracefully — 200 with that entry dropped, never a 500.
- [ ] **Isolation:** user A's write leaves user B's read unchanged for the same project, and a write on project 1 does not affect project 2.
- [ ] A caller cannot read or write preferences for another user, nor for a project they are not a member of — same not-found shape used elsewhere, no existence leak.
- [ ] **Preferences cannot widen visibility:** hiding or showing columns leaves the set of ticket ids returned by the board endpoint identical (test). Column preferences are presentation-only and must never interact with the D2 matrix.
- [ ] Hiding every column is either rejected or documented as legal and rendered as an explicit empty-board state; whichever is chosen is asserted by a test.
- [ ] Gates green: `python -m pytest -q` (**run serially**), `ruff check .`, `mypy src`.
RA Updated by Redmine Admin 1 day ago
- Status changed from Spec to In development
RA Updated by Redmine Admin 1 day ago
- Status changed from In development to QA
- branch set to feat/1544-board-column-prefs
- pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/95
Backend shipped to a PR against `dev`: https://github.com/omdev-tech/PipeLiner-Client/pull/95 (branch `feat/1544-board-column-prefs`).
The "Open PO question" in the description is resolved: the PO confirmed the **server-side store**, so that is what was built (cross-device persistence, not localStorage).
**What ships**
- Migration `0021_board_column_prefs` — `board_column_preferences`, `UNIQUE (client_id, project_id)`, hidden ids as JSONB, both FKs cascading. `upgrade` + `downgrade` round-trip clean on a fresh `pgvector/pgvector:pg16`, both guarded so a re-run is a no-op.
- `GET` / `PUT /auth/me/board-columns/{project_id}` — cookie-authed, in the caller's own `/auth/me` namespace. Ids validated against the canonical `STAGE_ORDER` (never re-derived).
**Acceptance criteria**
- Default with no row: `200` with exactly `[13]` (Shipped), never a `404`. ✅
- Write is idempotent — a repeated identical `PUT` leaves exactly one row (asserted with a row count). ✅
- Status id absent from `STAGE_ORDER` → `422`, nothing persisted. ✅
- A stored id later removed from `STAGE_ORDER` → `200` with that entry dropped, never a `500`. ✅
- Isolation: user A's write leaves user B's read at the default for the same project, and a write on one project does not touch another. Per-user isolation is structural — no path, query or body field names a user, so another user's preferences are not addressable. ✅
- A foreign project and a non-existent project answer **identically** (`403 Forbidden`, the same `owned_project` shape every project route uses) — no existence leak. ✅
- **Preferences cannot widen visibility:** pinned by a test — hiding a populated column and then showing everything leaves the set of ticket ids returned by `GET /projects/{id}/tasks` identical, and the hidden columns are still in the payload. Nothing added here is read by the board endpoint, the aggregator query or the visibility matrix. ✅
- All-columns-hidden: **decision — rejected with `422`**. The board with no columns has nothing to render and no in-product way back (the filter lives above the columns). Hiding all but one stays legal. Asserted by a test. ✅
**Gates** (own ephemeral pg16, run serially): `pytest -q` → 1102 passed / 2 skipped, run **twice** on the one database; `ruff check .` clean; `mypy src` clean.
Scope kept off #1542 / #1543: no change to the board payload, the visibility matrix or any capability field. Note for the merge queue — this takes migration revision `0021`; if #1542 lands a `0021` first, this one needs its `down_revision` rebased.