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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user