Project

General

Profile

Actions

Feature #1542

closed
RA

Feature #1511: Replace ticket view with Kanban board for CTO and assigned developers

API: assignee, priority and parent on the task list payload

Feature #1542: API: assignee, priority and parent on the task list payload

Added by Redmine Admin 1 day ago. Updated 1 day ago.

Status:
Shipped
Priority:
Normal
Assignee:
-
Start date:
08/03/2026
Due date:
% Done:

0%

Estimated time:
spec_ref:

docs/superpowers/specs/2026-08-03-kanban-board-design.md

build_status:
build_number:
ci_run_url:
scan_status:
scan_report_url:
deploy_status:
preprod_url:
deployed_at:
branch:
feat/1542-board-list-payload
pr_url:
https://github.com/omdev-tech/PipeLiner-Client/pull/96
security_key:
severity:
paused:

Description

**Surface:** backend (`api/`)
**Depends on:** nothing — see the note below
**Blocks:** "API: per-viewer visibility matrix and per-card drag capability"

Display-data work only. **No authorization changes in this ticket** — the visibility matrix and drag capability are a separate child, deliberately, because a mistake there leaks another developer's or another tenant's tickets and deserves its own review and test suite.

> ### ⚠ Corrected wire names — read before starting
> The spec originally said the aggregator emits nothing for assignee/priority. **That was wrong.** `ticket_list` has always carried `assignee` and `priority` as flat **display-name strings**. Retyping them to objects would break every existing consumer, so #1541 added the structured data under **new** keys:
>
> - `assignee_ref` → `{id, display_name}` (nullable)
> - `priority_ref` → `{id, key, name, position}` (nullable)
>
> **Map from `assignee_ref` / `priority_ref`, not from `assignee` / `priority`.** The flat strings still exist and are untouched — do not remove them, and do not map from them. Our outward `TaskSummaryOut` field names are independent of these wire names; pick what reads best on our side.
> Shipped in aggregator PR https://github.com/omdev-tech/aggregator/pull/24 — read `docs/ENRICHMENT.md` there for the authoritative shape.

1. Map `assignee_ref`, `priority_ref` and the existing parent reference from the aggregator through the hexagon: `infrastructure/tasks/aggregator.py:43` → application → `TaskSummaryOut` (`presentation/api/tasks.py:84`). **Priority is greenfield in this repo** — no DTO, no domain object, no i18n keys exist — so give it a real domain representation with a documented ordering. `priority_ref.position` is the rank to order by.
2. Add the **board fetch mode**: the view being replaced is paginated, but a board loads whole columns. Return every board column in one response, capped per status, each column carrying `total_count` and `has_more`.

**Why this does not hard-depend on the aggregator child.** The aggregator deploys on its own pipeline, so the dashboard will at some point run against an aggregator that has not shipped the new keys. Once this ticket carries the pre-change-payload AC below, that scenario is *covered by a test* rather than avoided by sequencing. The two may land in either order.

This is dashboard gotcha #2 in `CLAUDE.md`: a non-optional field left `None` → `model_validate` raises → 500 instead of a schema-valid empty envelope.

**Known upstream limitation to carry, not to fix here:** `priority_ref.position` comes from a single per-poll `/enumerations/issue_priorities.json` call in the aggregator and degrades to `null` if that call fails. Our ordering must tolerate a null position. Also, a Redmine version that omits `type` from `assigned_to` makes a group assignee indistinguishable from a user, so `assignee_ref` may occasionally name a group — do not assume it is a person.

**Proposed cap: N = 50 tickets per status.** The number must end up in one named constant, documented.

## Acceptance criteria
- [ ] `TaskSummaryOut` exposes assignee (id, display name — nullable), priority (id/key, name, position — nullable) and parent (id, subject — nullable), alongside everything the list already returns.
- [ ] Mapping reads `assignee_ref` / `priority_ref`; a test asserts the flat `assignee` / `priority` strings are NOT the source, and that both still pass through untouched for any existing consumer.
- [ ] Board mode returns all board statuses in ONE response, capped at N per status, each column carrying `total_count` and `has_more`. Test: seed 120 tickets in one status → that column holds exactly N items, `total_count` 120, `has_more` true; a 3-ticket status has `has_more` false.
- [ ] **Pre-change aggregator resilience:** a unit test feeds a captured pre-change aggregator payload (`assignee_ref`/`priority_ref` keys entirely absent) and a second with explicit nulls; both return a schema-valid 200 with null fields and no `model_validate` error. The test fails if either field is made required.
- [ ] A `priority_ref` whose `position` is null still maps and orders deterministically (test).
- [ ] A degraded/down/500 aggregator still returns the schema-valid empty envelope with the requested params stamped, matching the existing `_normalize_*` guards — never a 500.
- [ ] Call-count assertion: building a 120-ticket board issues exactly **one** aggregator call and zero per-ticket detail lookups (mocked transport asserts the count).
- [ ] Priority has a domain representation with a documented ordering; an unknown or absent priority degrades to `null` rather than raising.
- [ ] Aggregator DTOs do not reach the domain layer; mapping stays in infrastructure/application.
- [ ] The endpoint neither computes, stores nor returns any complexity or price field.
- [ ] Gates green: `python -m pytest -q` (**run serially — one shared Postgres**), `ruff check .`, `mypy src`.

RA Updated by Redmine Admin 1 day ago Actions #1

  • Description updated (diff)

Corrected the wire field names after #1541 shipped.

The spec claimed the aggregator emitted nothing for assignee/priority. It was wrong — `ticket_list` has always carried both as flat **display-name strings**. Retyping them would have broken every existing consumer, contradicting #1541's own "purely additive" AC, so the structured data landed under new keys instead:

