3.3.3 Board config service + API — set swimlane group-by + per-column WIP limit
Estimate: 22m · Depends on: 3.3.2, 3.1.3
The write path for both pieces of board config, through the 4-layer architecture (Route → Service → Repository → Prisma), with the explicit application-layer workspaceId gate (finding #26). No business logic in the route; the service owns the transaction + DTO mapping; the repository writes are single-op with required tx.
boardsService.setSwimlaneGroupBy(boardId, groupBy, ctx) — validates groupBy is a BoardSwimlaneGroupBy, updates board.swimlaneGroupBy (via boardRepository.update, tx-required), returns the updated board DTO. boardsService.setColumnWipLimit(columnId, limit, ctx) — limit is a non-negative integer OR null (clear); validates (reject negatives / non-integers with a typed error), updates board_column.wipLimit (via boardColumnRepository.update, tx-required), returns the updated column DTO. Both verify the board/column belongs to a project in the caller's workspace BEFORE writing (the finding-#26 app-layer gate; RLS is the backstop, not the sole guard).
Permissions note (no early RBAC). Roles/permissions are Epic 6.4. Mirror the Story-2.2.5 workflow editor — a project-settings write any workspace member can make today — so these config writes are membership-gated now, with a // TODO(6.4): gate by project role note in the service. Do NOT build a role check this story (rung-2 consistency with shipped code; inventing RBAC early is the forbidden shortcut in the other direction).
Routes. PATCH /api/projects/[key]/board (body { swimlaneGroupBy }) → setSwimlaneGroupBy; PATCH /api/projects/[key]/board/columns/[columnId] (body { wipLimit: number | null }) → setColumnWipLimit. Both read the session via getSession(), call exactly one service method, and map typed errors to status codes (400 invalid limit, 403 wrong workspace, 404 missing board/column).
Out of scope here: reading the config back into the board (the projection — 3.3.4 returns swimlaneGroupBy + per-column wipLimit); any UI (3.3.5/3.3.6).
Acceptance criteria
boardsService.setSwimlaneGroupBy+setColumnWipLimitexist, own their transaction, validate input (invalid group-by / negative / non-integer limit → typed error), and write via tx-required repository methods; both enforce the explicitworkspaceIdgate before writing.setColumnWipLimitaccepts a non-negative integer ornull(clear); a negative/non-integer is rejected with a typed error mapped to HTTP 400.PATCH …/boardandPATCH …/board/columns/[columnId]routes are HTTP-only (one service call each, typed-error→status mapping, no Prisma/transaction in the route); a write from a different workspace is 403/404.- A
// TODO(6.4)role-gate note is present; NO role check is built this story (membership gate only, matching 2.2.5). - Returns DTOs (no raw Prisma rows cross the boundary), mapped in
lib/mappers/boardMappers.ts. - Vitest (real Postgres) covers: a successful group-by set, a successful + a clearing WIP set, the invalid-limit rejection, and the cross-workspace denial.
Context refs
lib/services/boardsService.ts+lib/repositories/boardRepository.ts/boardColumnRepository.ts(Story 3.1.3) — the services/repos this extends (add the two writes)lib/services/workflowsService.ts+app/api/.../settings/project/workflow(Story 2.2.5) — the project-settings write + membership-gate precedent to mirror (incl. the "member can write, RBAC later" stance)lib/dto/boards.ts+lib/mappers/boardMappers.ts— the board/column DTO shapes to return- finding #26 — the explicit app-layer
workspaceIdgate;motir-core/CLAUDE.md— the 4-layer rules (route HTTP-only, service owns tx + DTO, repo single-op tx-required write)