2.4.1 Detail route `/issues/[key]` + `getIssueDetail` read + page shell
Estimate: 16m
The route + data spine the rest of the Story hangs off. A Server Component at app/(authed)/issues/[key]/page.tsx resolves the active project (getActiveProject()) and reads the work item BY ITS PROJECT-SCOPED IDENTIFIER (e.g. PROD-42) — not by raw id — since that's what the URL carries and what every link in the app produces.
Service (one aggregate read). Add workItemsService.getIssueDetail(identifier, ctx) returning a single IssueDetailDto that bundles what the page needs in one round-trip: the work item, its parent (id + identifier + kind + title + status, for the breadcrumb), its direct children (same shape, for the child list), its links (blocked-by / blocks), and its workflow context (the project's statuses + legal next-transitions for the current status). It composes existing repo reads behind ONE service method (CLAUDE.md: the page calls one service method, not five repos). Tenant gate FIRST: a cross-workspace or non-existent identifier collapses to a uniform 404 (no existence leak) — reuse WorkItemNotFoundError. getWorkItemByIdentifier is the underlying repo lookup (@@unique([projectId, identifier])); if 2.3.6 already added it, reuse it — do not add a second copy.
Page shell. The two-column layout every later subtask fills: a header row (type icon from 2.1.1 · PROD-N identifier · title · a status pill placeholder 2.4.4 upgrades to the inline control), a main column (description/explanation — 2.4.2, child list — 2.4.3), and a metadata sidebar (core fields — 2.4.2; relationships — 2.4.5). An "Edit" button links to /issues/[key]/edit (2.3.6). The layout reserves clearly-commented Epic-5 extension slots (comments · attachments · custom fields · activity) as empty regions, so those land as fills rather than a re-layout. Renders inside the Story-1.5 authed shell; unauthenticated → sign-in redirect; non-member / cross-workspace → 404.
Acceptance criteria
- Route renders for an existing issue addressed by
PROD-N; cross-workspace / unknown identifier → 404 (no title/existence leak); unauthenticated → sign-in redirect. workItemsService.getIssueDetailreturns ONEIssueDetailDto(work item + parent + children + links + workflow context); the page makes exactly one service call. ExplicitworkspaceIdgate (finding #26).getWorkItemByIdentifieris the single shared identifier lookup (reused from 2.3.6 if present, else added here — never duplicated).- Header renders type icon + identifier + title + a (static, for now) status pill; an "Edit" button links to the 2.3.6 edit route; Epic-5 extension slots exist as documented empty regions.
- Vitest (real Postgres) over
getIssueDetail: happy path shape, cross-workspace 404, parent/children/links populated correctly. The STRICT shell-a11y sweep is extended to the detail route (added here, asserted across later subtasks).
Context refs
lib/services/workItemsService.ts+lib/repositories/workItemRepository.ts(1.4) —getWorkItem/listChildren/findById/getWorkItemByIdentifier+ the workspaceId-filter conventionapp/(authed)/issues/[key]/edit/(2.3.6) — the sibling route shape +getActiveProject()resolution (finding #50)lib/dto/workItems.ts+lib/mappers/workItemMappers.ts— DTO + mapper conventions (no@prisma/clientleak)- Story 1.5 authed shell + 1.5.5 shell-a11y sweep — extend, don't duplicate
motir-core/CLAUDE.md— 4-layer, one-service-call-per-page, entity-naming