refactor(geschichte): update service, controller, repository for JourneyItem model

- GeschichteService.list() now returns List<GeschichteSummary> via JPQL
  projection query; accepts (status, personIds, limit); DRAFT clamp for
  non-BLOG_WRITE users; AND-semantics person filter with sentinel UUID guard
- GeschichteService.getById() is @Transactional(readOnly=true) and calls
  Hibernate.initialize(g.getItems()) to force-init the LAZY bag under
  open-in-view=false
- GeschichteRepository: add findSummaries() JPQL query with person subquery
- GeschichteController.list(): remove documentId param, change return type
  to List<GeschichteSummary>
- GeschichteSpecifications: remove hasDocument() and documentSubquery() —
  TODO left for lesereisen-editor follow-on

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-08 12:24:50 +02:00
parent b3ce9b930f
commit 439385dd35
4 changed files with 57 additions and 57 deletions

View File

@@ -1,12 +1,8 @@
package org.raddatz.familienarchiv.geschichte; package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission; import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.geschichte.GeschichteService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -30,15 +26,13 @@ public class GeschichteController {
private final GeschichteService geschichteService; private final GeschichteService geschichteService;
@GetMapping @GetMapping
public List<Geschichte> list( public List<GeschichteSummary> list(
@RequestParam(required = false) GeschichteStatus status, @RequestParam(required = false) GeschichteStatus status,
@RequestParam(name = "personId", required = false) List<UUID> personIds, @RequestParam(name = "personId", required = false) List<UUID> personIds,
@RequestParam(required = false) UUID documentId,
@RequestParam(required = false, defaultValue = "50") int limit) { @RequestParam(required = false, defaultValue = "50") int limit) {
return geschichteService.list( return geschichteService.list(
status, status,
personIds == null ? List.of() : personIds, personIds == null ? List.of() : personIds,
documentId,
limit); limit);
} }

View File

@@ -1,12 +1,43 @@
package org.raddatz.familienarchiv.geschichte; package org.raddatz.familienarchiv.geschichte;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> { public interface GeschichteRepository extends JpaRepository<Geschichte, UUID>, JpaSpecificationExecutor<Geschichte> {
/**
* Returns the grid projection. Never carries items (avoids lazy-init 500 under open-in-view:false).
*
* <p>Status clamp: callers must pass the effective status (PUBLISHED for readers,
* raw status for BLOG_WRITE users). authorId restricts to own drafts when effective=DRAFT.
*
* <p>Person filter: personCount=0 disables the filter. When personCount>0, the story must
* be associated with ALL person ids in personIds (AND-semantics via counting subquery).
* Pass a non-empty personIds collection when personCount>0 — empty IN() is invalid SQL.
*/
@Query("""
SELECT g.id AS id, g.title AS title, g.status AS status, g.type AS type,
g.author AS author, g.publishedAt AS publishedAt, g.body AS body
FROM Geschichte g
WHERE g.status = :effectiveStatus
AND (:authorId IS NULL OR g.author.id = :authorId)
AND (:personCount = 0 OR
(SELECT COUNT(DISTINCT p.id)
FROM Geschichte g2 JOIN g2.persons p
WHERE g2.id = g.id AND p.id IN :personIds) = :personCount)
ORDER BY COALESCE(g.publishedAt, g.updatedAt) DESC
""")
List<GeschichteSummary> findSummaries(
@Param("effectiveStatus") GeschichteStatus effectiveStatus,
@Param("authorId") UUID authorId,
@Param("personIds") Collection<UUID> personIds,
@Param("personCount") long personCount);
} }

View File

@@ -2,30 +2,24 @@ package org.raddatz.familienarchiv.geschichte;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.hibernate.Hibernate;
import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.HtmlPolicyBuilder;
import org.owasp.html.PolicyFactory; import org.owasp.html.PolicyFactory;
import org.raddatz.familienarchiv.geschichte.GeschichteUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException; import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode; import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.user.AppUser; import org.raddatz.familienarchiv.user.AppUser;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.raddatz.familienarchiv.geschichte.GeschichteRepository;
import org.raddatz.familienarchiv.geschichte.GeschichteSpecifications;
import org.raddatz.familienarchiv.security.Permission; import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.document.DocumentService; import org.raddatz.familienarchiv.document.DocumentService;
import org.raddatz.familienarchiv.person.PersonService; import org.raddatz.familienarchiv.person.PersonService;
import org.raddatz.familienarchiv.user.UserService; import org.raddatz.familienarchiv.user.UserService;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -39,6 +33,8 @@ public class GeschichteService {
private final GeschichteRepository geschichteRepository; private final GeschichteRepository geschichteRepository;
private final PersonService personService; private final PersonService personService;
// Reserved for lesereisen-editor: JourneyItem document resolution must go through
// DocumentService.getDocumentById to enforce existence and scope checks.
private final DocumentService documentService; private final DocumentService documentService;
private final UserService userService; private final UserService userService;
@@ -60,6 +56,7 @@ public class GeschichteService {
return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED)); return geschichteRepository.count(GeschichteSpecifications.hasStatus(GeschichteStatus.PUBLISHED));
} }
@Transactional(readOnly = true)
public Geschichte getById(UUID id) { public Geschichte getById(UUID id) {
Geschichte g = geschichteRepository.findById(id) Geschichte g = geschichteRepository.findById(id)
.orElseThrow(() -> DomainException.notFound( .orElseThrow(() -> DomainException.notFound(
@@ -69,6 +66,10 @@ public class GeschichteService {
throw DomainException.notFound( throw DomainException.notFound(
ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id); ErrorCode.GESCHICHTE_NOT_FOUND, "Geschichte not found: " + id);
} }
// Force-initialize LAZY items inside this transaction.
// open-in-view is FALSE — without this touch, Jackson serializes a closed
// Hibernate session and throws LazyInitializationException → HTTP 500.
Hibernate.initialize(g.getItems());
return g; return g;
} }
@@ -76,20 +77,25 @@ public class GeschichteService {
* Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story * Lists Geschichten with optional filters. {@code personIds} uses AND semantics: the story
* must be associated with every person id supplied. An empty or null list applies no * must be associated with every person id supplied. An empty or null list applies no
* person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}. * person filter. Result is ordered by {@code COALESCE(publishedAt, updatedAt) DESC}.
*
* <p>Returns a {@link GeschichteSummary} projection — never carries items, preventing
* LazyInitializationException on the non-transactional list path.
*/ */
public List<Geschichte> list(GeschichteStatus status, List<UUID> personIds, UUID documentId, int limit) { public List<GeschichteSummary> list(GeschichteStatus status, List<UUID> personIds, int limit) {
GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED; GeschichteStatus effective = currentUserHasBlogWrite() ? status : GeschichteStatus.PUBLISHED;
int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT); int safeLimit = limit <= 0 ? DEFAULT_LIMIT : Math.min(limit, MAX_LIMIT);
UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null; UUID authorId = effective == GeschichteStatus.DRAFT ? currentUser().getId() : null;
Specification<Geschichte> spec = Specification.allOf(
GeschichteSpecifications.hasStatus(effective), // When personIds is empty, personCount=0 short-circuits the IN() predicate.
GeschichteSpecifications.hasAuthor(authorId), // Pass a sentinel UUID to avoid invalid empty IN() SQL while the predicate is skipped.
GeschichteSpecifications.hasAllPersons(personIds), Collection<UUID> safePersonIds = (personIds == null || personIds.isEmpty())
GeschichteSpecifications.hasDocument(documentId), ? List.of(UUID.fromString("00000000-0000-0000-0000-000000000000"))
GeschichteSpecifications.orderByDisplayDateDesc() : personIds;
); long personCount = (personIds == null) ? 0 : personIds.size();
return geschichteRepository.findAll(spec, Sort.unsorted())
return geschichteRepository
.findSummaries(effective, authorId, safePersonIds, personCount)
.stream() .stream()
.limit(safeLimit) .limit(safeLimit)
.toList(); .toList();
@@ -106,7 +112,6 @@ public class GeschichteService {
.status(GeschichteStatus.DRAFT) .status(GeschichteStatus.DRAFT)
.author(currentUser()) .author(currentUser())
.persons(resolvePersons(dto.getPersonIds())) .persons(resolvePersons(dto.getPersonIds()))
.documents(resolveDocuments(dto.getDocumentIds()))
.build(); .build();
if (dto.getStatus() == GeschichteStatus.PUBLISHED) { if (dto.getStatus() == GeschichteStatus.PUBLISHED) {
g.setStatus(GeschichteStatus.PUBLISHED); g.setStatus(GeschichteStatus.PUBLISHED);
@@ -130,9 +135,6 @@ public class GeschichteService {
if (dto.getPersonIds() != null) { if (dto.getPersonIds() != null) {
g.setPersons(resolvePersons(dto.getPersonIds())); g.setPersons(resolvePersons(dto.getPersonIds()));
} }
if (dto.getDocumentIds() != null) {
g.setDocuments(resolveDocuments(dto.getDocumentIds()));
}
if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) { if (dto.getStatus() != null && dto.getStatus() != g.getStatus()) {
applyStatusTransition(g, dto.getStatus()); applyStatusTransition(g, dto.getStatus());
} }
@@ -176,15 +178,6 @@ public class GeschichteService {
return new LinkedHashSet<>(personService.getAllById(ids)); return new LinkedHashSet<>(personService.getAllById(ids));
} }
private Set<Document> resolveDocuments(List<UUID> ids) {
if (ids == null || ids.isEmpty()) return new HashSet<>();
Set<Document> out = new LinkedHashSet<>();
for (UUID id : ids) {
out.add(documentService.getDocumentById(id));
}
return out;
}
private AppUser currentUser() { private AppUser currentUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) { if (auth == null || !auth.isAuthenticated()) {

View File

@@ -6,9 +6,6 @@ import jakarta.persistence.criteria.Join;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Root;
import jakarta.persistence.criteria.Subquery; import jakarta.persistence.criteria.Subquery;
import org.raddatz.familienarchiv.document.Document;
import org.raddatz.familienarchiv.geschichte.Geschichte;
import org.raddatz.familienarchiv.geschichte.GeschichteStatus;
import org.raddatz.familienarchiv.person.Person; import org.raddatz.familienarchiv.person.Person;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
@@ -48,12 +45,7 @@ public final class GeschichteSpecifications {
authorId == null ? null : cb.equal(root.get("author").get("id"), authorId); authorId == null ? null : cb.equal(root.get("author").get("id"), authorId);
} }
public static Specification<Geschichte> hasDocument(UUID documentId) { // TODO(lesereisen-editor): restore document filter via journey_items join when editor lands
return (root, query, cb) -> {
if (documentId == null) return null;
return cb.exists(documentSubquery(root, query, cb, documentId));
};
}
/** /**
* AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}. * AND-filter across persons: the Geschichte must be associated with EVERY id in {@code personIds}.
@@ -84,14 +76,4 @@ public final class GeschichteSpecifications {
return sub; return sub;
} }
private static Subquery<UUID> documentSubquery(
Root<Geschichte> root, CriteriaQuery<?> query, CriteriaBuilder cb, UUID documentId) {
Subquery<UUID> sub = query.subquery(UUID.class);
Root<Geschichte> subRoot = sub.from(Geschichte.class);
Join<Geschichte, Document> documents = subRoot.join("documents");
sub.select(subRoot.get("id"))
.where(cb.equal(subRoot.get("id"), root.get("id")),
cb.equal(documents.get("id"), documentId));
return sub;
}
} }