5.3.3 `customFieldsService` (values) — per-type validated set/clear + revision-trail diffs + the bounded detail-read join
Estimate: 28m · Depends on: 5.3.1
The values half. setValue(workItemId, fieldId, rawValue | null, ctx) + the read shape the rail consumes.
Per-type validation (the service is the authority): text → trimmed, length-capped (a constant, e.g. 1000 chars); number → decimal parse into the Decimal column (reject NaN/∞; preserve scale); date → date-only ISO, UTC-safe (the dueDate T00:00:00.000Z convention — no local-tz off-by-one); select → the option belongs to this field AND is not archived for NEW writes (an existing value holding an archived option remains valid + renderable); user → a workspace member who can view the project (the 6.4 assignableMembersService scoping — same rule as assignee/mentions). Type mismatches and cross-field options → typed 422s.
Write semantics: non-null → upsert the [workItemId, fieldId] row (exactly one per-type column populated, the rest null); null → DELETE the row (no tombstones). In the SAME transaction, write the 1.4.6 revision entry — diff: { "customFields.<key>": { from, to } } (display-friendly from/to: the option label, the user id, the raw scalar — the mapper renders), changeKind: 'updated' — the History entry 5.5 renders, the parity Jira has. Permissions: who edits the issue edits values (admin/member with view; viewer 403; cross-workspace 404).
Read — extend getIssueDetail with customFields: CustomFieldWithValueDto[] (definitions in position order, each with its option set + this issue's value, resolved for display: option label+archived flag, user id/name/image). ONE bounded query (≤50 defs cap) slotted into the existing parallel fetch — no N+1, no separate round-trip from the page. A dedicated setCustomFieldValueAction server action (the rail pattern: action → service → router.refresh()); custom values do NOT overload updateIssueAction's scalar input (different table, different validation surface — keep the seams clean).
Acceptance criteria
setValuevalidates per type exactly as specified (incl. archived-option new-write rejection, non-viewable-user rejection, decimal + UTC-date integrity), upserts/deletes the single row, and writes thecustomFields.<key>revision diff in the same tx.getIssueDetailreturns the boundedcustomFieldsarray (defs + resolved values) without an N+1; an issue with no values still lists definitions (the rail needs them for "Show more fields").- Permission matrix enforced (member sets, viewer 403, cross-workspace 404); concurrent set on the same pair converges via the upsert (last-write-wins, no duplicate-row error).
- Routes/action are HTTP-only thin;
pnpm test:coverage≥90% across every type branch + the clear path.
Context refs
- 5.3.1 value model + repos;
lib/services/workItemsService.tsgetIssueDetail(the parallel-fetch assembly to extend) +updateIssueAction(the action pattern to mirror, NOT extend) lib/services/workItemRevisionsService.ts+ the revision diff shape (Record<string, {from,to}>) — extend with thecustomFields.<key>keyslib/services/assignableMembersService.ts(6.4) — the user-value scoping- The dueDate UTC convention (2.3.12) for the date type