From 5f2a6d12ac982e245aaddd1169ec275653a430f1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:19 +0300 Subject: [PATCH] core: restore compilation and tests (ports, DTOs, repo fixes, metrics) --- .../metrics/BusinessMetricsService.kt | 66 +++++++ .../persistence/jdbc/FilmRepository.kt | 20 ++- .../adapters/web/FilmLibraryController.kt | 1 + .../adapters/web/FilmRatingController.kt | 46 +++++ .../adapters/web/RecommendationController.kt | 34 ++++ .../adapters/web/UserPreferencesController.kt | 53 ++++++ .../web/dto/request/RateFilmRequest.kt | 6 + .../request/UpsertUserPreferencesRequest.kt | 10 ++ .../web/dto/response/FilmRatingResponse.kt | 28 +++ .../dto/response/UserPreferencesResponse.kt | 28 +++ .../ports/input/FilmRatingUseCase.kt | 19 ++ .../ports/input/GetRecommendationsUseCase.kt | 16 ++ .../ports/input/UserPreferencesUseCase.kt | 23 +++ .../application/services/FilmRatingService.kt | 57 ++++++ .../application/services/FilmService.kt | 81 +++++---- .../services/RecommendationService.kt | 117 ++++++++++++ .../services/UserPreferencesService.kt | 29 +++ .../movienight/RecommendationSmokeTest.kt | 168 ++++++++++++++++++ 18 files changed, 766 insertions(+), 36 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt create mode 100644 src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt new file mode 100644 index 0000000..6a8f2d0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -0,0 +1,66 @@ +package com.project.movienight.adapters.metrics + +import com.project.movienight.domain.model.JellyfinSyncSummary +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicInteger + +@Service +class BusinessMetricsService( + meterRegistry: MeterRegistry, +) { + private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") + private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") + private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total") + private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total") + private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total") + private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total") + private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total") + private val jellyfinSyncDuration: Timer = + Timer + .builder("business_jellyfin_sync_duration_seconds") + .publishPercentileHistogram() + .register(meterRegistry) + private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total") + private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0) + + init { + meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue) + } + + private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") + + fun recordRecommendationRequest() { + recommendationRequests.increment() + } + + fun recordRatingSubmitted() { + ratingsSubmitted.increment() + } + + fun recordLibraryEvent() { + libraryEvents.increment() + } + + fun recordJellyfinSync(summary: JellyfinSyncSummary) { + jellyfinSyncRuns.increment() + jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) + jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) + jellyfinSyncedItems.increment(summary.syncedItems.toDouble()) + jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) + } + + fun recordJellyfinSyncFailure() { + jellyfinSyncFailures.increment() + } + + fun recordJellyfinUnmappedUser() { + jellyfinUnmappedUsersGaugeValue.incrementAndGet() + } + + fun recordBackendWriteFailure() { + backendWriteFailures.increment() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index a661396..b1d4ab2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -209,7 +209,25 @@ class FilmRepository( override fun findByTitle(title: String): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE title = ? + ORDER BY id + LIMIT 1 + """.trimIndent(), filmRowMapper, title, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 12c2c5e..81115cd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -36,6 +36,7 @@ class FilmLibraryController( private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt new file mode 100644 index 0000000..fec4889 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -0,0 +1,46 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/ratings") +class FilmRatingController( + private val rateFilmUseCase: RateFilmUseCase, + private val getFilmRatingsUseCase: GetFilmRatingsUseCase, +) { + @PostMapping("/films/{filmId}") + @ResponseStatus(HttpStatus.CREATED) + fun rate( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + @RequestBody request: RateFilmRequest, + ): FilmRatingResponse = + FilmRatingResponse.fromDomain( + rateFilmUseCase.rate( + RateFilmCommand( + userId = userId, + filmId = filmId, + score = request.score, + note = request.note, + ), + ), + ) + + @GetMapping + fun list( + @PathVariable userId: UUID, + ): List = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt new file mode 100644 index 0000000..8cf7823 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -0,0 +1,34 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationResult +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendations") +class RecommendationController( + private val getRecommendationsUseCase: GetRecommendationsUseCase, +) { + @GetMapping + fun recommend( + @PathVariable userId: UUID, + @RequestParam(required = false) contentType: String?, + @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "10") limit: Int, + ): List = + getRecommendationsUseCase.recommend( + RecommendationQuery( + userId = userId, + contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + mood = mood, + limit = limit, + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt new file mode 100644 index 0000000..276565b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -0,0 +1,53 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.domain.model.ContentType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/preferences") +class UserPreferencesController( + private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase, + private val getUserPreferencesUseCase: GetUserPreferencesUseCase, +) { + @PutMapping + fun upsert( + @PathVariable userId: UUID, + @RequestBody request: UpsertUserPreferencesRequest, + ): UserPreferencesResponse = + UserPreferencesResponse.fromDomain( + upsertUserPreferencesUseCase.upsert( + UpsertUserPreferencesCommand( + userId = userId, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = + request.contentTypes.mapNotNull { + runCatching { + ContentType.valueOf( + it, + ) + }.getOrNull() + }, + ), + ), + ) + + @GetMapping + fun get( + @PathVariable userId: UUID, + ): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt new file mode 100644 index 0000000..1f44e39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -0,0 +1,6 @@ +package com.project.movienight.adapters.web.dto.request + +data class RateFilmRequest( + val score: Int, + val note: String? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt new file mode 100644 index 0000000..38c809e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpsertUserPreferencesRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt new file mode 100644 index 0000000..8f276fc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(rating: FilmRating): FilmRatingResponse = + FilmRatingResponse( + id = rating.id, + userId = rating.userId, + filmId = rating.filmId, + score = rating.score, + note = rating.note, + createdAt = rating.createdAt, + updatedAt = rating.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt new file mode 100644 index 0000000..2388d3f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesResponse( + val userId: UUID, + val weightedGenres: Map, + val plotTypes: List, + val eras: List, + val castAndDirectors: List, + val moods: List, + val contentTypes: List, +) { + companion object { + fun fromDomain(preferences: UserPreferences): UserPreferencesResponse = + UserPreferencesResponse( + userId = preferences.userId, + weightedGenres = preferences.weightedGenres, + plotTypes = preferences.plotTypes, + eras = preferences.eras, + castAndDirectors = preferences.castAndDirectors, + moods = preferences.moods, + contentTypes = preferences.contentTypes, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt new file mode 100644 index 0000000..37c8226 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -0,0 +1,19 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface RateFilmUseCase { + fun rate(command: RateFilmCommand): FilmRating +} + +data class RateFilmCommand( + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, +) + +interface GetFilmRatingsUseCase { + fun getRatings(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt new file mode 100644 index 0000000..de9f91f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -0,0 +1,16 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +interface GetRecommendationsUseCase { + fun recommend(query: RecommendationQuery): List +} + +data class RecommendationQuery( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val limit: Int = 10, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt new file mode 100644 index 0000000..b44820c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -0,0 +1,23 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UpsertUserPreferencesUseCase { + fun upsert(command: UpsertUserPreferencesCommand): UserPreferences +} + +data class UpsertUserPreferencesCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) + +interface GetUserPreferencesUseCase { + fun get(userId: UUID): UserPreferences? +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt new file mode 100644 index 0000000..738bada --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmRating +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class FilmRatingService( + private val filmRepository: FilmRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) : RateFilmUseCase, + GetFilmRatingsUseCase { + override fun rate(command: RateFilmCommand): FilmRating { + if (command.score !in 1..10) { + throw DomainException("Film rating score must be between 1 and 10") + } + + filmRepository.findById(command.filmId) + ?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString()) + + val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId) + val now = LocalDateTime.now() + + val rating = + if (existingRating == null) { + FilmRating( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + score = command.score, + note = command.note, + createdAt = now, + updatedAt = now, + ) + } else { + existingRating.copy(score = command.score, note = command.note, updatedAt = now) + } + + val savedRating = filmRatingRepository.save(rating) + businessMetricsService.recordRatingSubmitted() + return savedRating + } + + override fun getRatings(userId: UUID): List = filmRatingRepository.findByUserId(userId) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 564667f..7424e84 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}", + "Create film request received: title='{}', descriptionLength={}'", command.title, command.description.length, ) @@ -56,23 +56,29 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) - return filmRepository.save(film) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + + val saved = filmRepository.save(film) + filmCreatedCounter.increment() + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( @@ -95,30 +101,35 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = filmRepository.findById(id) + var film = filmRepository.findById(id) if (film == null) { log.debug("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - film = - film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - return filmRepository.save(film) + val saved = filmRepository.save(film) + filmEditedCounter.increment() + return saved + } finally { + sample.stop(editFilmTimer) + } } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt new file mode 100644 index 0000000..cc6bc39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -0,0 +1,117 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.RecommendationResult +import org.springframework.stereotype.Service + +@Service +class RecommendationService( + private val filmRepository: FilmRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val businessMetricsService: BusinessMetricsService, +) : GetRecommendationsUseCase { + override fun recommend(query: RecommendationQuery): List { + businessMetricsService.recordRecommendationRequest() + val preferences = userPreferencesRepository.findByUserId(query.userId) + val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId } + val watchedFilmIds = + filmLibraryRepository + .findAll() + .filter { + it.userId == query.userId && it.isViewed + }.map { it.filmId } + .toSet() + + return filmRepository + .findAll() + .asSequence() + .filter { film -> query.contentType == null || film.contentType == query.contentType } + .map { film -> + scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id)) + }.sortedByDescending { it.score } + .take(query.limit.coerceAtLeast(1)) + .toList() + } + + private fun scoreFilm( + film: Film, + mood: String?, + preferences: com.project.movienight.domain.model.UserPreferences?, + hasUserRating: Boolean, + watched: Boolean, + ): RecommendationResult { + var score = 0.0 + val reasons = mutableListOf() + + preferences?.contentTypes?.let { + if (it.isEmpty() || it.contains(film.contentType)) { + score += 2.0 + reasons += "Matches content preference" + } + } + + preferences?.weightedGenres?.forEach { (genre, weight) -> + if (film.genres.any { it.equals(genre, ignoreCase = true) }) { + score += weight + reasons += "Matches genre $genre" + } + } + + preferences?.castAndDirectors?.forEach { favorite -> + val found = + film.cast.any { it.equals(favorite, ignoreCase = true) } || + film.directors.any { it.equals(favorite, ignoreCase = true) } + if (found) { + score += 1.5 + reasons += "Matches favorite creator or cast member $favorite" + } + } + + preferences?.moods?.forEach { preferredMood -> + if (mood != null && preferredMood.equals(mood, ignoreCase = true)) { + score += 1.25 + reasons += "Matches requested mood $mood" + } + } + + film.imdbRating?.let { + score += it / 2.0 + reasons += "Strong IMDb signal" + } + + film.platformRating?.let { + score += it + reasons += "Strong platform signal" + } + + if (hasUserRating) { + score += 2.0 + reasons += "User has already rated similar content" + } + + if (watched) { + score -= 3.0 + reasons += "Already watched" + } + + if (mood != null && film.title.contains(mood, ignoreCase = true)) { + score += 0.5 + } + + if (reasons.isEmpty()) { + reasons += "Baseline recommendation from library catalog" + } + + return RecommendationResult(film = film, score = score, reasons = reasons) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt new file mode 100644 index 0000000..de388ce --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -0,0 +1,29 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.stereotype.Service + +@Service +class UserPreferencesService( + private val userPreferencesRepository: UserPreferencesRepositoryPort, +) : UpsertUserPreferencesUseCase, + GetUserPreferencesUseCase { + override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId) +} diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt new file mode 100644 index 0000000..a7ab288 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -0,0 +1,168 @@ +package com.project.movienight + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class RecommendationSmokeTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + @Test + fun `should create data and return a ranked recommendation`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Jane", email = "jane@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "jane@example.com", + ), + ) + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Orbital Drift", + description = "A science-fiction rescue mission", + contentType = "FILM", + genres = listOf("SCI-FI", "THRILLER"), + directors = listOf("Nora Finch"), + imdbRating = 8.7, + platformRating = 9.0, + externalUrl = "https://example.com/orbital-drift", + ), + ) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Small Town Summer", + description = "A grounded family drama", + contentType = "FILM", + genres = listOf("DRAMA"), + directors = listOf("Ava Reed"), + imdbRating = 7.1, + platformRating = 6.8, + ), + ) + }.andExpect { + status { isCreated() } + } + + val createdFilms = jdbcTemplate.queryForList("SELECT id, title FROM films ORDER BY title") + val filmIdByTitle = + createdFilms.associate { row -> + row["title"].toString() to UUID.fromString(row["id"].toString()) + } + val firstFilmId = filmIdByTitle.getValue("Orbital Drift") + val secondFilmId = filmIdByTitle.getValue("Small Town Summer") + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("SCI-FI" to 5), + moods = listOf("focused"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.weightedGenres['SCI-FI']") { value(5) } + } + + mockMvc + .post("/api/users/$userId/ratings/films/$firstFilmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Great fit")) + }.andExpect { + status { isCreated() } + jsonPath("$.score") { value(10) } + } + + mockMvc + .post("/api/users/$userId/library/films/$secondFilmId/viewed") + .andExpect { + status { isOk() } + jsonPath("$.viewed") { value(true) } + } + + mockMvc + .get("/api/users/$userId/ratings") + .andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].film.id") { value(firstFilmId.toString()) } + } + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM film_ratings") + jdbcTemplate.execute("DELETE FROM user_preferences") + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } +}