diff --git a/CLAUDE.md b/CLAUDE.md
index 1bcccf8..3e71d53 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -88,4 +88,11 @@ backend/ # Spring Boot application
nginx.conf # Reverse proxy configuration
docker-compose.yml # Container orchestration
.env # Environment variables
-```
\ No newline at end of file
+```
+
+
+## Development Best Practices
+
+- Always create unit and integration tests where it makes sense, when creating new classes.
+- **Always check if Test Classes have to be updated after code changes**
+- When you fix an error, automatically check and see if this error might also occur in other classes.
\ No newline at end of file
diff --git a/backend/pom.xml b/backend/pom.xml
index 7d3cc40..ddcf852 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -52,7 +52,6 @@
org.postgresql
postgresql
- runtime
io.jsonwebtoken
@@ -80,6 +79,11 @@
org.apache.httpcomponents.client5
httpclient5
+
+ org.typesense
+ typesense-java
+ 1.3.0
+
diff --git a/backend/src/main/java/com/storycove/config/SecurityConfig.java b/backend/src/main/java/com/storycove/config/SecurityConfig.java
new file mode 100644
index 0000000..65baae5
--- /dev/null
+++ b/backend/src/main/java/com/storycove/config/SecurityConfig.java
@@ -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();
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/storycove/config/TypesenseConfig.java b/backend/src/main/java/com/storycove/config/TypesenseConfig.java
new file mode 100644
index 0000000..f7d70da
--- /dev/null
+++ b/backend/src/main/java/com/storycove/config/TypesenseConfig.java
@@ -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 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);
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/storycove/controller/AuthController.java b/backend/src/main/java/com/storycove/controller/AuthController.java
new file mode 100644
index 0000000..fd2f56a
--- /dev/null
+++ b/backend/src/main/java/com/storycove/controller/AuthController.java
@@ -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;
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/storycove/controller/AuthorController.java b/backend/src/main/java/com/storycove/controller/AuthorController.java
new file mode 100644
index 0000000..e93376e
--- /dev/null
+++ b/backend/src/main/java/com/storycove/controller/AuthorController.java
@@ -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> 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 authors = authorService.findAll(pageable);
+ Page authorDtos = authors.map(this::convertToDto);
+
+ return ResponseEntity.ok(authorDtos);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getAuthorById(@PathVariable UUID id) {
+ Author author = authorService.findById(id);
+ return ResponseEntity.ok(convertToDto(author));
+ }
+
+ @PostMapping
+ public ResponseEntity 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 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 rateAuthor(@PathVariable UUID id, @RequestBody RatingRequest request) {
+ Author author = authorService.setRating(id, request.getRating());
+ return ResponseEntity.ok(convertToDto(author));
+ }
+
+ @GetMapping("/search")
+ public ResponseEntity> searchAuthors(
+ @RequestParam String query,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+
+ Pageable pageable = PageRequest.of(page, size);
+ Page authors = authorService.searchByName(query, pageable);
+ Page authorDtos = authors.map(this::convertToDto);
+
+ return ResponseEntity.ok(authorDtos);
+ }
+
+ @GetMapping("/top-rated")
+ public ResponseEntity> getTopRatedAuthors(@RequestParam(defaultValue = "10") int limit) {
+ Pageable pageable = PageRequest.of(0, limit);
+ List authors = authorService.findTopRated(pageable);
+ List authorDtos = authors.stream().map(this::convertToDto).collect(Collectors.toList());
+
+ return ResponseEntity.ok(authorDtos);
+ }
+
+ @PostMapping("/{id}/urls")
+ public ResponseEntity addUrl(@PathVariable UUID id, @RequestBody UrlRequest request) {
+ Author author = authorService.addUrl(id, request.getUrl());
+ return ResponseEntity.ok(convertToDto(author));
+ }
+
+ @DeleteMapping("/{id}/urls")
+ public ResponseEntity 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 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 getUrls() { return urls; }
+ public void setUrls(List urls) { this.urls = urls; }
+ }
+
+ public static class UpdateAuthorRequest {
+ private String name;
+ private String notes;
+ private List 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 getUrls() { return urls; }
+ public void setUrls(List 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; }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/storycove/controller/FileController.java b/backend/src/main/java/com/storycove/controller/FileController.java
new file mode 100644
index 0000000..c20e1e0
--- /dev/null
+++ b/backend/src/main/java/com/storycove/controller/FileController.java
@@ -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 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 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 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()));
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/storycove/controller/SearchController.java b/backend/src/main/java/com/storycove/controller/SearchController.java
new file mode 100644
index 0000000..2ce3046
--- /dev/null
+++ b/backend/src/main/java/com/storycove/controller/SearchController.java
@@ -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 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()
+ ));
+ }
+ }
+}
\ No newline at end of file
diff --git a/backend/src/main/java/com/storycove/controller/SeriesController.java b/backend/src/main/java/com/storycove/controller/SeriesController.java
new file mode 100644
index 0000000..df53eaa
--- /dev/null
+++ b/backend/src/main/java/com/storycove/controller/SeriesController.java
@@ -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> 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 = seriesService.findAll(pageable);
+ Page seriesDtos = series.map(this::convertToDto);
+
+ return ResponseEntity.ok(seriesDtos);
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getSeriesById(@PathVariable UUID id) {
+ Series series = seriesService.findById(id);
+ return ResponseEntity.ok(convertToDto(series));
+ }
+
+ @PostMapping
+ public ResponseEntity 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 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> searchSeries(
+ @RequestParam String query,
+ @RequestParam(defaultValue = "0") int page,
+ @RequestParam(defaultValue = "20") int size) {
+
+ Pageable pageable = PageRequest.of(page, size);
+ Page series = seriesService.searchByName(query, pageable);
+ Page seriesDtos = series.map(this::convertToDto);
+
+ return ResponseEntity.ok(seriesDtos);
+ }
+
+ @GetMapping("/with-stories")
+ public ResponseEntity> getSeriesWithStories(@RequestParam(defaultValue = "20") int limit) {
+ Pageable pageable = PageRequest.of(0, limit);
+ List series = seriesService.findSeriesWithStoriesLimited(pageable);
+ List seriesDtos = series.stream().map(this::convertToDto).collect(Collectors.toList());
+
+ return ResponseEntity.ok(seriesDtos);
+ }
+
+ @GetMapping("/popular")
+ public ResponseEntity> getPopularSeries(@RequestParam(defaultValue = "10") int limit) {
+ List series = seriesService.findMostPopular(limit);
+ List seriesDtos = series.stream().map(this::convertToDto).collect(Collectors.toList());
+
+ return ResponseEntity.ok(seriesDtos);
+ }
+
+ @GetMapping("/empty")
+ public ResponseEntity> getEmptySeries() {
+ List series = seriesService.findEmptySeries();
+ List seriesDtos = series.stream().map(this::convertToDto).collect(Collectors.toList());
+
+ return ResponseEntity.ok(seriesDtos);
+ }
+
+ @GetMapping("/stats")
+ public ResponseEntity