mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
feat(content-restrictions): add age rating and content rating support (#2619)
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -42,4 +42,6 @@ public class MetadataClearFlags {
|
||||
private boolean reviews;
|
||||
private boolean narrator;
|
||||
private boolean abridged;
|
||||
private boolean ageRating;
|
||||
private boolean contentRating;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -66,6 +66,10 @@ public enum RuleField {
|
||||
@JsonProperty("tags")
|
||||
TAGS,
|
||||
@JsonProperty("genre")
|
||||
GENRE
|
||||
GENRE,
|
||||
@JsonProperty("ageRating")
|
||||
AGE_RATING,
|
||||
@JsonProperty("contentRating")
|
||||
CONTENT_RATING
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum ContentRating {
|
||||
EVERYONE,
|
||||
TEEN,
|
||||
MATURE,
|
||||
ADULT,
|
||||
EXPLICIT
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum ContentRestrictionMode {
|
||||
EXCLUDE,
|
||||
ALLOW_ONLY
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.booklore.model.enums;
|
||||
|
||||
public enum ContentRestrictionType {
|
||||
CATEGORY,
|
||||
TAG,
|
||||
MOOD,
|
||||
AGE_RATING,
|
||||
CONTENT_RATING
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user