feat(content-restrictions): add age rating and content rating support (#2619)

This commit is contained in:
ACX
2026-02-05 17:10:44 -07:00
committed by GitHub
parent 89b4319970
commit 9f9c762180
42 changed files with 1755 additions and 17 deletions

View File

@@ -6,6 +6,7 @@ import org.booklore.exception.ApiError;
import org.booklore.model.dto.BookLoreUser;
import org.booklore.model.entity.BookEntity;
import org.booklore.repository.BookRepository;
import org.booklore.service.restriction.ContentRestrictionService;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
@@ -14,6 +15,7 @@ import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Objects;
import java.util.regex.Pattern;
@@ -25,6 +27,7 @@ public class BookAccessAspect {
private static final Pattern NUMERIC_PATTERN = Pattern.compile("\\d+");
private final AuthenticationService authenticationService;
private final BookRepository bookRepository;
private final ContentRestrictionService contentRestrictionService;
@Before("@annotation(org.booklore.config.security.annotation.CheckBookAccess)")
public void checkBookAccess(JoinPoint joinPoint) {
@@ -49,9 +52,14 @@ public class BookAccessAspect {
return;
}
boolean hasAccess = user.getAssignedLibraries().stream().anyMatch(library -> library.getId().equals(bookEntity.getLibrary().getId()));
boolean hasLibraryAccess = user.getAssignedLibraries().stream().anyMatch(library -> library.getId().equals(bookEntity.getLibrary().getId()));
if (!hasAccess) {
if (!hasLibraryAccess) {
throw ApiError.FORBIDDEN.createException("You are not authorized to access this book.");
}
List<BookEntity> filteredBooks = contentRestrictionService.applyRestrictions(List.of(bookEntity), user.getId());
if (filteredBooks.isEmpty()) {
throw ApiError.FORBIDDEN.createException("You are not authorized to access this book.");
}
}

View File

@@ -0,0 +1,97 @@
package org.booklore.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.booklore.model.dto.ContentRestriction;
import org.booklore.service.restriction.ContentRestrictionService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "Content Restrictions", description = "Endpoints for managing user content restrictions")
@RestController
@RequestMapping("/api/v1/users/{userId}/content-restrictions")
@RequiredArgsConstructor
public class ContentRestrictionController {
private final ContentRestrictionService contentRestrictionService;
@Operation(summary = "Get user content restrictions", description = "Retrieve all content restrictions for a specific user")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Content restrictions retrieved successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden - requires admin or own user"),
@ApiResponse(responseCode = "404", description = "User not found")
})
@GetMapping
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.isSelf(#userId)")
public ResponseEntity<List<ContentRestriction>> getUserRestrictions(
@Parameter(description = "ID of the user") @PathVariable Long userId) {
return ResponseEntity.ok(contentRestrictionService.getUserRestrictions(userId));
}
@Operation(summary = "Add content restriction", description = "Add a new content restriction for a user")
@ApiResponses({
@ApiResponse(responseCode = "201", description = "Content restriction created successfully"),
@ApiResponse(responseCode = "400", description = "Invalid request or restriction already exists"),
@ApiResponse(responseCode = "403", description = "Forbidden - requires admin"),
@ApiResponse(responseCode = "404", description = "User not found")
})
@PostMapping
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<ContentRestriction> addRestriction(
@Parameter(description = "ID of the user") @PathVariable Long userId,
@Parameter(description = "Content restriction to add") @RequestBody @Valid ContentRestriction restriction) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(contentRestrictionService.addRestriction(userId, restriction));
}
@Operation(summary = "Update all content restrictions", description = "Replace all content restrictions for a user")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Content restrictions updated successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden - requires admin"),
@ApiResponse(responseCode = "404", description = "User not found")
})
@PutMapping
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<List<ContentRestriction>> updateRestrictions(
@Parameter(description = "ID of the user") @PathVariable Long userId,
@Parameter(description = "List of content restrictions") @RequestBody @Valid List<ContentRestriction> restrictions) {
return ResponseEntity.ok(contentRestrictionService.updateRestrictions(userId, restrictions));
}
@Operation(summary = "Delete content restriction", description = "Delete a specific content restriction")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Content restriction deleted successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden - requires admin"),
@ApiResponse(responseCode = "404", description = "Content restriction not found")
})
@DeleteMapping("/{restrictionId}")
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<Void> deleteRestriction(
@Parameter(description = "ID of the user") @PathVariable Long userId,
@Parameter(description = "ID of the restriction to delete") @PathVariable Long restrictionId) {
contentRestrictionService.deleteRestriction(restrictionId);
return ResponseEntity.noContent().build();
}
@Operation(summary = "Delete all user content restrictions", description = "Delete all content restrictions for a user")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "All content restrictions deleted successfully"),
@ApiResponse(responseCode = "403", description = "Forbidden - requires admin")
})
@DeleteMapping
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<Void> deleteAllRestrictions(
@Parameter(description = "ID of the user") @PathVariable Long userId) {
contentRestrictionService.deleteAllUserRestrictions(userId);
return ResponseEntity.noContent().build();
}
}

