From ee279a29e5cef078c8ff478aace7409301bcdd88 Mon Sep 17 00:00:00 2001 From: Marcel Date: Sun, 15 Mar 2026 20:47:01 +0000 Subject: [PATCH] feat: edit persons --- .../controller/PersonController.java | 32 ++- .../familienarchiv/model/Document.java | 4 +- .../repository/PersonRepository.java | 22 ++ .../service/MassImportService.java | 57 ++--- .../familienarchiv/service/PersonService.java | 50 ++++ frontend/src/lib/generated/api.ts | 163 +++++++++---- .../src/routes/persons/[id]/+page.server.ts | 48 +++- frontend/src/routes/persons/[id]/+page.svelte | 216 ++++++++++++++---- 8 files changed, 468 insertions(+), 124 deletions(-) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java 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 395acfeb..32a0a59f 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/PersonController.java @@ -6,16 +6,14 @@ import org.raddatz.familienarchiv.model.Document; import org.raddatz.familienarchiv.model.Person; import org.raddatz.familienarchiv.repository.DocumentRepository; import org.raddatz.familienarchiv.repository.PersonRepository; +import org.raddatz.familienarchiv.service.PersonService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; import org.springframework.web.server.ResponseStatusException; import java.util.List; +import java.util.Map; import java.util.UUID; @RestController @@ -25,6 +23,7 @@ public class PersonController { private final PersonRepository personRepository; private final DocumentRepository documentRepository; + private final PersonService personService; @GetMapping public ResponseEntity> getPersons(@RequestParam(required = false) String q) { @@ -34,7 +33,6 @@ public class PersonController { return ResponseEntity.ok(personRepository.findAllByOrderByLastNameAscFirstNameAsc()); } - @GetMapping("/{id}") public Person getPerson(@PathVariable UUID id) { return personRepository.findById(id) @@ -45,4 +43,24 @@ public class PersonController { public List getPersonDocuments(@PathVariable UUID id) { return documentRepository.findBySenderId(id); } -} \ No newline at end of file + + @PutMapping("/{id}") + public ResponseEntity updatePerson(@PathVariable UUID id, @RequestBody Map body) { + String firstName = body.get("firstName"); + String lastName = body.get("lastName"); + 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"))); + } + + @PostMapping("/{id}/merge") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void mergePerson(@PathVariable UUID id, @RequestBody Map body) { + String targetIdStr = body.get("targetPersonId"); + if (targetIdStr == null || targetIdStr.isBlank()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "targetPersonId fehlt"); + } + personService.mergePersons(id, UUID.fromString(targetIdStr)); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java index 54cc7ff1..28099a43 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/Document.java @@ -75,8 +75,9 @@ public class Document { @UpdateTimestamp private LocalDateTime updatedAt; - @ManyToMany + @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "document_receivers", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "person_id")) + @Builder.Default private Set receivers = new HashSet<>(); @ManyToOne @@ -85,5 +86,6 @@ public class Document { @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "document_tags", joinColumns = @JoinColumn(name = "document_id"), inverseJoinColumns = @JoinColumn(name = "tag_id")) + @Builder.Default private Set tags = new HashSet<>(); } diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java index 9b7fc9a0..b57af49c 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PersonRepository.java @@ -6,6 +6,7 @@ import java.util.UUID; import org.raddatz.familienarchiv.model.Person; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; @@ -26,4 +27,25 @@ public interface PersonRepository extends JpaRepository { // Lookup by full alias string, used during ODS mass import Optional findByAliasIgnoreCase(String alias); + + // --- Merge helpers (native SQL to bypass JPA entity layer) --- + + @Modifying + @Query(value = "UPDATE documents SET sender_id = :target WHERE sender_id = :source", nativeQuery = true) + void reassignSender(@Param("source") UUID source, @Param("target") UUID target); + + @Modifying + @Query(value = """ + INSERT INTO document_receivers (document_id, person_id) + SELECT document_id, :target FROM document_receivers + WHERE person_id = :source + AND document_id NOT IN ( + SELECT document_id FROM document_receivers WHERE person_id = :target + ) + """, nativeQuery = true) + void insertMissingReceiverReference(@Param("source") UUID source, @Param("target") UUID target); + + @Modifying + @Query(value = "DELETE FROM document_receivers WHERE person_id = :source", nativeQuery = true) + void deleteReceiverReferences(@Param("source") UUID source); } \ No newline at end of file diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java index 98db4b45..c076078b 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/MassImportService.java @@ -246,18 +246,17 @@ public class MassImportService { String filename = index.contains(".") ? index : index + ".pdf"; Optional fileOnDisk = findFileRecursive(filename); - if (fileOnDisk.isPresent()) { - importSingleDocument(cells, fileOnDisk.get(), filename, index); - count++; - } else { - log.warn("Datei nicht gefunden: {}", filename); + if (fileOnDisk.isEmpty()) { + log.warn("Datei nicht gefunden, importiere nur Metadaten: {}", filename); } + importSingleDocument(cells, fileOnDisk, filename, index); + count++; } return count; } @Transactional - protected void importSingleDocument(List cells, File file, String originalFilename, String index) { + protected void importSingleDocument(List cells, Optional file, String originalFilename, String index) { Optional existing = documentRepository.findByOriginalFilename(originalFilename); if (existing.isPresent() && existing.get().getStatus() != DocumentStatus.PLACEHOLDER) { log.info("Dokument {} existiert bereits, überspringe.", originalFilename); @@ -274,25 +273,31 @@ public class MassImportService { String summary = getCell(cells, colSummary); String transcription = getCell(cells, colTranscription); - String contentType; - try { - contentType = Files.probeContentType(file.toPath()); - } catch (IOException e) { - contentType = null; - } - if (contentType == null) contentType = "application/octet-stream"; + String s3Key = null; + String contentType = null; + DocumentStatus status = DocumentStatus.PLACEHOLDER; - String s3Key = "documents/" + UUID.randomUUID() + "_" + file.getName(); - try { - s3Client.putObject(PutObjectRequest.builder() - .bucket(bucketName) - .key(s3Key) - .contentType(contentType) - .build(), - RequestBody.fromFile(file)); - } catch (Exception e) { - log.error("S3 Upload Fehler für {}", file.getName(), e); - return; + if (file.isPresent()) { + try { + contentType = Files.probeContentType(file.get().toPath()); + } catch (IOException e) { + contentType = null; + } + if (contentType == null) contentType = "application/octet-stream"; + + s3Key = "documents/" + UUID.randomUUID() + "_" + file.get().getName(); + try { + s3Client.putObject(PutObjectRequest.builder() + .bucket(bucketName) + .key(s3Key) + .contentType(contentType) + .build(), + RequestBody.fromFile(file.get())); + status = DocumentStatus.UPLOADED; + } catch (Exception e) { + log.error("S3 Upload Fehler für {}", file.get().getName(), e); + return; + } } Person sender = senderRaw.isBlank() ? null : findOrCreatePerson(senderRaw); @@ -313,7 +318,7 @@ public class MassImportService { doc.setTitle(buildTitle(index, date, location)); doc.setFilePath(s3Key); doc.setContentType(contentType); - doc.setStatus(DocumentStatus.UPLOADED); + doc.setStatus(status); doc.setArchiveBox(archiveBox.isBlank() ? null : archiveBox); doc.setArchiveFolder(archiveFolder.isBlank() ? null : archiveFolder); doc.setDocumentDate(date); @@ -325,7 +330,7 @@ public class MassImportService { if (tag != null) doc.getTags().add(tag); documentRepository.save(doc); - log.info("Importiert: {}", originalFilename); + log.info("Importiert{}: {}", file.isEmpty() ? " (nur Metadaten)" : "", originalFilename); } // --- Helpers --- diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java new file mode 100644 index 00000000..670e3385 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java @@ -0,0 +1,50 @@ +package org.raddatz.familienarchiv.service; + +import lombok.RequiredArgsConstructor; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.repository.PersonRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class PersonService { + + private final PersonRepository personRepository; + + @Transactional + public Person updatePerson(UUID id, String firstName, String lastName, String alias) { + 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()); + return personRepository.save(person); + } + + @Transactional + public void mergePersons(UUID sourceId, UUID targetId) { + if (sourceId.equals(targetId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Quelle und Ziel dürfen nicht identisch sein"); + } + personRepository.findById(sourceId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Quell-Person nicht gefunden")); + personRepository.findById(targetId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Ziel-Person nicht gefunden")); + + // Reassign sender references + personRepository.reassignSender(sourceId, targetId); + + // Add target as receiver where source is receiver but target is not yet + personRepository.insertMissingReceiverReference(sourceId, targetId); + + // Remove all remaining source receiver references (duplicates already handled) + personRepository.deleteReceiverReferences(sourceId); + + personRepository.deleteById(sourceId); + } +} diff --git a/frontend/src/lib/generated/api.ts b/frontend/src/lib/generated/api.ts index 779b4ccc..8f177766 100644 --- a/frontend/src/lib/generated/api.ts +++ b/frontend/src/lib/generated/api.ts @@ -20,6 +20,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getPerson"]; + put: operations["updatePerson"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/documents/{id}": { parameters: { query?: never; @@ -52,6 +68,22 @@ export interface paths { patch?: never; trace?: never; }; + "/api/persons/{id}/merge": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["mergePerson"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/groups": { parameters: { query?: never; @@ -148,22 +180,6 @@ export interface paths { patch?: never; trace?: never; }; - "/api/persons/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get: operations["getPerson"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/api/persons/{id}/documents": { parameters: { query?: never; @@ -269,6 +285,13 @@ export interface components { id?: string; name?: string; }; + Person: { + /** Format: uuid */ + id?: string; + firstName?: string; + lastName?: string; + alias?: string; + }; DocumentUpdateDTO: { title?: string; /** Format: date */ @@ -287,6 +310,7 @@ export interface components { id?: string; title?: string; filePath?: string; + contentType?: string; originalFilename?: string; /** @enum {string} */ status?: "PLACEHOLDER" | "UPLOADED" | "TRANSCRIBED" | "REVIEWED" | "ARCHIVED"; @@ -294,6 +318,8 @@ export interface components { documentDate?: string; location?: string; documentLocation?: string; + archiveBox?: string; + archiveFolder?: string; transcription?: string; summary?: string; /** Format: date-time */ @@ -304,13 +330,6 @@ export interface components { sender?: components["schemas"]["Person"]; tags?: components["schemas"]["Tag"][]; }; - Person: { - /** Format: uuid */ - id?: string; - firstName?: string; - lastName?: string; - alias?: string; - }; CreateUserRequest: { username?: string; email?: string; @@ -404,6 +423,56 @@ export interface operations { }; }; }; + getPerson: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Person"]; + }; + }; + }; + }; + updatePerson: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["Person"]; + }; + }; + }; + }; getDocument: { parameters: { query?: never; @@ -496,6 +565,32 @@ export interface operations { }; }; }; + mergePerson: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + [key: string]: string; + }; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; getAllGroups: { parameters: { query?: never; @@ -670,28 +765,6 @@ export interface operations { }; }; }; - getPerson: { - parameters: { - query?: never; - header?: never; - path: { - id: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description OK */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "*/*": components["schemas"]["Person"]; - }; - }; - }; - }; getPersonDocuments: { parameters: { query?: never; diff --git a/frontend/src/routes/persons/[id]/+page.server.ts b/frontend/src/routes/persons/[id]/+page.server.ts index a10830c7..378c371f 100644 --- a/frontend/src/routes/persons/[id]/+page.server.ts +++ b/frontend/src/routes/persons/[id]/+page.server.ts @@ -1,4 +1,4 @@ -import { error } from '@sveltejs/kit'; +import { error, fail, redirect } from '@sveltejs/kit'; import { createApiClient } from '$lib/api.server'; import { getErrorMessage } from '$lib/errors'; @@ -21,3 +21,49 @@ export async function load({ params, fetch }) { documents: docsResult.data ?? [] }; } + +export const actions = { + update: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const firstName = formData.get('firstName')?.toString().trim(); + const lastName = formData.get('lastName')?.toString().trim(); + const alias = formData.get('alias')?.toString().trim() || undefined; + + if (!firstName || !lastName) { + return fail(400, { updateError: 'Vor- und Nachname sind Pflichtfelder.' }); + } + + const api = createApiClient(fetch); + const { error: apiError } = await api.PUT('/api/persons/{id}', { + params: { path: { id: params.id } }, + body: { firstName, lastName, ...(alias ? { alias } : {}) } + }); + + if (apiError) { + return fail(400, { updateError: 'Speichern fehlgeschlagen.' }); + } + + return { updated: true }; + }, + + merge: async ({ request, params, fetch }) => { + const formData = await request.formData(); + const targetPersonId = formData.get('targetPersonId')?.toString(); + + if (!targetPersonId) { + return fail(400, { mergeError: 'Bitte eine Zielperson auswählen.' }); + } + + const api = createApiClient(fetch); + const { error: apiError } = await api.POST('/api/persons/{id}/merge', { + params: { path: { id: params.id } }, + body: { targetPersonId } + }); + + if (apiError) { + return fail(400, { mergeError: 'Zusammenführen fehlgeschlagen.' }); + } + + throw redirect(303, `/persons/${targetPersonId}`); + } +}; diff --git a/frontend/src/routes/persons/[id]/+page.svelte b/frontend/src/routes/persons/[id]/+page.svelte index eac716fa..fd70832d 100644 --- a/frontend/src/routes/persons/[id]/+page.svelte +++ b/frontend/src/routes/persons/[id]/+page.svelte @@ -1,6 +1,28 @@
@@ -15,58 +37,169 @@
-
-
+ {#if editMode} + +
+
+

Person bearbeiten

- -
-
- {person.firstName[0]}{person.lastName[0]} -
-
- - -
-

- {person.firstName} {person.lastName} -

- -
-
- Voller Name - {person.firstName} {person.lastName} -
- - {#if person.alias} -
- Rufname / Alias - "{person.alias}" -
+ {#if form?.updateError} +

{form.updateError}

{/if} - {#if person.birthDate} -
- Geburtsdatum - {person.birthDate} +
+
+ + +
+
+ + +
+
+ + +
- {/if} - +
+ + +
+
+ + {:else} + +
+
+
+ {person.firstName[0]}{person.lastName[0]} +
+
+ +
+
+

+ {person.firstName} {person.lastName} +

+ +
+ +
+
+ Voller Name + {person.firstName} {person.lastName} +
+ + {#if person.alias} +
+ Rufname / Alias + "{person.alias}" +
+ {/if} +
-
+ {/if} +
+
+ + +
+
+

Person zusammenführen

+

+ Diese Person wird in die gewählte Zielperson überführt. Alle Dokumente und Verknüpfungen werden übertragen, danach wird diese Person gelöscht. +

+ + {#if form?.mergeError} +

{form.mergeError}

+ {/if} + +
+ + +
+
+ { mergeTargetId = e.detail.value; showMergeConfirm = false; }} + /> +
+ + {#if !showMergeConfirm} + + {:else} +
+ + +
+ {/if} +
+ + {#if showMergeConfirm} +

+ Achtung: Diese Aktion ist nicht rückgängig zu machen. {person.firstName} {person.lastName} wird gelöscht. +

+ {/if} +
-

- Gesendete Dokumente -

+

Gesendete Dokumente

{documents.length} @@ -83,12 +216,9 @@
-
- -
{doc.title || doc.originalFilename} @@ -102,12 +232,10 @@
- -