From b07391541b47fdac18213ce9c711389cf988b0c0 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 19 Mar 2026 21:45:02 +0100 Subject: [PATCH] feat(persons): add birth/death year fields (issue #18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V5 Flyway migration adds birth_year and death_year INTEGER columns. Service validates birthYear <= deathYear (400 otherwise). Frontend edit form adds year number inputs; view mode renders * year / † year. Backed by 3 backend service tests and 1 E2E test. Co-Authored-By: Claude Sonnet 4.6 --- .../controller/PersonController.java | 6 ++- .../raddatz/familienarchiv/model/Person.java | 3 ++ .../familienarchiv/service/PersonService.java | 7 +++- .../V5__add_birth_death_years_to_persons.sql | 2 + .../service/PersonServiceTest.java | 38 +++++++++++++++++++ frontend/e2e/persons.spec.ts | 22 +++++++++++ frontend/messages/de.json | 5 +++ frontend/messages/en.json | 5 +++ frontend/messages/es.json | 5 +++ frontend/src/lib/generated/api.ts | 2 + .../src/routes/persons/[id]/+page.server.ts | 9 ++++- frontend/src/routes/persons/[id]/+page.svelte | 37 ++++++++++++++++++ 12 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V5__add_birth_death_years_to_persons.sql diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java index 87be2361..a536533a 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -55,7 +55,11 @@ public class PersonController { if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder"); } - return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"))); + Integer birthYear = body.get("birthYear") != null && !body.get("birthYear").isBlank() + ? Integer.parseInt(body.get("birthYear")) : null; + Integer deathYear = body.get("deathYear") != null && !body.get("deathYear").isBlank() + ? Integer.parseInt(body.get("deathYear")) : null; + return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"), birthYear, deathYear)); } @PostMapping("/{id}/merge") diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java index aa704a29..7d40d68e 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Person.java @@ -28,4 +28,7 @@ public class Person { // Optional: Aliasse für die Suche (z.B. "Opa Hans") private String alias; + + private Integer birthYear; + private Integer deathYear; } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java index 8e7a2bc9..56cfcaed 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -58,12 +58,17 @@ public class PersonService { } @Transactional - public Person updatePerson(UUID id, String firstName, String lastName, String alias) { + public Person updatePerson(UUID id, String firstName, String lastName, String alias, Integer birthYear, Integer deathYear) { + if (birthYear != null && deathYear != null && birthYear > deathYear) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Geburtsjahr darf nicht nach dem Todesjahr liegen"); + } Person person = personRepository.findById(id) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden")); person.setFirstName(firstName); person.setLastName(lastName); person.setAlias(alias == null || alias.isBlank() ? null : alias.trim()); + person.setBirthYear(birthYear); + person.setDeathYear(deathYear); return personRepository.save(person); } diff --git a/backend/src/main/resources/db/migration/V5__add_birth_death_years_to_persons.sql b/backend/src/main/resources/db/migration/V5__add_birth_death_years_to_persons.sql new file mode 100644 index 00000000..31875721 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__add_birth_death_years_to_persons.sql @@ -0,0 +1,2 @@ +ALTER TABLE persons ADD COLUMN birth_year INTEGER; +ALTER TABLE persons ADD COLUMN death_year INTEGER; diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java index 2246106e..5691d583 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java @@ -83,6 +83,44 @@ class PersonServiceTest { verify(personRepository).findByAliasIgnoreCase("Clara Cram"); } + // ─── updatePerson (birth/death years) ──────────────────────────────────── + + @Test + void updatePerson_persistsBirthAndDeathYear() { + UUID id = UUID.randomUUID(); + Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); + when(personRepository.findById(id)).thenReturn(Optional.of(person)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Person result = personService.updatePerson(id, "Anna", "Alt", null, 1890, 1965); + + assertThat(result.getBirthYear()).isEqualTo(1890); + assertThat(result.getDeathYear()).isEqualTo(1965); + } + + @Test + void updatePerson_throwsBadRequest_whenBirthYearAfterDeathYear() { + UUID id = UUID.randomUUID(); + + assertThatThrownBy(() -> personService.updatePerson(id, "Anna", "Alt", null, 1970, 1950)) + .isInstanceOf(ResponseStatusException.class) + .extracting(e -> ((ResponseStatusException) e).getStatusCode().value()) + .isEqualTo(400); + } + + @Test + void updatePerson_allowsSameYear() { + UUID id = UUID.randomUUID(); + Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").build(); + when(personRepository.findById(id)).thenReturn(Optional.of(person)); + when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Person result = personService.updatePerson(id, "Anna", "Alt", null, 1900, 1900); + + assertThat(result.getBirthYear()).isEqualTo(1900); + assertThat(result.getDeathYear()).isEqualTo(1900); + } + // ─── mergePersons ───────────────────────────────────────────────────────── @Test diff --git a/frontend/e2e/persons.spec.ts b/frontend/e2e/persons.spec.ts index c6072347..bff25d38 100644 --- a/frontend/e2e/persons.spec.ts +++ b/frontend/e2e/persons.spec.ts @@ -52,6 +52,28 @@ test.describe('Person detail', () => { await expect(page.getByLabel('Vorname')).not.toBeVisible(); } }); + + test('birth and death year fields appear in edit mode and save correctly', async ({ page }) => { + await page.goto('/persons'); + const firstPerson = page.locator('a[href^="/persons/"]').first(); + await firstPerson.click(); + + const editBtn = page.getByRole('button', { name: /Bearbeiten/i }); + await editBtn.click(); + + await expect(page.getByLabel(/Geburtsjahr/i)).toBeVisible(); + await expect(page.getByLabel(/Todesjahr/i)).toBeVisible(); + + await page.getByLabel(/Geburtsjahr/i).fill('1890'); + await page.getByLabel(/Todesjahr/i).fill('1965'); + + await page.getByRole('button', { name: /Speichern/i }).click(); + + // After saving, the years should be shown in view mode + await expect(page.getByText('* 1890')).toBeVisible(); + await expect(page.getByText('† 1965')).toBeVisible(); + await page.screenshot({ path: 'test-results/e2e/person-birth-death-years.png' }); + }); }); test.describe('New person', () => { diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 9d994075..6ca7097b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -111,6 +111,11 @@ "person_btn_merge": "Zusammenführen", "person_btn_merge_confirm": "Ja, zusammenführen", "person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.", + "person_label_birth_year": "Geburtsjahr", + "person_label_death_year": "Todesjahr", + "person_placeholder_year": "z.B. 1923", + "person_year_error": "Bitte eine vierstellige Jahreszahl eingeben", + "person_years_error_order": "Geburtsjahr muss vor dem Todesjahr liegen", "person_docs_heading": "Gesendete Dokumente", "person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cb4ec322..d19d9631 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -111,6 +111,11 @@ "person_btn_merge": "Merge", "person_btn_merge_confirm": "Yes, merge", "person_merge_warning": "Warning: This action cannot be undone.", + "person_label_birth_year": "Birth year", + "person_label_death_year": "Death year", + "person_placeholder_year": "e.g. 1923", + "person_year_error": "Please enter a four-digit year", + "person_years_error_order": "Birth year must be before death year", "person_docs_heading": "Sent documents", "person_no_docs": "This person has not yet been linked as a sender.", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 90ce4108..04d02a68 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -111,6 +111,11 @@ "person_btn_merge": "Fusionar", "person_btn_merge_confirm": "Sí, fusionar", "person_merge_warning": "Atención: Esta acción no se puede deshacer.", + "person_label_birth_year": "Año de nacimiento", + "person_label_death_year": "Año de fallecimiento", + "person_placeholder_year": "p.ej. 1923", + "person_year_error": "Introduzca un año de cuatro dígitos", + "person_years_error_order": "El año de nacimiento debe ser anterior al año de fallecimiento", "person_docs_heading": "Documentos enviados", "person_no_docs": "Esta persona aún no está vinculada como remitente.", diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 9de906e7..99e96c38 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -307,6 +307,8 @@ export interface components { firstName: string; lastName: string; alias?: string; + birthYear?: number; + deathYear?: number; }; DocumentUpdateDTO: { title?: string; diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index daef64f9..0d1385e8 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -28,6 +28,8 @@ export const actions = { const firstName = formData.get('firstName')?.toString().trim(); const lastName = formData.get('lastName')?.toString().trim(); const alias = formData.get('alias')?.toString().trim() || undefined; + const birthYear = formData.get('birthYear')?.toString().trim() || undefined; + const deathYear = formData.get('deathYear')?.toString().trim() || undefined; if (!firstName || !lastName) { return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' }); @@ -36,7 +38,12 @@ export const actions = { const api = createApiClient(fetch); const { error: apiError } = await api.PUT('/api/persons/{id}', { params: { path: { id: params.id } }, - body: { firstName, lastName, ...(alias ? { alias } : {}) } + body: { + firstName, lastName, + ...(alias ? { alias } : {}), + ...(birthYear ? { birthYear } : {}), + ...(deathYear ? { deathYear } : {}) + } }); if (apiError) { diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index 3b7b8f3e..06602bde 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -82,6 +82,32 @@ class="block w-full border border-gray-300 rounded px-3 py-2 font-serif text-brand-navy focus:outline-none focus:border-brand-navy" /> +
+ + +
+
+ + +
@@ -126,6 +152,17 @@ "{person.alias}"
{/if} + + {#if person.birthYear || person.deathYear} +
+ + {#if person.birthYear && person.deathYear}{m.person_label_birth_year()} / {m.person_label_death_year()}{:else if person.birthYear}{m.person_label_birth_year()}{:else}{m.person_label_death_year()}{/if} + + + {#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear}  {/if}{#if person.deathYear}† {person.deathYear}{/if} + +
+ {/if} -- 2.49.1