6.3.1 Dashboard + widget data model, services, and CRUD API (workspace-scoped, access private|workspace, TOTAL widget-type registry, 20-widget cap)
Estimate: 30m · Depends on: 6.2.1
The dashboard substrate. Pure backend — no UI.
Migration — dashboard (id, workspaceId, name, ownerId, access: 'private' | 'workspace', layout: 'one' | 'two' | 'three' columns, timestamps) and dashboard_widget (id, dashboardId, type, column, position — the shipped base-62 fractional index (lib/workItems/positioning.ts vocabulary) so drag-reorder is one-field, config Json, plus a NULLABLE savedFilterId FK (onDelete: SetNull — a deleted filter STALES the widget, never deletes it) and a nullable projectId FK (onDelete: Cascade)). Both FKs modelled as @relations on BOTH sides (the FK-drift rule). Indexes: [workspaceId], [dashboardId, column, position].
lib/dashboards/widgetRegistry.ts — the TOTAL per-widget-type registry (mistake #29): each type (filter_results, distribution, created_vs_resolved) maps to a config Zod schema (validating the data source — exactly one of savedFilterId/projectId — plus per-type settings: page size ≤50 / statistic-type id / period + days-back + cumulative), a data-source resolver, and the renderer/editor kinds (the UI contract for 6.3.3/6.3.5). Unknown type or malformed config → typed 422; the enumeration test fails on any registry gap.
dashboardsService (4-layer; one method = one transaction): dashboard CRUD (create = any workspace member; rename/relayout/access-change/delete = owner-only — typed 403 otherwise), widget add/update/remove/move (validated through the registry; the 20-widget cap → typed 422; move takes column + neighbour ids and computes the fractional index server-side, the board precedent), and the reads — listDashboards (private-to-me + workspace-shared, bounded) and getDashboard (widgets ordered by column/position; access enforced: private + not owner → 404-shaped denial). Routes are HTTP-only under app/api/dashboards/; the finding-#26 workspaceId gate covers every route. Fill the reserved 6.2.1 dependents line: extend the saved-filter delete-dependents enumeration with the widget FK join (the Cloud-style warning now counts "N dashboard widgets" alongside subscriptions — the line 6.2.2's warning design reserved); the delete itself stays SetNull (the widget goes STALE, the verified Cloud gadget behaviour — it never cascades).
Acceptance criteria
- The migration applies cleanly (re-run: no drift); both FKs are two-sided
@relations; deleting a saved filter nullssavedFilterId(stale, not gone); deleting a project cascades its project-sourced widgets; the 6.2.1 delete-dependents read counts the filter's widgets (asserted: the warning DTO names N widgets before a delete that stales them). - The registry is total over the three types (enumeration test: every type has schema × resolver × renderer-kind × editor-kind); unknown types/configs → 422; the data-source XOR is enforced.
- CRUD honours the permission matrix (member create / owner-only mutate / private invisible to non-owners — each asserted both service- and route-level); the widget cap holds; move produces stable fractional orderings under concurrent-ish sequences (the 3.2 test pattern).
- Reads are bounded (list ≤ a sane page; get loads one dashboard's ≤20 widgets); empty-input guards on the new repo methods (coverage gate);
pnpm test:coverage≥90%.
Context refs
- 6.2.1 (the
saved_filtertable the FK references + the delete-dependents enumeration this extends — its "6.3 widgets join in by FK later" line) lib/workItems/positioning.ts+ the 3.2 move-service pattern (fractional index, server-computed)motir-core/CLAUDE.md(4-layer, required-tx, FK/migration rules); finding #26 (workspace gate);lock-before-read-derived-update(the move tx)- The verified Jira dashboard facts in the Story 6.3 description (ownership/sharing/layout/cap)