refactor: обновить use case слои и интеграцию Jellyfin #58
@@ -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)
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<String>,
|
||||
val cast: List<String>,
|
||||
val directors: List<String>,
|
||||
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<JellyfinRemoteUser> =
|
||||
override fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||
request("Users")
|
||||
.asItems()
|
||||
.mapNotNull { node ->
|
||||
@@ -51,7 +33,7 @@ class JellyfinApiClient(
|
||||
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||
}
|
||||
|
||||
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||
@Suppress("MaxLineLength")
|
||||
request(
|
||||
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User =
|
||||
id = id,
|
||||
name = name,
|
||||
email = email,
|
||||
library = null,
|
||||
preferences = null,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
)
|
||||
|
||||
+38
-35
@@ -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,
|
||||
).firstOrNull()
|
||||
|
||||
override fun findByUserId(userId: UUID): List<FilmLibraryEntry> =
|
||||
jdbc.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?",
|
||||
rowMapper,
|
||||
userId,
|
||||
)
|
||||
return entries.firstOrNull()
|
||||
}
|
||||
|
||||
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<FilmLibrary> =
|
||||
override fun findAll(): List<FilmLibraryEntry> =
|
||||
jdbc.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
|
||||
filmLibraryRowMapper,
|
||||
rowMapper,
|
||||
)
|
||||
|
||||
override fun deleteById(id: UUID) {
|
||||
@@ -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<Film> =
|
||||
jdbc.query(
|
||||
"""
|
||||
|
||||
+12
-24
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+103
-5
@@ -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<User> =
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
userRepository.linkOAuthAccount(
|
||||
userId = userByEmail.id,
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
userRepository.save(entity.toDomain())
|
||||
} 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(
|
||||
userRepository.createOAuthUser(
|
||||
user = newUser,
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
userRepository.save(entity.toDomain())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/**")
|
||||
|
||||
@@ -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<ErrorResponse> {
|
||||
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 {
|
||||
|
||||
@@ -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) }
|
||||
@@ -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<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/search")
|
||||
fun searchByTitle(
|
||||
@RequestParam title: String,
|
||||
): ResponseEntity<FilmResponse> {
|
||||
val film = searchFilmByTitleUseCase.searchByTitle(title)
|
||||
val film = filmUseCase.searchByTitle(title)
|
||||
return if (film != null) {
|
||||
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||
} else {
|
||||
|
||||
@@ -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<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
|
||||
): List<FilmLibraryEntryResponse> =
|
||||
filmLibraryUseCase
|
||||
.list(userId)
|
||||
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
|
||||
|
||||
@GetMapping("/entries")
|
||||
fun listEntries(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmLibraryEntryResponse> = 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<FilmResponse> {
|
||||
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<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@@ -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<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@@ -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,7 +45,8 @@ class JellyfinEventsController(
|
||||
request.jellyfinUserId,
|
||||
request.itemId,
|
||||
)
|
||||
jellyfinEventService.handleEvent(
|
||||
jellyfinEventUseCase.handle(
|
||||
HandleJellyfinEventCommand(
|
||||
eventId = request.eventId,
|
||||
serverId = null,
|
||||
eventType = request.eventType,
|
||||
@@ -51,6 +54,7 @@ class JellyfinEventsController(
|
||||
jellyfinUserId = request.jellyfinUserId,
|
||||
itemId = request.itemId,
|
||||
payload = request.payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
|
||||
fun syncState(): List<JellyfinSyncState> = jellyfinSyncUseCase.getSyncStates()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||
fun getAll(): List<UserResponse> = 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)
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class CreateFilmLibraryRequest(
|
||||
val name: String = "My films",
|
||||
)
|
||||
@@ -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<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = 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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = 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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+26
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-25
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
-31
@@ -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<FilmLibraryEntry>
|
||||
|
||||
fun listAvailableFilms(userId: UUID): List<Film>
|
||||
}
|
||||
|
||||
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<FilmLibrary>
|
||||
}
|
||||
|
||||
@@ -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<FilmRating>
|
||||
}
|
||||
|
||||
data class RateFilmCommand(
|
||||
@@ -13,7 +15,3 @@ data class RateFilmCommand(
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
interface GetFilmRatingsUseCase {
|
||||
fun getRatings(userId: UUID): List<FilmRating>
|
||||
}
|
||||
|
||||
@@ -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<Film>
|
||||
|
||||
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<Film>
|
||||
}
|
||||
|
||||
interface SearchFilmByTitleUseCase {
|
||||
fun searchByTitle(title: String): Film?
|
||||
}
|
||||
|
||||
@@ -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<String, Any>?,
|
||||
)
|
||||
|
||||
interface JellyfinSyncUseCase {
|
||||
fun syncNow(): JellyfinSyncSummary
|
||||
|
||||
fun getSyncStates(): List<JellyfinSyncState>
|
||||
}
|
||||
+3
-5
@@ -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<String> = emptyList(),
|
||||
val contentTypes: List<ContentType> = emptyList(),
|
||||
)
|
||||
|
||||
interface GetUserPreferencesUseCase {
|
||||
fun get(userId: UUID): UserPreferences?
|
||||
}
|
||||
|
||||
@@ -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<User>
|
||||
}
|
||||
|
||||
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<User>
|
||||
}
|
||||
|
||||
+30
@@ -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()
|
||||
}
|
||||
+21
@@ -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<FilmLibraryEntry>
|
||||
|
||||
fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibraryEntry?
|
||||
|
||||
fun findAll(): List<FilmLibraryEntry>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
}
|
||||
-19
@@ -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<FilmLibrary>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
}
|
||||
@@ -10,8 +10,6 @@ interface FilmRepositoryPort {
|
||||
|
||||
fun findByJellyfinItemId(jellyfinItemId: String): Film?
|
||||
|
||||
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
|
||||
|
||||
fun findAll(): List<Film>
|
||||
|
||||
fun findByTitle(title: String): Film?
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
|
||||
interface JellyfinCatalogPort {
|
||||
fun fetchUsers(): List<JellyfinRemoteUser>
|
||||
|
||||
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
|
||||
}
|
||||
|
||||
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<String>,
|
||||
val cast: List<String>,
|
||||
val directors: List<String>,
|
||||
val platformRating: Double?,
|
||||
val imdbRating: Double?,
|
||||
val externalUrl: String?,
|
||||
val jellyfinLibraryId: String?,
|
||||
val isPlayed: Boolean,
|
||||
)
|
||||
+17
@@ -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?,
|
||||
)
|
||||
@@ -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<User>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
|
||||
@@ -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<FilmLibraryEntry> = filmLibraryEntryRepository.findByUserId(userId)
|
||||
|
||||
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
|
||||
override fun listAvailableFilms(userId: UUID): List<Film> {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,19 +20,15 @@ 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)
|
||||
|
||||
try {
|
||||
log.debug(
|
||||
"Create film request received: title='{}', descriptionLength={}",
|
||||
command.title,
|
||||
@@ -47,12 +37,12 @@ class FilmService(
|
||||
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
log.debug("Create film blocked by title policy: title='{}'", command.title)
|
||||
filmBlockedCounter.increment()
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
log.debug("Create film blocked by description policy")
|
||||
filmBlockedCounter.increment()
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
@@ -74,41 +64,37 @@ class FilmService(
|
||||
)
|
||||
|
||||
val saved = filmRepository.save(film)
|
||||
filmCreatedCounter.increment()
|
||||
businessMetricsService.recordFilmCreated()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(createFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
@Timed(
|
||||
value = "business_films_edit_duration_seconds",
|
||||
description = "Film edit duration",
|
||||
)
|
||||
override fun edit(
|
||||
id: UUID,
|
||||
command: EditFilmCommand,
|
||||
): Film {
|
||||
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)
|
||||
filmBlockedCounter.increment()
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
log.debug("Edit film blocked by description policy")
|
||||
filmBlockedCounter.increment()
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
var film = filmRepository.findById(id)
|
||||
val film =
|
||||
filmRepository.findById(id)
|
||||
?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
|
||||
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,20 +108,17 @@ class FilmService(
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
),
|
||||
)
|
||||
|
||||
val saved = filmRepository.save(film)
|
||||
filmEditedCounter.increment()
|
||||
businessMetricsService.recordFilmEdited()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(editFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
@Timed(
|
||||
value = "business_films_delete_duration_seconds",
|
||||
description = "Film deletion duration",
|
||||
)
|
||||
override fun delete(id: UUID) {
|
||||
val sample = Timer.start(meterRegistry)
|
||||
|
||||
try {
|
||||
log.debug("Delete film with id: {}", id)
|
||||
|
||||
val film = filmRepository.findById(id)
|
||||
@@ -146,13 +129,9 @@ class FilmService(
|
||||
}
|
||||
|
||||
filmRepository.deleteById(id)
|
||||
|
||||
filmDeletedCounter.increment()
|
||||
businessMetricsService.recordFilmDeleted()
|
||||
|
||||
log.info("Film deleted: id='{}'", id)
|
||||
} finally {
|
||||
sample.stop(deleteFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getById(id: UUID): Film =
|
||||
@@ -161,46 +140,4 @@ class FilmService(
|
||||
override fun getAll(): List<Film> = 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)
|
||||
}
|
||||
|
||||
+29
-35
@@ -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<String, Any>?,
|
||||
) {
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
+51
-25
@@ -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<JellyfinSyncState> = 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<JellyfinSyncState> = 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,19 +153,23 @@ 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(
|
||||
private fun markFilmViewed(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
watchedAt: LocalDateTime,
|
||||
) {
|
||||
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||
filmLibraryEntryRepository.save(
|
||||
existingEntry?.copy(
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
) ?: FilmLibrary(
|
||||
) ?: FilmLibraryEntry(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = savedFilm.id,
|
||||
filmId = filmId,
|
||||
comment = null,
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
@@ -150,4 +177,3 @@ class JellyfinSyncService(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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,
|
||||
|
||||
+7
-7
@@ -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<FilmRating>,
|
||||
libraryEntries: List<FilmLibrary>,
|
||||
libraryEntries: List<FilmLibraryEntry>,
|
||||
filmsById: Map<UUID, Film>,
|
||||
weights: UserRecommendationWeights,
|
||||
): SparseVector {
|
||||
|
||||
+2
-4
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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);
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
-4
@@ -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()
|
||||
|
||||
+39
-39
@@ -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)
|
||||
}
|
||||
}
|
||||
+45
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<CreateFilmUseCase>(),
|
||||
editFilmUseCase = mockk<EditFilmUseCase>(),
|
||||
deleteFilmUseCase = mockk<DeleteFilmUseCase>(),
|
||||
getFilmByIdUseCase = mockk<GetFilmByIdUseCase>(),
|
||||
getAllFilmsUseCase = mockk<GetAllFilmsUseCase>(),
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
+75
-174
@@ -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<EntityNotFoundException> {
|
||||
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<EntityNotFoundException> {
|
||||
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<DomainException> {
|
||||
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<EntityNotFoundException> {
|
||||
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<DomainException> {
|
||||
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<EntityNotFoundException> {
|
||||
filmLibraryService.getLibrary(query)
|
||||
}
|
||||
assertEquals(entries, filmLibraryService.list(userId))
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user