From 87a4999ffec9c19b41bed4174dcd372d0a2313dd Mon Sep 17 00:00:00 2001 From: Stefan Hardegger Date: Thu, 21 Aug 2025 08:54:28 +0200 Subject: [PATCH] Fixing Database switching functionality. --- .../com/storycove/config/DatabaseConfig.java | 19 +- .../config/SmartRoutingDataSource.java | 158 +++ .../storycove/controller/AuthController.java | 8 +- .../controller/LibraryController.java | 39 + .../com/storycove/service/AuthorService.java | 66 +- .../storycove/service/CollectionService.java | 5 +- .../service/DatabaseManagementService.java | 31 +- .../com/storycove/service/LibraryService.java | 207 ++- .../PasswordAuthenticationService.java | 4 +- .../com/storycove/service/SeriesService.java | 4 + .../com/storycove/service/StoryService.java | 137 +- .../service/StoryService.java.backup | 1192 +++++++++++++++++ .../com/storycove/service/TagService.java | 4 + .../storycove/service/TypesenseService.java | 86 ++ .../main/java/com/storycove/util/JwtUtil.java | 22 +- .../storycove/service/AuthorServiceTest.java | 4 +- .../storycove/service/StoryServiceTest.java | 3 +- 17 files changed, 1743 insertions(+), 246 deletions(-) create mode 100644 backend/src/main/java/com/storycove/config/SmartRoutingDataSource.java create mode 100644 backend/src/main/java/com/storycove/service/StoryService.java.backup diff --git a/backend/src/main/java/com/storycove/config/DatabaseConfig.java b/backend/src/main/java/com/storycove/config/DatabaseConfig.java index 6b70c97..61a793f 100644 --- a/backend/src/main/java/com/storycove/config/DatabaseConfig.java +++ b/backend/src/main/java/com/storycove/config/DatabaseConfig.java @@ -48,22 +48,17 @@ public class DatabaseConfig { } /** - * Primary datasource bean - using fallback for stability. - * Library-specific routing will be handled at the service layer instead. + * Primary datasource bean - uses smart routing that excludes authentication operations */ @Bean(name = "dataSource") @Primary - public DataSource primaryDataSource() { - return fallbackDataSource(); - } - - /** - * Optional routing datasource for future use if needed - */ - @Bean(name = "libraryRoutingDataSource") - public DataSource libraryAwareDataSource(LibraryService libraryService) { - LibraryAwareDataSource routingDataSource = new LibraryAwareDataSource(libraryService); + @DependsOn("libraryService") + public DataSource primaryDataSource(LibraryService libraryService) { + SmartRoutingDataSource routingDataSource = new SmartRoutingDataSource( + libraryService, baseDbUrl, dbUsername, dbPassword); routingDataSource.setDefaultTargetDataSource(fallbackDataSource()); + routingDataSource.setTargetDataSources(new java.util.HashMap<>()); return routingDataSource; } + } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/config/SmartRoutingDataSource.java b/backend/src/main/java/com/storycove/config/SmartRoutingDataSource.java new file mode 100644 index 0000000..8b6f568 --- /dev/null +++ b/backend/src/main/java/com/storycove/config/SmartRoutingDataSource.java @@ -0,0 +1,158 @@ +package com.storycove.config; + +import com.storycove.service.LibraryService; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import javax.sql.DataSource; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Smart routing datasource that: + * 1. Routes to library-specific databases when a library is active + * 2. Excludes authentication operations (keeps them on default database) + * 3. Uses request context to determine when routing is appropriate + */ +public class SmartRoutingDataSource extends AbstractRoutingDataSource { + + private static final Logger logger = LoggerFactory.getLogger(SmartRoutingDataSource.class); + + private final LibraryService libraryService; + private final Map libraryDataSources = new ConcurrentHashMap<>(); + + // Database connection details - will be injected via constructor + private final String baseDbUrl; + private final String dbUsername; + private final String dbPassword; + + public SmartRoutingDataSource(LibraryService libraryService, String baseDbUrl, String dbUsername, String dbPassword) { + this.libraryService = libraryService; + this.baseDbUrl = baseDbUrl; + this.dbUsername = dbUsername; + this.dbPassword = dbPassword; + + logger.info("SmartRoutingDataSource initialized with database: {}", baseDbUrl); + } + + @Override + protected Object determineCurrentLookupKey() { + try { + // Check if this is an authentication request - if so, use default database + if (isAuthenticationRequest()) { + logger.debug("Authentication request detected, using default database"); + return null; // null means use default datasource + } + + // Check if we have an active library + if (libraryService != null) { + String currentLibraryId = libraryService.getCurrentLibraryId(); + if (currentLibraryId != null && !currentLibraryId.trim().isEmpty()) { + logger.info("ROUTING: Directing to library-specific database: {}", currentLibraryId); + return currentLibraryId; + } else { + logger.info("ROUTING: No active library, using default database"); + } + } else { + logger.info("ROUTING: LibraryService is null, using default database"); + } + + } catch (Exception e) { + logger.debug("Error determining lookup key, falling back to default database", e); + } + + return null; // Use default datasource + } + + /** + * Check if the current request is an authentication request that should use the default database + */ + private boolean isAuthenticationRequest() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + String requestURI = attributes.getRequest().getRequestURI(); + String method = attributes.getRequest().getMethod(); + + // Authentication endpoints that should use default database + if (requestURI.contains("/auth/") || + requestURI.contains("/login") || + requestURI.contains("/api/libraries/switch") || + (requestURI.contains("/api/libraries") && "POST".equals(method))) { + return true; + } + } + } catch (Exception e) { + logger.debug("Could not determine request context", e); + } + + return false; + } + + @Override + protected DataSource determineTargetDataSource() { + Object lookupKey = determineCurrentLookupKey(); + + if (lookupKey != null) { + String libraryId = (String) lookupKey; + return getLibraryDataSource(libraryId); + } + + return getDefaultDataSource(); + } + + /** + * Get or create a datasource for the specified library + */ + private DataSource getLibraryDataSource(String libraryId) { + return libraryDataSources.computeIfAbsent(libraryId, id -> { + try { + HikariConfig config = new HikariConfig(); + + // Replace database name in URL with library-specific name + String libraryUrl = baseDbUrl.replaceAll("/[^/]*$", "/" + "storycove_" + id); + + config.setJdbcUrl(libraryUrl); + config.setUsername(dbUsername); + config.setPassword(dbPassword); + config.setDriverClassName("org.postgresql.Driver"); + config.setMaximumPoolSize(5); // Smaller pool for library-specific databases + config.setConnectionTimeout(10000); + config.setMaxLifetime(600000); // 10 minutes + + logger.info("Created new datasource for library: {} -> {}", id, libraryUrl); + return new HikariDataSource(config); + + } catch (Exception e) { + logger.error("Failed to create datasource for library: {}", id, e); + return getDefaultDataSource(); + } + }); + } + + private DataSource getDefaultDataSource() { + // Use the default target datasource that was set in the configuration + try { + return (DataSource) super.determineTargetDataSource(); + } catch (Exception e) { + logger.debug("Could not get default datasource via super method", e); + } + + // Fallback: create a basic datasource + logger.warn("No default datasource available, creating fallback"); + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(baseDbUrl); + config.setUsername(dbUsername); + config.setPassword(dbPassword); + config.setDriverClassName("org.postgresql.Driver"); + config.setMaximumPoolSize(10); + config.setConnectionTimeout(30000); + return new HikariDataSource(config); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/controller/AuthController.java b/backend/src/main/java/com/storycove/controller/AuthController.java index 85a0ee6..3510140 100644 --- a/backend/src/main/java/com/storycove/controller/AuthController.java +++ b/backend/src/main/java/com/storycove/controller/AuthController.java @@ -1,5 +1,6 @@ package com.storycove.controller; +import com.storycove.service.LibraryService; import com.storycove.service.PasswordAuthenticationService; import com.storycove.util.JwtUtil; import jakarta.servlet.http.HttpServletResponse; @@ -18,10 +19,12 @@ import java.time.Duration; public class AuthController { private final PasswordAuthenticationService passwordService; + private final LibraryService libraryService; private final JwtUtil jwtUtil; - public AuthController(PasswordAuthenticationService passwordService, JwtUtil jwtUtil) { + public AuthController(PasswordAuthenticationService passwordService, LibraryService libraryService, JwtUtil jwtUtil) { this.passwordService = passwordService; + this.libraryService = libraryService; this.jwtUtil = jwtUtil; } @@ -50,6 +53,9 @@ public class AuthController { @PostMapping("/logout") public ResponseEntity logout(HttpServletResponse response) { + // Clear authentication state + libraryService.clearAuthentication(); + // Clear the cookie ResponseCookie cookie = ResponseCookie.from("token", "") .httpOnly(true) diff --git a/backend/src/main/java/com/storycove/controller/LibraryController.java b/backend/src/main/java/com/storycove/controller/LibraryController.java index 6f9fe63..8d8ce4a 100644 --- a/backend/src/main/java/com/storycove/controller/LibraryController.java +++ b/backend/src/main/java/com/storycove/controller/LibraryController.java @@ -200,4 +200,43 @@ public class LibraryController { return ResponseEntity.internalServerError().body(Map.of("error", "Server error")); } } + + /** + * Update library metadata (name and description) + */ + @PutMapping("/{libraryId}/metadata") + public ResponseEntity> updateLibraryMetadata( + @PathVariable String libraryId, + @RequestBody Map updates) { + + try { + String newName = updates.get("name"); + String newDescription = updates.get("description"); + + if (newName == null || newName.trim().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "Library name is required")); + } + + // Update the library + libraryService.updateLibraryMetadata(libraryId, newName, newDescription); + + // Return updated library info + LibraryDto updatedLibrary = libraryService.getLibraryById(libraryId); + if (updatedLibrary != null) { + Map response = new HashMap<>(); + response.put("success", true); + response.put("message", "Library metadata updated successfully"); + response.put("library", updatedLibrary); + return ResponseEntity.ok(response); + } else { + return ResponseEntity.notFound().build(); + } + + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of("error", e.getMessage())); + } catch (Exception e) { + logger.error("Failed to update library metadata for {}: {}", libraryId, e.getMessage(), e); + return ResponseEntity.internalServerError().body(Map.of("error", "Failed to update library metadata")); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/AuthorService.java b/backend/src/main/java/com/storycove/service/AuthorService.java index 275e7c7..76173d3 100644 --- a/backend/src/main/java/com/storycove/service/AuthorService.java +++ b/backend/src/main/java/com/storycove/service/AuthorService.java @@ -15,8 +15,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; -import java.sql.ResultSet; -import java.sql.SQLException; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -31,13 +29,11 @@ public class AuthorService { private final AuthorRepository authorRepository; private final TypesenseService typesenseService; - private final LibraryAwareService libraryAwareService; @Autowired - public AuthorService(AuthorRepository authorRepository, @Autowired(required = false) TypesenseService typesenseService, LibraryAwareService libraryAwareService) { + public AuthorService(AuthorRepository authorRepository, @Autowired(required = false) TypesenseService typesenseService) { this.authorRepository = authorRepository; this.typesenseService = typesenseService; - this.libraryAwareService = libraryAwareService; } @Transactional(readOnly = true) @@ -67,36 +63,8 @@ public class AuthorService { @Transactional(readOnly = true) public Author findById(UUID id) { - // Smart routing: use library-specific database if available, otherwise use repository - if (libraryAwareService.hasActiveLibrary()) { - return findByIdFromCurrentLibrary(id); - } else { - return authorRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Author", id.toString())); - } - } - - /** - * Find author by ID using the current library's database connection - */ - private Author findByIdFromCurrentLibrary(UUID id) { - try (var connection = libraryAwareService.getCurrentLibraryConnection()) { - String query = "SELECT id, name, notes, avatar_image_path, author_rating, created_at, updated_at FROM authors WHERE id = ?"; - - try (var stmt = connection.prepareStatement(query)) { - stmt.setObject(1, id); - try (var rs = stmt.executeQuery()) { - if (rs.next()) { - return createAuthorFromResultSet(rs); - } else { - throw new ResourceNotFoundException("Author", id.toString()); - } - } - } - } catch (Exception e) { - logger.error("Failed to find author by ID from current library: {}", id, e); - throw new RuntimeException("Database error while fetching author", e); - } + return authorRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Author", id.toString())); } @Transactional(readOnly = true) @@ -394,32 +362,4 @@ public class AuthorService { existing.getUrls().addAll(urlsCopy); } } - - /** - * Create an Author entity from ResultSet for library-aware queries - */ - private Author createAuthorFromResultSet(ResultSet rs) throws SQLException { - var author = new Author(); - author.setId(UUID.fromString(rs.getString("id"))); - author.setName(rs.getString("name")); - author.setNotes(rs.getString("notes")); - author.setAvatarImagePath(rs.getString("avatar_image_path")); - - Integer rating = rs.getInt("author_rating"); - if (!rs.wasNull()) { - author.setAuthorRating(rating); - } - - var createdAtTimestamp = rs.getTimestamp("created_at"); - if (createdAtTimestamp != null) { - author.setCreatedAt(createdAtTimestamp.toLocalDateTime()); - } - - var updatedAtTimestamp = rs.getTimestamp("updated_at"); - if (updatedAtTimestamp != null) { - author.setUpdatedAt(updatedAtTimestamp.toLocalDateTime()); - } - - return author; - } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/CollectionService.java b/backend/src/main/java/com/storycove/service/CollectionService.java index 48058a6..495eebd 100644 --- a/backend/src/main/java/com/storycove/service/CollectionService.java +++ b/backend/src/main/java/com/storycove/service/CollectionService.java @@ -33,7 +33,6 @@ public class CollectionService { private final TagRepository tagRepository; private final TypesenseService typesenseService; private final ReadingTimeService readingTimeService; - private final LibraryAwareService libraryAwareService; @Autowired public CollectionService(CollectionRepository collectionRepository, @@ -41,15 +40,13 @@ public class CollectionService { StoryRepository storyRepository, TagRepository tagRepository, @Autowired(required = false) TypesenseService typesenseService, - ReadingTimeService readingTimeService, - LibraryAwareService libraryAwareService) { + ReadingTimeService readingTimeService) { this.collectionRepository = collectionRepository; this.collectionStoryRepository = collectionStoryRepository; this.storyRepository = storyRepository; this.tagRepository = tagRepository; this.typesenseService = typesenseService; this.readingTimeService = readingTimeService; - this.libraryAwareService = libraryAwareService; } /** diff --git a/backend/src/main/java/com/storycove/service/DatabaseManagementService.java b/backend/src/main/java/com/storycove/service/DatabaseManagementService.java index f0dc824..cbb4ab7 100644 --- a/backend/src/main/java/com/storycove/service/DatabaseManagementService.java +++ b/backend/src/main/java/com/storycove/service/DatabaseManagementService.java @@ -3,7 +3,10 @@ package com.storycove.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.storycove.repository.*; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @@ -22,11 +25,15 @@ import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; @Service -public class DatabaseManagementService { +public class DatabaseManagementService implements ApplicationContextAware { - // DataSource is now dynamic based on active library + @Autowired + @Qualifier("dataSource") // Use the primary routing datasource + private DataSource dataSource; + + // Use the routing datasource which automatically handles library switching private DataSource getDataSource() { - return libraryService.getCurrentDataSource(); + return dataSource; } @Autowired @@ -55,6 +62,13 @@ public class DatabaseManagementService { @Value("${storycove.images.upload-dir:/app/images}") private String uploadDir; + + private ApplicationContext applicationContext; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } /** * Create a comprehensive backup including database and files in ZIP format @@ -131,6 +145,17 @@ public class DatabaseManagementService { System.err.println("No files directory found in backup - skipping file restore."); } + // 6. Trigger complete Typesense reindex after data restoration + try { + System.err.println("Starting Typesense reindex after restore..."); + TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class); + typesenseService.performCompleteReindex(); + System.err.println("Typesense reindex completed successfully."); + } catch (Exception e) { + System.err.println("Warning: Failed to reindex Typesense after restore: " + e.getMessage()); + // Don't fail the entire restore for Typesense issues + } + System.err.println("Complete backup restore finished successfully."); } catch (Exception e) { diff --git a/backend/src/main/java/com/storycove/service/LibraryService.java b/backend/src/main/java/com/storycove/service/LibraryService.java index bf9b54d..c24a177 100644 --- a/backend/src/main/java/com/storycove/service/LibraryService.java +++ b/backend/src/main/java/com/storycove/service/LibraryService.java @@ -21,7 +21,9 @@ import jakarta.annotation.PreDestroy; import javax.sql.DataSource; import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.sql.SQLException; import java.time.Duration; @@ -59,10 +61,13 @@ public class LibraryService implements ApplicationContextAware { // Current active resources private volatile String currentLibraryId; - private volatile DataSource currentDataSource; private volatile Client currentTypesenseClient; + // Security: Track if user has explicitly authenticated in this session + private volatile boolean explicitlyAuthenticated = false; + private static final String LIBRARIES_CONFIG_PATH = "/app/config/libraries.json"; + private static final Path libraryConfigDir = Paths.get("/app/config"); @Override public void setApplicationContext(ApplicationContext applicationContext) { @@ -78,35 +83,71 @@ public class LibraryService implements ApplicationContextAware { createDefaultLibrary(); } - // Set first library as active if none specified - if (currentLibraryId == null && !libraries.isEmpty()) { - String firstLibraryId = libraries.keySet().iterator().next(); - logger.info("No active library set, defaulting to: {}", firstLibraryId); - try { - switchToLibrary(firstLibraryId); - } catch (Exception e) { - logger.error("Failed to initialize default library: {}", firstLibraryId, e); - } + // Security: Do NOT automatically switch to any library on startup + // Users must authenticate before accessing any library + explicitlyAuthenticated = false; + currentLibraryId = null; + + if (!libraries.isEmpty()) { + logger.info("Loaded {} libraries. Authentication required to access any library.", libraries.size()); + } else { + logger.info("No libraries found. A default library will be created on first authentication."); } + + logger.info("Security: Application startup completed. All users must re-authenticate."); } @PreDestroy public void cleanup() { - closeCurrentResources(); + currentLibraryId = null; + currentTypesenseClient = null; + explicitlyAuthenticated = false; } + /** + * Clear authentication state (for logout) + */ + public void clearAuthentication() { + explicitlyAuthenticated = false; + currentLibraryId = null; + currentTypesenseClient = null; + logger.info("Authentication cleared - user must re-authenticate to access libraries"); + } + + public String authenticateAndGetLibrary(String password) { for (Library library : libraries.values()) { if (passwordEncoder.matches(password, library.getPasswordHash())) { + // Mark as explicitly authenticated for this session + explicitlyAuthenticated = true; + logger.info("User explicitly authenticated for library: {}", library.getId()); return library.getId(); } } return null; // Authentication failed } + /** + * Switch to library after authentication with forced reindexing + * This ensures Typesense is always up-to-date after login + */ + public synchronized void switchToLibraryAfterAuthentication(String libraryId) throws Exception { + logger.info("Switching to library after authentication: {} (forcing reindex)", libraryId); + switchToLibrary(libraryId, true); + } + public synchronized void switchToLibrary(String libraryId) throws Exception { - if (libraryId.equals(currentLibraryId)) { - return; // Already active + switchToLibrary(libraryId, false); + } + + public synchronized void switchToLibrary(String libraryId, boolean forceReindex) throws Exception { + // Security: Only allow library switching after explicit authentication + if (!explicitlyAuthenticated) { + throw new IllegalStateException("Library switching requires explicit authentication. Please log in first."); + } + + if (libraryId.equals(currentLibraryId) && !forceReindex) { + return; // Already active and no forced reindex requested } Library library = libraries.get(libraryId); @@ -114,33 +155,68 @@ public class LibraryService implements ApplicationContextAware { throw new IllegalArgumentException("Library not found: " + libraryId); } - logger.info("Switching to library: {} ({})", library.getName(), libraryId); + String previousLibraryId = currentLibraryId; + + if (libraryId.equals(currentLibraryId) && forceReindex) { + logger.info("Forcing reindex for current library: {} ({})", library.getName(), libraryId); + } else { + logger.info("Switching to library: {} ({})", library.getName(), libraryId); + } // Close current resources closeCurrentResources(); - // Create new resources - currentDataSource = createDataSource(library.getDbName()); - currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection()); + // Set new active library (datasource routing handled by SmartRoutingDataSource) currentLibraryId = libraryId; + currentTypesenseClient = createTypesenseClient(library.getTypesenseCollection()); - // Initialize Typesense collections for this library if they don't exist + // Initialize Typesense collections for this library try { TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class); + // First ensure collections exist typesenseService.initializeCollectionsForCurrentLibrary(); + logger.info("Completed Typesense initialization for library: {}", libraryId); } catch (Exception e) { - logger.warn("Failed to initialize Typesense collections for library {}: {}", libraryId, e.getMessage()); + logger.warn("Failed to initialize Typesense for library {}: {}", libraryId, e.getMessage()); // Don't fail the switch - collections can be created later } logger.info("Successfully switched to library: {}", library.getName()); + + // Perform complete reindex AFTER library switch is fully complete + // This ensures database routing is properly established + if (forceReindex || !libraryId.equals(previousLibraryId)) { + logger.info("Starting post-switch Typesense reindex for library: {}", libraryId); + + // Run reindex asynchronously to avoid blocking authentication response + // and allow time for database routing to fully stabilize + String finalLibraryId = libraryId; + new Thread(() -> { + try { + // Give routing time to stabilize + Thread.sleep(500); + logger.info("Starting async Typesense reindex for library: {}", finalLibraryId); + + TypesenseService typesenseService = applicationContext.getBean(TypesenseService.class); + typesenseService.performCompleteReindex(); + logger.info("Completed async Typesense reindexing for library: {}", finalLibraryId); + } catch (Exception e) { + logger.warn("Failed to async reindex Typesense for library {}: {}", finalLibraryId, e.getMessage()); + } + }, "TypesenseReindex-" + libraryId).start(); + } } public DataSource getCurrentDataSource() { - if (currentDataSource == null) { + if (currentLibraryId == null) { throw new IllegalStateException("No active library - please authenticate first"); } - return currentDataSource; + // Return the Spring-managed primary datasource which handles routing automatically + try { + return applicationContext.getBean("dataSource", DataSource.class); + } catch (Exception e) { + throw new IllegalStateException("Failed to get routing datasource", e); + } } public Client getCurrentTypesenseClient() { @@ -176,6 +252,21 @@ public class LibraryService implements ApplicationContextAware { return result; } + public LibraryDto getLibraryById(String libraryId) { + Library library = libraries.get(libraryId); + if (library != null) { + boolean isActive = library.getId().equals(currentLibraryId); + return new LibraryDto( + library.getId(), + library.getName(), + library.getDescription(), + isActive, + library.isInitialized() + ); + } + return null; + } + public String getCurrentImagePath() { Library current = getCurrentLibrary(); return current != null ? current.getImagePath() : "/images/default"; @@ -689,14 +780,74 @@ public class LibraryService implements ApplicationContextAware { } private void closeCurrentResources() { - if (currentDataSource instanceof HikariDataSource) { - logger.info("Closing current DataSource"); - ((HikariDataSource) currentDataSource).close(); + // No need to close datasource - SmartRoutingDataSource handles this + // Typesense client doesn't need explicit cleanup + currentTypesenseClient = null; + // Don't clear currentLibraryId here - only when explicitly switching + } + + /** + * Update library metadata (name and description) + */ + public synchronized void updateLibraryMetadata(String libraryId, String newName, String newDescription) throws Exception { + if (libraryId == null || libraryId.trim().isEmpty()) { + throw new IllegalArgumentException("Library ID cannot be null or empty"); } - // Typesense client doesn't need explicit cleanup - currentDataSource = null; - currentTypesenseClient = null; - currentLibraryId = null; + Library library = libraries.get(libraryId); + if (library == null) { + throw new IllegalArgumentException("Library not found: " + libraryId); + } + + // Validate new name + if (newName == null || newName.trim().isEmpty()) { + throw new IllegalArgumentException("Library name cannot be null or empty"); + } + + String oldName = library.getName(); + String oldDescription = library.getDescription(); + + // Update the library object + library.setName(newName.trim()); + library.setDescription(newDescription != null ? newDescription.trim() : ""); + + try { + // Save to configuration file + saveLibraryConfiguration(library); + + logger.info("Updated library metadata - ID: {}, Name: '{}' -> '{}', Description: '{}' -> '{}'", + libraryId, oldName, newName, oldDescription, library.getDescription()); + + } catch (Exception e) { + // Rollback changes on failure + library.setName(oldName); + library.setDescription(oldDescription); + throw new RuntimeException("Failed to update library metadata: " + e.getMessage(), e); + } + } + + /** + * Save library configuration to file + */ + private void saveLibraryConfiguration(Library library) throws Exception { + Path libraryConfigPath = libraryConfigDir.resolve(library.getId() + ".json"); + + // Create library configuration object + Map config = new HashMap<>(); + config.put("id", library.getId()); + config.put("name", library.getName()); + config.put("description", library.getDescription()); + config.put("passwordHash", library.getPasswordHash()); + config.put("dbName", library.getDbName()); + config.put("typesenseCollection", library.getTypesenseCollection()); + config.put("imagePath", library.getImagePath()); + config.put("initialized", library.isInitialized()); + + // Write to file + ObjectMapper mapper = new ObjectMapper(); + String configJson = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(config); + Files.writeString(libraryConfigPath, configJson, StandardCharsets.UTF_8); + + logger.debug("Saved library configuration to: {}", libraryConfigPath); } } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/PasswordAuthenticationService.java b/backend/src/main/java/com/storycove/service/PasswordAuthenticationService.java index dd246e3..9639da7 100644 --- a/backend/src/main/java/com/storycove/service/PasswordAuthenticationService.java +++ b/backend/src/main/java/com/storycove/service/PasswordAuthenticationService.java @@ -43,8 +43,8 @@ public class PasswordAuthenticationService { } try { - // Switch to the authenticated library (may take 2-3 seconds) - libraryService.switchToLibrary(libraryId); + // Switch to the authenticated library with forced reindexing (may take 2-3 seconds) + libraryService.switchToLibraryAfterAuthentication(libraryId); // Generate JWT token with library context String token = jwtUtil.generateToken("user", libraryId); diff --git a/backend/src/main/java/com/storycove/service/SeriesService.java b/backend/src/main/java/com/storycove/service/SeriesService.java index 9ece990..fd1d113 100644 --- a/backend/src/main/java/com/storycove/service/SeriesService.java +++ b/backend/src/main/java/com/storycove/service/SeriesService.java @@ -5,6 +5,8 @@ import com.storycove.repository.SeriesRepository; 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; @@ -20,6 +22,8 @@ import java.util.UUID; @Validated @Transactional public class SeriesService { + + private static final Logger logger = LoggerFactory.getLogger(SeriesService.class); private final SeriesRepository seriesRepository; diff --git a/backend/src/main/java/com/storycove/service/StoryService.java b/backend/src/main/java/com/storycove/service/StoryService.java index 389a3a5..1590956 100644 --- a/backend/src/main/java/com/storycove/service/StoryService.java +++ b/backend/src/main/java/com/storycove/service/StoryService.java @@ -19,8 +19,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; -import java.sql.ResultSet; -import java.sql.SQLException; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.HashSet; @@ -45,7 +43,6 @@ public class StoryService { private final SeriesService seriesService; private final HtmlSanitizationService sanitizationService; private final TypesenseService typesenseService; - private final LibraryAwareService libraryAwareService; @Autowired public StoryService(StoryRepository storyRepository, @@ -55,8 +52,7 @@ public class StoryService { TagService tagService, SeriesService seriesService, HtmlSanitizationService sanitizationService, - @Autowired(required = false) TypesenseService typesenseService, - LibraryAwareService libraryAwareService) { + @Autowired(required = false) TypesenseService typesenseService) { this.storyRepository = storyRepository; this.tagRepository = tagRepository; this.readingPositionRepository = readingPositionRepository; @@ -65,7 +61,6 @@ public class StoryService { this.seriesService = seriesService; this.sanitizationService = sanitizationService; this.typesenseService = typesenseService; - this.libraryAwareService = libraryAwareService; } @Transactional(readOnly = true) @@ -85,47 +80,10 @@ public class StoryService { @Transactional(readOnly = true) public Story findById(UUID id) { - // Smart routing: use library-specific database if available, otherwise use repository - if (libraryAwareService.hasActiveLibrary()) { - return findByIdFromCurrentLibrary(id); - } else { - return storyRepository.findById(id) - .orElseThrow(() -> new ResourceNotFoundException("Story", id.toString())); - } + return storyRepository.findById(id) + .orElseThrow(() -> new ResourceNotFoundException("Story", id.toString())); } - /** - * Find story by ID using the current library's database connection - */ - private Story findByIdFromCurrentLibrary(UUID id) { - try (var connection = libraryAwareService.getCurrentLibraryConnection()) { - String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + - "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + - "s.created_at, s.updated_at, " + - "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + - "a.created_at as author_created_at, a.updated_at as author_updated_at, " + - "ser.name as series_name, ser.description as series_description, " + - "ser.created_at as series_created_at " + - "FROM stories s " + - "LEFT JOIN authors a ON s.author_id = a.id " + - "LEFT JOIN series ser ON s.series_id = ser.id " + - "WHERE s.id = ?"; - - try (var stmt = connection.prepareStatement(query)) { - stmt.setObject(1, id); - try (var rs = stmt.executeQuery()) { - if (rs.next()) { - return createStoryFromResultSet(rs); - } else { - throw new ResourceNotFoundException("Story", id.toString()); - } - } - } - } catch (Exception e) { - logger.error("Failed to find story by ID from current library: {}", id, e); - throw new RuntimeException("Database error while fetching story", e); - } - } @Transactional(readOnly = true) public Optional findByIdOptional(UUID id) { @@ -736,15 +694,15 @@ public class StoryService { } } - // Fallback to original database-based implementation if Typesense is not available - return findRandomStoryFallback(searchQuery, tags); + // Fallback to repository-based implementation (global routing handles library selection) + return findRandomStoryFromRepository(searchQuery, tags); } + /** - * Fallback method for random story selection using database queries. - * Used when Typesense is not available or fails. + * Find random story using repository methods (for default database or when library-aware fails) */ - private Optional findRandomStoryFallback(String searchQuery, List tags) { + private Optional findRandomStoryFromRepository(String searchQuery, List tags) { // Clean up inputs String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null; List cleanTags = (tags != null) ? tags.stream() @@ -811,82 +769,5 @@ public class StoryService { return randomStory; } - /** - * Create a Story entity from ResultSet for library-aware queries - */ - private Story createStoryFromResultSet(ResultSet rs) throws SQLException { - var story = new Story(); - story.setId(UUID.fromString(rs.getString("id"))); - story.setTitle(rs.getString("title")); - story.setSummary(rs.getString("summary")); - story.setDescription(rs.getString("description")); - story.setContentHtml(rs.getString("content_html")); - story.setSourceUrl(rs.getString("source_url")); - story.setCoverPath(rs.getString("cover_path")); - story.setWordCount(rs.getInt("word_count")); - story.setRating(rs.getInt("rating")); - story.setVolume(rs.getInt("volume")); - story.setIsRead(rs.getBoolean("is_read")); - story.setReadingPosition(rs.getInt("reading_position")); - - var lastReadAtTimestamp = rs.getTimestamp("last_read_at"); - if (lastReadAtTimestamp != null) { - story.setLastReadAt(lastReadAtTimestamp.toLocalDateTime()); - } - - var createdAtTimestamp = rs.getTimestamp("created_at"); - if (createdAtTimestamp != null) { - story.setCreatedAt(createdAtTimestamp.toLocalDateTime()); - } - - var updatedAtTimestamp = rs.getTimestamp("updated_at"); - if (updatedAtTimestamp != null) { - story.setUpdatedAt(updatedAtTimestamp.toLocalDateTime()); - } - - // Set complete author information - String authorIdStr = rs.getString("author_id"); - if (authorIdStr != null) { - var author = new Author(); - author.setId(UUID.fromString(authorIdStr)); - author.setName(rs.getString("author_name")); - author.setNotes(rs.getString("author_notes")); - author.setAvatarImagePath(rs.getString("author_avatar")); - - Integer authorRating = rs.getInt("author_rating"); - if (!rs.wasNull()) { - author.setAuthorRating(authorRating); - } - - var authorCreatedAt = rs.getTimestamp("author_created_at"); - if (authorCreatedAt != null) { - author.setCreatedAt(authorCreatedAt.toLocalDateTime()); - } - - var authorUpdatedAt = rs.getTimestamp("author_updated_at"); - if (authorUpdatedAt != null) { - author.setUpdatedAt(authorUpdatedAt.toLocalDateTime()); - } - - story.setAuthor(author); - } - - // Set complete series information - String seriesIdStr = rs.getString("series_id"); - if (seriesIdStr != null) { - var series = new Series(); - series.setId(UUID.fromString(seriesIdStr)); - series.setName(rs.getString("series_name")); - series.setDescription(rs.getString("series_description")); - - var seriesCreatedAt = rs.getTimestamp("series_created_at"); - if (seriesCreatedAt != null) { - series.setCreatedAt(seriesCreatedAt.toLocalDateTime()); - } - - story.setSeries(series); - } - - return story; - } + } \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/StoryService.java.backup b/backend/src/main/java/com/storycove/service/StoryService.java.backup new file mode 100644 index 0000000..e7ce8cd --- /dev/null +++ b/backend/src/main/java/com/storycove/service/StoryService.java.backup @@ -0,0 +1,1192 @@ +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.ReadingPositionRepository; +import com.storycove.repository.StoryRepository; +import com.storycove.repository.TagRepository; +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; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@Validated +@Transactional +public class StoryService { + + private static final Logger logger = LoggerFactory.getLogger(StoryService.class); + + private final StoryRepository storyRepository; + private final TagRepository tagRepository; + private final ReadingPositionRepository readingPositionRepository; + private final AuthorService authorService; + private final TagService tagService; + private final SeriesService seriesService; + private final HtmlSanitizationService sanitizationService; + private final TypesenseService typesenseService; + + @Autowired + public StoryService(StoryRepository storyRepository, + TagRepository tagRepository, + ReadingPositionRepository readingPositionRepository, + AuthorService authorService, + TagService tagService, + SeriesService seriesService, + HtmlSanitizationService sanitizationService, + @Autowired(required = false) TypesenseService typesenseService) { + this.storyRepository = storyRepository; + this.tagRepository = tagRepository; + this.readingPositionRepository = readingPositionRepository; + this.authorService = authorService; + this.tagService = tagService; + this.seriesService = seriesService; + this.sanitizationService = sanitizationService; + this.typesenseService = typesenseService; + } + + @Transactional(readOnly = true) + public List findAll() { + return storyRepository.findAll(); + } + + @Transactional(readOnly = true) + public List findAllWithAssociations() { + return storyRepository.findAllWithAssociations(); + } + + @Transactional(readOnly = true) + public Page 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())); + } + + /** + * Find story by ID using the current library's database connection + */ + private Story findByIdFromCurrentLibrary(UUID id) { + try (var connection = libraryAwareService.getCurrentLibraryConnection()) { + String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + + "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + + "s.created_at, s.updated_at, " + + "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + + "a.created_at as author_created_at, a.updated_at as author_updated_at, " + + "ser.name as series_name, ser.description as series_description, " + + "ser.created_at as series_created_at " + + "FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "WHERE s.id = ?"; + + try (var stmt = connection.prepareStatement(query)) { + stmt.setObject(1, id); + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return createStoryFromResultSet(rs); + } else { + throw new ResourceNotFoundException("Story", id.toString()); + } + } + } + } catch (Exception e) { + logger.error("Failed to find story by ID from current library: {}", id, e); + throw new RuntimeException("Database error while fetching story", e); + } + } + + @Transactional(readOnly = true) + public Optional findByIdOptional(UUID id) { + return storyRepository.findById(id); + } + + + @Transactional(readOnly = true) + public Optional findByTitle(String title) { + return storyRepository.findByTitle(title); + } + + @Transactional(readOnly = true) + public Optional findBySourceUrl(String sourceUrl) { + return storyRepository.findBySourceUrl(sourceUrl); + } + + @Transactional(readOnly = true) + public List searchByTitle(String title) { + return storyRepository.findByTitleContainingIgnoreCase(title); + } + + @Transactional(readOnly = true) + public Page searchByTitle(String title, Pageable pageable) { + return storyRepository.findByTitleContainingIgnoreCase(title, pageable); + } + + @Transactional(readOnly = true) + public List findByAuthor(UUID authorId) { + Author author = authorService.findById(authorId); + return storyRepository.findByAuthor(author); + } + + @Transactional(readOnly = true) + public Page findByAuthor(UUID authorId, Pageable pageable) { + Author author = authorService.findById(authorId); + return storyRepository.findByAuthor(author, pageable); + } + + @Transactional(readOnly = true) + public List findBySeries(UUID seriesId) { + seriesService.findById(seriesId); // Validate series exists + return storyRepository.findBySeriesOrderByVolume(seriesId); + } + + @Transactional(readOnly = true) + public Page findBySeries(UUID seriesId, Pageable pageable) { + Series series = seriesService.findById(seriesId); + return storyRepository.findBySeries(series, pageable); + } + + @Transactional(readOnly = true) + public Optional findBySeriesAndVolume(UUID seriesId, Integer volume) { + return storyRepository.findBySeriesAndVolume(seriesId, volume); + } + + @Transactional(readOnly = true) + public List findByTag(UUID tagId) { + Tag tag = tagService.findById(tagId); + return storyRepository.findByTag(tag); + } + + @Transactional(readOnly = true) + public Page findByTag(UUID tagId, Pageable pageable) { + Tag tag = tagService.findById(tagId); + return storyRepository.findByTag(tag, pageable); + } + + @Transactional(readOnly = true) + public List findByTagNames(List tagNames) { + return storyRepository.findByTagNames(tagNames); + } + + @Transactional(readOnly = true) + public Page findByTagNames(List tagNames, Pageable pageable) { + return storyRepository.findByTagNames(tagNames, pageable); + } + + // Favorite and completion status methods removed as these fields were not in spec + + @Transactional(readOnly = true) + public List findRecentlyRead(int hours) { + LocalDateTime since = LocalDateTime.now().minusHours(hours); + return storyRepository.findRecentlyRead(since); + } + + @Transactional(readOnly = true) + public List findRecentlyAdded() { + return storyRepository.findRecentlyAdded(); + } + + @Transactional(readOnly = true) + public Page findRecentlyAdded(Pageable pageable) { + return storyRepository.findRecentlyAdded(pageable); + } + + @Transactional(readOnly = true) + public List findTopRated() { + return storyRepository.findTopRatedStories(); + } + + @Transactional(readOnly = true) + public Page findTopRated(Pageable pageable) { + return storyRepository.findTopRatedStories(pageable); + } + + @Transactional(readOnly = true) + public List findByWordCountRange(Integer minWords, Integer maxWords) { + return storyRepository.findByWordCountRange(minWords, maxWords); + } + + @Transactional(readOnly = true) + public List searchByKeyword(String keyword) { + return storyRepository.findByKeyword(keyword); + } + + @Transactional(readOnly = true) + public Page searchByKeyword(String keyword, Pageable pageable) { + return storyRepository.findByKeyword(keyword, pageable); + } + + @Transactional + public Story setCoverImage(UUID id, String imagePath) { + Story story = findById(id); + + // Delete old cover if exists + if (story.getCoverPath() != null && !story.getCoverPath().isEmpty()) { + // Note: ImageService would be injected here in a real implementation + // For now, we just update the path + } + + story.setCoverPath(imagePath); + return storyRepository.save(story); + } + + @Transactional + public void removeCoverImage(UUID id) { + Story story = findById(id); + + if (story.getCoverPath() != null && !story.getCoverPath().isEmpty()) { + // Note: ImageService would be injected here to delete file + story.setCoverPath(null); + storyRepository.save(story); + } + } + + @Transactional + public Story addTag(UUID storyId, UUID tagId) { + Story story = findById(storyId); + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId)); + + story.addTag(tag); + Story savedStory = storyRepository.save(story); + + // Update Typesense index with new tag information + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + + @Transactional + public Story removeTag(UUID storyId, UUID tagId) { + Story story = findById(storyId); + Tag tag = tagRepository.findById(tagId) + .orElseThrow(() -> new ResourceNotFoundException("Tag not found with id: " + tagId)); + + story.removeTag(tag); + Story savedStory = storyRepository.save(story); + + // Update Typesense index with updated tag information + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + + @Transactional + public Story setRating(UUID id, Integer rating) { + if (rating != null && (rating < 1 || rating > 5)) { + throw new IllegalArgumentException("Rating must be between 1 and 5"); + } + + Story story = findById(id); + story.setRating(rating); + Story savedStory = storyRepository.save(story); + + // Update Typesense index with new rating + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + + @Transactional + public Story updateReadingProgress(UUID id, Integer position) { + if (position != null && position < 0) { + throw new IllegalArgumentException("Reading position must be non-negative"); + } + + Story story = findById(id); + story.updateReadingProgress(position); + Story savedStory = storyRepository.save(story); + + // Update Typesense index with new reading progress + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + + @Transactional + public Story updateReadingStatus(UUID id, Boolean isRead) { + Story story = findById(id); + + if (Boolean.TRUE.equals(isRead)) { + story.markAsRead(); + } else { + story.setIsRead(false); + story.setLastReadAt(LocalDateTime.now()); + } + + Story savedStory = storyRepository.save(story); + + // Update Typesense index with new reading status + if (typesenseService != null) { + typesenseService.updateStory(savedStory); + } + + return savedStory; + } + + @Transactional(readOnly = true) + public List findBySeriesOrderByVolume(UUID seriesId) { + return storyRepository.findBySeriesOrderByVolume(seriesId); + } + + @Transactional(readOnly = true) + public List findRecentlyAddedLimited(Pageable pageable) { + return storyRepository.findRecentlyAdded(pageable).getContent(); + } + + @Transactional(readOnly = true) + public List findTopRatedStoriesLimited(Pageable pageable) { + return storyRepository.findTopRatedStories(pageable).getContent(); + } + + 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); + validateSeriesVolume(series, story.getVolume()); + } + + Story savedStory = storyRepository.save(story); + + // Handle tags + if (story.getTags() != null && !story.getTags().isEmpty()) { + updateStoryTags(savedStory, story.getTags()); + } + + // Index in Typesense (if available) + if (typesenseService != null) { + typesenseService.indexStory(savedStory); + } + + return savedStory; + } + + public Story createWithTagNames(@Valid Story story, java.util.List tagNames) { + 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); + validateSeriesVolume(series, story.getVolume()); + } + + Story savedStory = storyRepository.save(story); + + // Handle tags by names + if (tagNames != null && !tagNames.isEmpty()) { + updateStoryTagsByNames(savedStory, tagNames); + } + + // Index in Typesense (if available) + if (typesenseService != null) { + typesenseService.indexStory(savedStory); + } + + 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); + Story updatedStory = storyRepository.save(existingStory); + + // Update in Typesense (if available) + if (typesenseService != null) { + typesenseService.updateStory(updatedStory); + } + + return updatedStory; + } + + public Story updateWithTagNames(UUID id, Object request) { + Story existingStory = findById(id); + + // Update basic fields + updateStoryFieldsFromRequest(existingStory, request); + + // Handle tags if it's an update request with tag names + if (request instanceof com.storycove.controller.StoryController.UpdateStoryRequest updateReq) { + if (updateReq.getTagNames() != null) { + updateStoryTagsByNames(existingStory, updateReq.getTagNames()); + } + } + + Story updatedStory = storyRepository.save(existingStory); + + // Update in Typesense (if available) + if (typesenseService != null) { + typesenseService.updateStory(updatedStory); + } + + return updatedStory; + } + + public void delete(UUID id) { + Story story = findById(id); + + // Clean up reading positions first (to avoid foreign key constraint violations) + readingPositionRepository.deleteByStoryId(id); + + // Remove from series if part of one + if (story.getSeries() != null) { + story.getSeries().removeStory(story); + } + + // Remove tags (this will update tag usage counts) + // Create a copy to avoid ConcurrentModificationException + new ArrayList<>(story.getTags()).forEach(tag -> story.removeTag(tag)); + + // Delete from Typesense first (if available) + if (typesenseService != null) { + typesenseService.deleteStory(story.getId().toString()); + } + + storyRepository.delete(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 volume) { + Story story = findById(storyId); + Series series = seriesService.findById(seriesId); + + validateSeriesVolume(series, volume); + + story.setSeries(series); + story.setVolume(volume); + 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.setVolume(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 validateSeriesVolume(Series series, Integer volume) { + if (volume != null) { + Optional existingPart = storyRepository.findBySeriesAndVolume(series.getId(), volume); + if (existingPart.isPresent()) { + throw new DuplicateResourceException("Story", "volume " + volume + " of series " + series.getName()); + } + } + } + + private void updateStoryFields(Story existing, Story updates) { + if (updates.getTitle() != null) { + existing.setTitle(updates.getTitle()); + } + if (updates.getSummary() != null) { + existing.setSummary(updates.getSummary()); + } + if (updates.getDescription() != null) { + existing.setDescription(updates.getDescription()); + } + if (updates.getContentHtml() != null) { + existing.setContentHtml(updates.getContentHtml()); + } + if (updates.getSourceUrl() != null) { + existing.setSourceUrl(updates.getSourceUrl()); + } + if (updates.getCoverPath() != null) { + existing.setCoverPath(updates.getCoverPath()); + } + if (updates.getVolume() != null) { + existing.setVolume(updates.getVolume()); + } + + // 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.getVolume() != null) { + validateSeriesVolume(series, updates.getVolume()); + existing.setVolume(updates.getVolume()); + } + } + + // Handle tags update + if (updates.getTags() != null) { + updateStoryTags(existing, updates.getTags()); + } + } + + private void updateStoryTags(Story story, Set newTags) { + // Remove existing tags - create a copy to avoid ConcurrentModificationException + Set existingTags = new HashSet<>(story.getTags()); + existingTags.forEach(tag -> story.removeTag(tag)); + + // 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); + } + } + + private void updateStoryFieldsFromRequest(Story story, Object request) { + if (request instanceof com.storycove.controller.StoryController.UpdateStoryRequest updateReq) { + if (updateReq.getTitle() != null) { + story.setTitle(updateReq.getTitle()); + } + if (updateReq.getSummary() != null) { + story.setSummary(updateReq.getSummary()); + } + if (updateReq.getContentHtml() != null) { + story.setContentHtml(sanitizationService.sanitize(updateReq.getContentHtml())); + } + if (updateReq.getSourceUrl() != null) { + story.setSourceUrl(updateReq.getSourceUrl()); + } + if (updateReq.getVolume() != null) { + story.setVolume(updateReq.getVolume()); + } + // Handle author - either by ID or by name + if (updateReq.getAuthorId() != null) { + 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) { + if (updateReq.getSeriesName().trim().isEmpty()) { + // Empty series name means remove from series + story.setSeries(null); + } else { + // Find or create series by name + Series series = seriesService.findByNameOptional(updateReq.getSeriesName().trim()) + .orElseGet(() -> { + Series newSeries = new Series(); + newSeries.setName(updateReq.getSeriesName().trim()); + return seriesService.create(newSeries); + }); + story.setSeries(series); + } + } + } + } + + private void updateStoryTagsByNames(Story story, java.util.List tagNames) { + // Clear existing tags first + Set existingTags = new HashSet<>(story.getTags()); + for (Tag existingTag : existingTags) { + story.removeTag(existingTag); + } + + // Add new tags + for (String tagName : tagNames) { + if (tagName != null && !tagName.trim().isEmpty()) { + Tag tag = tagService.findByNameOptional(tagName.trim().toLowerCase()) + .orElseGet(() -> { + Tag newTag = new Tag(); + newTag.setName(tagName.trim().toLowerCase()); + return tagService.create(newTag); + }); + story.addTag(tag); + } + } + } + + @Transactional(readOnly = true) + public List findPotentialDuplicates(String title, String authorName) { + if (title == null || title.trim().isEmpty() || authorName == null || authorName.trim().isEmpty()) { + return List.of(); + } + return storyRepository.findByTitleAndAuthorNameIgnoreCase(title.trim(), authorName.trim()); + } + + /** + * Find a random story based on optional filters. + * Uses Typesense for consistency with Library search functionality. + * Supports text search and multiple tags using the same logic as the Library view. + */ + @Transactional(readOnly = true) + public Optional findRandomStory(String searchQuery, List tags) { + + // Use Typesense if available for consistency with Library search + if (typesenseService != null) { + try { + Optional randomStoryId = typesenseService.getRandomStoryId(searchQuery, tags); + if (randomStoryId.isPresent()) { + return storyRepository.findById(randomStoryId.get()); + } + return Optional.empty(); + } catch (Exception e) { + // Fallback to database queries if Typesense fails + logger.warn("Typesense random story lookup failed, falling back to database queries", e); + } + } + + // Fallback to repository-based implementation (global routing handles library selection) + return findRandomStoryFromRepository(searchQuery, tags); + } + + /** + * Find random story using repository methods (for default database or when library-aware fails) + */ + private Optional findRandomStoryFromCurrentLibrary(String searchQuery, List tags) { + // Clean up inputs + String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null; + List cleanTags = (tags != null) ? tags.stream() + .filter(tag -> tag != null && !tag.trim().isEmpty()) + .map(String::trim) + .collect(Collectors.toList()) : List.of(); + + try (var connection = libraryAwareService.getCurrentLibraryConnection()) { + long totalCount = 0; + + if (cleanSearchQuery != null && !cleanTags.isEmpty()) { + // Both search query and tags - complex query + String countQuery = "SELECT COUNT(DISTINCT s.id) FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE (UPPER(s.title) LIKE UPPER(?) OR UPPER(s.summary) LIKE UPPER(?) OR UPPER(s.description) LIKE UPPER(?) OR UPPER(a.name) LIKE UPPER(?)) " + + "AND UPPER(t.name) IN (" + cleanTags.stream().map(tag -> "?").collect(Collectors.joining(",")) + ") " + + "GROUP BY s.id HAVING COUNT(DISTINCT t.id) = ?"; + + try (var stmt = connection.prepareStatement(countQuery)) { + String searchPattern = "%" + cleanSearchQuery + "%"; + stmt.setString(1, searchPattern); + stmt.setString(2, searchPattern); + stmt.setString(3, searchPattern); + stmt.setString(4, searchPattern); + + int paramIndex = 5; + for (String tag : cleanTags) { + stmt.setString(paramIndex++, tag.toUpperCase()); + } + stmt.setInt(paramIndex, cleanTags.size()); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + totalCount = rs.getLong(1); + } + } + } + + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + return getRandomStoryByTextSearchAndTags(connection, cleanSearchQuery, cleanTags, randomOffset); + } + + } else if (cleanSearchQuery != null) { + // Only search query + String countQuery = "SELECT COUNT(*) FROM stories s LEFT JOIN authors a ON s.author_id = a.id " + + "WHERE UPPER(s.title) LIKE UPPER(?) OR UPPER(s.summary) LIKE UPPER(?) OR UPPER(s.description) LIKE UPPER(?) OR UPPER(a.name) LIKE UPPER(?)"; + + try (var stmt = connection.prepareStatement(countQuery)) { + String searchPattern = "%" + cleanSearchQuery + "%"; + stmt.setString(1, searchPattern); + stmt.setString(2, searchPattern); + stmt.setString(3, searchPattern); + stmt.setString(4, searchPattern); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + totalCount = rs.getLong(1); + } + } + } + + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + return getRandomStoryByTextSearch(connection, cleanSearchQuery, randomOffset); + } + + } else if (!cleanTags.isEmpty()) { + // Only tags + if (cleanTags.size() == 1) { + // Single tag - optimized query + String countQuery = "SELECT COUNT(*) FROM stories s JOIN story_tags st ON s.id = st.story_id JOIN tags t ON st.tag_id = t.id WHERE UPPER(t.name) = UPPER(?)"; + + try (var stmt = connection.prepareStatement(countQuery)) { + stmt.setString(1, cleanTags.get(0)); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + totalCount = rs.getLong(1); + } + } + } + + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + return getRandomStoryByTagName(connection, cleanTags.get(0), randomOffset); + } + } else { + // Multiple tags + String countQuery = "SELECT COUNT(DISTINCT s.id) FROM stories s " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE UPPER(t.name) IN (" + cleanTags.stream().map(tag -> "?").collect(Collectors.joining(",")) + ") " + + "GROUP BY s.id HAVING COUNT(DISTINCT t.id) = ?"; + + try (var stmt = connection.prepareStatement(countQuery)) { + int paramIndex = 1; + for (String tag : cleanTags) { + stmt.setString(paramIndex++, tag.toUpperCase()); + } + stmt.setInt(paramIndex, cleanTags.size()); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + totalCount = rs.getLong(1); + } + } + } + + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + return getRandomStoryByMultipleTags(connection, cleanTags, randomOffset); + } + } + } else { + // No filters - get random from all stories + String countQuery = "SELECT COUNT(*) FROM stories"; + + try (var stmt = connection.prepareStatement(countQuery)) { + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + totalCount = rs.getLong(1); + } + } + } + + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + return getRandomStoryFromAll(connection, randomOffset); + } + } + + } catch (Exception e) { + logger.error("Failed to find random story from current library", e); + // Fallback to repository method + return findRandomStoryFromRepository(searchQuery, tags); + } + + return Optional.empty(); + } + + /** + * Find random story using repository methods (for default database or when library-aware fails) + */ + private Optional findRandomStoryFromRepository(String searchQuery, List tags) { + // Clean up inputs + String cleanSearchQuery = (searchQuery != null && !searchQuery.trim().isEmpty()) ? searchQuery.trim() : null; + List cleanTags = (tags != null) ? tags.stream() + .filter(tag -> tag != null && !tag.trim().isEmpty()) + .map(String::trim) + .collect(Collectors.toList()) : List.of(); + + long totalCount = 0; + Optional randomStory = Optional.empty(); + + if (cleanSearchQuery != null && !cleanTags.isEmpty()) { + // Both search query and tags + String searchPattern = "%" + cleanSearchQuery + "%"; + List upperCaseTags = cleanTags.stream() + .map(String::toUpperCase) + .collect(Collectors.toList()); + + totalCount = storyRepository.countStoriesByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size()); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByTextSearchAndTags(searchPattern, upperCaseTags, cleanTags.size(), randomOffset); + } + + } else if (cleanSearchQuery != null) { + // Only search query + String searchPattern = "%" + cleanSearchQuery + "%"; + totalCount = storyRepository.countStoriesByTextSearch(searchPattern); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByTextSearch(searchPattern, randomOffset); + } + + } else if (!cleanTags.isEmpty()) { + // Only tags + if (cleanTags.size() == 1) { + // Single tag - use optimized single tag query + totalCount = storyRepository.countStoriesByTagName(cleanTags.get(0)); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByTagName(cleanTags.get(0), randomOffset); + } + } else { + // Multiple tags + List upperCaseTags = cleanTags.stream() + .map(String::toUpperCase) + .collect(Collectors.toList()); + + totalCount = storyRepository.countStoriesByMultipleTags(upperCaseTags, cleanTags.size()); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStoryByMultipleTags(upperCaseTags, cleanTags.size(), randomOffset); + } + } + + } else { + // No filters - get random from all stories + totalCount = storyRepository.countAllStories(); + if (totalCount > 0) { + long randomOffset = (long) (Math.random() * totalCount); + randomStory = storyRepository.findRandomStory(randomOffset); + } + } + + return randomStory; + } + + /** + * Helper methods for library-aware random story queries + */ + private Optional getRandomStoryByTextSearchAndTags(java.sql.Connection connection, String searchQuery, List tags, long offset) throws Exception { + String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + + "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + + "s.created_at, s.updated_at, " + + "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + + "a.created_at as author_created_at, a.updated_at as author_updated_at, " + + "ser.name as series_name, ser.description as series_description, " + + "ser.created_at as series_created_at " + + "FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE (UPPER(s.title) LIKE UPPER(?) OR UPPER(s.summary) LIKE UPPER(?) OR UPPER(s.description) LIKE UPPER(?) OR UPPER(a.name) LIKE UPPER(?)) " + + "AND UPPER(t.name) IN (" + tags.stream().map(tag -> "?").collect(Collectors.joining(",")) + ") " + + "GROUP BY s.id HAVING COUNT(DISTINCT t.id) = ? LIMIT 1 OFFSET ?"; + + try (var stmt = connection.prepareStatement(query)) { + String searchPattern = "%" + searchQuery + "%"; + stmt.setString(1, searchPattern); + stmt.setString(2, searchPattern); + stmt.setString(3, searchPattern); + stmt.setString(4, searchPattern); + + int paramIndex = 5; + for (String tag : tags) { + stmt.setString(paramIndex++, tag.toUpperCase()); + } + stmt.setInt(paramIndex++, tags.size()); + stmt.setLong(paramIndex, offset); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(createStoryFromResultSet(rs)); + } + } + } + return Optional.empty(); + } + + private Optional getRandomStoryByTextSearch(java.sql.Connection connection, String searchQuery, long offset) throws Exception { + String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + + "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + + "s.created_at, s.updated_at, " + + "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + + "a.created_at as author_created_at, a.updated_at as author_updated_at, " + + "ser.name as series_name, ser.description as series_description, " + + "ser.created_at as series_created_at " + + "FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "WHERE UPPER(s.title) LIKE UPPER(?) OR UPPER(s.summary) LIKE UPPER(?) OR UPPER(s.description) LIKE UPPER(?) OR UPPER(a.name) LIKE UPPER(?) " + + "LIMIT 1 OFFSET ?"; + + try (var stmt = connection.prepareStatement(query)) { + String searchPattern = "%" + searchQuery + "%"; + stmt.setString(1, searchPattern); + stmt.setString(2, searchPattern); + stmt.setString(3, searchPattern); + stmt.setString(4, searchPattern); + stmt.setLong(5, offset); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(createStoryFromResultSet(rs)); + } + } + } + return Optional.empty(); + } + + private Optional getRandomStoryByTagName(java.sql.Connection connection, String tagName, long offset) throws Exception { + String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + + "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + + "s.created_at, s.updated_at, " + + "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + + "a.created_at as author_created_at, a.updated_at as author_updated_at, " + + "ser.name as series_name, ser.description as series_description, " + + "ser.created_at as series_created_at " + + "FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE UPPER(t.name) = UPPER(?) " + + "LIMIT 1 OFFSET ?"; + + try (var stmt = connection.prepareStatement(query)) { + stmt.setString(1, tagName); + stmt.setLong(2, offset); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(createStoryFromResultSet(rs)); + } + } + } + return Optional.empty(); + } + + private Optional getRandomStoryByMultipleTags(java.sql.Connection connection, List tags, long offset) throws Exception { + String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + + "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + + "s.created_at, s.updated_at, " + + "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + + "a.created_at as author_created_at, a.updated_at as author_updated_at, " + + "ser.name as series_name, ser.description as series_description, " + + "ser.created_at as series_created_at " + + "FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "JOIN story_tags st ON s.id = st.story_id " + + "JOIN tags t ON st.tag_id = t.id " + + "WHERE UPPER(t.name) IN (" + tags.stream().map(tag -> "?").collect(Collectors.joining(",")) + ") " + + "GROUP BY s.id HAVING COUNT(DISTINCT t.id) = ? LIMIT 1 OFFSET ?"; + + try (var stmt = connection.prepareStatement(query)) { + int paramIndex = 1; + for (String tag : tags) { + stmt.setString(paramIndex++, tag.toUpperCase()); + } + stmt.setInt(paramIndex++, tags.size()); + stmt.setLong(paramIndex, offset); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(createStoryFromResultSet(rs)); + } + } + } + return Optional.empty(); + } + + private Optional getRandomStoryFromAll(java.sql.Connection connection, long offset) throws Exception { + String query = "SELECT s.id, s.title, s.summary, s.description, s.content_html, s.content_plain, s.source_url, s.cover_path, " + + "s.word_count, s.rating, s.volume, s.is_read, s.reading_position, s.last_read_at, s.author_id, s.series_id, " + + "s.created_at, s.updated_at, " + + "a.name as author_name, a.notes as author_notes, a.avatar_image_path as author_avatar, a.author_rating, " + + "a.created_at as author_created_at, a.updated_at as author_updated_at, " + + "ser.name as series_name, ser.description as series_description, " + + "ser.created_at as series_created_at " + + "FROM stories s " + + "LEFT JOIN authors a ON s.author_id = a.id " + + "LEFT JOIN series ser ON s.series_id = ser.id " + + "LIMIT 1 OFFSET ?"; + + try (var stmt = connection.prepareStatement(query)) { + stmt.setLong(1, offset); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(createStoryFromResultSet(rs)); + } + } + } + return Optional.empty(); + } + + /** + * Create a Story entity from ResultSet for library-aware queries + */ + private Story createStoryFromResultSet(ResultSet rs) throws SQLException { + var story = new Story(); + story.setId(UUID.fromString(rs.getString("id"))); + story.setTitle(rs.getString("title")); + story.setSummary(rs.getString("summary")); + story.setDescription(rs.getString("description")); + story.setContentHtml(rs.getString("content_html")); + story.setSourceUrl(rs.getString("source_url")); + story.setCoverPath(rs.getString("cover_path")); + story.setWordCount(rs.getInt("word_count")); + story.setRating(rs.getInt("rating")); + story.setVolume(rs.getInt("volume")); + story.setIsRead(rs.getBoolean("is_read")); + story.setReadingPosition(rs.getInt("reading_position")); + + var lastReadAtTimestamp = rs.getTimestamp("last_read_at"); + if (lastReadAtTimestamp != null) { + story.setLastReadAt(lastReadAtTimestamp.toLocalDateTime()); + } + + var createdAtTimestamp = rs.getTimestamp("created_at"); + if (createdAtTimestamp != null) { + story.setCreatedAt(createdAtTimestamp.toLocalDateTime()); + } + + var updatedAtTimestamp = rs.getTimestamp("updated_at"); + if (updatedAtTimestamp != null) { + story.setUpdatedAt(updatedAtTimestamp.toLocalDateTime()); + } + + // Set complete author information + String authorIdStr = rs.getString("author_id"); + if (authorIdStr != null) { + var author = new Author(); + author.setId(UUID.fromString(authorIdStr)); + author.setName(rs.getString("author_name")); + author.setNotes(rs.getString("author_notes")); + author.setAvatarImagePath(rs.getString("author_avatar")); + + Integer authorRating = rs.getInt("author_rating"); + if (!rs.wasNull()) { + author.setAuthorRating(authorRating); + } + + var authorCreatedAt = rs.getTimestamp("author_created_at"); + if (authorCreatedAt != null) { + author.setCreatedAt(authorCreatedAt.toLocalDateTime()); + } + + var authorUpdatedAt = rs.getTimestamp("author_updated_at"); + if (authorUpdatedAt != null) { + author.setUpdatedAt(authorUpdatedAt.toLocalDateTime()); + } + + story.setAuthor(author); + } + + // Set complete series information + String seriesIdStr = rs.getString("series_id"); + if (seriesIdStr != null) { + var series = new Series(); + series.setId(UUID.fromString(seriesIdStr)); + series.setName(rs.getString("series_name")); + series.setDescription(rs.getString("series_description")); + + var seriesCreatedAt = rs.getTimestamp("series_created_at"); + if (seriesCreatedAt != null) { + series.setCreatedAt(seriesCreatedAt.toLocalDateTime()); + } + + story.setSeries(series); + } + + return story; + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/storycove/service/TagService.java b/backend/src/main/java/com/storycove/service/TagService.java index 9bdb3e2..dffe50f 100644 --- a/backend/src/main/java/com/storycove/service/TagService.java +++ b/backend/src/main/java/com/storycove/service/TagService.java @@ -8,6 +8,8 @@ import com.storycove.repository.TagAliasRepository; 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; @@ -26,6 +28,8 @@ import java.util.UUID; @Validated @Transactional public class TagService { + + private static final Logger logger = LoggerFactory.getLogger(TagService.class); private final TagRepository tagRepository; private final TagAliasRepository tagAliasRepository; diff --git a/backend/src/main/java/com/storycove/service/TypesenseService.java b/backend/src/main/java/com/storycove/service/TypesenseService.java index 0428623..00310ff 100644 --- a/backend/src/main/java/com/storycove/service/TypesenseService.java +++ b/backend/src/main/java/com/storycove/service/TypesenseService.java @@ -13,6 +13,7 @@ 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.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.typesense.api.Client; import org.typesense.model.*; @@ -47,6 +48,19 @@ public class TypesenseService { private final CollectionStoryRepository collectionStoryRepository; private final ReadingTimeService readingTimeService; + // Services for complete reindexing (avoiding circular dependencies with @Lazy) + @Autowired + @Lazy + private StoryService storyService; + + @Autowired + @Lazy + private AuthorService authorService; + + @Autowired + @Lazy + private CollectionService collectionService; + @Autowired public TypesenseService(LibraryService libraryService, @Autowired(required = false) CollectionStoryRepository collectionStoryRepository, @@ -1343,6 +1357,78 @@ public class TypesenseService { } } + /** + * Perform a complete reindex of all entity types for the current library. + * This should be called when switching libraries to ensure Typesense has accurate data. + */ + public void performCompleteReindex() { + try { + logger.info("Starting complete Typesense reindex for current library"); + long startTime = System.currentTimeMillis(); + + // Get all data from the current library (SmartRoutingDataSource handles library-specific routing) + + // Reindex stories + try { + logger.info("DEBUG: About to fetch stories from storyService..."); + String currentLib = libraryService.getCurrentLibraryId(); + logger.info("DEBUG: Current library ID during reindex: {}", currentLib); + List allStories = storyService.findAllWithAssociations(); + logger.info("DEBUG: StoryService returned {} stories", allStories.size()); + if (!allStories.isEmpty()) { + reindexAllStories(allStories); + logger.info("Reindexed {} stories", allStories.size()); + } else { + // Still create empty collection for consistency + recreateStoriesCollection(); + logger.info("Created empty stories collection - NO STORIES FOUND IN DATABASE"); + } + } catch (Exception e) { + logger.error("Failed to reindex stories during complete reindex", e); + } + + // Reindex authors + try { + logger.info("DEBUG: About to fetch authors from authorService..."); + List allAuthors = authorService.findAllWithStories(); + logger.info("DEBUG: AuthorService returned {} authors", allAuthors.size()); + if (!allAuthors.isEmpty()) { + reindexAllAuthors(allAuthors); + logger.info("Reindexed {} authors", allAuthors.size()); + } else { + // Still create empty collection for consistency + recreateAuthorsCollection(); + logger.info("Created empty authors collection - NO AUTHORS FOUND IN DATABASE"); + } + } catch (Exception e) { + logger.error("Failed to reindex authors during complete reindex", e); + } + + // Reindex collections + try { + List allCollections = collectionService.findAllWithTags(); + if (!allCollections.isEmpty()) { + reindexAllCollections(allCollections); + logger.info("Reindexed {} collections", allCollections.size()); + } else { + // Still create empty collection for consistency + createCollectionsCollectionIfNotExists(); + logger.info("Created empty collections collection"); + } + } catch (Exception e) { + logger.error("Failed to reindex collections during complete reindex", e); + } + + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + logger.info("Completed full Typesense reindex in {}ms", duration); + + } catch (Exception e) { + logger.error("Failed to complete full Typesense reindex", e); + throw new RuntimeException("Complete reindex failed", e); + } + } + /** * Create Typesense document from Collection entity */ diff --git a/backend/src/main/java/com/storycove/util/JwtUtil.java b/backend/src/main/java/com/storycove/util/JwtUtil.java index e587723..ef5f7db 100644 --- a/backend/src/main/java/com/storycove/util/JwtUtil.java +++ b/backend/src/main/java/com/storycove/util/JwtUtil.java @@ -3,21 +3,41 @@ package com.storycove.util; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; import javax.crypto.SecretKey; +import java.security.SecureRandom; +import java.util.Base64; import java.util.Date; @Component public class JwtUtil { - @Value("${storycove.jwt.secret}") + private static final Logger logger = LoggerFactory.getLogger(JwtUtil.class); + + // Security: Generate new secret on each startup to invalidate all existing tokens private String secret; @Value("${storycove.jwt.expiration:86400000}") // 24 hours default private Long expiration; + @PostConstruct + public void initialize() { + // Generate a new random secret on startup to invalidate all existing JWT tokens + // This ensures users must re-authenticate after application restart + SecureRandom random = new SecureRandom(); + byte[] secretBytes = new byte[64]; // 512 bits + random.nextBytes(secretBytes); + this.secret = Base64.getEncoder().encodeToString(secretBytes); + + logger.info("JWT secret rotated on startup - all existing tokens invalidated"); + logger.info("Users will need to re-authenticate after application restart for security"); + } + private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes()); } diff --git a/backend/src/test/java/com/storycove/service/AuthorServiceTest.java b/backend/src/test/java/com/storycove/service/AuthorServiceTest.java index 04bfce9..25c7d56 100644 --- a/backend/src/test/java/com/storycove/service/AuthorServiceTest.java +++ b/backend/src/test/java/com/storycove/service/AuthorServiceTest.java @@ -44,8 +44,8 @@ class AuthorServiceTest { testAuthor.setId(testId); testAuthor.setNotes("Test notes"); - // Initialize service with null TypesenseService and LibraryAwareService (which is allowed for tests) - authorService = new AuthorService(authorRepository, null, null); + // Initialize service with null TypesenseService (which is allowed for tests) + authorService = new AuthorService(authorRepository, null); } @Test diff --git a/backend/src/test/java/com/storycove/service/StoryServiceTest.java b/backend/src/test/java/com/storycove/service/StoryServiceTest.java index 8b3a04d..bab8518 100644 --- a/backend/src/test/java/com/storycove/service/StoryServiceTest.java +++ b/backend/src/test/java/com/storycove/service/StoryServiceTest.java @@ -53,8 +53,7 @@ class StoryServiceTest { null, // tagService - not needed for reading progress tests null, // seriesService - not needed for reading progress tests null, // sanitizationService - not needed for reading progress tests - null, // typesenseService - will test both with and without - null // libraryAwareService - not needed for these tests + null // typesenseService - will test both with and without ); }