- `assignee_ref` → `{id, display_name}`
- `priority_ref` → `{id, key, name, position}`

This ticket now says to map from those, and asserts by test that the flat strings are neither the source nor removed. Two upstream limitations are also carried into the ACs: `priority_ref.position` can be null if the aggregator's per-poll enumerations call fails, and a group assignee is not always distinguishable from a user.

Aggregator side: https://github.com/omdev-tech/aggregator/pull/24

RA Updated by Redmine Admin 1 day ago Actions #2

## Amendment after the design review (#1545) — these change the ACs

**1. Priority keys off `position`, not `key`.** #1541 ships `priority_ref.key` as `_slug(name)` — a slug of the *display name*. On a French Redmine "Élevée" becomes `elevee`, so it is neither locale-stable nor rename-stable and cannot drive translations or a colour scale. Redmine priority ids are instance-specific, so there is no portable id→key map either.

- PO decision: **`priority_ref.position` (the enumeration rank) is the stable identifier.** `name` is display-only; `key` is advisory and must not be the basis of any behaviour.
- New AC: the domain priority ordering derives from `position`. Where `position` is null, ordering is deterministic and the ticket sorts last — never raises, never reorders unstably between requests.

**2. The cap needs a defined sort, applied server-side BEFORE it.** Nothing currently says *which* N tickets survive the per-status cap. If the server caps by recency while the UI sorts by priority, a client's Immediate ticket can be the one that is invisible — a silent, high-consequence bug.

- New AC: each column is sorted **priority rank descending, then `updated_at` descending**, server-side, **before** the cap is applied. Asserted by a test that seeds a low-priority-recent and a high-priority-old ticket past the cap boundary and proves the high-priority one survives.

**3. Add `offset` so `+N more` is not a dead end.** v1 correctly forbids infinite scroll, but that leaves a client's 51st ticket unreachable with no path to it at all.

- New AC: board mode accepts a per-status `offset`, so the UI's "+N more" chip can become a "Load more" button. The design (§KB.6d) is already sized for that upgrade.

**4. Assignee is NOT withheld on the board.** PO decision: cards show the assignee name to every viewer, including ordinary clients. Do not apply the `_detail_out` redaction to the list payload. See #1543 — the same decision lifts the detail-side withholding, so the two surfaces stay consistent.

Design section: `docs/design/design-system.md` §KB (KB.0–KB.11).

RA Updated by Redmine Admin 1 day ago Actions #3

  • Status changed from Spec to In development

RA Updated by Redmine Admin 1 day ago Actions #4

  • Status changed from In development to QA
  • branch set to feat/1542-board-list-payload
  • pr_url set to https://github.com/omdev-tech/PipeLiner-Client/pull/96

PR ready for QA: https://github.com/omdev-tech/PipeLiner-Client/pull/96 (base `dev`, branch `feat/1542-board-list-payload`).

**What shipped, against the amended ACs (#1545 review):**

1. **Mapping reads `assignee_ref` / `priority_ref`**, never the flat `assignee` / `priority` display-name strings. Tests assert the flat strings are not the source, that the refs win when the two disagree, and that the mapper mutates nothing so existing consumers still see them untouched.
2. **Priority ordering derives from `priority_ref.position`**, the enumeration rank — not from `key` (upstream slugs the *display name*, so "Élevée" becomes `elevee`: neither locale- nor rename-stable) and not from the per-instance id. A null `position` maps fine and sorts last, deterministically, never raises.
3. **The per-column sort is server-side, BEFORE the cap**: priority rank desc, then `updated_at` desc, with the issue id as a stable tie-break. Asserted by a test that seeds 59 recent Normal tickets plus one very old Immediate one across the cap boundary and proves the Immediate ticket leads the column instead of being the one dropped.
4. **Per-status `offset`** added to board mode — repeatable `offset=<status_id>:<n>` pages one column and leaves every other on its first slice, so the "+N more" chip can become a "Load more" button. Malformed offsets are a 400.
5. **The assignee is NOT withheld on the board.** Cards carry it for every viewer; a test logs in as an ordinary client (`internal_role: null`) and asserts the name is present. The `_detail_out` redaction is deliberately not applied to the list payload.

**Also per the original ACs:** cap `BOARD_COLUMN_CAP = 50` in one documented constant; columns carry `total_count` / `has_more` / `offset` (120-ticket status → 50 cards, total 120, has_more true; a 3-ticket status → has_more false); a 120-card board costs exactly one aggregator call and zero per-ticket lookups; a pre-change payload (ref keys absent) and one with explicit nulls both return a schema-valid 200, asserted end to end through the real adapter, so the test fails if either field is made required; a degraded/down aggregator returns the full set of empty columns with the requested offsets stamped, never a 500; aggregator DTOs stay in infrastructure (guarded by a layering test); and no complexity or price field is computed, stored or returned.

**Out of scope, as specified:** no authorization, no visibility matrix, no capability fields — that is #1543.

**Gates:** `pytest -q` 1116 passed / 2 skipped (run serially against a dedicated ephemeral Postgres, green twice on the same database), `ruff check .` clean, `mypy src` clean.

Note for #1546: `TaskSummaryOut` exposes nested nullable objects — `assignee {id, display_name}`, `priority {id, key, name, position}` — plus the existing `parent_id` and a new `parent_subject`. `priorityToken` must key on `priority.position`.

RA Updated by Redmine Admin 1 day ago Actions #5

  • Status changed from QA to Shipped

Shipped to production in master build #109 (`c784f95`). PR #96.

Actions

Also available in: PDF Atom