View File

@@ -18,5 +18,7 @@ public interface MetadataClearFlagsMapper {
@Mapping(target = "categories", source = "clearGenres")
@Mapping(target = "moods", source = "clearMoods")
@Mapping(target = "tags", source = "clearTags")
@Mapping(target = "ageRating", source = "clearAgeRating")
@Mapping(target = "contentRating", source = "clearContentRating")
MetadataClearFlags toClearFlags(BulkMetadataUpdateRequest request);
}

View File

@@ -42,4 +42,6 @@ public class MetadataClearFlags {
private boolean reviews;
private boolean narrator;
private boolean abridged;
private boolean ageRating;
private boolean contentRating;
}

View File

@@ -112,4 +112,9 @@ public class BookMetadata {
private Boolean reviewsLocked;
private Boolean narratorLocked;
private Boolean abridgedLocked;
private Integer ageRating;
private String contentRating;
private Boolean ageRatingLocked;
private Boolean contentRatingLocked;
}

View File

@@ -0,0 +1,23 @@
package org.booklore.model.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.*;
import org.booklore.model.enums.ContentRestrictionMode;
import org.booklore.model.enums.ContentRestrictionType;
import java.time.LocalDateTime;
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ContentRestriction {
private Long id;
private Long userId;
private ContentRestrictionType restrictionType;
private ContentRestrictionMode mode;
private String value;
private LocalDateTime createdAt;
}

View File

@@ -66,6 +66,10 @@ public enum RuleField {
@JsonProperty("tags")
TAGS,
@JsonProperty("genre")
GENRE
GENRE,
@JsonProperty("ageRating")
AGE_RATING,
@JsonProperty("contentRating")
CONTENT_RATING
}

View File

@@ -39,4 +39,10 @@ public class BulkMetadataUpdateRequest {
private boolean mergeCategories;
private boolean mergeMoods;
private boolean mergeTags;
private Integer ageRating;
private boolean clearAgeRating;
private String contentRating;
private boolean clearContentRating;
}

View File

