refactor: enforce Controller → Service → Repository layering throughout backend

- Created TagService: encapsulates all tag find/create/update/delete operations
- Extended PersonService: added findAll(), getById(), getAllById(), findOrCreateByAlias()
- Extended UserService: added createGroup(), updateGroup(), deleteGroup(), getGroupById()
- DocumentService: replaced direct PersonRepository/TagRepository access with
  PersonService/TagService calls; added getDocumentById(), getDocumentsBySender(),
  getConversationFiltered(), deleteTagCascading()
- MassImportService: replaced PersonRepository/TagRepository with PersonService/TagService
- PersonController: removed direct repo injections, delegates to PersonService/DocumentService
- DocumentController: removed DocumentRepository injection, delegates to DocumentService
- TagController: removed TagRepository/DocumentRepository, delegates to TagService/DocumentService
- GroupController: removed UserGroupRepository injection, delegates to UserService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-17 08:49:33 +01:00
parent 97e5255d7f
commit 25e095ea47
9 changed files with 171 additions and 117 deletions

View File

@@ -9,16 +9,15 @@ import org.raddatz.familienarchiv.dto.DocumentUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.FileService;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
@@ -39,26 +38,21 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class DocumentController {
private final DocumentRepository documentRepository;
private final DocumentService documentService;
private final FileService fileService;
// --- DOWNLOAD ---
@GetMapping("/{id}/file")
public ResponseEntity<InputStreamResource> getDocumentFile(@PathVariable UUID id) {
// 1. Look up path in DB
Document doc = documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
Document doc = documentService.getDocumentById(id);
if (doc.getFilePath() == null) {
throw DomainException.notFound(ErrorCode.DOCUMENT_NO_FILE, "Document has no file attached: " + id);
}
// 2. Delegate Retrieval to FileService
try {
FileService.S3FileDownload download = fileService.downloadFile(doc.getFilePath());
// Prefer the content type stored at upload time; fall back to whatever S3 reports
String contentType = (doc.getContentType() != null && !doc.getContentType().isBlank())
? doc.getContentType()
: download.contentType();
@@ -75,8 +69,7 @@ public class DocumentController {
// --- METADATA ---
@GetMapping("/{id}")
public Document getDocument(@PathVariable UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
return documentService.getDocumentById(id);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -95,7 +88,7 @@ public class DocumentController {
@RequirePermission(Permission.WRITE_ALL)
public Document updateDocument(
@PathVariable UUID id,
@ModelAttribute DocumentUpdateDTO dto, // Bindet Form-Felder automatisch
@ModelAttribute DocumentUpdateDTO dto,
@RequestPart(value = "file", required = false) MultipartFile file) {
try {
return documentService.updateDocument(id, dto, file);
@@ -121,18 +114,8 @@ public class DocumentController {
@RequestParam UUID receiverId,
@RequestParam(required = false) LocalDate from,
@RequestParam(required = false) LocalDate to,
@RequestParam(defaultValue = "DESC") String dir // ASC oder DESC
) {
// 1. Standard-Datumswerte setzen
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
LocalDate dateTo = (to != null) ? to : LocalDate.now();
// 2. Sortierung
Sort.Direction direction = Sort.Direction.fromString(dir.toUpperCase());
Sort sort = Sort.by(direction, "documentDate");
// 3. Abfrage
return documentRepository.findConversation(
senderId, receiverId, dateFrom, dateTo, sort);
@RequestParam(defaultValue = "DESC") String dir) {
Sort sort = Sort.by(Sort.Direction.fromString(dir.toUpperCase()), "documentDate");
return documentService.getConversationFiltered(senderId, receiverId, from, to, sort);
}
}

View File

@@ -5,12 +5,9 @@ import java.util.UUID;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.model.UserGroup;
import org.raddatz.familienarchiv.repository.UserGroupRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.UserService;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -28,33 +25,22 @@ import lombok.RequiredArgsConstructor;
@RequirePermission(Permission.ADMIN_PERMISSION)
@RequiredArgsConstructor
public class GroupController {
private final UserGroupRepository groupRepository;
private final UserService userService;
@PostMapping("")
public ResponseEntity<UserGroup> createGroup(@RequestBody GroupDTO dto) {
UserGroup group = new UserGroup();
group.setName(dto.getName());
group.setPermissions(dto.getPermissions()); // Assuming entity has Set<String> or Set<Enum>
return ResponseEntity.ok(groupRepository.save(group));
return ResponseEntity.ok(userService.createGroup(dto));
}
@PatchMapping("/{id}")
public ResponseEntity<UserGroup> updateGroup(@PathVariable UUID id, @RequestBody GroupDTO dto) {
UserGroup group = groupRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.INTERNAL_ERROR, "Group not found: " + id));
if (dto.getName() != null)
group.setName(dto.getName());
if (dto.getPermissions() != null)
group.setPermissions(dto.getPermissions());
return ResponseEntity.ok(groupRepository.save(group));
return ResponseEntity.ok(userService.updateGroup(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteGroup(@PathVariable UUID id) {
groupRepository.deleteById(id);
userService.deleteGroup(id);
return ResponseEntity.ok().build();
}
@@ -62,5 +48,4 @@ public class GroupController {
public ResponseEntity<List<UserGroup>> getAllGroups() {
return ResponseEntity.ok(userService.getAllGroups());
}
}

View File

@@ -1,47 +1,41 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.Map;
import java.util.UUID;
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.DocumentService;
import org.raddatz.familienarchiv.service.PersonService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/persons")
@RequiredArgsConstructor
public class PersonController {
private final PersonRepository personRepository;
private final DocumentRepository documentRepository;
private final PersonService personService;
private final DocumentService documentService;
@GetMapping
public ResponseEntity<List<Person>> getPersons(@RequestParam(required = false) String q) {
if (q != null && !q.isBlank()) {
return ResponseEntity.ok(personRepository.searchByName(q));
}
return ResponseEntity.ok(personRepository.findAllByOrderByLastNameAscFirstNameAsc());
return ResponseEntity.ok(personService.findAll(q));
}
@GetMapping("/{id}")
public Person getPerson(@PathVariable UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
return personService.getById(id);
}
@GetMapping("/{id}/documents")
public List<Document> getPersonDocuments(@PathVariable UUID id) {
return documentRepository.findBySenderId(id);
return documentService.getDocumentsBySender(id);
}
@PostMapping

View File

@@ -4,14 +4,12 @@ import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Document;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.DocumentService;
import org.raddatz.familienarchiv.service.TagService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -21,46 +19,31 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/tags")
@RequiredArgsConstructor
public class TagController {
private final TagRepository tagRepository;
private final DocumentRepository documentRepository;
// Rename Tag
private final TagService tagService;
private final DocumentService documentService;
@PutMapping("/{id}")
@RequirePermission(Permission.ADMIN_TAG)
public ResponseEntity<Tag> updateTag(@PathVariable UUID id, @RequestBody Map<String, String> payload) {
Tag tag = tagRepository.findById(id).orElseThrow();
tag.setName(payload.get("name"));
return ResponseEntity.ok(tagRepository.save(tag));
return ResponseEntity.ok(tagService.update(id, payload.get("name")));
}
// Delete Tag
@DeleteMapping("/{id}")
@RequirePermission(Permission.ADMIN_TAG)
@Transactional
public ResponseEntity<Void> deleteTag(@PathVariable UUID id) {
Tag tag = tagRepository.findById(id).orElseThrow();
// Remove tag from all documents first to prevent FK constraint errors
List<Document> documents = documentRepository.findByTags_Id(id);
for (Document doc : documents) {
doc.getTags().remove(tag);
documentRepository.save(doc);
}
tagRepository.delete(tag);
documentService.deleteTagCascading(id);
return ResponseEntity.ok().build();
}
@GetMapping
public List<Tag> searchTags(@RequestParam(defaultValue = "") String query) {
return tagRepository.findByNameContainingIgnoreCase(query);
return tagService.search(query);
}
}
}

View File

@@ -9,8 +9,6 @@ import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.raddatz.familienarchiv.exception.DomainException;
@@ -36,9 +34,9 @@ import static org.raddatz.familienarchiv.repository.DocumentSpecifications.*;
public class DocumentService {
private final DocumentRepository documentRepository;
private final PersonRepository personRepository;
private final PersonService personService;
private final FileService fileService;
private final TagRepository tagRepository;
private final TagService tagService;
/**
* Lädt eine Datei hoch.
@@ -109,12 +107,12 @@ public class DocumentService {
// Sender
if (dto.getSenderId() != null) {
doc.setSender(personRepository.findById(dto.getSenderId()).orElse(null));
doc.setSender(personService.getById(dto.getSenderId()));
}
// Empfänger
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
doc.setReceivers(new HashSet<>(personRepository.findAllById(dto.getReceiverIds())));
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
}
// Datei
@@ -153,17 +151,14 @@ public class DocumentService {
// 2. Sender verknüpfen
if (dto.getSenderId() != null) {
Person sender = personRepository.findById(dto.getSenderId()).orElse(null);
doc.setSender(sender);
doc.setSender(personService.getById(dto.getSenderId()));
} else {
doc.setSender(null);
}
// 3. Empfänger verknüpfen
if (dto.getReceiverIds() != null && !dto.getReceiverIds().isEmpty()) {
List<Person> receivers = personRepository.findAllById(dto.getReceiverIds());
doc.setReceivers(new HashSet<>(receivers));
doc.setReceivers(new HashSet<>(personService.getAllById(dto.getReceiverIds())));
} else {
doc.getReceivers().clear(); // Alle entfernen
}
@@ -195,11 +190,7 @@ public class DocumentService {
if (cleanName.isEmpty())
continue;
// Find existing or Create new
Tag tag = tagRepository.findByNameIgnoreCase(cleanName)
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
newTags.add(tag);
newTags.add(tagService.findOrCreate(cleanName));
}
doc.setTags(newTags);
@@ -253,4 +244,28 @@ public class DocumentService {
return documentRepository.findAll(conversation, Sort.by(Sort.Direction.ASC, "documentDate"));
}
public Document getDocumentById(UUID id) {
return documentRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.DOCUMENT_NOT_FOUND, "Document not found: " + id));
}
public List<Document> getDocumentsBySender(UUID senderId) {
return documentRepository.findBySenderId(senderId);
}
public List<Document> getConversationFiltered(UUID senderId, UUID receiverId, LocalDate from, LocalDate to, Sort sort) {
LocalDate dateFrom = (from != null) ? from : LocalDate.parse("0000-01-01");
LocalDate dateTo = (to != null) ? to : LocalDate.now();
return documentRepository.findConversation(senderId, receiverId, dateFrom, dateTo, sort);
}
@Transactional
public void deleteTagCascading(UUID tagId) {
documentRepository.findByTags_Id(tagId).forEach(doc -> {
doc.getTags().removeIf(t -> t.getId().equals(tagId));
documentRepository.save(doc);
});
tagService.delete(tagId);
}
}

View File

@@ -10,8 +10,6 @@ import org.raddatz.familienarchiv.model.DocumentStatus;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.DocumentRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -57,8 +55,8 @@ public class MassImportService {
}
private final DocumentRepository documentRepository;
private final PersonRepository personRepository;
private final TagRepository tagRepository;
private final PersonService personService;
private final TagService tagService;
private final S3Client s3Client;
@Value("${app.s3.bucket}")
@@ -307,8 +305,7 @@ public class MassImportService {
Tag tag = null;
if (!tagRaw.isBlank()) {
tag = tagRepository.findByNameIgnoreCase(tagRaw)
.orElseGet(() -> tagRepository.save(Tag.builder().name(tagRaw).build()));
tag = tagService.findOrCreate(tagRaw);
}
Document doc = existing.orElse(Document.builder()
@@ -362,15 +359,7 @@ public class MassImportService {
}
private Person findOrCreatePerson(String rawName) {
String alias = rawName.trim();
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
PersonNameParser.SplitName split = PersonNameParser.split(alias);
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
});
return personService.findOrCreateByAlias(rawName);
}
private Optional<File> findFileRecursive(String filename) {

View File

@@ -1,6 +1,8 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Person;
import org.raddatz.familienarchiv.repository.PersonRepository;
import org.springframework.http.HttpStatus;
@@ -8,7 +10,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
@@ -16,6 +18,35 @@ public class PersonService {
private final PersonRepository personRepository;
public List<Person> findAll(String q) {
if (q != null && !q.isBlank()) {
return personRepository.searchByName(q);
}
return personRepository.findAllByOrderByLastNameAscFirstNameAsc();
}
public Person getById(UUID id) {
return personRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Person nicht gefunden"));
}
public List<Person> getAllById(List<UUID> ids) {
return personRepository.findAllById(ids);
}
@Transactional
public Person findOrCreateByAlias(String rawName) {
String alias = rawName.trim();
return personRepository.findByAliasIgnoreCase(alias).orElseGet(() -> {
PersonNameParser.SplitName split = PersonNameParser.split(alias);
return personRepository.save(Person.builder()
.alias(alias)
.firstName(split.firstName())
.lastName(split.lastName())
.build());
});
}
@Transactional
public Person createPerson(String firstName, String lastName, String alias) {
Person person = Person.builder()

View File

@@ -0,0 +1,47 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
import java.util.UUID;
import org.raddatz.familienarchiv.model.Tag;
import org.raddatz.familienarchiv.repository.TagRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
@Service
@RequiredArgsConstructor
public class TagService {
private final TagRepository tagRepository;
public List<Tag> search(String query) {
return tagRepository.findByNameContainingIgnoreCase(query);
}
public Tag getById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tag nicht gefunden"));
}
public Tag findOrCreate(String name) {
String cleanName = name.trim();
return tagRepository.findByNameIgnoreCase(cleanName)
.orElseGet(() -> tagRepository.save(Tag.builder().name(cleanName).build()));
}
@Transactional
public Tag update(UUID id, String newName) {
Tag tag = getById(id);
tag.setName(newName);
return tagRepository.save(tag);
}
@Transactional
public void delete(UUID id) {
tagRepository.delete(getById(id));
}
}

View File

@@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateUserRequest;
import org.raddatz.familienarchiv.dto.GroupDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.AppUser;
@@ -81,4 +82,30 @@ public AppUser createUserOrUpdate(CreateUserRequest request) {
public List<UserGroup> getAllGroups() {
return groupRepository.findAll();
}
public UserGroup getGroupById(UUID id) {
return groupRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.INTERNAL_ERROR, "Group not found: " + id));
}
@Transactional
public UserGroup createGroup(GroupDTO dto) {
UserGroup group = new UserGroup();
group.setName(dto.getName());
group.setPermissions(dto.getPermissions());
return groupRepository.save(group);
}
@Transactional
public UserGroup updateGroup(UUID id, GroupDTO dto) {
UserGroup group = getGroupById(id);
if (dto.getName() != null) group.setName(dto.getName());
if (dto.getPermissions() != null) group.setPermissions(dto.getPermissions());
return groupRepository.save(group);
}
@Transactional
public void deleteGroup(UUID id) {
groupRepository.deleteById(id);
}
}