feat(persons): add notes field to person profile (issue #23)
Some checks failed
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

V5 Flyway migration adds TEXT notes column; Person entity, service, and
controller updated to persist notes. Frontend edit form adds textarea and
view mode renders the notes section. Backed by 2 new service unit tests
(persist + blank clears).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #27.
This commit is contained in:
Marcel
2026-03-19 21:33:56 +01:00
committed by marcel
parent c01a07bd82
commit 08f7ae9a5c
11 changed files with 60 additions and 3 deletions

View File

@@ -55,7 +55,7 @@ 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"))); return ResponseEntity.ok(personService.updatePerson(id, firstName.trim(), lastName.trim(), body.get("alias"), body.get("notes")));
} }
@PostMapping("/{id}/merge") @PostMapping("/{id}/merge")

View File

@@ -28,4 +28,8 @@ public class Person {
// Optional: Aliasse für die Suche (z.B. "Opa Hans") // Optional: Aliasse für die Suche (z.B. "Opa Hans")
private String alias; private String alias;
// Optional: Free-text biographical notes
@Column(columnDefinition = "TEXT")
private String notes;
} }

View File

@@ -58,12 +58,13 @@ public class PersonService {
} }
@Transactional @Transactional
public Person updatePerson(UUID id, String firstName, String lastName, String alias) { public Person updatePerson(UUID id, String firstName, String lastName, String alias, String notes) {
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());
return personRepository.save(person); return personRepository.save(person);
} }

View File

@@ -0,0 +1 @@
ALTER TABLE persons ADD COLUMN notes TEXT;

View File

@@ -83,6 +83,32 @@ class PersonServiceTest {
verify(personRepository).findByAliasIgnoreCase("Clara Cram"); verify(personRepository).findByAliasIgnoreCase("Clara Cram");
} }
// ─── updatePerson (notes) ────────────────────────────────────────────────
@Test
void updatePerson_persistsNotes() {
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, "Some notes here.");
assertThat(result.getNotes()).isEqualTo("Some notes here.");
}
@Test
void updatePerson_clearsNotes_whenBlank() {
UUID id = UUID.randomUUID();
Person person = Person.builder().id(id).firstName("Anna").lastName("Alt").notes("old notes").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, " ");
assertThat(result.getNotes()).isNull();
}
// ─── mergePersons ───────────────────────────────────────────────────────── // ─── mergePersons ─────────────────────────────────────────────────────────
@Test @Test

View File

@@ -111,6 +111,8 @@
"person_btn_merge": "Zusammenführen", "person_btn_merge": "Zusammenführen",
"person_btn_merge_confirm": "Ja, zusammenführen", "person_btn_merge_confirm": "Ja, zusammenführen",
"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_placeholder_notes": "Biographische Hinweise, Besonderheiten…",
"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.",

View File

@@ -111,6 +111,8 @@
"person_btn_merge": "Merge", "person_btn_merge": "Merge",
"person_btn_merge_confirm": "Yes, merge", "person_btn_merge_confirm": "Yes, merge",
"person_merge_warning": "Warning: This action cannot be undone.", "person_merge_warning": "Warning: This action cannot be undone.",
"person_label_notes": "Notes",
"person_placeholder_notes": "Biographical notes, remarks…",
"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.",

View File

@@ -111,6 +111,8 @@
"person_btn_merge": "Fusionar", "person_btn_merge": "Fusionar",
"person_btn_merge_confirm": "Sí, fusionar", "person_btn_merge_confirm": "Sí, fusionar",
"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_placeholder_notes": "Notas biográficas, observaciones…",
"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.",

View File

@@ -307,6 +307,7 @@ export interface components {
firstName: string; firstName: string;
lastName: string; lastName: string;
alias?: string; alias?: string;
notes?: string;
}; };
DocumentUpdateDTO: { DocumentUpdateDTO: {
title?: string; title?: string;

View File

@@ -28,6 +28,7 @@ export const actions = {
const firstName = formData.get('firstName')?.toString().trim(); const firstName = formData.get('firstName')?.toString().trim();
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;
if (!firstName || !lastName) { if (!firstName || !lastName) {
return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' }); return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' });
@@ -36,7 +37,7 @@ 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 } : {}) } body: { firstName, lastName, ...(alias ? { alias } : {}), ...(notes ? { notes } : {}) }
}); });
if (apiError) { if (apiError) {

View File

@@ -86,6 +86,16 @@
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 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>
<textarea
id="notes"
name="notes"
rows="4"
placeholder={m.person_placeholder_notes()}
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 resize-y"
>{person.notes ?? ''}</textarea>
</div>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
@@ -136,6 +146,13 @@
<span class="block text-lg font-serif text-brand-navy italic">"{person.alias}"</span> <span class="block text-lg font-serif text-brand-navy italic">"{person.alias}"</span>
</div> </div>
{/if} {/if}
{#if person.notes}
<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>
<p class="text-base font-serif text-brand-navy whitespace-pre-wrap">{person.notes}</p>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>