Files
familienarchiv/frontend/src/lib/shared/errors.ts
marcel 8558567688
All checks were successful
CI / Unit & Component Tests (push) Successful in 5m10s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 5m14s
CI / fail2ban Regex (push) Successful in 50s
CI / Semgrep Security Scan (push) Successful in 27s
CI / Compose Bucket Idempotency (push) Successful in 1m5s
feat(relationship): editable relationships with LocalDate+DatePrecision dates and notes (#841)
Closes #837

Makes `PersonRelationship` fully editable (type, related person, dates, notes), migrates its dates from `Integer fromYear/toYear` to `LocalDate + DatePrecision` (mirroring the #773 person pattern, ADR-039 / V76), activates the previously-dead `notes` column, and gives the Zeitstrahl's derived **Heirat** events full date precision for free.

Both Open Decisions confirmed as adopted: **no `@Version`** (last-write-wins, single-writer archive) and **`DELETE` ownership-mismatch aligned 403 → 404** (anti-enumeration, matching the new `PUT`).

## What's in it
- **V78** migrates `person_relationships.from_year/to_year` → `from_date`/`to_date` + NOT-NULL `*_date_precision` (default `UNKNOWN`); pre-check abort on corrupt years, `YYYY-01-01`/`YEAR` backfill, 5 named CHECK constraints, year columns dropped.
- **`PUT /api/persons/{id}/relationships/{relId}`** (`@RequirePermission(WRITE_ALL)`) re-runs every create invariant (self / coherence / order / reverse-PARENT_OF / duplicate) and re-flags family membership; orientation preserved per viewpoint.
- New `ErrorCode.INVALID_RELATIONSHIP_DATES` registered in all four sites (§3.6).
- `TimelineEventService` sources the derived marriage date from `SPOUSE_OF.fromDate` + precision.
- Frontend: `RelationshipDateField` (DAY/MONTH/YEAR), upsert-capable `AddRelationshipForm` (pre-fill + notes + in-flight submit lock), `RelationshipChip` Edit affordance, `updateRelationship` server action, read-view date range + notes, `formatRelationshipDateRange` helper. `api.ts` regenerated.
- Docs: ADR-044, db-orm/db-relationships diagrams, DEPLOYMENT §5 deploy note, RTM REQ-001…REQ-019.

## Requirements
All 19 EARS requirements implemented red/green and marked `Done` in `.specify/rtm.md`.

## Test plan
- **Backend** (targeted, green): `RelationshipMigrationTest` (Testcontainers pg16, 8), `RelationshipServiceTest` (22), `RelationshipControllerTest` (15), `RelationshipServiceIntegrationTest` (real DB, 10), `DerivedEventsAssemblyTest` (17), `ArchitectureTest` (14); `clean package` builds.
- **Frontend** (green): `relationshipDates.spec.ts`, `AddRelationshipForm.svelte.spec.ts`, `RelationshipChip.svelte.spec.ts`, `PersonRelationshipsCard.svelte.test.ts`, `page.server.spec.ts`, `messages.spec.ts`. `npm run check` = 798 (below the ~834 baseline); `npm run lint` clean.

## Notes for reviewers
- **Spec deviation:** the edit form was built by making `AddRelationshipForm` upsert-capable rather than a duplicate `EditRelationshipForm` (DRY); RTM rows reference `AddRelationshipForm.svelte.spec.ts`.
- `api.ts` regenerated from the live spec; only relationship-relevant hunks remain (one springdoc `PageableObject` field-reorder pruned).
- **Deploy:** V78 is one-way and not rolling-deploy-safe — stop old JAR → start new JAR (Flyway runs first); targeted `pg_restore -t person_relationships` for rollback. No maintenance window.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Marcel <marcel@familienarchiv>
Reviewed-on: #841
2026-06-14 21:17:36 +02:00

236 lines
7.3 KiB
TypeScript

import * as m from '$lib/paraglide/messages.js';
/**
* Mirror of the backend ErrorCode enum.
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
*/
export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
| 'BIRTH_AFTER_DEATH'
| 'INVALID_DATE_PRECISION'
| 'INVALID_RELATIONSHIP_DATES'
| 'INVALID_DATE_RANGE'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
| 'FILE_UPLOAD_FAILED'
| 'UNSUPPORTED_FILE_TYPE'
| 'USER_NOT_FOUND'
| 'EMAIL_ALREADY_IN_USE'
| 'WRONG_CURRENT_PASSWORD'
| 'IMPORT_ALREADY_RUNNING'
| 'IMPORT_ARTIFACT_INVALID'
| 'INVALID_RESET_TOKEN'
| 'INVITE_NOT_FOUND'
| 'INVITE_EXHAUSTED'
| 'INVITE_REVOKED'
| 'INVITE_EXPIRED'
| 'GROUP_HAS_ACTIVE_INVITES'
| 'GROUP_NOT_FOUND'
| 'ANNOTATION_NOT_FOUND'
| 'ANNOTATION_UPDATE_FAILED'
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
| 'TRANSCRIPTION_BLOCK_CONFLICT'
| 'COMMENT_NOT_FOUND'
| 'OCR_SERVICE_UNAVAILABLE'
| 'OCR_JOB_NOT_FOUND'
| 'OCR_DOCUMENT_NOT_UPLOADED'
| 'OCR_PROCESSING_FAILED'
| 'TRAINING_ALREADY_RUNNING'
| 'OCR_TRAINING_CONFLICT'
| 'INVALID_TAG_COLOR'
| 'TAG_CYCLE_DETECTED'
| 'TAG_NOT_FOUND'
| 'TAG_MERGE_SELF'
| 'TAG_MERGE_INVALID_TARGET'
| 'RELATIONSHIP_NOT_FOUND'
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'JOURNEY_ITEM_NOT_FOUND'
| 'JOURNEY_ITEM_POSITION_CONFLICT'
| 'JOURNEY_AT_CAPACITY'
| 'JOURNEY_NOTE_TOO_LONG'
| 'JOURNEY_DOCUMENT_ALREADY_ADDED'
| 'GESCHICHTE_TYPE_IMMUTABLE'
| 'GESCHICHTE_TITLE_TOO_LONG'
| 'GESCHICHTE_INTRO_TOO_LONG'
| 'TIMELINE_EVENT_NOT_FOUND'
| 'TIMELINE_EVENT_CONFLICT'
| 'TIMELINE_TITLE_TOO_LONG'
| 'INVALID_CREDENTIALS'
| 'SESSION_EXPIRED'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'CSRF_TOKEN_MISSING'
| 'TOO_MANY_LOGIN_ATTEMPTS'
| 'VALIDATION_ERROR'
| 'BATCH_TOO_LARGE'
| 'BULK_EDIT_TOO_MANY_IDS'
| 'CONFLICT'
| 'INTERNAL_ERROR';
export interface BackendError {
code: ErrorCode;
message: string; // English developer message — not shown to users
}
/**
* Attempts to parse a backend ErrorResponse from a failed fetch response.
* Returns null if the body is not valid JSON or does not contain a code field.
*/
export async function parseBackendError(res: Response): Promise<BackendError | null> {
try {
const body = await res.json();
if (body && typeof body.code === 'string') {
return body as BackendError;
}
} catch {
// Body was not JSON
}
return null;
}
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
export function getErrorMessage(code: ErrorCode | string | undefined): string {
switch (code) {
case 'PERSON_NOT_FOUND':
return m.error_person_not_found();
case 'ALIAS_NOT_FOUND':
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
case 'BIRTH_AFTER_DEATH':
return m.error_birth_after_death();
case 'INVALID_DATE_PRECISION':
return m.error_invalid_date_precision();
case 'INVALID_RELATIONSHIP_DATES':
return m.error_invalid_relationship_dates();
case 'INVALID_DATE_RANGE':
return m.error_invalid_date_range();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':
return m.error_document_no_file();
case 'FILE_NOT_FOUND':
return m.error_file_not_found();
case 'FILE_UPLOAD_FAILED':
return m.error_file_upload_failed();
case 'UNSUPPORTED_FILE_TYPE':
return m.error_unsupported_file_type();
case 'USER_NOT_FOUND':
return m.error_user_not_found();
case 'EMAIL_ALREADY_IN_USE':
return m.error_email_already_in_use();
case 'WRONG_CURRENT_PASSWORD':
return m.error_wrong_current_password();
case 'IMPORT_ALREADY_RUNNING':
return m.error_import_already_running();
case 'IMPORT_ARTIFACT_INVALID':
return m.error_import_artifact_invalid();
case 'INVALID_RESET_TOKEN':
return m.error_invalid_reset_token();
case 'INVITE_NOT_FOUND':
return m.error_invite_not_found();
case 'INVITE_EXHAUSTED':
return m.error_invite_exhausted();
case 'INVITE_REVOKED':
return m.error_invite_revoked();
case 'INVITE_EXPIRED':
return m.error_invite_expired();
case 'GROUP_HAS_ACTIVE_INVITES':
return m.error_group_has_active_invites();
case 'GROUP_NOT_FOUND':
return m.error_group_not_found();
case 'ANNOTATION_NOT_FOUND':
return m.error_annotation_not_found();
case 'ANNOTATION_UPDATE_FAILED':
return m.error_annotation_update_failed();
case 'TRANSCRIPTION_BLOCK_NOT_FOUND':
return m.error_transcription_block_not_found();
case 'TRANSCRIPTION_BLOCK_CONFLICT':
return m.error_transcription_block_conflict();
case 'COMMENT_NOT_FOUND':
return m.error_comment_not_found();
case 'OCR_SERVICE_UNAVAILABLE':
return m.error_ocr_service_unavailable();
case 'OCR_JOB_NOT_FOUND':
return m.error_ocr_job_not_found();
case 'OCR_DOCUMENT_NOT_UPLOADED':
return m.error_ocr_document_not_uploaded();
case 'OCR_PROCESSING_FAILED':
return m.error_ocr_processing_failed();
case 'TRAINING_ALREADY_RUNNING':
return m.error_training_already_running();
case 'OCR_TRAINING_CONFLICT':
return m.error_internal_error();
case 'INVALID_TAG_COLOR':
return m.error_invalid_tag_color();
case 'TAG_CYCLE_DETECTED':
return m.error_tag_cycle_detected();
case 'TAG_NOT_FOUND':
return m.error_tag_not_found();
case 'TAG_MERGE_SELF':
return m.error_tag_merge_self();
case 'TAG_MERGE_INVALID_TARGET':
return m.error_tag_merge_invalid_target();
case 'RELATIONSHIP_NOT_FOUND':
return m.error_relationship_not_found();
case 'CIRCULAR_RELATIONSHIP':
return m.error_circular_relationship();
case 'DUPLICATE_RELATIONSHIP':
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'JOURNEY_ITEM_NOT_FOUND':
return m.error_journey_item_not_found();
case 'JOURNEY_ITEM_POSITION_CONFLICT':
return m.error_journey_item_position_conflict();
case 'JOURNEY_AT_CAPACITY':
return m.error_journey_at_capacity();
case 'JOURNEY_NOTE_TOO_LONG':
return m.error_journey_note_too_long();
case 'JOURNEY_DOCUMENT_ALREADY_ADDED':
return m.error_journey_document_already_added();
case 'GESCHICHTE_TYPE_IMMUTABLE':
return m.error_geschichte_type_immutable();
case 'GESCHICHTE_TITLE_TOO_LONG':
return m.error_geschichte_title_too_long();
case 'GESCHICHTE_INTRO_TOO_LONG':
return m.error_geschichte_intro_too_long();
case 'TIMELINE_EVENT_NOT_FOUND':
return m.error_timeline_event_not_found();
case 'TIMELINE_EVENT_CONFLICT':
return m.error_timeline_event_conflict();
case 'TIMELINE_TITLE_TOO_LONG':
return m.error_timeline_title_too_long();
case 'INVALID_CREDENTIALS':
return m.error_invalid_credentials();
case 'SESSION_EXPIRED':
return m.error_session_expired();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':
return m.error_unauthorized();
case 'FORBIDDEN':
return m.error_forbidden();
case 'CSRF_TOKEN_MISSING':
return m.error_csrf_token_missing();
case 'TOO_MANY_LOGIN_ATTEMPTS':
return m.error_too_many_login_attempts();
case 'VALIDATION_ERROR':
return m.error_validation_error();
case 'BATCH_TOO_LARGE':
return m.error_batch_too_large();
case 'BULK_EDIT_TOO_MANY_IDS':
return m.error_bulk_edit_too_many_ids();
case 'CONFLICT':
return m.error_conflict();
default:
return m.error_internal_error();
}
}