Backend implementation

This commit is contained in:
Stefan Hardegger
2025-07-21 10:46:11 +02:00
parent 68c7c8115f
commit bebb799784
29 changed files with 10303 additions and 5 deletions

View File

@@ -0,0 +1,124 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public class AuthorDto {
private UUID id;
@NotBlank(message = "Author name is required")
@Size(max = 255, message = "Author name must not exceed 255 characters")
private String name;
@Size(max = 1000, message = "Bio must not exceed 1000 characters")
private String bio;
private String avatarPath;
private Double rating;
private Double averageStoryRating;
private Integer totalStoryRatings;
private List<String> urls;
private Integer storyCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public AuthorDto() {}
public AuthorDto(String name) {
this.name = name;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
public String getAvatarPath() {
return avatarPath;
}
public void setAvatarPath(String avatarPath) {
this.avatarPath = avatarPath;
}
public Double getRating() {
return rating;
}
public void setRating(Double rating) {
this.rating = rating;
}
public Double getAverageStoryRating() {
return averageStoryRating;
}
public void setAverageStoryRating(Double averageStoryRating) {
this.averageStoryRating = averageStoryRating;
}
public Integer getTotalStoryRatings() {
return totalStoryRatings;
}
public void setTotalStoryRatings(Integer totalStoryRatings) {
this.totalStoryRatings = totalStoryRatings;
}
public List<String> getUrls() {
return urls;
}
public void setUrls(List<String> urls) {
this.urls = urls;
}
public Integer getStoryCount() {
return storyCount;
}
public void setStoryCount(Integer storyCount) {
this.storyCount = storyCount;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,217 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public class StoryDto {
private UUID id;
@NotBlank(message = "Story title is required")
@Size(max = 255, message = "Story title must not exceed 255 characters")
private String title;
@Size(max = 1000, message = "Story description must not exceed 1000 characters")
private String description;
private String content;
private String sourceUrl;
private String coverPath;
private Integer wordCount;
private Integer readingTimeMinutes;
private Double averageRating;
private Integer totalRatings;
private Boolean isFavorite;
private Double readingProgress;
private LocalDateTime lastReadAt;
private Integer partNumber;
// Related entities as simple references
private UUID authorId;
private String authorName;
private UUID seriesId;
private String seriesName;
private List<String> tagNames;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public StoryDto() {}
public StoryDto(String title) {
this.title = title;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getSourceUrl() {
return sourceUrl;
}
public void setSourceUrl(String sourceUrl) {
this.sourceUrl = sourceUrl;
}
public String getCoverPath() {
return coverPath;
}
public void setCoverPath(String coverPath) {
this.coverPath = coverPath;
}
public Integer getWordCount() {
return wordCount;
}
public void setWordCount(Integer wordCount) {
this.wordCount = wordCount;
}
public Integer getReadingTimeMinutes() {
return readingTimeMinutes;
}
public void setReadingTimeMinutes(Integer readingTimeMinutes) {
this.readingTimeMinutes = readingTimeMinutes;
}
public Double getAverageRating() {
return averageRating;
}
public void setAverageRating(Double averageRating) {
this.averageRating = averageRating;
}
public Integer getTotalRatings() {
return totalRatings;
}
public void setTotalRatings(Integer totalRatings) {
this.totalRatings = totalRatings;
}
public Boolean getIsFavorite() {
return isFavorite;
}
public void setIsFavorite(Boolean isFavorite) {
this.isFavorite = isFavorite;
}
public Double getReadingProgress() {
return readingProgress;
}
public void setReadingProgress(Double readingProgress) {
this.readingProgress = readingProgress;
}
public LocalDateTime getLastReadAt() {
return lastReadAt;
}
public void setLastReadAt(LocalDateTime lastReadAt) {
this.lastReadAt = lastReadAt;
}
public Integer getPartNumber() {
return partNumber;
}
public void setPartNumber(Integer partNumber) {
this.partNumber = partNumber;
}
public UUID getAuthorId() {
return authorId;
}
public void setAuthorId(UUID authorId) {
this.authorId = authorId;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public UUID getSeriesId() {
return seriesId;
}
public void setSeriesId(UUID seriesId) {
this.seriesId = seriesId;
}
public String getSeriesName() {
return seriesName;
}
public void setSeriesName(String seriesName) {
this.seriesName = seriesName;
}
public List<String> getTagNames() {
return tagNames;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -0,0 +1,200 @@
package com.storycove.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "authors")
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank(message = "Author name is required")
@Size(max = 255, message = "Author name must not exceed 255 characters")
@Column(nullable = false)
private String name;
@Size(max = 1000, message = "Bio must not exceed 1000 characters")
@Column(length = 1000)
private String bio;
@Column(name = "avatar_path")
private String avatarPath;
@Column(name = "rating")
private Double rating = 0.0;
@ElementCollection
@CollectionTable(name = "author_urls", joinColumns = @JoinColumn(name = "author_id"))
@Column(name = "url")
private List<String> urls = new ArrayList<>();
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Story> stories = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Author() {}
public Author(String name) {
this.name = name;
}
public void addStory(Story story) {
stories.add(story);
story.setAuthor(this);
}
public void removeStory(Story story) {
stories.remove(story);
story.setAuthor(null);
}
public void addUrl(String url) {
if (url != null && !urls.contains(url)) {
urls.add(url);
}
}
public void removeUrl(String url) {
urls.remove(url);
}
public double getAverageStoryRating() {
if (stories.isEmpty()) {
return 0.0;
}
double totalRating = stories.stream()
.filter(story -> story.getTotalRatings() > 0)
.mapToDouble(story -> story.getAverageRating())
.sum();
long ratedStoriesCount = stories.stream()
.filter(story -> story.getTotalRatings() > 0)
.count();
return ratedStoriesCount > 0 ? totalRating / ratedStoriesCount : 0.0;
}
public int getTotalStoryRatings() {
return stories.stream()
.mapToInt(story -> story.getTotalRatings())
.sum();
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getBio() {
return bio;
}
public void setBio(String bio) {
this.bio = bio;
}
public String getAvatarPath() {
return avatarPath;
}
public void setAvatarPath(String avatarPath) {
this.avatarPath = avatarPath;
}
public Double getRating() {
return rating;
}
public void setRating(Double rating) {
this.rating = rating;
}
public List<String> getUrls() {
return urls;
}
public void setUrls(List<String> urls) {
this.urls = urls;
}
public List<Story> getStories() {
return stories;
}
public void setStories(List<Story> stories) {
this.stories = stories;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Author)) return false;
Author author = (Author) o;
return id != null && id.equals(author.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Author{" +
"id=" + id +
", name='" + name + '\'' +
", rating=" + rating +
", averageStoryRating=" + getAverageStoryRating() +
", totalStoryRatings=" + getTotalStoryRatings() +
'}';
}
}

View File

@@ -0,0 +1,183 @@
package com.storycove.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Entity
@Table(name = "series")
public class Series {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank(message = "Series name is required")
@Size(max = 255, message = "Series name must not exceed 255 characters")
@Column(nullable = false)
private String name;
@Size(max = 1000, message = "Series description must not exceed 1000 characters")
@Column(length = 1000)
private String description;
@Column(name = "total_parts")
private Integer totalParts = 0;
@Column(name = "is_complete")
private Boolean isComplete = false;
@OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@OrderBy("partNumber ASC")
private List<Story> stories = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Series() {}
public Series(String name) {
this.name = name;
}
public Series(String name, String description) {
this.name = name;
this.description = description;
}
public void addStory(Story story) {
stories.add(story);
story.setSeries(this);
updateTotalParts();
}
public void removeStory(Story story) {
stories.remove(story);
story.setSeries(null);
updateTotalParts();
}
private void updateTotalParts() {
this.totalParts = stories.size();
}
public Story getNextStory(Story currentStory) {
if (currentStory.getPartNumber() == null) return null;
return stories.stream()
.filter(story -> story.getPartNumber() != null)
.filter(story -> story.getPartNumber().equals(currentStory.getPartNumber() + 1))
.findFirst()
.orElse(null);
}
public Story getPreviousStory(Story currentStory) {
if (currentStory.getPartNumber() == null || currentStory.getPartNumber() <= 1) return null;
return stories.stream()
.filter(story -> story.getPartNumber() != null)
.filter(story -> story.getPartNumber().equals(currentStory.getPartNumber() - 1))
.findFirst()
.orElse(null);
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getTotalParts() {
return totalParts;
}
public void setTotalParts(Integer totalParts) {
this.totalParts = totalParts;
}
public Boolean getIsComplete() {
return isComplete;
}
public void setIsComplete(Boolean isComplete) {
this.isComplete = isComplete;
}
public List<Story> getStories() {
return stories;
}
public void setStories(List<Story> stories) {
this.stories = stories;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Series)) return false;
Series series = (Series) o;
return id != null && id.equals(series.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Series{" +
"id=" + id +
", name='" + name + '\'' +
", totalParts=" + totalParts +
", isComplete=" + isComplete +
'}';
}
}

View File

@@ -0,0 +1,317 @@
package com.storycove.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "stories")
public class Story {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank(message = "Story title is required")
@Size(max = 255, message = "Story title must not exceed 255 characters")
@Column(nullable = false)
private String title;
@Size(max = 1000, message = "Story description must not exceed 1000 characters")
@Column(length = 1000)
private String description;
@Column(columnDefinition = "TEXT")
private String content;
@Column(name = "source_url")
private String sourceUrl;
@Column(name = "cover_path")
private String coverPath;
@Column(name = "word_count")
private Integer wordCount = 0;
@Column(name = "reading_time_minutes")
private Integer readingTimeMinutes = 0;
@Column(name = "average_rating")
private Double averageRating = 0.0;
@Column(name = "total_ratings")
private Integer totalRatings = 0;
@Column(name = "is_favorite")
private Boolean isFavorite = false;
@Column(name = "reading_progress")
private Double readingProgress = 0.0;
@Column(name = "last_read_at")
private LocalDateTime lastReadAt;
@Column(name = "part_number")
private Integer partNumber;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "series_id")
private Series series;
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "story_tags",
joinColumns = @JoinColumn(name = "story_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private Set<Tag> tags = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Story() {}
public Story(String title) {
this.title = title;
}
public Story(String title, String content) {
this.title = title;
this.content = content;
updateWordCount();
}
public void addTag(Tag tag) {
tags.add(tag);
tag.getStories().add(this);
tag.incrementUsage();
}
public void removeTag(Tag tag) {
tags.remove(tag);
tag.getStories().remove(this);
tag.decrementUsage();
}
public void updateRating(double newRating) {
if (totalRatings == 0) {
averageRating = newRating;
totalRatings = 1;
} else {
double totalScore = averageRating * totalRatings;
totalRatings++;
averageRating = (totalScore + newRating) / totalRatings;
}
}
public void updateWordCount() {
if (content != null) {
String cleanText = content.replaceAll("<[^>]*>", "");
String[] words = cleanText.trim().split("\\s+");
this.wordCount = words.length;
this.readingTimeMinutes = Math.max(1, (int) Math.ceil(wordCount / 200.0));
}
}
public void updateReadingProgress(double progress) {
this.readingProgress = Math.max(0.0, Math.min(1.0, progress));
this.lastReadAt = LocalDateTime.now();
}
public boolean isPartOfSeries() {
return series != null && partNumber != null;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
updateWordCount();
}
public String getSourceUrl() {
return sourceUrl;
}
public void setSourceUrl(String sourceUrl) {
this.sourceUrl = sourceUrl;
}
public String getCoverPath() {
return coverPath;
}
public void setCoverPath(String coverPath) {
this.coverPath = coverPath;
}
public Integer getWordCount() {
return wordCount;
}
public void setWordCount(Integer wordCount) {
this.wordCount = wordCount;
}
public Integer getReadingTimeMinutes() {
return readingTimeMinutes;
}
public void setReadingTimeMinutes(Integer readingTimeMinutes) {
this.readingTimeMinutes = readingTimeMinutes;
}
public Double getAverageRating() {
return averageRating;
}
public void setAverageRating(Double averageRating) {
this.averageRating = averageRating;
}
public Integer getTotalRatings() {
return totalRatings;
}
public void setTotalRatings(Integer totalRatings) {
this.totalRatings = totalRatings;
}
public Boolean getIsFavorite() {
return isFavorite;
}
public void setIsFavorite(Boolean isFavorite) {
this.isFavorite = isFavorite;
}
public Double getReadingProgress() {
return readingProgress;
}
public void setReadingProgress(Double readingProgress) {
this.readingProgress = readingProgress;
}
public LocalDateTime getLastReadAt() {
return lastReadAt;
}
public void setLastReadAt(LocalDateTime lastReadAt) {
this.lastReadAt = lastReadAt;
}
public Integer getPartNumber() {
return partNumber;
}
public void setPartNumber(Integer partNumber) {
this.partNumber = partNumber;
}
public Author getAuthor() {
return author;
}
public void setAuthor(Author author) {
this.author = author;
}
public Series getSeries() {
return series;
}
public void setSeries(Series series) {
this.series = series;
}
public Set<Tag> getTags() {
return tags;
}
public void setTags(Set<Tag> tags) {
this.tags = tags;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Story)) return false;
Story story = (Story) o;
return id != null && id.equals(story.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Story{" +
"id=" + id +
", title='" + title + '\'' +
", wordCount=" + wordCount +
", averageRating=" + averageRating +
'}';
}
}

View File

@@ -0,0 +1,130 @@
package com.storycove.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.UUID;
@Entity
@Table(name = "tags")
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@NotBlank(message = "Tag name is required")
@Size(max = 50, message = "Tag name must not exceed 50 characters")
@Column(nullable = false, unique = true)
private String name;
@Size(max = 255, message = "Tag description must not exceed 255 characters")
private String description;
@Column(name = "usage_count")
private Integer usageCount = 0;
@ManyToMany(mappedBy = "tags")
private Set<Story> stories = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public Tag() {}
public Tag(String name) {
this.name = name;
}
public Tag(String name, String description) {
this.name = name;
this.description = description;
}
public void incrementUsage() {
this.usageCount++;
}
public void decrementUsage() {
if (this.usageCount > 0) {
this.usageCount--;
}
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getUsageCount() {
return usageCount;
}
public void setUsageCount(Integer usageCount) {
this.usageCount = usageCount;
}
public Set<Story> getStories() {
return stories;
}
public void setStories(Set<Story> stories) {
this.stories = stories;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Tag)) return false;
Tag tag = (Tag) o;
return id != null && id.equals(tag.id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
@Override
public String toString() {
return "Tag{" +
"id=" + id +
", name='" + name + '\'' +
", usageCount=" + usageCount +
'}';
}
}

View File

@@ -0,0 +1,49 @@
package com.storycove.repository;
import com.storycove.entity.Author;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface AuthorRepository extends JpaRepository<Author, UUID> {
Optional<Author> findByName(String name);
boolean existsByName(String name);
List<Author> findByNameContainingIgnoreCase(String name);
Page<Author> findByNameContainingIgnoreCase(String name, Pageable pageable);
@Query("SELECT a FROM Author a WHERE SIZE(a.stories) > 0")
List<Author> findAuthorsWithStories();
@Query("SELECT a FROM Author a WHERE SIZE(a.stories) > 0")
Page<Author> findAuthorsWithStories(Pageable pageable);
@Query("SELECT a FROM Author a ORDER BY a.rating DESC")
List<Author> findTopRatedAuthors();
@Query("SELECT a FROM Author a WHERE a.rating >= :minRating ORDER BY a.rating DESC")
List<Author> findAuthorsByMinimumRating(@Param("minRating") Double minRating);
@Query("SELECT a FROM Author a JOIN a.stories s GROUP BY a.id ORDER BY COUNT(s) DESC")
List<Author> findMostProlificAuthors();
@Query("SELECT a FROM Author a JOIN a.stories s GROUP BY a.id ORDER BY COUNT(s) DESC")
Page<Author> findMostProlificAuthors(Pageable pageable);
@Query("SELECT DISTINCT a FROM Author a JOIN a.urls u WHERE u LIKE %:domain%")
List<Author> findByUrlDomain(@Param("domain") String domain);
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= CURRENT_DATE - :days")
long countRecentAuthors(@Param("days") int days);
}

View File

@@ -0,0 +1,59 @@
package com.storycove.repository;
import com.storycove.entity.Series;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface SeriesRepository extends JpaRepository<Series, UUID> {
Optional<Series> findByName(String name);
boolean existsByName(String name);
List<Series> findByNameContainingIgnoreCase(String name);
Page<Series> findByNameContainingIgnoreCase(String name, Pageable pageable);
@Query("SELECT s FROM Series s WHERE SIZE(s.stories) > 0")
List<Series> findSeriesWithStories();
@Query("SELECT s FROM Series s WHERE SIZE(s.stories) > 0")
Page<Series> findSeriesWithStories(Pageable pageable);
List<Series> findByIsComplete(Boolean isComplete);
Page<Series> findByIsComplete(Boolean isComplete, Pageable pageable);
@Query("SELECT s FROM Series s WHERE s.totalParts >= :minParts ORDER BY s.totalParts DESC")
List<Series> findByMinimumParts(@Param("minParts") Integer minParts);
@Query("SELECT s FROM Series s ORDER BY s.totalParts DESC")
List<Series> findLongestSeries();
@Query("SELECT s FROM Series s ORDER BY s.totalParts DESC")
Page<Series> findLongestSeries(Pageable pageable);
@Query("SELECT s FROM Series s WHERE SIZE(s.stories) = 0")
List<Series> findEmptySeries();
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY AVG(st.averageRating) DESC")
List<Series> findTopRatedSeries();
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY AVG(st.averageRating) DESC")
Page<Series> findTopRatedSeries(Pageable pageable);
@Query("SELECT COUNT(s) FROM Series s WHERE s.createdAt >= CURRENT_DATE - :days")
long countRecentSeries(@Param("days") int days);
@Query("SELECT s FROM Series s WHERE s.isComplete = false AND SIZE(s.stories) > 0 ORDER BY s.updatedAt DESC")
List<Series> findIncompleteSeriesWithStories();
}

View File

@@ -0,0 +1,130 @@
package com.storycove.repository;
import com.storycove.entity.Author;
import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface StoryRepository extends JpaRepository<Story, UUID> {
Optional<Story> findByTitle(String title);
List<Story> findByTitleContainingIgnoreCase(String title);
Page<Story> findByTitleContainingIgnoreCase(String title, Pageable pageable);
List<Story> findByAuthor(Author author);
Page<Story> findByAuthor(Author author, Pageable pageable);
List<Story> findBySeries(Series series);
Page<Story> findBySeries(Series series, Pageable pageable);
@Query("SELECT s FROM Story s JOIN s.series ser WHERE ser.id = :seriesId ORDER BY s.partNumber ASC")
List<Story> findBySeriesOrderByPartNumber(@Param("seriesId") UUID seriesId);
@Query("SELECT s FROM Story s WHERE s.series.id = :seriesId AND s.partNumber = :partNumber")
Optional<Story> findBySeriesAndPartNumber(@Param("seriesId") UUID seriesId, @Param("partNumber") Integer partNumber);
List<Story> findByIsFavorite(Boolean isFavorite);
Page<Story> findByIsFavorite(Boolean isFavorite, Pageable pageable);
@Query("SELECT s FROM Story s JOIN s.tags t WHERE t = :tag")
List<Story> findByTag(@Param("tag") Tag tag);
@Query("SELECT s FROM Story s JOIN s.tags t WHERE t = :tag")
Page<Story> findByTag(@Param("tag") Tag tag, Pageable pageable);
@Query("SELECT DISTINCT s FROM Story s JOIN s.tags t WHERE t.name IN :tagNames")
List<Story> findByTagNames(@Param("tagNames") List<String> tagNames);
@Query("SELECT DISTINCT s FROM Story s JOIN s.tags t WHERE t.name IN :tagNames")
Page<Story> findByTagNames(@Param("tagNames") List<String> tagNames, Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.averageRating >= :minRating ORDER BY s.averageRating DESC")
List<Story> findByMinimumRating(@Param("minRating") Double minRating);
@Query("SELECT s FROM Story s WHERE s.averageRating >= :minRating ORDER BY s.averageRating DESC")
Page<Story> findByMinimumRating(@Param("minRating") Double minRating, Pageable pageable);
@Query("SELECT s FROM Story s ORDER BY s.averageRating DESC")
List<Story> findTopRatedStories();
@Query("SELECT s FROM Story s ORDER BY s.averageRating DESC")
Page<Story> findTopRatedStories(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.wordCount BETWEEN :minWords AND :maxWords")
List<Story> findByWordCountRange(@Param("minWords") Integer minWords, @Param("maxWords") Integer maxWords);
@Query("SELECT s FROM Story s WHERE s.wordCount BETWEEN :minWords AND :maxWords")
Page<Story> findByWordCountRange(@Param("minWords") Integer minWords, @Param("maxWords") Integer maxWords, Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.readingTimeMinutes BETWEEN :minTime AND :maxTime")
List<Story> findByReadingTimeRange(@Param("minTime") Integer minTime, @Param("maxTime") Integer maxTime);
@Query("SELECT s FROM Story s WHERE s.readingProgress > 0 ORDER BY s.lastReadAt DESC")
List<Story> findStoriesInProgress();
@Query("SELECT s FROM Story s WHERE s.readingProgress > 0 ORDER BY s.lastReadAt DESC")
Page<Story> findStoriesInProgress(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.readingProgress >= 1.0 ORDER BY s.lastReadAt DESC")
List<Story> findCompletedStories();
@Query("SELECT s FROM Story s WHERE s.readingProgress >= 1.0 ORDER BY s.lastReadAt DESC")
Page<Story> findCompletedStories(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.lastReadAt >= :since ORDER BY s.lastReadAt DESC")
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
@Query("SELECT s FROM Story s WHERE s.lastReadAt >= :since ORDER BY s.lastReadAt DESC")
Page<Story> findRecentlyRead(@Param("since") LocalDateTime since, Pageable pageable);
@Query("SELECT s FROM Story s ORDER BY s.createdAt DESC")
List<Story> findRecentlyAdded();
@Query("SELECT s FROM Story s ORDER BY s.createdAt DESC")
Page<Story> findRecentlyAdded(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.sourceUrl IS NOT NULL")
List<Story> findStoriesWithSourceUrl();
@Query("SELECT s FROM Story s WHERE s.coverPath IS NOT NULL")
List<Story> findStoriesWithCover();
@Query("SELECT COUNT(s) FROM Story s WHERE s.createdAt >= :since")
long countStoriesCreatedSince(@Param("since") LocalDateTime since);
@Query("SELECT AVG(s.wordCount) FROM Story s")
Double findAverageWordCount();
@Query("SELECT AVG(s.averageRating) FROM Story s WHERE s.totalRatings > 0")
Double findOverallAverageRating();
@Query("SELECT SUM(s.wordCount) FROM Story s")
Long findTotalWordCount();
@Query("SELECT s FROM Story s WHERE s.title LIKE %:keyword% OR s.description LIKE %:keyword%")
List<Story> findByKeyword(@Param("keyword") String keyword);
@Query("SELECT s FROM Story s WHERE s.title LIKE %:keyword% OR s.description LIKE %:keyword%")
Page<Story> findByKeyword(@Param("keyword") String keyword, Pageable pageable);
boolean existsBySourceUrl(String sourceUrl);
Optional<Story> findBySourceUrl(String sourceUrl);
}

View File

@@ -0,0 +1,52 @@
package com.storycove.repository;
import com.storycove.entity.Tag;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Repository
public interface TagRepository extends JpaRepository<Tag, UUID> {
Optional<Tag> findByName(String name);
boolean existsByName(String name);
List<Tag> findByNameContainingIgnoreCase(String name);
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > 0 ORDER BY t.usageCount DESC")
List<Tag> findUsedTags();
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > 0 ORDER BY t.usageCount DESC")
Page<Tag> findUsedTags(Pageable pageable);
@Query("SELECT t FROM Tag t ORDER BY t.usageCount DESC")
List<Tag> findMostUsedTags();
@Query("SELECT t FROM Tag t ORDER BY t.usageCount DESC")
Page<Tag> findMostUsedTags(Pageable pageable);
@Query("SELECT t FROM Tag t WHERE t.usageCount >= :minUsage ORDER BY t.usageCount DESC")
List<Tag> findTagsByMinimumUsage(@Param("minUsage") Integer minUsage);
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) = 0")
List<Tag> findUnusedTags();
@Query("SELECT t FROM Tag t WHERE t.usageCount > :threshold ORDER BY t.usageCount DESC")
List<Tag> findPopularTags(@Param("threshold") Integer threshold);
@Query("SELECT COUNT(t) FROM Tag t WHERE t.createdAt >= CURRENT_DATE - :days")
long countRecentTags(@Param("days") int days);
@Query("SELECT t FROM Tag t WHERE t.name IN :names")
List<Tag> findByNames(@Param("names") List<String> names);
}

View File

@@ -0,0 +1,201 @@
package com.storycove.service;
import com.storycove.entity.Author;
import com.storycove.repository.AuthorRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@Validated
@Transactional
public class AuthorService {
private final AuthorRepository authorRepository;
@Autowired
public AuthorService(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@Transactional(readOnly = true)
public List<Author> findAll() {
return authorRepository.findAll();
}
@Transactional(readOnly = true)
public Page<Author> findAll(Pageable pageable) {
return authorRepository.findAll(pageable);
}
@Transactional(readOnly = true)
public Author findById(UUID id) {
return authorRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Author", id.toString()));
}
@Transactional(readOnly = true)
public Optional<Author> findByIdOptional(UUID id) {
return authorRepository.findById(id);
}
@Transactional(readOnly = true)
public Author findByName(String name) {
return authorRepository.findByName(name)
.orElseThrow(() -> new ResourceNotFoundException("Author", name));
}
@Transactional(readOnly = true)
public Optional<Author> findByNameOptional(String name) {
return authorRepository.findByName(name);
}
@Transactional(readOnly = true)
public List<Author> searchByName(String name) {
return authorRepository.findByNameContainingIgnoreCase(name);
}
@Transactional(readOnly = true)
public Page<Author> searchByName(String name, Pageable pageable) {
return authorRepository.findByNameContainingIgnoreCase(name, pageable);
}
@Transactional(readOnly = true)
public List<Author> findAuthorsWithStories() {
return authorRepository.findAuthorsWithStories();
}
@Transactional(readOnly = true)
public Page<Author> findAuthorsWithStories(Pageable pageable) {
return authorRepository.findAuthorsWithStories(pageable);
}
@Transactional(readOnly = true)
public List<Author> findTopRatedAuthors() {
return authorRepository.findTopRatedAuthors();
}
@Transactional(readOnly = true)
public List<Author> findMostProlificAuthors() {
return authorRepository.findMostProlificAuthors();
}
@Transactional(readOnly = true)
public Page<Author> findMostProlificAuthors(Pageable pageable) {
return authorRepository.findMostProlificAuthors(pageable);
}
@Transactional(readOnly = true)
public List<Author> findByUrlDomain(String domain) {
return authorRepository.findByUrlDomain(domain);
}
@Transactional(readOnly = true)
public boolean existsByName(String name) {
return authorRepository.existsByName(name);
}
public Author create(@Valid Author author) {
validateAuthorForCreate(author);
return authorRepository.save(author);
}
public Author update(UUID id, @Valid Author authorUpdates) {
Author existingAuthor = findById(id);
// Check for name conflicts if name is being changed
if (!existingAuthor.getName().equals(authorUpdates.getName()) &&
existsByName(authorUpdates.getName())) {
throw new DuplicateResourceException("Author", authorUpdates.getName());
}
updateAuthorFields(existingAuthor, authorUpdates);
return authorRepository.save(existingAuthor);
}
public void delete(UUID id) {
Author author = findById(id);
// Check if author has stories
if (!author.getStories().isEmpty()) {
throw new IllegalStateException("Cannot delete author with existing stories. Delete stories first or reassign them to another author.");
}
authorRepository.delete(author);
}
public Author addUrl(UUID id, String url) {
Author author = findById(id);
author.addUrl(url);
return authorRepository.save(author);
}
public Author removeUrl(UUID id, String url) {
Author author = findById(id);
author.removeUrl(url);
return authorRepository.save(author);
}
public Author setDirectRating(UUID id, double rating) {
if (rating < 0 || rating > 5) {
throw new IllegalArgumentException("Rating must be between 0 and 5");
}
Author author = findById(id);
author.setRating(rating);
return authorRepository.save(author);
}
public Author setAvatar(UUID id, String avatarPath) {
Author author = findById(id);
author.setAvatarPath(avatarPath);
return authorRepository.save(author);
}
public Author removeAvatar(UUID id) {
Author author = findById(id);
author.setAvatarPath(null);
return authorRepository.save(author);
}
@Transactional(readOnly = true)
public long countRecentAuthors(int days) {
return authorRepository.countRecentAuthors(days);
}
private void validateAuthorForCreate(Author author) {
if (existsByName(author.getName())) {
throw new DuplicateResourceException("Author", author.getName());
}
}
private void updateAuthorFields(Author existing, Author updates) {
if (updates.getName() != null) {
existing.setName(updates.getName());
}
if (updates.getBio() != null) {
existing.setBio(updates.getBio());
}
if (updates.getAvatarPath() != null) {
existing.setAvatarPath(updates.getAvatarPath());
}
if (updates.getRating() != null) {
existing.setRating(updates.getRating());
}
if (updates.getUrls() != null && !updates.getUrls().isEmpty()) {
existing.getUrls().clear();
existing.getUrls().addAll(updates.getUrls());
}
}
}

View File

@@ -0,0 +1,237 @@
package com.storycove.service;
import com.storycove.entity.Series;
import com.storycove.repository.SeriesRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@Validated
@Transactional
public class SeriesService {
private final SeriesRepository seriesRepository;
@Autowired
public SeriesService(SeriesRepository seriesRepository) {
this.seriesRepository = seriesRepository;
}
@Transactional(readOnly = true)
public List<Series> findAll() {
return seriesRepository.findAll();
}
@Transactional(readOnly = true)
public Page<Series> findAll(Pageable pageable) {
return seriesRepository.findAll(pageable);
}
@Transactional(readOnly = true)
public Series findById(UUID id) {
return seriesRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Series", id.toString()));
}
@Transactional(readOnly = true)
public Optional<Series> findByIdOptional(UUID id) {
return seriesRepository.findById(id);
}
@Transactional(readOnly = true)
public Series findByName(String name) {
return seriesRepository.findByName(name)
.orElseThrow(() -> new ResourceNotFoundException("Series", name));
}
@Transactional(readOnly = true)
public Optional<Series> findByNameOptional(String name) {
return seriesRepository.findByName(name);
}
@Transactional(readOnly = true)
public List<Series> searchByName(String name) {
return seriesRepository.findByNameContainingIgnoreCase(name);
}
@Transactional(readOnly = true)
public Page<Series> searchByName(String name, Pageable pageable) {
return seriesRepository.findByNameContainingIgnoreCase(name, pageable);
}
@Transactional(readOnly = true)
public List<Series> findSeriesWithStories() {
return seriesRepository.findSeriesWithStories();
}
@Transactional(readOnly = true)
public Page<Series> findSeriesWithStories(Pageable pageable) {
return seriesRepository.findSeriesWithStories(pageable);
}
@Transactional(readOnly = true)
public List<Series> findCompleteSeries() {
return seriesRepository.findByIsComplete(true);
}
@Transactional(readOnly = true)
public Page<Series> findCompleteSeries(Pageable pageable) {
return seriesRepository.findByIsComplete(true, pageable);
}
@Transactional(readOnly = true)
public List<Series> findIncompleteSeries() {
return seriesRepository.findByIsComplete(false);
}
@Transactional(readOnly = true)
public Page<Series> findIncompleteSeries(Pageable pageable) {
return seriesRepository.findByIsComplete(false, pageable);
}
@Transactional(readOnly = true)
public List<Series> findIncompleteSeriesWithStories() {
return seriesRepository.findIncompleteSeriesWithStories();
}
@Transactional(readOnly = true)
public List<Series> findLongestSeries() {
return seriesRepository.findLongestSeries();
}
@Transactional(readOnly = true)
public Page<Series> findLongestSeries(Pageable pageable) {
return seriesRepository.findLongestSeries(pageable);
}
@Transactional(readOnly = true)
public List<Series> findTopRatedSeries() {
return seriesRepository.findTopRatedSeries();
}
@Transactional(readOnly = true)
public Page<Series> findTopRatedSeries(Pageable pageable) {
return seriesRepository.findTopRatedSeries(pageable);
}
@Transactional(readOnly = true)
public List<Series> findByMinimumParts(Integer minParts) {
return seriesRepository.findByMinimumParts(minParts);
}
@Transactional(readOnly = true)
public List<Series> findEmptySeries() {
return seriesRepository.findEmptySeries();
}
@Transactional(readOnly = true)
public boolean existsByName(String name) {
return seriesRepository.existsByName(name);
}
public Series create(@Valid Series series) {
validateSeriesForCreate(series);
return seriesRepository.save(series);
}
public Series update(UUID id, @Valid Series seriesUpdates) {
Series existingSeries = findById(id);
// Check for name conflicts if name is being changed
if (!existingSeries.getName().equals(seriesUpdates.getName()) &&
existsByName(seriesUpdates.getName())) {
throw new DuplicateResourceException("Series", seriesUpdates.getName());
}
updateSeriesFields(existingSeries, seriesUpdates);
return seriesRepository.save(existingSeries);
}
public void delete(UUID id) {
Series series = findById(id);
// Check if series has stories
if (!series.getStories().isEmpty()) {
throw new IllegalStateException("Cannot delete series with existing stories. Delete stories first or remove them from the series.");
}
seriesRepository.delete(series);
}
public Series markComplete(UUID id) {
Series series = findById(id);
series.setIsComplete(true);
return seriesRepository.save(series);
}
public Series markIncomplete(UUID id) {
Series series = findById(id);
series.setIsComplete(false);
return seriesRepository.save(series);
}
public List<Series> deleteEmptySeries() {
List<Series> emptySeries = findEmptySeries();
seriesRepository.deleteAll(emptySeries);
return emptySeries;
}
public Series findOrCreate(String name) {
return findByNameOptional(name)
.orElseGet(() -> create(new Series(name)));
}
public Series findOrCreate(String name, String description) {
return findByNameOptional(name)
.orElseGet(() -> create(new Series(name, description)));
}
@Transactional(readOnly = true)
public long countRecentSeries(int days) {
return seriesRepository.countRecentSeries(days);
}
@Transactional(readOnly = true)
public long getTotalCount() {
return seriesRepository.count();
}
@Transactional(readOnly = true)
public long getActiveSeriesCount() {
return findSeriesWithStories().size();
}
@Transactional(readOnly = true)
public long getCompleteSeriesCount() {
return seriesRepository.findByIsComplete(true).size();
}
private void validateSeriesForCreate(Series series) {
if (existsByName(series.getName())) {
throw new DuplicateResourceException("Series", series.getName());
}
}
private void updateSeriesFields(Series existing, Series updates) {
if (updates.getName() != null) {
existing.setName(updates.getName());
}
if (updates.getDescription() != null) {
existing.setDescription(updates.getDescription());
}
if (updates.getIsComplete() != null) {
existing.setIsComplete(updates.getIsComplete());
}
}
}

View File

@@ -0,0 +1,423 @@
package com.storycove.service;
import com.storycove.entity.Author;
import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.repository.StoryRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
@Service
@Validated
@Transactional
public class StoryService {
private final StoryRepository storyRepository;
private final AuthorService authorService;
private final TagService tagService;
private final SeriesService seriesService;
@Autowired
public StoryService(StoryRepository storyRepository,
AuthorService authorService,
TagService tagService,
SeriesService seriesService) {
this.storyRepository = storyRepository;
this.authorService = authorService;
this.tagService = tagService;
this.seriesService = seriesService;
}
@Transactional(readOnly = true)
public List<Story> findAll() {
return storyRepository.findAll();
}
@Transactional(readOnly = true)
public Page<Story> findAll(Pageable pageable) {
return storyRepository.findAll(pageable);
}
@Transactional(readOnly = true)
public Story findById(UUID id) {
return storyRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Story", id.toString()));
}
@Transactional(readOnly = true)
public Optional<Story> findByIdOptional(UUID id) {
return storyRepository.findById(id);
}
@Transactional(readOnly = true)
public Optional<Story> findByTitle(String title) {
return storyRepository.findByTitle(title);
}
@Transactional(readOnly = true)
public Optional<Story> findBySourceUrl(String sourceUrl) {
return storyRepository.findBySourceUrl(sourceUrl);
}
@Transactional(readOnly = true)
public List<Story> searchByTitle(String title) {
return storyRepository.findByTitleContainingIgnoreCase(title);
}
@Transactional(readOnly = true)
public Page<Story> searchByTitle(String title, Pageable pageable) {
return storyRepository.findByTitleContainingIgnoreCase(title, pageable);
}
@Transactional(readOnly = true)
public List<Story> findByAuthor(UUID authorId) {
Author author = authorService.findById(authorId);
return storyRepository.findByAuthor(author);
}
@Transactional(readOnly = true)
public Page<Story> findByAuthor(UUID authorId, Pageable pageable) {
Author author = authorService.findById(authorId);
return storyRepository.findByAuthor(author, pageable);
}
@Transactional(readOnly = true)
public List<Story> findBySeries(UUID seriesId) {
Series series = seriesService.findById(seriesId);
return storyRepository.findBySeriesOrderByPartNumber(seriesId);
}
@Transactional(readOnly = true)
public Page<Story> findBySeries(UUID seriesId, Pageable pageable) {
Series series = seriesService.findById(seriesId);
return storyRepository.findBySeries(series, pageable);
}
@Transactional(readOnly = true)
public Optional<Story> findBySeriesAndPartNumber(UUID seriesId, Integer partNumber) {
return storyRepository.findBySeriesAndPartNumber(seriesId, partNumber);
}
@Transactional(readOnly = true)
public List<Story> findByTag(UUID tagId) {
Tag tag = tagService.findById(tagId);
return storyRepository.findByTag(tag);
}
@Transactional(readOnly = true)
public Page<Story> findByTag(UUID tagId, Pageable pageable) {
Tag tag = tagService.findById(tagId);
return storyRepository.findByTag(tag, pageable);
}
@Transactional(readOnly = true)
public List<Story> findByTagNames(List<String> tagNames) {
return storyRepository.findByTagNames(tagNames);
}
@Transactional(readOnly = true)
public Page<Story> findByTagNames(List<String> tagNames, Pageable pageable) {
return storyRepository.findByTagNames(tagNames, pageable);
}
@Transactional(readOnly = true)
public List<Story> findFavorites() {
return storyRepository.findByIsFavorite(true);
}
@Transactional(readOnly = true)
public Page<Story> findFavorites(Pageable pageable) {
return storyRepository.findByIsFavorite(true, pageable);
}
@Transactional(readOnly = true)
public List<Story> findStoriesInProgress() {
return storyRepository.findStoriesInProgress();
}
@Transactional(readOnly = true)
public Page<Story> findStoriesInProgress(Pageable pageable) {
return storyRepository.findStoriesInProgress(pageable);
}
@Transactional(readOnly = true)
public List<Story> findCompletedStories() {
return storyRepository.findCompletedStories();
}
@Transactional(readOnly = true)
public List<Story> findRecentlyRead(int hours) {
LocalDateTime since = LocalDateTime.now().minusHours(hours);
return storyRepository.findRecentlyRead(since);
}
@Transactional(readOnly = true)
public List<Story> findRecentlyAdded() {
return storyRepository.findRecentlyAdded();
}
@Transactional(readOnly = true)
public Page<Story> findRecentlyAdded(Pageable pageable) {
return storyRepository.findRecentlyAdded(pageable);
}
@Transactional(readOnly = true)
public List<Story> findTopRated() {
return storyRepository.findTopRatedStories();
}
@Transactional(readOnly = true)
public Page<Story> findTopRated(Pageable pageable) {
return storyRepository.findTopRatedStories(pageable);
}
@Transactional(readOnly = true)
public List<Story> findByWordCountRange(Integer minWords, Integer maxWords) {
return storyRepository.findByWordCountRange(minWords, maxWords);
}
@Transactional(readOnly = true)
public List<Story> searchByKeyword(String keyword) {
return storyRepository.findByKeyword(keyword);
}
@Transactional(readOnly = true)
public Page<Story> searchByKeyword(String keyword, Pageable pageable) {
return storyRepository.findByKeyword(keyword, pageable);
}
public Story create(@Valid Story story) {
validateStoryForCreate(story);
// Set up relationships
if (story.getAuthor() != null && story.getAuthor().getId() != null) {
Author author = authorService.findById(story.getAuthor().getId());
story.setAuthor(author);
}
if (story.getSeries() != null && story.getSeries().getId() != null) {
Series series = seriesService.findById(story.getSeries().getId());
story.setSeries(series);
validateSeriesPartNumber(series, story.getPartNumber());
}
Story savedStory = storyRepository.save(story);
// Handle tags
if (story.getTags() != null && !story.getTags().isEmpty()) {
updateStoryTags(savedStory, story.getTags());
}
return savedStory;
}
public Story update(UUID id, @Valid Story storyUpdates) {
Story existingStory = findById(id);
// Check for source URL conflicts if URL is being changed
if (storyUpdates.getSourceUrl() != null &&
!storyUpdates.getSourceUrl().equals(existingStory.getSourceUrl()) &&
storyRepository.existsBySourceUrl(storyUpdates.getSourceUrl())) {
throw new DuplicateResourceException("Story with source URL", storyUpdates.getSourceUrl());
}
updateStoryFields(existingStory, storyUpdates);
return storyRepository.save(existingStory);
}
public void delete(UUID id) {
Story story = findById(id);
// Remove from series if part of one
if (story.getSeries() != null) {
story.getSeries().removeStory(story);
}
// Remove tags (this will update tag usage counts)
story.getTags().forEach(tag -> story.removeTag(tag));
storyRepository.delete(story);
}
public Story addToFavorites(UUID id) {
Story story = findById(id);
story.setIsFavorite(true);
return storyRepository.save(story);
}
public Story removeFromFavorites(UUID id) {
Story story = findById(id);
story.setIsFavorite(false);
return storyRepository.save(story);
}
public Story updateReadingProgress(UUID id, double progress) {
if (progress < 0 || progress > 1) {
throw new IllegalArgumentException("Reading progress must be between 0 and 1");
}
Story story = findById(id);
story.updateReadingProgress(progress);
return storyRepository.save(story);
}
public Story updateRating(UUID id, double rating) {
if (rating < 0 || rating > 5) {
throw new IllegalArgumentException("Rating must be between 0 and 5");
}
Story story = findById(id);
story.updateRating(rating);
// Note: Author's average story rating will be calculated dynamically
return storyRepository.save(story);
}
public Story setCover(UUID id, String coverPath) {
Story story = findById(id);
story.setCoverPath(coverPath);
return storyRepository.save(story);
}
public Story removeCover(UUID id) {
Story story = findById(id);
story.setCoverPath(null);
return storyRepository.save(story);
}
public Story addToSeries(UUID storyId, UUID seriesId, Integer partNumber) {
Story story = findById(storyId);
Series series = seriesService.findById(seriesId);
validateSeriesPartNumber(series, partNumber);
story.setSeries(series);
story.setPartNumber(partNumber);
series.addStory(story);
return storyRepository.save(story);
}
public Story removeFromSeries(UUID storyId) {
Story story = findById(storyId);
if (story.getSeries() != null) {
story.getSeries().removeStory(story);
story.setSeries(null);
story.setPartNumber(null);
}
return storyRepository.save(story);
}
@Transactional(readOnly = true)
public boolean existsBySourceUrl(String sourceUrl) {
return storyRepository.existsBySourceUrl(sourceUrl);
}
@Transactional(readOnly = true)
public Double getAverageWordCount() {
return storyRepository.findAverageWordCount();
}
@Transactional(readOnly = true)
public Double getOverallAverageRating() {
return storyRepository.findOverallAverageRating();
}
@Transactional(readOnly = true)
public Long getTotalWordCount() {
return storyRepository.findTotalWordCount();
}
private void validateStoryForCreate(Story story) {
if (story.getSourceUrl() != null && existsBySourceUrl(story.getSourceUrl())) {
throw new DuplicateResourceException("Story with source URL", story.getSourceUrl());
}
}
private void validateSeriesPartNumber(Series series, Integer partNumber) {
if (partNumber != null) {
Optional<Story> existingPart = storyRepository.findBySeriesAndPartNumber(series.getId(), partNumber);
if (existingPart.isPresent()) {
throw new DuplicateResourceException("Story", "part " + partNumber + " of series " + series.getName());
}
}
}
private void updateStoryFields(Story existing, Story updates) {
if (updates.getTitle() != null) {
existing.setTitle(updates.getTitle());
}
if (updates.getDescription() != null) {
existing.setDescription(updates.getDescription());
}
if (updates.getContent() != null) {
existing.setContent(updates.getContent());
}
if (updates.getSourceUrl() != null) {
existing.setSourceUrl(updates.getSourceUrl());
}
if (updates.getCoverPath() != null) {
existing.setCoverPath(updates.getCoverPath());
}
if (updates.getIsFavorite() != null) {
existing.setIsFavorite(updates.getIsFavorite());
}
// Handle author update
if (updates.getAuthor() != null && updates.getAuthor().getId() != null) {
Author author = authorService.findById(updates.getAuthor().getId());
existing.setAuthor(author);
}
// Handle series update
if (updates.getSeries() != null && updates.getSeries().getId() != null) {
Series series = seriesService.findById(updates.getSeries().getId());
existing.setSeries(series);
if (updates.getPartNumber() != null) {
validateSeriesPartNumber(series, updates.getPartNumber());
existing.setPartNumber(updates.getPartNumber());
}
}
// Handle tags update
if (updates.getTags() != null) {
updateStoryTags(existing, updates.getTags());
}
}
private void updateStoryTags(Story story, Set<Tag> newTags) {
// Remove existing tags
story.getTags().forEach(tag -> story.removeTag(tag));
story.getTags().clear();
// Add new tags
for (Tag tag : newTags) {
Tag managedTag;
if (tag.getId() != null) {
managedTag = tagService.findById(tag.getId());
} else {
// Try to find existing tag by name or create new one
managedTag = tagService.findByNameOptional(tag.getName())
.orElseGet(() -> tagService.create(tag));
}
story.addTag(managedTag);
}
}
}

View File

@@ -0,0 +1,187 @@
package com.storycove.service;
import com.storycove.entity.Tag;
import com.storycove.repository.TagRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Service
@Validated
@Transactional
public class TagService {
private final TagRepository tagRepository;
@Autowired
public TagService(TagRepository tagRepository) {
this.tagRepository = tagRepository;
}
@Transactional(readOnly = true)
public List<Tag> findAll() {
return tagRepository.findAll();
}
@Transactional(readOnly = true)
public Page<Tag> findAll(Pageable pageable) {
return tagRepository.findAll(pageable);
}
@Transactional(readOnly = true)
public Tag findById(UUID id) {
return tagRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Tag", id.toString()));
}
@Transactional(readOnly = true)
public Optional<Tag> findByIdOptional(UUID id) {
return tagRepository.findById(id);
}
@Transactional(readOnly = true)
public Tag findByName(String name) {
return tagRepository.findByName(name)
.orElseThrow(() -> new ResourceNotFoundException("Tag", name));
}
@Transactional(readOnly = true)
public Optional<Tag> findByNameOptional(String name) {
return tagRepository.findByName(name);
}
@Transactional(readOnly = true)
public List<Tag> searchByName(String name) {
return tagRepository.findByNameContainingIgnoreCase(name);
}
@Transactional(readOnly = true)
public Page<Tag> searchByName(String name, Pageable pageable) {
return tagRepository.findByNameContainingIgnoreCase(name, pageable);
}
@Transactional(readOnly = true)
public List<Tag> findUsedTags() {
return tagRepository.findUsedTags();
}
@Transactional(readOnly = true)
public Page<Tag> findUsedTags(Pageable pageable) {
return tagRepository.findUsedTags(pageable);
}
@Transactional(readOnly = true)
public List<Tag> findMostUsedTags() {
return tagRepository.findMostUsedTags();
}
@Transactional(readOnly = true)
public Page<Tag> findMostUsedTags(Pageable pageable) {
return tagRepository.findMostUsedTags(pageable);
}
@Transactional(readOnly = true)
public List<Tag> findPopularTags(Integer threshold) {
return tagRepository.findPopularTags(threshold);
}
@Transactional(readOnly = true)
public List<Tag> findUnusedTags() {
return tagRepository.findUnusedTags();
}
@Transactional(readOnly = true)
public List<Tag> findByNames(List<String> names) {
return tagRepository.findByNames(names);
}
@Transactional(readOnly = true)
public boolean existsByName(String name) {
return tagRepository.existsByName(name);
}
public Tag create(@Valid Tag tag) {
validateTagForCreate(tag);
return tagRepository.save(tag);
}
public Tag update(UUID id, @Valid Tag tagUpdates) {
Tag existingTag = findById(id);
// Check for name conflicts if name is being changed
if (!existingTag.getName().equals(tagUpdates.getName()) &&
existsByName(tagUpdates.getName())) {
throw new DuplicateResourceException("Tag", tagUpdates.getName());
}
updateTagFields(existingTag, tagUpdates);
return tagRepository.save(existingTag);
}
public void delete(UUID id) {
Tag tag = findById(id);
// Check if tag is used by any stories
if (!tag.getStories().isEmpty()) {
throw new IllegalStateException("Cannot delete tag that is used by stories. Remove tag from all stories first.");
}
tagRepository.delete(tag);
}
public List<Tag> deleteUnusedTags() {
List<Tag> unusedTags = findUnusedTags();
tagRepository.deleteAll(unusedTags);
return unusedTags;
}
public Tag findOrCreate(String name) {
return findByNameOptional(name)
.orElseGet(() -> create(new Tag(name)));
}
public Tag findOrCreate(String name, String description) {
return findByNameOptional(name)
.orElseGet(() -> create(new Tag(name, description)));
}
@Transactional(readOnly = true)
public long countRecentTags(int days) {
return tagRepository.countRecentTags(days);
}
@Transactional(readOnly = true)
public long getTotalCount() {
return tagRepository.count();
}
@Transactional(readOnly = true)
public long getUsedTagCount() {
return findUsedTags().size();
}
private void validateTagForCreate(Tag tag) {
if (existsByName(tag.getName())) {
throw new DuplicateResourceException("Tag", tag.getName());
}
}
private void updateTagFields(Tag existing, Tag updates) {
if (updates.getName() != null) {
existing.setName(updates.getName());
}
if (updates.getDescription() != null) {
existing.setDescription(updates.getDescription());
}
}
}

View File

@@ -0,0 +1,16 @@
package com.storycove.service.exception;
public class DuplicateResourceException extends RuntimeException {
public DuplicateResourceException(String message) {
super(message);
}
public DuplicateResourceException(String resourceType, String identifier) {
super(String.format("%s already exists with identifier: %s", resourceType, identifier));
}
public DuplicateResourceException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,16 @@
package com.storycove.service.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
public ResourceNotFoundException(String resourceType, String identifier) {
super(String.format("%s not found with identifier: %s", resourceType, identifier));
}
public ResourceNotFoundException(String message, Throwable cause) {
super(message, cause);
}
}