inital working version

This commit is contained in:
Stefan Hardegger
2025-07-22 21:49:40 +02:00
parent bebb799784
commit 59d29dceaf
98 changed files with 8027 additions and 856 deletions

View File

@@ -0,0 +1,73 @@
package com.storycove.config;
import com.storycove.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${storycove.cors.allowed-origins:http://localhost:3000}")
private String allowedOrigins;
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
// Public endpoints
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/files/images/**").permitAll() // Public image serving
.requestMatchers("/actuator/health").permitAll()
// All other API endpoints require authentication
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList(allowedOrigins.split(",")));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,37 @@
package com.storycove.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.typesense.api.Client;
import org.typesense.resources.Node;
import java.util.ArrayList;
import java.util.List;
@Configuration
public class TypesenseConfig {
@Value("${storycove.typesense.api-key}")
private String apiKey;
@Value("${storycove.typesense.host}")
private String host;
@Value("${storycove.typesense.port}")
private int port;
@Bean
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
public Client typesenseClient() {
List<Node> nodes = new ArrayList<>();
nodes.add(new Node("http", host, String.valueOf(port)));
org.typesense.api.Configuration configuration = new org.typesense.api.Configuration(
nodes, java.time.Duration.ofSeconds(10), apiKey
);
return new Client(configuration);
}
}

View File

@@ -0,0 +1,128 @@
package com.storycove.controller;
import com.storycove.service.PasswordAuthenticationService;
import com.storycove.util.JwtUtil;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.Duration;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final PasswordAuthenticationService passwordService;
private final JwtUtil jwtUtil;
public AuthController(PasswordAuthenticationService passwordService, JwtUtil jwtUtil) {
this.passwordService = passwordService;
this.jwtUtil = jwtUtil;
}
@PostMapping("/login")
public ResponseEntity<?> login(@Valid @RequestBody LoginRequest request, HttpServletResponse response) {
if (passwordService.authenticate(request.getPassword())) {
String token = jwtUtil.generateToken();
// Set httpOnly cookie
ResponseCookie cookie = ResponseCookie.from("token", token)
.httpOnly(true)
.secure(false) // Set to true in production with HTTPS
.path("/")
.maxAge(Duration.ofDays(1))
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(new LoginResponse("Authentication successful", token));
} else {
return ResponseEntity.status(401).body(new ErrorResponse("Invalid password"));
}
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
// Clear the cookie
ResponseCookie cookie = ResponseCookie.from("token", "")
.httpOnly(true)
.secure(false)
.path("/")
.maxAge(Duration.ZERO)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
return ResponseEntity.ok(new MessageResponse("Logged out successfully"));
}
@GetMapping("/verify")
public ResponseEntity<?> verify(Authentication authentication) {
if (authentication != null && authentication.isAuthenticated()) {
return ResponseEntity.ok(new MessageResponse("Token is valid"));
} else {
return ResponseEntity.status(401).body(new ErrorResponse("Token is invalid or expired"));
}
}
// DTOs
public static class LoginRequest {
@NotBlank(message = "Password is required")
private String password;
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
public static class LoginResponse {
private String message;
private String token;
public LoginResponse(String message, String token) {
this.message = message;
this.token = token;
}
public String getMessage() {
return message;
}
public String getToken() {
return token;
}
}
public static class MessageResponse {
private String message;
public MessageResponse(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
public static class ErrorResponse {
private String error;
public ErrorResponse(String error) {
this.error = error;
}
public String getError() {
return error;
}
}
}

View File

@@ -0,0 +1,221 @@
package com.storycove.controller;
import com.storycove.dto.AuthorDto;
import com.storycove.entity.Author;
import com.storycove.service.AuthorService;
import com.storycove.service.ImageService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/authors")
public class AuthorController {
private final AuthorService authorService;
private final ImageService imageService;
public AuthorController(AuthorService authorService, ImageService imageService) {
this.authorService = authorService;
this.imageService = imageService;
}
@GetMapping
public ResponseEntity<Page<AuthorDto>> getAllAuthors(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String sortDir) {
Sort sort = sortDir.equalsIgnoreCase("desc") ?
Sort.by(sortBy).descending() : Sort.by(sortBy).ascending();
Pageable pageable = PageRequest.of(page, size, sort);
Page<Author> authors = authorService.findAll(pageable);
Page<AuthorDto> authorDtos = authors.map(this::convertToDto);
return ResponseEntity.ok(authorDtos);
}
@GetMapping("/{id}")
public ResponseEntity<AuthorDto> getAuthorById(@PathVariable UUID id) {
Author author = authorService.findById(id);
return ResponseEntity.ok(convertToDto(author));
}
@PostMapping
public ResponseEntity<AuthorDto> createAuthor(@Valid @RequestBody CreateAuthorRequest request) {
Author author = new Author();
updateAuthorFromRequest(author, request);
Author savedAuthor = authorService.create(author);
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedAuthor));
}
@PutMapping("/{id}")
public ResponseEntity<AuthorDto> updateAuthor(@PathVariable UUID id,
@Valid @RequestBody UpdateAuthorRequest request) {
Author existingAuthor = authorService.findById(id);
updateAuthorFromRequest(existingAuthor, request);
Author updatedAuthor = authorService.update(id, existingAuthor);
return ResponseEntity.ok(convertToDto(updatedAuthor));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteAuthor(@PathVariable UUID id) {
authorService.delete(id);
return ResponseEntity.ok(Map.of("message", "Author deleted successfully"));
}
@PostMapping("/{id}/avatar")
public ResponseEntity<?> uploadAvatar(@PathVariable UUID id, @RequestParam("file") MultipartFile file) {
try {
String imagePath = imageService.uploadImage(file, ImageService.ImageType.AVATAR);
Author author = authorService.setAvatar(id, imagePath);
return ResponseEntity.ok(Map.of(
"message", "Avatar uploaded successfully",
"avatarPath", author.getAvatarImagePath(),
"avatarUrl", "/api/files/images/" + author.getAvatarImagePath()
));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/{id}/avatar")
public ResponseEntity<?> deleteAvatar(@PathVariable UUID id) {
authorService.removeAvatar(id);
return ResponseEntity.ok(Map.of("message", "Avatar removed successfully"));
}
@PostMapping("/{id}/rating")
public ResponseEntity<AuthorDto> rateAuthor(@PathVariable UUID id, @RequestBody RatingRequest request) {
Author author = authorService.setRating(id, request.getRating());
return ResponseEntity.ok(convertToDto(author));
}
@GetMapping("/search")
public ResponseEntity<Page<AuthorDto>> searchAuthors(
@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Author> authors = authorService.searchByName(query, pageable);
Page<AuthorDto> authorDtos = authors.map(this::convertToDto);
return ResponseEntity.ok(authorDtos);
}
@GetMapping("/top-rated")
public ResponseEntity<List<AuthorDto>> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit);
List<Author> authors = authorService.findTopRated(pageable);
List<AuthorDto> authorDtos = authors.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(authorDtos);
}
@PostMapping("/{id}/urls")
public ResponseEntity<AuthorDto> addUrl(@PathVariable UUID id, @RequestBody UrlRequest request) {
Author author = authorService.addUrl(id, request.getUrl());
return ResponseEntity.ok(convertToDto(author));
}
@DeleteMapping("/{id}/urls")
public ResponseEntity<AuthorDto> removeUrl(@PathVariable UUID id, @RequestBody UrlRequest request) {
Author author = authorService.removeUrl(id, request.getUrl());
return ResponseEntity.ok(convertToDto(author));
}
private void updateAuthorFromRequest(Author author, Object request) {
if (request instanceof CreateAuthorRequest createReq) {
author.setName(createReq.getName());
author.setNotes(createReq.getNotes());
if (createReq.getUrls() != null) {
author.setUrls(createReq.getUrls());
}
} else if (request instanceof UpdateAuthorRequest updateReq) {
if (updateReq.getName() != null) {
author.setName(updateReq.getName());
}
if (updateReq.getNotes() != null) {
author.setNotes(updateReq.getNotes());
}
if (updateReq.getUrls() != null) {
author.setUrls(updateReq.getUrls());
}
}
}
private AuthorDto convertToDto(Author author) {
AuthorDto dto = new AuthorDto();
dto.setId(author.getId());
dto.setName(author.getName());
dto.setNotes(author.getNotes());
dto.setAvatarImagePath(author.getAvatarImagePath());
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());
return dto;
}
// Request DTOs
public static class CreateAuthorRequest {
private String name;
private String notes;
private List<String> urls;
// Getters and setters
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 List<String> getUrls() { return urls; }
public void setUrls(List<String> urls) { this.urls = urls; }
}
public static class UpdateAuthorRequest {
private String name;
private String notes;
private List<String> urls;
// Getters and setters
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 List<String> getUrls() { return urls; }
public void setUrls(List<String> urls) { this.urls = urls; }
}
public static class RatingRequest {
private Integer rating;
public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; }
}
public static class UrlRequest {
private String url;
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
}
}

View File

@@ -0,0 +1,115 @@
package com.storycove.controller;
import com.storycove.service.ImageService;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
@RestController
@RequestMapping("/api/files")
public class FileController {
private final ImageService imageService;
public FileController(ImageService imageService) {
this.imageService = imageService;
}
@PostMapping("/upload/cover")
public ResponseEntity<?> uploadCover(@RequestParam("file") MultipartFile file) {
try {
String imagePath = imageService.uploadImage(file, ImageService.ImageType.COVER);
Map<String, String> response = new HashMap<>();
response.put("message", "Cover uploaded successfully");
response.put("path", imagePath);
response.put("url", "/api/files/images/" + imagePath);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to upload image: " + e.getMessage()));
}
}
@PostMapping("/upload/avatar")
public ResponseEntity<?> uploadAvatar(@RequestParam("file") MultipartFile file) {
try {
String imagePath = imageService.uploadImage(file, ImageService.ImageType.AVATAR);
Map<String, String> response = new HashMap<>();
response.put("message", "Avatar uploaded successfully");
response.put("path", imagePath);
response.put("url", "/api/files/images/" + imagePath);
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (IOException e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to upload image: " + e.getMessage()));
}
}
@GetMapping("/images/**")
public ResponseEntity<Resource> serveImage(@RequestParam String path) {
try {
// Extract path from the URL
String imagePath = path.replace("/api/files/images/", "");
if (!imageService.imageExists(imagePath)) {
return ResponseEntity.notFound().build();
}
Path fullPath = imageService.getImagePath(imagePath);
Resource resource = new FileSystemResource(fullPath);
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
// Determine content type
String contentType = Files.probeContentType(fullPath);
if (contentType == null) {
contentType = "application/octet-stream";
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CACHE_CONTROL, "public, max-age=31536000") // Cache for 1 year
.body(resource);
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@DeleteMapping("/images")
public ResponseEntity<?> deleteImage(@RequestParam String path) {
try {
boolean deleted = imageService.deleteImage(path);
if (deleted) {
return ResponseEntity.ok(Map.of("message", "Image deleted successfully"));
} else {
return ResponseEntity.notFound().build();
}
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to delete image: " + e.getMessage()));
}
}
}

View File

@@ -0,0 +1,72 @@
package com.storycove.controller;
import com.storycove.entity.Story;
import com.storycove.service.StoryService;
import com.storycove.service.TypesenseService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/search")
public class SearchController {
private final TypesenseService typesenseService;
private final StoryService storyService;
public SearchController(@Autowired(required = false) TypesenseService typesenseService, StoryService storyService) {
this.typesenseService = typesenseService;
this.storyService = storyService;
}
@PostMapping("/reindex")
public ResponseEntity<?> reindexAllStories() {
if (typesenseService == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Typesense service is not available"
));
}
try {
List<Story> allStories = storyService.findAll();
typesenseService.reindexAllStories(allStories);
return ResponseEntity.ok(Map.of(
"message", "Successfully reindexed all stories",
"storiesCount", allStories.size()
));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Failed to reindex stories: " + e.getMessage()
));
}
}
@GetMapping("/health")
public ResponseEntity<?> searchHealthCheck() {
if (typesenseService == null) {
return ResponseEntity.ok(Map.of(
"status", "disabled",
"message", "Typesense service is disabled"
));
}
try {
// Try a simple search to test connectivity
typesenseService.searchSuggestions("test", 1);
return ResponseEntity.ok(Map.of(
"status", "healthy",
"message", "Search service is operational"
));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of(
"status", "unhealthy",
"error", e.getMessage()
));
}
}
}

View File

@@ -0,0 +1,176 @@
package com.storycove.controller;
import com.storycove.dto.SeriesDto;
import com.storycove.entity.Series;
import com.storycove.service.SeriesService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/series")
public class SeriesController {
private final SeriesService seriesService;
public SeriesController(SeriesService seriesService) {
this.seriesService = seriesService;
}
@GetMapping
public ResponseEntity<Page<SeriesDto>> getAllSeries(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String sortDir) {
Sort sort = sortDir.equalsIgnoreCase("desc") ?
Sort.by(sortBy).descending() : Sort.by(sortBy).ascending();
Pageable pageable = PageRequest.of(page, size, sort);
Page<Series> series = seriesService.findAll(pageable);
Page<SeriesDto> seriesDtos = series.map(this::convertToDto);
return ResponseEntity.ok(seriesDtos);
}
@GetMapping("/{id}")
public ResponseEntity<SeriesDto> getSeriesById(@PathVariable UUID id) {
Series series = seriesService.findById(id);
return ResponseEntity.ok(convertToDto(series));
}
@PostMapping
public ResponseEntity<SeriesDto> createSeries(@Valid @RequestBody CreateSeriesRequest request) {
Series series = new Series();
updateSeriesFromRequest(series, request);
Series savedSeries = seriesService.create(series);
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedSeries));
}
@PutMapping("/{id}")
public ResponseEntity<SeriesDto> updateSeries(@PathVariable UUID id,
@Valid @RequestBody UpdateSeriesRequest request) {
Series existingSeries = seriesService.findById(id);
updateSeriesFromRequest(existingSeries, request);
Series updatedSeries = seriesService.update(id, existingSeries);
return ResponseEntity.ok(convertToDto(updatedSeries));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteSeries(@PathVariable UUID id) {
seriesService.delete(id);
return ResponseEntity.ok(Map.of("message", "Series deleted successfully"));
}
@GetMapping("/search")
public ResponseEntity<Page<SeriesDto>> searchSeries(
@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Series> series = seriesService.searchByName(query, pageable);
Page<SeriesDto> seriesDtos = series.map(this::convertToDto);
return ResponseEntity.ok(seriesDtos);
}
@GetMapping("/with-stories")
public ResponseEntity<List<SeriesDto>> getSeriesWithStories(@RequestParam(defaultValue = "20") int limit) {
Pageable pageable = PageRequest.of(0, limit);
List<Series> series = seriesService.findSeriesWithStoriesLimited(pageable);
List<SeriesDto> seriesDtos = series.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(seriesDtos);
}
@GetMapping("/popular")
public ResponseEntity<List<SeriesDto>> getPopularSeries(@RequestParam(defaultValue = "10") int limit) {
List<Series> series = seriesService.findMostPopular(limit);
List<SeriesDto> seriesDtos = series.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(seriesDtos);
}
@GetMapping("/empty")
public ResponseEntity<List<SeriesDto>> getEmptySeries() {
List<Series> series = seriesService.findEmptySeries();
List<SeriesDto> seriesDtos = series.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(seriesDtos);
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getSeriesStats() {
long totalSeries = seriesService.countAll();
long seriesWithStories = seriesService.countSeriesWithStories();
long emptySeries = totalSeries - seriesWithStories;
Map<String, Object> stats = Map.of(
"totalSeries", totalSeries,
"seriesWithStories", seriesWithStories,
"emptySeries", emptySeries
);
return ResponseEntity.ok(stats);
}
private void updateSeriesFromRequest(Series series, Object request) {
if (request instanceof CreateSeriesRequest createReq) {
series.setName(createReq.getName());
series.setDescription(createReq.getDescription());
} else if (request instanceof UpdateSeriesRequest updateReq) {
if (updateReq.getName() != null) {
series.setName(updateReq.getName());
}
if (updateReq.getDescription() != null) {
series.setDescription(updateReq.getDescription());
}
}
}
private SeriesDto convertToDto(Series series) {
SeriesDto dto = new SeriesDto();
dto.setId(series.getId());
dto.setName(series.getName());
dto.setDescription(series.getDescription());
dto.setStoryCount(series.getStories() != null ? series.getStories().size() : 0);
dto.setCreatedAt(series.getCreatedAt());
return dto;
}
// Request DTOs
public static class CreateSeriesRequest {
private String name;
private String description;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
public static class UpdateSeriesRequest {
private String name;
private String description;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
}

View File

@@ -0,0 +1,426 @@
package com.storycove.controller;
import com.storycove.dto.StoryDto;
import com.storycove.dto.TagDto;
import com.storycove.entity.Author;
import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto;
import com.storycove.service.*;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/stories")
public class StoryController {
private final StoryService storyService;
private final AuthorService authorService;
private final SeriesService seriesService;
private final TagService tagService;
private final HtmlSanitizationService sanitizationService;
private final ImageService imageService;
private final TypesenseService typesenseService;
public StoryController(StoryService storyService,
AuthorService authorService,
SeriesService seriesService,
TagService tagService,
HtmlSanitizationService sanitizationService,
ImageService imageService,
@Autowired(required = false) TypesenseService typesenseService) {
this.storyService = storyService;
this.authorService = authorService;
this.seriesService = seriesService;
this.tagService = tagService;
this.sanitizationService = sanitizationService;
this.imageService = imageService;
this.typesenseService = typesenseService;
}
@GetMapping
public ResponseEntity<Page<StoryDto>> getAllStories(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String sortDir) {
Sort sort = sortDir.equalsIgnoreCase("desc") ?
Sort.by(sortBy).descending() : Sort.by(sortBy).ascending();
Pageable pageable = PageRequest.of(page, size, sort);
Page<Story> stories = storyService.findAll(pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto);
return ResponseEntity.ok(storyDtos);
}
@GetMapping("/{id}")
public ResponseEntity<StoryDto> getStoryById(@PathVariable UUID id) {
Story story = storyService.findById(id);
return ResponseEntity.ok(convertToDto(story));
}
@PostMapping
public ResponseEntity<StoryDto> createStory(@Valid @RequestBody CreateStoryRequest request) {
Story story = new Story();
updateStoryFromRequest(story, request);
Story savedStory = storyService.create(story);
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedStory));
}
@PutMapping("/{id}")
public ResponseEntity<StoryDto> updateStory(@PathVariable UUID id,
@Valid @RequestBody UpdateStoryRequest request) {
Story updatedStory = storyService.updateWithTagNames(id, request);
return ResponseEntity.ok(convertToDto(updatedStory));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteStory(@PathVariable UUID id) {
storyService.delete(id);
return ResponseEntity.ok(Map.of("message", "Story deleted successfully"));
}
@PostMapping("/{id}/cover")
public ResponseEntity<?> uploadCover(@PathVariable UUID id, @RequestParam("file") MultipartFile file) {
try {
String imagePath = imageService.uploadImage(file, ImageService.ImageType.COVER);
Story story = storyService.setCoverImage(id, imagePath);
return ResponseEntity.ok(Map.of(
"message", "Cover uploaded successfully",
"coverPath", story.getCoverPath(),
"coverUrl", "/api/files/images/" + story.getCoverPath()
));
} catch (Exception e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/{id}/cover")
public ResponseEntity<?> deleteCover(@PathVariable UUID id) {
storyService.removeCoverImage(id);
return ResponseEntity.ok(Map.of("message", "Cover removed successfully"));
}
@PostMapping("/{id}/tags/{tagId}")
public ResponseEntity<StoryDto> addTag(@PathVariable UUID id, @PathVariable UUID tagId) {
Story story = storyService.addTag(id, tagId);
return ResponseEntity.ok(convertToDto(story));
}
@DeleteMapping("/{id}/tags/{tagId}")
public ResponseEntity<StoryDto> removeTag(@PathVariable UUID id, @PathVariable UUID tagId) {
Story story = storyService.removeTag(id, tagId);
return ResponseEntity.ok(convertToDto(story));
}
@PostMapping("/{id}/rating")
public ResponseEntity<StoryDto> rateStory(@PathVariable UUID id, @RequestBody RatingRequest request) {
Story story = storyService.setRating(id, request.getRating());
return ResponseEntity.ok(convertToDto(story));
}
@GetMapping("/search")
public ResponseEntity<SearchResultDto<StorySearchDto>> searchStories(
@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) List<String> authors,
@RequestParam(required = false) List<String> tags,
@RequestParam(required = false) Integer minRating,
@RequestParam(required = false) Integer maxRating) {
if (typesenseService != null) {
SearchResultDto<StorySearchDto> results = typesenseService.searchStories(
query, page, size, authors, tags, minRating, maxRating);
return ResponseEntity.ok(results);
} else {
// Fallback to basic search if Typesense is not available
return ResponseEntity.badRequest().body(null);
}
}
@GetMapping("/search/suggestions")
public ResponseEntity<List<String>> getSearchSuggestions(
@RequestParam String query,
@RequestParam(defaultValue = "5") int limit) {
if (typesenseService != null) {
List<String> suggestions = typesenseService.searchSuggestions(query, limit);
return ResponseEntity.ok(suggestions);
} else {
return ResponseEntity.ok(new ArrayList<>());
}
}
@GetMapping("/author/{authorId}")
public ResponseEntity<Page<StoryDto>> getStoriesByAuthor(
@PathVariable UUID authorId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Story> stories = storyService.findByAuthor(authorId, pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto);
return ResponseEntity.ok(storyDtos);
}
@GetMapping("/series/{seriesId}")
public ResponseEntity<List<StoryDto>> getStoriesBySeries(@PathVariable UUID seriesId) {
List<Story> stories = storyService.findBySeriesOrderByVolume(seriesId);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos);
}
@GetMapping("/tags/{tagName}")
public ResponseEntity<Page<StoryDto>> getStoriesByTag(
@PathVariable String tagName,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Story> stories = storyService.findByTagNames(List.of(tagName), pageable);
Page<StoryDto> storyDtos = stories.map(this::convertToDto);
return ResponseEntity.ok(storyDtos);
}
@GetMapping("/recent")
public ResponseEntity<List<StoryDto>> getRecentStories(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit, Sort.by("createdAt").descending());
List<Story> stories = storyService.findRecentlyAddedLimited(pageable);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos);
}
@GetMapping("/top-rated")
public ResponseEntity<List<StoryDto>> getTopRatedStories(@RequestParam(defaultValue = "10") int limit) {
Pageable pageable = PageRequest.of(0, limit);
List<Story> stories = storyService.findTopRatedStoriesLimited(pageable);
List<StoryDto> storyDtos = stories.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(storyDtos);
}
private Author findOrCreateAuthor(String authorName) {
// First try to find existing author by name
try {
return authorService.findByName(authorName);
} catch (Exception e) {
// Author doesn't exist, create a new one
Author newAuthor = new Author();
newAuthor.setName(authorName);
return authorService.create(newAuthor);
}
}
private void updateStoryFromRequest(Story story, Object request) {
if (request instanceof CreateStoryRequest createReq) {
story.setTitle(createReq.getTitle());
story.setSummary(createReq.getSummary());
story.setDescription(createReq.getDescription());
story.setContentHtml(sanitizationService.sanitize(createReq.getContentHtml()));
story.setSourceUrl(createReq.getSourceUrl());
story.setVolume(createReq.getVolume());
// Handle author - either by ID or by name
if (createReq.getAuthorId() != null) {
Author author = authorService.findById(createReq.getAuthorId());
story.setAuthor(author);
} else if (createReq.getAuthorName() != null && !createReq.getAuthorName().trim().isEmpty()) {
Author author = findOrCreateAuthor(createReq.getAuthorName().trim());
story.setAuthor(author);
}
if (createReq.getSeriesId() != null) {
Series series = seriesService.findById(createReq.getSeriesId());
story.setSeries(series);
}
// Handle tags
if (createReq.getTagNames() != null && !createReq.getTagNames().isEmpty()) {
for (String tagName : createReq.getTagNames()) {
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);
}
}
}
} else if (request instanceof UpdateStoryRequest updateReq) {
if (updateReq.getTitle() != null) {
story.setTitle(updateReq.getTitle());
}
if (updateReq.getSummary() != null) {
story.setSummary(updateReq.getSummary());
}
if (updateReq.getDescription() != null) {
story.setDescription(updateReq.getDescription());
}
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());
}
if (updateReq.getAuthorId() != null) {
Author author = authorService.findById(updateReq.getAuthorId());
story.setAuthor(author);
}
if (updateReq.getSeriesId() != null) {
Series series = seriesService.findById(updateReq.getSeriesId());
story.setSeries(series);
}
// Note: Tags are now handled in StoryService.updateWithTagNames()
}
}
private StoryDto convertToDto(Story story) {
StoryDto dto = new StoryDto();
dto.setId(story.getId());
dto.setTitle(story.getTitle());
dto.setSummary(story.getSummary());
dto.setDescription(story.getDescription());
dto.setContentHtml(story.getContentHtml());
dto.setContentPlain(story.getContentPlain());
dto.setSourceUrl(story.getSourceUrl());
dto.setCoverPath(story.getCoverPath());
dto.setWordCount(story.getWordCount());
dto.setRating(story.getRating());
dto.setVolume(story.getVolume());
dto.setCreatedAt(story.getCreatedAt());
dto.setUpdatedAt(story.getUpdatedAt());
if (story.getAuthor() != null) {
dto.setAuthorId(story.getAuthor().getId());
dto.setAuthorName(story.getAuthor().getName());
}
if (story.getSeries() != null) {
dto.setSeriesId(story.getSeries().getId());
dto.setSeriesName(story.getSeries().getName());
}
dto.setTags(story.getTags().stream()
.map(this::convertTagToDto)
.collect(Collectors.toList()));
return dto;
}
private TagDto convertTagToDto(Tag tag) {
TagDto tagDto = new TagDto();
tagDto.setId(tag.getId());
tagDto.setName(tag.getName());
tagDto.setCreatedAt(tag.getCreatedAt());
// storyCount can be set if needed, but it might be expensive to calculate for each tag
return tagDto;
}
// Request DTOs
public static class CreateStoryRequest {
private String title;
private String summary;
private String description;
private String contentHtml;
private String sourceUrl;
private Integer volume;
private UUID authorId;
private String authorName;
private UUID seriesId;
private List<String> tagNames;
// Getters and setters
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public String getSourceUrl() { return sourceUrl; }
public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; }
public Integer getVolume() { return volume; }
public void setVolume(Integer volume) { this.volume = volume; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public String getAuthorName() { return authorName; }
public void setAuthorName(String authorName) { this.authorName = authorName; }
public UUID getSeriesId() { return seriesId; }
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
public List<String> getTagNames() { return tagNames; }
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
}
public static class UpdateStoryRequest {
private String title;
private String summary;
private String description;
private String contentHtml;
private String sourceUrl;
private Integer volume;
private UUID authorId;
private UUID seriesId;
private List<String> tagNames;
// Getters and setters
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getSummary() { return summary; }
public void setSummary(String summary) { this.summary = summary; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getContentHtml() { return contentHtml; }
public void setContentHtml(String contentHtml) { this.contentHtml = contentHtml; }
public String getSourceUrl() { return sourceUrl; }
public void setSourceUrl(String sourceUrl) { this.sourceUrl = sourceUrl; }
public Integer getVolume() { return volume; }
public void setVolume(Integer volume) { this.volume = volume; }
public UUID getAuthorId() { return authorId; }
public void setAuthorId(UUID authorId) { this.authorId = authorId; }
public UUID getSeriesId() { return seriesId; }
public void setSeriesId(UUID seriesId) { this.seriesId = seriesId; }
public List<String> getTagNames() { return tagNames; }
public void setTagNames(List<String> tagNames) { this.tagNames = tagNames; }
}
public static class RatingRequest {
private Integer rating;
public Integer getRating() { return rating; }
public void setRating(Integer rating) { this.rating = rating; }
}
}

View File

@@ -0,0 +1,160 @@
package com.storycove.controller;
import com.storycove.dto.TagDto;
import com.storycove.entity.Tag;
import com.storycove.service.TagService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/tags")
public class TagController {
private final TagService tagService;
public TagController(TagService tagService) {
this.tagService = tagService;
}
@GetMapping
public ResponseEntity<Page<TagDto>> getAllTags(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String sortDir) {
Sort sort = sortDir.equalsIgnoreCase("desc") ?
Sort.by(sortBy).descending() : Sort.by(sortBy).ascending();
Pageable pageable = PageRequest.of(page, size, sort);
Page<Tag> tags = tagService.findAll(pageable);
Page<TagDto> tagDtos = tags.map(this::convertToDto);
return ResponseEntity.ok(tagDtos);
}
@GetMapping("/{id}")
public ResponseEntity<TagDto> getTagById(@PathVariable UUID id) {
Tag tag = tagService.findById(id);
return ResponseEntity.ok(convertToDto(tag));
}
@PostMapping
public ResponseEntity<TagDto> createTag(@Valid @RequestBody CreateTagRequest request) {
Tag tag = new Tag();
tag.setName(request.getName());
Tag savedTag = tagService.create(tag);
return ResponseEntity.status(HttpStatus.CREATED).body(convertToDto(savedTag));
}
@PutMapping("/{id}")
public ResponseEntity<TagDto> updateTag(@PathVariable UUID id,
@Valid @RequestBody UpdateTagRequest request) {
Tag existingTag = tagService.findById(id);
if (request.getName() != null) {
existingTag.setName(request.getName());
}
Tag updatedTag = tagService.update(id, existingTag);
return ResponseEntity.ok(convertToDto(updatedTag));
}
@DeleteMapping("/{id}")
public ResponseEntity<?> deleteTag(@PathVariable UUID id) {
tagService.delete(id);
return ResponseEntity.ok(Map.of("message", "Tag deleted successfully"));
}
@GetMapping("/search")
public ResponseEntity<Page<TagDto>> searchTags(
@RequestParam String query,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, size);
Page<Tag> tags = tagService.searchByName(query, pageable);
Page<TagDto> tagDtos = tags.map(this::convertToDto);
return ResponseEntity.ok(tagDtos);
}
@GetMapping("/autocomplete")
public ResponseEntity<List<TagDto>> autocompleteTags(
@RequestParam String query,
@RequestParam(defaultValue = "10") int limit) {
List<Tag> tags = tagService.findByNameStartingWith(query, limit);
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(tagDtos);
}
@GetMapping("/popular")
public ResponseEntity<List<TagDto>> getPopularTags(@RequestParam(defaultValue = "20") int limit) {
List<Tag> tags = tagService.findMostUsed(limit);
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(tagDtos);
}
@GetMapping("/unused")
public ResponseEntity<List<TagDto>> getUnusedTags() {
List<Tag> tags = tagService.findUnusedTags();
List<TagDto> tagDtos = tags.stream().map(this::convertToDto).collect(Collectors.toList());
return ResponseEntity.ok(tagDtos);
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getTagStats() {
long totalTags = tagService.countAll();
long usedTags = tagService.countUsedTags();
long unusedTags = totalTags - usedTags;
Map<String, Object> stats = Map.of(
"totalTags", totalTags,
"usedTags", usedTags,
"unusedTags", unusedTags
);
return ResponseEntity.ok(stats);
}
private TagDto convertToDto(Tag tag) {
TagDto dto = new TagDto();
dto.setId(tag.getId());
dto.setName(tag.getName());
dto.setStoryCount(tag.getStories() != null ? tag.getStories().size() : 0);
dto.setCreatedAt(tag.getCreatedAt());
// updatedAt field not present in Tag entity per spec
return dto;
}
// Request DTOs
public static class CreateTagRequest {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
public static class UpdateTagRequest {
private String name;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
}
}

View File

@@ -15,13 +15,10 @@ public class AuthorDto {
@Size(max = 255, message = "Author name must not exceed 255 characters")
private String name;
@Size(max = 1000, message = "Bio must not exceed 1000 characters")
private String bio;
private String notes;
private String avatarPath;
private Double rating;
private Double averageStoryRating;
private Integer totalStoryRatings;
private String avatarImagePath;
private Integer authorRating;
private List<String> urls;
private Integer storyCount;
private LocalDateTime createdAt;
@@ -50,44 +47,28 @@ public class AuthorDto {
this.name = name;
}
public String getBio() {
return bio;
public String getNotes() {
return notes;
}
public void setBio(String bio) {
this.bio = bio;
public void setNotes(String notes) {
this.notes = notes;
}
public String getAvatarPath() {
return avatarPath;
public String getAvatarImagePath() {
return avatarImagePath;
}
public void setAvatarPath(String avatarPath) {
this.avatarPath = avatarPath;
public void setAvatarImagePath(String avatarImagePath) {
this.avatarImagePath = avatarImagePath;
}
public Double getRating() {
return rating;
public Integer getAuthorRating() {
return authorRating;
}
public void setRating(Double rating) {
this.rating = rating;
}
public Double getAverageStoryRating() {
return averageStoryRating;
}
public void setAverageStoryRating(Double averageStoryRating) {
this.averageStoryRating = averageStoryRating;
}
public Integer getTotalStoryRatings() {
return totalStoryRatings;
}
public void setTotalStoryRatings(Integer totalStoryRatings) {
this.totalStoryRatings = totalStoryRatings;
public void setAuthorRating(Integer authorRating) {
this.authorRating = authorRating;
}
public List<String> getUrls() {

View File

@@ -0,0 +1,73 @@
package com.storycove.dto;
import java.util.List;
public class SearchResultDto<T> {
private List<T> results;
private long totalHits;
private int page;
private int perPage;
private String query;
private long searchTimeMs;
public SearchResultDto() {}
public SearchResultDto(List<T> results, long totalHits, int page, int perPage, String query, long searchTimeMs) {
this.results = results;
this.totalHits = totalHits;
this.page = page;
this.perPage = perPage;
this.query = query;
this.searchTimeMs = searchTimeMs;
}
// Getters and Setters
public List<T> getResults() {
return results;
}
public void setResults(List<T> results) {
this.results = results;
}
public long getTotalHits() {
return totalHits;
}
public void setTotalHits(long totalHits) {
this.totalHits = totalHits;
}
public int getPage() {
return page;
}
public void setPage(int page) {
this.page = page;
}
public int getPerPage() {
return perPage;
}
public void setPerPage(int perPage) {
this.perPage = perPage;
}
public String getQuery() {
return query;
}
public void setQuery(String query) {
this.query = query;
}
public long getSearchTimeMs() {
return searchTimeMs;
}
public void setSearchTimeMs(long searchTimeMs) {
this.searchTimeMs = searchTimeMs;
}
}

View File

@@ -0,0 +1,67 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.UUID;
public class SeriesDto {
private UUID id;
@NotBlank(message = "Series name is required")
@Size(max = 255, message = "Series name must not exceed 255 characters")
private String name;
private String description;
private Integer storyCount;
private LocalDateTime createdAt;
public SeriesDto() {}
public SeriesDto(String name) {
this.name = name;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getStoryCount() {
return storyCount;
}
public void setStoryCount(Integer storyCount) {
this.storyCount = storyCount;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -15,27 +15,25 @@ public class StoryDto {
@Size(max = 255, message = "Story title must not exceed 255 characters")
private String title;
private String summary;
@Size(max = 1000, message = "Story description must not exceed 1000 characters")
private String description;
private String content;
private String contentHtml;
private String contentPlain;
private String sourceUrl;
private String coverPath;
private Integer wordCount;
private Integer readingTimeMinutes;
private Double averageRating;
private Integer totalRatings;
private Boolean isFavorite;
private Double readingProgress;
private LocalDateTime lastReadAt;
private Integer partNumber;
private Integer rating;
private Integer volume;
// Related entities as simple references
private UUID authorId;
private String authorName;
private UUID seriesId;
private String seriesName;
private List<String> tagNames;
private List<TagDto> tags;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@@ -63,6 +61,14 @@ public class StoryDto {
this.title = title;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getDescription() {
return description;
}
@@ -71,12 +77,20 @@ public class StoryDto {
this.description = description;
}
public String getContent() {
return content;
public String getContentHtml() {
return contentHtml;
}
public void setContent(String content) {
this.content = content;
public void setContentHtml(String contentHtml) {
this.contentHtml = contentHtml;
}
public String getContentPlain() {
return contentPlain;
}
public void setContentPlain(String contentPlain) {
this.contentPlain = contentPlain;
}
public String getSourceUrl() {
@@ -103,60 +117,20 @@ public class StoryDto {
this.wordCount = wordCount;
}
public Integer getReadingTimeMinutes() {
return readingTimeMinutes;
public Integer getRating() {
return rating;
}
public void setReadingTimeMinutes(Integer readingTimeMinutes) {
this.readingTimeMinutes = readingTimeMinutes;
public void setRating(Integer rating) {
this.rating = rating;
}
public Double getAverageRating() {
return averageRating;
public Integer getVolume() {
return volume;
}
public void setAverageRating(Double averageRating) {
this.averageRating = averageRating;
}
public Integer getTotalRatings() {
return totalRatings;
}
public void setTotalRatings(Integer totalRatings) {
this.totalRatings = totalRatings;
}
public Boolean getIsFavorite() {
return isFavorite;
}
public void setIsFavorite(Boolean isFavorite) {
this.isFavorite = isFavorite;
}
public Double getReadingProgress() {
return readingProgress;
}
public void setReadingProgress(Double readingProgress) {
this.readingProgress = readingProgress;
}
public LocalDateTime getLastReadAt() {
return lastReadAt;
}
public void setLastReadAt(LocalDateTime lastReadAt) {
this.lastReadAt = lastReadAt;
}
public Integer getPartNumber() {
return partNumber;
}
public void setPartNumber(Integer partNumber) {
this.partNumber = partNumber;
public void setVolume(Integer volume) {
this.volume = volume;
}
public UUID getAuthorId() {
@@ -191,12 +165,12 @@ public class StoryDto {
this.seriesName = seriesName;
}
public List<String> getTagNames() {
return tagNames;
public List<TagDto> getTags() {
return tags;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
public void setTags(List<TagDto> tags) {
this.tags = tags;
}
public LocalDateTime getCreatedAt() {

View File

@@ -0,0 +1,183 @@
package com.storycove.dto;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
public class StorySearchDto {
private UUID id;
private String title;
private String description;
private String contentPlain;
private String sourceUrl;
private String coverPath;
private Integer wordCount;
private Integer rating;
private Integer volume;
// Author info
private UUID authorId;
private String authorName;
// Series info
private UUID seriesId;
private String seriesName;
// Tags
private List<String> tagNames;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Search-specific fields
private double searchScore;
private List<String> highlights;
public StorySearchDto() {}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getContentPlain() {
return contentPlain;
}
public void setContentPlain(String contentPlain) {
this.contentPlain = contentPlain;
}
public String getSourceUrl() {
return sourceUrl;
}
public void setSourceUrl(String sourceUrl) {
this.sourceUrl = sourceUrl;
}
public String getCoverPath() {
return coverPath;
}
public void setCoverPath(String coverPath) {
this.coverPath = coverPath;
}
public Integer getWordCount() {
return wordCount;
}
public void setWordCount(Integer wordCount) {
this.wordCount = wordCount;
}
public Integer getRating() {
return rating;
}
public void setRating(Integer rating) {
this.rating = rating;
}
public Integer getVolume() {
return volume;
}
public void setVolume(Integer volume) {
this.volume = volume;
}
public UUID getAuthorId() {
return authorId;
}
public void setAuthorId(UUID authorId) {
this.authorId = authorId;
}
public String getAuthorName() {
return authorName;
}
public void setAuthorName(String authorName) {
this.authorName = authorName;
}
public UUID getSeriesId() {
return seriesId;
}
public void setSeriesId(UUID seriesId) {
this.seriesId = seriesId;
}
public String getSeriesName() {
return seriesName;
}
public void setSeriesName(String seriesName) {
this.seriesName = seriesName;
}
public List<String> getTagNames() {
return tagNames;
}
public void setTagNames(List<String> tagNames) {
this.tagNames = tagNames;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
public double getSearchScore() {
return searchScore;
}
public void setSearchScore(double searchScore) {
this.searchScore = searchScore;
}
public List<String> getHighlights() {
return highlights;
}
public void setHighlights(List<String> highlights) {
this.highlights = highlights;
}
}

View File

@@ -0,0 +1,67 @@
package com.storycove.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.UUID;
public class TagDto {
private UUID id;
@NotBlank(message = "Tag name is required")
@Size(max = 100, message = "Tag name must not exceed 100 characters")
private String name;
private Integer storyCount;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
public TagDto() {}
public TagDto(String name) {
this.name = name;
}
// Getters and Setters
public UUID getId() {
return id;
}
public void setId(UUID id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Integer getStoryCount() {
return storyCount;
}
public void setStoryCount(Integer storyCount) {
this.storyCount = storyCount;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
}

View File

@@ -24,15 +24,14 @@ public class Author {
@Column(nullable = false)
private String name;
@Size(max = 1000, message = "Bio must not exceed 1000 characters")
@Column(length = 1000)
private String bio;
@Column(columnDefinition = "TEXT")
private String notes;
@Column(name = "avatar_path")
private String avatarPath;
@Column(name = "avatar_image_path")
private String avatarImagePath;
@Column(name = "rating")
private Double rating = 0.0;
@Column(name = "author_rating")
private Integer authorRating;
@ElementCollection
@@ -77,28 +76,6 @@ public class Author {
urls.remove(url);
}
public double getAverageStoryRating() {
if (stories.isEmpty()) {
return 0.0;
}
double totalRating = stories.stream()
.filter(story -> story.getTotalRatings() > 0)
.mapToDouble(story -> story.getAverageRating())
.sum();
long ratedStoriesCount = stories.stream()
.filter(story -> story.getTotalRatings() > 0)
.count();
return ratedStoriesCount > 0 ? totalRating / ratedStoriesCount : 0.0;
}
public int getTotalStoryRatings() {
return stories.stream()
.mapToInt(story -> story.getTotalRatings())
.sum();
}
// Getters and Setters
public UUID getId() {
@@ -117,28 +94,28 @@ public class Author {
this.name = name;
}
public String getBio() {
return bio;
public String getNotes() {
return notes;
}
public void setBio(String bio) {
this.bio = bio;
public void setNotes(String notes) {
this.notes = notes;
}
public String getAvatarPath() {
return avatarPath;
public String getAvatarImagePath() {
return avatarImagePath;
}
public void setAvatarPath(String avatarPath) {
this.avatarPath = avatarPath;
public void setAvatarImagePath(String avatarImagePath) {
this.avatarImagePath = avatarImagePath;
}
public Double getRating() {
return rating;
public Integer getAuthorRating() {
return authorRating;
}
public void setRating(Double rating) {
this.rating = rating;
public void setAuthorRating(Integer authorRating) {
this.authorRating = authorRating;
}
@@ -192,9 +169,7 @@ public class Author {
return "Author{" +
"id=" + id +
", name='" + name + '\'' +
", rating=" + rating +
", averageStoryRating=" + getAverageStoryRating() +
", totalStoryRatings=" + getTotalStoryRatings() +
", authorRating=" + authorRating +
'}';
}
}

View File

@@ -4,7 +4,6 @@ import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import java.time.LocalDateTime;
import java.util.ArrayList;
@@ -28,23 +27,14 @@ public class Series {
@Column(length = 1000)
private String description;
@Column(name = "total_parts")
private Integer totalParts = 0;
@Column(name = "is_complete")
private Boolean isComplete = false;
@OneToMany(mappedBy = "series", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@OrderBy("partNumber ASC")
@OrderBy("volume ASC")
private List<Story> stories = new ArrayList<>();
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Series() {}
@@ -60,35 +50,30 @@ public class Series {
public void addStory(Story story) {
stories.add(story);
story.setSeries(this);
updateTotalParts();
}
public void removeStory(Story story) {
stories.remove(story);
story.setSeries(null);
updateTotalParts();
}
private void updateTotalParts() {
this.totalParts = stories.size();
}
public Story getNextStory(Story currentStory) {
if (currentStory.getPartNumber() == null) return null;
if (currentStory.getVolume() == null) return null;
return stories.stream()
.filter(story -> story.getPartNumber() != null)
.filter(story -> story.getPartNumber().equals(currentStory.getPartNumber() + 1))
.filter(story -> story.getVolume() != null)
.filter(story -> story.getVolume().equals(currentStory.getVolume() + 1))
.findFirst()
.orElse(null);
}
public Story getPreviousStory(Story currentStory) {
if (currentStory.getPartNumber() == null || currentStory.getPartNumber() <= 1) return null;
if (currentStory.getVolume() == null || currentStory.getVolume() <= 1) return null;
return stories.stream()
.filter(story -> story.getPartNumber() != null)
.filter(story -> story.getPartNumber().equals(currentStory.getPartNumber() - 1))
.filter(story -> story.getVolume() != null)
.filter(story -> story.getVolume().equals(currentStory.getVolume() - 1))
.findFirst()
.orElse(null);
}
@@ -118,21 +103,6 @@ public class Series {
this.description = description;
}
public Integer getTotalParts() {
return totalParts;
}
public void setTotalParts(Integer totalParts) {
this.totalParts = totalParts;
}
public Boolean getIsComplete() {
return isComplete;
}
public void setIsComplete(Boolean isComplete) {
this.isComplete = isComplete;
}
public List<Story> getStories() {
return stories;
@@ -150,13 +120,6 @@ public class Series {
this.createdAt = createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public void setUpdatedAt(LocalDateTime updatedAt) {
this.updatedAt = updatedAt;
}
@Override
public boolean equals(Object o) {
@@ -176,8 +139,6 @@ public class Series {
return "Series{" +
"id=" + id +
", name='" + name + '\'' +
", totalParts=" + totalParts +
", isComplete=" + isComplete +
'}';
}
}

View File

@@ -5,6 +5,7 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import org.jsoup.Jsoup;
import java.time.LocalDateTime;
import java.util.HashSet;
@@ -24,12 +25,18 @@ public class Story {
@Column(nullable = false)
private String title;
@Column(name = "summary", columnDefinition = "TEXT")
private String summary;
@Size(max = 1000, message = "Story description must not exceed 1000 characters")
@Column(length = 1000)
private String description;
@Column(columnDefinition = "TEXT")
private String content;
@Column(name = "content_html", columnDefinition = "TEXT")
private String contentHtml;
@Column(name = "content_plain", columnDefinition = "TEXT")
private String contentPlain;
@Column(name = "source_url")
private String sourceUrl;
@@ -40,26 +47,11 @@ public class Story {
@Column(name = "word_count")
private Integer wordCount = 0;
@Column(name = "reading_time_minutes")
private Integer readingTimeMinutes = 0;
@Column(name = "rating")
private Integer rating;
@Column(name = "average_rating")
private Double averageRating = 0.0;
@Column(name = "total_ratings")
private Integer totalRatings = 0;
@Column(name = "is_favorite")
private Boolean isFavorite = false;
@Column(name = "reading_progress")
private Double readingProgress = 0.0;
@Column(name = "last_read_at")
private LocalDateTime lastReadAt;
@Column(name = "part_number")
private Integer partNumber;
@Column(name = "volume")
private Integer volume;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
@@ -91,51 +83,37 @@ public class Story {
this.title = title;
}
public Story(String title, String content) {
public Story(String title, String contentHtml) {
this.title = title;
this.content = content;
this.contentHtml = contentHtml;
updateWordCount();
}
public void addTag(Tag tag) {
tags.add(tag);
tag.getStories().add(this);
tag.incrementUsage();
}
public void removeTag(Tag tag) {
tags.remove(tag);
tag.getStories().remove(this);
tag.decrementUsage();
}
public void updateRating(double newRating) {
if (totalRatings == 0) {
averageRating = newRating;
totalRatings = 1;
} else {
double totalScore = averageRating * totalRatings;
totalRatings++;
averageRating = (totalScore + newRating) / totalRatings;
}
}
public void updateWordCount() {
if (content != null) {
String cleanText = content.replaceAll("<[^>]*>", "");
if (contentPlain != null) {
String[] words = contentPlain.trim().split("\\s+");
this.wordCount = words.length;
} else if (contentHtml != null) {
String cleanText = contentHtml.replaceAll("<[^>]*>", "");
String[] words = cleanText.trim().split("\\s+");
this.wordCount = words.length;
this.readingTimeMinutes = Math.max(1, (int) Math.ceil(wordCount / 200.0));
}
}
public void updateReadingProgress(double progress) {
this.readingProgress = Math.max(0.0, Math.min(1.0, progress));
this.lastReadAt = LocalDateTime.now();
}
public boolean isPartOfSeries() {
return series != null && partNumber != null;
return series != null && volume != null;
}
// Getters and Setters
@@ -155,6 +133,14 @@ public class Story {
this.title = title;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getDescription() {
return description;
}
@@ -163,15 +149,24 @@ public class Story {
this.description = description;
}
public String getContent() {
return content;
public String getContentHtml() {
return contentHtml;
}
public void setContent(String content) {
this.content = content;
public void setContentHtml(String contentHtml) {
this.contentHtml = contentHtml;
this.setContentPlain(Jsoup.parse(contentHtml).text());
updateWordCount();
}
public String getContentPlain() {
return contentPlain;
}
protected void setContentPlain(String contentPlain) {
this.contentPlain = contentPlain;
}
public String getSourceUrl() {
return sourceUrl;
}
@@ -196,60 +191,20 @@ public class Story {
this.wordCount = wordCount;
}
public Integer getReadingTimeMinutes() {
return readingTimeMinutes;
public Integer getRating() {
return rating;
}
public void setReadingTimeMinutes(Integer readingTimeMinutes) {
this.readingTimeMinutes = readingTimeMinutes;
public void setRating(Integer rating) {
this.rating = rating;
}
public Double getAverageRating() {
return averageRating;
public Integer getVolume() {
return volume;
}
public void setAverageRating(Double averageRating) {
this.averageRating = averageRating;
}
public Integer getTotalRatings() {
return totalRatings;
}
public void setTotalRatings(Integer totalRatings) {
this.totalRatings = totalRatings;
}
public Boolean getIsFavorite() {
return isFavorite;
}
public void setIsFavorite(Boolean isFavorite) {
this.isFavorite = isFavorite;
}
public Double getReadingProgress() {
return readingProgress;
}
public void setReadingProgress(Double readingProgress) {
this.readingProgress = readingProgress;
}
public LocalDateTime getLastReadAt() {
return lastReadAt;
}
public void setLastReadAt(LocalDateTime lastReadAt) {
this.lastReadAt = lastReadAt;
}
public Integer getPartNumber() {
return partNumber;
}
public void setPartNumber(Integer partNumber) {
this.partNumber = partNumber;
public void setVolume(Integer volume) {
this.volume = volume;
}
public Author getAuthor() {
@@ -311,7 +266,7 @@ public class Story {
"id=" + id +
", title='" + title + '\'' +
", wordCount=" + wordCount +
", averageRating=" + averageRating +
", rating=" + rating +
'}';
}
}

View File

@@ -19,15 +19,10 @@ public class Tag {
private UUID id;
@NotBlank(message = "Tag name is required")
@Size(max = 50, message = "Tag name must not exceed 50 characters")
@Size(max = 100, message = "Tag name must not exceed 100 characters")
@Column(nullable = false, unique = true)
private String name;
@Size(max = 255, message = "Tag description must not exceed 255 characters")
private String description;
@Column(name = "usage_count")
private Integer usageCount = 0;
@ManyToMany(mappedBy = "tags")
private Set<Story> stories = new HashSet<>();
@@ -42,20 +37,7 @@ public class Tag {
this.name = name;
}
public Tag(String name, String description) {
this.name = name;
this.description = description;
}
public void incrementUsage() {
this.usageCount++;
}
public void decrementUsage() {
if (this.usageCount > 0) {
this.usageCount--;
}
}
// Getters and Setters
public UUID getId() {
@@ -74,21 +56,6 @@ public class Tag {
this.name = name;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Integer getUsageCount() {
return usageCount;
}
public void setUsageCount(Integer usageCount) {
this.usageCount = usageCount;
}
public Set<Story> getStories() {
return stories;
@@ -124,7 +91,6 @@ public class Tag {
return "Tag{" +
"id=" + id +
", name='" + name + '\'' +
", usageCount=" + usageCount +
'}';
}
}

View File

@@ -0,0 +1,91 @@
package com.storycove.exception;
import com.storycove.service.exception.DuplicateResourceException;
import com.storycove.service.exception.ResourceNotFoundException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<?> handleResourceNotFoundException(ResourceNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(DuplicateResourceException.class)
public ResponseEntity<?> handleDuplicateResourceException(DuplicateResourceException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleIllegalArgumentException(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<?> handleIllegalStateException(IllegalStateException ex) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationException(MethodArgumentNotValidException ex) {
List<String> errors = new ArrayList<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
errors.add(error.getField() + ": " + error.getDefaultMessage()));
ex.getBindingResult().getGlobalErrors().forEach(error ->
errors.add(error.getDefaultMessage()));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Validation failed", "details", errors));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<?> handleConstraintViolationException(ConstraintViolationException ex) {
List<String> errors = new ArrayList<>();
for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
errors.add(violation.getPropertyPath() + ": " + violation.getMessage());
}
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Validation failed", "details", errors));
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex) {
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
.body(Map.of("error", "File size exceeds maximum allowed size"));
}
@ExceptionHandler(SecurityException.class)
public ResponseEntity<?> handleSecurityException(SecurityException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "Access denied: " + ex.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleGenericException(Exception ex) {
// Log the exception for debugging
ex.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "An unexpected error occurred"));
}
}

View File

@@ -29,11 +29,14 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
@Query("SELECT a FROM Author a WHERE SIZE(a.stories) > 0")
Page<Author> findAuthorsWithStories(Pageable pageable);
@Query("SELECT a FROM Author a ORDER BY a.rating DESC")
@Query("SELECT a FROM Author a ORDER BY a.authorRating DESC, a.name ASC")
List<Author> findTopRatedAuthors();
@Query("SELECT a FROM Author a WHERE a.rating >= :minRating ORDER BY a.rating DESC")
List<Author> findAuthorsByMinimumRating(@Param("minRating") Double minRating);
@Query("SELECT a FROM Author a ORDER BY a.authorRating DESC, a.name ASC")
Page<Author> findTopRatedAuthors(Pageable pageable);
@Query("SELECT a FROM Author a WHERE a.authorRating >= :minRating ORDER BY a.authorRating DESC, a.name ASC")
List<Author> findAuthorsByMinimumRating(@Param("minRating") Integer minRating);
@Query("SELECT a FROM Author a JOIN a.stories s GROUP BY a.id ORDER BY COUNT(s) DESC")
List<Author> findMostProlificAuthors();
@@ -44,6 +47,6 @@ public interface AuthorRepository extends JpaRepository<Author, UUID> {
@Query("SELECT DISTINCT a FROM Author a JOIN a.urls u WHERE u LIKE %:domain%")
List<Author> findByUrlDomain(@Param("domain") String domain);
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= CURRENT_DATE - :days")
long countRecentAuthors(@Param("days") int days);
@Query("SELECT COUNT(a) FROM Author a WHERE a.createdAt >= :cutoffDate")
long countRecentAuthors(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
}

View File

@@ -29,31 +29,27 @@ public interface SeriesRepository extends JpaRepository<Series, UUID> {
@Query("SELECT s FROM Series s WHERE SIZE(s.stories) > 0")
Page<Series> findSeriesWithStories(Pageable pageable);
List<Series> findByIsComplete(Boolean isComplete);
Page<Series> findByIsComplete(Boolean isComplete, Pageable pageable);
@Query("SELECT s FROM Series s WHERE s.totalParts >= :minParts ORDER BY s.totalParts DESC")
List<Series> findByMinimumParts(@Param("minParts") Integer minParts);
@Query("SELECT s FROM Series s ORDER BY s.totalParts DESC")
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY COUNT(st) DESC")
List<Series> findLongestSeries();
@Query("SELECT s FROM Series s ORDER BY s.totalParts DESC")
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY COUNT(st) DESC")
Page<Series> findLongestSeries(Pageable pageable);
@Query("SELECT s FROM Series s WHERE SIZE(s.stories) >= :minParts ORDER BY SIZE(s.stories) DESC")
List<Series> findByMinimumParts(@Param("minParts") Integer minParts);
@Query("SELECT s FROM Series s WHERE SIZE(s.stories) = 0")
List<Series> findEmptySeries();
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY AVG(st.averageRating) DESC")
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY AVG(st.rating) DESC")
List<Series> findTopRatedSeries();
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY AVG(st.averageRating) DESC")
@Query("SELECT s FROM Series s JOIN s.stories st GROUP BY s.id ORDER BY AVG(st.rating) DESC")
Page<Series> findTopRatedSeries(Pageable pageable);
@Query("SELECT COUNT(s) FROM Series s WHERE s.createdAt >= CURRENT_DATE - :days")
long countRecentSeries(@Param("days") int days);
@Query("SELECT COUNT(s) FROM Series s WHERE s.createdAt >= :cutoffDate")
long countRecentSeries(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
@Query("SELECT s FROM Series s WHERE s.isComplete = false AND SIZE(s.stories) > 0 ORDER BY s.updatedAt DESC")
List<Series> findIncompleteSeriesWithStories();
@Query("SELECT COUNT(s) FROM Series s WHERE SIZE(s.stories) > 0")
long countSeriesWithStories();
}

View File

@@ -33,15 +33,12 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
Page<Story> findBySeries(Series series, Pageable pageable);
@Query("SELECT s FROM Story s JOIN s.series ser WHERE ser.id = :seriesId ORDER BY s.partNumber ASC")
List<Story> findBySeriesOrderByPartNumber(@Param("seriesId") UUID seriesId);
@Query("SELECT s FROM Story s JOIN s.series ser WHERE ser.id = :seriesId ORDER BY s.volume ASC")
List<Story> findBySeriesOrderByVolume(@Param("seriesId") UUID seriesId);
@Query("SELECT s FROM Story s WHERE s.series.id = :seriesId AND s.partNumber = :partNumber")
Optional<Story> findBySeriesAndPartNumber(@Param("seriesId") UUID seriesId, @Param("partNumber") Integer partNumber);
@Query("SELECT s FROM Story s WHERE s.series.id = :seriesId AND s.volume = :volume")
Optional<Story> findBySeriesAndVolume(@Param("seriesId") UUID seriesId, @Param("volume") Integer volume);
List<Story> findByIsFavorite(Boolean isFavorite);
Page<Story> findByIsFavorite(Boolean isFavorite, Pageable pageable);
@Query("SELECT s FROM Story s JOIN s.tags t WHERE t = :tag")
List<Story> findByTag(@Param("tag") Tag tag);
@@ -55,16 +52,16 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
@Query("SELECT DISTINCT s FROM Story s JOIN s.tags t WHERE t.name IN :tagNames")
Page<Story> findByTagNames(@Param("tagNames") List<String> tagNames, Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.averageRating >= :minRating ORDER BY s.averageRating DESC")
List<Story> findByMinimumRating(@Param("minRating") Double minRating);
@Query("SELECT s FROM Story s WHERE s.rating >= :minRating ORDER BY s.rating DESC")
List<Story> findByMinimumRating(@Param("minRating") Integer minRating);
@Query("SELECT s FROM Story s WHERE s.averageRating >= :minRating ORDER BY s.averageRating DESC")
Page<Story> findByMinimumRating(@Param("minRating") Double minRating, Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.rating >= :minRating ORDER BY s.rating DESC")
Page<Story> findByMinimumRating(@Param("minRating") Integer minRating, Pageable pageable);
@Query("SELECT s FROM Story s ORDER BY s.averageRating DESC")
@Query("SELECT s FROM Story s ORDER BY s.rating DESC")
List<Story> findTopRatedStories();
@Query("SELECT s FROM Story s ORDER BY s.averageRating DESC")
@Query("SELECT s FROM Story s ORDER BY s.rating DESC")
Page<Story> findTopRatedStories(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.wordCount BETWEEN :minWords AND :maxWords")
@@ -73,26 +70,7 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
@Query("SELECT s FROM Story s WHERE s.wordCount BETWEEN :minWords AND :maxWords")
Page<Story> findByWordCountRange(@Param("minWords") Integer minWords, @Param("maxWords") Integer maxWords, Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.readingTimeMinutes BETWEEN :minTime AND :maxTime")
List<Story> findByReadingTimeRange(@Param("minTime") Integer minTime, @Param("maxTime") Integer maxTime);
@Query("SELECT s FROM Story s WHERE s.readingProgress > 0 ORDER BY s.lastReadAt DESC")
List<Story> findStoriesInProgress();
@Query("SELECT s FROM Story s WHERE s.readingProgress > 0 ORDER BY s.lastReadAt DESC")
Page<Story> findStoriesInProgress(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.readingProgress >= 1.0 ORDER BY s.lastReadAt DESC")
List<Story> findCompletedStories();
@Query("SELECT s FROM Story s WHERE s.readingProgress >= 1.0 ORDER BY s.lastReadAt DESC")
Page<Story> findCompletedStories(Pageable pageable);
@Query("SELECT s FROM Story s WHERE s.lastReadAt >= :since ORDER BY s.lastReadAt DESC")
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
@Query("SELECT s FROM Story s WHERE s.lastReadAt >= :since ORDER BY s.lastReadAt DESC")
Page<Story> findRecentlyRead(@Param("since") LocalDateTime since, Pageable pageable);
@Query("SELECT s FROM Story s ORDER BY s.createdAt DESC")
List<Story> findRecentlyAdded();
@@ -112,7 +90,7 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
@Query("SELECT AVG(s.wordCount) FROM Story s")
Double findAverageWordCount();
@Query("SELECT AVG(s.averageRating) FROM Story s WHERE s.totalRatings > 0")
@Query("SELECT AVG(s.rating) FROM Story s WHERE s.rating IS NOT NULL")
Double findOverallAverageRating();
@Query("SELECT SUM(s.wordCount) FROM Story s")
@@ -127,4 +105,7 @@ public interface StoryRepository extends JpaRepository<Story, UUID> {
boolean existsBySourceUrl(String sourceUrl);
Optional<Story> findBySourceUrl(String sourceUrl);
@Query("SELECT s FROM Story s WHERE s.createdAt >= :since ORDER BY s.createdAt DESC")
List<Story> findRecentlyRead(@Param("since") LocalDateTime since);
}

View File

@@ -23,30 +23,35 @@ public interface TagRepository extends JpaRepository<Tag, UUID> {
Page<Tag> findByNameContainingIgnoreCase(String name, Pageable pageable);
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > 0 ORDER BY t.usageCount DESC")
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > 0 ORDER BY SIZE(t.stories) DESC")
List<Tag> findUsedTags();
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > 0 ORDER BY t.usageCount DESC")
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > 0 ORDER BY SIZE(t.stories) DESC")
Page<Tag> findUsedTags(Pageable pageable);
@Query("SELECT t FROM Tag t ORDER BY t.usageCount DESC")
@Query("SELECT t FROM Tag t ORDER BY SIZE(t.stories) DESC")
List<Tag> findMostUsedTags();
@Query("SELECT t FROM Tag t ORDER BY t.usageCount DESC")
@Query("SELECT t FROM Tag t ORDER BY SIZE(t.stories) DESC")
Page<Tag> findMostUsedTags(Pageable pageable);
@Query("SELECT t FROM Tag t WHERE t.usageCount >= :minUsage ORDER BY t.usageCount DESC")
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) >= :minUsage ORDER BY SIZE(t.stories) DESC")
List<Tag> findTagsByMinimumUsage(@Param("minUsage") Integer minUsage);
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) = 0")
List<Tag> findUnusedTags();
@Query("SELECT t FROM Tag t WHERE t.usageCount > :threshold ORDER BY t.usageCount DESC")
@Query("SELECT t FROM Tag t WHERE SIZE(t.stories) > :threshold ORDER BY SIZE(t.stories) DESC")
List<Tag> findPopularTags(@Param("threshold") Integer threshold);
@Query("SELECT COUNT(t) FROM Tag t WHERE t.createdAt >= CURRENT_DATE - :days")
long countRecentTags(@Param("days") int days);
@Query("SELECT COUNT(t) FROM Tag t WHERE t.createdAt >= :cutoffDate")
long countRecentTags(@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
@Query("SELECT t FROM Tag t WHERE t.name IN :names")
List<Tag> findByNames(@Param("names") List<String> names);
List<Tag> findByNameStartingWithIgnoreCase(String prefix);
@Query("SELECT COUNT(t) FROM Tag t WHERE SIZE(t.stories) > 0")
long countUsedTags();
}

View File

@@ -0,0 +1,51 @@
package com.storycove.security;
import com.storycove.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.ArrayList;
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
public JwtAuthenticationFilter(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
String token = null;
if (authHeader != null && authHeader.startsWith("Bearer ")) {
token = authHeader.substring(7);
}
if (token != null && jwtUtil.validateToken(token) && !jwtUtil.isTokenExpired(token)) {
String subject = jwtUtil.getSubjectFromToken(token);
if (subject != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(subject, null, new ArrayList<>());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -146,32 +146,48 @@ public class AuthorService {
return authorRepository.save(author);
}
public Author setDirectRating(UUID id, double rating) {
public Author setDirectRating(UUID id, int rating) {
if (rating < 0 || rating > 5) {
throw new IllegalArgumentException("Rating must be between 0 and 5");
}
Author author = findById(id);
author.setRating(rating);
author.setAuthorRating(rating);
return authorRepository.save(author);
}
public Author setRating(UUID id, Integer rating) {
if (rating != null && (rating < 1 || rating > 5)) {
throw new IllegalArgumentException("Rating must be between 1 and 5");
}
Author author = findById(id);
author.setAuthorRating(rating);
return authorRepository.save(author);
}
@Transactional(readOnly = true)
public List<Author> findTopRated(Pageable pageable) {
return authorRepository.findTopRatedAuthors(pageable).getContent();
}
public Author setAvatar(UUID id, String avatarPath) {
Author author = findById(id);
author.setAvatarPath(avatarPath);
author.setAvatarImagePath(avatarPath);
return authorRepository.save(author);
}
public Author removeAvatar(UUID id) {
Author author = findById(id);
author.setAvatarPath(null);
author.setAvatarImagePath(null);
return authorRepository.save(author);
}
@Transactional(readOnly = true)
public long countRecentAuthors(int days) {
return authorRepository.countRecentAuthors(days);
java.time.LocalDateTime cutoffDate = java.time.LocalDateTime.now().minusDays(days);
return authorRepository.countRecentAuthors(cutoffDate);
}
private void validateAuthorForCreate(Author author) {
@@ -184,14 +200,14 @@ public class AuthorService {
if (updates.getName() != null) {
existing.setName(updates.getName());
}
if (updates.getBio() != null) {
existing.setBio(updates.getBio());
if (updates.getNotes() != null) {
existing.setNotes(updates.getNotes());
}
if (updates.getAvatarPath() != null) {
existing.setAvatarPath(updates.getAvatarPath());
if (updates.getAvatarImagePath() != null) {
existing.setAvatarImagePath(updates.getAvatarImagePath());
}
if (updates.getRating() != null) {
existing.setRating(updates.getRating());
if (updates.getAuthorRating() != null) {
existing.setAuthorRating(updates.getAuthorRating());
}
if (updates.getUrls() != null && !updates.getUrls().isEmpty()) {
existing.getUrls().clear();

View File

@@ -0,0 +1,74 @@
package com.storycove.service;
import org.jsoup.Jsoup;
import org.jsoup.safety.Safelist;
import org.springframework.stereotype.Service;
@Service
public class HtmlSanitizationService {
private final Safelist allowlist;
public HtmlSanitizationService() {
// Create a custom allowlist for story content
this.allowlist = Safelist.relaxed()
// Basic formatting
.addTags("p", "br", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6")
// Text formatting
.addTags("b", "strong", "i", "em", "u", "s", "strike", "del", "ins")
.addTags("sup", "sub", "small", "big", "mark", "pre", "code")
// Lists
.addTags("ul", "ol", "li", "dl", "dt", "dd")
// Links (but remove href for security)
.addTags("a").removeAttributes("a", "href", "target")
// Tables
.addTags("table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption")
// Quotes
.addTags("blockquote", "cite", "q")
// Horizontal rule
.addTags("hr")
// Allow basic styling attributes
.addAttributes("p", "class", "style")
.addAttributes("div", "class", "style")
.addAttributes("span", "class", "style")
.addAttributes("h1", "class", "style")
.addAttributes("h2", "class", "style")
.addAttributes("h3", "class", "style")
.addAttributes("h4", "class", "style")
.addAttributes("h5", "class", "style")
.addAttributes("h6", "class", "style")
// Allow limited CSS properties
.addProtocols("style", "color", "background-color", "font-size", "font-weight",
"font-style", "text-align", "text-decoration", "margin", "padding");
}
public String sanitize(String html) {
if (html == null || html.trim().isEmpty()) {
return "";
}
return Jsoup.clean(html, allowlist);
}
public String extractPlainText(String html) {
if (html == null || html.trim().isEmpty()) {
return "";
}
return Jsoup.parse(html).text();
}
public boolean isClean(String html) {
if (html == null || html.trim().isEmpty()) {
return true;
}
String cleaned = sanitize(html);
return html.equals(cleaned);
}
public String sanitizeAndExtractText(String html) {
String sanitized = sanitize(html);
return extractPlainText(sanitized);
}
}

View File

@@ -0,0 +1,209 @@
package com.storycove.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Set;
import java.util.UUID;
@Service
public class ImageService {
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"image/jpeg", "image/jpg", "image/png", "image/webp"
);
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
"jpg", "jpeg", "png", "webp"
);
@Value("${storycove.images.upload-dir:/app/images}")
private String uploadDir;
@Value("${storycove.images.cover.max-width:800}")
private int coverMaxWidth;
@Value("${storycove.images.cover.max-height:1200}")
private int coverMaxHeight;
@Value("${storycove.images.avatar.max-size:400}")
private int avatarMaxSize;
@Value("${storycove.images.max-file-size:5242880}") // 5MB default
private long maxFileSize;
public enum ImageType {
COVER("covers"),
AVATAR("avatars");
private final String directory;
ImageType(String directory) {
this.directory = directory;
}
public String getDirectory() {
return directory;
}
}
public String uploadImage(MultipartFile file, ImageType imageType) throws IOException {
validateFile(file);
// Create directories if they don't exist
Path typeDir = Paths.get(uploadDir, imageType.getDirectory());
Files.createDirectories(typeDir);
// Generate unique filename
String originalFilename = file.getOriginalFilename();
String extension = getFileExtension(originalFilename);
String filename = UUID.randomUUID().toString() + "." + extension;
Path filePath = typeDir.resolve(filename);
// Process and resize image
BufferedImage processedImage = processImage(file, imageType);
// Save processed image
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(processedImage, extension.equals("jpg") ? "jpeg" : extension, baos);
Files.write(filePath, baos.toByteArray());
// Return relative path for database storage
return imageType.getDirectory() + "/" + filename;
}
public boolean deleteImage(String imagePath) {
if (imagePath == null || imagePath.trim().isEmpty()) {
return false;
}
try {
Path fullPath = Paths.get(uploadDir, imagePath);
return Files.deleteIfExists(fullPath);
} catch (IOException e) {
return false;
}
}
public Path getImagePath(String imagePath) {
return Paths.get(uploadDir, imagePath);
}
public boolean imageExists(String imagePath) {
if (imagePath == null || imagePath.trim().isEmpty()) {
return false;
}
return Files.exists(getImagePath(imagePath));
}
private void validateFile(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("File is empty");
}
if (file.getSize() > maxFileSize) {
throw new IllegalArgumentException("File size exceeds maximum allowed size of " + maxFileSize + " bytes");
}
String contentType = file.getContentType();
if (!ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new IllegalArgumentException("File type not allowed. Allowed types: " + ALLOWED_CONTENT_TYPES);
}
String filename = file.getOriginalFilename();
if (filename == null || !hasValidExtension(filename)) {
throw new IllegalArgumentException("Invalid file extension. Allowed extensions: " + ALLOWED_EXTENSIONS);
}
}
private BufferedImage processImage(MultipartFile file, ImageType imageType) throws IOException {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(file.getBytes()));
if (originalImage == null) {
throw new IOException("Cannot read image file");
}
// Calculate new dimensions
Dimension newSize = calculateNewSize(originalImage, imageType);
// Resize image if necessary
if (newSize.width != originalImage.getWidth() || newSize.height != originalImage.getHeight()) {
return resizeImage(originalImage, newSize.width, newSize.height);
}
return originalImage;
}
private Dimension calculateNewSize(BufferedImage image, ImageType imageType) {
int originalWidth = image.getWidth();
int originalHeight = image.getHeight();
int maxWidth, maxHeight;
switch (imageType) {
case COVER:
maxWidth = coverMaxWidth;
maxHeight = coverMaxHeight;
break;
case AVATAR:
maxWidth = avatarMaxSize;
maxHeight = avatarMaxSize;
break;
default:
return new Dimension(originalWidth, originalHeight);
}
// Calculate scaling factor
double scaleX = (double) maxWidth / originalWidth;
double scaleY = (double) maxHeight / originalHeight;
double scale = Math.min(scaleX, scaleY);
if (scale >= 1.0) {
// No scaling needed
return new Dimension(originalWidth, originalHeight);
}
int newWidth = (int) Math.round(originalWidth * scale);
int newHeight = (int) Math.round(originalHeight * scale);
return new Dimension(newWidth, newHeight);
}
private BufferedImage resizeImage(BufferedImage originalImage, int targetWidth, int targetHeight) {
BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resizedImage.createGraphics();
// Enable high-quality rendering
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null);
g2d.dispose();
return resizedImage;
}
private String getFileExtension(String filename) {
if (filename == null || filename.lastIndexOf('.') == -1) {
return "";
}
return filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
}
private boolean hasValidExtension(String filename) {
String extension = getFileExtension(filename);
return ALLOWED_EXTENSIONS.contains(extension);
}
}

View File

@@ -0,0 +1,36 @@
package com.storycove.service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class PasswordAuthenticationService {
@Value("${storycove.auth.password}")
private String applicationPassword;
private final PasswordEncoder passwordEncoder;
public PasswordAuthenticationService(PasswordEncoder passwordEncoder) {
this.passwordEncoder = passwordEncoder;
}
public boolean authenticate(String providedPassword) {
if (providedPassword == null || providedPassword.trim().isEmpty()) {
return false;
}
// If application password starts with {bcrypt}, it's already encoded
if (applicationPassword.startsWith("{bcrypt}") || applicationPassword.startsWith("$2")) {
return passwordEncoder.matches(providedPassword, applicationPassword);
}
// Otherwise, compare directly (for development/testing)
return applicationPassword.equals(providedPassword);
}
public String encodePassword(String rawPassword) {
return passwordEncoder.encode(rawPassword);
}
}

View File

@@ -80,31 +80,6 @@ public class SeriesService {
return seriesRepository.findSeriesWithStories(pageable);
}
@Transactional(readOnly = true)
public List<Series> findCompleteSeries() {
return seriesRepository.findByIsComplete(true);
}
@Transactional(readOnly = true)
public Page<Series> findCompleteSeries(Pageable pageable) {
return seriesRepository.findByIsComplete(true, pageable);
}
@Transactional(readOnly = true)
public List<Series> findIncompleteSeries() {
return seriesRepository.findByIsComplete(false);
}
@Transactional(readOnly = true)
public Page<Series> findIncompleteSeries(Pageable pageable) {
return seriesRepository.findByIsComplete(false, pageable);
}
@Transactional(readOnly = true)
public List<Series> findIncompleteSeriesWithStories() {
return seriesRepository.findIncompleteSeriesWithStories();
}
@Transactional(readOnly = true)
public List<Series> findLongestSeries() {
return seriesRepository.findLongestSeries();
@@ -169,17 +144,7 @@ public class SeriesService {
seriesRepository.delete(series);
}
public Series markComplete(UUID id) {
Series series = findById(id);
series.setIsComplete(true);
return seriesRepository.save(series);
}
public Series markIncomplete(UUID id) {
Series series = findById(id);
series.setIsComplete(false);
return seriesRepository.save(series);
}
// Mark complete/incomplete methods removed - isComplete field not in spec
public List<Series> deleteEmptySeries() {
List<Series> emptySeries = findEmptySeries();
@@ -199,7 +164,8 @@ public class SeriesService {
@Transactional(readOnly = true)
public long countRecentSeries(int days) {
return seriesRepository.countRecentSeries(days);
java.time.LocalDateTime cutoffDate = java.time.LocalDateTime.now().minusDays(days);
return seriesRepository.countRecentSeries(cutoffDate);
}
@Transactional(readOnly = true)
@@ -213,8 +179,25 @@ public class SeriesService {
}
@Transactional(readOnly = true)
public long getCompleteSeriesCount() {
return seriesRepository.findByIsComplete(true).size();
public long countAll() {
return seriesRepository.count();
}
@Transactional(readOnly = true)
public long countSeriesWithStories() {
return seriesRepository.countSeriesWithStories();
}
@Transactional(readOnly = true)
public List<Series> findSeriesWithStoriesLimited(Pageable pageable) {
return seriesRepository.findSeriesWithStories(pageable).getContent();
}
@Transactional(readOnly = true)
public List<Series> findMostPopular(int limit) {
return seriesRepository.findLongestSeries().stream()
.limit(limit)
.collect(java.util.stream.Collectors.toList());
}
private void validateSeriesForCreate(Series series) {
@@ -230,8 +213,6 @@ public class SeriesService {
if (updates.getDescription() != null) {
existing.setDescription(updates.getDescription());
}
if (updates.getIsComplete() != null) {
existing.setIsComplete(updates.getIsComplete());
}
// isComplete field not in spec
}
}

View File

@@ -5,10 +5,12 @@ import com.storycove.entity.Series;
import com.storycove.entity.Story;
import com.storycove.entity.Tag;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
@@ -16,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -27,19 +30,28 @@ import java.util.UUID;
public class StoryService {
private final StoryRepository storyRepository;
private final TagRepository tagRepository;
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,
public StoryService(StoryRepository storyRepository,
TagRepository tagRepository,
AuthorService authorService,
TagService tagService,
SeriesService seriesService) {
SeriesService seriesService,
HtmlSanitizationService sanitizationService,
@Autowired(required = false) TypesenseService typesenseService) {
this.storyRepository = storyRepository;
this.tagRepository = tagRepository;
this.authorService = authorService;
this.tagService = tagService;
this.seriesService = seriesService;
this.sanitizationService = sanitizationService;
this.typesenseService = typesenseService;
}
@Transactional(readOnly = true)
@@ -98,7 +110,7 @@ public class StoryService {
@Transactional(readOnly = true)
public List<Story> findBySeries(UUID seriesId) {
Series series = seriesService.findById(seriesId);
return storyRepository.findBySeriesOrderByPartNumber(seriesId);
return storyRepository.findBySeriesOrderByVolume(seriesId);
}
@Transactional(readOnly = true)
@@ -108,8 +120,8 @@ public class StoryService {
}
@Transactional(readOnly = true)
public Optional<Story> findBySeriesAndPartNumber(UUID seriesId, Integer partNumber) {
return storyRepository.findBySeriesAndPartNumber(seriesId, partNumber);
public Optional<Story> findBySeriesAndVolume(UUID seriesId, Integer volume) {
return storyRepository.findBySeriesAndVolume(seriesId, volume);
}
@Transactional(readOnly = true)
@@ -134,30 +146,7 @@ public class StoryService {
return storyRepository.findByTagNames(tagNames, pageable);
}
@Transactional(readOnly = true)
public List<Story> findFavorites() {
return storyRepository.findByIsFavorite(true);
}
@Transactional(readOnly = true)
public Page<Story> findFavorites(Pageable pageable) {
return storyRepository.findByIsFavorite(true, pageable);
}
@Transactional(readOnly = true)
public List<Story> findStoriesInProgress() {
return storyRepository.findStoriesInProgress();
}
@Transactional(readOnly = true)
public Page<Story> findStoriesInProgress(Pageable pageable) {
return storyRepository.findStoriesInProgress(pageable);
}
@Transactional(readOnly = true)
public List<Story> findCompletedStories() {
return storyRepository.findCompletedStories();
}
// Favorite and completion status methods removed as these fields were not in spec
@Transactional(readOnly = true)
public List<Story> findRecentlyRead(int hours) {
@@ -199,6 +188,77 @@ public class StoryService {
public Page<Story> 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);
return storyRepository.save(story);
}
@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);
return storyRepository.save(story);
}
@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);
return storyRepository.save(story);
}
@Transactional(readOnly = true)
public List<Story> findBySeriesOrderByVolume(UUID seriesId) {
return storyRepository.findBySeriesOrderByVolume(seriesId);
}
@Transactional(readOnly = true)
public List<Story> findRecentlyAddedLimited(Pageable pageable) {
return storyRepository.findRecentlyAdded(pageable).getContent();
}
@Transactional(readOnly = true)
public List<Story> findTopRatedStoriesLimited(Pageable pageable) {
return storyRepository.findTopRatedStories(pageable).getContent();
}
public Story create(@Valid Story story) {
validateStoryForCreate(story);
@@ -212,7 +272,7 @@ public class StoryService {
if (story.getSeries() != null && story.getSeries().getId() != null) {
Series series = seriesService.findById(story.getSeries().getId());
story.setSeries(series);
validateSeriesPartNumber(series, story.getPartNumber());
validateSeriesVolume(series, story.getVolume());
}
Story savedStory = storyRepository.save(story);
@@ -222,6 +282,11 @@ public class StoryService {
updateStoryTags(savedStory, story.getTags());
}
// Index in Typesense (if available)
if (typesenseService != null) {
typesenseService.indexStory(savedStory);
}
return savedStory;
}
@@ -236,7 +301,37 @@ public class StoryService {
}
updateStoryFields(existingStory, storyUpdates);
return storyRepository.save(existingStory);
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) {
@@ -250,44 +345,14 @@ public class StoryService {
// Remove tags (this will update tag usage counts)
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 addToFavorites(UUID id) {
Story story = findById(id);
story.setIsFavorite(true);
return storyRepository.save(story);
}
public Story removeFromFavorites(UUID id) {
Story story = findById(id);
story.setIsFavorite(false);
return storyRepository.save(story);
}
public Story updateReadingProgress(UUID id, double progress) {
if (progress < 0 || progress > 1) {
throw new IllegalArgumentException("Reading progress must be between 0 and 1");
}
Story story = findById(id);
story.updateReadingProgress(progress);
return storyRepository.save(story);
}
public Story updateRating(UUID id, double rating) {
if (rating < 0 || rating > 5) {
throw new IllegalArgumentException("Rating must be between 0 and 5");
}
Story story = findById(id);
story.updateRating(rating);
// Note: Author's average story rating will be calculated dynamically
return storyRepository.save(story);
}
public Story setCover(UUID id, String coverPath) {
Story story = findById(id);
story.setCoverPath(coverPath);
@@ -300,14 +365,14 @@ public class StoryService {
return storyRepository.save(story);
}
public Story addToSeries(UUID storyId, UUID seriesId, Integer partNumber) {
public Story addToSeries(UUID storyId, UUID seriesId, Integer volume) {
Story story = findById(storyId);
Series series = seriesService.findById(seriesId);
validateSeriesPartNumber(series, partNumber);
validateSeriesVolume(series, volume);
story.setSeries(series);
story.setPartNumber(partNumber);
story.setVolume(volume);
series.addStory(story);
return storyRepository.save(story);
@@ -319,7 +384,7 @@ public class StoryService {
if (story.getSeries() != null) {
story.getSeries().removeStory(story);
story.setSeries(null);
story.setPartNumber(null);
story.setVolume(null);
}
return storyRepository.save(story);
@@ -351,11 +416,11 @@ public class StoryService {
}
}
private void validateSeriesPartNumber(Series series, Integer partNumber) {
if (partNumber != null) {
Optional<Story> existingPart = storyRepository.findBySeriesAndPartNumber(series.getId(), partNumber);
private void validateSeriesVolume(Series series, Integer volume) {
if (volume != null) {
Optional<Story> existingPart = storyRepository.findBySeriesAndVolume(series.getId(), volume);
if (existingPart.isPresent()) {
throw new DuplicateResourceException("Story", "part " + partNumber + " of series " + series.getName());
throw new DuplicateResourceException("Story", "volume " + volume + " of series " + series.getName());
}
}
}
@@ -364,11 +429,14 @@ public class StoryService {
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.getContent() != null) {
existing.setContent(updates.getContent());
if (updates.getContentHtml() != null) {
existing.setContentHtml(updates.getContentHtml());
}
if (updates.getSourceUrl() != null) {
existing.setSourceUrl(updates.getSourceUrl());
@@ -376,8 +444,8 @@ public class StoryService {
if (updates.getCoverPath() != null) {
existing.setCoverPath(updates.getCoverPath());
}
if (updates.getIsFavorite() != null) {
existing.setIsFavorite(updates.getIsFavorite());
if (updates.getVolume() != null) {
existing.setVolume(updates.getVolume());
}
// Handle author update
@@ -390,9 +458,9 @@ public class StoryService {
if (updates.getSeries() != null && updates.getSeries().getId() != null) {
Series series = seriesService.findById(updates.getSeries().getId());
existing.setSeries(series);
if (updates.getPartNumber() != null) {
validateSeriesPartNumber(series, updates.getPartNumber());
existing.setPartNumber(updates.getPartNumber());
if (updates.getVolume() != null) {
validateSeriesVolume(series, updates.getVolume());
existing.setVolume(updates.getVolume());
}
}
@@ -403,9 +471,9 @@ public class StoryService {
}
private void updateStoryTags(Story story, Set<Tag> newTags) {
// Remove existing tags
story.getTags().forEach(tag -> story.removeTag(tag));
story.getTags().clear();
// Remove existing tags - create a copy to avoid ConcurrentModificationException
Set<Tag> existingTags = new HashSet<>(story.getTags());
existingTags.forEach(tag -> story.removeTag(tag));
// Add new tags
for (Tag tag : newTags) {
@@ -420,4 +488,53 @@ public class StoryService {
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());
}
if (updateReq.getAuthorId() != null) {
Author author = authorService.findById(updateReq.getAuthorId());
story.setAuthor(author);
}
if (updateReq.getSeriesId() != null) {
Series series = seriesService.findById(updateReq.getSeriesId());
story.setSeries(series);
}
}
}
private void updateStoryTagsByNames(Story story, java.util.List<String> tagNames) {
// Clear existing tags first
Set<Tag> 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);
}
}
}
}

View File

@@ -150,14 +150,12 @@ public class TagService {
.orElseGet(() -> create(new Tag(name)));
}
public Tag findOrCreate(String name, String description) {
return findByNameOptional(name)
.orElseGet(() -> create(new Tag(name, description)));
}
// Method removed - Tag doesn't have description field per spec
@Transactional(readOnly = true)
public long countRecentTags(int days) {
return tagRepository.countRecentTags(days);
java.time.LocalDateTime cutoffDate = java.time.LocalDateTime.now().minusDays(days);
return tagRepository.countRecentTags(cutoffDate);
}
@Transactional(readOnly = true)
@@ -169,6 +167,30 @@ public class TagService {
public long getUsedTagCount() {
return findUsedTags().size();
}
@Transactional(readOnly = true)
public List<Tag> findByNameStartingWith(String prefix, int limit) {
return tagRepository.findByNameStartingWithIgnoreCase(prefix).stream()
.limit(limit)
.collect(java.util.stream.Collectors.toList());
}
@Transactional(readOnly = true)
public List<Tag> findMostUsed(int limit) {
return tagRepository.findMostUsedTags().stream()
.limit(limit)
.collect(java.util.stream.Collectors.toList());
}
@Transactional(readOnly = true)
public long countAll() {
return tagRepository.count();
}
@Transactional(readOnly = true)
public long countUsedTags() {
return tagRepository.countUsedTags();
}
private void validateTagForCreate(Tag tag) {
if (existsByName(tag.getName())) {
@@ -180,8 +202,5 @@ public class TagService {
if (updates.getName() != null) {
existing.setName(updates.getName());
}
if (updates.getDescription() != null) {
existing.setDescription(updates.getDescription());
}
}
}

View File

@@ -0,0 +1,444 @@
package com.storycove.service;
import com.storycove.dto.SearchResultDto;
import com.storycove.dto.StorySearchDto;
import com.storycove.entity.Story;
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.stereotype.Service;
import org.typesense.api.Client;
import org.typesense.model.*;
import jakarta.annotation.PostConstruct;
import java.util.*;
import java.util.stream.Collectors;
@Service
@ConditionalOnProperty(name = "storycove.typesense.enabled", havingValue = "true", matchIfMissing = true)
public class TypesenseService {
private static final Logger logger = LoggerFactory.getLogger(TypesenseService.class);
private static final String STORIES_COLLECTION = "stories";
private final Client typesenseClient;
@Autowired
public TypesenseService(Client typesenseClient) {
this.typesenseClient = typesenseClient;
}
@PostConstruct
public void initializeCollections() {
try {
createStoriesCollectionIfNotExists();
} catch (Exception e) {
logger.error("Failed to initialize Typesense collections", e);
}
}
private void createStoriesCollectionIfNotExists() throws Exception {
try {
// Check if collection already exists
typesenseClient.collections(STORIES_COLLECTION).retrieve();
logger.info("Stories collection already exists");
} catch (Exception e) {
logger.info("Creating stories collection...");
createStoriesCollection();
}
}
private void createStoriesCollection() throws Exception {
List<Field> fields = Arrays.asList(
new Field().name("id").type("string").facet(false),
new Field().name("title").type("string").facet(false),
new Field().name("summary").type("string").facet(false).optional(true),
new Field().name("description").type("string").facet(false),
new Field().name("contentPlain").type("string").facet(false),
new Field().name("authorId").type("string").facet(true),
new Field().name("authorName").type("string").facet(true),
new Field().name("seriesId").type("string").facet(true).optional(true),
new Field().name("seriesName").type("string").facet(true).optional(true),
new Field().name("tagNames").type("string[]").facet(true).optional(true),
new Field().name("rating").type("int32").facet(true).optional(true),
new Field().name("wordCount").type("int32").facet(true).optional(true),
new Field().name("volume").type("int32").facet(true).optional(true),
new Field().name("createdAt").type("int64").facet(false),
new Field().name("sourceUrl").type("string").facet(false).optional(true),
new Field().name("coverPath").type("string").facet(false).optional(true)
);
CollectionSchema collectionSchema = new CollectionSchema()
.name(STORIES_COLLECTION)
.fields(fields);
typesenseClient.collections().create(collectionSchema);
logger.info("Stories collection created successfully");
}
public void indexStory(Story story) {
try {
Map<String, Object> document = createStoryDocument(story);
typesenseClient.collections(STORIES_COLLECTION).documents().create(document);
logger.debug("Indexed story: {}", story.getTitle());
} catch (Exception e) {
logger.error("Failed to index story: " + story.getTitle(), e);
}
}
public void updateStory(Story story) {
try {
Map<String, Object> document = createStoryDocument(story);
typesenseClient.collections(STORIES_COLLECTION).documents(story.getId().toString()).update(document);
logger.debug("Updated story index: {}", story.getTitle());
} catch (Exception e) {
logger.error("Failed to update story index: " + story.getTitle(), e);
}
}
public void deleteStory(String storyId) {
try {
typesenseClient.collections(STORIES_COLLECTION).documents(storyId).delete();
logger.debug("Deleted story from index: {}", storyId);
} catch (Exception e) {
logger.error("Failed to delete story from index: " + storyId, e);
}
}
public SearchResultDto<StorySearchDto> searchStories(
String query,
int page,
int perPage,
List<String> authorFilters,
List<String> tagFilters,
Integer minRating,
Integer maxRating) {
try {
long startTime = System.currentTimeMillis();
// Convert 0-based page (frontend/backend) to 1-based page (Typesense)
int typesensePage = page + 1;
SearchParameters searchParameters = new SearchParameters()
.q(query.isEmpty() ? "*" : query)
.queryBy("title,description,contentPlain,authorName,seriesName,tagNames")
.page(typesensePage)
.perPage(perPage)
.highlightFields("title,description")
.highlightStartTag("<mark>")
.highlightEndTag("</mark>")
.sortBy("_text_match:desc,createdAt:desc");
// Add filters
List<String> filterConditions = new ArrayList<>();
if (authorFilters != null && !authorFilters.isEmpty()) {
String authorFilter = authorFilters.stream()
.map(author -> "authorName:=" + author)
.collect(Collectors.joining(" || "));
filterConditions.add("(" + authorFilter + ")");
}
if (tagFilters != null && !tagFilters.isEmpty()) {
String tagFilter = tagFilters.stream()
.map(tag -> "tagNames:=" + tag)
.collect(Collectors.joining(" || "));
filterConditions.add("(" + tagFilter + ")");
}
if (minRating != null) {
filterConditions.add("rating:>=" + minRating);
}
if (maxRating != null) {
filterConditions.add("rating:<=" + maxRating);
}
if (!filterConditions.isEmpty()) {
searchParameters.filterBy(String.join(" && ", filterConditions));
}
SearchResult searchResult = typesenseClient.collections(STORIES_COLLECTION)
.documents()
.search(searchParameters);
List<StorySearchDto> results = convertSearchResult(searchResult);
long searchTime = System.currentTimeMillis() - startTime;
return new SearchResultDto<>(
results,
searchResult.getFound(),
page,
perPage,
query,
searchTime
);
} catch (Exception e) {
logger.error("Search failed for query: " + query, e);
return new SearchResultDto<>(new ArrayList<>(), 0, page, perPage, query, 0);
}
}
public void bulkIndexStories(List<Story> stories) {
if (stories == null || stories.isEmpty()) {
return;
}
try {
List<Map<String, Object>> documents = stories.stream()
.map(this::createStoryDocument)
.collect(Collectors.toList());
for (Map<String, Object> document : documents) {
typesenseClient.collections(STORIES_COLLECTION).documents().create(document);
}
logger.info("Bulk indexed {} stories", stories.size());
} catch (Exception e) {
logger.error("Failed to bulk index stories", e);
}
}
public void reindexAllStories(List<Story> stories) {
try {
// Clear existing collection
try {
typesenseClient.collections(STORIES_COLLECTION).delete();
} catch (Exception e) {
logger.debug("Collection didn't exist for deletion: {}", e.getMessage());
}
// Recreate collection
createStoriesCollection();
// Bulk index all stories
bulkIndexStories(stories);
logger.info("Reindexed all {} stories", stories.size());
} catch (Exception e) {
logger.error("Failed to reindex all stories", e);
}
}
public List<String> searchSuggestions(String query, int limit) {
try {
SearchParameters searchParameters = new SearchParameters()
.q(query)
.queryBy("title,authorName")
.perPage(limit)
.highlightFields("title,authorName");
SearchResult searchResult = typesenseClient.collections(STORIES_COLLECTION)
.documents()
.search(searchParameters);
return searchResult.getHits().stream()
.map(hit -> (String) hit.getDocument().get("title"))
.collect(Collectors.toList());
} catch (Exception e) {
logger.error("Failed to get search suggestions for: " + query, e);
return new ArrayList<>();
}
}
private Map<String, Object> createStoryDocument(Story story) {
Map<String, Object> document = new HashMap<>();
document.put("id", story.getId().toString());
document.put("title", story.getTitle());
document.put("summary", story.getSummary() != null ? story.getSummary() : "");
document.put("description", story.getDescription() != null ? story.getDescription() : "");
document.put("contentPlain", story.getContentPlain() != null ? story.getContentPlain() : "");
// Required fields - always include even if null
if (story.getAuthor() != null) {
document.put("authorId", story.getAuthor().getId().toString());
document.put("authorName", story.getAuthor().getName());
} else {
document.put("authorId", "");
document.put("authorName", "");
}
if (story.getSeries() != null) {
document.put("seriesId", story.getSeries().getId().toString());
document.put("seriesName", story.getSeries().getName());
}
if (story.getTags() != null && !story.getTags().isEmpty()) {
List<String> tagNames = story.getTags().stream()
.map(tag -> tag.getName())
.collect(Collectors.toList());
document.put("tagNames", tagNames);
}
document.put("rating", story.getRating() != null ? story.getRating() : 0);
document.put("wordCount", story.getWordCount() != null ? story.getWordCount() : 0);
document.put("volume", story.getVolume() != null ? story.getVolume() : 0);
document.put("createdAt", story.getCreatedAt() != null ?
story.getCreatedAt().toEpochSecond(java.time.ZoneOffset.UTC) :
java.time.LocalDateTime.now().toEpochSecond(java.time.ZoneOffset.UTC));
if (story.getSourceUrl() != null) {
document.put("sourceUrl", story.getSourceUrl());
}
if (story.getCoverPath() != null) {
document.put("coverPath", story.getCoverPath());
}
return document;
}
@SuppressWarnings("unchecked")
private List<StorySearchDto> convertSearchResult(SearchResult searchResult) {
return searchResult.getHits().stream()
.map(hit -> {
Map<String, Object> doc = hit.getDocument();
StorySearchDto dto = new StorySearchDto();
dto.setId(UUID.fromString((String) doc.get("id")));
dto.setTitle((String) doc.get("title"));
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")));
dto.setAuthorName((String) doc.get("authorName"));
}
if (doc.get("seriesId") != null) {
dto.setSeriesId(UUID.fromString((String) doc.get("seriesId")));
dto.setSeriesName((String) doc.get("seriesName"));
}
if (doc.get("tagNames") != null) {
dto.setTagNames((List<String>) doc.get("tagNames"));
}
if (doc.get("rating") != null) {
dto.setRating(((Number) doc.get("rating")).intValue());
}
if (doc.get("wordCount") != null) {
dto.setWordCount(((Number) doc.get("wordCount")).intValue());
}
if (doc.get("volume") != null) {
dto.setVolume(((Number) doc.get("volume")).intValue());
}
dto.setSourceUrl((String) doc.get("sourceUrl"));
dto.setCoverPath((String) doc.get("coverPath"));
// Convert timestamp back to LocalDateTime
if (doc.get("createdAt") != null) {
long timestamp = ((Number) doc.get("createdAt")).longValue();
dto.setCreatedAt(java.time.LocalDateTime.ofEpochSecond(
timestamp, 0, java.time.ZoneOffset.UTC));
}
// Set search-specific fields
dto.setSearchScore(hit.getTextMatch());
// Extract highlights from the Typesense response with multiple fallback approaches
List<String> highlights = extractHighlights(hit, dto.getTitle());
dto.setHighlights(highlights);
return dto;
})
.collect(Collectors.toList());
}
/**
* Extract highlights from SearchHit with multiple fallback approaches to handle
* different Typesense client versions and response structures.
*/
@SuppressWarnings("unchecked")
private List<String> extractHighlights(SearchResultHit hit, String storyTitle) {
List<String> highlights = new ArrayList<>();
logger.debug("Processing highlights for story: {}", storyTitle);
logger.debug("Hit highlights (array): {}", hit.getHighlights());
logger.debug("Hit highlight (object): {}", hit.getHighlight());
try {
// Approach 1: Try the standard getHighlights() with getSnippets()
if (hit.getHighlights() != null && !hit.getHighlights().isEmpty()) {
logger.debug("Found {} highlight objects", hit.getHighlights().size());
for (SearchHighlight highlight : hit.getHighlights()) {
logger.debug("Processing highlight - Field: {}", highlight.getField());
// Log all available methods to debug the SearchHighlight object
logger.debug("SearchHighlight methods available: {}",
Arrays.toString(highlight.getClass().getMethods()));
try {
// Try getSnippet() (singular) first, as Typesense returns single snippet per field
String snippet = highlight.getSnippet();
if (snippet != null && !snippet.trim().isEmpty()) {
highlights.add(snippet);
logger.debug("Added snippet from getSnippet() for field: {}", highlight.getField());
} else {
logger.debug("getSnippet() returned null or empty for field: {}", highlight.getField());
}
} catch (Exception e) {
logger.debug("Error calling getSnippet(): {}", e.getMessage());
// Fallback: Try getSnippets() (plural) in case client uses different method
try {
List<String> snippets = highlight.getSnippets();
if (snippets != null && !snippets.isEmpty()) {
highlights.addAll(snippets);
logger.debug("Added {} snippets from getSnippets() for field: {}",
snippets.size(), highlight.getField());
}
} catch (Exception e2) {
logger.debug("Error accessing snippets field: {}", e2.getMessage());
}
}
}
}
// Approach 3: Try accessing the singular highlight object
if (highlights.isEmpty() && hit.getHighlight() != null) {
logger.debug("No highlights from array, trying singular highlight object");
Object highlightObj = hit.getHighlight();
logger.debug("Highlight object type: {}", highlightObj.getClass());
// If it's a Map, try to extract highlight data from it
if (highlightObj instanceof Map) {
Map<String, Object> highlightMap = (Map<String, Object>) highlightObj;
logger.debug("Highlight map contents: {}", highlightMap);
// Look for common field names that might contain highlights
for (String field : Arrays.asList("title", "description", "content")) {
Object fieldHighlight = highlightMap.get(field);
if (fieldHighlight != null) {
if (fieldHighlight instanceof List) {
highlights.addAll((List<String>) fieldHighlight);
} else {
highlights.add(fieldHighlight.toString());
}
logger.debug("Added highlight from map field: {}", field);
}
}
}
}
} catch (Exception e) {
logger.error("Error extracting highlights for story: {}", storyTitle, e);
}
logger.debug("Final highlights count for {}: {}", storyTitle, highlights.size());
if (!highlights.isEmpty()) {
logger.debug("Sample highlights: {}", highlights.subList(0, Math.min(3, highlights.size())));
}
return highlights;
}
}

View File

@@ -0,0 +1,65 @@
package com.storycove.util;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
@Component
public class JwtUtil {
@Value("${storycove.jwt.secret}")
private String secret;
@Value("${storycove.jwt.expiration:86400000}") // 24 hours default
private Long expiration;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secret.getBytes());
}
public String generateToken() {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.subject("user")
.issuedAt(now)
.expiration(expiryDate)
.signWith(getSigningKey())
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public Claims getClaimsFromToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean isTokenExpired(String token) {
Date expiration = getClaimsFromToken(token).getExpiration();
return expiration.before(new Date());
}
public String getSubjectFromToken(String token) {
return getClaimsFromToken(token).getSubject();
}
}