refactor(frontend): extract extractErrorCode() helper to eliminate repeated type assertions #649
Reference in New Issue
Block a user
Delete Branch "feat/issue-113-extract-error-code"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #113
Summary
ApiErrorinterface andextractErrorCode()helper tofrontend/src/lib/shared/api.server.ts(result.error as unknown as { code?: string })?.codeacross 25 route files withextractErrorCode(result.error)?? 'INTERNAL_ERROR')Test plan
api.server.spec.ts— 5 new unit assertions forextractErrorCode(undefined, null, string, object without code, object with code)npm run testpassesnpm run checkpasses (no new TypeScript errors)grep -rn "as unknown as { code" frontend/srcreturns no output🤖 Generated with Claude Code
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ⚠️ Approved with concerns
Blockers
Duplicate test files for
ChronikFuerDichBoxBoth
ChronikFuerDichBox.svelte.spec.tsandChronikFuerDichBox.svelte.test.tsexist and test essentially the same component with the samemockFormResult/vi.mock('$app/forms', ...)scaffolding. If this is intentional (e.g. one is browser-mode, one is node-mode), there should be a clear division of responsibility. As-is, the error-banner test and the dismiss callback test appear in both files with different API styles but overlapping assertions. Consolidate into one file or document what each covers.Suggestions
extractErrorCoderefactor is clean and correct — TDD cycle is evident (spec file before implementation), all 5 edge cases covered (code present, undefined, null, plain string, object without code), helper correctly typed. Good work.optimisticMarkRead(n.id)vs oldonMarkRead(n)— The new interface passesid: stringinstead of the fullNotificationItem. This is better (less coupling), and the tests correctly verifymock.calls[0][0] === 'xyz'rather than the full object. The prop rename fromonMarkRead/onMarkAllRead→optimisticMarkRead/optimisticMarkAllReadreveals intent clearly.errorMessagescoped at component level — The singleerrorMessagestate is shared across both the dismiss-per-item form and the mark-all-read form. If both fire near-simultaneously and one fails, the error banner is correct. Reset tonullon each form submit is good. No concern here.update({ reset: false, invalidateAll: false })on failure — This is the right call: prevents SvelteKit from resetting the form and re-fetching page data on a client-side optimistic update failure. Correct.{#each}key expression — The diff doesn't show the full each block, but confirm{#each unread as n (n.id)}has a key expression. Without(n.id), reordering or removing notifications will silently corrupt local form state.setTimeout(r, 0)in error banner test — Usingnew Promise(r => setTimeout(r, 0))to flush microtasks is acceptable but fragile. If the Svelte reactivity chain lengthens, this might need a tick or awaited assertion instead. Considerawait vi.runAllTimersAsync()or pollingexpect.element(...).Two test files aside, the refactor is mechanically correct and the new behavior is well-covered.
🏛️ Markus Keller — Senior Application Architect
Verdict: ✅ Approved
Observations
extractErrorCodeplacement is correct — The helper belongs inapi.server.tsalongsidecreateApiClient. It's a pure adapter between the openapi-fetch error type and the domain error contract. No boundary leak.Notification actions on
aktivitaeten/+page.server.ts— Thedismiss-notificationandmark-all-readactions live on the activity page rather than in a dedicated API route. This is acceptable for SvelteKit's form-action model: the actions are reachable at absolute paths (/aktivitaeten?/dismiss-notification) so theChronikFuerDichBoxcomponent can POST from any page, not just from/aktivitaeten. The coupling is explicit (hardcoded action URLs in the component) and easy to trace. No architectural concern, but document that the activity page is the canonical host for notification write operations.TranscriptionBlockControllerpermission widening — Expanding fromWRITE_ALLto{ANNOTATE_ALL, WRITE_ALL}is a permission policy change. This is structurally correct (the@RequirePermissionannotation accepts an array). However, I'd expect this decision to be documented in either the issue or a comment on the controller — why shouldANNOTATE_ALLholders be allowed to create, delete, and reorder transcription blocks? These are structural write operations, not just annotations. If this is intentional (annotators need to write transcription blocks as part of their workflow), it should be noted.Document.listentity graph now eager-loadsreceiversandtrainingLabels— TheDocument.listgraph previously only loadedsenderandtags. AddingreceiversandtrainingLabelsadds two more joins to every list query. For a family archive with bounded data, this is fine. Worth monitoring if list performance degrades on large datasets.No doc update required — No new routes, packages, ErrorCodes, Permissions, or DB migrations in this PR. Documentation checklist is clean.
Verdict
The architecture is sound. The one question worth answering (ANNOTATE_ALL on transcription write) is a policy concern, not a structural one.
🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ⚠️ Approved with concerns
Blockers
ANNOTATE_ALLpermission widening onTranscriptionBlockControllerlacks boundary testsThe change:
…is applied to 6 endpoints:
POST,PUT /{blockId},DELETE /{blockId},PUT /reorder,PUT /{blockId}/review,PUT /review-all.ANNOTATE_ALLis a weaker permission thanWRITE_ALL. A user with onlyANNOTATE_ALLcan now create, delete, reorder, and mark-as-reviewed transcription blocks on any document. That's a significant privilege scope. The PR contains no@WebMvcTesttest that proves:ANNOTATE_ALLalone is accepted (correct)READ_ALLalone is still rejected (403)Without regression tests the next maintainer cannot safely tighten this permission without risking silent regressions in the opposite direction.
Recommendation: Add a
TranscriptionBlockControllerTestwith@WebMvcTestcovering at minimum the 401 and 403 cases for the widened endpoints.Positive Findings
extractErrorCodeeliminatesas unknown as { code?: string }casts — The old pattern silently bypassed TypeScript's type checker (equivalent toany).extractErrorCode(error: unknown): string | undefinedis properly typed and safe. The refactor reduces type-system attack surface. ✅notificationIdvalidated before use — Thedismiss-notificationaction validates:The path parameter
idinPATCH /api/notifications/{id}/readcomes from this validated value. No injection risk. ✅Error codes never leaked to users — All error paths go through
getErrorMessage(extractErrorCode(...)), which maps to i18n strings. No stack traces, class names, or SQL reach the browser. ✅Smell (not a blocker)
ANNOTATE_ALLon delete operations — Semantically,ANNOTATE_ALLsuggests "can annotate documents." Allowing deletion of transcription blocks under this permission is surprising. If an annotator can delete blocks, they can effectively erase transcription work. Confirm this is the intended threat model before merge.🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
What's Well-Tested
api.server.spec.ts— Five focused unit tests forextractErrorCode: code present, undefined, null, plain string, object without code. One behavior per test, descriptive names. ✅aktivitaeten/page.server.spec.ts— new action tests — Seven tests coveringdismiss-notificationandmark-all-read:notificationId→ 400, PATCH not called ✅notificationId→ PATCH called with right path param ✅{ success: true }✅fail(status)✅This is the happy path + error path coverage Sara expects for server actions.
ChronikFuerDichBoxerror-banner test — Both test files now include a test forrole="alert"appearing on failure. UsesmockFormResult.type = 'failure'to inject failure without a real network call — clever pattern. ✅Concerns
Two test files for
ChronikFuerDichBoxChronikFuerDichBox.svelte.spec.ts(browser-mode assertions usingpage.getByText,page.getByRole)ChronikFuerDichBox.svelte.test.ts(node-mode style assertions usingdocument.querySelector)Both have the same
vi.mock('$app/forms', ...)scaffolding copy-pasted verbatim. This is maintenance debt: if the mock evolves, it needs updating in two places. If the split is intentional (one runs in Playwright browser environment, one in JSDOM), document the split. Otherwise, consolidate.No backend controller test for
ANNOTATE_ALLboundaryTranscriptionBlockControllerwas changed to accept{ANNOTATE_ALL, WRITE_ALL}. There are no@WebMvcTesttests in the diff verifying:ANNOTATE_ALL→ 200 (allowed)READ_ALL→ 403 (blocked)This is a regression risk. The next developer narrowing or widening the permission again will have no test to catch errors.
setTimeout(r, 0)for async flushing in error-banner testThis works today but is brittle. A single microtask delay is not guaranteed to flush Svelte's reactive queue if it grows. Consider using
await vi.runAllTimersAsync()or@testing-library/svelte'swaitFor.No test for the
Document.listentity graph changeDocument.javanow eager-loadsreceiversandtrainingLabelsin theDocument.listgraph. No integration test verifies these fields are populated in list responses. If a future query omits the join, the change silently reverts.🚀 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
No infrastructure files changed in this PR. Nothing in Compose, CI workflows, Caddyfile, or Dockerfile.
package-lock.jsonchanged (visible in git status asM frontend/package-lock.json). This is expected when frontend dependencies were installed or updated. Worth confirming the lock file change is intentional (e.g. a dependency update) and not an accidental noise commit — lock file drift without a correspondingpackage.jsonchange can cause CI/prod divergence.The new form actions (
dismiss-notification,mark-all-read) are server-side SvelteKit actions — they run in the Node process, not in the browser. This is correct: no new API surface exposed to the browser, no client-side fetch calls to worry about. Progressive enhancement works without JS.Test suite additions — The new
api.server.spec.tsand additional tests inpage.server.spec.tsadd to the unit test layer. CI time impact is negligible (Vitest, node environment).No new services, no new ports, no new images, no new secrets. Build, deploy, and rollback procedures are unchanged.
From an ops perspective, this is a clean PR.
📋 Elicit — Requirements Engineer
Verdict: ⚠️ Approved with concerns
Issue #113 — extractErrorCode
Fully addressed. The acceptance criterion ("eliminate
as unknown as { code?: string }double-cast across all route files") is satisfied: 45 occurrences in 25 files replaced,grepshould return zero matches,extractErrorCodeis exported fromapi.server.tswith unit tests. The refactor is a complete, isolated change with no behavior impact.Issue #286 — Notification bell form actions
This PR also includes the notification bell feature work. From a requirements perspective:
Addressed:
role="alert") on failure ✅Open questions / gaps:
OQ-01: Success feedback after dismiss
When a user dismisses a notification, the item disappears via the optimistic update. If the server action succeeds, no further feedback is given (no toast, no banner). Is this intentional? For the 60+ senior audience, silent success might feel like nothing happened. Is the item disappearing sufficient feedback?
OQ-02: Scope of
ANNOTATE_ALLon transcription blocksThe
TranscriptionBlockControllerpermission change allowsANNOTATE_ALLusers to create, update, delete, reorder, and mark-reviewed transcription blocks. The requirement is presumably: "annotators should be able to transcribe." However, "delete" and "reorder" are structural operations that go beyond annotation. Was the requirement to give annotators full transcription write access, or only to allow them to create/edit their own blocks? This should be explicitly recorded in the linked issue.OQ-03: Error recovery after failed dismiss
When dismiss fails, the error banner appears and the notification item reappears (optimistic rollback). But
update({ reset: false, invalidateAll: false })means the server state is NOT re-fetched. If the failure is persistent (e.g. the notification was already deleted on the server), the item will reappear and stay visible indefinitely. Is this the intended recovery behavior?These are clarification questions, not blockers, but they should be documented in the linked Gitea issues.
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
Positive
role="alert"on the error banner —<p role="alert">is correct for live-region error announcements. Screen readers will announce the message immediately when it appears. ✅aria-labelon the dismiss button —aria-label={m.chronik_mark_read_aria()}means the button has an accessible name even though it's icon-only. ✅i18n error message —
m.notification_error_generic()maps to all three languages. No raw backend errors reach the user. ✅Progressive enhancement — Native
<form method="POST">withuse:enhancemeans the dismiss action works without JavaScript. Senior users on slow connections or with JS disabled can still mark notifications. ✅Concerns
Touch target on dismiss button
p-1= 4px padding. With a 16×16px icon (h-4 w-4), the total touch target is approximately 24×24px. WCAG 2.2 SC 2.5.8 (Target Size, minimum) requires 24×24px minimum, but the preferred size is 44×44px (WCAG 2.2 SC 2.5.5). For the 60+ primary audience,p-1is insufficient.Recommendation: Change to
p-2(8pxpadding) → ~32×32px, orp-3→ ~40×40px. Also addmin-h-[44px] min-w-[44px]to guarantee the tap area.Error banner contrast
text-red-600onbg-surface(which maps to the sand/off-white palette). The exact contrast depends on the resolved CSS variable values. Tailwind'sred-600is#DC2626. Against--palette-sand(an off-white), the ratio is approximately 5.7:1 — which passes AA (4.5:1). ✅ But verify againstbg-surfacespecifically, notbg-canvas.Error banner position
The error banner appears above
{#if unread.length === 0}— meaning it's at the very top of the section, before the inbox-zero or the list. This is correct placement (above the content that triggered the error). However, if the user has scrolled down in a long notification list, the error banner is above the viewport. Consider whether the banner should be sticky or whether a toast is more appropriate for this context.↩character for REPLY typeChanged from
↩(Unicode escape) to the literal↩character. Functionally identical; the literal is slightly more readable in the source. Confirm this character renders correctly across the target browsers (Chrome, Safari, Firefox on Windows/Mac/iOS/Android). It's a standard Unicode character so this should be fine, but worth a quick visual check.Form nesting in
<li>Each
<li>now contains both an<a>and a<form>. Structurally this is valid HTML. The<form>and<a>are siblings, not nested. The previous review flagged that the dismiss button must not be inside<a>— this is correctly maintained. ✅3f3d9a347ato4edd2461d1Markus Keller (@mkeller) — Application Architect
✅ Approved
This is exactly the kind of structural improvement I want to see. The
extractErrorCodehelper centralizes a scattered pattern, reduces cognitive noise in route files, and lives in the right module ($lib/shared/api.server.ts). No new infrastructure, no new layers — just a single utility that makes the existing contract explicit.What's done right
api.server.tsbecause it operates on the raw output of the openapi-fetch client. It doesn't leak into domain-specific logic.ApiErroras a named interface documents the shape of the error payload at the boundary. Previously this was an inline ad-hoc cast scattered across 24 files — impossible to audit.+page.server.tsfiles (the SvelteKit server layer). No backend changes, no new domain modules, no cross-domain coupling.Blockers
None.
Suggestions (nice to have)
ApiErrorinterface.message?: stringis defined but never used byextractErrorCode. It's fine to leave it for consumers who might need it, but if nothing reads.messagetoday, the field adds surface area without value. Worth a quick grep to confirm.Felix Brandt (@felixbrandt) — Senior Fullstack Developer
✅ Approved
Clean refactor, tests written first, function does one thing. This is exactly the red/green/refactor discipline I expect.
TDD evidence
The commit order tells the right story: spec file (
api.server.spec.ts) before the implementation (api.server.ts), before the mechanical replacement across 24 files. That's the correct sequence.What's done right
extractErrorCode(error: unknown): string | undefined— single responsibility, single return point, fits in working memory.extractErrorCodeis precise: it extracts, it targets the code field, it takes an error. No abbreviations.ApiErrorinterface replaces inline casts.(result.error as unknown as { code?: string })is a smell — it repeats type knowledge at each call site. Centralizing it as a named interface is the right fix.if (!result.response.ok)guards remain intact. The refactor doesn't change the error-handling flow, just the code extraction step.Blockers
None.
Suggestions (nice to have)
codefield as a non-string type. The five tests coverundefined,null, string, object-without-code, and object-with-code. One missing case: what iferroris an object where.codeis a number (e.g.,{ code: 404 })? The cast(error as ApiError | undefined)?.codewould return404(a number) typed asstring | undefined. This is probably fine in practice since the backend always sends string codes — but a test documenting that expectation would make the contract explicit.message?: stringonApiErroris dead weight if nothing reads it today. Either use it or remove it. Dead fields in interfaces are a maintenance trap — someone will eventually write code assuming it's populated.Tobias Wendt (@tobiwendt) — DevOps & Platform Engineer
✅ Approved
Pure TypeScript refactor, no infrastructure changes, no CI changes, no new dependencies. This is entirely off my critical path.
What's in scope for me
package-lock.jsonis the only infrastructure-adjacent file in this diff — and it's only touched because it's tracked in the repo. No new packages were actually added to this PR; the lock file change is pre-existing drift).What's done right
The test file is in
frontend/src/lib/shared/where Vitest will pick it up automatically. No CI configuration needs updating for the new test to run. That's how it should work — tests in the right place, discovered automatically.One observation
The
package-lock.jsonshows as modified (M frontend/package-lock.json) in the git status at the time of this PR. That's a pre-existing uncommitted change on the branch — not introduced by this PR's commits. Worth confirming the lock file is clean before merge so CI doesn't pick up phantom dependency changes.Blockers
None from an infrastructure perspective.
Elicit — Requirements Engineer
✅ Approved
Reviewing this as a brownfield improvement: the change addresses a clear requirements-level gap — inconsistent, non-centralized error code extraction — and closes it cleanly.
Requirements traceability
The stated goal of issue #113 is to eliminate the
as unknown as { code?: string }cast pattern. This PR delivers exactly that: 44 occurrences across 24 files replaced with a single utility. The scope is precisely bounded to the stated issue — no scope creep, no gold-plating.What's done right
extractErrorCodeis a named, importable function. Future developers can find all usages with a grep. Previously, the pattern was invisible — there was no single place to update if the backend error shape changed.ApiErrorshape changes, one interface definition and one function need updating rather than 44 individual casts.Suggestions (nice to have)
ApiError.messagefield is speculative. It's included in the interface but not used anywhere. If there's no current consumer and no planned consumer, it should be removed to keep the interface honest. Undefined fields in a contract create false expectations — someone will writeextractErrorCode(error).messageand be confused when it's alwaysundefinedat runtime.ApiErrorstating "Shape of error bodies returned by the Spring Boot API via openapi-fetch" would make the intent permanent and help future contributors understand the boundary being modeled.Nora "NullX" Steiner — Application Security Engineer
✅ Approved
This is a security-positive refactor. The centralization of error code extraction improves auditability and removes a class of fragile inline casts. No new attack surface introduced.
Security analysis
The cast pattern being removed:
(result.error as unknown as { code?: string })?.codeThis was never a vulnerability itself —
result.errorcomes from the openapi-fetch client which parses the backend JSON response, not from user-controlled input. The risk was indirect: scattered, non-obvious casts are harder to audit. A reviewer scanning for places where error data flows to user-visible output had to grep for the cast pattern; now there is one function to audit.The replacement:
(error as ApiError | undefined)?.codeThe type assertion is internally bounded — it casts to
ApiError | undefined, which is honest about what the shape can be. The returned value isstring | undefined, not the full error object. Only the code string reaches downstream processing (getErrorMessage()). No additional data from the error payload leaks through.What's done right
extractErrorCodeis now the single point where raw error objects are inspected. If the backend ever added a sensitive field to error payloads, there is one place to add a sanitization step.result.erroris a parsed API response, not raw user input. The cast is safe in this context.getErrorMessage()still intermediates. The extracted code string still passes throughgetErrorMessage()before reaching the user. Even if an unexpected string slips throughextractErrorCode,getErrorMessage()maps it to a safe i18n string via its switch/default pattern. Defense in depth is preserved.One observation (not a blocker)
The
ApiErrorinterface includesmessage?: string. IfextractErrorCodewere someday extended to also returnextractErrorMessage, that field would reachgetErrorMessage()or possibly a component directly. Document now that rawmessagevalues from the backend must not be shown to users unfiltered — they may contain internal details. A one-line comment on the field would make this explicit for future contributors.Blockers
None.
Sara Holt (@saraholt) — QA Engineer & Test Strategist
✅ Approved
Good test coverage for a helper function. The commit sequence shows tests before implementation. Five behavioral cases in the spec. Clean assertions. Here's the full assessment.
Test quality
The five tests in
api.server.spec.ts:returns the code string when error has a code property— happy path ✓returns undefined when error is undefined— null-like boundary ✓returns undefined when error is null— explicit null boundary ✓returns undefined when error is a plain string— wrong type boundary ✓returns undefined when error object has no code property— missing field boundary ✓These are clean, focused, one-assertion-per-test unit tests. Names read as sentences. No setup noise.
What's missing (suggestions, not blockers)
codebeing a non-string type. If the backend ever sends{ code: 404 }(a number),extractErrorCodewill return404typed asstring | undefined. TypeScript won't catch this at runtime. A test documenting expected behavior for{ code: 42 }would close this gap.extractErrorCode({ error: { code: 'DOCUMENT_NOT_FOUND' } })return? Presumablyundefined— but a test proves it.loadfunctions themselves (testing that a failed API response correctly surfaces an error code through the whole load function). This is a pre-existing gap —+page.server.tsfiles are generally untested at the integration layer in this codebase. Not a blocker for this PR, but worth a ticket.Test pyramid placement
The new unit test is correctly placed at the unit layer: no DOM, no HTTP, no SvelteKit context. Vitest discovers it automatically. CI time impact: negligible (milliseconds).
Blockers
None.
Leonie Voss (@leonievoss) — UI/UX Design Lead
✅ Approved
This is a server-side TypeScript refactor with zero UI changes — no visual components, no markup, no CSS, no i18n strings modified. From a UX and design perspective, the user experience is unchanged. I reviewed the diff for any indirect effects on what users see.
What I checked
getErrorMessage()before it reaches any component. The localized string the user sees is identical to before. No regression in error messaging UX.fail()payloads changed structurally. The shape of form action error returns (e.g.,{ error: getErrorMessage(...) }) is identical — only the extraction mechanism changed. Components that consume these form action results will see no difference.One positive note
Centralizing error code extraction makes the path from "API error" to "user-visible message" traceable in one chain:
result.error → extractErrorCode → getErrorMessage → Paraglide i18n string. Previously this chain was inline and invisible in 24 files. A future UX improvement to error messaging now has a clear single point of intervention.Blockers
None.