MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-112

2.5.1 Project issue-tree read — `getProjectTree` + workspace gate + filter

Done
Description

Estimate: 18m

The read that backs the whole list: every non-archived issue in a project, assembled into the nested forest the tree-table renders. New workItemsService.getProjectTree(projectId, filter, ctx) returning WorkItemTreeNodeDto[] — roots ordered by key asc, each node carrying its children (same order) plus the columns the rows show: kind, identifier, title, status, assigneeId, depth, and hasChildren. Reuses Story 1.4's recursive-CTE pattern: add a workItemRepository.findProjectForest(projectId, workspaceId, filter?) single-op read (one round-trip, no N+1), then the service nests the flat rows by parentId preserving sibling key order. Map to DTOs in lib/mappers/workItemMappers.ts.

Explicit workspace gate (finding #26). The shipped findByProjectFiltered filters only projectId + archivedAt; the forest read carries an EXPLICIT workspaceId on both the anchor and the recursive step (mirrors findByProjectAndKinds / findAncestors) so a cross-workspace row can't leak even with RLS inert. The service resolves the project's tenant and rejects a cross-tenant projectId (reuse ProjectNotFoundError → 404, the existing no-existence-leak shape).

Filter semantics — context-preserving. The optional filter (kind · status · assigneeId · case-insensitive text on identifier+title) matches rows, but a matching DESCENDANT RETAINS its ancestors (rendered muted/non-matching) so the tree stays navigable — the standard tree-filter behavior, not a flat WHERE that orphans children. Text search is a scoped contains (identifier + title only) — full-text / cross-project search is Epic 6, not this Story.

Acceptance criteria

  • getProjectTree returns the project's non-archived issues nested by parent, roots and siblings ordered by key asc, each node exposing kind/identifier/title/status/assigneeId/depth/hasChildren.
  • The forest read carries an explicit workspaceId filter on anchor + recursive step; a cross-workspace projectId 404s (no row/title leak).
  • A filter (kind/status/assignee/text) returns matching rows AND their ancestor chain for context; an empty project returns []; a no-filter call returns the full forest.
  • One DB round-trip (recursive CTE), no N+1; archived items excluded; depth bounded by the Story-1.4 cap.
  • Vitest (real Postgres): nesting + sibling order at depth ≥3, ancestor-retention under a descendant-matching filter, cross-workspace isolation at the repo layer, empty project.

Context refs

  • workItemRepository.findSubtree / findAncestors (1.4 / 2.4.3) — recursive-CTE + dual-side workspaceId pattern to mirror
  • findByProjectFiltered + listWorkItems + WorkItemSummaryDto — the flat read this generalizes (reconcile, don't duplicate)
  • lib/mappers/workItemMappers.ts, lib/dto/workItems.ts, ProjectNotFoundError
  • motir-core/CLAUDE.md — 4-layer · single-op repo · finding-#26 workspace gate
Status
Done
Type
Sub-task