feat(persons): add birth/death year fields (issue #18) #28
@@ -55,7 +55,11 @@ public class PersonController {
|
|||||||
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
if (firstName == null || firstName.isBlank() || lastName == null || lastName.isBlank()) {
|
||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Vor- und Nachname sind Pflichtfelder");
|
||||||
}
|
}
|
||||||
return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"), body.get("notes")));
|
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"), body.get("notes"), birthYear, deathYear));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{id}/merge")
|
@PostMapping("/{id}/merge")
|
||||||
|
|||||||
@@ -32,4 +32,7 @@ public class Person {
|
|||||||
// Optional: Free-text biographical notes
|
// Optional: Free-text biographical notes
|
||||||
@Column(columnDefinition = "TEXT")
|
@Column(columnDefinition = "TEXT")
|
||||||
private String notes;
|
private String notes;
|
||||||
|
|
||||||
|
private Integer birthYear;
|
||||||
|
private Integer deathYear;
|
||||||
}
|
}
|
||||||
@@ -58,13 +58,18 @@ public class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Person updatePerson(UUID id, String firstName, String lastName, String alias, String notes) {
|
public Person updatePerson(UUID id, String firstName, String lastName, String alias, String notes, 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)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
|
||||||
person.setFirstName(firstName);
|
person.setFirstName(firstName);
|
||||||
person.setLastName(lastName);
|
person.setLastName(lastName);
|
||||||
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
|
person.setAlias(alias == null || alias.isBlank() ? null : alias.trim());
|
||||||
person.setNotes(notes == null || notes.isBlank() ? null : notes.trim());
|
person.setNotes(notes == null || notes.isBlank() ? null : notes.trim());
|
||||||
|
person.setBirthYear(birthYear);
|
||||||
|
person.setDeathYear(deathYear);
|
||||||
return personRepository.save(person);
|
return personRepository.save(person);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE persons ADD COLUMN birth_year INTEGER;
|
||||||
|
ALTER TABLE persons ADD COLUMN death_year INTEGER;
|
||||||
@@ -92,7 +92,7 @@ class PersonServiceTest {
|
|||||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
Person result = personService.updatePerson(id, "Anna", "Alt", null, "Some notes here.");
|
Person result = personService.updatePerson(id, "Anna", "Alt", null, "Some notes here.", null, null);
|
||||||
|
|
||||||
assertThat(result.getNotes()).isEqualTo("Some notes here.");
|
assertThat(result.getNotes()).isEqualTo("Some notes here.");
|
||||||
}
|
}
|
||||||
@@ -104,11 +104,49 @@ class PersonServiceTest {
|
|||||||
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
when(personRepository.findById(id)).thenReturn(Optional.of(person));
|
||||||
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
Person result = personService.updatePerson(id, "Anna", "Alt", null, " ");
|
Person result = personService.updatePerson(id, "Anna", "Alt", null, " ", null, null);
|
||||||
|
|
||||||
assertThat(result.getNotes()).isNull();
|
assertThat(result.getNotes()).isNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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, 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, 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, null, 1900, 1900);
|
||||||
|
|
||||||
|
assertThat(result.getBirthYear()).isEqualTo(1900);
|
||||||
|
assertThat(result.getDeathYear()).isEqualTo(1900);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── mergePersons ─────────────────────────────────────────────────────────
|
// ─── mergePersons ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -52,6 +52,28 @@ test.describe('Person detail', () => {
|
|||||||
await expect(page.getByLabel('Vorname')).not.toBeVisible();
|
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', () => {
|
test.describe('New person', () => {
|
||||||
|
|||||||
@@ -113,6 +113,11 @@
|
|||||||
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
"person_merge_warning": "Achtung: Diese Aktion ist nicht rückgängig zu machen.",
|
||||||
"person_label_notes": "Notizen",
|
"person_label_notes": "Notizen",
|
||||||
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
"person_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
|
||||||
|
"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_docs_heading": "Gesendete Dokumente",
|
||||||
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
"person_no_docs": "Diese Person ist noch nicht als Absender verknüpft.",
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,11 @@
|
|||||||
"person_merge_warning": "Warning: This action cannot be undone.",
|
"person_merge_warning": "Warning: This action cannot be undone.",
|
||||||
"person_label_notes": "Notes",
|
"person_label_notes": "Notes",
|
||||||
"person_placeholder_notes": "Biographical notes, remarks…",
|
"person_placeholder_notes": "Biographical notes, remarks…",
|
||||||
|
"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_docs_heading": "Sent documents",
|
||||||
"person_no_docs": "This person has not yet been linked as a sender.",
|
"person_no_docs": "This person has not yet been linked as a sender.",
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,11 @@
|
|||||||
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
"person_merge_warning": "Atención: Esta acción no se puede deshacer.",
|
||||||
"person_label_notes": "Notas",
|
"person_label_notes": "Notas",
|
||||||
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
"person_placeholder_notes": "Notas biográficas, observaciones…",
|
||||||
|
"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_docs_heading": "Documentos enviados",
|
||||||
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
"person_no_docs": "Esta persona aún no está vinculada como remitente.",
|
||||||
|
|
||||||
|
|||||||
@@ -308,6 +308,8 @@ export interface components {
|
|||||||
lastName: string;
|
lastName: string;
|
||||||
alias?: string;
|
alias?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
birthYear?: number;
|
||||||
|
deathYear?: number;
|
||||||
};
|
};
|
||||||
DocumentUpdateDTO: {
|
DocumentUpdateDTO: {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export const actions = {
|
|||||||
const lastName = formData.get('lastName')?.toString().trim();
|
const lastName = formData.get('lastName')?.toString().trim();
|
||||||
const alias = formData.get('alias')?.toString().trim() || undefined;
|
const alias = formData.get('alias')?.toString().trim() || undefined;
|
||||||
const notes = formData.get('notes')?.toString().trim() || undefined;
|
const notes = formData.get('notes')?.toString().trim() || undefined;
|
||||||
|
const birthYear = formData.get('birthYear')?.toString().trim() || undefined;
|
||||||
|
const deathYear = formData.get('deathYear')?.toString().trim() || undefined;
|
||||||
|
|
||||||
if (!firstName || !lastName) {
|
if (!firstName || !lastName) {
|
||||||
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
|
||||||
@@ -37,7 +39,13 @@ export const actions = {
|
|||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const { error: apiError } = await api.PUT('/api/persons/{id}', {
|
const { error: apiError } = await api.PUT('/api/persons/{id}', {
|
||||||
params: { path: { id: params.id } },
|
params: { path: { id: params.id } },
|
||||||
body: { firstName, lastName, ...(alias ? { alias } : {}), ...(notes ? { notes } : {}) }
|
body: {
|
||||||
|
firstName, lastName,
|
||||||
|
...(alias ? { alias } : {}),
|
||||||
|
...(notes ? { notes } : {}),
|
||||||
|
...(birthYear ? { birthYear } : {}),
|
||||||
|
...(deathYear ? { deathYear } : {})
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (apiError) {
|
if (apiError) {
|
||||||
|
|||||||
@@ -86,6 +86,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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="birthYear" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.person_label_birth_year()}</label>
|
||||||
|
<input
|
||||||
|
id="birthYear"
|
||||||
|
name="birthYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
value={person.birthYear ?? ''}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="deathYear" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.person_label_death_year()}</label>
|
||||||
|
<input
|
||||||
|
id="deathYear"
|
||||||
|
name="deathYear"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="2100"
|
||||||
|
placeholder={m.person_placeholder_year()}
|
||||||
|
value={person.deathYear ?? ''}
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label for="notes" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.person_label_notes()}</label>
|
<label for="notes" class="block text-xs font-bold uppercase tracking-widest text-gray-400 mb-1">{m.person_label_notes()}</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -147,6 +173,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if person.birthYear || person.deathYear}
|
||||||
|
<div>
|
||||||
|
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">
|
||||||
|
{#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}
|
||||||
|
</span>
|
||||||
|
<span class="block text-lg font-serif text-brand-navy">
|
||||||
|
{#if person.birthYear}* {person.birthYear}{/if}{#if person.birthYear && person.deathYear} {/if}{#if person.deathYear}† {person.deathYear}{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if person.notes}
|
{#if person.notes}
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">{m.person_label_notes()}</span>
|
<span class="block text-xs font-bold font-sans text-gray-400 uppercase tracking-widest mb-1">{m.person_label_notes()}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user