MVP Version

This commit is contained in:
Stefan Hardegger
2025-07-23 12:28:48 +02:00
parent 59d29dceaf
commit d69bed00a2
22 changed files with 1781 additions and 153 deletions

View File

@@ -2,8 +2,10 @@ package com.storycove;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class StoryCoveApplication {
public static void main(String[] args) {

View File

@@ -1,10 +1,16 @@
package com.storycove.controller;
import com.storycove.dto.AuthorDto;
import com.storycove.dto.AuthorSearchDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.entity.Author;
import com.storycove.service.AuthorService;
import com.storycove.service.ImageService;
import com.storycove.service.TypesenseService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
@@ -14,6 +20,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -23,12 +30,16 @@ import java.util.stream.Collectors;
@RequestMapping("/api/authors")
public class AuthorController {
private static final Logger logger = LoggerFactory.getLogger(AuthorController.class);
private final AuthorService authorService;
private final ImageService imageService;
private final TypesenseService typesenseService;
public AuthorController(AuthorService authorService, ImageService imageService) {
public AuthorController(AuthorService authorService, ImageService imageService, TypesenseService typesenseService) {
this.authorService = authorService;
this.imageService = imageService;
this.typesenseService = typesenseService;
}
@GetMapping
@@ -63,9 +74,65 @@ public class AuthorController {
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedAuthor));
}
@PutMapping("/{id}")
public ResponseEntity<AuthorDto> updateAuthor(@PathVariable UUID id,
@Valid @RequestBody UpdateAuthorRequest request) {
@PutMapping(value = "/{id}", consumes = "multipart/form-data")
public ResponseEntity<AuthorDto> updateAuthorMultipart(
@PathVariable UUID id,
@RequestParam(required = false) String name,
@RequestParam(required = false) String notes,
@RequestParam(required = false) List<String> urls,
@RequestParam(required = false, name = "authorRating") Integer rating,
@RequestParam(required = false, name = "avatar") MultipartFile avatarFile) {
System.out.println("DEBUG: MULTIPART PUT called with:");
System.out.println(" - name: " + name);
System.out.println(" - notes: " + notes);
System.out.println(" - urls: " + urls);
System.out.println(" - rating: " + rating);
System.out.println(" - avatar: " + (avatarFile != null ? avatarFile.getOriginalFilename() : "null"));
try {
Author existingAuthor = authorService.findById(id);
// Update basic fields
if (name != null && !name.trim().isEmpty()) {
existingAuthor.setName(name.trim());
}
if (notes != null) {
existingAuthor.setNotes(notes);
}
if (urls != null) {
existingAuthor.setUrls(urls);
}
// Handle rating update
if (rating != null) {
System.out.println("DEBUG: Setting author rating via PUT: " + rating);
existingAuthor.setAuthorRating(rating);
}
// Handle avatar upload if provided
if (avatarFile != null && !avatarFile.isEmpty()) {
String imagePath = imageService.uploadImage(avatarFile, ImageService.ImageType.AVATAR);
existingAuthor.setAvatarImagePath(imagePath);
}
Author updatedAuthor = authorService.update(id, existingAuthor);
return ResponseEntity.ok(convertToDto(updatedAuthor));
} catch (Exception e) {
return ResponseEntity.badRequest().body(null);
}
}
@PutMapping(value = "/{id}", consumes = "application/json")
public ResponseEntity<AuthorDto> updateAuthorJson(@PathVariable UUID id,
@Valid @RequestBody UpdateAuthorRequest request) {
System.out.println("DEBUG: JSON PUT called with:");
System.out.println(" - name: " + request.getName());
System.out.println(" - notes: " + request.getNotes());
System.out.println(" - urls: " + request.getUrls());
System.out.println(" - rating: " + request.getRating());
Author existingAuthor = authorService.findById(id);
updateAuthorFromRequest(existingAuthor, request);
@@ -73,6 +140,15 @@ public class AuthorController {
return ResponseEntity.ok(convertToDto(updatedAuthor));
}
@PutMapping("/{id}")
public ResponseEntity<String> updateAuthorGeneric(@PathVariable UUID id, HttpServletRequest request) {
System.out.println("DEBUG: GENERIC PUT called!");
System.out.println(" - Content-Type: " + request.getContentType());
System.out.println(" - Method: " + request.getMethod());
return ResponseEntity.status(415).body("Unsupported Media Type. Expected multipart/form-data or application/json");
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteAuthor(@PathVariable UUID id) {
authorService.delete(id);
@@ -103,8 +179,79 @@ public class AuthorController {
@PostMapping("/{id}/rating")
public ResponseEntity<AuthorDto> rateAuthor(@PathVariable UUID id, @RequestBody RatingRequest request) {
System.out.println("DEBUG: Rating author " + id + " with rating " + request.getRating());
Author author = authorService.setRating(id, request.getRating());
return ResponseEntity.ok(convertToDto(author));
System.out.println("DEBUG: After setRating, author rating is: " + author.getAuthorRating());
AuthorDto dto = convertToDto(author);
System.out.println("DEBUG: Final DTO rating is: " + dto.getAuthorRating());
return ResponseEntity.ok(dto);
}
@GetMapping("/{id}/debug")
public ResponseEntity<Map<String, Object>> debugAuthor(@PathVariable UUID id) {
Author author = authorService.findById(id);
Integer directDbRating = null;
String dbError = null;
try {
directDbRating = authorService.getAuthorRatingFromDb(id);
} catch (Exception e) {
dbError = e.getMessage();
}
Map<String, Object> debug = Map.of(
"authorId", author.getId(),
"authorName", author.getName(),
"authorRating_entity", author.getAuthorRating(),
"authorRating_direct_db", directDbRating,
"db_error", dbError,
"toString", author.toString()
);
return ResponseEntity.ok(debug);
}
@PostMapping("/{id}/test-rating/{rating}")
public ResponseEntity<Map<String, Object>> testSetRating(@PathVariable UUID id, @PathVariable Integer rating) {
try {
System.out.println("DEBUG: Test setting rating " + rating + " for author " + id);
Author author = authorService.setRating(id, rating);
System.out.println("DEBUG: After test setRating, got: " + author.getAuthorRating());
return ResponseEntity.ok(Map.of(
"success", true,
"authorRating", author.getAuthorRating(),
"message", "Rating set successfully"
));
} catch (Exception e) {
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@PostMapping("/{id}/test-put-rating")
public ResponseEntity<Map<String, Object>> testPutWithRating(@PathVariable UUID id, @RequestParam Integer rating) {
try {
System.out.println("DEBUG: Test PUT with rating " + rating + " for author " + id);
Author existingAuthor = authorService.findById(id);
existingAuthor.setAuthorRating(rating);
Author updatedAuthor = authorService.update(id, existingAuthor);
System.out.println("DEBUG: After PUT update, rating is: " + updatedAuthor.getAuthorRating());
return ResponseEntity.ok(Map.of(
"success", true,
"authorRating", updatedAuthor.getAuthorRating(),
"message", "Rating updated via PUT successfully"
));
} catch (Exception e) {
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@GetMapping("/search")
@@ -120,6 +267,91 @@ public class AuthorController {
return ResponseEntity.ok(authorDtos);
}
@GetMapping("/search-typesense")
public ResponseEntity<SearchResultDto<AuthorDto>> searchAuthorsTypesense(
@RequestParam(defaultValue = "*") String q,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String sortOrder) {
SearchResultDto<AuthorSearchDto> searchResults = typesenseService.searchAuthors(q, page, size, sortBy, sortOrder);
// Convert AuthorSearchDto results to AuthorDto
SearchResultDto<AuthorDto> results = new SearchResultDto<>();
results.setQuery(searchResults.getQuery());
results.setPage(searchResults.getPage());
results.setPerPage(searchResults.getPerPage());
results.setTotalHits(searchResults.getTotalHits());
results.setSearchTimeMs(searchResults.getSearchTimeMs());
// Handle null results gracefully
List<AuthorDto> authorDtos = searchResults.getResults() != null
? searchResults.getResults().stream()
.map(this::convertSearchDtoToDto)
.collect(Collectors.toList())
: new ArrayList<>();
results.setResults(authorDtos);
return ResponseEntity.ok(results);
}
@PostMapping("/reindex-typesense")
public ResponseEntity<Map<String, Object>> reindexAuthorsTypesense() {
try {
List<Author> allAuthors = authorService.findAllWithStories();
typesenseService.reindexAllAuthors(allAuthors);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Reindexed " + allAuthors.size() + " authors",
"count", allAuthors.size()
));
} catch (Exception e) {
logger.error("Failed to reindex authors", e);
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@PostMapping("/recreate-typesense-collection")
public ResponseEntity<Map<String, Object>> recreateAuthorsCollection() {
try {
// This will delete the existing collection and recreate it with correct schema
List<Author> allAuthors = authorService.findAllWithStories();
typesenseService.reindexAllAuthors(allAuthors);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Recreated authors collection and indexed " + allAuthors.size() + " authors",
"count", allAuthors.size()
));
} catch (Exception e) {
logger.error("Failed to recreate authors collection", e);
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@GetMapping("/typesense-schema")
public ResponseEntity<Map<String, Object>> getAuthorsTypesenseSchema() {
try {
Map<String, Object> schema = typesenseService.getAuthorsCollectionSchema();
return ResponseEntity.ok(Map.of(
"success", true,
"schema", schema
));
} catch (Exception e) {
logger.error("Failed to get authors schema", e);
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@GetMapping("/top-rated")
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit);
@@ -158,6 +390,10 @@ public class AuthorController {
if (updateReq.getUrls() != null) {
author.setUrls(updateReq.getUrls());
}
if (updateReq.getRating() != null) {
System.out.println("DEBUG: Setting author rating via JSON: " + updateReq.getRating());
author.setAuthorRating(updateReq.getRating());
}
}
}
@@ -167,12 +403,38 @@ public class AuthorController {
dto.setName(author.getName());
dto.setNotes(author.getNotes());
dto.setAvatarImagePath(author.getAvatarImagePath());
// Debug logging for author rating
System.out.println("DEBUG: Converting author " + author.getName() +
" with rating: " + author.getAuthorRating());
dto.setAuthorRating(author.getAuthorRating());
dto.setUrls(author.getUrls());
dto.setStoryCount(author.getStories() != null ? author.getStories().size() : 0);
dto.setCreatedAt(author.getCreatedAt());
dto.setUpdatedAt(author.getUpdatedAt());
// Calculate and set average story rating
dto.setAverageStoryRating(authorService.calculateAverageStoryRating(author.getId()));
System.out.println("DEBUG: DTO authorRating set to: " + dto.getAuthorRating());
return dto;
}
private AuthorDto convertSearchDtoToDto(AuthorSearchDto searchDto) {
AuthorDto dto = new AuthorDto();
dto.setId(searchDto.getId());
dto.setName(searchDto.getName());
dto.setNotes(searchDto.getNotes());
dto.setAuthorRating(searchDto.getAuthorRating());
dto.setAverageStoryRating(searchDto.getAverageStoryRating());
dto.setStoryCount(searchDto.getStoryCount());
dto.setUrls(searchDto.getUrls());
dto.setAvatarImagePath(searchDto.getAvatarImagePath());
dto.setCreatedAt(searchDto.getCreatedAt());
dto.setUpdatedAt(searchDto.getUpdatedAt());
return dto;
}
@@ -195,6 +457,7 @@ public class AuthorController {
private String name;
private String notes;
private List<String> urls;
private Integer rating;
// Getters and setters
public String getName() { return name; }
@@ -203,6 +466,8 @@ public class AuthorController {
public void setNotes(String notes) { this.notes = notes; }
public List<String> getUrls() { return urls; }
public void setUrls(List<String> urls) { this.urls = urls; }
public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; }
}
public static class RatingRequest {

View File

@@ -10,6 +10,8 @@ import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto;
import com.storycove.service.*;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@@ -32,6 +34,8 @@ import java.util.stream.Collectors;
@RequestMapping("/api/stories")
public class StoryController {
private static final Logger logger = LoggerFactory.getLogger(StoryController.class);
private final StoryService storyService;
private final AuthorService authorService;
private final SeriesService seriesService;
@@ -141,6 +145,60 @@ public class StoryController {
return ResponseEntity.ok(convertToDto(story));
}
@PostMapping("/reindex")
public ResponseEntity<String> manualReindex() {
if (typesenseService == null) {
return ResponseEntity.ok("Typesense is not enabled, no reindexing performed");
}
try {
List<Story> allStories = storyService.findAllWithAssociations();
typesenseService.reindexAllStories(allStories);
return ResponseEntity.ok("Successfully reindexed " + allStories.size() + " stories");
} catch (Exception e) {
return ResponseEntity.status(500).body("Failed to reindex stories: " + e.getMessage());
}
}
@PostMapping("/reindex-typesense")
public ResponseEntity<Map<String, Object>> reindexStoriesTypesense() {
try {
List<Story> allStories = storyService.findAllWithAssociations();
typesenseService.reindexAllStories(allStories);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Reindexed " + allStories.size() + " stories",
"count", allStories.size()
));
} catch (Exception e) {
logger.error("Failed to reindex stories", e);
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@PostMapping("/recreate-typesense-collection")
public ResponseEntity<Map<String, Object>> recreateStoriesCollection() {
try {
// This will delete the existing collection and recreate it with correct schema
List<Story> allStories = storyService.findAllWithAssociations();
typesenseService.reindexAllStories(allStories);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Recreated stories collection and indexed " + allStories.size() + " stories",
"count", allStories.size()
));
} catch (Exception e) {
logger.error("Failed to recreate stories collection", e);
return ResponseEntity.ok(Map.of(
"success", false,
"error", e.getMessage()
));
}
}
@GetMapping("/search")
public ResponseEntity<SearchResultDto<StorySearchDto>> searchStories(
@RequestParam String query,
@@ -149,11 +207,13 @@ public class StoryController {
@RequestParam(required = false) List<String> authors,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer minRating,
@RequestParam(required = false) Integer maxRating) {
@RequestParam(required = false) Integer maxRating,
@RequestParam(required = false) String sortBy,
@RequestParam(required = false) String sortDir) {
if (typesenseService != null) {
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
query, page, size, authors, tags, minRating, maxRating);
query, page, size, authors, tags, minRating, maxRating, sortBy, sortDir);
return ResponseEntity.ok(results);
} else {
// Fallback to basic search if Typesense is not available
@@ -256,9 +316,13 @@ public class StoryController {
story.setAuthor(author);
}
// Handle series - either by ID or by name
if (createReq.getSeriesId() != null) {
Series series = seriesService.findById(createReq.getSeriesId());
story.setSeries(series);
} else if (createReq.getSeriesName() != null && !createReq.getSeriesName().trim().isEmpty()) {
Series series = seriesService.findOrCreate(createReq.getSeriesName().trim());
story.setSeries(series);
}
// Handle tags
@@ -298,9 +362,13 @@ public class StoryController {
Author author = authorService.findById(updateReq.getAuthorId());
story.setAuthor(author);
}
// Handle series - either by ID or by name
if (updateReq.getSeriesId() != null) {
Series series = seriesService.findById(updateReq.getSeriesId());
story.setSeries(series);
} else if (updateReq.getSeriesName() != null && !updateReq.getSeriesName().trim().isEmpty()) {
Series series = seriesService.findOrCreate(updateReq.getSeriesName().trim());
story.setSeries(series);
}
// Note: Tags are now handled in StoryService.updateWithTagNames()
@@ -360,6 +428,7 @@ public class StoryController {
private UUID authorId;
private String authorName;
private UUID seriesId;
private String seriesName;
private List<String> tagNames;
// Getters and setters
@@ -381,6 +450,8 @@ public class StoryController {
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; }
}
@@ -394,6 +465,7 @@ public class StoryController {
private Integer volume;
private UUID authorId;
private UUID seriesId;
private String seriesName;
private List<String> tagNames;
// Getters and setters
@@ -413,6 +485,8 @@ public class StoryController {
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
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; }
}

View File

@@ -19,6 +19,7 @@ public class AuthorDto {
private String avatarImagePath;
private Integer authorRating;
private Double averageStoryRating;
private List<String> urls;
private Integer storyCount;
private LocalDateTime createdAt;
@@ -71,6 +72,14 @@ public class AuthorDto {
this.authorRating = authorRating;
}
public Double getAverageStoryRating() {
return averageStoryRating;
}
public void setAverageStoryRating(Double averageStoryRating) {
this.averageStoryRating = averageStoryRating;
}
public List<String> getUrls() {
return urls;
}

View File

@@ -0,0 +1,58 @@
package com.storycove.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public class AuthorSearchDto {
private UUID id;
private String name;
private String notes;
private Integer authorRating;
private Double averageStoryRating;
private Integer storyCount;
private List<String> urls;
private String avatarImagePath;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Search-specific fields
private Long searchScore;
public AuthorSearchDto() {}
// 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 getNotes() { return notes; }
public void setNotes(String notes) { this.notes = notes; }
public Integer getAuthorRating() { return authorRating; }
public void setAuthorRating(Integer authorRating) { this.authorRating = authorRating; }
public Double getAverageStoryRating() { return averageStoryRating; }
public void setAverageStoryRating(Double averageStoryRating) { this.averageStoryRating = averageStoryRating; }
public Integer getStoryCount() { return storyCount; }
public void setStoryCount(Integer storyCount) { this.storyCount = storyCount; }
public List<String> getUrls() { return urls; }
public void setUrls(List<String> urls) { this.urls = urls; }
public String getAvatarImagePath() { return avatarImagePath; }
public void setAvatarImagePath(String avatarImagePath) { this.avatarImagePath = avatarImagePath; }
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; }
public Long getSearchScore() { return searchScore; }
public void setSearchScore(Long searchScore) { this.searchScore = searchScore; }
}

View File

@@ -49,4 +49,7 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= :cutoffDate")
long countRecentAuthors(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
@Query(value = "SELECT author_rating FROM authors WHERE id = :id", nativeQuery = true)
Integer findAuthorRatingById(@Param("id") UUID id);
}

View File

@@ -108,4 +108,10 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
@Query("SELECT s FROM Story s WHERE s.createdAt >= :since ORDER BY s.createdAt DESC")
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
@Query("SELECT DISTINCT s FROM Story s " +
"LEFT JOIN FETCH s.author " +
"LEFT JOIN FETCH s.series " +
"LEFT JOIN FETCH s.tags")
List<Story> findAllWithAssociations();
}

View File

@@ -0,0 +1,84 @@
package com.storycove.scheduled;
import com.storycove.entity.Story;
import com.storycove.service.StoryService;
import com.storycove.service.TypesenseService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* Scheduled task to periodically reindex all stories in Typesense
* to ensure search index stays synchronized with database changes.
*/
@Component
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
public class TypesenseIndexScheduler {
private static final Logger logger = LoggerFactory.getLogger(TypesenseIndexScheduler.class);
private final StoryService storyService;
private final TypesenseService typesenseService;
@Autowired
public TypesenseIndexScheduler(StoryService storyService,
@Autowired(required = false) TypesenseService typesenseService) {
this.storyService = storyService;
this.typesenseService = typesenseService;
}
/**
* Scheduled task that runs periodically to reindex all stories in Typesense.
* This ensures the search index stays synchronized with any database changes
* that might have occurred outside of the normal story update flow.
*
* Interval is configurable via storycove.typesense.reindex-interval property (default: 1 hour).
*/
@Scheduled(fixedRateString = "${storycove.typesense.reindex-interval:3600000}")
public void reindexAllStories() {
if (typesenseService == null) {
logger.debug("TypesenseService is not available, skipping scheduled reindexing");
return;
}
logger.info("Starting scheduled Typesense reindexing at {}", LocalDateTime.now());
try {
long startTime = System.currentTimeMillis();
// Get all stories from database with eagerly loaded associations
List<Story> allStories = storyService.findAllWithAssociations();
if (allStories.isEmpty()) {
logger.info("No stories found in database, skipping reindexing");
return;
}
// Perform full reindex
typesenseService.reindexAllStories(allStories);
long endTime = System.currentTimeMillis();
long duration = endTime - startTime;
logger.info("Completed scheduled Typesense reindexing of {} stories in {}ms",
allStories.size(), duration);
} catch (Exception e) {
logger.error("Failed to complete scheduled Typesense reindexing", e);
}
}
/**
* Manual trigger for reindexing - can be called from other services or endpoints if needed
*/
public void triggerManualReindex() {
logger.info("Manual Typesense reindexing triggered");
reindexAllStories();
}
}

View File

@@ -0,0 +1,49 @@
package com.storycove.service;
import com.storycove.entity.Author;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
public class AuthorIndexScheduler {
private static final Logger logger = LoggerFactory.getLogger(AuthorIndexScheduler.class);
private final AuthorService authorService;
private final TypesenseService typesenseService;
@Autowired
public AuthorIndexScheduler(AuthorService authorService, TypesenseService typesenseService) {
this.authorService = authorService;
this.typesenseService = typesenseService;
}
@Scheduled(fixedRateString = "${storycove.typesense.author-reindex-interval:7200000}") // 2 hours default
public void reindexAllAuthors() {
try {
logger.info("Starting scheduled author reindexing...");
List<Author> allAuthors = authorService.findAllWithStories();
logger.info("Found {} authors to reindex", allAuthors.size());
if (!allAuthors.isEmpty()) {
typesenseService.reindexAllAuthors(allAuthors);
logger.info("Successfully completed scheduled author reindexing");
} else {
logger.info("No authors found to reindex");
}
} catch (Exception e) {
logger.error("Failed to complete scheduled author reindexing", e);
}
}
// Manual reindex endpoint can be added to AuthorController if needed
}

View File

@@ -1,10 +1,13 @@
package com.storycove.service;
import com.storycove.entity.Author;
import com.storycove.entity.Story;
import com.storycove.repository.AuthorRepository;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.Valid;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
@@ -21,17 +24,36 @@ import java.util.UUID;
@Transactional
public class AuthorService {
private static final Logger logger = LoggerFactory.getLogger(AuthorService.class);
private final AuthorRepository authorRepository;
private final TypesenseService typesenseService;
@Autowired
public AuthorService(AuthorRepository authorRepository) {
public AuthorService(AuthorRepository authorRepository, TypesenseService typesenseService) {
this.authorRepository = authorRepository;
this.typesenseService = typesenseService;
}
@Transactional(readOnly = true)
public List<Author> findAll() {
return authorRepository.findAll();
}
@Transactional(readOnly = true)
public List<Author> findAllWithStories() {
List<Author> authors = authorRepository.findAll();
// Force load lazy collections within transaction to avoid lazy loading issues
authors.forEach(author -> {
if (author.getStories() != null) {
author.getStories().size(); // Force initialization
}
if (author.getUrls() != null) {
author.getUrls().size(); // Force initialization
}
});
return authors;
}
@Transactional(readOnly = true)
public Page<Author> findAll(Pageable pageable) {
@@ -107,7 +129,16 @@ public class AuthorService {
public Author create(@Valid Author author) {
validateAuthorForCreate(author);
return authorRepository.save(author);
Author savedAuthor = authorRepository.save(author);
// Index in Typesense
try {
typesenseService.indexAuthor(savedAuthor);
} catch (Exception e) {
logger.warn("Failed to index author in Typesense: " + savedAuthor.getName(), e);
}
return savedAuthor;
}
public Author update(UUID id, @Valid Author authorUpdates) {
@@ -120,7 +151,16 @@ public class AuthorService {
}
updateAuthorFields(existingAuthor, authorUpdates);
return authorRepository.save(existingAuthor);
Author savedAuthor = authorRepository.save(existingAuthor);
// Update in Typesense
try {
typesenseService.updateAuthor(savedAuthor);
} catch (Exception e) {
logger.warn("Failed to update author in Typesense: " + savedAuthor.getName(), e);
}
return savedAuthor;
}
public void delete(UUID id) {
@@ -132,18 +172,43 @@ public class AuthorService {
}
authorRepository.delete(author);
// Remove from Typesense
try {
typesenseService.deleteAuthor(id.toString());
} catch (Exception e) {
logger.warn("Failed to delete author from Typesense: " + author.getName(), e);
}
}
public Author addUrl(UUID id, String url) {
Author author = findById(id);
author.addUrl(url);
return authorRepository.save(author);
Author savedAuthor = authorRepository.save(author);
// Update in Typesense
try {
typesenseService.updateAuthor(savedAuthor);
} catch (Exception e) {
logger.warn("Failed to update author in Typesense after adding URL: " + savedAuthor.getName(), e);
}
return savedAuthor;
}
public Author removeUrl(UUID id, String url) {
Author author = findById(id);
author.removeUrl(url);
return authorRepository.save(author);
Author savedAuthor = authorRepository.save(author);
// Update in Typesense
try {
typesenseService.updateAuthor(savedAuthor);
} catch (Exception e) {
logger.warn("Failed to update author in Typesense after removing URL: " + savedAuthor.getName(), e);
}
return savedAuthor;
}
public Author setDirectRating(UUID id, int rating) {
@@ -162,8 +227,32 @@ public class AuthorService {
}
Author author = findById(id);
logger.debug("Setting author rating: {} for author: {} (current rating: {})",
rating, author.getName(), author.getAuthorRating());
author.setAuthorRating(rating);
return authorRepository.save(author);
Author savedAuthor = authorRepository.save(author);
// Flush and refresh to ensure the entity is up-to-date
authorRepository.flush();
Author refreshedAuthor = findById(id);
logger.debug("Saved author rating: {} for author: {}",
refreshedAuthor.getAuthorRating(), refreshedAuthor.getName());
// Update in Typesense
try {
typesenseService.updateAuthor(refreshedAuthor);
} catch (Exception e) {
logger.warn("Failed to update author in Typesense after rating: " + refreshedAuthor.getName(), e);
}
return refreshedAuthor;
}
@Transactional(readOnly = true)
public Integer getAuthorRatingFromDb(UUID id) {
return authorRepository.findAuthorRatingById(id);
}
@Transactional(readOnly = true)
@@ -171,17 +260,57 @@ public class AuthorService {
return authorRepository.findTopRatedAuthors(pageable).getContent();
}
@Transactional(readOnly = true)
public Double calculateAverageStoryRating(UUID authorId) {
Author author = findById(authorId);
if (author.getStories() == null || author.getStories().isEmpty()) {
return null;
}
List<Integer> ratings = author.getStories().stream()
.map(Story::getRating)
.filter(rating -> rating != null)
.toList();
if (ratings.isEmpty()) {
return null;
}
return ratings.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
}
public Author setAvatar(UUID id, String avatarPath) {
Author author = findById(id);
author.setAvatarImagePath(avatarPath);
return authorRepository.save(author);
Author savedAuthor = authorRepository.save(author);
// Update in Typesense
try {
typesenseService.updateAuthor(savedAuthor);
} catch (Exception e) {
logger.warn("Failed to update author in Typesense after setting avatar: " + savedAuthor.getName(), e);
}
return savedAuthor;
}
public Author removeAvatar(UUID id) {
Author author = findById(id);
author.setAvatarImagePath(null);
return authorRepository.save(author);
Author savedAuthor = authorRepository.save(author);
// Update in Typesense
try {
typesenseService.updateAuthor(savedAuthor);
} catch (Exception e) {
logger.warn("Failed to update author in Typesense after removing avatar: " + savedAuthor.getName(), e);
}
return savedAuthor;
}
@Transactional(readOnly = true)

View File

@@ -58,6 +58,11 @@ public class StoryService {
public List<Story> findAll() {
return storyRepository.findAll();
}
@Transactional(readOnly = true)
public List<Story> findAllWithAssociations() {
return storyRepository.findAllWithAssociations();
}
@Transactional(readOnly = true)
public Page<Story> findAll(Pageable pageable) {
@@ -221,7 +226,14 @@ public class StoryService {
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
story.addTag(tag);
return storyRepository.save(story);
Story savedStory = storyRepository.save(story);
// Update Typesense index with new tag information
if (typesenseService != null) {
typesenseService.updateStory(savedStory);
}
return savedStory;
}
@Transactional
@@ -231,7 +243,14 @@ public class StoryService {
.orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId));
story.removeTag(tag);
return storyRepository.save(story);
Story savedStory = storyRepository.save(story);
// Update Typesense index with updated tag information
if (typesenseService != null) {
typesenseService.updateStory(savedStory);
}
return savedStory;
}
@Transactional
@@ -242,7 +261,14 @@ public class StoryService {
Story story = findById(id);
story.setRating(rating);
return storyRepository.save(story);
Story savedStory = storyRepository.save(story);
// Update Typesense index with new rating
if (typesenseService != null) {
typesenseService.updateStory(savedStory);
}
return savedStory;
}
@Transactional(readOnly = true)

View File

@@ -1,7 +1,9 @@
package com.storycove.service;
import com.storycove.dto.AuthorSearchDto;
import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto;
import com.storycove.entity.Author;
import com.storycove.entity.Story;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -21,6 +23,7 @@ public class TypesenseService {
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
private static final String STORIES_COLLECTION = "stories";
private static final String AUTHORS_COLLECTION = "authors";
private final Client typesenseClient;
@@ -33,6 +36,7 @@ public class TypesenseService {
public void initializeCollections() {
try {
createStoriesCollectionIfNotExists();
createAuthorsCollectionIfNotExists();
} catch (Exception e) {
logger.error("Failed to initialize Typesense collections", e);
}
@@ -77,6 +81,59 @@ public class TypesenseService {
logger.info("Stories collection created successfully");
}
private void createAuthorsCollectionIfNotExists() throws Exception {
try {
// Check if collection already exists
typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
logger.info("Authors collection already exists");
} catch (Exception e) {
logger.info("Creating authors collection...");
createAuthorsCollection();
}
}
/**
* Force recreate the authors collection, deleting it first if it exists
*/
public void recreateAuthorsCollection() throws Exception {
try {
logger.info("Force deleting authors collection for recreation...");
typesenseClient.collections(AUTHORS_COLLECTION).delete();
logger.info("Successfully deleted authors collection");
} catch (Exception e) {
logger.debug("Authors collection didn't exist for deletion: {}", e.getMessage());
}
// Wait a brief moment to ensure deletion is complete
Thread.sleep(100);
logger.info("Creating authors collection with fresh schema...");
createAuthorsCollection();
logger.info("Successfully created authors collection");
}
private void createAuthorsCollection() throws Exception {
List<Field> fields = Arrays.asList(
new Field().name("id").type("string").facet(false),
new Field().name("name").type("string").facet(true).sort(true), // Enable both faceting and sorting
new Field().name("notes").type("string").facet(false).optional(true),
new Field().name("authorRating").type("int32").facet(true).sort(true).optional(true), // Enable both faceting and sorting
new Field().name("averageStoryRating").type("float").facet(true).sort(true).optional(true), // Enable both faceting and sorting
new Field().name("storyCount").type("int32").facet(true).sort(true), // Enable both faceting and sorting
new Field().name("urls").type("string[]").facet(false).optional(true),
new Field().name("avatarImagePath").type("string").facet(false).optional(true),
new Field().name("createdAt").type("int64").facet(true).sort(true), // Enable both faceting and sorting
new Field().name("updatedAt").type("int64").facet(true).sort(true) // Enable both faceting and sorting
);
CollectionSchema collectionSchema = new CollectionSchema()
.name(AUTHORS_COLLECTION)
.fields(fields);
typesenseClient.collections().create(collectionSchema);
logger.info("Authors collection created successfully");
}
public void indexStory(Story story) {
try {
Map<String, Object> document = createStoryDocument(story);
@@ -113,7 +170,9 @@ public class TypesenseService {
List<String> authorFilters,
List<String> tagFilters,
Integer minRating,
Integer maxRating) {
Integer maxRating,
String sortBy,
String sortDir) {
try {
long startTime = System.currentTimeMillis();
@@ -121,15 +180,21 @@ public class TypesenseService {
// Convert 0-based page (frontend/backend) to 1-based page (Typesense)
int typesensePage = page + 1;
// Normalize query - treat empty, null, or "*" as wildcard
String normalizedQuery = query;
if (query == null || query.trim().isEmpty() || "*".equals(query.trim())) {
normalizedQuery = "*";
}
SearchParameters searchParameters = new SearchParameters()
.q(query.isEmpty() ? "*" : query)
.q(normalizedQuery)
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
.page(typesensePage)
.perPage(perPage)
.highlightFields("title,description")
.highlightStartTag("<mark>")
.highlightEndTag("</mark>")
.sortBy("_text_match:desc,createdAt:desc");
.sortBy(buildSortParameter(normalizedQuery, sortBy, sortDir));
// Add filters
List<String> filterConditions = new ArrayList<>();
@@ -254,7 +319,7 @@ public class TypesenseService {
document.put("description", story.getDescription() != null ? story.getDescription() : "");
document.put("contentPlain", story.getContentPlain() != null ? story.getContentPlain() : "");
// Required fields - always include even if null
// Author fields - required in schema, use empty string for missing values
if (story.getAuthor() != null) {
document.put("authorId", story.getAuthor().getId().toString());
document.put("authorName", story.getAuthor().getName());
@@ -263,6 +328,7 @@ public class TypesenseService {
document.put("authorName", "");
}
// Series fields - optional
if (story.getSeries() != null) {
document.put("seriesId", story.getSeries().getId().toString());
document.put("seriesName", story.getSeries().getName());
@@ -305,13 +371,15 @@ public class TypesenseService {
dto.setDescription((String) doc.get("description"));
dto.setContentPlain((String) doc.get("contentPlain"));
if (doc.get("authorId") != null) {
dto.setAuthorId(UUID.fromString((String) doc.get("authorId")));
String authorId = (String) doc.get("authorId");
if (authorId != null && !authorId.trim().isEmpty()) {
dto.setAuthorId(UUID.fromString(authorId));
dto.setAuthorName((String) doc.get("authorName"));
}
if (doc.get("seriesId") != null) {
dto.setSeriesId(UUID.fromString((String) doc.get("seriesId")));
String seriesId = (String) doc.get("seriesId");
if (seriesId != null && !seriesId.trim().isEmpty()) {
dto.setSeriesId(UUID.fromString(seriesId));
dto.setSeriesName((String) doc.get("seriesName"));
}
@@ -341,8 +409,9 @@ public class TypesenseService {
timestamp, 0, java.time.ZoneOffset.UTC));
}
// Set search-specific fields
dto.setSearchScore(hit.getTextMatch());
// Set search-specific fields - handle null for wildcard queries
Long textMatch = hit.getTextMatch();
dto.setSearchScore(textMatch != null ? textMatch : 0L);
// Extract highlights from the Typesense response with multiple fallback approaches
List<String> highlights = extractHighlights(hit, dto.getTitle());
@@ -441,4 +510,384 @@ public class TypesenseService {
return highlights;
}
/**
* Build sort parameter for Typesense search.
* Maps frontend sort fields to Typesense document fields and handles direction.
*/
private String buildSortParameter(String query, String sortBy, String sortDir) {
// If it's a wildcard query, use default sort without text match scoring
boolean isWildcardQuery = query == null || query.trim().isEmpty() || "*".equals(query.trim());
// If no sort parameters provided, use appropriate default
if (sortBy == null || sortBy.trim().isEmpty()) {
return isWildcardQuery ? "createdAt:desc" : "_text_match:desc,createdAt:desc";
}
// Map frontend sort fields to Typesense fields
String typesenseField = mapSortField(sortBy);
String direction = (sortDir != null && sortDir.equalsIgnoreCase("asc")) ? "asc" : "desc";
String sortParameter = typesenseField + ":" + direction;
// For text queries (not wildcard), include text match scoring if not already sorting by a text-based field
if (!isWildcardQuery && !isTextBasedSort(sortBy)) {
sortParameter = "_text_match:desc," + sortParameter;
}
// Always include createdAt as a tie-breaker unless we're already sorting by it
if (!"createdAt".equals(sortBy)) {
sortParameter = sortParameter + ",createdAt:desc";
}
return sortParameter;
}
/**
* Map frontend sort field names to Typesense document field names
*/
private String mapSortField(String frontendField) {
switch (frontendField.toLowerCase()) {
case "title":
return "title";
case "author":
case "authorname":
return "authorName";
case "createdat":
case "created_at":
case "date":
return "createdAt";
case "rating":
return "rating";
case "wordcount":
case "word_count":
return "wordCount";
case "volume":
return "volume";
case "series":
case "seriesname":
return "seriesName";
default:
// Fallback to createdAt for unknown fields
return "createdAt";
}
}
/**
* Check if the sort field is text-based (title, author) where text match scoring is less relevant
*/
private boolean isTextBasedSort(String sortBy) {
return sortBy != null && (
sortBy.equalsIgnoreCase("title") ||
sortBy.equalsIgnoreCase("author") ||
sortBy.equalsIgnoreCase("authorname") ||
sortBy.equalsIgnoreCase("series") ||
sortBy.equalsIgnoreCase("seriesname")
);
}
// Author indexing methods
public void indexAuthor(Author author) {
try {
Map<String, Object> document = createAuthorDocument(author);
typesenseClient.collections(AUTHORS_COLLECTION).documents().create(document);
logger.debug("Indexed author: {}", author.getName());
} catch (Exception e) {
logger.error("Failed to index author: " + author.getName(), e);
}
}
public void updateAuthor(Author author) {
try {
Map<String, Object> document = createAuthorDocument(author);
typesenseClient.collections(AUTHORS_COLLECTION).documents(author.getId().toString()).update(document);
logger.debug("Updated author index: {}", author.getName());
} catch (Exception e) {
logger.error("Failed to update author index: " + author.getName(), e);
}
}
public void deleteAuthor(String authorId) {
try {
typesenseClient.collections(AUTHORS_COLLECTION).documents(authorId).delete();
logger.debug("Deleted author from index: {}", authorId);
} catch (Exception e) {
logger.error("Failed to delete author from index: " + authorId, e);
}
}
public void bulkIndexAuthors(List<Author> authors) {
if (authors.isEmpty()) {
return;
}
try {
// Index authors one by one for now (can optimize later)
for (Author author : authors) {
indexAuthor(author);
}
logger.info("Bulk indexed {} authors", authors.size());
} catch (Exception e) {
logger.error("Failed to bulk index authors", e);
}
}
public void reindexAllAuthors(List<Author> authors) {
try {
// Force recreate collection with proper schema
recreateAuthorsCollection();
// Bulk index all authors
bulkIndexAuthors(authors);
logger.info("Reindexed all {} authors", authors.size());
} catch (Exception e) {
logger.error("Failed to reindex all authors", e);
throw new RuntimeException("Failed to reindex all authors", e);
}
}
private Map<String, Object> createAuthorDocument(Author author) {
Map<String, Object> document = new HashMap<>();
document.put("id", author.getId().toString());
document.put("name", author.getName());
document.put("notes", author.getNotes() != null ? author.getNotes() : "");
document.put("authorRating", author.getAuthorRating());
// Safely handle potentially lazy-loaded stories collection
int storyCount = 0;
Double averageStoryRating = null;
try {
if (author.getStories() != null && !author.getStories().isEmpty()) {
storyCount = author.getStories().size();
// Calculate average story rating
double avgRating = author.getStories().stream()
.filter(story -> story.getRating() != null)
.mapToInt(story -> story.getRating())
.average()
.orElse(0.0);
averageStoryRating = avgRating > 0 ? avgRating : null;
}
} catch (Exception e) {
// If stories can't be loaded (lazy loading issue), set defaults
logger.debug("Could not load stories for author {}, using defaults: {}", author.getName(), e.getMessage());
storyCount = 0;
averageStoryRating = null;
}
document.put("storyCount", storyCount);
document.put("averageStoryRating", averageStoryRating);
// Safely handle potentially lazy-loaded URLs collection
List<String> urls = new ArrayList<>();
try {
if (author.getUrls() != null) {
urls = author.getUrls();
}
} catch (Exception e) {
logger.debug("Could not load URLs for author {}, using empty list: {}", author.getName(), e.getMessage());
}
document.put("urls", urls);
document.put("avatarImagePath", author.getAvatarImagePath());
document.put("createdAt", author.getCreatedAt() != null ?
author.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0);
document.put("updatedAt", author.getUpdatedAt() != null ?
author.getUpdatedAt().toEpochSecond(java.time.ZoneOffset.UTC) : 0);
return document;
}
public SearchResultDto<AuthorSearchDto> searchAuthors(String query, int page, int perPage, String sortBy, String sortOrder) {
try {
logger.info("AUTHORS SEARCH DEBUG: Searching collection '{}' with query='{}', sortBy='{}', sortOrder='{}'",
AUTHORS_COLLECTION, query, sortBy, sortOrder);
SearchParameters searchParameters = new SearchParameters()
.q(query != null && !query.trim().isEmpty() ? query : "*")
.queryBy("name,notes")
.page(page + 1) // Typesense pages are 1-indexed
.perPage(perPage);
// Add sorting if specified, with fallback if sorting fails
if (sortBy != null && !sortBy.trim().isEmpty()) {
String sortDirection = "desc".equalsIgnoreCase(sortOrder) ? "desc" : "asc";
String sortField = mapAuthorSortField(sortBy);
String sortString = sortField + ":" + sortDirection;
logger.info("AUTHORS SEARCH DEBUG: Original sortBy='{}', mapped to='{}', full sort string='{}'",
sortBy, sortField, sortString);
searchParameters.sortBy(sortString);
}
SearchResult searchResult;
try {
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
.documents()
.search(searchParameters);
} catch (Exception sortException) {
// If sorting fails (likely due to schema issues), retry without sorting
logger.error("SORTING ERROR DEBUG: Full exception details", sortException);
logger.warn("Sorting failed for authors search, retrying without sort: " + sortException.getMessage());
// Try to get collection info for debugging
try {
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
logger.error("COLLECTION DEBUG: Collection '{}' exists with {} documents and {} fields",
collection.getName(), collection.getNumDocuments(), collection.getFields().size());
logger.error("COLLECTION DEBUG: Fields: {}", collection.getFields());
} catch (Exception debugException) {
logger.error("COLLECTION DEBUG: Failed to retrieve collection info", debugException);
}
searchParameters = new SearchParameters()
.q(query != null && !query.trim().isEmpty() ? query : "*")
.queryBy("name,notes")
.page(page + 1)
.perPage(perPage);
searchResult = typesenseClient.collections(AUTHORS_COLLECTION)
.documents()
.search(searchParameters);
}
return convertAuthorSearchResult(searchResult, query, page, perPage);
} catch (Exception e) {
logger.error("Failed to search authors with query: " + query, e);
SearchResultDto<AuthorSearchDto> emptyResult = new SearchResultDto<>();
emptyResult.setQuery(query);
emptyResult.setPage(page);
emptyResult.setPerPage(perPage);
emptyResult.setTotalHits(0);
emptyResult.setSearchTimeMs(0);
emptyResult.setResults(new ArrayList<>());
return emptyResult;
}
}
private String mapAuthorSortField(String sortBy) {
switch (sortBy.toLowerCase()) {
case "name":
return "name";
case "rating":
case "authorrating":
return "authorRating";
case "stories":
case "storycount":
return "storyCount";
case "avgrating":
case "averagestoryrating":
return "averageStoryRating";
case "created":
case "createdat":
return "createdAt";
default:
return "name";
}
}
private SearchResultDto<AuthorSearchDto> convertAuthorSearchResult(SearchResult searchResult, String query, int page, int perPage) {
SearchResultDto<AuthorSearchDto> result = new SearchResultDto<>();
result.setQuery(query);
result.setPage(page);
result.setPerPage(perPage);
result.setTotalHits(searchResult.getFound().intValue());
result.setSearchTimeMs(searchResult.getSearchTimeMs().intValue());
List<AuthorSearchDto> authors = searchResult.getHits().stream()
.map(this::convertAuthorHit)
.collect(Collectors.toList());
result.setResults(authors);
return result;
}
private AuthorSearchDto convertAuthorHit(SearchResultHit hit) {
Map<String, Object> doc = hit.getDocument();
AuthorSearchDto dto = new AuthorSearchDto();
dto.setId(UUID.fromString((String) doc.get("id")));
dto.setName((String) doc.get("name"));
dto.setNotes((String) doc.get("notes"));
// Handle numeric fields safely
Object authorRating = doc.get("authorRating");
if (authorRating instanceof Number) {
dto.setAuthorRating(((Number) authorRating).intValue());
}
Object storyCount = doc.get("storyCount");
if (storyCount instanceof Number) {
dto.setStoryCount(((Number) storyCount).intValue());
}
Object avgRating = doc.get("averageStoryRating");
if (avgRating instanceof Number) {
dto.setAverageStoryRating(((Number) avgRating).doubleValue());
}
dto.setAvatarImagePath((String) doc.get("avatarImagePath"));
@SuppressWarnings("unchecked")
List<String> urls = (List<String>) doc.get("urls");
dto.setUrls(urls);
// Convert timestamps back to LocalDateTime
Object createdAt = doc.get("createdAt");
if (createdAt instanceof Number) {
dto.setCreatedAt(java.time.LocalDateTime.ofEpochSecond(
((Number) createdAt).longValue(), 0, java.time.ZoneOffset.UTC));
}
Object updatedAt = doc.get("updatedAt");
if (updatedAt instanceof Number) {
dto.setUpdatedAt(java.time.LocalDateTime.ofEpochSecond(
((Number) updatedAt).longValue(), 0, java.time.ZoneOffset.UTC));
}
// Set search score
Long textMatch = hit.getTextMatch();
dto.setSearchScore(textMatch != null ? textMatch : 0L);
return dto;
}
/**
* Get current schema information for the authors collection
*/
public Map<String, Object> getAuthorsCollectionSchema() {
try {
CollectionResponse collection = typesenseClient.collections(AUTHORS_COLLECTION).retrieve();
return Map.of(
"name", collection.getName(),
"num_documents", collection.getNumDocuments(),
"fields", collection.getFields(),
"created_at", collection.getCreatedAt()
);
} catch (Exception e) {
logger.error("Failed to retrieve authors collection schema", e);
return Map.of("error", e.getMessage());
}
}
/**
* Get current schema information for the stories collection
*/
public Map<String, Object> getStoriesCollectionSchema() {
try {
CollectionResponse collection = typesenseClient.collections(STORIES_COLLECTION).retrieve();
return Map.of(
"name", collection.getName(),
"num_documents", collection.getNumDocuments(),
"fields", collection.getFields(),
"created_at", collection.getCreatedAt()
);
} catch (Exception e) {
logger.error("Failed to retrieve stories collection schema", e);
return Map.of("error", e.getMessage());
}
}
}

View File

@@ -32,6 +32,8 @@ storycove:
api-key: ${TYPESENSE_API_KEY:xyz}
host: ${TYPESENSE_HOST:localhost}
port: ${TYPESENSE_PORT:8108}
enabled: ${TYPESENSE_ENABLED:true}
reindex-interval: ${TYPESENSE_REINDEX_INTERVAL:3600000} # 1 hour in milliseconds
images:
storage-path: ${IMAGE_STORAGE_PATH:/app/images}