@@ -70,6 +70,10 @@ public class BookLoreUserEntity {
@Builder.Default
private Set<ReadingSessionEntity> readingSessions = new HashSet<>();
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
@Builder.Default
private Set<UserContentRestrictionEntity> contentRestrictions = new HashSet<>();
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();

View File

@@ -305,6 +305,20 @@ public class BookMetadataEntity {
@Column(name = "search_text", columnDefinition = "TEXT")
private String searchText;
@Column(name = "age_rating")
private Integer ageRating;
@Column(name = "content_rating", length = 20)
private String contentRating;
@Column(name = "age_rating_locked")
@Builder.Default
private Boolean ageRatingLocked = Boolean.FALSE;
@Column(name = "content_rating_locked")
@Builder.Default
private Boolean contentRatingLocked = Boolean.FALSE;
@PrePersist
@PreUpdate
public void updateSearchText() {
@@ -398,6 +412,8 @@ public class BookMetadataEntity {
this.reviewsLocked = lock;
this.narratorLocked = lock;
this.abridgedLocked = lock;
this.ageRatingLocked = lock;
this.contentRatingLocked = lock;
}
public boolean areAllFieldsLocked() {
@@ -441,6 +457,8 @@ public class BookMetadataEntity {
&& Boolean.TRUE.equals(this.reviewsLocked)
&& Boolean.TRUE.equals(this.narratorLocked)
&& Boolean.TRUE.equals(this.abridgedLocked)
&& Boolean.TRUE.equals(this.ageRatingLocked)
&& Boolean.TRUE.equals(this.contentRatingLocked)
;
}
}

View File

@@ -0,0 +1,46 @@
package org.booklore.model.entity;
import jakarta.persistence.*;
import lombok.*;
import org.booklore.model.enums.ContentRestrictionMode;
import org.booklore.model.enums.ContentRestrictionType;
import java.time.LocalDateTime;
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user_content_restriction",
uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "restriction_type", "value"}))
public class UserContentRestrictionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private BookLoreUserEntity user;
@Column(name = "restriction_type", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private ContentRestrictionType restrictionType;
@Column(name = "mode", nullable = false, length = 15)
@Enumerated(EnumType.STRING)
private ContentRestrictionMode mode;
@Column(name = "value", nullable = false)
private String value;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
this.createdAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,9 @@
package org.booklore.model.enums;
public enum ContentRating {
EVERYONE,
TEEN,
MATURE,
ADULT,
EXPLICIT
}

View File

@@ -0,0 +1,6 @@
package org.booklore.model.enums;
public enum ContentRestrictionMode {
EXCLUDE,
ALLOW_ONLY
}

View File

@@ -0,0 +1,9 @@
package org.booklore.model.enums;
public enum ContentRestrictionType {
CATEGORY,
TAG,
MOOD,
AGE_RATING,
CONTENT_RATING
}

View File

@@ -0,0 +1,35 @@
package org.booklore.repository;
import org.booklore.model.entity.UserContentRestrictionEntity;
import org.booklore.model.enums.ContentRestrictionMode;
import org.booklore.model.enums.ContentRestrictionType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface UserContentRestrictionRepository extends JpaRepository<UserContentRestrictionEntity, Long> {
List<UserContentRestrictionEntity> findByUserId(Long userId);
List<UserContentRestrictionEntity> findByUserIdAndRestrictionType(Long userId, ContentRestrictionType type);
List<UserContentRestrictionEntity> findByUserIdAndMode(Long userId, ContentRestrictionMode mode);
Optional<UserContentRestrictionEntity> findByUserIdAndRestrictionTypeAndValue(
Long userId, ContentRestrictionType restrictionType, String value);
void deleteByUserId(Long userId);
void deleteByUserIdAndRestrictionType(Long userId, ContentRestrictionType type);
@Query("SELECT r FROM UserContentRestrictionEntity r WHERE r.user.id = :userId AND r.restrictionType = :type AND r.mode = :mode")
List<UserContentRestrictionEntity> findByUserIdAndTypeAndMode(
@Param("userId") Long userId,
@Param("type") ContentRestrictionType type,
@Param("mode") ContentRestrictionMode mode);
boolean existsByUserIdAndRestrictionTypeAndValue(Long userId, ContentRestrictionType type, String value);
}

View File

@@ -338,6 +338,8 @@ public class BookRuleEvaluatorService {
case HARDCOVER_RATING -> root.get("metadata").get("hardcoverRating");
case HARDCOVER_REVIEW_COUNT -> root.get("metadata").get("hardcoverReviewCount");
case RANOBEDB_RATING -> root.get("metadata").get("ranobedbRating");
case AGE_RATING -> root.get("metadata").get("ageRating");
case CONTENT_RATING -> root.get("metadata").get("contentRating");
case FILE_TYPE -> cb.function("SUBSTRING_INDEX", String.class,
root.get("fileName"), cb.literal("."), cb.literal(-1));
default -> null;

View File

@@ -4,6 +4,7 @@ import org.booklore.mapper.v2.BookMapperV2;
import org.booklore.model.dto.Book;
import org.booklore.model.entity.BookEntity;
import org.booklore.repository.BookRepository;
import org.booklore.service.restriction.ContentRestrictionService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -17,6 +18,7 @@ public class BookQueryService {
private final BookRepository bookRepository;
private final BookMapperV2 bookMapperV2;
private final ContentRestrictionService contentRestrictionService;
public List<Book> getAllBooks(boolean includeDescription) {
List<BookEntity> books = bookRepository.findAllWithMetadata();
@@ -25,6 +27,7 @@ public class BookQueryService {
public List<Book> getAllBooksByLibraryIds(Set<Long> libraryIds, boolean includeDescription, Long userId) {
List<BookEntity> books = bookRepository.findAllWithMetadataByLibraryIds(libraryIds);
books = contentRestrictionService.applyRestrictions(books, userId);
return mapBooksToDto(books, includeDescription, userId);
}

View File

@@ -152,6 +152,8 @@ public class BookMetadataService {
.categories(request.getGenres() != null ? request.getGenres() : Collections.emptySet())
.moods(request.getMoods() != null ? request.getMoods() : Collections.emptySet())
.tags(request.getTags() != null ? request.getTags() : Collections.emptySet())
.ageRating(request.getAgeRating())
.contentRating(request.getContentRating())
.build();
for (Long bookId : request.getBookIds()) {

View File

@@ -180,6 +180,8 @@ public class BookMetadataUpdater {
handleFieldUpdate(e.getLubimyczytacRatingLocked(), clear.isLubimyczytacRating(), m.getLubimyczytacRating(), v -> e.setLubimyczytacRating(v), () -> e.getLubimyczytacRating(), replaceMode);
handleFieldUpdate(e.getRanobedbIdLocked(), clear.isRanobedbId(), m.getRanobedbId(), v -> e.setRanobedbId(nullIfBlank(v)), e::getRanobedbId, replaceMode);
handleFieldUpdate(e.getRanobedbRatingLocked(), clear.isRanobedbRating(), m.getRanobedbRating(), e::setRanobedbRating, e::getRanobedbRating, replaceMode);
handleFieldUpdate(e.getAgeRatingLocked(), clear.isAgeRating(), m.getAgeRating(), e::setAgeRating, e::getAgeRating, replaceMode);
handleFieldUpdate(e.getContentRatingLocked(), clear.isContentRating(), m.getContentRating(), v -> e.setContentRating(nullIfBlank(v)), e::getContentRating, replaceMode);
}
private <T> void handleFieldUpdate(Boolean locked, boolean shouldClear, T newValue, Consumer<T> setter, Supplier<T> getter, MetadataReplaceMode mode) {
@@ -416,7 +418,9 @@ public class BookMetadataUpdater {
Pair.of(m.getTagsLocked(), e::setTagsLocked),
Pair.of(m.getReviewsLocked(), e::setReviewsLocked),
Pair.of(m.getNarratorLocked(), e::setNarratorLocked),
Pair.of(m.getAbridgedLocked(), e::setAbridgedLocked)
Pair.of(m.getAbridgedLocked(), e::setAbridgedLocked),
Pair.of(m.getAgeRatingLocked(), e::setAgeRatingLocked),
Pair.of(m.getContentRatingLocked(), e::setContentRatingLocked)
);
lockMappings.forEach(pair -> {
if (pair.getLeft() != null) pair.getRight().accept(pair.getLeft());

View File

@@ -0,0 +1,272 @@
package org.booklore.service.restriction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.booklore.exception.ApiError;
import org.booklore.model.dto.ContentRestriction;
import org.booklore.model.entity.BookEntity;
import org.booklore.model.entity.BookLoreUserEntity;
import org.booklore.model.entity.BookMetadataEntity;
import org.booklore.model.entity.UserContentRestrictionEntity;
import org.booklore.model.enums.ContentRestrictionMode;
import org.booklore.model.enums.ContentRestrictionType;
import org.booklore.repository.UserContentRestrictionRepository;
import org.booklore.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class ContentRestrictionService {
private final UserContentRestrictionRepository restrictionRepository;
private final UserRepository userRepository;
public List<ContentRestriction> getUserRestrictions(Long userId) {
return restrictionRepository.findByUserId(userId).stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public ContentRestriction getRestriction(Long restrictionId) {
return restrictionRepository.findById(restrictionId)
.map(this::toDto)
.orElseThrow(() -> ApiError.GENERIC_NOT_FOUND.createException("Content restriction not found"));
}
@Transactional
public ContentRestriction addRestriction(Long userId, ContentRestriction restriction) {
BookLoreUserEntity user = userRepository.findById(userId)
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId));
if (restrictionRepository.existsByUserIdAndRestrictionTypeAndValue(
userId, restriction.getRestrictionType(), restriction.getValue())) {
throw ApiError.GENERIC_BAD_REQUEST.createException("Restriction already exists");
}
UserContentRestrictionEntity entity = UserContentRestrictionEntity.builder()
.user(user)
.restrictionType(restriction.getRestrictionType())
.mode(restriction.getMode())
.value(restriction.getValue())
.build();
return toDto(restrictionRepository.save(entity));
}
@Transactional
public List<ContentRestriction> updateRestrictions(Long userId, List<ContentRestriction> restrictions) {
BookLoreUserEntity user = userRepository.findById(userId)
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(userId));
restrictionRepository.deleteByUserId(userId);
List<UserContentRestrictionEntity> entities = restrictions.stream()
.map(r -> UserContentRestrictionEntity.builder()
.user(user)
.restrictionType(r.getRestrictionType())
.mode(r.getMode())
.value(r.getValue())
.build())
.collect(Collectors.toList());
return restrictionRepository.saveAll(entities).stream()
.map(this::toDto)
.collect(Collectors.toList());
}
@Transactional
public void deleteRestriction(Long restrictionId) {
if (!restrictionRepository.existsById(restrictionId)) {
throw ApiError.GENERIC_NOT_FOUND.createException("Content restriction not found");
}
restrictionRepository.deleteById(restrictionId);
}
@Transactional
public void deleteAllUserRestrictions(Long userId) {
restrictionRepository.deleteByUserId(userId);
}
public List<BookEntity> applyRestrictions(List<BookEntity> books, Long userId) {
List<UserContentRestrictionEntity> restrictions = restrictionRepository.findByUserId(userId);
if (restrictions.isEmpty()) {
return books;
}
Set<String> excludedCategories = getValuesForTypeAndMode(restrictions, ContentRestrictionType.CATEGORY, ContentRestrictionMode.EXCLUDE);
Set<String> excludedTags = getValuesForTypeAndMode(restrictions, ContentRestrictionType.TAG, ContentRestrictionMode.EXCLUDE);
Set<String> excludedMoods = getValuesForTypeAndMode(restrictions, ContentRestrictionType.MOOD, ContentRestrictionMode.EXCLUDE);
Set<String> excludedContentRatings = getValuesForTypeAndMode(restrictions, ContentRestrictionType.CONTENT_RATING, ContentRestrictionMode.EXCLUDE);
Set<String> allowedCategories = getValuesForTypeAndMode(restrictions, ContentRestrictionType.CATEGORY, ContentRestrictionMode.ALLOW_ONLY);
Set<String> allowedTags = getValuesForTypeAndMode(restrictions, ContentRestrictionType.TAG, ContentRestrictionMode.ALLOW_ONLY);
Set<String> allowedMoods = getValuesForTypeAndMode(restrictions, ContentRestrictionType.MOOD, ContentRestrictionMode.ALLOW_ONLY);
Set<String> allowedContentRatings = getValuesForTypeAndMode(restrictions, ContentRestrictionType.CONTENT_RATING, ContentRestrictionMode.ALLOW_ONLY);
Integer maxAgeRating = getMaxAgeRating(restrictions);
return books.stream()
.filter(book -> !hasExcludedContent(book, excludedCategories, excludedTags, excludedMoods, excludedContentRatings))
.filter(book -> matchesAllowList(book, allowedCategories, allowedTags, allowedMoods, allowedContentRatings))
.filter(book -> isWithinAgeRating(book, maxAgeRating))
.collect(Collectors.toList());
}
private Set<String> getValuesForTypeAndMode(List<UserContentRestrictionEntity> restrictions,
ContentRestrictionType type,
ContentRestrictionMode mode) {
return restrictions.stream()
.filter(r -> r.getRestrictionType() == type && r.getMode() == mode)
.map(r -> r.getValue().toLowerCase())
.collect(Collectors.toSet());
}
private Integer getMaxAgeRating(List<UserContentRestrictionEntity> restrictions) {
return restrictions.stream()
.filter(r -> r.getRestrictionType() == ContentRestrictionType.AGE_RATING)
.filter(r -> r.getMode() == ContentRestrictionMode.EXCLUDE)
.map(r -> {
try {
return Integer.parseInt(r.getValue());
} catch (NumberFormatException e) {
return null;
}
})
.filter(Objects::nonNull)
.min(Integer::compareTo)
.orElse(null);
}
private boolean hasExcludedContent(BookEntity book,
Set<String> excludedCategories,
Set<String> excludedTags,
Set<String> excludedMoods,
Set<String> excludedContentRatings) {
BookMetadataEntity metadata = book.getMetadata();
if (metadata == null) {
return false;
}
if (!excludedCategories.isEmpty() && metadata.getCategories() != null) {
boolean hasExcludedCategory = metadata.getCategories().stream()
.anyMatch(c -> excludedCategories.contains(c.getName().toLowerCase()));
if (hasExcludedCategory) {
return true;
}
}
if (!excludedTags.isEmpty() && metadata.getTags() != null) {
boolean hasExcludedTag = metadata.getTags().stream()
.anyMatch(t -> excludedTags.contains(t.getName().toLowerCase()));
if (hasExcludedTag) {
return true;
}
}
if (!excludedMoods.isEmpty() && metadata.getMoods() != null) {
boolean hasExcludedMood = metadata.getMoods().stream()
.anyMatch(m -> excludedMoods.contains(m.getName().toLowerCase()));
if (hasExcludedMood) {
return true;
}
}
if (!excludedContentRatings.isEmpty() && metadata.getContentRating() != null) {
if (excludedContentRatings.contains(metadata.getContentRating().toLowerCase())) {
return true;
}
}
return false;
}
private boolean matchesAllowList(BookEntity book,
Set<String> allowedCategories,
Set<String> allowedTags,
Set<String> allowedMoods,
Set<String> allowedContentRatings) {
BookMetadataEntity metadata = book.getMetadata();
if (allowedCategories.isEmpty() && allowedTags.isEmpty() && allowedMoods.isEmpty() && allowedContentRatings.isEmpty()) {
return true;
}
if (metadata == null) {
return false;
}
if (!allowedCategories.isEmpty()) {
if (metadata.getCategories() == null || metadata.getCategories().isEmpty()) {
return false;
}
boolean hasAllowedCategory = metadata.getCategories().stream()
.anyMatch(c -> allowedCategories.contains(c.getName().toLowerCase()));
if (!hasAllowedCategory) {
return false;
}
}
if (!allowedTags.isEmpty()) {
if (metadata.getTags() == null || metadata.getTags().isEmpty()) {
return false;
}
boolean hasAllowedTag = metadata.getTags().stream()
.anyMatch(t -> allowedTags.contains(t.getName().toLowerCase()));
if (!hasAllowedTag) {
return false;
}
}
if (!allowedMoods.isEmpty()) {
if (metadata.getMoods() == null || metadata.getMoods().isEmpty()) {
return false;
}
boolean hasAllowedMood = metadata.getMoods().stream()
.anyMatch(m -> allowedMoods.contains(m.getName().toLowerCase()));
if (!hasAllowedMood) {
return false;
}
}
if (!allowedContentRatings.isEmpty()) {
if (metadata.getContentRating() == null) {
return false;
}
if (!allowedContentRatings.contains(metadata.getContentRating().toLowerCase())) {
return false;
}
}
return true;
}
private boolean isWithinAgeRating(BookEntity book, Integer maxAgeRating) {
if (maxAgeRating == null) {
return true;
}
BookMetadataEntity metadata = book.getMetadata();
if (metadata == null || metadata.getAgeRating() == null) {
return true;
}
return metadata.getAgeRating() < maxAgeRating;
}
private ContentRestriction toDto(UserContentRestrictionEntity entity) {
return ContentRestriction.builder()
.id(entity.getId())
.userId(entity.getUser().getId())
.restrictionType(entity.getRestrictionType())
.mode(entity.getMode())
.value(entity.getValue())
.createdAt(entity.getCreatedAt())
.build();
}
}

View File

@@ -203,7 +203,15 @@ public class MetadataChangeDetector {
new FieldDescriptor<>("abridged",
BookMetadata::getAbridged, BookMetadataEntity::getAbridged,
BookMetadata::getAbridgedLocked, BookMetadataEntity::getAbridgedLocked,
MetadataClearFlags::isAbridged, false)
MetadataClearFlags::isAbridged, false),
new FieldDescriptor<>("ageRating",
BookMetadata::getAgeRating, BookMetadataEntity::getAgeRating,
BookMetadata::getAgeRatingLocked, BookMetadataEntity::getAgeRatingLocked,
MetadataClearFlags::isAgeRating, false),
new FieldDescriptor<>("contentRating",
BookMetadata::getContentRating, BookMetadataEntity::getContentRating,
BookMetadata::getContentRatingLocked, BookMetadataEntity::getContentRatingLocked,
MetadataClearFlags::isContentRating, false)
);
private static final List<CollectionFieldDescriptor> COLLECTION_FIELDS = List.of(

View File

@@ -0,0 +1,5 @@
-- Add age rating fields to book_metadata table for content restriction filtering
ALTER TABLE book_metadata ADD COLUMN age_rating INT DEFAULT NULL;
ALTER TABLE book_metadata ADD COLUMN content_rating VARCHAR(20) DEFAULT NULL;
ALTER TABLE book_metadata ADD COLUMN age_rating_locked BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE book_metadata ADD COLUMN content_rating_locked BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,13 @@
-- Create user_content_restriction table for per-user content filtering
CREATE TABLE user_content_restriction (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
restriction_type VARCHAR(20) NOT NULL,
mode VARCHAR(15) NOT NULL,
value VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_ucr_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT uk_user_restriction UNIQUE (user_id, restriction_type, value)
);
CREATE INDEX idx_ucr_user_id ON user_content_restriction(user_id);

View File

@@ -384,8 +384,17 @@ class KoboEntitlementServiceTest {
private BookEntity createEpubBookEntity(Long id) {
BookEntity book = new BookEntity();
book.setId(id);
book.setBookType(BookFileType.EPUB);
book.setFileSizeKb(1024L);
book.setBookFiles(new java.util.ArrayList<>());
BookFileEntity bookFile = BookFileEntity.builder()
.book(book)
.fileName("test-book-" + id + ".epub")
.fileSubPath("")
.isBookFormat(true)
.bookType(BookFileType.EPUB)
.fileSizeKb(1024L)
.build();
book.getBookFiles().add(bookFile);
BookMetadataEntity metadata = new BookMetadataEntity();
metadata.setTitle("Test EPUB Book " + id);