From c3fc2568e0667433bf56fe94b424513d6232ec98 Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 14:20:45 +0300 Subject: [PATCH 1/7] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + .../services/FilmLibraryService.kt | 168 +++++++++------ .../application/services/FilmService.kt | 198 ++++++++++-------- .../application/services/UserService.kt | 81 ++++--- src/main/resources/application.yaml | 7 +- src/main/resources/logback-spring.xml | 23 ++ 6 files changed, 295 insertions(+), 184 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..468b2ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { implementation(libs.flyway.database.postgresql) implementation(libs.kotlin.reflect) + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation(libs.micrometer.tracing.bridge.otel) implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..b7f24dd 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -17,6 +17,7 @@ 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.FilmLibrary +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -31,98 +32,133 @@ class FilmLibraryService( RemoveFilmFromLibraryUseCase, GetFilmLibraryUseCase, ListFilmLibraryEntriesUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - findByUserId(command.userId)?.let { return it } + log.info("Creating film library for user: {}", command.userId) + log.debug("Create library request: userId={}, name={}", command.userId, command.name) + + val existing = findByUserId(command.userId) + if (existing != null) { + log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) + return existing + } + + log.warn("Library not found for user {}, cannot create", command.userId) throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { + log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) if (existingEntry != null) { - val saved = - filmLibraryRepository.save( - existingEntry.copy( - isViewed = false, - watchedAt = null, - ), + log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) + val saved = filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, ) + ) businessMetricsService.recordLibraryEvent() + log.info("Film re-added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) return saved } - val saved = + val saved = filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = false, + watchedAt = null, + ) + ) + businessMetricsService.recordLibraryEvent() + log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) + return saved + } + + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { + log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) + + val existingLibrary = if (command.libraryId != null) { + log.debug("Looking up by libraryId: {}", command.libraryId) + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + log.debug("Looking up by userId and filmId") + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } + + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) + throw DomainException("Film with id ${command.filmId} not found in user's library") + } + + filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() + log.info("Film removed from library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, existingLibrary.id) + return existingLibrary + } + + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + log.debug("Marking as viewed at: {}", watchedAt) + + val saved = if (existingEntry == null) { + log.debug("Film not in library, creating new entry as viewed") filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, - isViewed = false, - watchedAt = null, - ), + isViewed = true, + watchedAt = watchedAt, + ) + ) + } else { + log.debug("Updating existing entry: entryId={}, was viewed={}", existingEntry.id, existingEntry.isViewed) + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ) ) - businessMetricsService.recordLibraryEvent() - return saved - } - - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - val existingLibrary = - if (command.libraryId != null) { - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } else { - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) - } - - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - throw DomainException("Film with id ${command.filmId} not found in user's library") } - - filmLibraryRepository.deleteById(existingLibrary.id) - businessMetricsService.recordLibraryEvent() - return existingLibrary - } - - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - - val saved = - if (existingEntry == null) { - filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ), - ) - } else { - filmLibraryRepository.save( - existingEntry.copy( - isViewed = true, - watchedAt = watchedAt, - ), - ) - } businessMetricsService.recordLibraryEvent() + log.info("Film marked as viewed: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = - findByUserId(query.userId) + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { + log.debug("Getting library for user: {}", query.userId) + val library = findByUserId(query.userId) ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) + return library + } - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun list(userId: UUID): List { + log.debug("Listing all library entries for user: {}", userId) + val entries = filmLibraryRepository.findAll().filter { it.userId == userId } + log.info("User {} has {} films in library", userId, entries.size) + return entries + } - private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + private fun findByUserId(userId: UUID): FilmLibrary? { + return filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + } - private fun findByUserAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + private fun findByUserAndFilmId(userId: UUID, filmId: UUID): FilmLibrary? { + return filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + } } 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 d69a6c8..c1efda4 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -33,70 +33,68 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { + private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { + log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) + log.debug("Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", + command.title, command.description.length, command.genres, command.releaseYear) + val sample = Timer.start(meterRegistry) try { - log.debug( - "Create film request received: title='{}', descriptionLength={}", - command.title, - command.description.length, - ) - if (filmConfig.isBlocked(command.title)) { - log.debug("Create film blocked by title policy: title='{}'", command.title) + log.warn("Film creation blocked: title contains blocked pattern '{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.debug("Create film blocked by description policy") + log.warn("Film creation blocked: description contains blocked pattern") filmBlockedCounter.increment() 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, - ) + 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() + log.info("Film created successfully: id={}, title='{}'", saved.id, saved.title) return saved } finally { sample.stop(createFilmTimer) } } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { + log.info("Editing film: id={}", id) + log.debug("Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", + id, command.title, command.description.length, command.genres) + val sample = Timer.start(meterRegistry) try { - log.debug("Edit film with id: {}", id) - if (filmConfig.isBlocked(command.title)) { - log.debug("Edit film blocked by title policy: title='{}'", command.title) + log.warn("Film edit blocked: title contains blocked pattern '{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.debug("Edit film blocked by description policy") + log.warn("Film edit blocked: description contains blocked pattern") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -104,28 +102,30 @@ class FilmService( var film = filmRepository.findById(id) if (film == null) { - log.debug("Film not found for edit: id='{}'", id) + log.warn("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, - ) + log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) + + 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, + ) val saved = filmRepository.save(film) filmEditedCounter.increment() + log.info("Film edited successfully: id={}, new title='{}'", saved.id, saved.title) return saved } finally { sample.stop(editFilmTimer) @@ -133,74 +133,86 @@ class FilmService( } override fun delete(id: UUID) { + log.info("Deleting film: id={}", id) + val sample = Timer.start(meterRegistry) try { - log.debug("Delete film with id: {}", id) - val film = filmRepository.findById(id) if (film == null) { - log.debug("Film not found for delete: id='{}'", id) + log.warn("Film not found for delete: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } + log.debug("Film found for deletion: id={}, title='{}'", film.id, film.title) + filmRepository.deleteById(id) - filmDeletedCounter.increment() - - log.info("Film deleted: id='{}'", id) + log.info("Film deleted successfully: id={}, title='{}'", id, film.title) } finally { sample.stop(deleteFilmTimer) } } - override fun getById(id: UUID): Film = - filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + override fun getById(id: UUID): Film { + log.debug("Fetching film by id: {}", id) + val film = filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + log.debug("Film found: id={}, title='{}'", film.id, film.title) + return film + } - override fun getAll(): List = filmRepository.findAll() + override fun getAll(): List { + log.debug("Fetching all films") + val films = filmRepository.findAll() + log.info("Retrieved {} films from database", films.size) + return films + } - override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun searchByTitle(title: String): Film? { + log.debug("Searching film by title: '{}'", title) + val film = filmRepository.findByTitle(title) + if (film != null) { + log.info("Film found by title '{}': id={}", title, film.id) + } else { + log.debug("No film found with title: '{}'", title) + } + return film + } - private val filmCreatedCounter = - Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) + private val filmCreatedCounter = Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) - private val filmEditedCounter = - Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) + private val filmEditedCounter = Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) - private val filmDeletedCounter = - Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) + private val filmDeletedCounter = Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) - private val filmBlockedCounter = - Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) + private val filmBlockedCounter = Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) - private val createFilmTimer = - Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) + private val createFilmTimer = Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) - private val editFilmTimer = - Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) + private val editFilmTimer = Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) - private val deleteFilmTimer = - Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) + private val deleteFilmTimer = Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..8926742 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -13,6 +13,7 @@ import com.project.movienight.config.UserServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -26,48 +27,80 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + override fun create(command: CreateUserCommand): User { + log.info("Creating new user with email: {}", command.email) + log.debug("Create user request: name='{}', email='{}'", command.name, command.email) + if (userConfig.isBlocked(command.name)) { + log.warn("User creation blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - val user = - User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - jellyfinUserId = null, - ) - return userRepository.save(user) + val user = User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + jellyfinUserId = null, + ) + val saved = userRepository.save(user) + + log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) + return saved } - override fun edit( - id: UUID, - command: EditUserCommand, - ): User { + override fun edit(id: UUID, command: EditUserCommand): User { + log.info("Editing user: id={}", id) + log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) + if (userConfig.isBlocked(command.name)) { + log.warn("User edit blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + var user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = - user.copy( - name = command.name, - jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, - ) + log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) - return userRepository.save(user) + user = user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) + + val saved = userRepository.save(user) + log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) + return saved } override fun delete(id: UUID) { - userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + log.info("Deleting user: id={}", id) + log.debug("Delete user request: id={}", id) + + val user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + + log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) + userRepository.deleteById(id) + log.info("User deleted successfully: id={}", id) } - override fun getById(id: UUID): User = - userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + override fun getById(id: UUID): User { + log.debug("Fetching user by id: {}", id) + val user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) + return user + } - override fun getAll(): List = userRepository.findAll() + override fun getAll(): List { + log.debug("Fetching all users") + val users = userRepository.findAll() + log.info("Retrieved {} users from database", users.size) + return users + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c2af423..e277aa3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -115,6 +115,11 @@ services: - censored - epstein - python + logging: + level: + com.project.movienight: DEBUG + org.springframework: WARN + org.flywaydb: WARN pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n" diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..dd704c8 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,23 @@ + + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n + + + + + logs/app.json + + logs/app-%d{yyyy-MM-dd}.json + 30 + + + + + + + + + + + -- 2.54.0 From ef995422dda6c232049dc5aabc2347830ba7957a Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 14:43:26 +0300 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20use=20case=20=D1=81=D0=BB=D0=BE=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20Jellyfin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../adapters/jellyfin/JellyfinApiClient.kt | 30 +-- .../metrics/BusinessMetricsService.kt | 39 ++- .../adapters/persistence/entity/UserEntity.kt | 1 - ...itory.kt => FilmLibraryEntryRepository.kt} | 75 +++--- .../persistence/jdbc/FilmRepository.kt | 26 -- .../jdbc/JellyfinEventRepository.kt | 36 +-- .../persistence/jdbc/UserRepository.kt | 108 +++++++- .../security/CustomOAuth2UserService.kt | 25 +- .../security/SecurityConfiguration.kt | 5 + .../adapters/web/ApiExceptionHandler.kt | 41 +++ .../adapters/web/ContentTypeParser.kt | 10 + .../movienight/adapters/web/FilmController.kt | 41 +-- .../adapters/web/FilmLibraryController.kt | 97 ++----- .../adapters/web/FilmRatingController.kt | 13 +- .../adapters/web/JellyfinEventsController.kt | 26 +- .../adapters/web/JellyfinSyncController.kt | 8 +- .../adapters/web/RecommendationController.kt | 3 +- .../movienight/adapters/web/UserController.kt | 34 +-- .../adapters/web/UserPreferencesController.kt | 22 +- .../dto/request/CreateFilmLibraryRequest.kt | 5 - .../web/dto/request/CreateFilmRequest.kt | 15 ++ .../web/dto/request/CreateUserRequest.kt | 9 + .../web/dto/request/EditFilmRequest.kt | 15 ++ .../web/dto/request/EditUserRequest.kt | 6 + .../web/dto/request/JellyfinEventRequest.kt | 5 + .../web/dto/request/RateFilmRequest.kt | 7 + .../dto/response/FilmLibraryEntryResponse.kt | 26 ++ .../web/dto/response/FilmLibraryResponse.kt | 25 -- .../ports/input/FilmLibraryUseCase.kt | 42 +-- .../ports/input/FilmRatingUseCase.kt | 8 +- .../application/ports/input/FilmUseCase.kt | 38 +-- .../ports/input/JellyfinUseCase.kt | 25 ++ .../ports/input/UserPreferencesUseCase.kt | 8 +- .../application/ports/input/UserUseCase.kt | 32 +-- .../ports/output/BusinessMetricsPort.kt | 30 +++ .../output/FilmLibraryEntryRepositoryPort.kt | 21 ++ .../ports/output/FilmLibraryRepositoryPort.kt | 19 -- .../ports/output/FilmRepositoryPort.kt | 2 - .../ports/output/JellyfinCatalogPort.kt | 30 +++ .../ports/output/JellyfinEventStorePort.kt | 17 ++ .../ports/output/UserRepositoryPort.kt | 14 + .../services/FilmLibraryService.kt | 95 +++---- .../application/services/FilmRatingService.kt | 10 +- .../application/services/FilmService.kt | 225 ++++++---------- .../services/JellyfinEventService.kt | 66 +++-- .../services/JellyfinSyncService.kt | 96 ++++--- .../RecommendationOnboardingService.kt | 14 +- .../services/RecommendationService.kt | 14 +- .../services/UserPreferencesService.kt | 6 +- .../application/services/UserService.kt | 13 +- .../config/JellyfinIntegrationProperties.kt | 2 +- .../movienight/config/MetricsConfiguration.kt | 12 + .../{FilmLibrary.kt => FilmLibraryEntry.kt} | 2 +- .../project/movienight/domain/model/User.kt | 1 - src/main/resources/db/ER.md | 59 +++++ .../migration/V9__cleanup_legacy_schema.sql | 11 + .../com/project/movienight/ClassLoaderTest.kt | 17 ++ .../entity/UserEntityMappingTest.kt | 4 - ...mLibraryEntryRepositoryIntegrationTest.kt} | 78 +++--- .../jdbc/UserRepositoryIntegrationTest.kt | 53 +++- .../adapters/web/FilmControllerSearchTest.kt | 26 +- .../services/FilmLibraryServiceTest.kt | 249 ++++++------------ .../application/services/FilmServiceTest.kt | 8 +- .../application/services/UserServiceTest.kt | 8 +- .../config/TestSecurityConfiguration.kt | 29 ++ 67 files changed, 1144 insertions(+), 995 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepository.kt => FilmLibraryEntryRepository.kt} (60%) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt delete mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt create mode 100644 src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt rename src/main/kotlin/com/project/movienight/domain/model/{FilmLibrary.kt => FilmLibraryEntry.kt} (89%) create mode 100644 src/main/resources/db/migration/V9__cleanup_legacy_schema.sql create mode 100644 src/test/kotlin/com/project/movienight/ClassLoaderTest.kt rename src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepositoryIntegrationTest.kt => FilmLibraryEntryRepositoryIntegrationTest.kt} (61%) create mode 100644 src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..37564ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) + implementation(libs.spring.boot.starter.aop) implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72e81e2..8dbb373 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ mockk = "1.13.13" spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } +spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" } spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" } diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index 71670be..2aea71c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.application.ports.output.JellyfinCatalogPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinRemoteUser import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType import org.springframework.stereotype.Service @@ -11,39 +14,18 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration -data class JellyfinRemoteUser( - val id: String, - val name: String, -) - -data class JellyfinLibraryItemSnapshot( - val jellyfinItemId: String, - val title: String, - val description: String, - val contentType: ContentType, - val releaseYear: Int?, - val genres: List, - val cast: List, - val directors: List, - val platformRating: Double?, - val imdbRating: Double?, - val externalUrl: String?, - val jellyfinLibraryId: String?, - val isPlayed: Boolean, -) - @Service class JellyfinApiClient( private val properties: JellyfinIntegrationProperties, private val objectMapper: ObjectMapper, -) { +) : JellyfinCatalogPort { private val httpClient: HttpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) .build() - fun fetchUsers(): List = + override fun fetchUsers(): List = request("Users") .asItems() .mapNotNull { node -> @@ -51,7 +33,7 @@ class JellyfinApiClient( JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) } - fun fetchLibraryItems(userId: String): List = + override fun fetchLibraryItems(userId: String): List = @Suppress("MaxLineLength") request( "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index b3912f1..973071c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.metrics +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.RecommendationEventType import io.micrometer.core.instrument.Counter @@ -11,8 +12,12 @@ import java.util.concurrent.atomic.AtomicInteger @Service class BusinessMetricsService( private val meterRegistry: MeterRegistry, -) { +) : BusinessMetricsPort { private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") + private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total") + private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total") + private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total") + private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_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") @@ -33,11 +38,27 @@ class BusinessMetricsService( private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") - fun recordRecommendationRequest() { + override fun recordFilmCreated() { + filmsCreated.increment() + } + + override fun recordFilmEdited() { + filmsEdited.increment() + } + + override fun recordFilmDeleted() { + filmsDeleted.increment() + } + + override fun recordFilmBlocked() { + filmsBlocked.increment() + } + + override fun recordRecommendationRequest() { recommendationRequests.increment() } - fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) { + override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) { Counter .builder("recommendation_weights_updated_total") .tag("eventType", eventType.name) @@ -45,15 +66,15 @@ class BusinessMetricsService( .increment() } - fun recordRatingSubmitted() { + override fun recordRatingSubmitted() { ratingsSubmitted.increment() } - fun recordLibraryEvent() { + override fun recordLibraryEvent() { libraryEvents.increment() } - fun recordJellyfinSync(summary: JellyfinSyncSummary) { + override fun recordJellyfinSync(summary: JellyfinSyncSummary) { jellyfinSyncRuns.increment() jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) @@ -61,15 +82,15 @@ class BusinessMetricsService( jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) } - fun recordJellyfinSyncFailure() { + override fun recordJellyfinSyncFailure() { jellyfinSyncFailures.increment() } - fun recordJellyfinUnmappedUser() { + override fun recordJellyfinUnmappedUser() { jellyfinUnmappedUsersGaugeValue.incrementAndGet() } - fun recordBackendWriteFailure() { + override fun recordBackendWriteFailure() { backendWriteFailures.increment() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 58e2c3c..2a9c0c6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User = id = id, name = name, email = email, - library = null, preferences = null, jellyfinUserId = jellyfinUserId, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt similarity index 60% rename from src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt rename to src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt index 9fa474d..beaf6cb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt @@ -1,18 +1,18 @@ package com.project.movienight.adapters.persistence.jdbc -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.domain.model.FilmLibraryEntry import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet import java.util.UUID @Repository -class FilmLibraryRepository( +class FilmLibraryEntryRepository( private val jdbc: JdbcTemplate, -) : FilmLibraryRepositoryPort { - private val filmLibraryRowMapper = { rs: ResultSet, _: Int -> - FilmLibrary( +) : FilmLibraryEntryRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + FilmLibraryEntry( id = UUID.fromString(rs.getString("id")), userId = UUID.fromString(rs.getString("user_id")), filmId = UUID.fromString(rs.getString("film_id")), @@ -22,7 +22,7 @@ class FilmLibraryRepository( ) } - override fun save(filmLibrary: FilmLibrary): FilmLibrary { + override fun save(entry: FilmLibraryEntry): FilmLibraryEntry { val updatedRows = jdbc.update( """ @@ -30,12 +30,12 @@ class FilmLibraryRepository( SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ? WHERE id = ? """.trimIndent(), - filmLibrary.userId, - filmLibrary.filmId, - filmLibrary.comment, - filmLibrary.isViewed, - filmLibrary.watchedAt, - filmLibrary.id, + entry.userId, + entry.filmId, + entry.comment, + entry.isViewed, + entry.watchedAt, + entry.id, ) if (updatedRows == 0) { jdbc.update( @@ -43,48 +43,51 @@ class FilmLibraryRepository( INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at) VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), - filmLibrary.id, - filmLibrary.userId, - filmLibrary.filmId, - filmLibrary.comment, - filmLibrary.isViewed, - filmLibrary.watchedAt, + entry.id, + entry.userId, + entry.filmId, + entry.comment, + entry.isViewed, + entry.watchedAt, ) } - return filmLibrary + return entry } - override fun findById(id: UUID): FilmLibrary? { - val entries = - jdbc.query( + override fun findById(id: UUID): FilmLibraryEntry? = + jdbc + .query( "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?", - filmLibraryRowMapper, + rowMapper, id, - ) - return entries.firstOrNull() - } + ).firstOrNull() + + override fun findByUserId(userId: UUID): List = + jdbc.query( + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?", + rowMapper, + userId, + ) override fun findByUserIdAndFilmId( userId: UUID, filmId: UUID, - ): FilmLibrary? { - val entries = - jdbc.query( + ): FilmLibraryEntry? = + jdbc + .query( """ SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ? """.trimIndent(), - filmLibraryRowMapper, + rowMapper, userId, filmId, - ) - return entries.firstOrNull() - } + ).firstOrNull() - override fun findAll(): List = + override fun findAll(): List = jdbc.query( "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", - filmLibraryRowMapper, + rowMapper, ) override fun deleteById(id: UUID) { 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 b1d4ab2..cdfc6b9 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 @@ -159,32 +159,6 @@ class FilmRepository( return films.firstOrNull() } - override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? { - val films = - jdbc.query( - """ - 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 jellyfin_library_id = ? - """.trimIndent(), - filmRowMapper, - jellyfinLibraryId, - ) - return films.firstOrNull() - } - override fun findAll(): List = jdbc.query( """ diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..a3ec81f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -1,5 +1,7 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.application.ports.output.JellyfinEventRecord +import com.project.movienight.application.ports.output.JellyfinEventStorePort import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.stereotype.Repository @@ -7,16 +9,8 @@ import org.springframework.stereotype.Repository @Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, -) { - fun save( - eventId: String, - serverId: String?, - eventType: String, - occurredAt: java.time.OffsetDateTime?, - jellyfinUserId: String?, - jellyfinItemId: String?, - payload: String?, - ): Int { +) : JellyfinEventStorePort { + override fun save(event: JellyfinEventRecord): Boolean { val sql = """ INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) @@ -26,20 +20,14 @@ class JellyfinEventRepository( val params = MapSqlParameterSource() - .addValue("eventId", eventId) - .addValue("serverId", serverId) - .addValue("eventType", eventType) - .addValue("occurredAt", occurredAt) - .addValue("jellyfinUserId", jellyfinUserId) - .addValue("jellyfinItemId", jellyfinItemId) - .addValue("payload", payload) + .addValue("eventId", event.eventId) + .addValue("serverId", event.serverId) + .addValue("eventType", event.eventType) + .addValue("occurredAt", event.occurredAt) + .addValue("jellyfinUserId", event.jellyfinUserId) + .addValue("jellyfinItemId", event.jellyfinItemId) + .addValue("payload", event.payload) - return jdbc.update(sql, params) - } - - fun delete(eventId: String) { - val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId" - val params = MapSqlParameterSource().addValue("eventId", eventId) - jdbc.update(sql, params) + return jdbc.update(sql, params) == 1 } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index ae34f89..02949ed 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -4,6 +4,7 @@ import com.project.movienight.adapters.persistence.entity.UserEntity import com.project.movienight.adapters.persistence.entity.toDomain import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate @@ -32,11 +33,9 @@ class UserRepository( val entity = if (existingUser != null) { - val existingEntity = existingUser.toEntity() user.toEntity( - provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, - providerId = existingEntity.providerId, - createdAt = existingEntity.createdAt, + provider = findProviderById(user.id), + providerId = findProviderIdById(user.id), ) } else { user.toEntity() @@ -75,6 +74,70 @@ class UserRepository( return user } + override fun createOAuthUser( + user: User, + provider: AuthProvider, + providerId: String, + ): User { + val entity = user.toEntity(provider = provider, providerId = providerId) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.id, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.createdAt, + ) + } + + return findById(user.id) ?: user + } + + override fun linkOAuthAccount( + userId: UUID, + provider: AuthProvider, + providerId: String, + ): User { + val updatedRows = + jdbc.update( + """ + UPDATE users + SET provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + provider.name, + providerId, + userId, + ) + + if (updatedRows == 0) { + throw EntityNotFoundException(entity = "User", id = userId.toString()) + } + + return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + } + override fun findById(id: UUID): User? { val entities = jdbc.query( @@ -88,13 +151,31 @@ class UserRepository( override fun findByEmail(email: String): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at + FROM users + WHERE email = ? + """.trimIndent(), userEntityRowMapper, email, ) return entities.firstOrNull()?.toDomain() } + override fun findByJellyfinUserId(jellyfinUserId: String): User? { + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at + FROM users + WHERE jellyfin_user_id = ? + """.trimIndent(), + userEntityRowMapper, + jellyfinUserId, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findAll(): List = jdbc .query( @@ -122,4 +203,21 @@ class UserRepository( ) return entities.firstOrNull()?.toDomain() } + + private fun findProviderById(id: UUID): AuthProvider? = + jdbc + .query( + "SELECT provider FROM users WHERE id = ?", + { rs: ResultSet, _: Int -> rs.getString("provider") }, + id, + ).firstOrNull() + ?.let { AuthProvider.valueOf(it) } + + private fun findProviderIdById(id: UUID): String? = + jdbc + .query( + "SELECT provider_id FROM users WHERE id = ?", + { rs: ResultSet, _: Int -> rs.getString("provider_id") }, + id, + ).firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index b64ea71..7b90df8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,7 +1,5 @@ package com.project.movienight.adapters.security -import com.project.movienight.adapters.persistence.entity.toDomain -import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.input.security.OAuth2UserInfo import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort @@ -59,12 +57,11 @@ class CustomOAuth2UserService( if (userByEmail != null) { log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) - val entity = - userByEmail.toEntity( - provider = provider, - providerId = userInfo.getProviderId(), - ) - userRepository.save(entity.toDomain()) + userRepository.linkOAuthAccount( + userId = userByEmail.id, + provider = provider, + providerId = userInfo.getProviderId(), + ) } else { log.debug("Creating new user for provider: {}", userInfo.getProvider()) val newUser = @@ -72,14 +69,12 @@ class CustomOAuth2UserService( id = idGenerator.generateId(), name = userInfo.getName(), email = userInfo.getEmail(), - library = null, ) - val entity = - newUser.toEntity( - provider = provider, - providerId = userInfo.getProviderId(), - ) - userRepository.save(entity.toDomain()) + userRepository.createOAuthUser( + user = newUser, + provider = provider, + providerId = userInfo.getProviderId(), + ) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 0bcb1b3..92fb02c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -23,6 +23,11 @@ class SecurityConfiguration( auth .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") .permitAll() + .requestMatchers( + "/api/integrations/jellyfin/events", + "/api/integrations/jellyfin/sync", + "/api/integrations/jellyfin/sync-state", + ).permitAll() .requestMatchers("/api/users/me") .authenticated() .requestMatchers("/api/**") diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 40b3362..29131e3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -6,9 +6,12 @@ import com.project.movienight.domain.exception.EntityNotFoundException import org.slf4j.LoggerFactory import org.slf4j.MDC 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.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.server.ResponseStatusException @RestControllerAdvice class ApiExceptionHandler { @@ -50,6 +53,44 @@ class ApiExceptionHandler { ) } + @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse { + val traceId = currentTraceId() + val details = + exception + .bindingResult + .fieldErrors + .joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" } + .ifBlank { "Invalid request" } + log.warn("Validation error: traceId='{}', message='{}'", traceId, details) + + return ErrorResponse( + message = details, + traceId = traceId, + ) + } + + @ExceptionHandler(ResponseStatusException::class) + fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity { + val traceId = currentTraceId() + log.warn( + "HTTP error: traceId='{}', status='{}', message='{}'", + traceId, + exception.statusCode, + exception.reason, + ) + + return ResponseEntity + .status(exception.statusCode) + .body( + ErrorResponse( + message = exception.reason ?: exception.message, + traceId = traceId, + ), + ) + } + @ExceptionHandler(Exception::class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun handleUnexpectedException(exception: Exception): ErrorResponse { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt new file mode 100644 index 0000000..cf8ed0b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.model.ContentType + +fun parseContentType(value: String): ContentType = + runCatching { ContentType.valueOf(value.uppercase()) } + .getOrElse { throw DomainException("Unsupported content type: $value") } + +fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 9bec40b..1a35dea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -4,13 +4,9 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.EditFilmRequest import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.CreateFilmCommand -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand -import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.application.ports.input.FilmUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -25,33 +21,22 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import java.util.UUID -private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType = - runCatching { - com.project.movienight.domain.model.ContentType - .valueOf(this) - }.getOrDefault(com.project.movienight.domain.model.ContentType.FILM) - @RestController @RequestMapping("/api/films") class FilmController( - private val createFilmUseCase: CreateFilmUseCase, - private val editFilmUseCase: EditFilmUseCase, - private val deleteFilmUseCase: DeleteFilmUseCase, - private val getFilmByIdUseCase: GetFilmByIdUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, - private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, + private val filmUseCase: FilmUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestBody request: CreateFilmRequest, + @Valid @RequestBody request: CreateFilmRequest, ): FilmResponse = FilmResponse.fromDomain( - createFilmUseCase.create( + filmUseCase.create( CreateFilmCommand( title = request.title, description = request.description, - contentType = request.contentType.toContentTypeOrFilm(), + contentType = parseContentType(request.contentType), releaseYear = request.releaseYear, genres = request.genres, cast = request.cast, @@ -68,16 +53,16 @@ class FilmController( @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, - @RequestBody request: EditFilmRequest, + @Valid @RequestBody request: EditFilmRequest, ): FilmResponse = FilmResponse.fromDomain( - editFilmUseCase.edit( + filmUseCase.edit( id = id, command = EditFilmCommand( title = request.title, description = request.description, - contentType = request.contentType.toContentTypeOrFilm(), + contentType = parseContentType(request.contentType), releaseYear = request.releaseYear, genres = request.genres, cast = request.cast, @@ -95,21 +80,21 @@ class FilmController( @ResponseStatus(HttpStatus.NO_CONTENT) fun delete( @PathVariable id: UUID, - ) = deleteFilmUseCase.delete(id) + ) = filmUseCase.delete(id) @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id)) @GetMapping - fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = filmUseCase.getAll().map { FilmResponse.fromDomain(it) } @GetMapping("/search") fun searchByTitle( @RequestParam title: String, ): ResponseEntity { - val film = searchFilmByTitleUseCase.searchByTitle(title) + val film = filmUseCase.searchByTitle(title) return if (film != null) { ResponseEntity.ok(FilmResponse.fromDomain(film)) } else { 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 81115cd..e955445 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -1,28 +1,16 @@ package com.project.movienight.adapters.web -import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest -import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse +import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.domain.exception.EntityNotFoundException import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping 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 @@ -31,52 +19,29 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/library") class FilmLibraryController( - private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, - private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, - private val markFilmViewedUseCase: MarkFilmViewedUseCase, - private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, - private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, - private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, ) { - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - fun create( - @PathVariable userId: UUID, - @RequestBody request: CreateFilmLibraryRequest, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - createFilmLibraryUseCase.create( - CreateFilmLibraryCommand( - userId = userId, - name = request.name, - ), - ), - ) - @GetMapping - fun get( - @PathVariable userId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ), - ) - - @GetMapping("/entries") fun list( @PathVariable userId: UUID, - ): List = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } + ): List = + filmLibraryUseCase + .list(userId) + .map { entry -> FilmLibraryEntryResponse.fromDomain(entry) } + + @GetMapping("/entries") + fun listEntries( + @PathVariable userId: UUID, + ): List = list(userId) @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - addFilmToLibraryUseCase.addFilm( + ): FilmLibraryEntryResponse = + FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.addFilm( AddFilmToLibraryCommand( userId = userId, filmId = filmId, @@ -88,9 +53,9 @@ class FilmLibraryController( fun markViewed( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - markFilmViewedUseCase.markViewed( + ): FilmLibraryEntryResponse = + FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.markViewed( MarkFilmViewedCommand( userId = userId, filmId = filmId, @@ -104,7 +69,7 @@ class FilmLibraryController( @PathVariable userId: UUID, @PathVariable filmId: UUID, ) { - removeFilmFromLibraryUseCase.removeFilm( + filmLibraryUseCase.removeFilm( RemoveFilmFromLibraryCommand( userId = userId, filmId = filmId, @@ -115,27 +80,5 @@ class FilmLibraryController( @GetMapping("/available-films") fun getAvailableFilms( @PathVariable userId: UUID, - ): List { - val userLibrary = - runCatching { - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - }.onFailure { exception -> - if (exception !is EntityNotFoundException) { - throw exception - } - }.getOrNull() - - val allFilms = getAllFilmsUseCase.getAll() - - val availableFilms = - if (userLibrary != null) { - allFilms.filter { it.id != userLibrary.filmId } - } else { - allFilms - } - - return availableFilms.map { FilmResponse.fromDomain(it) } - } + ): List = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt index fec4889..b86b5f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -2,9 +2,9 @@ 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.FilmRatingUseCase import com.project.movienight.application.ports.input.RateFilmCommand -import com.project.movienight.application.ports.input.RateFilmUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -18,18 +18,17 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/ratings") class FilmRatingController( - private val rateFilmUseCase: RateFilmUseCase, - private val getFilmRatingsUseCase: GetFilmRatingsUseCase, + private val filmRatingUseCase: FilmRatingUseCase, ) { @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun rate( @PathVariable userId: UUID, @PathVariable filmId: UUID, - @RequestBody request: RateFilmRequest, + @Valid @RequestBody request: RateFilmRequest, ): FilmRatingResponse = FilmRatingResponse.fromDomain( - rateFilmUseCase.rate( + filmRatingUseCase.rate( RateFilmCommand( userId = userId, filmId = filmId, @@ -42,5 +41,5 @@ class FilmRatingController( @GetMapping fun list( @PathVariable userId: UUID, - ): List = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } + ): List = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 81ee52c..0d7a294 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -1,8 +1,10 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest -import com.project.movienight.application.services.JellyfinEventService +import com.project.movienight.application.ports.input.HandleJellyfinEventCommand +import com.project.movienight.application.ports.input.JellyfinEventUseCase import com.project.movienight.config.JellyfinIntegrationProperties +import jakarta.validation.Valid import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.PostMapping @@ -16,7 +18,7 @@ import org.springframework.web.server.ResponseStatusException @RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinEventsController( - private val jellyfinEventService: JellyfinEventService, + private val jellyfinEventUseCase: JellyfinEventUseCase, private val properties: JellyfinIntegrationProperties, ) { private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) @@ -25,7 +27,7 @@ class JellyfinEventsController( @ResponseStatus(HttpStatus.OK) fun receiveEvent( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, - @RequestBody request: JellyfinEventRequest, + @Valid @RequestBody request: JellyfinEventRequest, ) { if (!properties.enabled) { throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") @@ -43,14 +45,16 @@ class JellyfinEventsController( request.jellyfinUserId, request.itemId, ) - jellyfinEventService.handleEvent( - eventId = request.eventId, - serverId = null, - eventType = request.eventType, - occurredAt = request.occurredAt, - jellyfinUserId = request.jellyfinUserId, - itemId = request.itemId, - payload = request.payload, + jellyfinEventUseCase.handle( + HandleJellyfinEventCommand( + eventId = request.eventId, + serverId = null, + eventType = request.eventType, + occurredAt = request.occurredAt, + jellyfinUserId = request.jellyfinUserId, + itemId = request.itemId, + payload = request.payload, + ), ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index 74aac90..aa107b7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -1,6 +1,6 @@ package com.project.movienight.adapters.web -import com.project.movienight.application.services.JellyfinSyncService +import com.project.movienight.application.ports.input.JellyfinSyncUseCase import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary import org.springframework.web.bind.annotation.GetMapping @@ -11,11 +11,11 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinSyncController( - private val jellyfinSyncService: JellyfinSyncService, + private val jellyfinSyncUseCase: JellyfinSyncUseCase, ) { @PostMapping("/sync") - fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow() + fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow() @GetMapping("/sync-state") - fun syncState(): List = jellyfinSyncService.getSyncStates() + fun syncState(): List = jellyfinSyncUseCase.getSyncStates() } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 6d5bdd9..cd37beb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -9,7 +9,6 @@ import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.input.RejectRecommendationCommand import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.config.JellyfinIntegrationProperties -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.PostMapping @@ -40,7 +39,7 @@ class RecommendationController( .recommend( RecommendationQuery( userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, + contentType = parseOptionalContentType(contentType), mood = mood, libraryOnly = libraryOnly, limit = limit, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index e06954c..ee74235 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -1,16 +1,15 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.security.UserPrincipal import com.project.movienight.adapters.web.dto.request.CreateUserRequest import com.project.movienight.adapters.web.dto.request.EditUserRequest import com.project.movienight.adapters.web.dto.response.UserResponse import com.project.movienight.application.ports.input.CreateUserCommand -import com.project.movienight.application.ports.input.CreateUserUseCase -import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand -import com.project.movienight.application.ports.input.EditUserUseCase -import com.project.movienight.application.ports.input.GetAllUsersUseCase -import com.project.movienight.application.ports.input.GetUserByIdUseCase +import com.project.movienight.application.ports.input.UserUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -25,19 +24,15 @@ import java.util.UUID @RestController @RequestMapping("/api/users") class UserController( - private val createUserUseCase: CreateUserUseCase, - private val editUserUseCase: EditUserUseCase, - private val deleteUserUseCase: DeleteUserUseCase, - private val getUserByIdUseCase: GetUserByIdUseCase, - private val getAllUsersUseCase: GetAllUsersUseCase, + private val userUseCase: UserUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestBody request: CreateUserRequest, + @Valid @RequestBody request: CreateUserRequest, ): UserResponse = UserResponse.fromDomain( - createUserUseCase.create( + userUseCase.create( CreateUserCommand( name = request.name, email = request.email, @@ -46,20 +41,25 @@ class UserController( ) @GetMapping - fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = userUseCase.getAll().map { UserResponse.fromDomain(it) } + + @GetMapping("/me") + fun getMe( + @AuthenticationPrincipal principal: UserPrincipal, + ): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId())) @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(userUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, - @RequestBody request: EditUserRequest, + @Valid @RequestBody request: EditUserRequest, ): UserResponse = UserResponse.fromDomain( - editUserUseCase.edit( + userUseCase.edit( id = id, command = EditUserCommand( @@ -73,5 +73,5 @@ class UserController( @ResponseStatus(HttpStatus.NO_CONTENT) fun delete( @PathVariable id: UUID, - ) = deleteUserUseCase.delete(id) + ) = userUseCase.delete(id) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt index 276565b..074fb15 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -2,10 +2,9 @@ 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 com.project.movienight.application.ports.input.UserPreferencesUseCase +import jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping @@ -17,16 +16,15 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/preferences") class UserPreferencesController( - private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase, - private val getUserPreferencesUseCase: GetUserPreferencesUseCase, + private val userPreferencesUseCase: UserPreferencesUseCase, ) { @PutMapping fun upsert( @PathVariable userId: UUID, - @RequestBody request: UpsertUserPreferencesRequest, + @Valid @RequestBody request: UpsertUserPreferencesRequest, ): UserPreferencesResponse = UserPreferencesResponse.fromDomain( - upsertUserPreferencesUseCase.upsert( + userPreferencesUseCase.upsert( UpsertUserPreferencesCommand( userId = userId, weightedGenres = request.weightedGenres, @@ -35,13 +33,7 @@ class UserPreferencesController( castAndDirectors = request.castAndDirectors, moods = request.moods, contentTypes = - request.contentTypes.mapNotNull { - runCatching { - ContentType.valueOf( - it, - ) - }.getOrNull() - }, + request.contentTypes.map { parseContentType(it) }, ), ), ) @@ -49,5 +41,5 @@ class UserPreferencesController( @GetMapping fun get( @PathVariable userId: UUID, - ): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } + ): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt deleted file mode 100644 index dc6cb11..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.project.movienight.adapters.web.dto.request - -data class CreateFilmLibraryRequest( - val name: String = "My films", -) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 82f7348..a07f049 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -1,14 +1,29 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class CreateFilmRequest( + @field:NotBlank + @field:Size(max = 255) val title: String, + @field:NotBlank val description: String, + @field:NotBlank val contentType: String = "FILM", + @field:Min(1888) + @field:Max(3000) val releaseYear: Int? = null, val genres: List = emptyList(), val cast: List = emptyList(), val directors: List = emptyList(), + @field:Min(0) + @field:Max(10) val imdbRating: Double? = null, + @field:Min(0) + @field:Max(10) val platformRating: Double? = null, val externalUrl: String? = null, val jellyfinItemId: String? = null, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt index 73dcb0f..94308b2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt @@ -1,6 +1,15 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class CreateUserRequest( + @field:NotBlank + @field:Size(max = 255) val name: String, + @field:Email + @field:NotBlank + @field:Size(max = 320) val email: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 60eddce..20d88e5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -1,14 +1,29 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class EditFilmRequest( + @field:NotBlank + @field:Size(max = 255) val title: String, + @field:NotBlank val description: String, + @field:NotBlank val contentType: String = "FILM", + @field:Min(1888) + @field:Max(3000) val releaseYear: Int? = null, val genres: List = emptyList(), val cast: List = emptyList(), val directors: List = emptyList(), + @field:Min(0) + @field:Max(10) val imdbRating: Double? = null, + @field:Min(0) + @field:Max(10) val platformRating: Double? = null, val externalUrl: String? = null, val jellyfinItemId: String? = null, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 358e0e4..b459396 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -1,6 +1,12 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class EditUserRequest( + @field:NotBlank + @field:Size(max = 255) val name: String, + @field:Size(max = 255) val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt index 68abfe8..973f7b8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -1,18 +1,23 @@ package com.project.movienight.adapters.web.dto.request import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotBlank import java.time.OffsetDateTime data class JellyfinEventRequest( @JsonProperty("event_id") + @field:NotBlank val eventId: String, @JsonProperty("event_type") + @field:NotBlank val eventType: String, @JsonProperty("occurred_at") val occurredAt: OffsetDateTime, @JsonProperty("jellyfin_user_id") + @field:NotBlank val jellyfinUserId: String, @JsonProperty("item_id") + @field:NotBlank val itemId: String, @JsonProperty("payload_version") val payloadVersion: Int = 1, 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 index 1f44e39..c2a8077 100644 --- 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 @@ -1,6 +1,13 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Size + data class RateFilmRequest( + @field:Min(1) + @field:Max(10) val score: Int, + @field:Size(max = 2048) val note: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt new file mode 100644 index 0000000..2a6d670 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmLibraryEntry +import java.time.LocalDateTime +import java.util.UUID + +data class FilmLibraryEntryResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val comment: String?, + val isViewed: Boolean, + val watchedAt: LocalDateTime?, +) { + companion object { + fun fromDomain(entry: FilmLibraryEntry): FilmLibraryEntryResponse = + FilmLibraryEntryResponse( + id = entry.id, + userId = entry.userId, + filmId = entry.filmId, + comment = entry.comment, + isViewed = entry.isViewed, + watchedAt = entry.watchedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt deleted file mode 100644 index 90a339d..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.project.movienight.adapters.web.dto.response - -import com.project.movienight.domain.model.FilmLibrary -import java.util.UUID - -data class FilmLibraryResponse( - val id: UUID, - val userId: UUID, - val filmId: UUID, - val comment: String?, - val isViewed: Boolean, - val watchedAt: java.time.LocalDateTime?, -) { - companion object { - fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = - FilmLibraryResponse( - id = filmLibrary.id, - userId = filmLibrary.userId, - filmId = filmLibrary.filmId, - comment = filmLibrary.comment, - isViewed = filmLibrary.isViewed, - watchedAt = filmLibrary.watchedAt, - ) - } -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index 3100547..39d9688 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,20 +1,20 @@ package com.project.movienight.application.ports.input -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import java.time.LocalDateTime import java.util.UUID -interface CreateFilmLibraryUseCase { - fun create(command: CreateFilmLibraryCommand): FilmLibrary -} +interface FilmLibraryUseCase { + fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry -data class CreateFilmLibraryCommand( - val userId: UUID, - val name: String = "Мои фильмы", -) + fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry -interface AddFilmToLibraryUseCase { - fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary + fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry + + fun list(userId: UUID): List + + fun listAvailableFilms(userId: UUID): List } data class AddFilmToLibraryCommand( @@ -22,34 +22,14 @@ data class AddFilmToLibraryCommand( val filmId: UUID, ) -interface MarkFilmViewedUseCase { - fun markViewed(command: MarkFilmViewedCommand): FilmLibrary -} - data class MarkFilmViewedCommand( val userId: UUID, val filmId: UUID, val watchedAt: LocalDateTime? = null, ) -interface RemoveFilmFromLibraryUseCase { - fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary -} - data class RemoveFilmFromLibraryCommand( val userId: UUID, val filmId: UUID, - val libraryId: UUID? = null, + val entryId: UUID? = null, ) - -interface GetFilmLibraryUseCase { - fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary -} - -data class GetFilmLibraryQuery( - val userId: UUID, -) - -interface ListFilmLibraryEntriesUseCase { - fun list(userId: UUID): List -} 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 index 37c8226..5a411a1 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -3,8 +3,10 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.FilmRating import java.util.UUID -interface RateFilmUseCase { +interface FilmRatingUseCase { fun rate(command: RateFilmCommand): FilmRating + + fun getRatings(userId: UUID): List } data class RateFilmCommand( @@ -13,7 +15,3 @@ data class RateFilmCommand( 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/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 27098c7..c88bc2d 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -4,8 +4,21 @@ import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID -interface CreateFilmUseCase { +interface FilmUseCase { fun create(command: CreateFilmCommand): Film + + fun edit( + id: UUID, + command: EditFilmCommand, + ): Film + + fun delete(id: UUID) + + fun getById(id: UUID): Film + + fun getAll(): List + + fun searchByTitle(title: String): Film? } data class CreateFilmCommand( @@ -23,13 +36,6 @@ data class CreateFilmCommand( val jellyfinLibraryId: String? = null, ) -interface EditFilmUseCase { - fun edit( - id: UUID, - command: EditFilmCommand, - ): Film -} - data class EditFilmCommand( val title: String, val description: String, @@ -44,19 +50,3 @@ data class EditFilmCommand( val jellyfinItemId: String? = null, val jellyfinLibraryId: String? = null, ) - -interface DeleteFilmUseCase { - fun delete(id: UUID) -} - -interface GetFilmByIdUseCase { - fun getById(id: UUID): Film -} - -interface GetAllFilmsUseCase { - fun getAll(): List -} - -interface SearchFilmByTitleUseCase { - fun searchByTitle(title: String): Film? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt new file mode 100644 index 0000000..b28e68d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -0,0 +1,25 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import java.time.OffsetDateTime + +interface JellyfinEventUseCase { + fun handle(command: HandleJellyfinEventCommand) +} + +data class HandleJellyfinEventCommand( + val eventId: String, + val serverId: String?, + val eventType: String, + val occurredAt: OffsetDateTime, + val jellyfinUserId: String, + val itemId: String, + val payload: Map?, +) + +interface JellyfinSyncUseCase { + fun syncNow(): JellyfinSyncSummary + + fun getSyncStates(): List +} 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 index b44820c..fcf539c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -4,8 +4,10 @@ import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.UserPreferences import java.util.UUID -interface UpsertUserPreferencesUseCase { +interface UserPreferencesUseCase { fun upsert(command: UpsertUserPreferencesCommand): UserPreferences + + fun get(userId: UUID): UserPreferences? } data class UpsertUserPreferencesCommand( @@ -17,7 +19,3 @@ data class UpsertUserPreferencesCommand( 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/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b066a4f..2cef36c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -3,8 +3,19 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.User import java.util.UUID -interface CreateUserUseCase { +interface UserUseCase { fun create(command: CreateUserCommand): User + + fun edit( + id: UUID, + command: EditUserCommand, + ): User + + fun delete(id: UUID) + + fun getById(id: UUID): User + + fun getAll(): List } data class CreateUserCommand( @@ -12,26 +23,7 @@ data class CreateUserCommand( val email: String, ) -interface EditUserUseCase { - fun edit( - id: UUID, - command: EditUserCommand, - ): User -} - data class EditUserCommand( val name: String, val jellyfinUserId: String? = null, ) - -interface DeleteUserUseCase { - fun delete(id: UUID) -} - -interface GetUserByIdUseCase { - fun getById(id: UUID): User -} - -interface GetAllUsersUseCase { - fun getAll(): List -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..516fafd --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.RecommendationEventType + +interface BusinessMetricsPort { + fun recordFilmCreated() + + fun recordFilmEdited() + + fun recordFilmDeleted() + + fun recordFilmBlocked() + + fun recordRecommendationRequest() + + fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) + + fun recordRatingSubmitted() + + fun recordLibraryEvent() + + fun recordJellyfinSync(summary: JellyfinSyncSummary) + + fun recordJellyfinSyncFailure() + + fun recordJellyfinUnmappedUser() + + fun recordBackendWriteFailure() +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt new file mode 100644 index 0000000..825bb06 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt @@ -0,0 +1,21 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmLibraryEntry +import java.util.UUID + +interface FilmLibraryEntryRepositoryPort { + fun save(entry: FilmLibraryEntry): FilmLibraryEntry + + fun findById(id: UUID): FilmLibraryEntry? + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibraryEntry? + + fun findAll(): List + + fun deleteById(id: UUID) +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt deleted file mode 100644 index 933f45c..0000000 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.project.movienight.application.ports.output - -import com.project.movienight.domain.model.FilmLibrary -import java.util.UUID - -interface FilmLibraryRepositoryPort { - fun save(filmLibrary: FilmLibrary): FilmLibrary - - fun findById(id: UUID): FilmLibrary? - - fun findByUserIdAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? - - fun findAll(): List - - fun deleteById(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index d18b2e6..1883fe9 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,8 +10,6 @@ interface FilmRepositoryPort { fun findByJellyfinItemId(jellyfinItemId: String): Film? - fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? - fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt new file mode 100644 index 0000000..0e48699 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +interface JellyfinCatalogPort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt new file mode 100644 index 0000000..b7b7751 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt @@ -0,0 +1,17 @@ +package com.project.movienight.application.ports.output + +import java.time.OffsetDateTime + +interface JellyfinEventStorePort { + fun save(event: JellyfinEventRecord): Boolean +} + +data class JellyfinEventRecord( + val eventId: String, + val serverId: String?, + val eventType: String, + val occurredAt: OffsetDateTime?, + val jellyfinUserId: String?, + val jellyfinItemId: String?, + val payload: String?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index dd69728..980ec30 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,10 +7,24 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun createOAuthUser( + user: User, + provider: AuthProvider, + providerId: String, + ): User + + fun linkOAuthAccount( + userId: UUID, + provider: AuthProvider, + providerId: String, + ): User + fun findById(id: UUID): User? fun findByEmail(email: String): User? + fun findByJellyfinUserId(jellyfinUserId: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..c78336e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,46 +1,34 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +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.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import org.springframework.stereotype.Service import java.util.UUID @Service class FilmLibraryService( - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, + private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : CreateFilmLibraryUseCase, - AddFilmToLibraryUseCase, - MarkFilmViewedUseCase, - RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase, - ListFilmLibraryEntriesUseCase { - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - findByUserId(command.userId)?.let { return it } - throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) - } + private val businessMetricsService: BusinessMetricsPort, +) : FilmLibraryUseCase { + override fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry { + ensureFilmExists(command.filmId) - override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) if (existingEntry != null) { val saved = - filmLibraryRepository.save( + filmLibraryEntryRepository.save( existingEntry.copy( isViewed = false, watchedAt = null, @@ -51,8 +39,8 @@ class FilmLibraryService( } val saved = - filmLibraryRepository.save( - FilmLibrary( + filmLibraryEntryRepository.save( + FilmLibraryEntry( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, @@ -65,33 +53,35 @@ class FilmLibraryService( return saved } - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - val existingLibrary = - if (command.libraryId != null) { - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry { + val existingEntry = + if (command.entryId != null) { + filmLibraryEntryRepository.findById(command.entryId) + ?: throw EntityNotFoundException(entity = "Film library entry", id = command.entryId.toString()) } else { - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library entry", id = command.filmId.toString()) } - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + if (existingEntry.userId != command.userId || existingEntry.filmId != command.filmId) { throw DomainException("Film with id ${command.filmId} not found in user's library") } - filmLibraryRepository.deleteById(existingLibrary.id) + filmLibraryEntryRepository.deleteById(existingEntry.id) businessMetricsService.recordLibraryEvent() - return existingLibrary + return existingEntry } - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + override fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry { + ensureFilmExists(command.filmId) + + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() val saved = if (existingEntry == null) { - filmLibraryRepository.save( - FilmLibrary( + filmLibraryEntryRepository.save( + FilmLibraryEntry( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, @@ -101,7 +91,7 @@ class FilmLibraryService( ), ) } else { - filmLibraryRepository.save( + filmLibraryEntryRepository.save( existingEntry.copy( isViewed = true, watchedAt = watchedAt, @@ -112,17 +102,14 @@ class FilmLibraryService( return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = - findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + override fun list(userId: UUID): List = filmLibraryEntryRepository.findByUserId(userId) - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun listAvailableFilms(userId: UUID): List { + val libraryFilmIds = list(userId).map { it.filmId }.toSet() + return filmRepository.findAll().filter { it.id !in libraryFilmIds } + } - private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { it.userId == userId } - - private fun findByUserAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + private fun ensureFilmExists(filmId: UUID) { + filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt index 738bada..0653171 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -1,9 +1,8 @@ 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.FilmRatingUseCase import com.project.movienight.application.ports.input.RateFilmCommand -import com.project.movienight.application.ports.input.RateFilmUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -19,9 +18,8 @@ class FilmRatingService( private val filmRepository: FilmRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : RateFilmUseCase, - GetFilmRatingsUseCase { + private val businessMetricsService: BusinessMetricsPort, +) : FilmRatingUseCase { override fun rate(command: RateFilmCommand): FilmRating { if (command.score !in 1..10) { throw DomainException("Film rating score must be between 1 and 10") 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 d69a6c8..30715f2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -1,22 +1,16 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateFilmCommand -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand -import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.application.ports.input.FilmUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film -import io.micrometer.core.instrument.Counter -import io.micrometer.core.instrument.MeterRegistry -import io.micrometer.core.instrument.Timer +import io.micrometer.core.annotation.Timed import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -26,89 +20,81 @@ class FilmService( private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, private val filmConfig: FilmServiceProperties, - private val meterRegistry: MeterRegistry, -) : CreateFilmUseCase, - EditFilmUseCase, - DeleteFilmUseCase, - GetFilmByIdUseCase, - GetAllFilmsUseCase, - SearchFilmByTitleUseCase { + private val businessMetricsService: BusinessMetricsPort, +) : FilmUseCase { private val log = LoggerFactory.getLogger(javaClass) + @Timed( + value = "business_films_create_duration_seconds", + description = "Film creation duration", + ) override fun create(command: CreateFilmCommand): Film { - val sample = Timer.start(meterRegistry) + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, + ) - try { - log.debug( - "Create film request received: title='{}', descriptionLength={}", - command.title, - command.description.length, + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + businessMetricsService.recordFilmBlocked() + 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, ) - if (filmConfig.isBlocked(command.title)) { - log.debug("Create film blocked by title policy: title='{}'", command.title) - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - log.debug("Create film blocked by description policy") - filmBlockedCounter.increment() - 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, - ) - - val saved = filmRepository.save(film) - filmCreatedCounter.increment() - return saved - } finally { - sample.stop(createFilmTimer) - } + val saved = filmRepository.save(film) + businessMetricsService.recordFilmCreated() + return saved } + @Timed( + value = "business_films_edit_duration_seconds", + description = "Film edit duration", + ) override fun edit( id: UUID, command: EditFilmCommand, ): Film { - val sample = Timer.start(meterRegistry) + log.debug("Edit film with id: {}", id) - try { - log.debug("Edit film with id: {}", id) + if (filmConfig.isBlocked(command.title)) { + log.debug("Edit film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "description") + } - if (filmConfig.isBlocked(command.title)) { - log.debug("Edit film blocked by title policy: title='{}'", command.title) - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - log.debug("Edit film blocked by description policy") - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "description") - } + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - 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 = + val saved = + filmRepository.save( film.copy( title = command.title, description = command.description, @@ -122,37 +108,30 @@ class FilmService( externalUrl = command.externalUrl, jellyfinItemId = command.jellyfinItemId, jellyfinLibraryId = command.jellyfinLibraryId, - ) - - val saved = filmRepository.save(film) - filmEditedCounter.increment() - return saved - } finally { - sample.stop(editFilmTimer) - } + ), + ) + businessMetricsService.recordFilmEdited() + return saved } + @Timed( + value = "business_films_delete_duration_seconds", + description = "Film deletion duration", + ) override fun delete(id: UUID) { - val sample = Timer.start(meterRegistry) + log.debug("Delete film with id: {}", id) - try { - log.debug("Delete film with id: {}", id) + val film = filmRepository.findById(id) - val film = filmRepository.findById(id) - - if (film == null) { - log.debug("Film not found for delete: id='{}'", id) - throw EntityNotFoundException(entity = "Film", id = id.toString()) - } - - filmRepository.deleteById(id) - - filmDeletedCounter.increment() - - log.info("Film deleted: id='{}'", id) - } finally { - sample.stop(deleteFilmTimer) + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) } + + filmRepository.deleteById(id) + businessMetricsService.recordFilmDeleted() + + log.info("Film deleted: id='{}'", id) } override fun getById(id: UUID): Film = @@ -161,46 +140,4 @@ class FilmService( override fun getAll(): List = filmRepository.findAll() override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) - - private val filmCreatedCounter = - Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) - - private val filmEditedCounter = - Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) - - private val filmDeletedCounter = - Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) - - private val filmBlockedCounter = - Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) - - private val createFilmTimer = - Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) - - private val editFilmTimer = - Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) - - private val deleteFilmTimer = - Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 64be033..b35c94c 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -1,79 +1,73 @@ package com.project.movienight.application.services import com.fasterxml.jackson.databind.ObjectMapper -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.HandleJellyfinEventCommand +import com.project.movienight.application.ports.input.JellyfinEventUseCase import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.JellyfinEventRecord +import com.project.movienight.application.ports.output.JellyfinEventStorePort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service -import java.time.OffsetDateTime +import org.springframework.transaction.annotation.Transactional @Service class JellyfinEventService( - private val jellyfinEventRepository: JellyfinEventRepository, + private val jellyfinEventStore: JellyfinEventStorePort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, - private val markFilmViewedUseCase: MarkFilmViewedUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, private val objectMapper: ObjectMapper, - private val businessMetricsService: BusinessMetricsService, -) { + private val businessMetricsService: BusinessMetricsPort, +) : JellyfinEventUseCase { private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") - fun handleEvent( - eventId: String, - serverId: String?, - eventType: String, - occurredAt: OffsetDateTime, - jellyfinUserId: String, - itemId: String, - payload: Map?, - ) { - val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + @Transactional + override fun handle(command: HandleJellyfinEventCommand) { + val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val inserted = - jellyfinEventRepository.save( - eventId = eventId, - serverId = serverId, - eventType = eventType, - occurredAt = occurredAt, - jellyfinUserId = jellyfinUserId, - jellyfinItemId = itemId, - payload = payloadJson, + jellyfinEventStore.save( + JellyfinEventRecord( + eventId = command.eventId, + serverId = command.serverId, + eventType = command.eventType, + occurredAt = command.occurredAt, + jellyfinUserId = command.jellyfinUserId, + jellyfinItemId = command.itemId, + payload = payloadJson, + ), ) - if (inserted != 1) { + if (!inserted) { return } try { - if (playbackEventTypes.contains(eventType)) { - val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } + if (playbackEventTypes.contains(command.eventType)) { + val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) if (localUser == null) { - jellyfinEventRepository.delete(eventId) businessMetricsService.recordJellyfinUnmappedUser() return } - val film = filmRepository.findByJellyfinItemId(itemId) + val film = filmRepository.findByJellyfinItemId(command.itemId) if (film == null) { - jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() return } - markFilmViewedUseCase.markViewed( + filmLibraryUseCase.markViewed( MarkFilmViewedCommand( userId = localUser.id, filmId = film.id, - watchedAt = occurredAt.toLocalDateTime(), + watchedAt = command.occurredAt.toLocalDateTime(), ), ) - businessMetricsService.recordLibraryEvent() } } catch ( @Suppress("TooGenericExceptionCaught") ex: RuntimeException, ) { - jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() throw ex } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index 46faa88..cc852d4 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,18 +1,17 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.jellyfin.JellyfinApiClient -import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot -import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.input.JellyfinSyncUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.JellyfinCatalogPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.JellyfinIntegrationProperties -import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary import org.springframework.scheduling.annotation.Scheduled @@ -20,18 +19,19 @@ import org.springframework.stereotype.Service import java.time.Duration import java.time.Instant import java.time.LocalDateTime +import java.util.UUID @Service class JellyfinSyncService( private val properties: JellyfinIntegrationProperties, - private val jellyfinApiClient: JellyfinApiClient, + private val jellyfinCatalog: JellyfinCatalogPort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val syncStateRepository: JellyfinSyncStateRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) { + private val businessMetricsService: BusinessMetricsPort, +) : JellyfinSyncUseCase { @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") fun scheduledSync() { if (properties.enabled) { @@ -39,13 +39,26 @@ class JellyfinSyncService( } } - fun syncNow(): JellyfinSyncSummary { + override fun syncNow(): JellyfinSyncSummary { if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) { return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) } + return try { + runSync() + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordJellyfinSyncFailure() + throw ex + } + } + + override fun getSyncStates(): List = syncStateRepository.findAll() + + private fun runSync(): JellyfinSyncSummary { val startedAt = Instant.now() - val remoteUsers = jellyfinApiClient.fetchUsers() + val remoteUsers = jellyfinCatalog.fetchUsers() val localUsersByJellyfinId = userRepository .findAll() @@ -64,7 +77,7 @@ class JellyfinSyncService( return@forEach } - val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id) + val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id) items.forEach { item -> syncItem(localUser.id, item) syncedItems += 1 @@ -94,12 +107,22 @@ class JellyfinSyncService( return summary } - fun getSyncStates(): List = syncStateRepository.findAll() - private fun syncItem( - userId: java.util.UUID, + userId: UUID, item: JellyfinLibraryItemSnapshot, ) { + val savedFilm = upsertFilm(item) + + if (item.isPlayed) { + markFilmViewed( + userId = userId, + filmId = savedFilm.id, + watchedAt = LocalDateTime.now(), + ) + } + } + + private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { val film = filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( title = item.title, @@ -130,24 +153,27 @@ class JellyfinSyncService( jellyfinLibraryId = item.jellyfinLibraryId, ) - val savedFilm = filmRepository.save(film) + return filmRepository.save(film) + } - if (item.isPlayed) { - val watchedAt = LocalDateTime.now() - val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id) - filmLibraryRepository.save( - existingEntry?.copy( - isViewed = true, - watchedAt = watchedAt, - ) ?: FilmLibrary( - id = idGenerator.generateId(), - userId = userId, - filmId = savedFilm.id, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ), - ) - } + private fun markFilmViewed( + userId: UUID, + filmId: UUID, + watchedAt: LocalDateTime, + ) { + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + filmLibraryEntryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibraryEntry( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) } } diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt index 8f9fe04..ee02a92 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -3,7 +3,7 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase import com.project.movienight.application.ports.input.RecommendationOnboardingResult -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -11,7 +11,7 @@ import com.project.movienight.application.ports.output.UserPreferencesRepository import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmRating import com.project.movienight.domain.model.UserPreferences import com.project.movienight.domain.model.UserRecommendationWeights @@ -25,7 +25,7 @@ class RecommendationOnboardingService( private val filmRepository: FilmRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, private val idGenerator: IdGenerator, ) : CompleteRecommendationOnboardingUseCase { @@ -125,12 +125,12 @@ class RecommendationOnboardingService( userId: UUID, filmId: UUID, isViewed: Boolean, - ): FilmLibrary { + ): FilmLibraryEntry { val watchedAt = LocalDateTime.now().takeIf { isViewed } - val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId) - return filmLibraryRepository.save( + val existing = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + return filmLibraryEntryRepository.save( existing?.copy(isViewed = isViewed, watchedAt = watchedAt) - ?: FilmLibrary( + ?: FilmLibraryEntry( id = idGenerator.generateId(), userId = userId, filmId = filmId, diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index e8a7722..da8dd01 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -1,13 +1,13 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AcceptRecommendationCommand import com.project.movienight.application.ports.input.AcceptRecommendationUseCase import com.project.movienight.application.ports.input.GetRecommendationsUseCase import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.input.RejectRecommendationCommand import com.project.movienight.application.ports.input.RejectRecommendationUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -17,7 +17,7 @@ import com.project.movienight.application.ports.output.UserRecommendationWeights import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmRating import com.project.movienight.domain.model.RecommendationEvent import com.project.movienight.domain.model.RecommendationEventType @@ -34,14 +34,14 @@ import kotlin.math.sqrt @Service class RecommendationService( private val filmRepository: FilmRepositoryPort, - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, private val userRepository: UserRepositoryPort, private val recommendationEventRepository: RecommendationEventRepositoryPort, private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) : GetRecommendationsUseCase, AcceptRecommendationUseCase, RejectRecommendationUseCase { @@ -54,7 +54,7 @@ class RecommendationService( val preferences = userPreferencesRepository.findByUserId(query.userId) val ratings = filmRatingRepository.findByUserId(query.userId) - val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId } + val libraryEntries = filmLibraryEntryRepository.findByUserId(query.userId) val libraryFilmIds = libraryEntries.map { it.filmId }.toSet() val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() val films = filmRepository.findAll() @@ -278,7 +278,7 @@ class RecommendationService( private fun buildUserProfile( preferences: UserPreferences?, ratings: List, - libraryEntries: List, + libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, ): SparseVector { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt index de388ce..02fb178 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -1,8 +1,7 @@ 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.input.UserPreferencesUseCase import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort import com.project.movienight.domain.model.UserPreferences import org.springframework.stereotype.Service @@ -10,8 +9,7 @@ import org.springframework.stereotype.Service @Service class UserPreferencesService( private val userPreferencesRepository: UserPreferencesRepositoryPort, -) : UpsertUserPreferencesUseCase, - GetUserPreferencesUseCase { +) : UserPreferencesUseCase { override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = userPreferencesRepository.save( UserPreferences( diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..dc0e1e0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -1,12 +1,8 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateUserCommand -import com.project.movienight.application.ports.input.CreateUserUseCase -import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand -import com.project.movienight.application.ports.input.EditUserUseCase -import com.project.movienight.application.ports.input.GetAllUsersUseCase -import com.project.movienight.application.ports.input.GetUserByIdUseCase +import com.project.movienight.application.ports.input.UserUseCase import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.UserServiceProperties @@ -21,11 +17,7 @@ class UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, private val userConfig: UserServiceProperties, -) : CreateUserUseCase, - EditUserUseCase, - DeleteUserUseCase, - GetUserByIdUseCase, - GetAllUsersUseCase { +) : UserUseCase { override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") @@ -36,7 +28,6 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - library = null, jellyfinUserId = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index a4dc555..efb1f22 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -6,7 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class JellyfinIntegrationProperties( val enabled: Boolean = false, val baseUrl: String = "", - val webUrl: String = "", + val webUrl: String = baseUrl, val apiKey: String = "", val syncIntervalMs: Long = 1_800_000, val requestTimeoutMs: Long = 20_000, diff --git a/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt new file mode 100644 index 0000000..3971bc3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt @@ -0,0 +1,12 @@ +package com.project.movienight.config + +import io.micrometer.core.aop.TimedAspect +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class MetricsConfiguration { + @Bean + fun timedAspect(meterRegistry: MeterRegistry): TimedAspect = TimedAspect(meterRegistry) +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt similarity index 89% rename from src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt rename to src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt index 8d7861c..cc81c1a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt @@ -3,7 +3,7 @@ package com.project.movienight.domain.model import java.time.LocalDateTime import java.util.UUID -data class FilmLibrary( +data class FilmLibraryEntry( val id: UUID, val userId: UUID, val filmId: UUID, diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index 236a698..10bc566 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,7 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, - val library: FilmLibrary?, val preferences: UserPreferences? = null, val jellyfinUserId: String? = null, ) diff --git a/src/main/resources/db/ER.md b/src/main/resources/db/ER.md index 4fecb18..5c0a829 100644 --- a/src/main/resources/db/ER.md +++ b/src/main/resources/db/ER.md @@ -6,12 +6,26 @@ erDiagram UUID id PK VARCHAR name VARCHAR email + VARCHAR provider + VARCHAR provider_id + VARCHAR jellyfin_user_id + TIMESTAMP created_at } films { UUID id PK VARCHAR title TEXT description + VARCHAR content_type + INT release_year + TEXT genres + TEXT cast_members + TEXT directors + DOUBLE imdb_rating + DOUBLE platform_rating + TEXT external_url + VARCHAR jellyfin_item_id + VARCHAR jellyfin_library_id } favorites { @@ -20,8 +34,53 @@ erDiagram UUID film_id FK VARCHAR comment BOOLEAN is_viewed + TIMESTAMP watched_at + } + + user_preferences { + UUID user_id PK,FK + TEXT weighted_genres + TEXT plot_types + TEXT eras + TEXT cast_and_directors + TEXT moods + TEXT content_types + } + + film_ratings { + UUID id PK + UUID user_id FK + UUID film_id FK + INT score + VARCHAR note + TIMESTAMP created_at + TIMESTAMP updated_at + } + + jellyfin_events { + VARCHAR event_id PK + VARCHAR server_id + VARCHAR event_type + TIMESTAMP occurred_at + VARCHAR jellyfin_user_id + VARCHAR jellyfin_item_id + JSON payload + TIMESTAMP created_at + } + + jellyfin_sync_state { + UUID user_id PK,FK + TIMESTAMP last_synced_at + TIMESTAMP last_successful_sync_at + TEXT last_error + INT synced_item_count + TIMESTAMP updated_at } users ||--o{ favorites : has films ||--o{ favorites : linked + users ||--o{ film_ratings : rates + films ||--o{ film_ratings : rated + users ||--|| user_preferences : configures + users ||--|| jellyfin_sync_state : syncs ``` diff --git a/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql new file mode 100644 index 0000000..9415580 --- /dev/null +++ b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS public.ratings; + +ALTER TABLE public.users + DROP COLUMN IF EXISTS jellyfin_id; + +ALTER TABLE public.films + DROP COLUMN IF EXISTS jellyfin_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_jellyfin_user_id ON public.users(jellyfin_user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_films_jellyfin_item_id ON public.films(jellyfin_item_id); +CREATE INDEX IF NOT EXISTS idx_films_jellyfin_library_id ON public.films(jellyfin_library_id); diff --git a/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt new file mode 100644 index 0000000..a963e59 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt @@ -0,0 +1,17 @@ +package com.project.movienight + +import org.junit.jupiter.api.Test +import kotlin.test.assertNotNull + +class ClassLoaderTest { + @Test + fun `can load OAuth2ClientProperties class`() { + val clazz = + Class.forName( + "org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties", + ) + assertNotNull(clazz) + println("Successfully loaded: ${clazz.name}") + println("ClassLoader: ${clazz.classLoader}") + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index 4e65569..859d9a4 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -26,7 +26,6 @@ class UserEntityMappingTest { assertEquals(entity.id, user.id) assertEquals(entity.name, user.name) assertEquals(entity.email, user.email) - assertNull(user.library) assertNull(user.jellyfinUserId) } @@ -37,7 +36,6 @@ class UserEntityMappingTest { id = UUID.randomUUID(), name = "Jane", email = "jane@mail.com", - library = null, ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -56,7 +54,6 @@ class UserEntityMappingTest { id = UUID.randomUUID(), name = "Bob", email = "bob@mail.com", - library = null, ) val entity = user.toEntity() @@ -72,7 +69,6 @@ class UserEntityMappingTest { id = UUID.randomUUID(), name = "Alice", email = "alice@email.com", - library = null, ) val mapped = original.toEntity().toDomain() diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt similarity index 61% rename from src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt rename to src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt index d880f02..fa8c616 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt @@ -1,7 +1,7 @@ package com.project.movienight.adapters.persistence.jdbc import com.project.movienight.domain.model.Film -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.User import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -19,9 +19,9 @@ import java.util.UUID @SpringBootTest @ActiveProfiles("test") -class FilmLibraryRepositoryIntegrationTest { +class FilmLibraryEntryRepositoryIntegrationTest { @Autowired - private lateinit var filmLibraryRepository: FilmLibraryRepository + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepository @Autowired private lateinit var userRepository: UserRepository @@ -62,7 +62,7 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save new film library entry and return saved entry`() { val entry = - FilmLibrary( + FilmLibraryEntry( id = UUID.randomUUID(), userId = testUser.id, filmId = testFilm.id, @@ -70,7 +70,7 @@ class FilmLibraryRepositoryIntegrationTest { isViewed = false, ) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertEquals(entry.id, savedEntry.id) @@ -83,17 +83,17 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should update existing film library entry`() { val entryId = UUID.randomUUID() - val originalEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false) - filmLibraryRepository.save(originalEntry) + val originalEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false) + filmLibraryEntryRepository.save(originalEntry) - val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) - val result = filmLibraryRepository.save(updatedEntry) + val updatedEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) + val result = filmLibraryEntryRepository.save(updatedEntry) assertEquals(entryId, result.id) assertEquals("Уже посмотрел, потрясающе!", result.comment) assertTrue(result.isViewed) - val foundEntry = filmLibraryRepository.findById(entryId) + val foundEntry = filmLibraryEntryRepository.findById(entryId) assertNotNull(foundEntry) assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment) assertTrue(foundEntry?.isViewed ?: false) @@ -101,10 +101,10 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should find film library entry by id`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) + filmLibraryEntryRepository.save(entry) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNotNull(foundEntry) assertEquals(entry.id, foundEntry?.id) @@ -118,22 +118,22 @@ class FilmLibraryRepositoryIntegrationTest { fun `should return null when film library entry not found by id`() { val nonExistentId = UUID.randomUUID() - val foundEntry = filmLibraryRepository.findById(nonExistentId) + val foundEntry = filmLibraryEntryRepository.findById(nonExistentId) assertNull(foundEntry) } @Test fun `should find all film library entries`() { - val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false) - val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true) - val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + val entry1 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false) + val entry2 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true) + val entry3 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false) - filmLibraryRepository.save(entry1) - filmLibraryRepository.save(entry2) - filmLibraryRepository.save(entry3) + filmLibraryEntryRepository.save(entry1) + filmLibraryEntryRepository.save(entry2) + filmLibraryEntryRepository.save(entry3) - val allEntries = filmLibraryRepository.findAll() + val allEntries = filmLibraryEntryRepository.findAll() assertEquals(3, allEntries.size) assertTrue(allEntries.any { it.id == entry1.id }) @@ -143,19 +143,19 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should return empty list when no film library entries exist`() { - val allEntries = filmLibraryRepository.findAll() + val allEntries = filmLibraryEntryRepository.findAll() assertTrue(allEntries.isEmpty()) } @Test fun `should delete film library entry by id`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false) + filmLibraryEntryRepository.save(entry) - filmLibraryRepository.deleteById(entry.id) + filmLibraryEntryRepository.deleteById(entry.id) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNull(foundEntry) } @@ -163,14 +163,14 @@ class FilmLibraryRepositoryIntegrationTest { fun `should not throw exception when deleting non-existent entry`() { val nonExistentId = UUID.randomUUID() - filmLibraryRepository.deleteById(nonExistentId) + filmLibraryEntryRepository.deleteById(nonExistentId) } @Test fun `should save entry with null comment`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertNull(savedEntry.comment) @@ -178,9 +178,9 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save entry with isViewed true`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertTrue(savedEntry.isViewed) @@ -188,9 +188,9 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save entry with isViewed false`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertFalse(savedEntry.isViewed) @@ -198,23 +198,23 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should cascade delete entries when user is deleted`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) + filmLibraryEntryRepository.save(entry) userRepository.deleteById(testUser.id) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNull(foundEntry) } @Test fun `should cascade delete entries when film is deleted`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false) + filmLibraryEntryRepository.save(entry) filmRepository.deleteById(testFilm.id) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNull(foundEntry) } } diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt index 02b588a..7d0c2bd 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -46,7 +47,6 @@ class UserRepositoryIntegrationTest { id = UUID.randomUUID(), name = "John Doe", email = "john@example.com", - library = null, ) val savedUser = userRepository.save(user) @@ -61,10 +61,10 @@ class UserRepositoryIntegrationTest { fun `should update existing user`() { // given val userId = UUID.randomUUID() - val originalUser = User(userId, "John Doe", "john@example.com", null) + val originalUser = User(userId, "John Doe", "john@example.com") userRepository.save(originalUser) - val updatedUser = User(userId, "Jane Doe", "jane@example.com", null) + val updatedUser = User(userId, "Jane Doe", "jane@example.com") val result = userRepository.save(updatedUser) assertEquals(userId, result.id) @@ -80,7 +80,7 @@ class UserRepositoryIntegrationTest { @Test fun `should find user by id`() { // given - val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + val user = User(UUID.randomUUID(), "John Doe", "john@example.com") userRepository.save(user) // when @@ -108,9 +108,9 @@ class UserRepositoryIntegrationTest { @Test fun `should find all users`() { // given - val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com", null) - val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null) - val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null) + val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com") + val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com") + val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com") userRepository.save(user1) userRepository.save(user2) @@ -138,7 +138,7 @@ class UserRepositoryIntegrationTest { @Test fun `should delete user by id`() { // given - val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + val user = User(UUID.randomUUID(), "John Doe", "john@example.com") userRepository.save(user) // when @@ -157,4 +157,41 @@ class UserRepositoryIntegrationTest { // when & then (no exception should be thrown) userRepository.deleteById(nonExistentId) } + + @Test + fun `should create OAuth user with provider identity`() { + val user = User(UUID.randomUUID(), "OAuth User", "oauth@example.com") + + val savedUser = userRepository.createOAuthUser(user, AuthProvider.GOOGLE, "google-123") + + assertEquals(user.id, savedUser.id) + assertEquals(user.email, savedUser.email) + + val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123") + assertNotNull(foundByProvider) + assertEquals(user.id, foundByProvider?.id) + } + + @Test + fun `should link OAuth account to existing user`() { + val user = userRepository.save(User(UUID.randomUUID(), "Link User", "link@example.com")) + + val linkedUser = userRepository.linkOAuthAccount(user.id, AuthProvider.YANDEX, "yandex-456") + + assertEquals(user.id, linkedUser.id) + val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.YANDEX, "yandex-456") + assertNotNull(foundByProvider) + assertEquals(user.id, foundByProvider?.id) + } + + @Test + fun `find by email should include jellyfin user id`() { + val user = userRepository.save(User(UUID.randomUUID(), "Jellyfin User", "jellyfin@example.com")) + jdbcTemplate.update("UPDATE users SET jellyfin_user_id = ? WHERE id = ?", "jellyfin-789", user.id) + + val foundUser = userRepository.findByEmail(user.email) + + assertNotNull(foundUser) + assertEquals("jellyfin-789", foundUser?.jellyfinUserId) + } } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt index 033cccd..04a794d 100644 --- a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -1,11 +1,6 @@ package com.project.movienight.adapters.web -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase -import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.application.ports.input.FilmUseCase import com.project.movienight.domain.model.Film import io.mockk.every import io.mockk.mockk @@ -19,20 +14,15 @@ import java.util.UUID class FilmControllerSearchTest { private lateinit var mockMvc: MockMvc - private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + private lateinit var filmUseCase: FilmUseCase @BeforeEach fun setup() { - searchFilmByTitleUseCase = mockk() + filmUseCase = mockk() val controller = FilmController( - createFilmUseCase = mockk(), - editFilmUseCase = mockk(), - deleteFilmUseCase = mockk(), - getFilmByIdUseCase = mockk(), - getAllFilmsUseCase = mockk(), - searchFilmByTitleUseCase = searchFilmByTitleUseCase, + filmUseCase = filmUseCase, ) mockMvc = MockMvcBuilders.standaloneSetup(controller).build() @@ -43,7 +33,7 @@ class FilmControllerSearchTest { val title = "Inception" val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") - every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + every { filmUseCase.searchByTitle(title) } returns film mockMvc .get("/api/films/search") { @@ -55,14 +45,14 @@ class FilmControllerSearchTest { jsonPath("$.description") { value("A dream heist") } } - verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + verify(exactly = 1) { filmUseCase.searchByTitle(title) } } @Test fun `search returns 404 when title is not found`() { val title = "Unknown Title" - every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + every { filmUseCase.searchByTitle(title) } returns null mockMvc .get("/api/films/search") { @@ -72,6 +62,6 @@ class FilmControllerSearchTest { content { string("") } } - verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + verify(exactly = 1) { filmUseCase.searchByTitle(title) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 7145507..beb7ec6 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -1,15 +1,15 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +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.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -22,83 +22,52 @@ import org.junit.jupiter.api.assertThrows import java.util.UUID class FilmLibraryServiceTest { - private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort + private lateinit var filmRepository: FilmRepositoryPort private lateinit var idGenerator: IdGenerator - private lateinit var businessMetricsService: BusinessMetricsService + private lateinit var businessMetricsService: BusinessMetricsPort private lateinit var filmLibraryService: FilmLibraryService @BeforeEach fun setup() { - filmLibraryRepository = mockk() + filmLibraryEntryRepository = mockk() + filmRepository = mockk() idGenerator = mockk() businessMetricsService = mockk(relaxed = true) - filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService) - } - - @Test - fun `should throw EntityNotFoundException when creating library for user with no entries`() { - val userId = UUID.randomUUID() - val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - - every { filmLibraryRepository.findAll() } returns emptyList() - - assertThrows { - filmLibraryService.create(command) - } - - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } - verify(exactly = 0) { filmLibraryRepository.save(any()) } - } - - @Test - fun `should return existing library when user already has one`() { - val userId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = UUID.randomUUID(), - comment = "Existing Library", - isViewed = false, + filmLibraryService = + FilmLibraryService( + filmLibraryEntryRepository, + filmRepository, + idGenerator, + businessMetricsService, ) - val command = CreateFilmLibraryCommand(userId = userId, name = "New Library") - - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - - val result = filmLibraryService.create(command) - - assertEquals(existingLibrary, result) - - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } - verify(exactly = 0) { filmLibraryRepository.save(any()) } } @Test - fun `should add film to new library when user has no library`() { + fun `should add film as new library entry`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() - val libraryId = UUID.randomUUID() + val entryId = UUID.randomUUID() val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) - val expectedLibrary = - FilmLibrary( - id = libraryId, + val expectedEntry = + FilmLibraryEntry( + id = entryId, userId = userId, filmId = filmId, comment = null, isViewed = false, ) - every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returns libraryId + every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description") + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null + every { idGenerator.generateId() } returns entryId every { - filmLibraryRepository.save( + filmLibraryEntryRepository.save( match { it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false }, ) - } returns expectedLibrary + } returns expectedEntry val result = filmLibraryService.addFilm(command) @@ -106,62 +75,46 @@ class FilmLibraryServiceTest { assertEquals(filmId, result.filmId) assertEquals(userId, result.userId) - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 1) { filmLibraryEntryRepository.save(any()) } } @Test - fun `should add film as a new library entry when another film already exists`() { + fun `should reset viewed state when adding existing entry`() { val userId = UUID.randomUUID() - val oldFilmId = UUID.randomUUID() - val newFilmId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( + val filmId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( id = UUID.randomUUID(), userId = userId, - filmId = oldFilmId, + filmId = filmId, comment = "My Library", isViewed = true, ) - val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) - val createdLibrary = - FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = newFilmId, - comment = null, - isViewed = false, - ) + val updatedEntry = existingEntry.copy(isViewed = false, watchedAt = null) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - every { idGenerator.generateId() } returns createdLibrary.id - every { - filmLibraryRepository.save( - match { - it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false - }, - ) - } returns createdLibrary + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry + every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description") + every { filmLibraryEntryRepository.save(updatedEntry) } returns updatedEntry - val result = filmLibraryService.addFilm(command) + val result = filmLibraryService.addFilm(AddFilmToLibraryCommand(userId = userId, filmId = filmId)) - assertEquals(newFilmId, result.filmId) - assertEquals(false, result.isViewed) + assertEquals(updatedEntry, result) - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryEntryRepository.save(updatedEntry) } } @Test fun `should remove film from library successfully`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() - val libraryId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = libraryId, + val entryId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = entryId, userId = userId, filmId = filmId, comment = "My Library", @@ -169,122 +122,70 @@ class FilmLibraryServiceTest { ) val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - justRun { filmLibraryRepository.deleteById(libraryId) } + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry + justRun { filmLibraryEntryRepository.deleteById(entryId) } val result = filmLibraryService.removeFilm(command) - assertEquals(existingLibrary, result) + assertEquals(existingEntry, result) - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 1) { filmLibraryEntryRepository.deleteById(entryId) } } @Test - fun `should throw EntityNotFoundException when removing film from non-existent library`() { + fun `should throw EntityNotFoundException when removing non-existent entry`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) - every { filmLibraryRepository.findAll() } returns emptyList() + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null assertThrows { filmLibraryService.removeFilm(command) } - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) } } @Test - fun `should throw DomainException when removing film that is not in library`() { - val userId = UUID.randomUUID() - val libraryFilmId = UUID.randomUUID() - val differentFilmId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = libraryFilmId, - comment = "My Library", - isViewed = false, - ) - val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId) - - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - - assertThrows { - filmLibraryService.removeFilm(command) - } - - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } - } - - @Test - fun `should throw EntityNotFoundException when libraryId does not match`() { + fun `should throw DomainException when entry id belongs to another film`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() - val actualLibraryId = UUID.randomUUID() - val wrongLibraryId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = actualLibraryId, - userId = userId, - filmId = filmId, - comment = "My Library", - isViewed = false, - ) - val command = - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - libraryId = wrongLibraryId, - ) - - every { filmLibraryRepository.findById(wrongLibraryId) } returns null - - assertThrows { - filmLibraryService.removeFilm(command) - } - - verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) } - verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } - } - - @Test - fun `should get library successfully`() { - val userId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = UUID.randomUUID(), + val entryId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = entryId, userId = userId, filmId = UUID.randomUUID(), comment = "My Library", isViewed = false, ) - val query = GetFilmLibraryQuery(userId = userId) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId, entryId = entryId) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryEntryRepository.findById(entryId) } returns existingEntry - val result = filmLibraryService.getLibrary(query) + assertThrows { + filmLibraryService.removeFilm(command) + } - assertEquals(existingLibrary, result) - - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findById(entryId) } + verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) } } @Test - fun `should throw EntityNotFoundException when getting non-existent library`() { + fun `should list entries by user`() { val userId = UUID.randomUUID() - val query = GetFilmLibraryQuery(userId = userId) + val entries = + listOf( + FilmLibraryEntry(UUID.randomUUID(), userId, UUID.randomUUID(), null, false), + ) - every { filmLibraryRepository.findAll() } returns emptyList() + every { filmLibraryEntryRepository.findByUserId(userId) } returns entries - assertThrows { - filmLibraryService.getLibrary(query) - } + assertEquals(entries, filmLibraryService.list(userId)) - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index e0f96be..5c48c2c 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -2,13 +2,13 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateFilmCommand import com.project.movienight.application.ports.input.EditFilmCommand +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film -import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -24,7 +24,7 @@ class FilmServiceTest { private lateinit var filmRepository: FilmRepositoryPort private lateinit var idGenerator: IdGenerator private lateinit var filmConfig: FilmServiceProperties - private lateinit var meterRegistry: SimpleMeterRegistry + private lateinit var businessMetricsService: BusinessMetricsPort private lateinit var filmService: FilmService @BeforeEach @@ -32,8 +32,8 @@ class FilmServiceTest { filmRepository = mockk() idGenerator = mockk() filmConfig = mockk() - meterRegistry = SimpleMeterRegistry() - filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry) + businessMetricsService = mockk(relaxed = true) + filmService = FilmService(filmRepository, idGenerator, filmConfig, businessMetricsService) } @Test diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt index c54a909..ce94f2e 100644 --- a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -37,7 +37,7 @@ class UserServiceTest { fun `should create user successfully`() { val command = CreateUserCommand(name = "John Doe", email = "john@example.com") val userId = UUID.randomUUID() - val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com") every { userConfig.isBlocked("John Doe") } returns false every { idGenerator.generateId() } returns userId @@ -74,8 +74,8 @@ class UserServiceTest { fun `should edit user successfully`() { val userId = UUID.randomUUID() val command = EditUserCommand(name = "Jane Doe") - val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) - val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null) + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com") + val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com") every { userConfig.isBlocked("Jane Doe") } returns false every { userRepository.findById(userId) } returns existingUser @@ -128,7 +128,7 @@ class UserServiceTest { @Test fun `should delete user successfully`() { val userId = UUID.randomUUID() - val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com") every { userRepository.findById(userId) } returns existingUser justRun { userRepository.deleteById(userId) } diff --git a/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt new file mode 100644 index 0000000..34ae9c7 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt @@ -0,0 +1,29 @@ +package com.project.movienight.config + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@TestConfiguration +@EnableWebSecurity +class TestSecurityConfiguration { + @Bean + @Primary + fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .authorizeHttpRequests { auth -> + auth.anyRequest().permitAll() + }.csrf { csrf -> + csrf.disable() + }.headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + + return http.build() + } +} -- 2.54.0 From df8d3ee404c154104201cf9a7a32f6165e94962b Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:07:03 +0300 Subject: [PATCH 3/7] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/application/services/FilmLibraryService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index b7f24dd..bdc184e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -62,7 +62,9 @@ class FilmLibraryService( ) ) businessMetricsService.recordLibraryEvent() - log.info("Film re-added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) + log.info( + "Film re-added to library: userId={}, filmId={}, entryId={}", + command.userId, command.filmId, saved.id) return saved } @@ -101,7 +103,9 @@ class FilmLibraryService( filmLibraryRepository.deleteById(existingLibrary.id) businessMetricsService.recordLibraryEvent() - log.info("Film removed from library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, existingLibrary.id) + log.info( + "Film removed from library: userId={}, filmId={}, entryId={}", + command.userId, command.filmId, existingLibrary.id) return existingLibrary } -- 2.54.0 From 9e9e2244abbfdb7190c9663eeac2f8d81579b5d3 Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:38:28 +0300 Subject: [PATCH 4/7] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/FilmLibraryService.kt | 360 ++++++++++-------- .../application/services/FilmService.kt | 152 ++++---- .../application/services/UserService.kt | 45 ++- 3 files changed, 305 insertions(+), 252 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index bdc184e..07d8d5a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,168 +1,192 @@ -package com.project.movienight.application.services - -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase -import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -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.FilmLibrary -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class FilmLibraryService( - private val filmLibraryRepository: FilmLibraryRepositoryPort, - private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : CreateFilmLibraryUseCase, - AddFilmToLibraryUseCase, - MarkFilmViewedUseCase, - RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase, - ListFilmLibraryEntriesUseCase { - - private val log = LoggerFactory.getLogger(javaClass) - - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - log.info("Creating film library for user: {}", command.userId) - log.debug("Create library request: userId={}, name={}", command.userId, command.name) - - val existing = findByUserId(command.userId) - if (existing != null) { - log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) - return existing - } - - log.warn("Library not found for user {}, cannot create", command.userId) - throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) - } - - override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) - - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - if (existingEntry != null) { - log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) - val saved = filmLibraryRepository.save( - existingEntry.copy( - isViewed = false, - watchedAt = null, - ) - ) - businessMetricsService.recordLibraryEvent() - log.info( - "Film re-added to library: userId={}, filmId={}, entryId={}", - command.userId, command.filmId, saved.id) - return saved - } - - val saved = filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = false, - watchedAt = null, - ) - ) - businessMetricsService.recordLibraryEvent() - log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) - return saved - } - - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) - - val existingLibrary = if (command.libraryId != null) { - log.debug("Looking up by libraryId: {}", command.libraryId) - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } else { - log.debug("Looking up by userId and filmId") - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) - } - - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) - throw DomainException("Film with id ${command.filmId} not found in user's library") - } - - filmLibraryRepository.deleteById(existingLibrary.id) - businessMetricsService.recordLibraryEvent() - log.info( - "Film removed from library: userId={}, filmId={}, entryId={}", - command.userId, command.filmId, existingLibrary.id) - return existingLibrary - } - - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) - - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - - log.debug("Marking as viewed at: {}", watchedAt) - - val saved = if (existingEntry == null) { - log.debug("Film not in library, creating new entry as viewed") - filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ) - ) - } else { - log.debug("Updating existing entry: entryId={}, was viewed={}", existingEntry.id, existingEntry.isViewed) - filmLibraryRepository.save( - existingEntry.copy( - isViewed = true, - watchedAt = watchedAt, - ) - ) - } - businessMetricsService.recordLibraryEvent() - log.info("Film marked as viewed: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) - return saved - } - - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { - log.debug("Getting library for user: {}", query.userId) - val library = findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) - return library - } - - override fun list(userId: UUID): List { - log.debug("Listing all library entries for user: {}", userId) - val entries = filmLibraryRepository.findAll().filter { it.userId == userId } - log.info("User {} has {} films in library", userId, entries.size) - return entries - } - - private fun findByUserId(userId: UUID): FilmLibrary? { - return filmLibraryRepository.findAll().firstOrNull { it.userId == userId } - } - - private fun findByUserAndFilmId(userId: UUID, filmId: UUID): FilmLibrary? { - return filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } - } -} +//package com.project.movienight.application.services +// +//import com.project.movienight.adapters.metrics.BusinessMetricsService +//import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +//import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase +//import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +//import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +//import com.project.movienight.application.ports.input.GetFilmLibraryQuery +//import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +//import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +//import com.project.movienight.application.ports.input.MarkFilmViewedCommand +//import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +//import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +//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.FilmLibrary +//import org.slf4j.LoggerFactory +//import org.springframework.stereotype.Service +//import java.util.UUID +// +//@Service +//class FilmLibraryService( +// private val filmLibraryRepository: FilmLibraryRepositoryPort, +// private val idGenerator: IdGenerator, +// private val businessMetricsService: BusinessMetricsService, +//) : CreateFilmLibraryUseCase, +// AddFilmToLibraryUseCase, +// MarkFilmViewedUseCase, +// RemoveFilmFromLibraryUseCase, +// GetFilmLibraryUseCase, +// ListFilmLibraryEntriesUseCase { +// private val log = LoggerFactory.getLogger(javaClass) +// +// override fun create(command: CreateFilmLibraryCommand): FilmLibrary { +// log.info("Creating film library for user: {}", command.userId) +// log.debug("Create library request: userId={}, name={}", command.userId, command.name) +// +// val existing = findByUserId(command.userId) +// if (existing != null) { +// log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) +// return existing +// } +// +// log.warn("Library not found for user {}, cannot create", command.userId) +// throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) +// } +// +// override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { +// log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) +// if (existingEntry != null) { +// log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) +// val saved = +// filmLibraryRepository.save( +// existingEntry.copy( +// isViewed = false, +// watchedAt = null, +// ), +// ) +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film re-added to library: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// saved.id, +// ) +// return saved +// } +// +// val saved = +// filmLibraryRepository.save( +// FilmLibrary( +// id = idGenerator.generateId(), +// userId = command.userId, +// filmId = command.filmId, +// comment = null, +// isViewed = false, +// watchedAt = null, +// ), +// ) +// businessMetricsService.recordLibraryEvent() +// log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) +// return saved +// } +// +// override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { +// log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingLibrary = +// if (command.libraryId != null) { +// log.debug("Looking up by libraryId: {}", command.libraryId) +// filmLibraryRepository.findById(command.libraryId) +// ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) +// } else { +// log.debug("Looking up by userId and filmId") +// findByUserAndFilmId(command.userId, command.filmId) +// ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) +// } +// +// if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { +// log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) +// throw DomainException("Film with id ${command.filmId} not found in user's library") +// } +// +// filmLibraryRepository.deleteById(existingLibrary.id) +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film removed from library: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// existingLibrary.id, +// ) +// return existingLibrary +// } +// +// override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { +// log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) +// val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() +// +// log.debug("Marking as viewed at: {}", watchedAt) +// +// val saved = +// if (existingEntry == null) { +// log.debug("Film not in library, creating new entry as viewed") +// filmLibraryRepository.save( +// FilmLibrary( +// id = idGenerator.generateId(), +// userId = command.userId, +// filmId = command.filmId, +// comment = null, +// isViewed = true, +// watchedAt = watchedAt, +// ), +// ) +// } else { +// log.debug( +// "Updating existing entry: entryId={}, was viewed={}", +// existingEntry.id, +// existingEntry.isViewed, +// ) +// filmLibraryRepository.save( +// existingEntry.copy( +// isViewed = true, +// watchedAt = watchedAt, +// ), +// ) +// } +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film marked as viewed: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// saved.id, +// ) +// return saved +// } +// +// override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { +// log.debug("Getting library for user: {}", query.userId) +// val library = +// findByUserId(query.userId) +// ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) +// log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) +// return library +// } +// +// override fun list(userId: UUID): List { +// log.debug("Listing all library entries for user: {}", userId) +// val entries = filmLibraryRepository.findAll().filter { it.userId == userId } +// log.info("User {} has {} films in library", userId, entries.size) +// return entries +// } +// +// private fun findByUserId(userId: UUID): FilmLibrary? = +// filmLibraryRepository.findAll().firstOrNull { +// it.userId == userId +// } +// +// private fun findByUserAndFilmId( +// userId: UUID, +// filmId: UUID, +// ): FilmLibrary? = +// filmLibraryRepository.findAll().firstOrNull { +// it.userId == userId && it.filmId == filmId +// } +//} 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 c1efda4..93de089 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -33,13 +33,17 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) - log.debug("Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", - command.title, command.description.length, command.genres, command.releaseYear) + log.debug( + "Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", + command.title, + command.description.length, + command.genres, + command.releaseYear, + ) val sample = Timer.start(meterRegistry) @@ -55,21 +59,22 @@ 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, - ) + 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() @@ -80,10 +85,18 @@ class FilmService( } } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { log.info("Editing film: id={}", id) - log.debug("Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", - id, command.title, command.description.length, command.genres) + log.debug( + "Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", + id, + command.title, + command.description.length, + command.genres, + ) val sample = Timer.start(meterRegistry) @@ -108,20 +121,21 @@ class FilmService( log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) - 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, + ) val saved = filmRepository.save(film) filmEditedCounter.increment() @@ -157,8 +171,9 @@ class FilmService( override fun getById(id: UUID): Film { log.debug("Fetching film by id: {}", id) - val film = filmRepository.findById(id) - ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) log.debug("Film found: id={}, title='{}'", film.id, film.title) return film } @@ -181,38 +196,45 @@ class FilmService( return film } - private val filmCreatedCounter = Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) + private val filmCreatedCounter = + Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) - private val filmEditedCounter = Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) + private val filmEditedCounter = + Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) - private val filmDeletedCounter = Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) + private val filmDeletedCounter = + Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) - private val filmBlockedCounter = Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) + private val filmBlockedCounter = + Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) - private val createFilmTimer = Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) + private val createFilmTimer = + Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) - private val editFilmTimer = Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) + private val editFilmTimer = + Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) - private val deleteFilmTimer = Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) + private val deleteFilmTimer = + Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 8926742..ba8fd76 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -27,7 +27,6 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateUserCommand): User { @@ -39,20 +38,24 @@ class UserService( throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - jellyfinUserId = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + jellyfinUserId = null, + ) val saved = userRepository.save(user) log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) return saved } - override fun edit(id: UUID, command: EditUserCommand): User { + override fun edit( + id: UUID, + command: EditUserCommand, + ): User { log.info("Editing user: id={}", id) log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) @@ -61,15 +64,17 @@ class UserService( throw BlockedValueException(target = "User", field = "name") } - var user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + var user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) - user = user.copy( - name = command.name, - jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, - ) + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) val saved = userRepository.save(user) log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) @@ -80,8 +85,9 @@ class UserService( log.info("Deleting user: id={}", id) log.debug("Delete user request: id={}", id) - val user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + val user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) @@ -91,8 +97,9 @@ class UserService( override fun getById(id: UUID): User { log.debug("Fetching user by id: {}", id) - val user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + val user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) return user } -- 2.54.0 From 6d35b2f5a61568533e71122051b625b5feb3c35d Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:41:24 +0300 Subject: [PATCH 5/7] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/FilmLibraryService.kt | 389 +++++++++--------- 1 file changed, 197 insertions(+), 192 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 07d8d5a..381f3bf 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,192 +1,197 @@ -//package com.project.movienight.application.services -// -//import com.project.movienight.adapters.metrics.BusinessMetricsService -//import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -//import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -//import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -//import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -//import com.project.movienight.application.ports.input.GetFilmLibraryQuery -//import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -//import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase -//import com.project.movienight.application.ports.input.MarkFilmViewedCommand -//import com.project.movienight.application.ports.input.MarkFilmViewedUseCase -//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -//import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -//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.FilmLibrary -//import org.slf4j.LoggerFactory -//import org.springframework.stereotype.Service -//import java.util.UUID -// -//@Service -//class FilmLibraryService( -// private val filmLibraryRepository: FilmLibraryRepositoryPort, -// private val idGenerator: IdGenerator, -// private val businessMetricsService: BusinessMetricsService, -//) : CreateFilmLibraryUseCase, -// AddFilmToLibraryUseCase, -// MarkFilmViewedUseCase, -// RemoveFilmFromLibraryUseCase, -// GetFilmLibraryUseCase, -// ListFilmLibraryEntriesUseCase { -// private val log = LoggerFactory.getLogger(javaClass) -// -// override fun create(command: CreateFilmLibraryCommand): FilmLibrary { -// log.info("Creating film library for user: {}", command.userId) -// log.debug("Create library request: userId={}, name={}", command.userId, command.name) -// -// val existing = findByUserId(command.userId) -// if (existing != null) { -// log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) -// return existing -// } -// -// log.warn("Library not found for user {}, cannot create", command.userId) -// throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) -// } -// -// override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { -// log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) -// if (existingEntry != null) { -// log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) -// val saved = -// filmLibraryRepository.save( -// existingEntry.copy( -// isViewed = false, -// watchedAt = null, -// ), -// ) -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film re-added to library: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// saved.id, -// ) -// return saved -// } -// -// val saved = -// filmLibraryRepository.save( -// FilmLibrary( -// id = idGenerator.generateId(), -// userId = command.userId, -// filmId = command.filmId, -// comment = null, -// isViewed = false, -// watchedAt = null, -// ), -// ) -// businessMetricsService.recordLibraryEvent() -// log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) -// return saved -// } -// -// override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { -// log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingLibrary = -// if (command.libraryId != null) { -// log.debug("Looking up by libraryId: {}", command.libraryId) -// filmLibraryRepository.findById(command.libraryId) -// ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) -// } else { -// log.debug("Looking up by userId and filmId") -// findByUserAndFilmId(command.userId, command.filmId) -// ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) -// } -// -// if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { -// log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) -// throw DomainException("Film with id ${command.filmId} not found in user's library") -// } -// -// filmLibraryRepository.deleteById(existingLibrary.id) -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film removed from library: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// existingLibrary.id, -// ) -// return existingLibrary -// } -// -// override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { -// log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) -// val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() -// -// log.debug("Marking as viewed at: {}", watchedAt) -// -// val saved = -// if (existingEntry == null) { -// log.debug("Film not in library, creating new entry as viewed") -// filmLibraryRepository.save( -// FilmLibrary( -// id = idGenerator.generateId(), -// userId = command.userId, -// filmId = command.filmId, -// comment = null, -// isViewed = true, -// watchedAt = watchedAt, -// ), -// ) -// } else { -// log.debug( -// "Updating existing entry: entryId={}, was viewed={}", -// existingEntry.id, -// existingEntry.isViewed, -// ) -// filmLibraryRepository.save( -// existingEntry.copy( -// isViewed = true, -// watchedAt = watchedAt, -// ), -// ) -// } -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film marked as viewed: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// saved.id, -// ) -// return saved -// } -// -// override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { -// log.debug("Getting library for user: {}", query.userId) -// val library = -// findByUserId(query.userId) -// ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) -// log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) -// return library -// } -// -// override fun list(userId: UUID): List { -// log.debug("Listing all library entries for user: {}", userId) -// val entries = filmLibraryRepository.findAll().filter { it.userId == userId } -// log.info("User {} has {} films in library", userId, entries.size) -// return entries -// } -// -// private fun findByUserId(userId: UUID): FilmLibrary? = -// filmLibraryRepository.findAll().firstOrNull { -// it.userId == userId -// } -// -// private fun findByUserAndFilmId( -// userId: UUID, -// filmId: UUID, -// ): FilmLibrary? = -// filmLibraryRepository.findAll().firstOrNull { -// it.userId == userId && it.filmId == filmId -// } -//} +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase +import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +import com.project.movienight.application.ports.input.GetFilmLibraryQuery +import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +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.FilmLibrary +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class FilmLibraryService( + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) : CreateFilmLibraryUseCase, + AddFilmToLibraryUseCase, + MarkFilmViewedUseCase, + RemoveFilmFromLibraryUseCase, + GetFilmLibraryUseCase, + ListFilmLibraryEntriesUseCase { + private val log = LoggerFactory.getLogger(javaClass) + + override fun create(command: CreateFilmLibraryCommand): FilmLibrary { + log.info("Creating film library for user: {}", command.userId) + log.debug("Create library request: userId={}, name={}", command.userId, command.name) + + val existing = findByUserId(command.userId) + if (existing != null) { + log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) + return existing + } + + log.warn("Library not found for user {}, cannot create", command.userId) + throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + } + + override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { + log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) + val saved = + filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + log.info( + "Film re-added to library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + val saved = + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + log.info( + "Film added to library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { + log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) + + val existingLibrary = + if (command.libraryId != null) { + log.debug("Looking up by libraryId: {}", command.libraryId) + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + log.debug("Looking up by userId and filmId") + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } + + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) + throw DomainException("Film with id ${command.filmId} not found in user's library") + } + + filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() + log.info( + "Film removed from library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + existingLibrary.id, + ) + return existingLibrary + } + + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + log.debug("Marking as viewed at: {}", watchedAt) + + val saved = + if (existingEntry == null) { + log.debug("Film not in library, creating new entry as viewed") + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } else { + log.debug( + "Updating existing entry: entryId={}, was viewed={}", + existingEntry.id, + existingEntry.isViewed, + ) + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + businessMetricsService.recordLibraryEvent() + log.info( + "Film marked as viewed: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { + log.debug("Getting library for user: {}", query.userId) + val library = + findByUserId(query.userId) + ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) + return library + } + + override fun list(userId: UUID): List { + log.debug("Listing all library entries for user: {}", userId) + val entries = filmLibraryRepository.findAll().filter { it.userId == userId } + log.info("User {} has {} films in library", userId, entries.size) + return entries + } + + private fun findByUserId(userId: UUID): FilmLibrary? = + filmLibraryRepository.findAll().firstOrNull { + it.userId == userId + } + + private fun findByUserAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? = + filmLibraryRepository.findAll().firstOrNull { + it.userId == userId && it.filmId == filmId + } +} -- 2.54.0 From 573fa12e6299c860054046aa5c513517c767886e Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 11:22:10 +0300 Subject: [PATCH 6/7] feat(misc): added script to generate library for Jellyfin --- scripts/generate_library.py | 182 ++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 scripts/generate_library.py diff --git a/scripts/generate_library.py b/scripts/generate_library.py new file mode 100644 index 0000000..1f84920 --- /dev/null +++ b/scripts/generate_library.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import os +import sys +import urllib.request +import argparse +import logging +import re +import random + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat" +DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies" +DEFAULT_COUNT = 1000 + +def download_dataset(url): + logger.info(f"Downloading dataset from {url}...") + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req) as response: + data = response.read().decode('utf-8') + logger.info("Dataset downloaded successfully.") + return data.splitlines() + except Exception as e: + logger.error(f"Failed to download dataset: {e}") + sys.exit(1) + +def parse_movies(lines): + """ + Parse the movies.dat file. + Format: IMDbID::Title (Year)::Genres + Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short + """ + movies = [] + # Regex to extract Title and Year from "Title (Year)" + title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$') + + for line in lines: + line = line.strip() + if not line: + continue + + parts = line.split('::') + if len(parts) >= 2: + imdb_id_raw = parts[0] + title_year_raw = parts[1] + + # Format IMDb ID to ttXXXXXXX + if imdb_id_raw.isdigit(): + imdb_id = f"tt{imdb_id_raw.zfill(7)}" + else: + continue + + match = title_year_pattern.match(title_year_raw) + if match: + title = match.group(1).strip() + year = match.group(2) + + # Clean title for filesystem (remove invalid characters) + safe_title = re.sub(r'[\\/*?:"<>|]', "", title) + safe_title = safe_title.strip() + + if safe_title: + movies.append({ + 'imdb_id': imdb_id, + 'title': safe_title, + 'year': year + }) + + logger.info(f"Parsed {len(movies)} valid movies from dataset.") + return movies + +def create_dummy_video(filepath): + """Create a minimal valid dummy video file (mp4).""" + try: + mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd" + with open(filepath, 'wb') as f: + f.write(mp4_header) + return True + except Exception as e: + logger.error(f"Failed to create dummy video {filepath}: {e}") + return False + +def generate_library(movies, output_dir, count): + """Generate the folder structure and dummy files.""" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + logger.info(f"Created output directory: {output_dir}") + + generated_imdb_ids = set() + created_count = 0 + skipped_count = 0 + failed_count = 0 + + logger.info(f"Starting generation of up to {count} movies...") + + # Shuffle to get diverse movies + random.shuffle(movies) + + for movie in movies: + if created_count >= count: + break + + if movie['imdb_id'] in generated_imdb_ids: + skipped_count += 1 + continue + + # Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567] + folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]" + folder_path = os.path.join(output_dir, folder_name) + + file_name = f"{folder_name}.mp4" + file_path = os.path.join(folder_path, file_name) + + if os.path.exists(file_path): + skipped_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + continue + + try: + os.makedirs(folder_path, exist_ok=True) + if create_dummy_video(file_path): + created_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + else: + failed_count += 1 + except Exception as e: + logger.error(f"Error processing {folder_name}: {e}") + failed_count += 1 + + logger.info("--- Generation Summary ---") + logger.info(f"Target count: {count}") + logger.info(f"Successfully created: {created_count}") + logger.info(f"Skipped (already exists or duplicate): {skipped_count}") + logger.info(f"Failed: {failed_count}") + + return created_count > 0 + +def main(): + parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.") + parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR, + help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})") + parser.add_argument("--count", type=int, default=DEFAULT_COUNT, + help=f"Number of movies to generate (default: {DEFAULT_COUNT})") + parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL, + help="URL to the movies.dat file") + + args = parser.parse_args() + + lines = download_dataset(args.dataset_url) + if not lines: + logger.error("No dataset lines to process.") + sys.exit(1) + + movies = parse_movies(lines) + + if not movies: + logger.error("No movies parsed from the dataset.") + sys.exit(1) + + if len(movies) < args.count: + logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.") + args.count = len(movies) + + success = generate_library(movies, args.output_dir, args.count) + + if success: + logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.") + else: + logger.error("Library generation failed.") + sys.exit(1) + +if __name__ == "__main__": + main() -- 2.54.0 From ee530d666e01ec3494301f000ee38b8c2d71acea Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 16:55:20 +0300 Subject: [PATCH 7/7] fix(): merge conflicts --- .../services/FilmLibraryService.kt | 83 ++----------------- .../application/services/FilmService.kt | 80 +++++------------- .../application/services/UserService.kt | 54 ++---------- 3 files changed, 37 insertions(+), 180 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 381f3bf..2924922 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -17,7 +17,6 @@ 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.FilmLibrary -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -32,28 +31,14 @@ class FilmLibraryService( RemoveFilmFromLibraryUseCase, GetFilmLibraryUseCase, ListFilmLibraryEntriesUseCase { - private val log = LoggerFactory.getLogger(javaClass) - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - log.info("Creating film library for user: {}", command.userId) - log.debug("Create library request: userId={}, name={}", command.userId, command.name) - - val existing = findByUserId(command.userId) - if (existing != null) { - log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) - return existing - } - - log.warn("Library not found for user {}, cannot create", command.userId) + findByUserId(command.userId)?.let { return it } throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) if (existingEntry != null) { - log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) val saved = filmLibraryRepository.save( existingEntry.copy( @@ -62,12 +47,6 @@ class FilmLibraryService( ), ) businessMetricsService.recordLibraryEvent() - log.info( - "Film re-added to library: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - saved.id, - ) return saved } @@ -83,56 +62,34 @@ class FilmLibraryService( ), ) businessMetricsService.recordLibraryEvent() - log.info( - "Film added to library: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - saved.id, - ) return saved } override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) - val existingLibrary = if (command.libraryId != null) { - log.debug("Looking up by libraryId: {}", command.libraryId) filmLibraryRepository.findById(command.libraryId) ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) } else { - log.debug("Looking up by userId and filmId") findByUserAndFilmId(command.userId, command.filmId) ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) } if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) throw DomainException("Film with id ${command.filmId} not found in user's library") } filmLibraryRepository.deleteById(existingLibrary.id) businessMetricsService.recordLibraryEvent() - log.info( - "Film removed from library: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - existingLibrary.id, - ) return existingLibrary } override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - log.debug("Marking as viewed at: {}", watchedAt) - val saved = if (existingEntry == null) { - log.debug("Film not in library, creating new entry as viewed") filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), @@ -144,11 +101,6 @@ class FilmLibraryService( ), ) } else { - log.debug( - "Updating existing entry: entryId={}, was viewed={}", - existingEntry.id, - existingEntry.isViewed, - ) filmLibraryRepository.save( existingEntry.copy( isViewed = true, @@ -157,41 +109,20 @@ class FilmLibraryService( ) } businessMetricsService.recordLibraryEvent() - log.info( - "Film marked as viewed: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - saved.id, - ) return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { - log.debug("Getting library for user: {}", query.userId) - val library = - findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) - return library - } + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = + findByUserId(query.userId) + ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - override fun list(userId: UUID): List { - log.debug("Listing all library entries for user: {}", userId) - val entries = filmLibraryRepository.findAll().filter { it.userId == userId } - log.info("User {} has {} films in library", userId, entries.size) - return entries - } + override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { - it.userId == userId - } + filmLibraryRepository.findAll().firstOrNull { it.userId == userId } private fun findByUserAndFilmId( userId: UUID, filmId: UUID, - ): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { - it.userId == userId && it.filmId == filmId - } + ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } } 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 93de089..d69a6c8 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -36,25 +36,22 @@ class FilmService( private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { - log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) - log.debug( - "Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", - command.title, - command.description.length, - command.genres, - command.releaseYear, - ) - val sample = Timer.start(meterRegistry) try { + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, + ) + if (filmConfig.isBlocked(command.title)) { - log.warn("Film creation blocked: title contains blocked pattern '{}'", command.title) + log.debug("Create film blocked by title policy: title='{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.warn("Film creation blocked: description contains blocked pattern") + log.debug("Create film blocked by description policy") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -78,7 +75,6 @@ class FilmService( val saved = filmRepository.save(film) filmCreatedCounter.increment() - log.info("Film created successfully: id={}, title='{}'", saved.id, saved.title) return saved } finally { sample.stop(createFilmTimer) @@ -89,25 +85,18 @@ class FilmService( id: UUID, command: EditFilmCommand, ): Film { - log.info("Editing film: id={}", id) - log.debug( - "Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", - id, - command.title, - command.description.length, - command.genres, - ) - val sample = Timer.start(meterRegistry) try { + log.debug("Edit film with id: {}", id) + if (filmConfig.isBlocked(command.title)) { - log.warn("Film edit blocked: title contains blocked pattern '{}'", command.title) + log.debug("Edit film blocked by title policy: title='{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.warn("Film edit blocked: description contains blocked pattern") + log.debug("Edit film blocked by description policy") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -115,12 +104,10 @@ class FilmService( var film = filmRepository.findById(id) if (film == null) { - log.warn("Film not found for edit: id='{}'", id) + log.debug("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) - film = film.copy( title = command.title, @@ -139,7 +126,6 @@ class FilmService( val saved = filmRepository.save(film) filmEditedCounter.increment() - log.info("Film edited successfully: id={}, new title='{}'", saved.id, saved.title) return saved } finally { sample.stop(editFilmTimer) @@ -147,54 +133,34 @@ class FilmService( } override fun delete(id: UUID) { - log.info("Deleting film: id={}", id) - val sample = Timer.start(meterRegistry) try { + log.debug("Delete film with id: {}", id) + val film = filmRepository.findById(id) if (film == null) { - log.warn("Film not found for delete: id='{}'", id) + log.debug("Film not found for delete: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - log.debug("Film found for deletion: id={}, title='{}'", film.id, film.title) - filmRepository.deleteById(id) + filmDeletedCounter.increment() - log.info("Film deleted successfully: id={}, title='{}'", id, film.title) + + log.info("Film deleted: id='{}'", id) } finally { sample.stop(deleteFilmTimer) } } - override fun getById(id: UUID): Film { - log.debug("Fetching film by id: {}", id) - val film = - filmRepository.findById(id) - ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - log.debug("Film found: id={}, title='{}'", film.id, film.title) - return film - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - override fun getAll(): List { - log.debug("Fetching all films") - val films = filmRepository.findAll() - log.info("Retrieved {} films from database", films.size) - return films - } + override fun getAll(): List = filmRepository.findAll() - override fun searchByTitle(title: String): Film? { - log.debug("Searching film by title: '{}'", title) - val film = filmRepository.findByTitle(title) - if (film != null) { - log.info("Film found by title '{}': id={}", title, film.id) - } else { - log.debug("No film found with title: '{}'", title) - } - return film - } + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) private val filmCreatedCounter = Counter diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index ba8fd76..684da5f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -13,7 +13,6 @@ import com.project.movienight.config.UserServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.User -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -27,14 +26,8 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - private val log = LoggerFactory.getLogger(javaClass) - override fun create(command: CreateUserCommand): User { - log.info("Creating new user with email: {}", command.email) - log.debug("Create user request: name='{}', email='{}'", command.name, command.email) - if (userConfig.isBlocked(command.name)) { - log.warn("User creation blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } @@ -46,29 +39,18 @@ class UserService( library = null, jellyfinUserId = null, ) - val saved = userRepository.save(user) - - log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) - return saved + return userRepository.save(user) } override fun edit( id: UUID, command: EditUserCommand, ): User { - log.info("Editing user: id={}", id) - log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) - if (userConfig.isBlocked(command.name)) { - log.warn("User edit blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - var user = - userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - - log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) + var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) user = user.copy( @@ -76,38 +58,16 @@ class UserService( jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, ) - val saved = userRepository.save(user) - log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) - return saved + return userRepository.save(user) } override fun delete(id: UUID) { - log.info("Deleting user: id={}", id) - log.debug("Delete user request: id={}", id) - - val user = - userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - - log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) - + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) userRepository.deleteById(id) - log.info("User deleted successfully: id={}", id) } - override fun getById(id: UUID): User { - log.debug("Fetching user by id: {}", id) - val user = - userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) - return user - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - override fun getAll(): List { - log.debug("Fetching all users") - val users = userRepository.findAll() - log.info("Retrieved {} users from database", users.size) - return users - } + override fun getAll(): List = userRepository.findAll() } -- 2.54.0