From d69419b90dfc64ed8c5fda36b8bd2637609dd305 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 14:03:34 +0300 Subject: [PATCH 001/106] =?UTF-8?q?=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=BE=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8E=20?= =?UTF-8?q?=D0=B2=20=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20/=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/jdbc/FilmRepository.kt | 9 +++++++ .../movienight/adapters/web/FilmController.kt | 17 ++++++------ .../adapters/web/FilmLibraryController.kt | 27 +++++++++++++------ .../ports/output/FilmRepositoryPort.kt | 2 ++ .../application/services/FilmService.kt | 4 +++ 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 2883aca..a13f721 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -61,6 +61,15 @@ class FilmRepository( filmRowMapper, ) + override fun findByTitle(title: String): Film? { + val films = jdbc.query( + "SELECT id, title, description FROM films WHERE title = ?", + filmRowMapper, + title + ) + return films.firstOrNull() + } + override fun deleteById(id: UUID) { jdbc.update("DELETE FROM films WHERE id = ?", id) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index d4d52ef..35f963e 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -8,15 +8,9 @@ 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.services.FilmService import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -25,6 +19,7 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, + private val filmService: FilmService, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -61,4 +56,10 @@ class FilmController( fun delete( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + + @GetMapping("/search") + fun searchByTitle( + @RequestParam title: String, + ): FilmResponse? = + filmService.findByTitle(title)?.let { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 59022b0..446e57a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -2,6 +2,7 @@ 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.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 @@ -10,15 +11,9 @@ import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.application.services.FilmService 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 +import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -28,6 +23,7 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val filmService: FilmService, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -80,4 +76,19 @@ class FilmLibraryController( filmId = filmId, ), ) + + @GetMapping("/available-films") + fun getAvailableFilms( + @PathVariable userId: UUID, + ): List { + val userLibrary = getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId) + ) + + val allFilms = filmService.findAll() + + val availableFilms = allFilms.filter { it.id != userLibrary.filmId } + + return availableFilms.map { FilmResponse.fromDomain(it) } + } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index 91d45b6..c0d3938 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,5 +10,7 @@ interface FilmRepositoryPort { fun findAll(): List + fun findByTitle(title: String): Film? + fun deleteById(id: UUID) } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 4166760..9410c3f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -62,4 +62,8 @@ class FilmService( filmRepository.deleteById(id) } + + fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) + + fun findAll(): List = filmRepository.findAll() } From b7b9061ba6e8181b17248db5a1292b31e58bc8bc Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 15:46:59 +0300 Subject: [PATCH 002/106] =?UTF-8?q?=D1=81=D0=BF=D1=80=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/FilmControllerTest.kt | 131 ++++++++++++++++++ .../controllers/FilmLibraryControllerTest.kt | 74 ++++++++++ .../controllers/UserControllerTest.kt | 98 +++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt create mode 100644 src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt create mode 100644 src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt new file mode 100644 index 0000000..c28b5f5 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -0,0 +1,131 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.EditFilmRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* + +@SpringBootTest +@AutoConfigureMockMvc +class FilmControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create film should return 201 CREATED`() { + val request = CreateFilmRequest( + title = "The Matrix", + description = "A computer hacker learns about the true nature of reality" + ) + + mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.title").value("The Matrix")) + .andExpect(jsonPath("$.description").value("A computer hacker learns about the true nature of reality")) + .andExpect(jsonPath("$.id").exists()) + } + + + @Test + fun `edit film should return updated film`() { + val createRequest = CreateFilmRequest( + title = "Old Title", + description = "Old Description" + ) + + val response = mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = EditFilmRequest( + title = "New Title", + description = "New Description" + ) + + mockMvc.perform( + patch("/api/films/$filmId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("New Title")) + .andExpect(jsonPath("$.description").value("New Description")) + } + + + @Test + fun `search film by title should return film`() { + val request = CreateFilmRequest( + title = "Inception", + description = "Dream within a dream" + ) + + mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + + mockMvc.perform( + get("/api/films/search") + .param("title", "Inception") + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.title").value("Inception")) + .andExpect(jsonPath("$.description").value("Dream within a dream")) + } + + + @Test + fun `search film by non-existent title should return empty`() { + mockMvc.perform( + get("/api/films/search") + .param("title", "NonExistentFilm12345") + ) + .andExpect(status().isOk) + .andExpect(content().string("")) + } + + + @Test + fun `delete film should return 204 NO CONTENT`() { + val request = CreateFilmRequest( + title = "Film To Delete", + description = "This film will be deleted" + ) + + val response = mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andReturn() + + val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc.perform(delete("/api/films/$filmId")) + .andExpect(status().isNoContent()) + + mockMvc.perform(get("/api/films/search").param("title", "Film To Delete")) + .andExpect(status().isOk) + .andExpect(content().string("")) + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt new file mode 100644 index 0000000..39b795d --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -0,0 +1,74 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class FilmLibraryControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + + @Test + fun `add film to library should work`() { + val userRequest = CreateUserRequest("Film Adder", "adder@example.com") + val userResponse = mockMvc.perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)) + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = CreateFilmRequest("Library Film", "Film description") + val filmResponse = mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)) + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc.perform( + post("/api/users/$userId/library/films/$filmId") + ) + .andExpect(status().isCreated) + } + + @Test + fun `remove film from library should return 204`() { + val userRequest = CreateUserRequest("Remove Film", "remove@example.com") + val userResponse = mockMvc.perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)) + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = CreateFilmRequest("Film To Remove", "Will be removed") + val filmResponse = mockMvc.perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)) + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$filmId")) + mockMvc.perform(delete("/api/users/$userId/library/films/$filmId")) + .andExpect(status().isNoContent()) + } +} diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt new file mode 100644 index 0000000..df266fe --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -0,0 +1,98 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import com.project.movienight.adapters.web.dto.request.EditUserRequest +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class UserControllerTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `create user should return 201 CREATED`() { + val request = CreateUserRequest( + name = "John Doe", + email = "john@example.com" + ) + + mockMvc.perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andExpect(status().isCreated) + .andExpect(jsonPath("$.name").value("John Doe")) + .andExpect(jsonPath("$.email").value("john@example.com")) + .andExpect(jsonPath("$.id").exists()) + } + + + @Test + fun `edit user should return updated user`() { + val createRequest = CreateUserRequest( + name = "Old Name", + email = "edit@example.com" + ) + + val response = mockMvc.perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)) + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + val editRequest = EditUserRequest(name = "New Name") + + mockMvc.perform( + patch("/api/users/$userId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)) + ) + .andExpect(status().isOk) + .andExpect(jsonPath("$.name").value("New Name")) + .andExpect(jsonPath("$.email").value("edit@example.com")) + } + + @Test + fun `delete user should return 204 NO CONTENT`() { + val request = CreateUserRequest( + name = "User To Delete", + email = "delete@example.com" + ) + + val response = mockMvc.perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andReturn() + + val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() + + mockMvc.perform(delete("/api/users/$userId")) + .andExpect(status().isNoContent()) + } + + @Test + fun `delete non-existent user should return 404`() { + val nonExistentId = "123e4567-e89b-12d3-a456-426614174000" + mockMvc.perform(delete("/api/users/$nonExistentId")) + .andExpect(status().isNotFound()) + } +} From 88837d2e185ad0771f16839b3e9b80bb0a242cdd Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 16:06:44 +0300 Subject: [PATCH 003/106] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20get=20=D0=B2=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BB=D0=BB=D0=B5=D1=80=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/adapters/web/FilmController.kt | 9 +++++++ .../adapters/web/FilmLibraryController.kt | 13 ++++++++++ .../movienight/adapters/web/UserController.kt | 24 ++++++++++++------- .../application/services/FilmService.kt | 2 ++ .../application/services/UserService.kt | 4 ++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 35f963e..045adf6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -9,6 +9,7 @@ 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.services.FilmService +import com.project.movienight.domain.exception.EntityNotFoundException import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.* import java.util.UUID @@ -57,6 +58,14 @@ class FilmController( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): FilmResponse = + FilmResponse.fromDomain( + filmService.findById(id) ?: throw EntityNotFoundException("Film", id.toString()) + ) + @GetMapping("/search") fun searchByTitle( @RequestParam title: String, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 446e57a..0989dff 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -50,6 +50,19 @@ class FilmLibraryController( ), ) + @GetMapping("/films") + fun getAllFilmsInLibrary( + @PathVariable userId: UUID, + ): List { + val library = getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId) + ) + + val film = filmService.findById(library.filmId) + + return film?.let { listOf(FilmResponse.fromDomain(it)) } ?: emptyList() + } + @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index f270736..5bdbc58 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -8,15 +8,10 @@ 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.services.UserService +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.PatchMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.ResponseStatus -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -25,6 +20,7 @@ class UserController( private val createUserUseCase: CreateUserUseCase, private val editUserUseCase: EditUserUseCase, private val deleteUserUseCase: DeleteUserUseCase, + private val userService: UserService, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -40,6 +36,18 @@ class UserController( ), ) + @GetMapping + fun getAll(): List = + userService.findAll().map { UserResponse.fromDomain(it) } + + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): UserResponse = + UserResponse.fromDomain( + userService.findById(id) ?: throw EntityNotFoundException("User", id.toString()) + ) + @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 9410c3f..6ed4748 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -66,4 +66,6 @@ class FilmService( fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) fun findAll(): List = filmRepository.findAll() + + fun findById(id: UUID): Film? = filmRepository.findById(id) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..71fb6ca 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -57,4 +57,8 @@ class UserService( userRepository.deleteById(id) } + + fun findAll(): List = userRepository.findAll() + + fun findById(id: UUID): User? = userRepository.findById(id) } From a8076594be559af6cf114f2952cffeb99c10c2dd Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 22:36:27 +0300 Subject: [PATCH 004/106] =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buildPipeline.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 buildPipeline.yml diff --git a/buildPipeline.yml b/buildPipeline.yml new file mode 100644 index 0000000..031ce85 --- /dev/null +++ b/buildPipeline.yml @@ -0,0 +1,27 @@ +name: CI Build + +on: + pull_request: + branches: [ develop, main ] + push: + branches: [ develop ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build project + run: ./gradlew build -x test From 910a3826794ba864cdafa9ff4c7ad1a85f638930 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Sun, 19 Apr 2026 22:39:29 +0300 Subject: [PATCH 005/106] =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D0=B9=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- buildPipeline.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/buildPipeline.yml b/buildPipeline.yml index 031ce85..da17798 100644 --- a/buildPipeline.yml +++ b/buildPipeline.yml @@ -25,3 +25,4 @@ jobs: - name: Build project run: ./gradlew build -x test + From 49f74d1898b1da8aee3e0deebcdad9b04d2c6628 Mon Sep 17 00:00:00 2001 From: skettiks Date: Mon, 20 Apr 2026 19:45:55 +0300 Subject: [PATCH 006/106] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20User,=20Film,=20FilmLibrary=20-=20?= =?UTF-8?q?=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B5=D0=B2=20application-test.yaml:=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FilmLibraryRepositoryIntegrationTest.kt | 219 ++++++++++++++++++ .../jdbc/FilmRepositoryIntegrationTest.kt | 152 ++++++++++++ .../jdbc/UserRepositoryIntegrationTest.kt | 159 +++++++++++++ src/test/resources/application-test.yaml | 28 +++ 4 files changed, 558 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt create mode 100644 src/test/resources/application-test.yaml diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt new file mode 100644 index 0000000..232216a --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt @@ -0,0 +1,219 @@ +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.User +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class FilmLibraryRepositoryIntegrationTest { + @Autowired + private lateinit var filmLibraryRepository: FilmLibraryRepository + + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var filmRepository: FilmRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + private lateinit var testUser: User + private lateinit var testFilm: Film + + @BeforeEach + fun setup() { + cleanDatabase() + createTestData() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + private fun createTestData() { + testUser = User(UUID.randomUUID(), "Иван Иванов", "ivan@example.com", null) + testFilm = Film(UUID.randomUUID(), "Начало", "Захватывающий триллер") + userRepository.save(testUser) + filmRepository.save(testFilm) + } + + @Test + fun `should save new film library entry and return saved entry`() { + val entry = FilmLibrary( + id = UUID.randomUUID(), + userId = testUser.id, + filmId = testFilm.id, + comment = "Отличный фильм!", + isViewed = false, + ) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertEquals(entry.id, savedEntry.id) + assertEquals(entry.userId, savedEntry.userId) + assertEquals(entry.filmId, savedEntry.filmId) + assertEquals(entry.comment, savedEntry.comment) + assertEquals(entry.isViewed, savedEntry.isViewed) + } + + @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 updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) + val result = filmLibraryRepository.save(updatedEntry) + + assertEquals(entryId, result.id) + assertEquals("Уже посмотрел, потрясающе!", result.comment) + assertTrue(result.isViewed) + + val foundEntry = filmLibraryRepository.findById(entryId) + assertNotNull(foundEntry) + assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment) + assertTrue(foundEntry?.isViewed ?: false) + } + + @Test + fun `should find film library entry by id`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) + filmLibraryRepository.save(entry) + + val foundEntry = filmLibraryRepository.findById(entry.id) + + assertNotNull(foundEntry) + assertEquals(entry.id, foundEntry?.id) + assertEquals(entry.userId, foundEntry?.userId) + assertEquals(entry.filmId, foundEntry?.filmId) + assertEquals(entry.comment, foundEntry?.comment) + assertEquals(entry.isViewed, foundEntry?.isViewed) + } + + @Test + fun `should return null when film library entry not found by id`() { + val nonExistentId = UUID.randomUUID() + + val foundEntry = filmLibraryRepository.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) + + filmLibraryRepository.save(entry1) + filmLibraryRepository.save(entry2) + filmLibraryRepository.save(entry3) + + val allEntries = filmLibraryRepository.findAll() + + assertEquals(3, allEntries.size) + assertTrue(allEntries.any { it.id == entry1.id }) + assertTrue(allEntries.any { it.id == entry2.id }) + assertTrue(allEntries.any { it.id == entry3.id }) + } + + @Test + fun `should return empty list when no film library entries exist`() { + val allEntries = filmLibraryRepository.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) + + filmLibraryRepository.deleteById(entry.id) + + val foundEntry = filmLibraryRepository.findById(entry.id) + assertNull(foundEntry) + } + + @Test + fun `should not throw exception when deleting non-existent entry`() { + val nonExistentId = UUID.randomUUID() + + filmLibraryRepository.deleteById(nonExistentId) + } + + @Test + fun `should save entry with null comment`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertNull(savedEntry.comment) + } + + @Test + fun `should save entry with isViewed true`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertTrue(savedEntry.isViewed) + } + + @Test + fun `should save entry with isViewed false`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) + + val savedEntry = filmLibraryRepository.save(entry) + + assertNotNull(savedEntry) + assertFalse(savedEntry.isViewed) + } + + @Test + fun `should cascade delete entries when user is deleted`() { + val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) + filmLibraryRepository.save(entry) + + userRepository.deleteById(testUser.id) + + val foundEntry = filmLibraryRepository.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) + + filmRepository.deleteById(testFilm.id) + + val foundEntry = filmLibraryRepository.findById(entry.id) + assertNull(foundEntry) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt new file mode 100644 index 0000000..926177b --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt @@ -0,0 +1,152 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.Film +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class FilmRepositoryIntegrationTest { + @Autowired + private lateinit var filmRepository: FilmRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + @Test + fun `should save new film and return saved film`() { + val film = Film( + id = UUID.randomUUID(), + title = "Начало", + description = "Захватывающий триллер о снах внутри снов", + ) + + val savedFilm = filmRepository.save(film) + + assertNotNull(savedFilm) + assertEquals(film.id, savedFilm.id) + assertEquals(film.title, savedFilm.title) + assertEquals(film.description, savedFilm.description) + } + + @Test + fun `should update existing film`() { + val filmId = UUID.randomUUID() + val originalFilm = Film(filmId, "Начало", "Оригинальное описание") + filmRepository.save(originalFilm) + + val updatedFilm = Film(filmId, "Начало (Обновлено)", "Обновленное описание с дополнительными деталями") + val result = filmRepository.save(updatedFilm) + + assertEquals(filmId, result.id) + assertEquals("Начало (Обновлено)", result.title) + assertEquals("Обновленное описание с дополнительными деталями", result.description) + + val foundFilm = filmRepository.findById(filmId) + assertNotNull(foundFilm) + assertEquals("Начало (Обновлено)", foundFilm?.title) + assertEquals("Обновленное описание с дополнительными деталями", foundFilm?.description) + } + + @Test + fun `should find film by id`() { + val film = Film(UUID.randomUUID(), "Матрица", "Хакер узнает правду о реальности") + filmRepository.save(film) + + val foundFilm = filmRepository.findById(film.id) + + assertNotNull(foundFilm) + assertEquals(film.id, foundFilm?.id) + assertEquals(film.title, foundFilm?.title) + assertEquals(film.description, foundFilm?.description) + } + + @Test + fun `should return null when film not found by id`() { + val nonExistentId = UUID.randomUUID() + + val foundFilm = filmRepository.findById(nonExistentId) + + assertNull(foundFilm) + } + + @Test + fun `should find all films`() { + val film1 = Film(UUID.randomUUID(), "Начало", "Сны внутри снов") + val film2 = Film(UUID.randomUUID(), "Матрица", "Реальность не то, чем кажется") + val film3 = Film(UUID.randomUUID(), "Интерстеллар", "Путешествие сквозь пространство и время") + + filmRepository.save(film1) + filmRepository.save(film2) + filmRepository.save(film3) + + val allFilms = filmRepository.findAll() + + assertEquals(3, allFilms.size) + assertTrue(allFilms.any { it.id == film1.id }) + assertTrue(allFilms.any { it.id == film2.id }) + assertTrue(allFilms.any { it.id == film3.id }) + } + + @Test + fun `should return empty list when no films exist`() { + val allFilms = filmRepository.findAll() + + assertTrue(allFilms.isEmpty()) + } + + @Test + fun `should delete film by id`() { + val film = Film(UUID.randomUUID(), "Начало", "Сны внутри снов") + filmRepository.save(film) + + filmRepository.deleteById(film.id) + + val foundFilm = filmRepository.findById(film.id) + assertNull(foundFilm) + } + + @Test + fun `should not throw exception when deleting non-existent film`() { + val nonExistentId = UUID.randomUUID() + + filmRepository.deleteById(nonExistentId) + } + + @Test + fun `should save film with long description`() { + val longDescription = "А".repeat(1000) + val film = Film(UUID.randomUUID(), "Тестовый фильм", longDescription) + + val savedFilm = filmRepository.save(film) + + assertNotNull(savedFilm) + assertEquals(longDescription, savedFilm.description) + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt new file mode 100644 index 0000000..e1f0cd9 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -0,0 +1,159 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import java.util.UUID + +@SpringBootTest +@ActiveProfiles("test") +class UserRepositoryIntegrationTest { + @Autowired + private lateinit var userRepository: UserRepository + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } + + @Test + fun `should save new user and return saved user`() { + val user = User( + id = UUID.randomUUID(), + name = "John Doe", + email = "john@example.com", + library = null, + ) + + val savedUser = userRepository.save(user) + + assertNotNull(savedUser) + assertEquals(user.id, savedUser.id) + assertEquals(user.name, savedUser.name) + assertEquals(user.email, savedUser.email) + } + + @Test + fun `should update existing user`() { + // given + val userId = UUID.randomUUID() + val originalUser = User(userId, "John Doe", "john@example.com", null) + userRepository.save(originalUser) + + val updatedUser = User(userId, "Jane Doe", "jane@example.com", null) + val result = userRepository.save(updatedUser) + + assertEquals(userId, result.id) + assertEquals("Jane Doe", result.name) + assertEquals("jane@example.com", result.email) + + val foundUser = userRepository.findById(userId) + assertNotNull(foundUser) + assertEquals("Jane Doe", foundUser?.name) + assertEquals("jane@example.com", foundUser?.email) + } + + @Test + fun `should find user by id`() { + // given + val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + userRepository.save(user) + + // when + val foundUser = userRepository.findById(user.id) + + // then + assertNotNull(foundUser) + assertEquals(user.id, foundUser?.id) + assertEquals(user.name, foundUser?.name) + assertEquals(user.email, foundUser?.email) + } + + @Test + fun `should return null when user not found by id`() { + // given + val nonExistentId = UUID.randomUUID() + + // when + val foundUser = userRepository.findById(nonExistentId) + + // then + assertNull(foundUser) + } + + @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) + + userRepository.save(user1) + userRepository.save(user2) + userRepository.save(user3) + + // when + val allUsers = userRepository.findAll() + + // then + assertEquals(3, allUsers.size) + assertTrue(allUsers.any { it.id == user1.id }) + assertTrue(allUsers.any { it.id == user2.id }) + assertTrue(allUsers.any { it.id == user3.id }) + } + + @Test + fun `should return empty list when no users exist`() { + // when + val allUsers = userRepository.findAll() + + // then + assertTrue(allUsers.isEmpty()) + } + + @Test + fun `should delete user by id`() { + // given + val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + userRepository.save(user) + + // when + userRepository.deleteById(user.id) + + // then + val foundUser = userRepository.findById(user.id) + assertNull(foundUser) + } + + @Test + fun `should not throw exception when deleting non-existent user`() { + // given + val nonExistentId = UUID.randomUUID() + + // when & then (no exception should be thrown) + userRepository.deleteById(nonExistentId) + } +} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..9aabcb5 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,28 @@ +spring: + application: + name: MovieNight-Test + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + username: sa + password: + driver-class-name: org.h2.Driver + flyway: + enabled: true + url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + locations: classpath:db/migration + baseline-on-migrate: true + h2: + console: + enabled: false + +services: + user: + blocked-names: + - admin + - root + - system + film: + blocked-patterns: + - censored + - epstein + - python From 8926e5b08c95fc5a475d5143988d92bff4188c7a Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Tue, 21 Apr 2026 00:19:14 +0300 Subject: [PATCH 007/106] =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82?= =?UTF-8?q?=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project/movienight/adapters/web/FilmController.kt | 9 +++++++++ .../movienight/adapters/web/FilmLibraryController.kt | 8 ++++++++ 2 files changed, 17 insertions(+) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 35f963e..659fdf6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.web + 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 @@ -10,6 +11,14 @@ import com.project.movienight.application.ports.input.EditFilmCommand import com.project.movienight.application.ports.input.EditFilmUseCase import com.project.movienight.application.services.FilmService import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.* import java.util.UUID diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 446e57a..32937c6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -13,6 +13,14 @@ import com.project.movienight.application.ports.input.RemoveFilmFromLibraryComma import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.services.FilmService 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 import org.springframework.web.bind.annotation.* import java.util.UUID From f927148897879803e6eab42925f45999a5541960 Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 00:21:00 +0300 Subject: [PATCH 008/106] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=20=D1=8E=D0=BD=D0=B8=D1=82=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=20=D1=81=D0=B5=D1=80=D0=B2=D0=B8=D1=81=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + .../services/FilmLibraryServiceTest.kt | 280 ++++++++++++++++++ .../application/services/FilmServiceTest.kt | 181 +++++++++++ .../application/services/UserServiceTest.kt | 155 ++++++++++ 4 files changed, 617 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt create mode 100644 src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt create mode 100644 src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..847a6f5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.kotlin.test.junit5) testImplementation(libs.spring.grpc.test) + testImplementation(libs.mockk) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt new file mode 100644 index 0000000..a5951d4 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -0,0 +1,280 @@ +package com.project.movienight.application.services + +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.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmLibrary +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class FilmLibraryServiceTest { + private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var filmLibraryService: FilmLibraryService + + @BeforeEach + fun setup() { + filmLibraryRepository = mockk() + idGenerator = mockk() + filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator) + } + + @Test + fun `should create new film library when user has no library`() { + val userId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") + val expectedLibrary = FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Films", + isViewed = false, + ) + + every { filmLibraryRepository.findAll() } returns emptyList() + every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) + every { filmLibraryRepository.save(match { + it.userId == userId && it.comment == "My Films" && it.isViewed == false + }) } returns expectedLibrary + + val result = filmLibraryService.create(command) + + assertNotNull(result) + assertEquals(libraryId, result.id) + assertEquals(userId, result.userId) + assertEquals(filmId, result.filmId) + assertEquals("My Films", result.comment) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 2) { idGenerator.generateId() } + verify(exactly = 1) { 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, + ) + 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`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val libraryId = UUID.randomUUID() + val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) + val expectedLibrary = FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = null, + isViewed = false, + ) + + every { filmLibraryRepository.findAll() } returns emptyList() + every { idGenerator.generateId() } returns libraryId + every { filmLibraryRepository.save(match { + it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false + }) } returns expectedLibrary + + val result = filmLibraryService.addFilm(command) + + assertNotNull(result) + assertEquals(filmId, result.filmId) + assertEquals(userId, result.userId) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryRepository.save(any()) } + } + + @Test + fun `should add film to existing library`() { + val userId = UUID.randomUUID() + val oldFilmId = UUID.randomUUID() + val newFilmId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = oldFilmId, + comment = "My Library", + isViewed = true, + ) + val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) + val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryRepository.save(match { + it.filmId == newFilmId && it.isViewed == false + }) } returns updatedLibrary + + val result = filmLibraryService.addFilm(command) + + assertEquals(newFilmId, result.filmId) + assertEquals(false, result.isViewed) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryRepository.save(any()) } + } + + @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, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + justRun { filmLibraryRepository.deleteById(libraryId) } + + val result = filmLibraryService.removeFilm(command) + + assertEquals(existingLibrary, result) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) } + } + + @Test + fun `should throw EntityNotFoundException when removing film from non-existent library`() { + val userId = UUID.randomUUID() + val filmId = UUID.randomUUID() + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) + + every { filmLibraryRepository.findAll() } returns emptyList() + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + } + + @Test + fun `should throw DomainException when removing film that is not in library`() { + val userId = UUID.randomUUID() + val libraryFilmId = UUID.randomUUID() + val differentFilmId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = libraryFilmId, + comment = "My Library", + isViewed = false, + ) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + } + + @Test + fun `should throw EntityNotFoundException when libraryId does not match`() { + 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.findAll() } returns listOf(existingLibrary) + + assertThrows { + filmLibraryService.removeFilm(command) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + } + + @Test + fun `should get library successfully`() { + val userId = UUID.randomUUID() + val existingLibrary = FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "My Library", + isViewed = false, + ) + val query = GetFilmLibraryQuery(userId = userId) + + every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + + val result = filmLibraryService.getLibrary(query) + + assertEquals(existingLibrary, result) + + verify(exactly = 1) { filmLibraryRepository.findAll() } + } + + @Test + fun `should throw EntityNotFoundException when getting non-existent library`() { + val userId = UUID.randomUUID() + val query = GetFilmLibraryQuery(userId = userId) + + every { filmLibraryRepository.findAll() } returns emptyList() + + assertThrows { + filmLibraryService.getLibrary(query) + } + + verify(exactly = 1) { filmLibraryRepository.findAll() } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt new file mode 100644 index 0000000..fcc5613 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -0,0 +1,181 @@ +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.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.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class FilmServiceTest { + private lateinit var filmRepository: FilmRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var filmConfig: FilmServiceProperties + private lateinit var filmService: FilmService + + @BeforeEach + fun setup() { + filmRepository = mockk() + idGenerator = mockk() + filmConfig = mockk() + filmService = FilmService(filmRepository, idGenerator, filmConfig) + } + + @Test + fun `should create film successfully`() { + val command = CreateFilmCommand(title = "Inception", description = "A mind-bending thriller") + val filmId = UUID.randomUUID() + val expectedFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + + every { filmConfig.isBlocked("Inception") } returns false + every { filmConfig.isBlocked("A mind-bending thriller") } returns false + every { idGenerator.generateId() } returns filmId + every { filmRepository.save(any()) } returns expectedFilm + + val result = filmService.create(command) + + assertNotNull(result) + assertEquals(filmId, result.id) + assertEquals("Inception", result.title) + assertEquals("A mind-bending thriller", result.description) + + verify(exactly = 1) { filmConfig.isBlocked("Inception") } + verify(exactly = 1) { filmConfig.isBlocked("A mind-bending thriller") } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating film with blocked title`() { + val command = CreateFilmCommand(title = "censored", description = "Some description") + + every { filmConfig.isBlocked("censored") } returns true + every { filmConfig.isBlocked("Some description") } returns false + + assertThrows { + filmService.create(command) + } + + verify(exactly = 1) { filmConfig.isBlocked("censored") } + verify(exactly = 0) { filmConfig.isBlocked("Some description") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating film with blocked description`() { + val command = CreateFilmCommand(title = "Good Film", description = "python") + + every { filmConfig.isBlocked("Good Film") } returns false + every { filmConfig.isBlocked("python") } returns true + + assertThrows { + filmService.create(command) + } + + verify(exactly = 1) { filmConfig.isBlocked("Good Film") } + verify(exactly = 1) { filmConfig.isBlocked("python") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should edit film successfully`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "Inception 2", description = "The sequel") + val existingFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + val updatedFilm = Film(id = filmId, title = "Inception 2", description = "The sequel") + + every { filmConfig.isBlocked("Inception 2") } returns false + every { filmConfig.isBlocked("The sequel") } returns false + every { filmRepository.findById(filmId) } returns existingFilm + every { filmRepository.save(any()) } returns updatedFilm + + val result = filmService.edit(filmId, command) + + assertNotNull(result) + assertEquals(filmId, result.id) + assertEquals("Inception 2", result.title) + assertEquals("The sequel", result.description) + + verify(exactly = 1) { filmConfig.isBlocked("Inception 2") } + verify(exactly = 1) { filmConfig.isBlocked("The sequel") } + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 1) { filmRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when editing film with blocked title`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "epstein", description = "Some description") + + every { filmConfig.isBlocked("epstein") } returns true + + assertThrows { + filmService.edit(filmId, command) + } + + verify(exactly = 1) { filmConfig.isBlocked("epstein") } + verify(exactly = 0) { filmRepository.findById(any()) } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should throw EntityNotFoundException when editing non-existent film`() { + val filmId = UUID.randomUUID() + val command = EditFilmCommand(title = "New Title", description = "New Description") + + every { filmConfig.isBlocked("New Title") } returns false + every { filmConfig.isBlocked("New Description") } returns false + every { filmRepository.findById(filmId) } returns null + + assertThrows { + filmService.edit(filmId, command) + } + + verify(exactly = 1) { filmConfig.isBlocked("New Title") } + verify(exactly = 1) { filmConfig.isBlocked("New Description") } + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 0) { filmRepository.save(any()) } + } + + @Test + fun `should delete film successfully`() { + val filmId = UUID.randomUUID() + val existingFilm = Film(id = filmId, title = "Inception", description = "A mind-bending thriller") + + every { filmRepository.findById(filmId) } returns existingFilm + justRun { filmRepository.deleteById(filmId) } + + filmService.delete(filmId) + + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 1) { filmRepository.deleteById(filmId) } + } + + @Test + fun `should throw EntityNotFoundException when deleting non-existent film`() { + val filmId = UUID.randomUUID() + + every { filmRepository.findById(filmId) } returns null + + assertThrows { + filmService.delete(filmId) + } + + verify(exactly = 1) { filmRepository.findById(filmId) } + verify(exactly = 0) { filmRepository.deleteById(any()) } + } +} diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt new file mode 100644 index 0000000..c54a909 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -0,0 +1,155 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.CreateUserCommand +import com.project.movienight.application.ports.input.EditUserCommand +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.UserServiceProperties +import com.project.movienight.domain.exception.BlockedValueException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.User +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.UUID + +class UserServiceTest { + private lateinit var userRepository: UserRepositoryPort + private lateinit var idGenerator: IdGenerator + private lateinit var userConfig: UserServiceProperties + private lateinit var userService: UserService + + @BeforeEach + fun setup() { + userRepository = mockk() + idGenerator = mockk() + userConfig = mockk() + userService = UserService(userRepository, idGenerator, userConfig) + } + + @Test + 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) + + every { userConfig.isBlocked("John Doe") } returns false + every { idGenerator.generateId() } returns userId + every { userRepository.save(any()) } returns expectedUser + + val result = userService.create(command) + + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("John Doe", result.name) + assertEquals("john@example.com", result.email) + + verify(exactly = 1) { userConfig.isBlocked("John Doe") } + verify(exactly = 1) { idGenerator.generateId() } + verify(exactly = 1) { userRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when creating user with blocked name`() { + val command = CreateUserCommand(name = "admin", email = "admin@example.com") + + every { userConfig.isBlocked("admin") } returns true + + assertThrows { + userService.create(command) + } + + verify(exactly = 1) { userConfig.isBlocked("admin") } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + 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) + + every { userConfig.isBlocked("Jane Doe") } returns false + every { userRepository.findById(userId) } returns existingUser + every { userRepository.save(any()) } returns updatedUser + + val result = userService.edit(userId, command) + + assertNotNull(result) + assertEquals(userId, result.id) + assertEquals("Jane Doe", result.name) + + verify(exactly = 1) { userConfig.isBlocked("Jane Doe") } + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 1) { userRepository.save(any()) } + } + + @Test + fun `should throw BlockedValueException when editing user with blocked name`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "root") + + every { userConfig.isBlocked("root") } returns true + + assertThrows { + userService.edit(userId, command) + } + + verify(exactly = 1) { userConfig.isBlocked("root") } + verify(exactly = 0) { userRepository.findById(any()) } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should throw EntityNotFoundException when editing non-existent user`() { + val userId = UUID.randomUUID() + val command = EditUserCommand(name = "Jane Doe") + + every { userConfig.isBlocked("Jane Doe") } returns false + every { userRepository.findById(userId) } returns null + + assertThrows { + userService.edit(userId, command) + } + + verify(exactly = 1) { userConfig.isBlocked("Jane Doe") } + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 0) { userRepository.save(any()) } + } + + @Test + fun `should delete user successfully`() { + val userId = UUID.randomUUID() + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + + every { userRepository.findById(userId) } returns existingUser + justRun { userRepository.deleteById(userId) } + + userService.delete(userId) + + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 1) { userRepository.deleteById(userId) } + } + + @Test + fun `should throw EntityNotFoundException when deleting non-existent user`() { + val userId = UUID.randomUUID() + + every { userRepository.findById(userId) } returns null + + assertThrows { + userService.delete(userId) + } + + verify(exactly = 1) { userRepository.findById(userId) } + verify(exactly = 0) { userRepository.deleteById(any()) } + } +} From 3cb52c019f23d24f8f177c2869d232dd165caaf4 Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 15:30:58 +0300 Subject: [PATCH 009/106] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20V2=5F=5Fadd=5Foauth2=5Ffields.sql=20=D0=92=D1=81=D0=B5?= =?UTF-8?q?=20UserRepositoryIntegrationTest=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 ++ .../resources/db/migration/V2__add_oauth2_fields.sql | 9 +++++++++ 3 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..0adabc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,7 +32,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) -// implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.validation) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..e43dfa6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" +mockk = "1.13.13" [libraries] spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } @@ -33,6 +34,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" } sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" } diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql new file mode 100644 index 0000000..0db4084 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_fields.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); +ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); +ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; + +CREATE UNIQUE INDEX idx_users_provider_provider_id + ON public.users(provider, provider_id); + +CREATE INDEX idx_users_email ON public.users(email); + From acd8f9fb01e9403ecdeebbc7eb193d4b480bdf6e Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 18:30:22 +0300 Subject: [PATCH 010/106] =?UTF-8?q?=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD?= =?UTF-8?q?=20AuthProvider=20enum=20=D1=81=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20GOOGLE,=20YA?= =?UTF-8?q?NDEX,=20VK=20=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B0=20Us?= =?UTF-8?q?erEntity=20=D0=B2=20adapters/persistence/entity=20=D1=81=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8F=D0=BC=D0=B8=20=D0=B4=D0=BB=D1=8F=20OAu?= =?UTF-8?q?th=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20e?= =?UTF-8?q?xtension-=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D0=BF=D0=BF=D0=B8=D0=BD=D0=B3=D0=B0=20toDomain()=20?= =?UTF-8?q?=D0=B8=20toEntity()=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20UserRepository=20=E2=80=94=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D0=B5=D1=82=20UserEntity=20?= =?UTF-8?q?=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=20findByProviderAndProviderId()?= =?UTF-8?q?=20=D0=A1=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D1=8B=20=D1=8E=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=20=D1=82=D0=B5=D1=81=D1=82=20-=20UserEntityMapping?= =?UTF-8?q?Test.kt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/persistence/entity/UserEntity.kt | 37 +++++++++ .../persistence/jdbc/UserRepository.kt | 64 ++++++++++----- .../ports/output/UserRepositoryPort.kt | 6 ++ .../movienight/domain/model/AuthProvider.kt | 7 ++ .../entity/UserEntityMappingTest.kt | 81 +++++++++++++++++++ 5 files changed, 176 insertions(+), 19 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt create mode 100644 src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt new file mode 100644 index 0000000..0beda74 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import java.time.LocalDateTime +import java.util.UUID + +data class UserEntity( + val id: UUID, + val name: String, + val email: String, + val provider: String?, + val providerId: String?, + val createdAt: LocalDateTime, +) + +fun UserEntity.toDomain(): User = + User( + id = id, + name = name, + email = email, + library = null, + ) + +fun User.toEntity( + provider: AuthProvider? = null, + providerId: String? = null, + createdAt: LocalDateTime = LocalDateTime.now(), +): UserEntity = + UserEntity( + id = id, + name = name, + email = email, + provider = provider?.name, + providerId = providerId, + createdAt = createdAt, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index a6607e5..09d8f9d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -1,6 +1,10 @@ package com.project.movienight.adapters.persistence.jdbc +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.model.AuthProvider import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -11,58 +15,80 @@ import java.util.UUID class UserRepository( private val jdbc: JdbcTemplate, ) : UserRepositoryPort { - private val userRowMapper = { rs: ResultSet, _: Int -> - User( + private val userEntityRowMapper = { rs: ResultSet, _: Int -> + UserEntity( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - library = null, + provider = rs.getString("provider"), + providerId = rs.getString("provider_id"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } override fun save(user: User): User { + val entity = user.toEntity() val updatedRows = jdbc.update( """ UPDATE users - SET name = ?, email = ? + SET name = ?, email = ?, provider = ?, provider_id = ? WHERE id = ? """.trimIndent(), - user.name, - user.email, - user.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email) - VALUES (?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), - user.id, - user.name, - user.email, + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.createdAt, ) } return user } override fun findById(id: UUID): User? { - val users = + val entities = jdbc.query( - "SELECT id, name, email FROM users WHERE id = ?", - userRowMapper, + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, id, ) - return users.firstOrNull() + return entities.firstOrNull()?.toDomain() } override fun findAll(): List = jdbc.query( - "SELECT id, name, email FROM users", - userRowMapper, - ) + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } + + override fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? { + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + userEntityRowMapper, + provider.name, + providerId, + ) + return entities.firstOrNull()?.toDomain() + } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index af8ef0a..e3c902c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.ports.output +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import java.util.UUID @@ -11,4 +12,9 @@ interface UserRepositoryPort { fun findAll(): List fun deleteById(id: UUID) + + fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? } diff --git a/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt new file mode 100644 index 0000000..4926b93 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/AuthProvider.kt @@ -0,0 +1,7 @@ +package com.project.movienight.domain.model + +enum class AuthProvider { + GOOGLE, + YANDEX, + VK, +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt new file mode 100644 index 0000000..bec7f20 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -0,0 +1,81 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.AuthProvider +import com.project.movienight.domain.model.User +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UserEntityMappingTest { + + @Test + fun `toDomain maps UserEntity correctly`() { + val entity = UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) + val user = entity.toDomain() + + assertEquals(entity.id, user.id) + assertEquals(entity.name, user.name) + assertEquals(entity.email, user.email) + assertNull(user.library) + } + + @Test + fun `toEntity maps User with OAuth provider`() { + val user = User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null + ) + + val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") + + assertEquals(user.id, entity.id) + assertEquals(user.name, entity.name) + assertEquals(user.email, entity.email) + assertEquals("YANDEX", entity.provider) + assertEquals("yandex456", entity.providerId) + } + + @Test + fun `toEntity maps User without OAuth provider`() { + val user = User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null + ) + + val entity = user.toEntity() + + assertNull(entity.provider) + assertNull(entity.providerId) + } + + @Test + fun `mapping is reversible for basic fields`() { + val original = User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null + ) + + val mapped = original.toEntity().toDomain() + + assertEquals(original.id, mapped.id) + assertEquals(original.name, mapped.name) + assertEquals(original.email, mapped.email) + } + + +} From 4f0e8ccbd40250d0e5cca4ade8fc2d44c9e7bc03 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:28:03 +0000 Subject: [PATCH 011/106] test: fix ktlint multiline wrapping in JDBC integration tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/e7455e93-ed32-49f5-9b29-dabcdcc01e87 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../jdbc/FilmLibraryRepositoryIntegrationTest.kt | 15 ++++++++------- .../jdbc/FilmRepositoryIntegrationTest.kt | 11 ++++++----- .../jdbc/UserRepositoryIntegrationTest.kt | 13 +++++++------ 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt index 232216a..d880f02 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt @@ -61,13 +61,14 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save new film library entry and return saved entry`() { - val entry = FilmLibrary( - id = UUID.randomUUID(), - userId = testUser.id, - filmId = testFilm.id, - comment = "Отличный фильм!", - isViewed = false, - ) + val entry = + FilmLibrary( + id = UUID.randomUUID(), + userId = testUser.id, + filmId = testFilm.id, + comment = "Отличный фильм!", + isViewed = false, + ) val savedEntry = filmLibraryRepository.save(entry) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt index 926177b..3c38a23 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepositoryIntegrationTest.kt @@ -41,11 +41,12 @@ class FilmRepositoryIntegrationTest { @Test fun `should save new film and return saved film`() { - val film = Film( - id = UUID.randomUUID(), - title = "Начало", - description = "Захватывающий триллер о снах внутри снов", - ) + val film = + Film( + id = UUID.randomUUID(), + title = "Начало", + description = "Захватывающий триллер о снах внутри снов", + ) val savedFilm = filmRepository.save(film) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt index e1f0cd9..02b588a 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -41,12 +41,13 @@ class UserRepositoryIntegrationTest { @Test fun `should save new user and return saved user`() { - val user = User( - id = UUID.randomUUID(), - name = "John Doe", - email = "john@example.com", - library = null, - ) + val user = + User( + id = UUID.randomUUID(), + name = "John Doe", + email = "john@example.com", + library = null, + ) val savedUser = userRepository.save(user) From c64fc35353fec000c6b2264f789cceef79801b59 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Wed, 22 Apr 2026 08:46:58 +0300 Subject: [PATCH 012/106] =?UTF-8?q?OAuth2=20(=D0=B1=D0=B5=D0=B7=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B0=20=D1=81=20java)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + gradle/libs.versions.toml | 1 + .../persistence/jdbc/UserRepository.kt | 10 ++++ .../security/CustomOAuth2UserService.kt | 57 +++++++++++++++++++ .../adapters/security/GoogleOAuth2UserInfo.kt | 16 ++++++ .../adapters/security/OAuth2UserInfo.kt | 9 +++ .../security/OAuth2UserInfoFactory.kt | 18 ++++++ .../adapters/security/UserPrincipal.kt | 42 ++++++++++++++ .../adapters/security/VkOAuth2UserInfo.kt | 26 +++++++++ .../adapters/security/YandexOAuth2UserInfo.kt | 20 +++++++ .../ports/output/UserRepositoryPort.kt | 2 + .../application/services/UserService.kt | 1 + .../project/movienight/domain/model/User.kt | 1 + 13 files changed, 205 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..609f72b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -47,6 +47,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) + implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) runtimeOnly(libs.postgresql) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..28cf8d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,7 @@ grpc-java = "1.60.0" springdoc = "2.8.6" [libraries] +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-security = { module = "org.springframework.boot:spring-boot-starter-security" } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index a6607e5..8eee586 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -16,6 +16,7 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), + password = rs.getString("password"), library = null, ) } @@ -56,6 +57,15 @@ class UserRepository( return users.firstOrNull() } + override fun findByEmail(email: String): User? { + val users = jdbc.query( + "SELECT id, name, email, password FROM users WHERE email = ?", + userRowMapper, + email, + ) + return users.firstOrNull() + } + override fun findAll(): List = jdbc.query( "SELECT id, name, email FROM users", diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..189e927 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class CustomOAuth2UserService( + private val userRepository: UserRepositoryPort, + private val idGenerator: IdGenerator, +) : DefaultOAuth2UserService() { + + companion object { + private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) + } + + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val registrationId = userRequest.clientRegistration.registrationId + + log.debug("Processing OAuth2 login for provider: {}", registrationId) + + return try { + val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) + val user = findOrCreateUser(userInfo) + UserPrincipal.create(user, oAuth2User.attributes) + } catch (e: Exception) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } + } + + private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { + val existingUser = userRepository.findByEmail(userInfo.getEmail()) + + return if (existingUser != null) { + log.debug("User found by email: {}", userInfo.getEmail()) + existingUser + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", // OAuth2 пользователи не имеют пароля + library = null, + ) + userRepository.save(newUser) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..dcff86e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -0,0 +1,16 @@ +package com.project.movienight.adapters.security.oauth2 + +class GoogleOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["sub"] as String + + override fun getEmail(): String = attributes["email"] as String + + override fun getName(): String = attributes["name"] as String + + override fun getProvider(): String = "google" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..5a155b9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -0,0 +1,9 @@ +package com.project.movienight.adapters.security.oauth2 + +interface OAuth2UserInfo { + fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..001b789 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -0,0 +1,18 @@ +package com.project.movienight.adapters.security.oauth2 + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User + +object OAuth2UserInfoFactory { + + fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + val attributes = user.attributes + + return when (registrationId.lowercase()) { + "google" -> GoogleOAuth2UserInfo(attributes) + "yandex" -> YandexOAuth2UserInfo(attributes) + "vk" -> VkOAuth2UserInfo(attributes) + else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..5fbbee1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.domain.model.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User +import java.util.* + +class UserPrincipal( + private val user: User, + private val attributes: Map? = null, +) : OAuth2User, UserDetails { + + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority("ROLE_USER")) + } + + override fun getPassword(): String = user.password + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + companion object { + fun create(user: User, attributes: Map? = null): UserPrincipal { + return UserPrincipal(user, attributes) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..492e74c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class VkOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String { + val response = attributes["response"] as? List> + return response?.firstOrNull()?.get("id")?.toString() ?: "" + } + + override fun getEmail(): String = attributes["email"] as? String ?: "" + + override fun getName(): String { + val response = attributes["response"] as? List> + val first = response?.firstOrNull() + val firstName = first?.get("first_name") as? String ?: "" + val lastName = first?.get("last_name") as? String ?: "" + return "$firstName $lastName".trim() + } + + override fun getProvider(): String = "vk" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..2929389 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -0,0 +1,20 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class YandexOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["id"]?.toString() ?: "" + + override fun getEmail(): String { + val emails = attributes["emails"] as? List> + return emails?.firstOrNull()?.get("value") ?: "" + } + + override fun getName(): String = attributes["display_name"] as? String ?: "" + + override fun getProvider(): String = "yandex" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index af8ef0a..cfefe9b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -8,6 +8,8 @@ interface UserRepositoryPort { fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index be32d6a..5eb754e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -32,6 +32,7 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, + password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..db9142b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,5 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, + val password: String, val library: FilmLibrary?, ) From bd7ecfcab5dc57f3e8ca11a688ef1f998093a728 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 23 Apr 2026 06:36:27 +0000 Subject: [PATCH 013/106] fix(test): add mockk catalog dependency and format service tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/abb7242b-7ff5-4cde-9d34-5733a7f1172f Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- gradle/libs.versions.toml | 2 + .../services/FilmLibraryServiceTest.kt | 161 ++++++++++-------- 2 files changed, 93 insertions(+), 70 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2e91484..7314bb3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" +mockk = "1.13.12" [libraries] spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } @@ -33,6 +34,7 @@ flyway-database-postgresql = { module = "org.flywaydb:flyway-database-postgresql kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect" } kotlin-test-junit5 = { module = "org.jetbrains.kotlin:kotlin-test-junit5" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher" } +mockk = { module = "io.mockk:mockk", version.ref = "mockk" } sentry-bom = { module = "io.sentry:sentry-bom", version.ref = "sentry" } sentry-spring-boot-starter = { module = "io.sentry:sentry-spring-boot-starter-jakarta" } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index a5951d4..1556f83 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -38,19 +38,24 @@ class FilmLibraryServiceTest { val libraryId = UUID.randomUUID() val filmId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - val expectedLibrary = FilmLibrary( - id = libraryId, - userId = userId, - filmId = filmId, - comment = "My Films", - isViewed = false, - ) + val expectedLibrary = + FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Films", + isViewed = false, + ) every { filmLibraryRepository.findAll() } returns emptyList() every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) - every { filmLibraryRepository.save(match { - it.userId == userId && it.comment == "My Films" && it.isViewed == false - }) } returns expectedLibrary + every { + filmLibraryRepository.save( + match { + it.userId == userId && it.comment == "My Films" && it.isViewed == false + }, + ) + } returns expectedLibrary val result = filmLibraryService.create(command) @@ -68,13 +73,14 @@ class FilmLibraryServiceTest { @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, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "Existing Library", + isViewed = false, + ) val command = CreateFilmLibraryCommand(userId = userId, name = "New Library") every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) @@ -94,19 +100,24 @@ class FilmLibraryServiceTest { val filmId = UUID.randomUUID() val libraryId = UUID.randomUUID() val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) - val expectedLibrary = FilmLibrary( - id = libraryId, - userId = userId, - filmId = filmId, - comment = null, - isViewed = false, - ) + val expectedLibrary = + FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = null, + isViewed = false, + ) every { filmLibraryRepository.findAll() } returns emptyList() every { idGenerator.generateId() } returns libraryId - every { filmLibraryRepository.save(match { - it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false - }) } returns expectedLibrary + every { + filmLibraryRepository.save( + match { + it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false + }, + ) + } returns expectedLibrary val result = filmLibraryService.addFilm(command) @@ -124,20 +135,25 @@ class FilmLibraryServiceTest { val userId = UUID.randomUUID() val oldFilmId = UUID.randomUUID() val newFilmId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = oldFilmId, - comment = "My Library", - isViewed = true, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = oldFilmId, + comment = "My Library", + isViewed = true, + ) val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - every { filmLibraryRepository.save(match { - it.filmId == newFilmId && it.isViewed == false - }) } returns updatedLibrary + every { + filmLibraryRepository.save( + match { + it.filmId == newFilmId && it.isViewed == false + }, + ) + } returns updatedLibrary val result = filmLibraryService.addFilm(command) @@ -154,13 +170,14 @@ class FilmLibraryServiceTest { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() val libraryId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = libraryId, - userId = userId, - filmId = filmId, - comment = "My Library", - isViewed = false, - ) + val existingLibrary = + FilmLibrary( + id = libraryId, + userId = userId, + filmId = filmId, + comment = "My Library", + isViewed = false, + ) val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) @@ -195,13 +212,14 @@ class FilmLibraryServiceTest { 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 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) @@ -220,18 +238,20 @@ class FilmLibraryServiceTest { 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, - ) + 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.findAll() } returns listOf(existingLibrary) @@ -246,13 +266,14 @@ class FilmLibraryServiceTest { @Test fun `should get library successfully`() { val userId = UUID.randomUUID() - val existingLibrary = FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = UUID.randomUUID(), - comment = "My Library", - isViewed = false, - ) + val existingLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = UUID.randomUUID(), + comment = "My Library", + isViewed = false, + ) val query = GetFilmLibraryQuery(userId = userId) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) From d50e2e640cf7ee48c8771d41b5144dd298a76900 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 18:47:30 +0300 Subject: [PATCH 014/106] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20V1=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=8F=D1=82=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20email=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20saveWithOAuth2()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20findByProviderAndProviderId()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20CustomOAuth2UserService=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20OAuth2=20=D0=BC=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20package=20declaration=20=D0=B2=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=207=20OAuth2=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/jdbc/UserRepository.kt | 52 +++++++++++++++++-- .../security/CustomOAuth2UserService.kt | 37 ++++++++----- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 +- .../adapters/security/OAuth2UserInfo.kt | 2 +- .../security/OAuth2UserInfoFactory.kt | 2 +- .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 2 +- .../adapters/security/YandexOAuth2UserInfo.kt | 2 +- .../ports/output/UserRepositoryPort.kt | 4 ++ src/main/resources/db/migration/V1__init.sql | 3 +- 10 files changed, 84 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 8eee586..420dbd0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -26,22 +26,24 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ? + SET name = ?, email = ?, password = ? WHERE id = ? """.trimIndent(), user.name, user.email, + user.password, user.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email) - VALUES (?, ?, ?) + INSERT INTO users (id, name, email, password) + VALUES (?, ?, ?, ?) """.trimIndent(), user.id, user.name, user.email, + user.password, ) } return user @@ -50,7 +52,7 @@ class UserRepository( override fun findById(id: UUID): User? { val users = jdbc.query( - "SELECT id, name, email FROM users WHERE id = ?", + "SELECT id, name, email, password FROM users WHERE id = ?", userRowMapper, id, ) @@ -68,11 +70,51 @@ class UserRepository( override fun findAll(): List = jdbc.query( - "SELECT id, name, email FROM users", + "SELECT id, name, email, password FROM users", userRowMapper, ) override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } + + override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { + val updatedRows = jdbc.update(""" + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + user.name, + user.email, + user.password, + provider, + providerId, + user.id, + ) + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, password, provider, provider_id) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + user.id, + user.name, + user.email, + user.password, + provider, + providerId, + ) + } + return user + } + + override fun findByProviderAndProviderId(provider: String, providerId: String): User? { + val users = jdbc.query( + "SELECT id, name, email, password FROM users WHERE provider = ? AND provider_id = ?", + userRowMapper, + provider, + providerId, + ) + return users.firstOrNull() + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 189e927..968424a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort @@ -37,21 +37,34 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByEmail(userInfo.getEmail()) + // Сначала ищем по provider + provider_id (основной способ для OAuth2) + val existingUser = userRepository.findByProviderAndProviderId( + userInfo.getProvider(), + userInfo.getProviderId() + ) return if (existingUser != null) { - log.debug("User found by email: {}", userInfo.getEmail()) + log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", // OAuth2 пользователи не имеют пароля - library = null, - ) - userRepository.save(newUser) + // Проверяем нет ли пользователя с таким email (связывание аккаунтов) + val userByEmail = userRepository.findByEmail(userInfo.getEmail()) + + if (userByEmail != null) { + // Пользователь существует, обновляем его OAuth2 данными + log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) + userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", + library = null, + ) + userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + } } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index dcff86e..fa41de5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security class GoogleOAuth2UserInfo( private val attributes: Map diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt index 5a155b9..b6abf09 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security interface OAuth2UserInfo { fun getProviderId(): String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index 001b789..e2db545 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index 5fbbee1..a4d94ea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.domain.model.User import org.springframework.security.core.GrantedAuthority diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 492e74c..47c41d2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class VkOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 2929389..467aa85 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class YandexOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index cfefe9b..0e45c1e 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -6,6 +6,10 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun saveWithOAuth2(user: User, provider: String, providerId: String): User + + fun findByProviderAndProviderId(provider: String, providerId: String): User? + fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..a94ddaf 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,8 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255) ); CREATE TABLE IF NOT EXISTS public.films ( From 312c18645b405d01a36ad6a2d3a5cdb8cb82baa0 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 23:59:16 +0300 Subject: [PATCH 015/106] =?UTF-8?q?-=20=D0=9F=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=91=D0=BD=20OAuth2UserInfo.kt=20=D0=B8=D0=B7=20a?= =?UTF-8?q?dapters/security/=20=D0=B2=20application/ports/input/security/;?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - В UserPrincipal.kt заменён star import java.util.* на явный java.util.UUID. - В YandexOAuth2UserInfo.kt и VkOAuth2UserInfo.kt убрана аннотация @Suppress("UNCHECKED_CAST") - В build.gradle.kts хардкод версии заменён на version catalog - Из CustomOAuth2UserService.kt удалены комментарии на русском языке --- build.gradle.kts | 3 +-- .../security/CustomOAuth2UserService.kt | 4 +--- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 ++ .../security/OAuth2UserInfoFactory.kt | 1 + .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 20 +++++++++++-------- .../adapters/security/YandexOAuth2UserInfo.kt | 12 +++++++---- .../ports/input}/security/OAuth2UserInfo.kt | 2 +- 8 files changed, 27 insertions(+), 19 deletions(-) rename src/main/kotlin/com/project/movienight/{adapters => application/ports/input}/security/OAuth2UserInfo.kt (74%) diff --git a/build.gradle.kts b/build.gradle.kts index 2d2ed27..a5b9744 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,8 +46,7 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - - implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 968424a..cc1cb8d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +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 import com.project.movienight.domain.model.User @@ -37,7 +38,6 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - // Сначала ищем по provider + provider_id (основной способ для OAuth2) val existingUser = userRepository.findByProviderAndProviderId( userInfo.getProvider(), userInfo.getProviderId() @@ -47,11 +47,9 @@ class CustomOAuth2UserService( log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - // Проверяем нет ли пользователя с таким email (связывание аккаунтов) val userByEmail = userRepository.findByEmail(userInfo.getEmail()) if (userByEmail != null) { - // Пользователь существует, обновляем его OAuth2 данными log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index fa41de5..c463ac6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,5 +1,7 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class GoogleOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index e2db545..89d6d30 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index a4d94ea..c602211 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -5,7 +5,7 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.oauth2.core.user.OAuth2User -import java.util.* +import java.util.UUID class UserPrincipal( private val user: User, diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 47c41d2..e2c55c0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,22 +1,26 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class VkOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { override fun getProviderId(): String { - val response = attributes["response"] as? List> - return response?.firstOrNull()?.get("id")?.toString() ?: "" + return (attributes["response"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("id") + ?.toString() ?: "" } - override fun getEmail(): String = attributes["email"] as? String ?: "" + override fun getEmail(): String = attributes["email"]?.toString() ?: "" override fun getName(): String { - val response = attributes["response"] as? List> - val first = response?.firstOrNull() - val firstName = first?.get("first_name") as? String ?: "" - val lastName = first?.get("last_name") as? String ?: "" + val response = attributes["response"] as? List<*> + val first = response?.firstOrNull() as? Map<*, *> + val firstName = first?.get("first_name")?.toString() ?: "" + val lastName = first?.get("last_name")?.toString() ?: "" return "$firstName $lastName".trim() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 467aa85..59bf3eb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,6 +1,7 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class YandexOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { @@ -8,11 +9,14 @@ class YandexOAuth2UserInfo( override fun getProviderId(): String = attributes["id"]?.toString() ?: "" override fun getEmail(): String { - val emails = attributes["emails"] as? List> - return emails?.firstOrNull()?.get("value") ?: "" + return (attributes["emails"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("value") + ?.toString() ?: "" } - override fun getName(): String = attributes["display_name"] as? String ?: "" + override fun getName(): String = attributes["display_name"]?.toString() ?: "" override fun getProvider(): String = "yandex" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 74% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt index b6abf09..c081592 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security +package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String From 8120294d47ed445209d7010cbc8bb4b774f60e60 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Fri, 24 Apr 2026 22:55:56 +0300 Subject: [PATCH 016/106] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/adapters/web/FilmController.kt | 39 ++++++++++++------- .../adapters/web/FilmLibraryController.kt | 21 +++++++--- .../movienight/adapters/web/UserController.kt | 30 ++++++++------ .../application/ports/input/FilmUseCase.kt | 12 ++++++ .../application/ports/input/UserUseCase.kt | 8 ++++ .../application/services/FilmService.kt | 36 +++++++++-------- .../application/services/UserService.kt | 29 +++++++------- 7 files changed, 114 insertions(+), 61 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 045adf6..12ffa79 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -8,10 +8,20 @@ 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.services.FilmService -import com.project.movienight.domain.exception.EntityNotFoundException +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 org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +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.RequestParam +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import java.util.UUID @RestController @@ -20,7 +30,9 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -44,11 +56,10 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = - EditFilmCommand( - title = request.title, - description = request.description, - ), + command = EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -62,13 +73,15 @@ class FilmController( fun getById( @PathVariable id: UUID, ): FilmResponse = - FilmResponse.fromDomain( - filmService.findById(id) ?: throw EntityNotFoundException("Film", id.toString()) - ) + FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping("/search") fun searchByTitle( @RequestParam title: String, ): FilmResponse? = - filmService.findByTitle(title)?.let { FilmResponse.fromDomain(it) } + searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } + + @GetMapping + fun getAll(): List = + getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 0989dff..f5246cc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -7,13 +7,21 @@ 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.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.services.FilmService import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.* +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 import java.util.UUID @RestController @@ -23,7 +31,8 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -58,9 +67,9 @@ class FilmLibraryController( GetFilmLibraryQuery(userId = userId) ) - val film = filmService.findById(library.filmId) + val film = getFilmByIdUseCase.getById(library.filmId) - return film?.let { listOf(FilmResponse.fromDomain(it)) } ?: emptyList() + return listOf(FilmResponse.fromDomain(film)) } @PostMapping("/films/{filmId}") @@ -98,7 +107,7 @@ class FilmLibraryController( GetFilmLibraryQuery(userId = userId) ) - val allFilms = filmService.findAll() + val allFilms = getAllFilmsUseCase.getAll() val availableFilms = allFilms.filter { it.id != userLibrary.filmId } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 5bdbc58..6c1a996 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -8,10 +8,18 @@ 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.services.UserService -import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.application.ports.input.GetAllUsersUseCase +import com.project.movienight.application.ports.input.GetUserByIdUseCase import org.springframework.http.HttpStatus -import org.springframework.web.bind.annotation.* +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PatchMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController import java.util.UUID @RestController @@ -20,7 +28,8 @@ class UserController( private val createUserUseCase: CreateUserUseCase, private val editUserUseCase: EditUserUseCase, private val deleteUserUseCase: DeleteUserUseCase, - private val userService: UserService, + private val getUserByIdUseCase: GetUserByIdUseCase, + private val getAllUsersUseCase: GetAllUsersUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -38,15 +47,13 @@ class UserController( @GetMapping fun getAll(): List = - userService.findAll().map { UserResponse.fromDomain(it) } + getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, ): UserResponse = - UserResponse.fromDomain( - userService.findById(id) ?: throw EntityNotFoundException("User", id.toString()) - ) + UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -56,10 +63,9 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = - EditUserCommand( - name = request.name, - ), + command = EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 3622878..db3f4b0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -27,3 +27,15 @@ data class EditFilmCommand( interface DeleteFilmUseCase { fun delete(id: UUID) } + +interface GetFilmByIdUseCase { + fun getById(id: UUID): Film +} + +interface GetAllFilmsUseCase { + fun getAll(): List +} + +interface SearchFilmByTitleUseCase { + fun searchByTitle(title: String): Film? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index c889946..b417525 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -26,3 +26,11 @@ data class EditUserCommand( interface DeleteUserUseCase { fun delete(id: UUID) } + +interface GetUserByIdUseCase { + fun getById(id: UUID): User +} + +interface GetAllUsersUseCase { + fun getAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 6ed4748..555655a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -5,6 +5,9 @@ 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.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties @@ -21,7 +24,11 @@ class FilmService( private val filmConfig: FilmServiceProperties, ) : CreateFilmUseCase, EditFilmUseCase, - DeleteFilmUseCase { + DeleteFilmUseCase, + GetFilmByIdUseCase, + GetAllFilmsUseCase, + SearchFilmByTitleUseCase { + override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -30,19 +37,15 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -51,21 +54,20 @@ class FilmService( } var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - return filmRepository.save(film) } override fun delete(id: UUID) { filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) } - fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun getById(id: UUID): Film { + return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + } - fun findAll(): List = filmRepository.findAll() + override fun getAll(): List = filmRepository.findAll() - fun findById(id: UUID): Film? = filmRepository.findById(id) + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 71fb6ca..4d16ea1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -5,6 +5,8 @@ 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.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.UserServiceProperties @@ -21,19 +23,21 @@ class UserService( private val userConfig: UserServiceProperties, ) : CreateUserUseCase, EditUserUseCase, - DeleteUserUseCase { + DeleteUserUseCase, + GetUserByIdUseCase, + GetAllUsersUseCase { + override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") } - val user = - User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - ) + val user = User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + ) return userRepository.save(user) } @@ -46,19 +50,18 @@ class UserService( } var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) - return userRepository.save(user) } override fun delete(id: UUID) { userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - userRepository.deleteById(id) } - fun findAll(): List = userRepository.findAll() + override fun getById(id: UUID): User { + return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + } - fun findById(id: UUID): User? = userRepository.findById(id) + override fun getAll(): List = userRepository.findAll() } From f563c3e7cfefdb898eae7250f40b5e680591516c Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Fri, 24 Apr 2026 23:47:56 +0300 Subject: [PATCH 017/106] =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../persistence/jdbc/FilmRepository.kt | 11 ++--- .../movienight/adapters/web/FilmController.kt | 42 ++++++++++++++----- .../adapters/web/FilmLibraryController.kt | 37 ++++++++++++---- .../application/ports/input/FilmUseCase.kt | 12 ++++++ .../application/services/FilmService.kt | 36 +++++++++------- .../controllers/FilmControllerTest.kt | 8 +--- .../controllers/FilmLibraryControllerTest.kt | 38 +++++++++++------ .../controllers/UserControllerTest.kt | 22 +++++----- 8 files changed, 137 insertions(+), 69 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index a13f721..9f4d80b 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -62,11 +62,12 @@ class FilmRepository( ) override fun findByTitle(title: String): Film? { - val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", - filmRowMapper, - title - ) + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", + filmRowMapper, + title, + ) return films.firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 659fdf6..df375e4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -1,6 +1,5 @@ package com.project.movienight.adapters.web - 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 @@ -9,17 +8,21 @@ 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.services.FilmService +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 org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping 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.RequestParam import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -28,7 +31,9 @@ class FilmController( private val createFilmUseCase: CreateFilmUseCase, private val editFilmUseCase: EditFilmUseCase, private val deleteFilmUseCase: DeleteFilmUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -52,11 +57,10 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = - EditFilmCommand( - title = request.title, - description = request.description, - ), + command = EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -66,9 +70,25 @@ class FilmController( @PathVariable id: UUID, ) = deleteFilmUseCase.delete(id) + @GetMapping("/{id}") + fun getById( + @PathVariable id: UUID, + ): FilmResponse = + FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + + @GetMapping + fun getAll(): List = + getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + @GetMapping("/search") fun searchByTitle( @RequestParam title: String, - ): FilmResponse? = - filmService.findByTitle(title)?.let { FilmResponse.fromDomain(it) } + ): ResponseEntity { + val film = searchFilmByTitleUseCase.searchByTitle(title) + return if (film != null) { + ResponseEntity.ok(FilmResponse.fromDomain(film)) + } else { + ResponseEntity.notFound().build() + } + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 32937c6..a4a6175 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -7,11 +7,13 @@ 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.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.services.FilmService +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 @@ -21,7 +23,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.bind.annotation.* import java.util.UUID @RestController @@ -31,7 +32,8 @@ class FilmLibraryController( private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val filmService: FilmService, + private val getFilmByIdUseCase: GetFilmByIdUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -58,6 +60,17 @@ class FilmLibraryController( ), ) + @GetMapping("/films") + fun getAllFilmsInLibrary( + @PathVariable userId: UUID, + ): List { + val library = getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + val film = getFilmByIdUseCase.getById(library.filmId) + return listOf(FilmResponse.fromDomain(film)) + } + @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( @@ -89,13 +102,21 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val userLibrary = try { + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + } catch (e: EntityNotFoundException) { + null + } - val allFilms = filmService.findAll() + val allFilms = getAllFilmsUseCase.getAll() - val availableFilms = allFilms.filter { it.id != userLibrary.filmId } + val availableFilms = if (userLibrary != null) { + allFilms.filter { it.id != userLibrary.filmId } + } else { + allFilms + } return availableFilms.map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 3622878..db3f4b0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -27,3 +27,15 @@ data class EditFilmCommand( interface DeleteFilmUseCase { fun delete(id: UUID) } + +interface GetFilmByIdUseCase { + fun getById(id: UUID): Film +} + +interface GetAllFilmsUseCase { + fun getAll(): List +} + +interface SearchFilmByTitleUseCase { + fun searchByTitle(title: String): Film? +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 9410c3f..555655a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -5,6 +5,9 @@ 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.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties @@ -21,7 +24,11 @@ class FilmService( private val filmConfig: FilmServiceProperties, ) : CreateFilmUseCase, EditFilmUseCase, - DeleteFilmUseCase { + DeleteFilmUseCase, + GetFilmByIdUseCase, + GetAllFilmsUseCase, + SearchFilmByTitleUseCase { + override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -30,19 +37,15 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -51,19 +54,20 @@ class FilmService( } var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - return filmRepository.save(film) } override fun delete(id: UUID) { filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) } - fun findByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun getById(id: UUID): Film { + return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + } - fun findAll(): List = filmRepository.findAll() + override fun getAll(): List = filmRepository.findAll() + + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) } diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt index c28b5f5..0784280 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -26,7 +26,7 @@ class FilmControllerTest { fun `create film should return 201 CREATED`() { val request = CreateFilmRequest( title = "The Matrix", - description = "A computer hacker learns about the true nature of reality" + description = "A computer hacker learns about the true nature of reality", ) mockMvc.perform( @@ -40,7 +40,6 @@ class FilmControllerTest { .andExpect(jsonPath("$.id").exists()) } - @Test fun `edit film should return updated film`() { val createRequest = CreateFilmRequest( @@ -58,7 +57,7 @@ class FilmControllerTest { val editRequest = EditFilmRequest( title = "New Title", - description = "New Description" + description = "New Description", ) mockMvc.perform( @@ -71,7 +70,6 @@ class FilmControllerTest { .andExpect(jsonPath("$.description").value("New Description")) } - @Test fun `search film by title should return film`() { val request = CreateFilmRequest( @@ -94,7 +92,6 @@ class FilmControllerTest { .andExpect(jsonPath("$.description").value("Dream within a dream")) } - @Test fun `search film by non-existent title should return empty`() { mockMvc.perform( @@ -105,7 +102,6 @@ class FilmControllerTest { .andExpect(content().string("")) } - @Test fun `delete film should return 204 NO CONTENT`() { val request = CreateFilmRequest( diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index 39b795d..d24e60e 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -9,8 +9,9 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest @@ -24,46 +25,57 @@ class FilmLibraryControllerTest { @Autowired private lateinit var objectMapper: ObjectMapper - @Test fun `add film to library should work`() { - val userRequest = CreateUserRequest("Film Adder", "adder@example.com") + val userRequest = CreateUserRequest( + name = "Film Adder", + email = "adder@example.com", + ) val userResponse = mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(userRequest)) + .content(objectMapper.writeValueAsString(userRequest)), ).andReturn() val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() - val filmRequest = CreateFilmRequest("Library Film", "Film description") + val filmRequest = CreateFilmRequest( + title = "Library Film", + description = "Film description", + ) val filmResponse = mockMvc.perform( post("/api/films") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(filmRequest)) + .content(objectMapper.writeValueAsString(filmRequest)), ).andReturn() val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() mockMvc.perform( - post("/api/users/$userId/library/films/$filmId") + post("/api/users/$userId/library/films/$filmId"), ) - .andExpect(status().isCreated) + .andExpect(status().isCreated()) } @Test fun `remove film from library should return 204`() { - val userRequest = CreateUserRequest("Remove Film", "remove@example.com") + val userRequest = CreateUserRequest( + name = "Remove Film", + email = "remove@example.com", + ) val userResponse = mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(userRequest)) + .content(objectMapper.writeValueAsString(userRequest)), ).andReturn() val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() - val filmRequest = CreateFilmRequest("Film To Remove", "Will be removed") + val filmRequest = CreateFilmRequest( + title = "Film To Remove", + description = "Will be removed", + ) val filmResponse = mockMvc.perform( post("/api/films") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(filmRequest)) + .content(objectMapper.writeValueAsString(filmRequest)), ).andReturn() val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt index df266fe..db2eecc 100644 --- a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -9,8 +9,11 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest @@ -28,13 +31,13 @@ class UserControllerTest { fun `create user should return 201 CREATED`() { val request = CreateUserRequest( name = "John Doe", - email = "john@example.com" + email = "john@example.com", ) mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)), ) .andExpect(status().isCreated) .andExpect(jsonPath("$.name").value("John Doe")) @@ -42,18 +45,17 @@ class UserControllerTest { .andExpect(jsonPath("$.id").exists()) } - @Test fun `edit user should return updated user`() { val createRequest = CreateUserRequest( name = "Old Name", - email = "edit@example.com" + email = "edit@example.com", ) val response = mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)) + .content(objectMapper.writeValueAsString(createRequest)), ).andReturn() val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() @@ -63,7 +65,7 @@ class UserControllerTest { mockMvc.perform( patch("/api/users/$userId") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(editRequest)) + .content(objectMapper.writeValueAsString(editRequest)), ) .andExpect(status().isOk) .andExpect(jsonPath("$.name").value("New Name")) @@ -74,13 +76,13 @@ class UserControllerTest { fun `delete user should return 204 NO CONTENT`() { val request = CreateUserRequest( name = "User To Delete", - email = "delete@example.com" + email = "delete@example.com", ) val response = mockMvc.perform( post("/api/users") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)), ).andReturn() val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() From c609bd1e3cbd72e9dbbe839a76c02c0922e5cf78 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 26 Apr 2026 19:25:43 +0300 Subject: [PATCH 018/106] ci(): added basic CI: build, test, docker build & push --- .github/workflows/build.yaml | 50 +++++++++++++++++++++++ .github/workflows/ci.yaml | 31 +++++++++++++++ .github/workflows/docker.yaml | 74 +++++++++++++++++++++++++++++++++++ buildPipeline.yml | 28 ------------- 4 files changed, 155 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/docker.yaml delete mode 100644 buildPipeline.yml diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..4cb473d --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,50 @@ +name: Build & Test + +on: + workflow_call: + outputs: + artifact-name: + description: "Uploaded artifact name for downstream jobs" + value: ${{ jobs.build.outputs.artifact-name }} + +jobs: + build: + name: Build & Test + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + artifact-name: ${{ steps.meta.outputs.artifact-name }} + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build + run: ./gradlew clean assemble --stacktrace --no-daemon + + - name: Test + run: ./gradlew test --stacktrace --no-daemon + + - name: Set artifact name + id: meta + run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.meta.outputs.artifact-name }} + path: | + build/libs/** + build/reports/** + build/test-results/** + retention-days: 7 + if-no-files-found: error diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f4a3f37 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,31 @@ +name: CI +run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" + +on: + push: + branches: [develop, main] + tags: ["v*"] + pull_request: + branches: [develop, main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Test + uses: ./.github/workflows/build.yaml + permissions: + contents: read + + docker: + name: Docker + needs: build + uses: ./.github/workflows/docker.yaml + permissions: + contents: read + packages: write + with: + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + secrets: inherit diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 0000000..21e61b8 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,74 @@ +name: Docker Build & Push + +on: + workflow_call: + inputs: + push: + description: "Push image to GHCR" + type: boolean + required: true + outputs: + image-digest: + description: "Pushed image digest (sha256:…)" + value: ${{ jobs.docker.outputs.image-digest }} + image-tags: + description: "Comma-separated list of applied tags" + value: ${{ jobs.docker.outputs.image-tags }} + +jobs: + docker: + name: Docker Build & Push + runs-on: ubuntu-latest + timeout-minutes: 20 + outputs: + image-digest: ${{ steps.build-push.outputs.digest }} + image-tags: ${{ steps.meta.outputs.tags }} + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Validate Dockerfile + if: inputs.push == false + uses: docker/build-push-action@v7 + with: + context: . + file: ./Containerfile + call: check + + - name: Log in to GHCR + if: inputs.push == true + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + if: inputs.push == true + id: meta + uses: docker/metadata-action@v6 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=ref,event=pr + type=sha,prefix=sha- + + - name: Build and push image + if: inputs.push == true + id: build-push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/buildPipeline.yml b/buildPipeline.yml deleted file mode 100644 index da17798..0000000 --- a/buildPipeline.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: CI Build - -on: - pull_request: - branches: [ develop, main ] - push: - branches: [ develop ] - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup JDK 21 - uses: actions/setup-java@v4 - with: - java-version: '21' - distribution: 'temurin' - - - name: Grant execute permission for gradlew - run: chmod +x gradlew - - - name: Build project - run: ./gradlew build -x test - From 2525f2f959f7cdc4474acbd80dd532d3e6e0f39b Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 26 Apr 2026 19:59:17 +0300 Subject: [PATCH 019/106] chore(gitignore): added wrapper to git --- .gitignore | 3 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 46175 bytes 2 files changed, 3 insertions(+) create mode 100644 gradle/wrapper/gradle-wrapper.jar diff --git a/.gitignore b/.gitignore index ee59915..795dcc9 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ gradle-app.setting *.tar.gz *.rar +# Gradle wrapper +!gradle/wrapper/gradle-wrapper.jar + # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..61285a659d17295f1de7c53e24fdf13ad755c379 GIT binary patch literal 46175 zcma&NWmKG9wk?cn;qLD4?(Xgo+}#P9AcecTOK=k0-KB7X7w!%r36RU%ea89j>2v%2 zy2jY`r|L&NwdbC5&AHZASAvGYhCo0-fPjFYcwhhD3mpOxLPbVff<-}9mQ7hfN=8*n zMn@YK0`jk~Y#ADPZt&s;&o%Vh+1OqX$SQPQUbO~kT2|`trE{h9WQ$5t)0<0SGK(9o zy!{fv+oYdReexE`UMYzV3-kOr>x=rJ7+6+0b5EnF$IG$Dt(hUAKx2>*-_*>j|Id49Q3}YN>5=$q?@D;}*%{N1&Ngq- zT;Qj#_R=+0ba4EqMNa487mOM?^?N!cyt;9!ID^&OIS$OX?qC^kSGrHw@&-mB@~L!$ zQMIB|qD849?j6c_o6Y9s2-@J%jl@tu1+mdGN~J$RK!v{juhQkNSMup%E!|Iwjp}G} z6l3PDwQp#b$A`v-92bY=W{dghjg1@gO53Q}P!4oN?n)(dY4}3I1erK<3&=O2;)*)+_&gzJwCFLYl&;nZCm zs21P5net@>H0V>H2FQ%TUoZBiSRH2w*u~K%d6Y|Fc_eO}lhQ1A!Z|)oX3+mS``s4O zQE>^#ibNrUi4P;{KRbbTOVweOhejS2x&Oab?s zB}^!pSukn*hb<|^*8b+28w~Kqr z5YDH20(#-gOLJR&1Q4qEEb{G)%nsAqPsEfj9FgZ% z5k%IHRQk6Xh}==R`LYmK?%(0w9zI}hkkj|3qvo$_FzU9$%Zf>(S>m|JTn!rYUwC)S z^+V+Gh@*U(Za&jUW#Wh#;1*R2he9SI68(&DeI%UQ&0gyQ73g7)Xts{uPx^&U`MALc)G9+Y<9KIjR1lICfNnw_Ju8 z-O7hoBM!+}IMUYZr29cN{aHL&dmr!ayq7;r?`7M3z+L@~Fx4o}lk{l?0w3=rqRxpv z0Tp-ETUvB<*2vTh_dr%}Lfx)%pxlb$ch}yCCUz6k4)hyMJ_Lq$SS(Rd8aWG-K{8TD zDUtTM2SQ|y5F;}M&9eL-xGpj#vTy0*Egq$K1aZnGq3I^$31WARgcJUb0T*QaRo~*Q*;H_Jc_7LeyDXHPh?}Ick1s{(QZWni3%OL|i zJ7foQ%gLbU+dOZP7Z^96OoW5YbS=0%+#j3#o3bYsnB}Ztbu_KuFcBz9M~>z z{s?I|KWR0CJT6eqNlIj57Jq@-><8 zV&>W=5}GL`X|of9PiXwZaoKWOehcgaB1!y0@zY^+$YFgk3UB@$4#qATzJk?b^M#iL zKe}&w?|SGj<-3Z>pDd^+G3w_>76zq%EZGhqzOYx6YQgnb;vA^%6(Sx4?gytM=^m`C z@c+mG0LSQOqF$oK!j8-B4hG`=`%8Hp#$+IvanscDc42T#q4=v2YuoSZd{VS%kBNtx zLd6U%s>y+0*0?dDt&wJ`=F&iRWyJS1Y>kZds97Z^J?Kmeu!Fh-L+F9?o#ZILhhvI& zyE^o10y()W>x@1skNd<(ehL$G%S9yZ>AxGNktZ_$h9RD?hd_YxvNIeb?3~*XE*54b z;}9`U&d_XFzBbijUqrX}i?s24Ox?EOfTz$aTz;dtw~F)!(XK9voHS_ii|YmI?eRrX z%Gr=T-7Qx7eB&|iMk+jCw4x6X6Hae`0esw}b;uVy6ljeACOq{ZM6e`2k%XdE* zcZotR`H{lmO?;6sfMz|Xv|aJ!F2{Ucp1Y5HM68;}hw4h%ntF`pl0QNFk@W?2S67+W zF1AU5YS7<_7H6+NrwMJ)&D8^-Sgj_rttU*gt3dvWH^sG8W6BbhtT{Lm3VV5cSo;$3 zNuSXq<>-4y>$9__aC`0aka&~k=}#N;Co3O<6()7bWgAZuB~%E!lv`DCbEMM)G$IQ< z*b89{3RV{((?H&X1kBl8+K_XHL`Hc=25|M6Djk8YZUc&s3Ki&|KcOb&!$LVf5~6*K z>pgW7g-7ASM5ZZ5?Ah_e13r7Z98K>?leVWPNQs_MXx_&Ftg92|SR`xrt$4|%fVGS- zTNZt(a#pl7RaYzzJlX1vk0kt*Vpxw_{M%KG%Q}`scIVU

pVX@HRij*jw$g4?}Pn zE7RuaO3V!l_a{`|jsZVjZSR#tYwAffrvo3AAynZ^vzgSR#N_HZ6Ark)t{_hJ^zSa( zT@R*X#7rxlaj%ZVUZ1?7!Q9{bw(p9N;v)bZUqGgPC=O&mM zRy{1k%Hlr=aPWCif%s7!4cpn_cTyB1=#k?e8m}0C$)+&PD!&)F?>9;L&0Lpv)ZfP| zJxlb;PjKA4x^1R%?vIk=kv;C0Y*;|7*_mO)hTMlfPH5JcHa>0BR$wlt@&-wZufD82 z51*ufTeW5&M!0=a$FS@0MJRlk*~l8^Wl?2mzt}H8ae}hQ7tSz0sBJs+8lQ!`o(21B z@HNyMoH{;2l$8FopO-a)0DQ&f_jq)|ZPO}_AjDPtuOl4>R^0rLnok(Ezuu@$4lJ`w zQ6-4DQIk{FwQJspTlz!>L$CVj^cN<|)t^;jR~M^L^a=dr5aA!{qg3Ek9p;X{QRIg1 z1oE`2L#=6s6vh%=R(TI9Z5ReZy&?Jtj8aEcyCiP*YaYk5=!QbxQSz|aBk58{{@nCc zSY}$niG-_Uad_iRV56Ju8STIoe{*WWn3_?3>0V>z8)z@g_|dm5vKgxu`{>`)X}aw) zyd~I|(HFpmTO&3smRUnoB$VU&snAXEY(aq=te76JpanOdrwx}UD4D8MQ34z&zcD8z><`W?<_; zvO01*U(i7v7=EAJ@&YE- z4Cz5FWI`J^+_;Ez1p&jMET;4j<<0ymV(~ma*ooWab$s6DuWt>sP0$fuap>j|b@rOb zu^i4yE`d@_H>;F8*y;JfvhSY_o*1uZB+)0G+l{2nmbRR>POBwArWP}e z*`!BSjr`p73wW@iA~}h|mFJDOdP|bAlqD)jwN_vU{ z0ntkb0iphH{UY}N?H5%fR25`pw6s}OWdGYUvdqjNg|VZ<>;{luC*iGup0bRpG-1*u zLmD>P9mq$M!k->%T2{@Ea^ZR|8LZp2lzpBQFAfvFIUps_-Vxkm4ldisDdti7Bn(qo zAYco0<;Bu1tt6?z=(H_4yD~5qL+2##Hfo|6qRB-vFmQ}Xpo&Qc^GdrM6&iQtrIVT_ z6q)qyz^vmNwsqEnS6Vw6kZ1XSL;dx94s%n6>F=ht<9+@6=i_*PK35N0Hd_yKD<^9< zODB6aDOYD_a~CURdlzd74_j|%YZosWKTB&jFMC%PR!b*yPtX5;conr7MQ9H6g65XG z7EMw%FD|O_`*U$^ye1(o}oGT&v6r7mQ)iC|9t;%`Wt_`W`dAAT;#O+)Ge! zPY6Umf)7Er6YsZ!=pEz^$%f~wDcEbz?9OR@jjSa(Rvr03@mNYZ%uLF}1I$B4Hj~*g zWOL7pdu2IQtK=^>^gM(G`DhbFDLZd6_AD4bHKi+I<{kGj!ftcccz}667=-{}7`0~m z(VVjxK=8g9faw}91J}cSq7PrpJi3tMmm)~lowHDOUZfP++x{^vOUJjZXkhn7qE^N! zV)eH6A;SGx&6U&c1EFgS6CAwUqS$$N)odq!@3|yVs}Lv@HEcBe?UTqFr9Nyab-F_) zNOXxFGKa2*Z|&o&`_h+{qBoSkb^_~=yo&NYU~qe1|9&TE|8^(T{$GE;wbq8_qB^!o zWNUaUctH}Q+oBtk0YrkWOS_G@9aP2`<7DUWB~FndluuPn;S@}GiG2Iia25p++<(6C zea7mI68gN(*_{_OvF&*I?P;Q+ZzmWcYlw2__v`ENA>SnKs!v266LL&z9X9riJ-15i z?+VKr6gj*!-w2v^x)aO%fNEX5_4-u@zsW(~Hen6*9N_w{$})i6E2y4Z$h5?;ZS!i! z#Q>M4TTsuI9=p|iU9!ExS=~piozz{USJ)(nwWf1TYy0Ul2epIh)bcRZA|?PU!4VrJ z^E`vzA;ZAfgAm2#Tu0K-8E!~1iW6{oBl4lS-5Fc2%_saw>BKrIuW`^4za9w7veO)+ z)~?rp*f&V-xoXD~e%a9Df~ixzE@AMs{a8am6R+SXhXPfqv!>(-9^g7!X;m~14_ReuNF;J z{)~ysZBHLY*>ow*`^ie7bhc3H$N1qVxaGt6xFusWF%owkNrl|{nn?h~fjxFur;u%{ zPf10%f#iPYY|=!*HH!WbI~jskWo9 z%vV&6J9*nXeR4B9>xWboSk9Eo;%Rc=iE)t~UQbj~kZ}4=;KwNN^|%wM#RG(8q5C1k z>f6|ABKw4TzF_F&4eI{KI~)AqlIA;D%ZP^dwp;M?kIJM*Nn1jZu`KDt@GR-|U9|cI z1nW&P8r5WLE6a}#e-Ogslihm9#r{J2n@QFmcUAr#tQi)Hpw4ELC$U8t>j~4TVQMBeq1ZPK`deHgU!QY`%5H8F{fX}O}fV)= zw|oE_A51>pxJ5Kp`wcemi6jERtbEsty7FV`lJt6lR?dhxnyg>(GW9ZID_9Ii$2i#G zdN8@uX$m?D%-Eq1v57~V)v%f8Se#&b=gLhg@U ze$?D?oYb{i2w@tccty}{bKwjeaiTuuL?Y(;;{c#-8v&4O?%RgKiToLey0P8POL9Kwj|;h#ul~;=V1gq!oLVrP zlwx-xwyB=#A|5Bw>09TQ+~jkdmGnJ$YrZ%|h0VcBeiw@b^J+BlumSY_)*u&%R)>JW z7(0lRtg+C9u68--7Kw&9^AeL`o5cpi$Cy>&&kBT$@!Nt_@iuYI<_q4`b~7LsTn<38 z@q_=pRRz<8vLEbi`ICI> ztVoyd+|~B7*q`1YG&7_fPT`QJ3v;k-%itr5x!$sYj;Y?a>MMPep@UxVTF#+1EV!N> z_6H2hN=N0Xcd@IV%9NJvYR74G?Ru3xuB)BwZmD7Zq}qomtW}na^#(qbREUPzmYN6p ziyU)gFriO8NCoWQj0cX0evy`_iBWmXRAqjv1s zUZv#j5;NRuz6K0Q1#jyMzmijh*97>D-0HyQpPUWas$-Ay(?|{416{@{5KP2ka?PEc zP8oI%1X4Fzj3>}EjfCUk#(+zT!v(}iw3p$!^Q@S^2sG(pZFxXmvZD}i1S#$t^890< z{qTT~_hK@t_;8eCDm(0+KRWb6`iW#<@oqli&F&)ud!?o@d#&sm5DU${T#J~}D*(W+tb(BT9{p5*$hl>S5#Xso0)3^_UA8`Gf}moKyx7WW&Za0bEVdTef`-Tw?^P zr({3nnvcOQnn@C^v4ZlJ=yE#rD^h{bm(KZBy#fUGpq~?g>prt}JS^tFeS?=|m?BaE zJ@8ZH<}v0~>8VyqJvJ#}R!cY&OHr9QC&Le-`&+%tpxZJGbNA}s(-?PsV!b$q%&_0+ zC$k1nfCE(B(j~5wJeTrsc466K?t9o4ZikU!~82D-nTxfSLC5X_z)Z!-7`Mxl(>;hU& zwS|rLUmoy3J@!cI)A2T1H2*w45C!(c8--k%iCVGPe+S%NbpuMfDLuXR2R<(-Sw*)Q7->L{-s5w3mfX% z?>dwU|98h&rogmI~+Qsg&`Cy24+@ zI~yTIuWMrcD~v&N)2vQrT9SR!dG`fB?z&e!-|lV$LSR7AG(bHzQ_;o8Ks!klRZlHs z@5q$YVtIP|a<0ze&Q5FD#f;Ht7tgR7)XE`-e2 z5vVHX7yNJH@VDzGGCwD3&Cv(4HA~0rre@MyJY3FgVyd_{ea3O;yVeEQJ4*-)5qs33 zN70F!zWStyRS@NYDW+6gDxGw=`~nt08}PMWhCD6!_JVcmsBLH{IV-gSc^LgclTkID z#*&}F&%i9%MP&SES zMzGEc)ZNPy=Pe~PxMIJEGf}r)daA7PevJ z9~2FSl=99aB`|MZDS^cR*40E>X4EU#m6FHPsurfX_nA42aR38WBr`!09eh=CTMTU4 zl~%%^;KR5%NlSXF?X@|}Nzv4dcNN+y5A)(8=UF7z_hF-i$MKDqj$UVS0g-WPyV6OL zuL{5wAthWbw>!-gJc}jYTscv0L})-yP{rUPfv+k9P(53RgvQc{t83(%8=TWEnJ)wh!#>`}qP_=0d( zpXBD5ujnfd8S4dSaF&g4qmxD%ZcDIqHsbGQdogW$0;r7pe{%LxZvJL` z)Sw{e>}9oM@k=(Jszzv1@-s+_s(2(wE3G)fjDXHCM`v_@jV67e?bV5N-QD0$C3zKK z-N)guBD&o&G#=>Pdw8OLjXj44&;h>!YZkRl>@noB4|)5}Ii9GhIkpa4&kWOcOhyRr zYx5XE6Z?9%mXL=$4#3A_%wWajqR1kAHqKxmm$x5@7@e3hWo_MNdf6MM9_$VgpoL*$ z(q{CFrM2<>{&S6Y`Toe=szf)7`jYyq-w&el6W+@arE9)tXY|B9U+jR~$~pq1W1&4( zf1+!D9CG<}H;#`2V#UaNc~{l_5Ivd<$=ro0i`rjH&%*uOT(BN-<|^pgFE!NF@KU5* zj~NZ;r9SIE?q%=3o+iJq==Y@ncGrYy%J1c~_suJ-ISHZ8;}7Ze!05^VW#JnSZ{I*& zIh*vqjYFYI!RPlGne6eHPoDm#*a$UbxXeR}t=rDi%u@AYv^@enQ$TaphrriwAw^mOF=o zL4X{Io~71KNrW8qCZt1ZAB`G432Db(WnJIQ9Xk;|poyayjFsO+K(=F|m6yMLxTfq2 zhmA&U#r#NiiRz~z8p#Dq)Z<0#?5fl-h3c zk>UdIdslOZew?=b_};J6j3dtba-*VcI`qcbk;`^8>kFo9S}}Tt9TLu=Z1ztD2YHPu zSZgnhwj72$6Yfmz|3b25Ha>8oD1+a}*z1w7`#@Py95vVcvT9dWRWBso7}3^OX!<5J zFcKmCk8_mJw*DB@`1;2cs z{yw*z5cIMwIsSwBJT&y%JBO71bq8VD$xeovL@et#f6tiC#UiA3`K|1TtQDghPWN8P zEdjNjpM*NYM&Wyck2a`6H)|X}!r?3)uN- zo_>B9W*}-{yshhLL1%rV{8BzHnQYJXCX7}POY9l?MPqbvfq+{Hef^*yK&|jtpz=8H z_xgmW~dlvT_#3qXgYW<(+du)1J=XdbY5|3?mgBC!dit@|i1pYvZ=t));Ws^GhP?7etFJ#A8#?jg99r^mOhBAF0jXRypO-&E7a&sa$~AcYYwYm|HmNboB84e)(T zMbK`=mwl{EXTkYc^^u;wdYm$I2%i?8R^+Xf1%XhS$iBcj=n`dTA0<<%tBGKw#pH_< z7yYlWMvJ8ygFM>pK6F^?P(R_40w80B#^gTpEC+Vb&&-!6^q&-vYPz)}``@sQ%YNR_ zNOaXl*@?QG{lR#3Gsel}$Q`3G)^I1q+oN;@z?#FkR0;YMyIDh(oqHLUT< zk%gnOLPl=j+HtG?g_Bx{A*S_^p$TG^ut?Hm$v?F`vMkXn_0D5fYW{-H;0MI!vWi7E zW&b|5>`<5JSg1K8FkRW`QJo!YzAX9xSr!^0mZUEfk+e_~Hmy%77CP-~XCFy_R*4Ny_`rntN5nAV}SQ6N8Kqw_8j7b%7ZDR?e^>X8K<8bXzAdC{U zbZE%9m#;pqPn(rbEIJk19@n!JN~SaxS$`yFfwM#h&6bLdZ|{BnweivPwU}5iB>tH2 z(DDBM^0Zt_|Dy<)@T|GowT3~5P4IWdOi;~Y6(Z-Ao7$ppc<*sKv0DE2 zQ7fJ1S??EtK+|tfC`0&UMEUqs_0z_`Tr-_=AzULJshV->?K>ppr+5%W&=*Se!)<}1 zK+gBXZb=Qr43OMnp>Vd>VvP)(DB)hLH~_LNbUK&g#Uu=wSZ1f)8T(5(=Gf2ks`Qa{xr90g&RZXd!6JA1Aw zH~bvvn5N$5qQCvfR*XVJ6iySM_p3Q6jj2|AA&s@!J8y>W`{M#gi1*@29nCFLvMWUb5-6g;Dkqe-W%-k<t{j$y~ zZ7Jv-AR3~g)EWPXi8B5gmP=?)iT9XMa^Qn@Af zcoYxd6o}pTBdGwc$_4n>X5-}pENro_;kLbQq#Dhu>sziG^)7u&Xr2tw>{M4F<>)%h z*d@4(v_5g`Ak*QtHlqz^vB9PvwxsxB4q`LjQ9BXRa9v*#!u0RuEzlJ)ycVg!jAzM< zYV{~*@!zH&U&Ky~T$-R{;HFjsr=cfwi1SeDIht|kx#-D|XfF8RB4qEs!reEjM<8hv zU=xYuWa`j&_=@NplwLBteU%fmX+IHI4fhNhJ(9zDJt6~n@mvvoH+3AG!+P>6J zoG)X6Iw7fjttAl^B_}-c(@4+*+h?Ha7Qe8QVJ}i!j`ualoyv4$& zTM5iU^f(^;K#s+&Qy=p_&aT6e@joE3-5OeTOqCbNH~Pmb+&wu*+Uz_5&+87~+0ARQ z-azQa1RfyT*cjWoYYQtMYJ{x=QO^7#VGg+K^X1L>lgQSiibOYd!ftWVlqi~aDO=o- z+b(cjHc_b9&hB%0moVs3e~5e42#vIrUbmI)E&zIrg7U)iRg@&c_Im;P!V|MaVmROn z?(JpEilGtTNb(aa@@UfeGqinFWh)iFm#LwOlE)&3%1~3TQSZ6O+$L@Lu`y7R^%~B7 zE}woyC&?yDU{|jD)NRh;$_FhR(|uJmsygG?T>{I2e56P`okogpWz{AU=73=yy67$ zcC?$q5B2xzV+^K8>>@tTcR2t~S#l77fpjIs0i$7=-9#ZS6mO&XpEqzg&DE)guyYm} zBoC;IEiNnv+0Qh}gVI%z<>#T09$#O%uyxfmobpOu2;?=Z-aZz6=B6kz5tC@rCfGX) zm<}1)3w~Ak;sJLFb4YQ8qVXCvDPZy^^(`&U1ynG$w4j!T$Pp2^f@mf0->j*ie}?xL z7WKMq_bK0TX!EyC5YGREoBl@HlmF3q9iv-mHLP2?PR$&VVlu(2lhn8^qDPP!iGg?h zzIDo*qoU|zggy^{%OZ?O8VEtAn78x`78Z~9{lSORlH*gcFFj!%J4HSZEP6Hzx`^H{LQLn>9BZE|(h!O@#5EOOBZcF z6-BayPVRUt0FB1~Gxql91k3tCxa8S(1yF5Zj?JXj^bmd60?)O(ng`Cu$~PW3dr}X8 zN0(%@SE59PaYtS_2R@rPDH1?-YAk&U%Bs#Z=4V}EIOnPTm}=;NWXJ80W5v^rP&yNw zOx@d(3Cb6uuitL3y+uFwv9=7EN!DQ1^%`EH2`&8D?HfvbAJ)#-iI= zlk*%1isoKmj-Lz`F!S+fW>x2w%1EB67abZ-T~^X9AReExl7sV@p9J8-1MZ>)VHZIm z?34yV$eyp&Kd(_of|WxGRb7B97~_HOR0NM;!K-gm@lH*%e@jhb{|Ov)Tpa(CBr;v= zQWZ-BT_m#=dlD(b6$e{ysnx3s0iOvUi<*Owh`j_qD!OBrQgpybQ~6jcbMp(ZWJK7{;R~r`CMiT z=_TjMgTlunNtE_VbG3eEqBqYns zV(n9T5S)pHyxSo=K-cG|D4z%`iKj@6P=$8kBid9^p^eMkn)3_HY4ENhpZ_?y#~&^q zTK>Z47dR=-AKZP##bkI~@>DexVZ9&9*vlk_BG!oJL1Ei#M3yJM(huR0QN0~M65s`i#`o=sciY?Ti;BPs;rIZ*Nq zOLVct7)Utdh%@Wu>TOw>M#Qu?*$o%i<8yo3KN|t0Y>nlq@cvM>s=!?CtyXsp#$?kii@j51YSaSHmqcD8K`ZPt{xYoH2h@X=f^)X&z zFqmL5sjK4cP8)@&nR2(wmzuA-zqIjoejdoZgD@i7SZ=glz76thfPhX~?i}^91xVVqU=pyesPK|Ax?EHnf z1O&K~Eu-T7cXLWl?UmAoE&TI@5*p(q*457~$mxu0e ze`?(Db8+hu9<5=8UiJ0_XK>hNA3^o12oCJ9D3=tOW);qG~lGfzo**>Xb&J}^Sz2Xu@*zcJSZM$@pHRhL$(%F)^$XaQro=Z}n;Ggf(0%SH%kli*5S`#7~u z*M<7&V*x48gsm0 zVUA_fXxXOx(k@c{oqGAp@b;izt}*_E2Yg|KJCV#CU6bcBo;72f!e%Kp2cO{V?3Fe; z>*8^i3-tkB7afkzC=wr4lTZ7o zsztT)HP5h$sNA@YlZtsRl=e&#Gl(QCszU{lpV(7~#vo^tR@oKk+x_vA>{9osLFsoy zS5)cL5glpM(sKT?8kN0^6 zqO7i<4UJYoF+rGw z)XET!cC!7sc9=ADGaCx}ewNH2F=eNn6mB&U6ll_bUDLk`21UpO#-y7->yTKIaI zZ~FG@O%6h9oJ%<1*TaXGsoji}?}tFbJVcwX1M=*aN60z#{5kg0_Z5>0uI~9vyp@R? zF(fli_tW(z(;EZXwIv(En9K(yAIs5~r2#tmIeG283az@`SA{HRf(#eVG=i!Po8$Iy z#~C&U@?B#rxgN=)qPzmQiPeE@&*|`S5~|rUOhc~rg0=`*x~v)Buyu}`;_64P7&B&; zX}AjY06Y@6)a?YSm-GRO%6f6ePC<^5w#0~Z_^LUu8VNnm)Q3^EfJ!W!p_0zgloie21K}^yuphA{ zr#G-tJ(dn|L()_VxUEim`lAM%-uW*Go?6X}k%Et&h0-V;ux`rvnYSm0U3mpf# z+auH5I<7}3GpsB~X9ldCt!$yBe5gUfraC6~=t%kSWLP(~_J=rU7 zR0Q{HWo|me08i&@@E?wZ^*zdJ45^LAG8Q_~NJ{>u5p<^$TyN3Jlg9x4;5;yoq*mdt znlDg8QcrIE?D?N2zrl!;+>Y>FoKcq~I;7>68J(W(V~*7VJ8M>A7|^ zP{=lk!0_Pc{oOSi0(6+_oJ9L%mJ~cV#qP_l8Vt2^s(wW|U9d@L5YO|Dx&W(SYB6TU zVvSt;VL?E|24F%SW$}4LUc`Ej;2X*s~%}Zs}ENa;}C`S-lWhTf07(0-sp+ntHd% zLgeH>7(T&*a9hy2z`|}sD;WmXD(L#Ye@teC#@?WZzZ0D1-x3`2|8_+Gi{Sp5)%*+1 zIjc`84vAxnSUN7Q{Hj{6i)EG`!EZ(?k0FQU!(~L0%v?O+CCR6@re%maiG0RmEi2lE zf7aM@9>~v~`Z&|Ub^m&Q3%iR?1l7RC##cw@OCAQVDA{%iC*`|?vfx+SJguGM=T3-u z4&+u)a!M$B48?#&<4vsFAXRj>-yxCvz&uuv;~frmzdtFPFj)L0BsSe*Gmuc`JD!#z zPa`c$gHeOUnc>^CEoevD+?_;w1|J|%L z0*cBks6lMxj!yTto>uK;kL4>$Rwc49p87NFU#fJO*KMo$Zewfzc8K|35;l96_aROf zb0;<%`}g5;b#pH}Z4YxFYY$IzCn-B?OGj&uf7v^4ohe@|9sECA73_=L5t!SW<_J&} zGg9=4nxsgO+&Q?^;wai+ACFW({&aY@f|5)>U$2{*-o+YYL29T-j8bB!`?2O6xB*mp z+m+gyhKbikZ(C3UnQv?1h^n0mCoT zG-)F7l#@A`)%bDwv}82PRoxo`N5Pnpx%LXG{7CBroox5+1)Lo^iuuGn%wB2(nvydI ztf;oYgnZ&zj>dZcMJ8SZ48a}_QZq|V&|c;}^%S&F0gedlP8tIO2R$<l0~Y0BWA( zSV|vwDB)Es1cO6Dq94jGL!#akBeCo}wGTYxbkfJ?HaSvNHU5IAga=PON?4nYe?HDt zz9--xcJ4mr8Hv&`-Pnm^es?x-zu-vqF}@0PQrw$uUTGzZBaPo_tZ|6?!%1$GddLfb z&CC(L)r?4F1VbnFJS~-H-m6mvRWiyVG7iI1-yhTnxW4%V62OxrjwT1wPAq-1?xeY3 zu97J`a#Uz!v#4y|8fjcuT@@ZuCUGYg&E_#?+;;)qd`m!jTA)%IOpQ?9;F-FQO+qXt z`z_Rj1`W8JS5BQCAb;9L#~CR4kV2p@K8BW=osN~CdGpmvj1%vXp(m8PJO<8E-uO|H zKjAQ+ABcrLNeMYreKI)BLzK*JDkHnzBMT7j%B~n`y*HS(P#=B2&2l4Yt`TF4VLhS- zM)_I2ct`%#d7>=lTbk<`4dD_xu)G)9RkK(@s;*&S^S251p!_$ZZHu)B7$M7?lHr-W zF%kEdYSwBGCi?dAMjwuuQl25^@qvB7`K+O3hKRZSSMK$|L=-#52Xfh0(%of7Slg56 z){|NTc7J~inp2I8F?ICJGS>rwP`NzKI!b0&NV!ysj-Z+@6E5SKuOjh|9@9KmC)Sq6 zc2*b44y~m+U);H434xpz7!4(t+WhIxA+fx@Aj-?SGo2BfY$dv=n1dS9rJ3*GA|GM7 zEsHJ%0?m=(MMtZJM`;;ImPA#DeXRr&oCH3CK^`x-Th#6RZ%;(*j_1a+w{&)aShu7r{tdXdk?WJ-bapM0|s?&8F+kibcI;Z z9Z-UtlJw?oG&;&NZSB9IEi;x5-qJKjWQrGy5d$ARAQ$wA@+G`d4m>e;Mm1sNfBDuX z;AlPXi|TGm(BpnE8T-ZXf{W~0Wx0qQ923F!n=H|$ktTp_<36%e?#jZTR%lsE?s`|G z_T*G`Yot#9M-G?e$E8&Z4^~CZQy!|3PN*F zDNfkD=^5SkBe6Yl_Le?z-ds^Xu zUGK3)J3ER-q{i5xeH_LQ#opHd`kzkZ8OR$wXuGOI0S9!4$bxd9rX#XpZE1rr4^nlI z%#Ifniqpe2QUU|_*1hla_WJzF5>$w}YuHz!Bn7$|L3T1o(*;+m?~4zM+b*Rf`2F@C zFENS_$mw8?Q|%@8ZDthiuM{w~NTxxb&VSsRle7&MYMAtnOu9n!RY4X8?EYiSeikH9 zOZndU(*0WjmH3|m`aikY$<@;Fy}`luezV8P+tc3XeMs5KTEf!O+S60T+{N7Xe=)PQ zhKd@t1bWcS73alQs#@~xV;CYJB5Mi?KBm+I_4{>vPgk`|r*9%;rv=}|<6hAJe6m%Q zMI{z_E?vq&91RPqy7IqXu2FoPGxhxefqJ98J2f-&`?k`IayjoSKR?nE_Zo_J0q**^ z=CMK65eJ9MM3UF=fpVw%jQosAdgrbkV|?jWk^G=GZgIWH-m}@m#m}e~pO>~^LxQ1C zxf5=MT9cUh7zX(?ajfHlS0m4UuFZU?mWD8edgL(v#~-b6dRBli37)yq(dkXa^0qYJ zm2>PSwXHmOY->)I(>c=@V=H#cH4iqkr>!Jcq>Rj7HCe5!sF`+DSryVrGhj1JPn0w1 zpz1F3V?}jAmjhC2W=WIhi1|62^IeKs_Vuu>tvlSbf{BEZssNH}YC!RXPf5va8 z&*O3h@9IqZw?VV$|3rnim%S6)e?vph!`#iy+C$pj^S%9L@&1{si;jnrl&j0TX1^=> zzle3jf3?G?B1XQFBaK`)JeJ#K>clF%=Vunm%H)`gIijk*u5HkZTQe8UY_h>oeW8^p z@_RMWVv0Q*F@)Uisoy6=JZF1;Y-Ts?hz7wmqN?rggTXHQJ*&xJNSfp}aD++2QG~si zmZ4!fZLnB;l)F@pm1^KxY6sa9z3@2v>*mIZV!qbQltmvKmnn`wiCxdz|KaPMqC?x7 zcHP*vZQGc!ZQHh!8QZpP8#A^sW7~FevVL5gZ|}V>M(b@{_p08j-tp8sUL>;HOB^b$ z;hIbdt|h(^Lz4!n2$`tDF>w>d+R^r-o8L4CV$Dx{(t;5vTIc;CPmAYCX2oT221P|P z0{m6DMhT zWW~*jfZ!{&jQk}73p}09Tf0mmdonALDG0GIE_*DY+Wdy$#(|jSR0=Mb{Usmq-&*Ok zCsP?iLH+L;SJ7sgXGBvgEBzL9X!Z;RdYm;+&8*;3+WY7|s0-y?RN9E6UFwIYEl&bu=-nMHo)d+Jw_>@v)eZkY$8$E+&w}~w$k+G*`#;JKQIBmWvt^#A{Oa{KQHq8GHYbN&e;1A7?*3)>&I>Ywl-Vf>E( zvQe0@{Tbw`B8+7nj^iMN)JBJMJ$R(z5LXRwgg`1KAfa*irOnlN`N+}PSeahWNpMH# zEkxJ;d(a<#rx3vg97J5ZWNArdiIsWV&-)W>2LT?HPe->0&o^vFLa%OWuTVX9U$?5V zfejQ?X|e?mz-n;a^uZt!@!@!QsCW=UAs?r zRTQ8XNK)|mhN);1*Wsgp=~a(a(w92^6ZpiaKY(SMu4&}wp%6OfyRLceC%f=xCKu3qzu@%oq+s|rI$JfnjjEiSl-yJ5 z&C_g*h8aF>XB<2ZUUb{fwE}K_wFQI*pmFoiWa1jwhB&aZpsjDf4n@s1PUvh=bKk*C zWaM%?xyG~!JU)K8UUYy2;p+0qDDAGskPGj)v*r6B2BAdWoLy{KH(Q7IIJhB130S>3 z=toe;P-9s7>Z@J+)~YG92JKow7C3C^J#6P|jnPB1!Rwqme_ipn11EyPmc@XS1EHFS zS%uv?Mosl{H8JrKN{f#G3;|qewLxT%X4^u_i>Fz}0Hd|^pCXn#=wA=R&w#{rDMJtI z*&o^M#SswkL;ycEj3FkB7P<59R9AXVo&TlI*!q9-F5_N$gO7st4#Kn4&qAwL1 ziF<%!Jg8Ee%Rr3Xvo9C&K|l*sRM(}efz`Gqe8mXaZaT$^<)VsFETikCE&uTWs3DGx zWx*Lp8pM_RVHS=@z8CgPNe)#U0t7Cd*wLtMBn#x}*}i7VPbu=sc9D}X;CdTPQJEKU z!`+jf%KLMi%F^;EZHM}qMQrSTOF?GVb_N7Y78K-1DWMeAJ>V^4{!G4ONMXe2mDhTE ztfTP05-4YxaNL=mTV9CBs$FRCk1*7;x1MMBZA(u3mM@oLRj89xoBa&8j~L+0i4)9o zcMIDE8-zVDve({jxwMBH6bZ;3Ry)bqL&Tz= zr-@}D>{Bm)oHD}UXpeSii4H8ck>-&k!B3XxBH|wa`0R6goeadkwK+w{@eWW`ozPTz zzJLC7khb;B?P!NKLSN9B>Rz>=rGQr;-4d34g-lkICG_Jdz1TZ|lQkU1`Q4g#k%5~G;DFt|mKYil=Ox%gkz zp}sQ~xzrDPfb_3y6wCkp-2UH`CHcu&cMky{iBt&{()hB;6kkw zP%0{lE%Zg3{OX9*0C#^X-QU03FtG7P>$saD*EhL3LBoIG*uYr6$~h!fMm~$ZSj8Df zMjOUCvdwJHWA0<`<4N}S{o_)406L?D-NU0J>!bFb$tm*w<_CjK?KyDg1?m**Q1F&x zvdA3LQMzE_Hu_PG9p8Bxi2HCoy0^C*C^v7$ywtlfB6`wGhENk7ye?;xxH_gr^j<|* z9Htl0oGx*#-6I<{2#ZdSh8oCICE5lv#lUjuc_gd1ND7QVuH)ol%3&KZh9aJHxnt5+ zoOs>TE@dPppAjuL+*mCi=6SCcMol=Vepu^7@EqmY(b?wl756n%fsW~wNrZd$k6$R1 z2~40ZH<(;xt+$7LuJcM=&e{1MgRYl5WJ0A1$C3PoVHme!Sjy&9C`}e&1;wB;C;A*2 z=zn0IKV9TBRf@}HLUf7wUPD*51(Z2OF-?aS8g9aGK19RG^p(MvSr*j-yJ~g`;DWQ@ zm>)jnf&y$qO43(PM>s>AzO@c0JT>h>Ml46?)9EG?S`3$r#{^%HIWQBrhVoRrP_hin zVZq6|`SdmdBU2ZIF_f< zwOk+eoCuOx{1Oa;*J8>1Dl~7xLUBf6U_0=tUBS`8K9P_XEDZ__5)FBJmf^FGg^9|3 z7|XM(3>NJ_OR62QE9Rz;RVXlwP1m!3l_XJ$;1bqgLzKSb;sdl;R{JK<+HjH+>=;|FgE)pRVZyy&y+fp6Kz6EOsS$nAil z)E&T0mU+z)s-ApBI_Q_!C)H$*TISc^zyE3l^#U6l=}c0y5DD6)m*t(~#`F$L5~=+; zg*v_EHOw_QcuQ?Ts3llUFA)Px%c8WdIf`U zwUs%DhS#-f$|o>`$MVsSLO%b>+YKvP9P6G4uKjRIlL29b%ULV zI;vtJ@0n`UcH@wNJC$W&9aQSf7Mw1(!(D8Iv#XggE8yhCXAO#R_FNiAtyG)W>@23? zS06PE--S7ya|$~!9cJKcg=H4nFtFurLci5Aq&A|RW5KWK6$LedAgKz--ouWjF;h2O zO?Mw&UeLh9uYdH;S-*W;4oh!-Xad3?2+(<}!<#uXCG#EYqswtbU1VA`t(Fd1C)rjJ z5lGFlCf@C`F|oel&7v6G+dNI|(d_Y;7 zIi!q0l$vFh7UBgcB(r~4Eszx?0!TAx7?N0Vs%j4vI4-k-CuPr6S5xoEY}gFyK$QZ5 zFl+%sE}f}p&ozcc*XpuDluDOFwyv<32n0)?8=9J*L&)N#`-cfEIBsP?OvmE!P#`P3 z@hBfK8ir4)L5}LY<`;lPOrAuQm8m+%)bj*e7&2v8JU`RM<$;kv7VYw|1KjF`CZyVq zQ;BY@l&6}Z3ILSqf+o^-g&8zYn3_A3W{LkCvcjxn$+1Y77M2+{SEkY<%ki!^B6Y-O z#IVs$I}{ez4=MCS2PZhR(SBp3gCLMa(6h|k^ocL8Ru{kfV3fX}Z|ww-Ig2O^a6ed+ zEigF}zE_#K%Od!Z7f<;&t0^|7nzl_Sh=Z84@<+;o2z#58Vz7S@*s{ZR6!Vaj%ya)v ziD~E^ClRVkP@NrNNF_?nJ4-HFQp97PVu(${w&6`I3 zAW}a~985bsE5sI6;-TNDBABp0QvlV1Lh;9`O=G7FXFF4lUdXVr@Yr;16ZKR+z$6;s zQ{9fUi9P|=&}ABh>jOeYeaE$}q>!#8Y%q?NM`0>>$kHHns3;l3sL2Rb z(3U|}J8`38Zwn!GrD>W0$t&Zp&F@&`D0KBYcDDgo*>h1|Ey3XydVqC~=G>q?L=edX zYFS8;47MB01Zsn`BMbKA>XvnjT71yfSLXwMPF7ayG|4ys(iA@%HNTFlpC{x6-}p6N zdhg{jk}pM3y?5#SItjDi5fCpE$>L`Qz#d^$pbC)=a%-NPHba*}>H#$&qo+jtvaTP)7PZStk*}35F|8HEoRnQRx;jguRohf(tGkLHrk{!MSDsI)YnZ^Pmmznq*))B<4J{?O=ge?P*=qdBr{SKk#JNQ z1vgFWb%qfIs)OzT;P!f_Pm$ru;d8nl8!A*+rGd(*$~T-9ll}1tW3xAU@}#MAuJC*L z0C;@^N&3czV9X-jWPjeFb+fOJoUQv$L{yq=a*L}Kd#At~5Bl0l{n zeH7>=^jr!`6Nz1t9E+x7hBY&EexVHXhIK%)k^qwsA*-id;Eark(C~&aV{~M|8FCKT zs0-mMgoGl>k#)iwf)-{t+Rg}68E}9kyIc=JP9+ezx{<7D4+gJ4$?_qsidkan7Hng9 zCqfv+1O!7he>OP?3up_hldSIDw+YYT+o!27ZtoW)_?spE>F+a%KZwEIS6_DqxSRs7 zGXTm=$d=h}<8TDfk%G@F4U>8n`pAr=6;CR%Ba>`9?1y|H4-O%sJ2%!5vA(7=JO&kk zX?ly;ss17g(X=9#nUWglspHq?j@f+YBG)GsQWG8CjK|mXGVC=3R zYy&BsP#C~;wC;oA{He+UWRN8A6vEWVGmaC&AtL|^>nR=S*@8mg_m-SSYh4o7h|5Rh z+5N2&1DIo0wnNW{IFH4fo70@u5TUL~e89t6qm;8njBvLCT0ODrN-b1qqwkByTP2d= z3u#x0Pu-GERkw}IAr@lU{IL_~viIH95L;=?Y4=(fUQbepY_C_Lo6EzVpM~N7wC48E zLHp>NA>#Mo3d}Fzy_x@bDfx6Ljk*Ot#qKu}-ktw3ZdgLkpxC?5r(fpz4J?9V`54+m zb5i>fCc7NelR{wncg9?ka!+E9YRr79{cE;0@@0$YTQU) zVH8x+&_YB1`T%(VJMj*;J3XT{mpNZc^^#0C*}^mP>=g<6Pl1l(q_P$Q2H6-Vr~qOV4Pn%(I>R>u8CrAVRH-FgLgmrn^!-+%wmWS zBI%O;v{5DdT?>bb1PlWdck;m& zG?8;NCa#=2oqHYKT0<~i3BRC?0{+JzM~g-D_D`yp+4N*OC-bxK``0V=Zxki%+)mDkS^pQ12u&|6wk0VNGM#$u+&mlTun2ByQ0crVttGAJx(LP92Vq6y3XSE|2J*}wga zKXbePGRmVA1~wR|#9mGR4wIkl+84^>OFy8}$=ce2qG0gZ=Sh{}4_e&=D03~pL5m{i zP(Ngin(dtf&?oVg55RB}PA>B3f9tXpk^5+?KN4NTze;pe{}w#|qx1ix&HhK^6l;Kc zYb~{Z_f$I6)+UnOFZ%7=*qzDvFsj)$nSTQGY00&)bYD$Vh z=Mp?E7@#elofl?nL+Ajyl*%veOj_a9#V>ZA19kX5)*frI<}B(>&E4Jdntt{df;j|DzDUxwq?|n{Hu!vR*H~>cCI&l7T$GeNk=Ng+1XBe( zfcX6q^Uq*Nu~&LYR2AFsz-f~tS7PbJ=!JATCIVojOo>QggJro0v5jy;xq3;fEzKkt zdb@do>>*3K#aFR`O2#+~Bsi;}M#`YH(+DnO1N5Hl-3d!{3G-A2gk&+M^dSK@3-NrK zytKdh{OIE4Dk@06#=(*W*_5ec^p=7JT_Um3)#?%xTs5fqy@kK*{is^ha)BbL66UmZ zXe+q8B`4Gc}VfQj zqdGkRB6Xjx*!hG7Eoh$%B)ih-SpfU!A)At?X5w7?>Lgj=RC!XmqJ@$`xkm$)&O{NE z7zj9>Wu5a1glJ6+sZqL&ku&qfJe_696xY%M+5{Q*03~s{gF+;MyxclXfz58vZb4r2 zGE@P$l^sMWnne@vmeP766QV|XTKw{f$_};3!{7iBk&;E3vrf2^l)d6O@R~&{!#Z9G zX{wlTM57#oM>Z;L3WuNo-J0C_&@>>~b{P#~_y_`gxG)DMEYUUqq0O(}&>ch-wC({e z9XT=mDtjJVyzNAu43=1Ow}&uu{|Uy8%0MEM-#-nIRG}=!CehVQKuYhrbe~6OK5OF$ zRDCn)f|R{sP1QnPJoZW14w{7rk!oBpOY@y=ix1R7IJkZobR>D$bv$aig~U4 zE<`A;fm7SCA4*XkiKemy+mlvxm*S7%=(0V0j2Cye5XTtz2x5PWHMEV}+>G zy7}=iU+iJQC?(sRT=??`!Z&fkLdo@J<0$1eA(GZuCJV;fWJV>y zia99Dv05Qs{8G83g^{w@@*~vZ2E5C3d$0$76^_=h0?Ay_FCq2?)2z|apx^r6Fq?X^ z&vU>OQWEXj+C6t)M+Gx;fk0RHH!H$ztpj}$<&!a8p{dft1imSbT$@s#(h=LWb3)Qz zYA8iL$QMWV@sfc=0CZ}{u_q6po+wOjpWrpy?q!;VBRBC7X7cF^bZ-eeB^f^> zQB`Z?1o{tEQvXOXqRY*(yLcw_fLf}o6r~WSG{{vGOiUVgD%J# z$j&gdK=e~U|J1hOZS(>U8Kj4rAvGrF1IWBx{2^Mp9Wk$g$C!xeTz`5gS{vz0 z-chgg;3v&I5-}eaJyclm^@TSC4tN8eor7K-uEcUJfuimwaZ64BEb%Suheq-h@Da~g zErZ@oft7xIYR7=)2~so^;HmQf-=SxIl&g3yZzQ)dn&;*|#&kWgLlX0cWP!F35QY=v zSB2>$;h|~6)Z{ZLT?-`a_JrYVoHNvsxvZ$p1q$y_cNN-mV}o;rcFMJONM=PnsDZIr zVC2MVapQDikYN5vCH)BZut{M2Q$T3})eTDtH9fqT2|SXZy|lnI`d{w$f~eB_D8UsS zn7lih>~118IeOB}ai<+1Y}Oohfff{nLFk}6M*X;93@U5h)p}SnK3uuK2q=fvx`Xyn zN>T9xkcy8E4;oi|>Ch|032-OHs zbh>nVJ8-&$cS0SUbBU)ew^T3qUYLo&ytrP?yM~iUh6a~yUEJE{s&}4%{tkwJ%I3pE z@~ClA0k^%03=gV<=L}RkZE7(7;dIzR{69fMY zU^Jt{-4CVPngMr)yA@ywB%OxN(9zlZeJ(P$YIo})tKSEG2nnWbN889d)`f#J(fV;cEu7)J%aN%~_$)Z>(fMP3Vw? zZ1PJCp0N}}5gDw$4Kt=g~m$O6&y+Kq$rbyR;oM+-R`+eqIfUr?P z^Tnv<)ZPK(iuebbZzaRTC4*x2up0rczT;GrI&O00wgD>Oq)Jp(5T~R}D0eh(ImW^V zq^(nk#P--V8q_ccE2YtLD|<`Rffk5wZr3k^DEXG3Po?}a=HOQVEB(M)*a!!fve8!z!Jf@HMHG$ z$9EKahtctY!Uf43{Inms%oP%|N{r%Wl8AXQreHG|%SgOX+R3KZ z^lNIxqQqP9lFtAjcNl}c`z!qTg|S|01BvwIC@gati68424l$8oM_w_9+~Bq9_mT)V#S**~fdp z@BLo^`s#=L`T%mcD=)EJ{Nzv_bWJw?j5-ReXPRv&KIY%_A8P(@L|Gh(XQ;v=Tp18@ z7r>|2AMn|^W-$2JU--UNcT(oY2iZbK8`9XdNGl$Xm&V*)@uAMX8u*)wDN`!HVV7d?xvknpLesf+@g5{Jqk@X&e0;gw;%` zRVef*D2U!@3ZuId8&n;3n2I&kYrq1EhU6q}s*ux(T+P&EymJ&Q7a<=G?M>9H*tV%h z23C!Wus=JN-k`lK#w861^^cSm_tZ{S?O=>Ak^9A(vodXxfpoNh_yg}l zM3JR4aSdggXNv$ftxyAIk0-;5u%ivhS2Q3>Fs1OA;)wuh>KVpmy;!!JQz+Fa)GQ^- zK!uQq2@hsSSp;nlsLM!C5tlR5`MNS6;IIr1_*gST6*BcvnIG;YyYGmmuR#K*= zW{uWUoEW*&=I0`Hp&gN!RL%z+39N<~#$AUFb$6G54ADoC(v^yC)==1-043o{yYRJP zyu`f4gc@N2j9u_+SNa&F=X+x+p#=hz8Lc@+1ki6W8YaIRTIemmIfy7dp&X{fj~8A5 z%MqUqz^ucP8mK;Nv?k6THibm?hKYU&l+RPs?&Z z1TK|`k~q+aFp8HT)feqXLhxS*m?YjEC#KtJaU7mYr$g!uMq%M1bm;dJ2e&Y7Q#L)5 zG4CQ59$X@{@~7_bQn`oLt_|6Bi~^4)#TQ}_xI$wrYB{JZq{uj9P__r4Tob6IC=Q}q zyu>Ec6-bEPsLB?pwBd4QBos#AOpVQ<=Ih6#w51-ET{XQ)KLY4HA`top_#AApi$CTs zpW(1RE-Yv4G@SK6yMC-3ZJll<7j}Q5jL!+2({qTggu>xjpO@Bs(qP7jm2sgow0Evu zUa5Pf zB$L4|q6bjR%lVO1em~M5oluvKL9?Kad-PZ0P0t16@Z#D(z;1?qUXOli*7Lg<#rW2V z0;mE!U_v+b8}Jit=ZwzDfy_G)d`c6&f+YBWELL)f^||ti_jW~^0=}#u{aqD1418FZ z=l{IshzcY0XC z`P8}4`8~_|wqkLI0@D1q?S++|j}8nchE+58NX4mY!|AqaMInDR7D9rWh0^j@qH!}( z0~#|rFu<)PAi@bY7dSWO(4;O(sW90AHT*0AgX0ClwN;lZ!_XRloGo^d(oR=yX`7eR z1>XR(6OY&6+M=Sd75vQ1EowgN+9r$4?EOtY4*lv1`$Lmj#GZ-`YDS!BGyYhnrmf$W z75wW^{L&R&KDp~P_kfF`!J&oab3foYFq|9uvJhbD!7kN%bw7DktjkmEy!5W?OT(c% zaGJp4Lp{#`F8Kj@Z>Ss0O%0@L z=_o3AS=j7D=%871sN3^>4%ZY_={S7NJKB5BZ|4RR zQ$Q7UxvnAL0uU9+9>1QsfJ}Vsk*j!!RFk+XflYjCk7$vTJ_2SjeXY~bvXqblWkH)8 zm_H8Xf6>cR-*W{BN_PLc7{{{Hc%%?Kj)Xka%N}5vxmf{!6{I)`F4FaaRen>B>7{M7 zFH;#D`{Vs0{<=mIehp`2#J!lZkG~;8{n4Mp0vT&&EO`ri*GTBE<@9%eA2EM~pMK|a z52w|kkFT#ceY#i1{l$%ZzzP>fzWZ#yiM*F4I6Ykr^6QAfqcIma+F$($yxTbswfDlgY zjgc~blW_GD#X`_8!LVXh#jx=VfgxneOSO`fgCvdo<$IRqBZc=+iQ4*V>q}zr*5$0y zCjk@J6MX~(C&%#*)pueRdgDq9e0j9PB zH6wwc{sz}!wSk_j`47%~w)U<~RoFV(39zI~L8E>5;}$1S)B!fUVwJTcH%^mMu~pJ2 zZPlV%ldph=kh!imgV=`k@d!MVYlsVmU#lPh>!3kmtG!ivoX)l=Bdj|w_Wt{f2|>{3 zNSJBa$L3sEA!C~DNco&iVHGD>@4!!uXNlu3Pk`?puU-1z@$Ouu+{YYp2%M>$YNN-R zX21B@IoT(UP0b=3v1js}LcOnCb?I|)r)^)mhCCFjNA8R6vyr}%?s@mhmn#KcH}bC% zW;QKLy@waI1`|<0|FQ+D!u#`z6h~9hlBk|$5N2e3gRK(2L6k3test;wIlH<@Hv+Qn92fx zxYGjYk#gV)nx5wDl36YZW|c(eQM1iTFxD$M4EWQ#@Ikmnos zgpO#tUHZE`YJGE~gbEs=MG9M`5m7I=qR>=1V z|2UtTmrRK@T1SpqX-PKPSeeIE#~-b^&hu!oPqmU-_+LgJG;WHj{q2!SZb7%m-xQ6! zprUP&%cs7y)ikUvpz?yHZLTdbd1_X+sV&8NcR6UqFVOS~I=djZX#X^7>faKhzJ#Bp zdXF`4{uJpL|DxC2*VjB(7e2@F)x1`h1r&p}vA@Wx#D!ct;SkNl>2{9Z_i?V?2dr?D zEd@K)v~=zX&B$_7XuJ*Q=;ZT)|s#?fm3jniC9CpukXut5IW=yN2N`|3UW`k#rI*J(Xog2^D)Y~x%W47}h`A5$ zmsV?ZyTV#5oJSmcHHL$rGkvPMqbhJO9T!=1UlzT!b*#&pQAD1fXRNT)LXTW-KH9P5 zqX6mHvf(zeb3x zEXeM>NHfb5+$HJGc+3)(nv@x8IBm+l(_C|(TuZNmP2*`>m!y$tW2AOSXO2r{YZStF z+Ccj=qg;lR(Uy42#$^$lL6qX^YC5E}J|Aurs@Ss9U?as1KZVF7dFk@jU~#Dse2ANf zF`pf3Q(VNOxBJMQUQBKAVH^sz485r#JAS)NU4%V+&Wow4Y{!*St3Gm=3c?7!luRLJ zg8-;Jw$eoq@LDU6z|5f3BMW1QW;(GV0rdsOsTMc{h*73QQFwmZi;R`xCLKjs4V{8z zpkLk}#kb!1H{sV&A#105ow)@<>CPfRO1^->7RCgfoa0qjRbtq>1#mQA6~Zmps*9$C zR{@xZBNKF?Mq2ai!d{@VHsOXn&+e@mbit@0s%m5tD@)I6_xzwH=z`O|vOpFckg9%m ze}V)thirtajxb6>mow9(IM=w0UNx?l27;MU_eGA7OLmk!q@j@SDNnEli|fF2ROYDX z(@@F^{@`$zOC}1MbT$&$^l@;LAtU!dl=fKGg;g3`;8!l{0*2`6io3n)3Z1lwW)qSMX&&H6B6op0BOsY^48CdE9CD;j|AytFc#uUQ^dVqKV zwPRM8q8!llV^uFELm7t;3^3M_RLO)8_Y+j<6@LtI9XsF1+}4a!SAPqcNLFg9^)`Fj zSgEmL4kjDU(UC-~)XR&&6b*YRSK8_SzPffPc3;=6(lfX%ve2OsF|@(LglrJAy6j&3 zQ53Gan!U=F)Di8RkReOBn>zer+=(TSwGnTf z*Rnzm*U6Wo*mtLhu4%hSke^_>nlU7&JcYPyEYiWY@cQ^DiF~Q?auFs3K@+K8;kuMg zwuV5kYV-V`8Pa0Rn8E0n?XNhH*Pzdpue#m!P-{kDo9Kc7o!U8?)FJFJY5DV=Q*K*H15|zoaeZ z;gxIT%0tMEjrEbAVn)F1EeL*5dWRT{nl;)MIguR%znlTsrb@ryC{?py2EGI|CFryT z!uC0_J2yACqMsk976rAxFnx|V^q+Qn7Iu;++gH158K^3#bC1z_krqGEZP2cH2SaAd zbWdZR#Bmx_1o4@I!Q%W3n9Tep>w1BA*_y zE*4?as4ov0?r$f9#I~7;2el*Mt(EV+zC5+-Le^6`%OR@XZ!})>Bn}{U%S&l75_70R zb>YYVd*B6-9;SVen?o4vme^s{;3Lh@2$FpuId@#!0V5XGt_n?Q?>0Aj{qI_?>+^xw zpWFpX8(TKSTB&wjom%A@uC4MfE>)(Z4|)#^vatul3d|Q&;^cbIOB)Ncc@bD-%Z)*b zPq1FtofUV>ei{WDtc7W$-qg(JrT|N}TkwuR+3~h=h~$sN2i|q+rc#10nyXjPFTte^ zX{QLKnDAZ)>$oJT&c$sbSl&ZaSmvY;Hy(U_{137EqvMIR4Tz3wJ*XZVoe?g>F+901 zYd1hLOzdEDvb{a#imlA+k7IPm1n=9%CPPZiV~iRw30G35qwSMmnzx? zIb+c;+iZk_2SHQzZBl&ygxB(x$tptwTl(*r^Cng#Z?J6bC#<$TK!Gh8s*s1u;;pQX zvRHWJVDysYrJS95YnW<`E0@-JJe=tSHzbs13RN2hQt&+7Ng;#3e^8-n6v{%EEkz8t7b~IQ zE0;F@wojhK9vK%HemcA8cBMI&s4v@}lHkJhXfrM1xj8Ej3nMj}xoUbosn^ObCdY7b ztp_(h)oP%ekys;b$wHPtmL%paSC_hQ*ReRSJSSzB+0-?Cy` z5(TS>p0S~tJG>R~%V(`qVL47z>BzEAo2^%wsckeF*O7_tEk%rL^AH+1}ZpX?fat+c#`9u{zqNInLk*PD-r4NK?HTgbbEW`hdk!^+)OerVxh}0<5*_sCkD)>jE>PECJ(`rs&vQSqiBi5#XrQ+l@&S1Yd zW~|6Kcs&JHx%qg0uNT5t*sdKbwI=mIMyH0=l~^7n4%Gx9Hr0&5HEkKzFe~Ccz#3>T z8x~`%;_^u&p%ch^L3|%V4fmqvp&jfpm{lcT_z+Z6sX{br`z*-z**l( zV*al|m~_3NXsFj%c&dvLtk<>Lzb&cp_>bRZ93&_w^(yYX=jDDbQn73PDp7cdU?aL*BL*VK;Q1cou@ z<%G;A5a@!4(@Hfo`NlXWafmoES8>Q#r+J<2e z(k-d+ZwTe`VlkbBAvPyD3t3`rz9J*x2ndxGh-PCkPFw{eMk~JwiK1`nq$^QlOp$CYm2hBso=rlg&n>nQl`gxTL!*$p%b2}P zBf8is+YZF7+2?v68)+4;J*=8pE|v(|x5qBE#a{YZEy5HT&i4U?GLdWzRHt;hud(O2N=D&%P3w#yDOqn~`& zeDzN3*cbj*P`#yuR3A_4HXNW$%i^6B_B8n4*HeP8ZuEu>)A(~TY$dutg3yjiq9{YiZ?V#Nt_LA)uWe9>rq zOHY``mM3W=EdOW_B57D+$7}l9V%T!+IC(oHe|atxeT|j1b1hi?4K?{V!Z>rS-^1@8 z=l5&k_Pl=J`@e>J5(Dl*2Vs8TAB=x%j{YCy*#9<1|Fiy=1;>BzKPK_(|NPN0lh*jjF#w9UmGnIgJ0%yOuB27j%sZCTS;t8-sn)vVC0#XPY$6p_koe4npSvG-=%AfGn*3X6--%4AUZ@@3_ahu(H#@uo&n zxre;2?qg+#zsr$OUQ@T-en-C`fQbw@O5YhpsEn&jzpAVR6zusmS^ltOlApN`RY_X~ zI;3&Oo?-f&#_gWM0U)t5HI+V1(@V7aD=M8lFE-^3tyu1#!4b=jvwO=Qleo`7FcV~*8oYO?n`U&ennfyJk^xQJE)AJRf`t%;S^ z`rFA&buF1xT+8q4X}bOSXMlwFm_N31W$SwnTG%Fk`{R(@-(`}(Hg{QC6mo|3uNnK`R*%TkSiL}N;=X8pxjI>x~k?l`hvnV_S^&7%)r-bq$H-gKFPQ1 zbPE7d;16MAoZJ~ZmW9r&iK%as6H9IJyyvmI?!@7Px0&B^L$k9cVQn6%oB2rdbW;lM zzlccZ`yY zb%o6E6xNkO*s7dVe9GAbbpt0G z#S(Rq!VJ14{_28x!6FY~v;`#sqGFDj(~AhsBH(PoQ(QJD5bF{JS}}>MFJl;{^0(8u z<~p337P0WT1+Z1U!t9=g6%jgQa-J~nW5YY*0L)x{M6)!a9E8i-C{Jf zC1qZ3Ju4q~Ov~+1ZN8NUe_VT+rbDnTLJ`I?T#rteXL)goXPMmWCA-9R870GE^e&K= zpw5b6wUSbaZMnvRYNF}#a#U4?33=bqiSdbQXve-VTu_dpjnWS-N2$V}PkQ+f)M1ce zS3vxWdnXr>Id@KfzEX=`WNer7%8^nn%(fsia8dL#VEHqwPSO0AywiDTzw+?k8iFB< zR)SiSjbbU1$53GloU_PXxbqpPwCAKk3%xQEsvusX%Z|>Y8 z$hFs9_1*nu9z7Q<)-#+=`|YAUlQPQTQDIKJ~`Bq9o{GoiVlM9 zks8$P!tjc6^$GbkdQ^iYJfTIohMEsb10N8G%WXpn@j)e)({uf8Z0=1zgBp*K#O1^u zX68l$9vUC+Hvsb1>qZ1096EvnKakT5X-ph$RjPebuUt|6!%uOq_mEeA5%}5C*LtvGPt2nN(CQ4$k*B4OxOsx=&{*8s}f87Kq>Ke&M;dh zo&PMi*My#^X$UgQM1Xz)M|lxbX0k8gq*DtnBErf`R9lR-7$cw59vzICBcG+YYO961 z@K&yAg4M?gGu!?(!lhm1W9BwIV6NaTS$&yXa!Jk%9cB?8mnUqLojR1UZX#C>ItR%; zG)_#*l;PTNF=kHof?cXZ*z}OqDTAckDzNk@I~rz$A&Yfttt9qf4rI|khDIwDkaCU0 z^{&56PF>BFbE~99Gu7d=+;EmYkd`~1b2M6~b&`{6A-5PHL|v%pwC}5f(ZX%K%v#z! zEg6NIPO&ZISs-$A9CmDoSN8Gr?>36*Qv;JNW5GxA`VKRyHULY~tkcJnk=aXVvn93a zv^?!_jh4r?GSp|#s|CM$XP*rVPo9;XwTDm!OcXxUzDIJ28bV)ZzH~feD?t22ytG@BiG0tF|Jr48RYwfkyUTe-hzpu0+vcJD^ zm1jDyZ`nlkG~eZbK*YsgFr2dmlDOKBhqZ?k=7km~+p9rBS&rhDAs$Hv&e(WQ!e00V zlb%AQAZBv$2TUq;OdBu26sDHtep#r@$42JkMaSdG(>!|=k-GdYZ$&d{JuBTtHSPns zcE^hIssoLqm!8pOT>gS;G0lDr0!OWbLxQurlvb}W9ogPdRow||T_}I_kmBf8)5d6O z(YyBp>hTvGD%o=7(~un0z*A_m(7@?eqIj9_Z7CWaJQiz9s3cyFpNShe9?ItFK`?E5 zpXL0a95Vq^BQ_oMGCLWT@+$t4Li(ln%P#6H^nKH?4A)P(S4}cJGs3C#d>NI@tW81s zij75YC|**UN#rEut6%X-TbDj=VoNPFvSB&m5^?dl#GcBbPZ=!m=GC6JODb|pSgZCw ztCg5B9PuE~OIR27yM(kMkQ(!Ayb3B97aDLpUe2mTmH^RYbkLF!W-<*pORgM&3RY5s zg->y6VNScDnxd0{AC*!28f+z{V4QhQq4&4FVZ3*R41Ar5Um(?ezKG+&&%9bfIA?M} zA9{i@<~yk3Dfs~1n4 z^@R26Nve`GN)Up+_acpcQyB{nAx4RYRdc8S$QIP7c?E7%!}0X$^5X zswW}mTFr6Z)wAfR#4*LC@Zr(ZX24543MFZLaO51*p(z*}G4P-52sT^khk#jOeWpzl2o!2Cc=buDucQ-a)H(-<0~A zgN{F!bDw%2A?63Ua6WjgUi-*deC;(kwk#Q$uy_N+Jq8TN*`sG#8s2XOELS-*0rZQF zre$(Nucb127C-ncK<7NfF#}p4#eG9J*|x=lDFdOoevYABGpHWRu>Le6p{46>jjd0G z7CwmzOJ-9=OmJlAfYKD!tWE4Q+Rn^}SYHVd>R6lyQ;$Dj-f}?qp3S~~{1VBz_iK1c z*2dOew4A+bma@?hLk1IUwYvdR&Bj&>_7yn$jeN%c>XPhYlwwjL&1|2^Df!~kgnolz zpp)zZcqrt1p}b#g8uGp$$8}a_Es*1sb4Y2m-fmwylOT!MukmT~H0658{#zf6@VAP@ z{HxGp_0wN$i4->&2cq)QAF(TC=XqA-%_F%|KF^+54?=Oy601KXeQEjTa->iF2*>${6U zNfJ7=tf9ndv)#TaYscj|kiq2aYO%3%V1#Pb#&v_gt})q~3Rhftzo*zb__9d)<;-T` z-WTuTJoD#xS~Ds1?$oh1JNulMim_Y7f#0$#naXiiT}_Xdp-MF|)K_C9wdvXyv%5-y zv=&BXwHKT?bgA13%ay~PkCV5H@RGHY+XLaK2QaYt!y;+hp#!6L8qp*MOeFNW{mIzH-2sTmXPW$mhoITa79;3sj0B`5yVnXsAFeC z9ZDFq4NNqb7#1P`fpMSN`T z*uXRg|6DEmNOyQtiG8>m#6Kv9V}lC`@K`{D=j&kMqDx=%RXm5Cs#?}NZ&Nckw0cO`W^Oc`hPtDT{_5b0WTY)dZ;8 zJ#&KTM2)%{3rt1enE@N&5v4?_1@OdUZn?U*`66nqHR|Gb>0h!<3W-O90hbQ&k# zOFNEtSV!X$Z0I^S&g*i3_`pPWc{K&*>4!C%EUetBw<7yuo5gc9T$B!axCqb{QTy(W z^#1NanWKZ7@1Me^J7Tqd!?spXS5Q#58l7Q`+!XVcPq|l#-8ws1?x?w0nkYHrBUNot z&gf=wtU(uMWI=R+;ukx_=|b$b&(09eFfUVAu=K8v`NO*k8p&oa2Sswj#TxpIf{Fr@ z(tViq2@(`F5I&mkMM>FQ7+j=3>gNofYMj8*I`Z#9&fih;50<=kIcAgLo|~R{pf)v` z$|oWmF>-GO%Lm=Vp`&b&hkP(X-7I+NEov>r*oQCfLrW#06P5=1aM%8QwzJWxUUgbM zd}6z`kDyFi6nnV*%hcf4OOdN_E2=Vk9sBCvKZB25VJPb7f`2PeB0RwFjZHLbsud>B z1dyZbAs+;_;)8!^A2&*6PLx0dJi9(t8H{=T&na_6*MA1*2zFChxe$C}qtkh{STX`B zAK>Atx8R3aPNf|W1L>EQBb0Yx*1inT$`Ow9$`*F&^q*O*EBGvZHcP`M3CH>lva- z)+;y$Y&K1gBDaAnEYFcRf`f>`N>F46K07E3qQx;O8zzS-d$r5*U%HQG9ydU0Gy|IZ zXJ_|zwLg4$B`^zKYg%l)LC*h63~KaHpa(1l2QE)&L-BX#saHBovuf~dm$X;TWgZ3^z|^;enzj_vgsX28+P== z1g#k33Mdl;W)o_+5MbR=1kQpO4B;wz`dnuYH;y6291Uu!S|jLym8>25G^ns+C`|i zU8?IW9*CTp+=#b1v3;Y^#gnj$#!+9~-|sxPtwrGTnms&B|#kyO6t`q~ZN) z-8vvD?Ni@K@@%2GwR4uD&%*w#xr>S@m~0^g3?_xG3yIyrQ6CRV_fuPnl-F=d`^?AX zqN8(~H)ERx><1xs6#_(7nFZ`Zn_$C<#Z#QKAMgjK6vXqkHN7lIM;2$a1`)G#dsp%3MXqQ{wZ zwi49qr;`zM68#yL*fzn`Zy;0UBVsAP5wjv8#}+Jr6m95Y0IfCV>V@ zbvtmr^LW8tUX$RWhiO>rp3Pf?u+B`GXp!>LMLVc9;05>a2 zJg&o$#;ZRz!6o zM+aOFeHgyi|3y;1HT~s)0vwjT4$uB`XqNHkGX|JE3rwSFZ*FXNO{*$x@XYAHF9euB zOPxR!tj6$=>Vc>ncnWFF6=Cu99TnveWvY;dB}fO*=jz$8^2oqZvCVhm(a3G)qhAId ziV&ZT=VdcI9fO~7JK{PfaAVnG(*ZCt_Gm>VlrhcJCtGjNTzP;?wh=9v`JIn#X!msA zrLV3}(zQ`NaiNV3U3C~@kypU2h{+$9cwifsq_f9O3rdU|0O>qFI?u;RqBqZNk7CJ7 z&bN5b6@lA2*K)iFnm1ZEIXsuEH-G)9!0fG@{es$9F}EXXf&2jKmJ2XsA)#caL_WWR z%TUPo6YkgK%^KbYtN3KnXElrVV?)7Iiq_SM^EO=WBOg{NQMP1~G<(Q$3etTtTooqz z269cn+^c>ZMaZxzD5hOH3l;p01qzD($UBz$R-@*KY#gO_`+f$w%N(Y`qyzct>8$qn z(+{*ZcOuU)#rtx|LZeXJ6=uvQ*lAgZmS|T@5O(s(D-a@Q?ayr@5L|2|Tg~@b_c>L2 z__306iq%m+V~qF|ACYkfKw@2R_x8;s&L%G&lTqswsbbZVW)adc+qf&Yk}xvc$5*Hs zagVTD?4VmRkx@0Huq5{>Ow41}GC-pn#uq1j{9>W!C#!^^&O#Qorn9Wg!-y6qM@Hue zltD~1T;WZB6p^cj=UtOntm|I}@3!o)2xEg7*X)Edk0Ky-fK zlJUBV+WA!)1|scHcmS1IS2+dMSbQ}7NBA4QZRYmjr15bEDB4JAnZ6yNQiy?}GU=8m z_LO*ACAVB!>ot4aZyUb(31GXc726pp{V9T{ZRe%vRC6#z(=tk)TL`C@5^K44rw?Rc z8~V=G3jbs~jxAArcF7d=(p)!m3ZHE@(5)^HA(K&E$5purbnHLtrd+b1-SlP`yS-_; zs(gPp);eC|BcB<--$ZA`Au9>%nZ%-H1n=5LuR*yuxjlpLK*OW~vo;pieYmOMNo8z< z+{>&h_|o*b5d+!4{Bv@D%CMklf!yP%?_o%UGk~!?^Q!^RMVLaTwYAdnjP;IzQ{C?c zuv>6|@i^+h&RwZ;u|OiYaI_~Y6sX_jGX0em)A^-l%B=R6_r`ejX4>>UJlGQyzhV~7 z7UEBjwMkz-AT;7Xgt~{a*NJoNIm<$|I*%{rk>Q^tFv!s@@a#Mxb9>7Mb?>Az3}5i# z!9W1HO)g>Q5n&fA5aAvP*WA(9Y(Kf6g1{H5*0SPOUN7o z%p2P2;4o09l~86ea|C^7znvop!ESRRyq*>}tr7vf(QOR$_V6riVv1WZZMV_ zKij&hvKF1vkP+LX!sPq`E!kNfBc7y$#~taz9UtA^7UgprsF_)y1;~Ry_)q*ZW1d$u zqTCy4I+?UI;f#B&DRznrAxfgrw=NkepspfGl1l)dh|){D2A1IphvFkWOeauvL9~n2 z{o`fCZZJ)G^evX4-41DP47S>$`O!em#-`S{Y8;T=5#(93h%qaig2 zNmzuYSAr{EEKnEE-X33eLrh`|7yCHEB8*K7K*Cun0!UEEj<%37yhOGHNSO6mpYAIp5NPaVSc9C{I!#62fF6mIEQ4?8sMEpE(o=9mky-V=L8TK-b^EV2!m+2m4c zE`)fOy&l!gie&EN`Ek<@>`rXD)UmsnW@E`k7%Gp$r;^e0*w*1J)T{t5)P{BLE`2p` z&RBkKZr)Qg@}QG7xp=00&A9}j zX{i}A7m@cV8btO(?xp&b;}E^r2}nJz3h8y8pJx=@4l>nsYb5BcKF*{ToSh4=-9g0Z zb)Ji2yc{J+v)`fAIQ*0+$Ty4SWD6T^=&0j{mFn`11?MH)Q@yG|joP^5P4BJ0GU{b9 zgG5``R2p!< zw1h!cv@m@@tjbOb-RiMdHA%4np26r3-GoG1E02X?W2~^SdUx)7d>7iq+4=HpfWm5R zCpo!$I^k@p-O+Tb`|;KJE}tjIvCr&A$&(u1aB=^IeS{I#$b(3GPC!WZft!euv0VQL zC%s;qM6RkX^&1BcQrKyq7b0%POVNLs7aEl%;X^dLxIf53jKVU zglZ0=okrM<2-%2jaNEZWGoD1kMSq!kv-+|pFQiQQo2AI5-1Si|v-Q{q+>$bF{R5vZ z0C>c{yy0gt>F|T%0-#sV5Bu=zmfMSY#~DmRI;%W*QyMF`fy?`8FxHofRh8L(pd9#& zb#iol1;`+wfFl3JT0dU7-!|pTa}F#4QlkMg*>x?oPL}e6FZUHIvy|EIqrsYGWzr5$ zp@6iWZVrWKSuy$KeXz2Iuw(8;M-&mgRI~;xo%M(6LqJY4BfqL*fgm;sdhZ8$%%bha zV1l61PHI34+lfw>Ys^~&4_$@Gbyk96Fef~;C{I}nK^DJG4XR|F)VJX&^V9dQZ-0oF zs6F8V+NWkvnni`AZ{LI}_J-hjhS~u)LLWEdY%H7*2{Dd=6*hs#TVU(J{fIq;An{!+ zn2E9-@ zZegpT_rXE8G#>nRy1^`PFscA@zvj@9dGerv1~1twD#bfWccCk}f9M(4R{{G+Xdpid z4xBBuZILxf;B5LMn~+%BC-~XsWfrFfI9JkG)0Ea%6w{014m)B|PL90ub8p2(2DX-m z8?3bf3dwMt1y(-_Q2g5?ZKI)b{kntGy^O zp23Ri;p0|TF733ZsFj*xQr3P(ET~^qr-%Ob<#$0~iCatY$H(a5T^5l6?ZBtp{7vXQ zswhdYscNN2y}nq5&+3AbZR>Vge}&Z;H@7ju4fN-=R2H-N%(&1+D#e>ru!x5(jVW>-HDcn3e*n zX1htG12i+^(gW&O{DdEi>_@-j^(U z5T3QjimlU@`B}qoK9=p6o#<6w?iB(~(kClUtuxD(6}y;MFESngI9m=Us@f$T%|J3o zaoL+0g0JBW&jdJMa~}E=kv)HGzSH0Lgd#`o(Qq3ifipq)M6qS)7`H8v+*#2#r>--C zY?X#Q0X!EvL9bjjNDeQq0*V^6J7^wA%Y*+*DXL{8cs1lFa466*l`Nh`wO$%hdBqOg^;OhX_VF} zQ6#S&_o-~%bm(%qpZ1v2$Y;I{dKilI)ZE)G*vKq9Pqb613ivS`X=&7f3>Zj- zKSd~}t{_w6Q!b&AvGTg_Wb@uJRrO;}Dx1|NiU&@Kn;TRk$|Y!rQcdH=8}F4%Uin(t z7W2uCLUq1ke+IBGzen))VEU<<)I-U z0r4L<3L+0=Bqfwp7!@S{(bc_0k~d^v5F7A^<(4Z9bO;D*TT>>}zxdIZo>-bQ-Oxf5 zu{C{R1?I8_3!WI;{AA&Kx8;|*Sxc|L%Yq3oukW?i;txy2_!Z7iCCTnOhujvVxsL8s zfLHR@l372@_uj9Z|0RHCOCe$cR#W&Fklmg2`(30gFlmnpxCv3<{R00jBpGmt)jxOF z-$7!m3g&ipU^Se7bt!nHfCVe;jepb31OcpxVKAgDnDqH}GqWiE0P=4v zM*~~qfA#gBV5Y@bA7+3DzB?F~`&QR(f^X2@Ud?}D{yE%DCHvdM^n&(};grErGS5tZ z)0sC#(phgcEQtOOkp8?$H#Mq-ZUMzJ{sGV*DzM)jo;M|3Z%-!PEWbznP2b&=Q@riG zlk>lv|J75!(1^Wz<~L>kt`!-7SU%tHo&RgV{pS2{s#)D0Wse1JLHtLi=ug!I?>6S9 zLejN_$q!o>{RPthtd(^a_okAL;4NH8iCeh;A2p`Cpf{CVu0?u&n3B{j(0^wQ{z$Ut zF3L@@iQ8Q&Df3g5{|HR{ZyGUoac@%YUrSm1Fhqr4PyPM@@$21lzgbIt%?SF#R&{=X@po9`C;Xsy0dCeKT$g13uui+5 z0{puM;jR|cUB@?HjlbPHOP;@U{EOm-yBIgK!q+d^|FClJUt#>_!rsi?U8j_P7-95J z-TpMeeD`E;CZujp^Iu|r>h)Jyz`M?GhLx{#T0cxN{^!pBAj5SRyKy50$qLSTURK|Fca-~JC(R-+UE literal 0 HcmV?d00001 From 6890b07c3e93dc9c20426e4281ede1197ba0e8ea Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 26 Apr 2026 19:59:53 +0300 Subject: [PATCH 020/106] fix(ci): fixed build commands --- .github/workflows/build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4cb473d..9561312 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -29,7 +29,7 @@ jobs: uses: gradle/actions/setup-gradle@v6 - name: Build - run: ./gradlew clean assemble --stacktrace --no-daemon + run: ./gradlew clean bootJar --stacktrace --no-daemon - name: Test run: ./gradlew test --stacktrace --no-daemon @@ -43,7 +43,7 @@ jobs: with: name: ${{ steps.meta.outputs.artifact-name }} path: | - build/libs/** + build/libs/*.jar build/reports/** build/test-results/** retention-days: 7 From 26dd04916e4e25df9443fd983a91ee3bb251ce2b Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Mon, 27 Apr 2026 23:22:26 +0300 Subject: [PATCH 021/106] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20release.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yaml | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/release.yaml diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..76f09cc --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,54 @@ +name: Release & Notify + +on: + workflow_call: + inputs: + version: + description: "Tag name, e.g. v1.2.3" + type: string + required: true + image-digest: + description: "Docker image digest from docker job" + type: string + required: false + default: "" + secrets: + TELEGRAM_BOT_TOKEN: + required: true + TELEGRAM_CHAT_ID: + required: true + +jobs: + release: + name: GitHub Release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + gh release create "${{ inputs.version }}" \ + --generate-notes \ + --title "${{ inputs.version }}" + + - name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + VERSION: ${{ inputs.version }} + REPO: ${{ github.repository }} + DIGEST: ${{ inputs.image-digest }} + run: | + RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" + MSG="*${REPO}* - released *${VERSION}*" + MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + if [ -n "${DIGEST}" ]; then + MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" + fi + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -d "chat_id=${CHAT_ID}" \ + -d "parse_mode=Markdown" \ + -d "text=${MSG}" From 401a556c54fd68fb0ec86bd713e244421c2a5386 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Mon, 27 Apr 2026 23:27:55 +0300 Subject: [PATCH 022/106] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20ci.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 50 +++++++++++++++------------------------ 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4a3f37..ffe61a7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,31 +1,19 @@ -name: CI -run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" - -on: - push: - branches: [develop, main] - tags: ["v*"] - pull_request: - branches: [develop, main] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build: - name: Build & Test - uses: ./.github/workflows/build.yaml - permissions: - contents: read - - docker: - name: Docker - needs: build - uses: ./.github/workflows/docker.yaml - permissions: - contents: read - packages: write - with: - push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} - secrets: inherit +- name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + VERSION: ${{ inputs.version }} + REPO: ${{ github.repository }} + DIGEST: ${{ inputs.image-digest }} + run: | + RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" + MSG="*${REPO}* - released *${VERSION}*" + MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + if [ -n "${DIGEST}" ]; then + MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" + fi + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -d "chat_id=${CHAT_ID}" \ + -d "parse_mode=Markdown" \ + -d "text=${MSG}" From 17950647106ee526ce9c67db6449364c0359a3bc Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Tue, 28 Apr 2026 18:34:15 +0300 Subject: [PATCH 023/106] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20ci.yaml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 78 +++++++++++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ffe61a7..4ea1ee6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,19 +1,59 @@ -- name: Send Telegram notification - env: - TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} - CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - VERSION: ${{ inputs.version }} - REPO: ${{ github.repository }} - DIGEST: ${{ inputs.image-digest }} - run: | - RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" - MSG="*${REPO}* - released *${VERSION}*" - MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" - if [ -n "${DIGEST}" ]; then - MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" - fi - curl -sf -X POST \ - "https://api.telegram.org/bot${TOKEN}/sendMessage" \ - -d "chat_id=${CHAT_ID}" \ - -d "parse_mode=Markdown" \ - -d "text=${MSG}" +name: CI +run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" + +on: + push: + branches: [develop, main] + tags: ["v*"] + pull_request: + branches: [develop, main] + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build & Test + uses: ./.github/workflows/build.yaml + permissions: + contents: read + + docker: + name: Docker + needs: build + uses: ./.github/workflows/docker.yaml + permissions: + contents: read + packages: write + with: + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + secrets: inherit + + telegram-notify: + name: Send Telegram Notification + needs: docker + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Send Telegram notification + env: + TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} + CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + VERSION: ${{ github.ref_name }} + REPO: ${{ github.repository }} + DIGEST: ${{ needs.docker.outputs.image-digest }} + run: | + RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" + MSG="*${REPO}* - released *${VERSION}*" + MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + if [ -n "${DIGEST}" ]; then + MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" + fi + curl -sf -X POST \ + "https://api.telegram.org/bot${TOKEN}/sendMessage" \ + -d "chat_id=${CHAT_ID}" \ + -d "parse_mode=Markdown" \ + -d "text=${MSG}" From 28eb823f98166571026db6f201f9c4ceea1eaa62 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 30 Apr 2026 09:44:37 +0300 Subject: [PATCH 024/106] deps(idea): kotlinc plugin update --- .idea/kotlinc.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 88cf87c..9b361e3 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -2,6 +2,6 @@ \ No newline at end of file From a41c3de0df319a8467806eade71065f87a4e1a51 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 30 Apr 2026 09:49:25 +0300 Subject: [PATCH 025/106] build(): added AOT for spring --- Containerfile | 11 +++-------- build.gradle.kts | 2 ++ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Containerfile b/Containerfile index 7e1867b..1fda1fa 100644 --- a/Containerfile +++ b/Containerfile @@ -26,13 +26,7 @@ RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ COPY src src RUN --mount=type=cache,target=${GRADLE_USER_HOME} \ - ./gradlew --no-daemon build \ - -x test \ - -x detekt \ - -x ktlintCheck \ - -x ktlintKotlinScriptCheck \ - -x ktlintMainSourceSetCheck \ - -x ktlintTestSourceSetCheck + ./gradlew --no-daemon bootJar RUN mkdir -p ${APP_HOME}/dist \ && cp ${APP_HOME}/build/libs/*.jar ${APP_HOME}/dist/${JAR_NAME} \ @@ -60,6 +54,7 @@ COPY --from=builder --chown=app:app /workspace/dist/app.jar /app/app.jar USER app ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC" \ + SPRING_AOT_ENABLED=true \ SERVER_PORT=8080 \ PATH="/app:$PATH" @@ -70,4 +65,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \ STOPSIGNAL SIGTERM -ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"] +ENTRYPOINT ["sh", "-c", "exec java ${JAVA_OPTS} -Dspring.aot.enabled=${SPRING_AOT_ENABLED} -Dserver.port=${SERVER_PORT} -jar /app/app.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index 18d7d23..7f24f7e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,6 +15,8 @@ plugins { jacoco } +apply(plugin = "org.springframework.boot.aot") + apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" From 1e2625a4602976689eb9fc3d85a4b0c2f024d434 Mon Sep 17 00:00:00 2001 From: skettiks Date: Sun, 3 May 2026 12:04:29 +0300 Subject: [PATCH 026/106] =?UTF-8?q?chore(ci):=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B8=D0=BB=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83?= =?UTF-8?q?=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20CI/CD=20=D0=BF=D0=B0=D0=B9?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=B9=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yaml | 14 +++++++++----- .github/workflows/docker.yaml | 9 ++++++--- .github/workflows/release.yaml | 10 +++++++--- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 9561312..8cd8d59 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -12,6 +12,8 @@ jobs: name: Build & Test runs-on: ubuntu-latest timeout-minutes: 20 + permissions: + contents: read outputs: artifact-name: ${{ steps.meta.outputs.artifact-name }} steps: @@ -28,23 +30,25 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 - - name: Build - run: ./gradlew clean bootJar --stacktrace --no-daemon - - - name: Test - run: ./gradlew test --stacktrace --no-daemon + - name: Run CI quality gate + run: ./gradlew clean check bootJar --stacktrace --no-daemon - name: Set artifact name id: meta run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" - name: Upload build artifacts + if: always() uses: actions/upload-artifact@v7 with: name: ${{ steps.meta.outputs.artifact-name }} path: | build/libs/*.jar + build/reports/detekt/** + build/reports/ktlint/** + build/reports/jacoco/** build/reports/** build/test-results/** + build/jacoco/** retention-days: 7 if-no-files-found: error diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 21e61b8..0854d36 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -30,13 +30,16 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - - name: Validate Dockerfile + - name: Build image (no push) if: inputs.push == false uses: docker/build-push-action@v7 with: context: . file: ./Containerfile - call: check + push: false + provenance: false + cache-from: type=gha + cache-to: type=gha,mode=max - name: Log in to GHCR if: inputs.push == true @@ -65,7 +68,7 @@ jobs: uses: docker/build-push-action@v7 with: context: . - file: ./Dockerfile + file: ./Containerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 76f09cc..0fdfcb1 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -29,9 +29,13 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | - gh release create "${{ inputs.version }}" \ - --generate-notes \ - --title "${{ inputs.version }}" + if gh release view "${{ inputs.version }}" >/dev/null 2>&1; then + echo "Release ${{ inputs.version }} already exists. Skipping creation." + else + gh release create "${{ inputs.version }}" \ + --generate-notes \ + --title "${{ inputs.version }}" + fi - name: Send Telegram notification env: From 517de4d3721e31ee1b98c054f8d8e9c34282d380 Mon Sep 17 00:00:00 2001 From: ITQ Date: Thu, 30 Apr 2026 09:41:36 +0300 Subject: [PATCH 027/106] ci(): added trufflehog, release notification improvements --- .github/workflows/ci.yaml | 47 ++++++++++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4ea1ee6..ece7eb1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,15 +13,30 @@ concurrency: cancel-in-progress: true jobs: - build: - name: Build & Test - uses: ./.github/workflows/build.yaml + trufflehog: + name: TruffleHog Secret Scan + runs-on: ubuntu-latest permissions: contents: read + steps: + - name: Checkout source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Run TruffleHog + uses: trufflesecurity/trufflehog@main + with: + extra_args: --results=verified,unknown + + build: + name: Build & Test + needs: [trufflehog] + uses: ./.github/workflows/build.yaml docker: name: Docker needs: build + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') uses: ./.github/workflows/docker.yaml permissions: contents: read @@ -30,10 +45,10 @@ jobs: push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} secrets: inherit - telegram-notify: - name: Send Telegram Notification + notify-main: + name: Notify Main Build needs: docker - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest permissions: contents: read @@ -42,13 +57,13 @@ jobs: env: TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} - VERSION: ${{ github.ref_name }} REPO: ${{ github.repository }} + SHA: ${{ github.sha }} DIGEST: ${{ needs.docker.outputs.image-digest }} run: | - RELEASE_URL="https://github.com/${REPO}/releases/tag/${VERSION}" - MSG="*${REPO}* - released *${VERSION}*" - MSG="${MSG}%0A🔗 [Release notes](${RELEASE_URL})" + COMMIT_URL="https://github.com/${REPO}/commit/${SHA}" + MSG="*${REPO}* - main branch CI succeeded" + MSG="${MSG}%0A🔗 [Commit](${COMMIT_URL})" if [ -n "${DIGEST}" ]; then MSG="${MSG}%0AImage: \`ghcr.io/${REPO}@${DIGEST}\`" fi @@ -57,3 +72,15 @@ jobs: -d "chat_id=${CHAT_ID}" \ -d "parse_mode=Markdown" \ -d "text=${MSG}" + + release: + name: Release + needs: docker + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + uses: ./.github/workflows/release.yaml + with: + version: ${{ github.ref_name }} + image-digest: ${{ needs.docker.outputs.image-digest }} + secrets: inherit From b7a246e0b40389ba6c21f720fd54ed13a70faaf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 12:02:31 +0000 Subject: [PATCH 028/106] fix: address copilot review issues - explicit imports, trailing commas, 404 for not-found search, tests for available-films endpoint Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/60cd9b1b-e3e1-46a1-bfd9-04a75bd0d569 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/adapters/web/FilmController.kt | 15 +- .../adapters/web/FilmLibraryController.kt | 33 +-- .../application/services/FilmService.kt | 22 +- .../controllers/FilmControllerTest.kt | 137 ++++++------ .../controllers/FilmLibraryControllerTest.kt | 202 ++++++++++++++---- .../controllers/UserControllerTest.kt | 82 +++---- 6 files changed, 317 insertions(+), 174 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index df375e4..3699e33 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -57,10 +57,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -73,12 +74,10 @@ class FilmController( @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = - FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping - fun getAll(): List = - getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } @GetMapping("/search") fun searchByTitle( diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index a4a6175..e4e000d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -64,9 +64,10 @@ class FilmLibraryController( fun getAllFilmsInLibrary( @PathVariable userId: UUID, ): List { - val library = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val film = getFilmByIdUseCase.getById(library.filmId) return listOf(FilmResponse.fromDomain(film)) } @@ -102,21 +103,23 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = try { - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - } catch (e: EntityNotFoundException) { - null - } + val userLibrary = + try { + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) + } catch (e: EntityNotFoundException) { + null + } val allFilms = getAllFilmsUseCase.getAll() - val availableFilms = if (userLibrary != null) { - allFilms.filter { it.id != userLibrary.filmId } - } else { - allFilms - } + val availableFilms = + if (userLibrary != null) { + allFilms.filter { it.id != userLibrary.filmId } + } else { + allFilms + } return availableFilms.map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 555655a..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -28,7 +28,6 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -37,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -63,9 +66,8 @@ class FilmService( filmRepository.deleteById(id) } - override fun getById(id: UUID): Film { - return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) override fun getAll(): List = filmRepository.findAll() diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt index 0784280..18a952a 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -9,13 +9,16 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.* -import org.springframework.test.web.servlet.result.MockMvcResultMatchers.* +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @SpringBootTest @AutoConfigureMockMvc class FilmControllerTest { - @Autowired private lateinit var mockMvc: MockMvc @@ -24,17 +27,18 @@ class FilmControllerTest { @Test fun `create film should return 201 CREATED`() { - val request = CreateFilmRequest( - title = "The Matrix", - description = "A computer hacker learns about the true nature of reality", - ) + val request = + CreateFilmRequest( + title = "The Matrix", + description = "A computer hacker learns about the true nature of reality", + ) - mockMvc.perform( - post("/api/films") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ) - .andExpect(status().isCreated) + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) .andExpect(jsonPath("$.title").value("The Matrix")) .andExpect(jsonPath("$.description").value("A computer hacker learns about the true nature of reality")) .andExpect(jsonPath("$.id").exists()) @@ -42,86 +46,95 @@ class FilmControllerTest { @Test fun `edit film should return updated film`() { - val createRequest = CreateFilmRequest( - title = "Old Title", - description = "Old Description" - ) + val createRequest = + CreateFilmRequest( + title = "Old Title", + description = "Old Description", + ) - val response = mockMvc.perform( - post("/api/films") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)) - ).andReturn() + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() - val editRequest = EditFilmRequest( - title = "New Title", - description = "New Description", - ) + val editRequest = + EditFilmRequest( + title = "New Title", + description = "New Description", + ) - mockMvc.perform( - patch("/api/films/$filmId") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(editRequest)) - ) - .andExpect(status().isOk) + mockMvc + .perform( + patch("/api/films/$filmId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) .andExpect(jsonPath("$.title").value("New Title")) .andExpect(jsonPath("$.description").value("New Description")) } @Test fun `search film by title should return film`() { - val request = CreateFilmRequest( - title = "Inception", - description = "Dream within a dream" - ) + val request = + CreateFilmRequest( + title = "Inception", + description = "Dream within a dream", + ) mockMvc.perform( post("/api/films") .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) + .content(objectMapper.writeValueAsString(request)), ) - mockMvc.perform( - get("/api/films/search") - .param("title", "Inception") - ) - .andExpect(status().isOk) + mockMvc + .perform( + get("/api/films/search") + .param("title", "Inception"), + ).andExpect(status().isOk) .andExpect(jsonPath("$.title").value("Inception")) .andExpect(jsonPath("$.description").value("Dream within a dream")) } @Test - fun `search film by non-existent title should return empty`() { - mockMvc.perform( - get("/api/films/search") - .param("title", "NonExistentFilm12345") - ) - .andExpect(status().isOk) - .andExpect(content().string("")) + fun `search film by non-existent title should return 404`() { + mockMvc + .perform( + get("/api/films/search") + .param("title", "NonExistentFilm12345"), + ).andExpect(status().isNotFound) } @Test fun `delete film should return 204 NO CONTENT`() { - val request = CreateFilmRequest( - title = "Film To Delete", - description = "This film will be deleted" - ) + val request = + CreateFilmRequest( + title = "Film To Delete", + description = "This film will be deleted", + ) - val response = mockMvc.perform( - post("/api/films") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)) - ).andReturn() + val response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() val filmId = objectMapper.readTree(response.response.contentAsString).get("id").asText() - mockMvc.perform(delete("/api/films/$filmId")) + mockMvc + .perform(delete("/api/films/$filmId")) .andExpect(status().isNoContent()) - mockMvc.perform(get("/api/films/search").param("title", "Film To Delete")) - .andExpect(status().isOk) - .andExpect(content().string("")) + mockMvc + .perform( + get("/api/films/search").param("title", "Film To Delete"), + ).andExpect(status().isNotFound) } } diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index d24e60e..3bbc177 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -10,7 +10,9 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @@ -18,7 +20,6 @@ import org.springframework.transaction.annotation.Transactional @AutoConfigureMockMvc @Transactional class FilmLibraryControllerTest { - @Autowired private lateinit var mockMvc: MockMvc @@ -27,60 +28,177 @@ class FilmLibraryControllerTest { @Test fun `add film to library should work`() { - val userRequest = CreateUserRequest( - name = "Film Adder", - email = "adder@example.com", - ) - val userResponse = mockMvc.perform( - post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(userRequest)), - ).andReturn() + val userRequest = + CreateUserRequest( + name = "Film Adder", + email = "adder@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() - val filmRequest = CreateFilmRequest( - title = "Library Film", - description = "Film description", - ) - val filmResponse = mockMvc.perform( - post("/api/films") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(filmRequest)), - ).andReturn() + val filmRequest = + CreateFilmRequest( + title = "Library Film", + description = "Film description", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() - mockMvc.perform( - post("/api/users/$userId/library/films/$filmId"), - ) - .andExpect(status().isCreated()) + mockMvc + .perform( + post("/api/users/$userId/library/films/$filmId"), + ).andExpect(status().isCreated()) } @Test fun `remove film from library should return 204`() { - val userRequest = CreateUserRequest( - name = "Remove Film", - email = "remove@example.com", - ) - val userResponse = mockMvc.perform( - post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(userRequest)), - ).andReturn() + val userRequest = + CreateUserRequest( + name = "Remove Film", + email = "remove@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() - val filmRequest = CreateFilmRequest( - title = "Film To Remove", - description = "Will be removed", - ) - val filmResponse = mockMvc.perform( - post("/api/films") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(filmRequest)), - ).andReturn() + val filmRequest = + CreateFilmRequest( + title = "Film To Remove", + description = "Will be removed", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() mockMvc.perform(post("/api/users/$userId/library/films/$filmId")) - mockMvc.perform(delete("/api/users/$userId/library/films/$filmId")) + mockMvc + .perform(delete("/api/users/$userId/library/films/$filmId")) .andExpect(status().isNoContent()) } + + @Test + fun `get available films should exclude film in user's library`() { + val userRequest = + CreateUserRequest( + name = "Available Films User", + email = "availablefilms@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val film1Request = + CreateFilmRequest( + title = "Film In Library", + description = "This will be in the library", + ) + val film1Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film1Request)), + ).andReturn() + val film1Id = objectMapper.readTree(film1Response.response.contentAsString).get("id").asText() + + val film2Request = + CreateFilmRequest( + title = "Film Not In Library", + description = "This will not be in the library", + ) + val film2Response = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(film2Request)), + ).andReturn() + val film2Id = objectMapper.readTree(film2Response.response.contentAsString).get("id").asText() + + mockMvc.perform(post("/api/users/$userId/library/films/$film1Id")) + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = (0 until films.size()).map { films[it].get("id").asText() } + assert(!returnedIds.contains(film1Id)) { "Film in library should not appear in available films" } + assert(returnedIds.contains(film2Id)) { "Film not in library should appear in available films" } + } + + @Test + fun `get available films for user without library returns all films`() { + val userRequest = + CreateUserRequest( + name = "No Library User", + email = "nolibrary@example.com", + ) + val userResponse = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(userRequest)), + ).andReturn() + val userId = objectMapper.readTree(userResponse.response.contentAsString).get("id").asText() + + val filmRequest = + CreateFilmRequest( + title = "Available Film", + description = "Should appear in available films", + ) + val filmResponse = + mockMvc + .perform( + post("/api/films") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(filmRequest)), + ).andReturn() + val filmId = objectMapper.readTree(filmResponse.response.contentAsString).get("id").asText() + + val result = + mockMvc + .perform( + get("/api/users/$userId/library/available-films"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[*].id").isArray) + .andReturn() + + val responseBody = result.response.contentAsString + val films = objectMapper.readTree(responseBody) + val returnedIds = (0 until films.size()).map { films[it].get("id").asText() } + assert(returnedIds.contains(filmId)) { "Film should appear in available films when user has no library" } + } } diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt index db2eecc..7a2dca3 100644 --- a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -20,7 +20,6 @@ import org.springframework.transaction.annotation.Transactional @AutoConfigureMockMvc @Transactional class UserControllerTest { - @Autowired private lateinit var mockMvc: MockMvc @@ -29,17 +28,18 @@ class UserControllerTest { @Test fun `create user should return 201 CREATED`() { - val request = CreateUserRequest( - name = "John Doe", - email = "john@example.com", - ) + val request = + CreateUserRequest( + name = "John Doe", + email = "john@example.com", + ) - mockMvc.perform( - post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)), - ) - .andExpect(status().isCreated) + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andExpect(status().isCreated) .andExpect(jsonPath("$.name").value("John Doe")) .andExpect(jsonPath("$.email").value("john@example.com")) .andExpect(jsonPath("$.id").exists()) @@ -47,54 +47,62 @@ class UserControllerTest { @Test fun `edit user should return updated user`() { - val createRequest = CreateUserRequest( - name = "Old Name", - email = "edit@example.com", - ) + val createRequest = + CreateUserRequest( + name = "Old Name", + email = "edit@example.com", + ) - val response = mockMvc.perform( - post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createRequest)), - ).andReturn() + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createRequest)), + ).andReturn() val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() val editRequest = EditUserRequest(name = "New Name") - mockMvc.perform( - patch("/api/users/$userId") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(editRequest)), - ) - .andExpect(status().isOk) + mockMvc + .perform( + patch("/api/users/$userId") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(editRequest)), + ).andExpect(status().isOk) .andExpect(jsonPath("$.name").value("New Name")) .andExpect(jsonPath("$.email").value("edit@example.com")) } @Test fun `delete user should return 204 NO CONTENT`() { - val request = CreateUserRequest( - name = "User To Delete", - email = "delete@example.com", - ) + val request = + CreateUserRequest( + name = "User To Delete", + email = "delete@example.com", + ) - val response = mockMvc.perform( - post("/api/users") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request)), - ).andReturn() + val response = + mockMvc + .perform( + post("/api/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)), + ).andReturn() val userId = objectMapper.readTree(response.response.contentAsString).get("id").asText() - mockMvc.perform(delete("/api/users/$userId")) + mockMvc + .perform(delete("/api/users/$userId")) .andExpect(status().isNoContent()) } @Test fun `delete non-existent user should return 404`() { val nonExistentId = "123e4567-e89b-12d3-a456-426614174000" - mockMvc.perform(delete("/api/users/$nonExistentId")) + mockMvc + .perform(delete("/api/users/$nonExistentId")) .andExpect(status().isNotFound()) } } From 44483d748fdbf1f08bbd687866cd10e248f6ea66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 3 May 2026 12:03:23 +0000 Subject: [PATCH 029/106] refactor: use idiomatic Kotlin iteration for JsonNode in tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/60cd9b1b-e3e1-46a1-bfd9-04a75bd0d569 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/controllers/FilmLibraryControllerTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index 3bbc177..560cf38 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -153,7 +153,7 @@ class FilmLibraryControllerTest { val responseBody = result.response.contentAsString val films = objectMapper.readTree(responseBody) - val returnedIds = (0 until films.size()).map { films[it].get("id").asText() } + val returnedIds = films.toList().map { it.get("id").asText() } assert(!returnedIds.contains(film1Id)) { "Film in library should not appear in available films" } assert(returnedIds.contains(film2Id)) { "Film not in library should appear in available films" } } @@ -198,7 +198,7 @@ class FilmLibraryControllerTest { val responseBody = result.response.contentAsString val films = objectMapper.readTree(responseBody) - val returnedIds = (0 until films.size()).map { films[it].get("id").asText() } + val returnedIds = films.toList().map { it.get("id").asText() } assert(returnedIds.contains(filmId)) { "Film should appear in available films when user has no library" } } } From 1d8e3dd4e157baa10db53f6d0d944c1f5fe3a253 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 19:14:02 +0300 Subject: [PATCH 030/106] style(format): reformatted with ktlint --- .../persistence/jdbc/UserRepository.kt | 14 +++-- .../entity/UserEntityMappingTest.kt | 59 ++++++++++--------- 2 files changed, 39 insertions(+), 34 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 09d8f9d..6c70b4c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -69,10 +69,11 @@ class UserRepository( } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -84,7 +85,10 @@ class UserRepository( ): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + """ + SELECT id, name, email, provider, provider_id, created_at FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), userEntityRowMapper, provider.name, providerId, diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index bec7f20..cdf0c42 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -9,17 +9,17 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class UserEntityMappingTest { - @Test fun `toDomain maps UserEntity correctly`() { - val entity = UserEntity( - id = UUID.randomUUID(), - name = "John Pork", - email = "john@email.com", - provider = "GOOGLE", - providerId = "google1234", - createdAt = LocalDateTime.now(), - ) + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) val user = entity.toDomain() assertEquals(entity.id, user.id) @@ -30,12 +30,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User with OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Jane", - email = "jane@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null, + ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -48,12 +49,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User without OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Bob", - email = "bob@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null, + ) val entity = user.toEntity() @@ -63,12 +65,13 @@ class UserEntityMappingTest { @Test fun `mapping is reversible for basic fields`() { - val original = User( - id = UUID.randomUUID(), - name = "Alice", - email = "alice@email.com", - library = null - ) + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null, + ) val mapped = original.toEntity().toDomain() @@ -76,6 +79,4 @@ class UserEntityMappingTest { assertEquals(original.name, mapped.name) assertEquals(original.email, mapped.email) } - - } From 02d3f4580daf3d3cafaf7c1c9510d41150ff6786 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 19:47:31 +0300 Subject: [PATCH 031/106] fix(repository): temporary schema fix before #32 PR merge --- .../adapters/web/FilmLibraryController.kt | 14 ++++++++------ src/main/resources/db/migration/V1__init.sql | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 59022b0..74b04bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -74,10 +74,12 @@ class FilmLibraryController( fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) = removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) + ) { + removeFilmFromLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) + } } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..900f6b5 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,10 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( From d9f45a9e4c3ea11a63c2b7eee31fe27b3a681311 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 21:08:43 +0300 Subject: [PATCH 032/106] style(format): run ktlint format --- .../persistence/jdbc/FilmRepository.kt | 11 +++++----- .../movienight/adapters/web/FilmController.kt | 18 +++++++-------- .../adapters/web/FilmLibraryController.kt | 14 +++++++----- .../movienight/adapters/web/UserController.kt | 13 +++++------ .../application/services/FilmService.kt | 22 ++++++++++--------- .../application/services/UserService.kt | 19 ++++++++-------- 6 files changed, 49 insertions(+), 48 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index a13f721..243b8d7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -62,11 +62,12 @@ class FilmRepository( ) override fun findByTitle(title: String): Film? { - val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", - filmRowMapper, - title - ) + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ?", + filmRowMapper, + title, + ) return films.firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 12ffa79..c2d1eb2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -56,10 +56,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -72,16 +73,13 @@ class FilmController( @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = - FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping("/search") fun searchByTitle( @RequestParam title: String, - ): FilmResponse? = - searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } + ): FilmResponse? = searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } @GetMapping - fun getAll(): List = - getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 8d0e69c..b6731a9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -63,9 +63,10 @@ class FilmLibraryController( fun getAllFilmsInLibrary( @PathVariable userId: UUID, ): List { - val library = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val film = getFilmByIdUseCase.getById(library.filmId) @@ -105,9 +106,10 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val userLibrary = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 6c1a996..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -46,14 +46,12 @@ class UserController( ) @GetMapping - fun getAll(): List = - getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = - UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -63,9 +61,10 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = EditUserCommand( - name = request.name, - ), + command = + EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 555655a..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -28,7 +28,6 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -37,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -63,9 +66,8 @@ class FilmService( filmRepository.deleteById(id) } - override fun getById(id: UUID): Film { - return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) override fun getAll(): List = filmRepository.findAll() diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 4d16ea1..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -26,18 +26,18 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + ) return userRepository.save(user) } @@ -59,9 +59,8 @@ class UserService( userRepository.deleteById(id) } - override fun getById(id: UUID): User { - return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) override fun getAll(): List = userRepository.findAll() } From 238aec898eb5e1f4fe544c8d40b922f16f27dbb3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sun, 3 May 2026 22:25:50 +0300 Subject: [PATCH 033/106] test(web): added search controller test --- .../adapters/web/FilmControllerSearchTest.kt | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt new file mode 100644 index 0000000..dd6bd33 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -0,0 +1,77 @@ +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.domain.model.Film +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.UUID + +class FilmControllerSearchTest { + private lateinit var mockMvc: MockMvc + private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + + @BeforeEach + fun setup() { + searchFilmByTitleUseCase = mockk() + + val controller = + FilmController( + createFilmUseCase = mockk(), + editFilmUseCase = mockk(), + deleteFilmUseCase = mockk(), + getFilmByIdUseCase = mockk(), + getAllFilmsUseCase = mockk(), + searchFilmByTitleUseCase = searchFilmByTitleUseCase, + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun `search returns film when title exists`() { + val title = "Inception" + val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(film.id.toString()) } + jsonPath("$.title") { value(title) } + jsonPath("$.description") { value("A dream heist") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } + + @Test + fun `search returns empty body when title is missing`() { + val title = "Unknown Title" + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + content { string("") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } +} From 27317c00e42b4325de6642281a03c972cdc7ceae Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 5 May 2026 00:58:20 +0300 Subject: [PATCH 034/106] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20develop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - После слияния с develop возникли конфликты в реализации OAuth2 - Две ветки независимо реализовали OAuth2 функциональность по-разному - Сборка проекта падала из-за отсутствия зависимостей OAuth2 Изменения: - Временно отключена OAuth2 зависимость в build.gradle.kts - Перенесен OAuth2 код в папку security.disabled для сохранения - Добавлены исключения security.disabled из компиляции, ktlint и detekt - Удалена миграция V2__add_oauth2_fields.sql (OAuth2 поля теперь в V1) - Удалено поле password из User domain модели - Обновлены репозитории и сервисы для работы с новой схемой БД Результат: - Проект успешно собирается (./gradlew build) - Все 35 тестов проходят - OAuth2 код сохранен для будущего использования --- build.gradle.kts | 9 +- .../movienight/MovieNightApplication.kt | 3 +- .../persistence/jdbc/FilmRepository.kt | 11 ++- .../persistence/jdbc/UserRepository.kt | 99 ++++++++----------- .../CustomOAuth2UserService.kt | 48 ++++++--- .../GoogleOAuth2UserInfo.kt | 3 +- .../OAuth2UserInfoFactory.kt | 6 +- .../UserPrincipal.kt | 17 ++-- .../VkOAuth2UserInfo.kt | 8 +- .../YandexOAuth2UserInfo.kt | 8 +- .../movienight/adapters/web/FilmController.kt | 18 ++-- .../adapters/web/FilmLibraryController.kt | 14 +-- .../movienight/adapters/web/UserController.kt | 13 ++- .../OAuth2UserInfo.kt | 4 + .../ports/output/UserRepositoryPort.kt | 4 - .../application/services/FilmService.kt | 22 +++-- .../application/services/UserService.kt | 7 +- .../project/movienight/domain/model/User.kt | 1 - src/main/resources/db/migration/V1__init.sql | 4 +- .../db/migration/V2__add_oauth2_fields.sql | 9 -- .../entity/UserEntityMappingTest.kt | 59 +++++------ 21 files changed, 180 insertions(+), 187 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/CustomOAuth2UserService.kt (61%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/GoogleOAuth2UserInfo.kt (91%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/OAuth2UserInfoFactory.kt (85%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/UserPrincipal.kt (77%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/VkOAuth2UserInfo.kt (84%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/YandexOAuth2UserInfo.kt (80%) rename src/main/kotlin/com/project/movienight/application/ports/input/{security => security.disabled}/OAuth2UserInfo.kt (98%) delete mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index f20ddff..6fcffe6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,8 @@ plugins { jacoco } -apply(plugin = "org.springframework.boot.aot") +// Temporarily disabled due to OAuth2 AOT processing issues +// apply(plugin = "org.springframework.boot.aot") apply(from = "$rootDir/gradle/docker.gradle.kts") @@ -48,7 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - implementation(libs.spring.boot.starter.oauth2.client) + // Temporarily disabled due to OAuth2 configuration issues + // implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) @@ -76,6 +78,7 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } + exclude("**/security.disabled/**") } tasks.withType { @@ -158,6 +161,7 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") + exclude("**/security.disabled/**") } } @@ -171,6 +175,7 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" + exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..f6c7d33 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,10 +1,11 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication +@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index a13f721..243b8d7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -62,11 +62,12 @@ class FilmRepository( ) override fun findByTitle(title: String): Film? { - val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ?", - filmRowMapper, - title - ) + val films = + jdbc.query( + "SELECT id, title, description FROM films WHERE title = ?", + filmRowMapper, + title, + ) return films.firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index cb866d9..cd1a860 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -20,8 +20,9 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - password = rs.getString("password"), - library = null, + provider = rs.getString("provider"), + providerId = rs.getString("provider_id"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -31,24 +32,25 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, password = ? + SET name = ?, email = ? WHERE id = ? """.trimIndent(), - user.name, - user.email, - user.password, - user.id, + entity.name, + entity.email, + entity.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, password) - VALUES (?, ?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), - user.id, - user.name, - user.email, - user.password, + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.createdAt, ) } return user @@ -57,69 +59,48 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, password FROM users WHERE id = ?", - userRowMapper, + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, id, ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val users = jdbc.query( - "SELECT id, name, email, password FROM users WHERE email = ?", - userRowMapper, - email, - ) - return users.firstOrNull() + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) + return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, password FROM users", - userRowMapper, - ) + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) } - override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { - val updatedRows = jdbc.update(""" - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - user.name, - user.email, - user.password, - provider, - providerId, - user.id, - ) - if (updatedRows == 0) { - jdbc.update( + override fun findByProviderAndProviderId( + provider: AuthProvider, + providerId: String, + ): User? { + val entities = + jdbc.query( """ - INSERT INTO users (id, name, email, password, provider, provider_id) - VALUES (?, ?, ?, ?, ?, ?) + SELECT id, name, email, provider, provider_id, created_at FROM users + WHERE provider = ? AND provider_id = ? """.trimIndent(), - user.id, - user.name, - user.email, - user.password, - provider, + userEntityRowMapper, + provider.name, providerId, ) - } - return user - } - - override fun findByProviderAndProviderId(provider: String, providerId: String): User? { - val users = jdbc.query( - "SELECT id, name, email, password FROM users WHERE provider = ? AND provider_id = ?", - userRowMapper, - provider, - providerId, - ) - return users.firstOrNull() + return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt similarity index 61% rename from src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt index cc1cb8d..b64ea71 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt @@ -1,8 +1,11 @@ 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 +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.slf4j.LoggerFactory import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService @@ -16,7 +19,6 @@ class CustomOAuth2UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, ) : DefaultOAuth2UserService() { - companion object { private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) } @@ -31,17 +33,23 @@ class CustomOAuth2UserService( val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) val user = findOrCreateUser(userInfo) UserPrincipal.create(user, oAuth2User.attributes) - } catch (e: Exception) { + } catch (e: IllegalArgumentException) { log.error("OAuth2 authentication failed: ${e.message}", e) throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } catch (e: OAuth2AuthenticationException) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw e } } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByProviderAndProviderId( - userInfo.getProvider(), - userInfo.getProviderId() - ) + val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase()) + + val existingUser = + userRepository.findByProviderAndProviderId( + provider, + userInfo.getProviderId(), + ) return if (existingUser != null) { log.debug("User found by provider: {}", userInfo.getProvider()) @@ -51,17 +59,27 @@ class CustomOAuth2UserService( if (userByEmail != null) { log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) - userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + val entity = + userByEmail.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } else { log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", - library = null, - ) - userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + val newUser = + User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + library = null, + ) + val entity = + newUser.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt similarity index 91% rename from src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt index c463ac6..123f9a0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt @@ -3,9 +3,8 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class GoogleOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["sub"] as String override fun getEmail(): String = attributes["email"] as String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt similarity index 85% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt index 89d6d30..1faf660 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt @@ -5,8 +5,10 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User object OAuth2UserInfoFactory { - - fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + fun getOAuth2UserInfo( + registrationId: String, + user: OAuth2User, + ): OAuth2UserInfo { val attributes = user.attributes return when (registrationId.lowercase()) { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt similarity index 77% rename from src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index c602211..3ea81b4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,19 +10,17 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection { - return listOf(SimpleGrantedAuthority("ROLE_USER")) - } + override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) - override fun getPassword(): String = user.password + override fun getPassword(): String = "" override fun getUsername(): String = user.email @@ -35,8 +33,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal { - return UserPrincipal(user, attributes) - } + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt similarity index 84% rename from src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt index e2c55c0..9b55f35 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt @@ -3,16 +3,14 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class VkOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - - override fun getProviderId(): String { - return (attributes["response"] as? List<*>) + override fun getProviderId(): String = + (attributes["response"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("id") ?.toString() ?: "" - } override fun getEmail(): String = attributes["email"]?.toString() ?: "" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt similarity index 80% rename from src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt index 59bf3eb..dc71df9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt @@ -3,18 +3,16 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class YandexOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["id"]?.toString() ?: "" - override fun getEmail(): String { - return (attributes["emails"] as? List<*>) + override fun getEmail(): String = + (attributes["emails"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("value") ?.toString() ?: "" - } override fun getName(): String = attributes["display_name"]?.toString() ?: "" diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 12ffa79..c2d1eb2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -56,10 +56,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) @@ -72,16 +73,13 @@ class FilmController( @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = - FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) @GetMapping("/search") fun searchByTitle( @RequestParam title: String, - ): FilmResponse? = - searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } + ): FilmResponse? = searchFilmByTitleUseCase.searchByTitle(title)?.let { FilmResponse.fromDomain(it) } @GetMapping - fun getAll(): List = - getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index f5246cc..ec44e2f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -63,9 +63,10 @@ class FilmLibraryController( fun getAllFilmsInLibrary( @PathVariable userId: UUID, ): List { - val library = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val library = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val film = getFilmByIdUseCase.getById(library.filmId) @@ -103,9 +104,10 @@ class FilmLibraryController( fun getAvailableFilms( @PathVariable userId: UUID, ): List { - val userLibrary = getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId) - ) + val userLibrary = + getFilmLibraryUseCase.getLibrary( + GetFilmLibraryQuery(userId = userId), + ) val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 6c1a996..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -46,14 +46,12 @@ class UserController( ) @GetMapping - fun getAll(): List = - getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = - UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -63,9 +61,10 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = EditUserCommand( - name = request.name, - ), + command = + EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt similarity index 98% rename from src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt index c081592..e45db2b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt @@ -2,8 +2,12 @@ package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index 678cea0..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,10 +7,6 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User - fun saveWithOAuth2(user: User, provider: String, providerId: String): User - - fun findByProviderAndProviderId(provider: String, providerId: String): User? - fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 555655a..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -28,7 +28,6 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") @@ -37,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } @@ -63,9 +66,8 @@ class FilmService( filmRepository.deleteById(id) } - override fun getById(id: UUID): Film { - return filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) override fun getAll(): List = filmRepository.findAll() diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 840208e..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -26,7 +26,6 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") @@ -37,7 +36,6 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - password = "", library = null, ) return userRepository.save(user) @@ -61,9 +59,8 @@ class UserService( userRepository.deleteById(id) } - override fun getById(id: UUID): User { - return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) override fun getAll(): List = userRepository.findAll() } diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index db9142b..b4f2d9b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,6 +6,5 @@ data class User( val id: UUID, val name: String, val email: String, - val password: String, val library: FilmLibrary?, ) diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index a94ddaf..900f6b5 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -2,7 +2,9 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(320) NOT NULL UNIQUE, - password VARCHAR(255) + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql deleted file mode 100644 index 0db4084..0000000 --- a/src/main/resources/db/migration/V2__add_oauth2_fields.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); -ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); -ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; - -CREATE UNIQUE INDEX idx_users_provider_provider_id - ON public.users(provider, provider_id); - -CREATE INDEX idx_users_email ON public.users(email); - diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index bec7f20..cdf0c42 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -9,17 +9,17 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class UserEntityMappingTest { - @Test fun `toDomain maps UserEntity correctly`() { - val entity = UserEntity( - id = UUID.randomUUID(), - name = "John Pork", - email = "john@email.com", - provider = "GOOGLE", - providerId = "google1234", - createdAt = LocalDateTime.now(), - ) + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) val user = entity.toDomain() assertEquals(entity.id, user.id) @@ -30,12 +30,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User with OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Jane", - email = "jane@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null, + ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -48,12 +49,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User without OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Bob", - email = "bob@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null, + ) val entity = user.toEntity() @@ -63,12 +65,13 @@ class UserEntityMappingTest { @Test fun `mapping is reversible for basic fields`() { - val original = User( - id = UUID.randomUUID(), - name = "Alice", - email = "alice@email.com", - library = null - ) + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null, + ) val mapped = original.toEntity().toDomain() @@ -76,6 +79,4 @@ class UserEntityMappingTest { assertEquals(original.name, mapped.name) assertEquals(original.email, mapped.email) } - - } From 6158d3543c75834f2b2e0de2e0942676ac51ed31 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 07:58:11 +0300 Subject: [PATCH 035/106] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +- .../movienight/MovieNightApplication.kt | 2 +- .../persistence/jdbc/UserRepository.kt | 88 +++++++++++-------- .../SecurityConfiguration.kt | 42 +++++++++ .../security.disabled/UserPrincipal.kt | 13 ++- .../movienight/adapters/web/UserController.kt | 9 ++ src/main/resources/application.yaml | 32 +++++++ .../db/migration/V2__add_oauth2_index.sql | 3 + 8 files changed, 145 insertions(+), 48 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt create mode 100644 src/main/resources/db/migration/V2__add_oauth2_index.sql diff --git a/build.gradle.kts b/build.gradle.kts index 6fcffe6..8468c3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,8 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - // Temporarily disabled due to OAuth2 configuration issues - // implementation(libs.spring.boot.starter.oauth2.client) + + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index f6c7d33..a792cd2 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAu import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) +@SpringBootApplication @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index cd1a860..3f73bd0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -9,6 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet +import java.time.LocalDateTime import java.util.UUID @Repository @@ -27,18 +28,32 @@ class UserRepository( } override fun save(user: User): User { - val entity = user.toEntity() - val updatedRows = - jdbc.update( - """ - UPDATE users - SET name = ?, email = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - entity.id, + val existingUser = findById(user.id) + + val entity = if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt ) + } else { + user.toEntity() + } + + val updatedRows = jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.id, + ) + if (updatedRows == 0) { jdbc.update( """ @@ -57,31 +72,28 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc - .query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -91,16 +103,16 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = - jdbc.query( - """ - SELECT id, name, email, provider, provider_id, created_at FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = jdbc.query( + """ + SELECT id, name, email, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt new file mode 100644 index 0000000..d885b91 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfiguration( + private val customOAuth2UserService: CustomOAuth2UserService, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .oauth2Login { oauth2 -> + oauth2 + .userInfoEndpoint { userInfo -> + userInfo.userService(customOAuth2UserService) + } + .defaultSuccessUrl("/api/users/me", true) + } + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() + .requestMatchers("/api/users/me").authenticated() + .requestMatchers("/api/**").authenticated() + .anyRequest().authenticated() + } + .headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + .csrf { csrf -> + csrf.disable() + } + + return http.build() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index 3ea81b4..b5d7eb9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,15 +10,16 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, - UserDetails { +) : OAuth2User, UserDetails { + fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) + override fun getAuthorities(): Collection = + listOf(SimpleGrantedAuthority("ROLE_USER")) override fun getPassword(): String = "" @@ -33,9 +34,7 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create( - user: User, - attributes: Map? = null, - ): UserPrincipal = UserPrincipal(user, attributes) + fun create(user: User, attributes: Map? = null): UserPrincipal = + UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..4d838fb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -73,4 +74,12 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) + + /* + @GetMapping("/me") + fun getCurrentUser(principal: UserPrincipal): UserResponse = + UserResponse.fromDomain( + getUserByIdUseCase.getById(principal.getId()) + ) + */ } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f36c77d..86c806c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,38 @@ spring: console: enabled: ${SPRING_H2_CONSOLE_ENABLED:true} path: /h2-console + security: + oauth2: + client: + registration: + google: + client-id: ${OAUTH2_GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET:test-secret} + scope: email,profile + yandex: + client-id: ${OAUTH2_YANDEX_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_YANDEX_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: login:email,login:avatar + vk: + client-id: ${OAUTH2_VK_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_VK_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: email + provider: + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: id + vk: + authorization-uri: https://oauth.vk.com/authorize + token-uri: https://oauth.vk.com/access_token + user-info-uri: https://api.vk.com/method/users.get?v=5.131&fields=photo_200 + user-name-attribute: response server: shutdown: graceful diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql new file mode 100644 index 0000000..d416108 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id +ON users(provider, provider_id) +WHERE provider IS NOT NULL AND provider_id IS NOT NULL; From 0aa7dcaf98ca6918c12d3b8c09b0f473672533cf Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 08:01:24 +0300 Subject: [PATCH 036/106] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/project/movienight/MovieNightApplication.kt | 2 +- .../movienight/adapters/persistence/jdbc/UserRepository.kt | 2 +- .../{security.disabled => security}/CustomOAuth2UserService.kt | 0 .../{security.disabled => security}/GoogleOAuth2UserInfo.kt | 0 .../{security.disabled => security}/OAuth2UserInfoFactory.kt | 0 .../{security.disabled => security}/SecurityConfiguration.kt | 0 .../adapters/{security.disabled => security}/UserPrincipal.kt | 0 .../{security.disabled => security}/VkOAuth2UserInfo.kt | 0 .../{security.disabled => security}/YandexOAuth2UserInfo.kt | 0 9 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/CustomOAuth2UserService.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/GoogleOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/OAuth2UserInfoFactory.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/SecurityConfiguration.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/UserPrincipal.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/VkOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/YandexOAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index a792cd2..6ac4f10 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,7 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration +//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 3f73bd0..f6598a6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -9,7 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -import java.time.LocalDateTime +//import java.time.LocalDateTime import java.util.UUID @Repository diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt rename to src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt From 0b57c3161125c2e2c41bf7cafdf8ea65426030fe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:37:35 +0000 Subject: [PATCH 037/106] fix: resolve CI compile failure and complete OAuth2 review fixes Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/62eaa8e4-560b-4737-be4b-478f1a4c484a Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/MovieNightApplication.kt | 1 - .../persistence/jdbc/UserRepository.kt | 99 ++++++++++--------- .../security/SecurityConfiguration.kt | 24 ++--- .../adapters/security/UserPrincipal.kt | 14 ++- .../movienight/adapters/web/UserController.kt | 9 -- .../OAuth2UserInfo.kt | 0 6 files changed, 73 insertions(+), 74 deletions(-) rename src/main/kotlin/com/project/movienight/application/ports/input/{security.disabled => security}/OAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 6ac4f10..39274c8 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,6 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index f6598a6..82fcb93 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -9,7 +9,6 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -//import java.time.LocalDateTime import java.util.UUID @Repository @@ -30,29 +29,31 @@ class UserRepository( override fun save(user: User): User { val existingUser = findById(user.id) - val entity = if (existingUser != null) { - val existingEntity = existingUser.toEntity() - user.toEntity( - provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, - providerId = existingEntity.providerId, - createdAt = existingEntity.createdAt - ) - } else { - user.toEntity() - } + val entity = + if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, + ) + } else { + user.toEntity() + } - val updatedRows = jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - entity.provider, - entity.providerId, - entity.id, - ) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.id, + ) if (updatedRows == 0) { jdbc.update( @@ -72,28 +73,31 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = + jdbc.query( + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -103,16 +107,17 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = jdbc.query( - """ - SELECT id, name, email, provider, provider_id, created_at - FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index d885b91..0bcb1b3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -18,22 +18,22 @@ class SecurityConfiguration( oauth2 .userInfoEndpoint { userInfo -> userInfo.userService(customOAuth2UserService) - } - .defaultSuccessUrl("/api/users/me", true) - } - .authorizeHttpRequests { auth -> + }.defaultSuccessUrl("/api/users/me", true) + }.authorizeHttpRequests { auth -> auth - .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() - .requestMatchers("/api/users/me").authenticated() - .requestMatchers("/api/**").authenticated() - .anyRequest().authenticated() - } - .headers { headers -> + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") + .permitAll() + .requestMatchers("/api/users/me") + .authenticated() + .requestMatchers("/api/**") + .authenticated() + .anyRequest() + .authenticated() + }.headers { headers -> headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } - } - .csrf { csrf -> + }.csrf { csrf -> csrf.disable() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index b5d7eb9..dd8eb93 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -10,8 +10,8 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name @@ -19,7 +19,9 @@ class UserPrincipal( override fun getAttributes(): Map = attributes ?: emptyMap() override fun getAuthorities(): Collection = - listOf(SimpleGrantedAuthority("ROLE_USER")) + listOf( + SimpleGrantedAuthority("ROLE_USER"), + ) override fun getPassword(): String = "" @@ -34,7 +36,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal = - UserPrincipal(user, attributes) + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 4d838fb..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,7 +20,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -74,12 +73,4 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) - - /* - @GetMapping("/me") - fun getCurrentUser(principal: UserPrincipal): UserResponse = - UserResponse.fromDomain( - getUserByIdUseCase.getById(principal.getId()) - ) - */ } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt From e91ebb7bd07fc71f892e73453824b5c3237460a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:48:57 +0000 Subject: [PATCH 038/106] fix: resolve detekt/ktlint issues blocking CI Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../persistence/jdbc/UserRepository.kt | 15 ++++++++++----- .../movienight/adapters/web/FilmController.kt | 9 +++++---- .../adapters/web/FilmLibraryController.kt | 10 +++++++--- .../movienight/adapters/web/UserController.kt | 13 ++++++------- .../application/services/FilmService.kt | 16 ++++++++++------ .../application/services/UserService.kt | 19 +++++++++---------- 6 files changed, 47 insertions(+), 35 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 09d8f9d..686899f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -69,10 +69,11 @@ class UserRepository( } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -84,7 +85,11 @@ class UserRepository( ): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + """ + SELECT id, name, email, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), userEntityRowMapper, provider.name, providerId, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 9195987..3699e33 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -57,10 +57,11 @@ class FilmController( FilmResponse.fromDomain( editFilmUseCase.edit( id = id, - command = EditFilmCommand( - title = request.title, - description = request.description, - ), + command = + EditFilmCommand( + title = request.title, + description = request.description, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index e4e000d..fa95bad 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -104,12 +104,16 @@ class FilmLibraryController( @PathVariable userId: UUID, ): List { val userLibrary = - try { + runCatching { getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) - } catch (e: EntityNotFoundException) { - null + }.getOrElse { exception -> + if (exception is EntityNotFoundException) { + null + } else { + throw exception + } } val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 6c1a996..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -46,14 +46,12 @@ class UserController( ) @GetMapping - fun getAll(): List = - getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = - UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @@ -63,9 +61,10 @@ class UserController( UserResponse.fromDomain( editUserUseCase.edit( id = id, - command = EditUserCommand( - name = request.name, - ), + command = + EditUserCommand( + name = request.name, + ), ), ) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 5f77455..f775de3 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -36,15 +36,19 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) return filmRepository.save(film) } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { if (filmConfig.isBlocked(command.title)) { throw BlockedValueException(target = "Film", field = "title") } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 4d16ea1..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -26,18 +26,18 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + ) return userRepository.save(user) } @@ -59,9 +59,8 @@ class UserService( userRepository.deleteById(id) } - override fun getById(id: UUID): User { - return userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) override fun getAll(): List = userRepository.findAll() } From 47ff6d80bfe3bfc06cdd05d83c17412d3bdfa11c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:50:42 +0000 Subject: [PATCH 039/106] merge: sync develop into feat/spring-tests Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/web/FilmLibraryController.kt | 14 ++-- src/main/resources/db/migration/V1__init.sql | 5 +- .../entity/UserEntityMappingTest.kt | 59 +++++++------- .../adapters/web/FilmControllerSearchTest.kt | 77 +++++++++++++++++++ 4 files changed, 119 insertions(+), 36 deletions(-) create mode 100644 src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index fa95bad..5a9f002 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -92,12 +92,14 @@ class FilmLibraryController( fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) = removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) + ) { + removeFilmFromLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) + } @GetMapping("/available-films") fun getAvailableFilms( diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 11017d1..900f6b5 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -1,7 +1,10 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, - email VARCHAR(320) NOT NULL UNIQUE + email VARCHAR(320) NOT NULL UNIQUE, + provider VARCHAR(64), + provider_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index bec7f20..cdf0c42 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -9,17 +9,17 @@ import kotlin.test.assertEquals import kotlin.test.assertNull class UserEntityMappingTest { - @Test fun `toDomain maps UserEntity correctly`() { - val entity = UserEntity( - id = UUID.randomUUID(), - name = "John Pork", - email = "john@email.com", - provider = "GOOGLE", - providerId = "google1234", - createdAt = LocalDateTime.now(), - ) + val entity = + UserEntity( + id = UUID.randomUUID(), + name = "John Pork", + email = "john@email.com", + provider = "GOOGLE", + providerId = "google1234", + createdAt = LocalDateTime.now(), + ) val user = entity.toDomain() assertEquals(entity.id, user.id) @@ -30,12 +30,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User with OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Jane", - email = "jane@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Jane", + email = "jane@mail.com", + library = null, + ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -48,12 +49,13 @@ class UserEntityMappingTest { @Test fun `toEntity maps User without OAuth provider`() { - val user = User( - id = UUID.randomUUID(), - name = "Bob", - email = "bob@mail.com", - library = null - ) + val user = + User( + id = UUID.randomUUID(), + name = "Bob", + email = "bob@mail.com", + library = null, + ) val entity = user.toEntity() @@ -63,12 +65,13 @@ class UserEntityMappingTest { @Test fun `mapping is reversible for basic fields`() { - val original = User( - id = UUID.randomUUID(), - name = "Alice", - email = "alice@email.com", - library = null - ) + val original = + User( + id = UUID.randomUUID(), + name = "Alice", + email = "alice@email.com", + library = null, + ) val mapped = original.toEntity().toDomain() @@ -76,6 +79,4 @@ class UserEntityMappingTest { assertEquals(original.name, mapped.name) assertEquals(original.email, mapped.email) } - - } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt new file mode 100644 index 0000000..dd6bd33 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -0,0 +1,77 @@ +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.domain.model.Film +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import java.util.UUID + +class FilmControllerSearchTest { + private lateinit var mockMvc: MockMvc + private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + + @BeforeEach + fun setup() { + searchFilmByTitleUseCase = mockk() + + val controller = + FilmController( + createFilmUseCase = mockk(), + editFilmUseCase = mockk(), + deleteFilmUseCase = mockk(), + getFilmByIdUseCase = mockk(), + getAllFilmsUseCase = mockk(), + searchFilmByTitleUseCase = searchFilmByTitleUseCase, + ) + + mockMvc = MockMvcBuilders.standaloneSetup(controller).build() + } + + @Test + fun `search returns film when title exists`() { + val title = "Inception" + val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + jsonPath("$.id") { value(film.id.toString()) } + jsonPath("$.title") { value(title) } + jsonPath("$.description") { value("A dream heist") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } + + @Test + fun `search returns empty body when title is missing`() { + val title = "Unknown Title" + + every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + + mockMvc + .get("/api/films/search") { + param("title", title) + }.andExpect { + status { isOk() } + content { string("") } + } + + verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + } +} From 22025326e77ff1fc82a560b5ddaa60c1a4618c6f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:52:01 +0000 Subject: [PATCH 040/106] fix: align search test and available-films error handling Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/adapters/web/FilmLibraryController.kt | 8 +++----- .../movienight/adapters/web/FilmControllerSearchTest.kt | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 5a9f002..998a330 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -110,13 +110,11 @@ class FilmLibraryController( getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) - }.getOrElse { exception -> - if (exception is EntityNotFoundException) { - null - } else { + }.onFailure { exception -> + if (exception !is EntityNotFoundException) { throw exception } - } + }.getOrNull() val allFilms = getAllFilmsUseCase.getAll() diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt index dd6bd33..033cccd 100644 --- a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -59,7 +59,7 @@ class FilmControllerSearchTest { } @Test - fun `search returns empty body when title is missing`() { + fun `search returns 404 when title is not found`() { val title = "Unknown Title" every { searchFilmByTitleUseCase.searchByTitle(title) } returns null @@ -68,7 +68,7 @@ class FilmControllerSearchTest { .get("/api/films/search") { param("title", title) }.andExpect { - status { isOk() } + status { isNotFound() } content { string("") } } From 267ecfef626fe4d1a1ebfd957c6952df407e1db2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:52:48 +0000 Subject: [PATCH 041/106] fix: keep removeFilm expression body with 204 response Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/42b7a686-07a7-41f5-baf7-737b968e12cf Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/web/FilmLibraryController.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 998a330..5d81414 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -92,14 +92,12 @@ class FilmLibraryController( fun removeFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ) { - removeFilmFromLibraryUseCase.removeFilm( - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - ), - ) - } + ) = removeFilmFromLibraryUseCase.removeFilm( + RemoveFilmFromLibraryCommand( + userId = userId, + filmId = filmId, + ), + ) @GetMapping("/available-films") fun getAvailableFilms( From 06e2b50ba517b301c866406c6b5e1c3ffe474b6e Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Wed, 22 Apr 2026 08:46:58 +0300 Subject: [PATCH 042/106] =?UTF-8?q?OAuth2=20(=D0=B1=D0=B5=D0=B7=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B0=20=D1=81=20java)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + gradle/libs.versions.toml | 1 + .../security/CustomOAuth2UserService.kt | 57 +++++++++++++++++++ .../adapters/security/GoogleOAuth2UserInfo.kt | 16 ++++++ .../adapters/security/OAuth2UserInfo.kt | 9 +++ .../security/OAuth2UserInfoFactory.kt | 18 ++++++ .../adapters/security/UserPrincipal.kt | 42 ++++++++++++++ .../adapters/security/VkOAuth2UserInfo.kt | 26 +++++++++ .../adapters/security/YandexOAuth2UserInfo.kt | 20 +++++++ .../ports/output/UserRepositoryPort.kt | 2 + .../application/services/UserService.kt | 1 + .../project/movienight/domain/model/User.kt | 1 + 12 files changed, 195 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt diff --git a/build.gradle.kts b/build.gradle.kts index 72e9333..1b4b205 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,6 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) + implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) runtimeOnly(libs.postgresql) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7314bb3..66e6c13 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ springdoc = "2.8.6" mockk = "1.13.12" [libraries] +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-security = { module = "org.springframework.boot:spring-boot-starter-security" } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt new file mode 100644 index 0000000..189e927 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service + +@Service +class CustomOAuth2UserService( + private val userRepository: UserRepositoryPort, + private val idGenerator: IdGenerator, +) : DefaultOAuth2UserService() { + + companion object { + private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) + } + + override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User { + val oAuth2User = super.loadUser(userRequest) + val registrationId = userRequest.clientRegistration.registrationId + + log.debug("Processing OAuth2 login for provider: {}", registrationId) + + return try { + val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) + val user = findOrCreateUser(userInfo) + UserPrincipal.create(user, oAuth2User.attributes) + } catch (e: Exception) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } + } + + private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { + val existingUser = userRepository.findByEmail(userInfo.getEmail()) + + return if (existingUser != null) { + log.debug("User found by email: {}", userInfo.getEmail()) + existingUser + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", // OAuth2 пользователи не имеют пароля + library = null, + ) + userRepository.save(newUser) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt new file mode 100644 index 0000000..dcff86e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -0,0 +1,16 @@ +package com.project.movienight.adapters.security.oauth2 + +class GoogleOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["sub"] as String + + override fun getEmail(): String = attributes["email"] as String + + override fun getName(): String = attributes["name"] as String + + override fun getProvider(): String = "google" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt new file mode 100644 index 0000000..5a155b9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -0,0 +1,9 @@ +package com.project.movienight.adapters.security.oauth2 + +interface OAuth2UserInfo { + fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt new file mode 100644 index 0000000..001b789 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -0,0 +1,18 @@ +package com.project.movienight.adapters.security.oauth2 + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException +import org.springframework.security.oauth2.core.user.OAuth2User + +object OAuth2UserInfoFactory { + + fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + val attributes = user.attributes + + return when (registrationId.lowercase()) { + "google" -> GoogleOAuth2UserInfo(attributes) + "yandex" -> YandexOAuth2UserInfo(attributes) + "vk" -> VkOAuth2UserInfo(attributes) + else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt new file mode 100644 index 0000000..5fbbee1 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security.oauth2 + +import com.project.movienight.domain.model.User +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.oauth2.core.user.OAuth2User +import java.util.* + +class UserPrincipal( + private val user: User, + private val attributes: Map? = null, +) : OAuth2User, UserDetails { + + fun getId(): UUID = user.id + + override fun getName(): String = user.name + + override fun getAttributes(): Map = attributes ?: emptyMap() + + override fun getAuthorities(): Collection { + return listOf(SimpleGrantedAuthority("ROLE_USER")) + } + + override fun getPassword(): String = user.password + + override fun getUsername(): String = user.email + + override fun isAccountNonExpired(): Boolean = true + + override fun isAccountNonLocked(): Boolean = true + + override fun isCredentialsNonExpired(): Boolean = true + + override fun isEnabled(): Boolean = true + + companion object { + fun create(user: User, attributes: Map? = null): UserPrincipal { + return UserPrincipal(user, attributes) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt new file mode 100644 index 0000000..492e74c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class VkOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String { + val response = attributes["response"] as? List> + return response?.firstOrNull()?.get("id")?.toString() ?: "" + } + + override fun getEmail(): String = attributes["email"] as? String ?: "" + + override fun getName(): String { + val response = attributes["response"] as? List> + val first = response?.firstOrNull() + val firstName = first?.get("first_name") as? String ?: "" + val lastName = first?.get("last_name") as? String ?: "" + return "$firstName $lastName".trim() + } + + override fun getProvider(): String = "vk" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt new file mode 100644 index 0000000..2929389 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -0,0 +1,20 @@ +package com.project.movienight.adapters.security.oauth2 + +@Suppress("UNCHECKED_CAST") +class YandexOAuth2UserInfo( + private val attributes: Map +) : OAuth2UserInfo { + + override fun getProviderId(): String = attributes["id"]?.toString() ?: "" + + override fun getEmail(): String { + val emails = attributes["emails"] as? List> + return emails?.firstOrNull()?.get("value") ?: "" + } + + override fun getName(): String = attributes["display_name"] as? String ?: "" + + override fun getProvider(): String = "yandex" + + override fun getAttributes(): Map = attributes +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index e3c902c..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -9,6 +9,8 @@ interface UserRepositoryPort { fun findById(id: UUID): User? + fun findByEmail(email: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index da2a84b..e6f22be 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -36,6 +36,7 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, + password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..db9142b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,5 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, + val password: String, val library: FilmLibrary?, ) From 88794e6a963a840a5cc65eb405fdbe5031ea90cf Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 21 Apr 2026 15:30:58 +0300 Subject: [PATCH 043/106] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20V2=5F=5Fadd=5Foauth2=5Ffields.sql=20=D0=92=D1=81=D0=B5?= =?UTF-8?q?=20UserRepositoryIntegrationTest=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BF=D1=80=D0=BE=D1=88=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 +- gradle/libs.versions.toml | 2 +- .../resources/db/migration/V2__add_oauth2_fields.sql | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index 1b4b205..24a137a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -34,7 +34,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) -// implementation(libs.spring.boot.starter.security) + implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) implementation(libs.spring.boot.starter.validation) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66e6c13..72e81e2 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,7 +11,7 @@ spring-grpc = "1.0.1" protoc = "3.25.1" grpc-java = "1.60.0" springdoc = "2.8.6" -mockk = "1.13.12" +mockk = "1.13.13" [libraries] spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql new file mode 100644 index 0000000..0db4084 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_fields.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); +ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); +ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; + +CREATE UNIQUE INDEX idx_users_provider_provider_id + ON public.users(provider, provider_id); + +CREATE INDEX idx_users_email ON public.users(email); + From 166c5be863c71e7d65856c39e3ffbb1b22944fac Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 18:47:30 +0300 Subject: [PATCH 044/106] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20V1=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=BF=D1=8F=D1=82=D0=B0=D1=8F?= =?UTF-8?q?=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20email=20=D0=A0=D0=B5=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20saveWithOAuth2()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20findByProviderAndProviderId()?= =?UTF-8?q?=20=D0=B2=20UserRepository=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20CustomOAuth2UserService=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8F=20OAuth2=20=D0=BC=D0=B5=D1=82=D0=BE?= =?UTF-8?q?=D0=B4=D0=BE=D0=B2=20=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=20package=20declaration=20=D0=B2=D0=BE=20=D0=B2=D1=81?= =?UTF-8?q?=D0=B5=D1=85=207=20OAuth2=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= =?UTF-8?q?=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/persistence/entity/UserEntity.kt | 2 + .../persistence/jdbc/UserRepository.kt | 56 +++++++++++++++++-- .../security/CustomOAuth2UserService.kt | 37 ++++++++---- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 +- .../adapters/security/OAuth2UserInfo.kt | 2 +- .../security/OAuth2UserInfoFactory.kt | 2 +- .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 2 +- .../adapters/security/YandexOAuth2UserInfo.kt | 2 +- .../ports/output/UserRepositoryPort.kt | 4 ++ src/main/resources/db/migration/V1__init.sql | 1 + 11 files changed, 88 insertions(+), 24 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 0beda74..f606ea1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -9,6 +9,7 @@ data class UserEntity( val id: UUID, val name: String, val email: String, + val password: String?, val provider: String?, val providerId: String?, val createdAt: LocalDateTime, @@ -31,6 +32,7 @@ fun User.toEntity( id = id, name = name, email = email, + password = password, provider = provider?.name, providerId = providerId, createdAt = createdAt, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 686899f..f3ea7ad 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -20,6 +20,7 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), + password = rs.getString("password"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), @@ -32,11 +33,12 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? WHERE id = ? """.trimIndent(), entity.name, entity.email, + user.password, entity.provider, entity.providerId, entity.id, @@ -44,12 +46,13 @@ class UserRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, provider, provider_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users (id, name, email, password, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, entity.name, entity.email, + user.password, entity.provider, entity.providerId, entity.createdAt, @@ -61,7 +64,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", userEntityRowMapper, id, ) @@ -71,7 +74,7 @@ class UserRepository( override fun findAll(): List = jdbc .query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", + "SELECT id, name, email, password, provider, provider_id, created_at FROM users", userEntityRowMapper, ).map { it.toDomain() } @@ -79,6 +82,47 @@ class UserRepository( jdbc.update("DELETE FROM users WHERE id = ?", id) } + override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { + val updatedRows = jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + user.name, + user.email, + user.password, + provider, + providerId, + user.id, + ) + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, password, provider, provider_id) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + user.id, + user.name, + user.email, + user.password, + provider, + providerId, + ) + } + return user + } + + override fun findByProviderAndProviderId(provider: String, providerId: String): User? { + val entities = jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", + userEntityRowMapper, + provider, + providerId, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findByProviderAndProviderId( provider: AuthProvider, providerId: String, @@ -86,7 +130,7 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, provider, provider_id, created_at + SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 189e927..968424a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort @@ -37,21 +37,34 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByEmail(userInfo.getEmail()) + // Сначала ищем по provider + provider_id (основной способ для OAuth2) + val existingUser = userRepository.findByProviderAndProviderId( + userInfo.getProvider(), + userInfo.getProviderId() + ) return if (existingUser != null) { - log.debug("User found by email: {}", userInfo.getEmail()) + log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", // OAuth2 пользователи не имеют пароля - library = null, - ) - userRepository.save(newUser) + // Проверяем нет ли пользователя с таким email (связывание аккаунтов) + val userByEmail = userRepository.findByEmail(userInfo.getEmail()) + + if (userByEmail != null) { + // Пользователь существует, обновляем его OAuth2 данными + log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) + userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + } else { + log.debug("Creating new user for provider: {}", userInfo.getProvider()) + val newUser = User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + password = "", + library = null, + ) + userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + } } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index dcff86e..fa41de5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security class GoogleOAuth2UserInfo( private val attributes: Map diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt index 5a155b9..b6abf09 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security interface OAuth2UserInfo { fun getProviderId(): String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index 001b789..e2db545 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index 5fbbee1..a4d94ea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security import com.project.movienight.domain.model.User import org.springframework.security.core.GrantedAuthority diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 492e74c..47c41d2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class VkOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 2929389..467aa85 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security.oauth2 +package com.project.movienight.adapters.security @Suppress("UNCHECKED_CAST") class YandexOAuth2UserInfo( diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index dd69728..678cea0 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,6 +7,10 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun saveWithOAuth2(user: User, provider: String, providerId: String): User + + fun findByProviderAndProviderId(provider: String, providerId: String): User? + fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 900f6b5..ee51933 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS public.users ( id UUID PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(320) NOT NULL UNIQUE, + password VARCHAR(255), provider VARCHAR(64), provider_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP From cf7e3411da760157840c0c5162a42f41eaa0e200 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 23 Apr 2026 23:59:16 +0300 Subject: [PATCH 045/106] =?UTF-8?q?-=20=D0=9F=D0=B5=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=91=D0=BD=20OAuth2UserInfo.kt=20=D0=B8=D0=B7=20a?= =?UTF-8?q?dapters/security/=20=D0=B2=20application/ports/input/security/;?= =?UTF-8?q?=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20=D0=B2=D0=BE=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D1=85=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - В UserPrincipal.kt заменён star import java.util.* на явный java.util.UUID. - В YandexOAuth2UserInfo.kt и VkOAuth2UserInfo.kt убрана аннотация @Suppress("UNCHECKED_CAST") - В build.gradle.kts хардкод версии заменён на version catalog - Из CustomOAuth2UserService.kt удалены комментарии на русском языке --- build.gradle.kts | 3 +-- .../security/CustomOAuth2UserService.kt | 4 +--- .../adapters/security/GoogleOAuth2UserInfo.kt | 2 ++ .../security/OAuth2UserInfoFactory.kt | 1 + .../adapters/security/UserPrincipal.kt | 2 +- .../adapters/security/VkOAuth2UserInfo.kt | 20 +++++++++++-------- .../adapters/security/YandexOAuth2UserInfo.kt | 12 +++++++---- .../ports/input}/security/OAuth2UserInfo.kt | 2 +- 8 files changed, 27 insertions(+), 19 deletions(-) rename src/main/kotlin/com/project/movienight/{adapters => application/ports/input}/security/OAuth2UserInfo.kt (74%) diff --git a/build.gradle.kts b/build.gradle.kts index 24a137a..f20ddff 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -48,8 +48,7 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - - implementation("org.springframework.boot:spring-boot-starter-oauth2-client:3.4.3") + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index 968424a..cc1cb8d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +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 import com.project.movienight.domain.model.User @@ -37,7 +38,6 @@ class CustomOAuth2UserService( } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - // Сначала ищем по provider + provider_id (основной способ для OAuth2) val existingUser = userRepository.findByProviderAndProviderId( userInfo.getProvider(), userInfo.getProviderId() @@ -47,11 +47,9 @@ class CustomOAuth2UserService( log.debug("User found by provider: {}", userInfo.getProvider()) existingUser } else { - // Проверяем нет ли пользователя с таким email (связывание аккаунтов) val userByEmail = userRepository.findByEmail(userInfo.getEmail()) if (userByEmail != null) { - // Пользователь существует, обновляем его OAuth2 данными log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt index fa41de5..c463ac6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt @@ -1,5 +1,7 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class GoogleOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt index e2db545..89d6d30 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.security +import com.project.movienight.application.ports.input.security.OAuth2UserInfo import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index a4d94ea..c602211 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -5,7 +5,7 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority import org.springframework.security.core.userdetails.UserDetails import org.springframework.security.oauth2.core.user.OAuth2User -import java.util.* +import java.util.UUID class UserPrincipal( private val user: User, diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt index 47c41d2..e2c55c0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt @@ -1,22 +1,26 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class VkOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { override fun getProviderId(): String { - val response = attributes["response"] as? List> - return response?.firstOrNull()?.get("id")?.toString() ?: "" + return (attributes["response"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("id") + ?.toString() ?: "" } - override fun getEmail(): String = attributes["email"] as? String ?: "" + override fun getEmail(): String = attributes["email"]?.toString() ?: "" override fun getName(): String { - val response = attributes["response"] as? List> - val first = response?.firstOrNull() - val firstName = first?.get("first_name") as? String ?: "" - val lastName = first?.get("last_name") as? String ?: "" + val response = attributes["response"] as? List<*> + val first = response?.firstOrNull() as? Map<*, *> + val firstName = first?.get("first_name")?.toString() ?: "" + val lastName = first?.get("last_name")?.toString() ?: "" return "$firstName $lastName".trim() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt index 467aa85..59bf3eb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt @@ -1,6 +1,7 @@ package com.project.movienight.adapters.security -@Suppress("UNCHECKED_CAST") +import com.project.movienight.application.ports.input.security.OAuth2UserInfo + class YandexOAuth2UserInfo( private val attributes: Map ) : OAuth2UserInfo { @@ -8,11 +9,14 @@ class YandexOAuth2UserInfo( override fun getProviderId(): String = attributes["id"]?.toString() ?: "" override fun getEmail(): String { - val emails = attributes["emails"] as? List> - return emails?.firstOrNull()?.get("value") ?: "" + return (attributes["emails"] as? List<*>) + ?.firstOrNull() + ?.let { it as? Map<*, *> } + ?.get("value") + ?.toString() ?: "" } - override fun getName(): String = attributes["display_name"] as? String ?: "" + override fun getName(): String = attributes["display_name"]?.toString() ?: "" override fun getProvider(): String = "yandex" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 74% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt index b6abf09..c081592 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt @@ -1,4 +1,4 @@ -package com.project.movienight.adapters.security +package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String From a10737a7f7d45b18dadf81ca4a25a96e39b5244f Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 5 May 2026 00:58:20 +0300 Subject: [PATCH 046/106] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=20develop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: - После слияния с develop возникли конфликты в реализации OAuth2 - Две ветки независимо реализовали OAuth2 функциональность по-разному - Сборка проекта падала из-за отсутствия зависимостей OAuth2 Изменения: - Временно отключена OAuth2 зависимость в build.gradle.kts - Перенесен OAuth2 код в папку security.disabled для сохранения - Добавлены исключения security.disabled из компиляции, ktlint и detekt - Удалена миграция V2__add_oauth2_fields.sql (OAuth2 поля теперь в V1) - Удалено поле password из User domain модели - Обновлены репозитории и сервисы для работы с новой схемой БД Результат: - Проект успешно собирается (./gradlew build) - Все 35 тестов проходят - OAuth2 код сохранен для будущего использования --- build.gradle.kts | 9 +++- .../movienight/MovieNightApplication.kt | 3 +- .../persistence/jdbc/UserRepository.kt | 10 ++++ .../CustomOAuth2UserService.kt | 48 +++++++++++++------ .../GoogleOAuth2UserInfo.kt | 3 +- .../OAuth2UserInfoFactory.kt | 6 ++- .../UserPrincipal.kt | 17 ++++--- .../VkOAuth2UserInfo.kt | 8 ++-- .../YandexOAuth2UserInfo.kt | 8 ++-- .../adapters/web/FilmLibraryController.kt | 1 + .../OAuth2UserInfo.kt | 4 ++ .../ports/output/UserRepositoryPort.kt | 4 -- .../application/services/UserService.kt | 1 - .../project/movienight/domain/model/User.kt | 1 - .../db/migration/V2__add_oauth2_fields.sql | 9 ---- 15 files changed, 76 insertions(+), 56 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/CustomOAuth2UserService.kt (61%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/GoogleOAuth2UserInfo.kt (91%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/OAuth2UserInfoFactory.kt (85%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/UserPrincipal.kt (77%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/VkOAuth2UserInfo.kt (84%) rename src/main/kotlin/com/project/movienight/adapters/{security => security.disabled}/YandexOAuth2UserInfo.kt (80%) rename src/main/kotlin/com/project/movienight/application/ports/input/{security => security.disabled}/OAuth2UserInfo.kt (98%) delete mode 100644 src/main/resources/db/migration/V2__add_oauth2_fields.sql diff --git a/build.gradle.kts b/build.gradle.kts index f20ddff..6fcffe6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,7 +15,8 @@ plugins { jacoco } -apply(plugin = "org.springframework.boot.aot") +// Temporarily disabled due to OAuth2 AOT processing issues +// apply(plugin = "org.springframework.boot.aot") apply(from = "$rootDir/gradle/docker.gradle.kts") @@ -48,7 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - implementation(libs.spring.boot.starter.oauth2.client) + // Temporarily disabled due to OAuth2 configuration issues + // implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) @@ -76,6 +78,7 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } + exclude("**/security.disabled/**") } tasks.withType { @@ -158,6 +161,7 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") + exclude("**/security.disabled/**") } } @@ -171,6 +175,7 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" + exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..f6c7d33 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,10 +1,11 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication +@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index f3ea7ad..95d123f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -71,6 +71,16 @@ class UserRepository( return entities.firstOrNull()?.toDomain() } + override fun findByEmail(email: String): User? { + val entities = + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findAll(): List = jdbc .query( diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt similarity index 61% rename from src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt index cc1cb8d..b64ea71 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt @@ -1,8 +1,11 @@ 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 +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.slf4j.LoggerFactory import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService @@ -16,7 +19,6 @@ class CustomOAuth2UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, ) : DefaultOAuth2UserService() { - companion object { private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java) } @@ -31,17 +33,23 @@ class CustomOAuth2UserService( val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User) val user = findOrCreateUser(userInfo) UserPrincipal.create(user, oAuth2User.attributes) - } catch (e: Exception) { + } catch (e: IllegalArgumentException) { log.error("OAuth2 authentication failed: ${e.message}", e) throw OAuth2AuthenticationException("Failed to process OAuth2 user data") + } catch (e: OAuth2AuthenticationException) { + log.error("OAuth2 authentication failed: ${e.message}", e) + throw e } } private fun findOrCreateUser(userInfo: OAuth2UserInfo): User { - val existingUser = userRepository.findByProviderAndProviderId( - userInfo.getProvider(), - userInfo.getProviderId() - ) + val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase()) + + val existingUser = + userRepository.findByProviderAndProviderId( + provider, + userInfo.getProviderId(), + ) return if (existingUser != null) { log.debug("User found by provider: {}", userInfo.getProvider()) @@ -51,17 +59,27 @@ class CustomOAuth2UserService( if (userByEmail != null) { log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) - userRepository.saveWithOAuth2(userByEmail, userInfo.getProvider(), userInfo.getProviderId()) + val entity = + userByEmail.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } else { log.debug("Creating new user for provider: {}", userInfo.getProvider()) - val newUser = User( - id = idGenerator.generateId(), - name = userInfo.getName(), - email = userInfo.getEmail(), - password = "", - library = null, - ) - userRepository.saveWithOAuth2(newUser, userInfo.getProvider(), userInfo.getProviderId()) + val newUser = + User( + id = idGenerator.generateId(), + name = userInfo.getName(), + email = userInfo.getEmail(), + library = null, + ) + val entity = + newUser.toEntity( + provider = provider, + providerId = userInfo.getProviderId(), + ) + userRepository.save(entity.toDomain()) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt similarity index 91% rename from src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt index c463ac6..123f9a0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt @@ -3,9 +3,8 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class GoogleOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["sub"] as String override fun getEmail(): String = attributes["email"] as String diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt similarity index 85% rename from src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt index 89d6d30..1faf660 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt @@ -5,8 +5,10 @@ import org.springframework.security.oauth2.core.OAuth2AuthenticationException import org.springframework.security.oauth2.core.user.OAuth2User object OAuth2UserInfoFactory { - - fun getOAuth2UserInfo(registrationId: String, user: OAuth2User): OAuth2UserInfo { + fun getOAuth2UserInfo( + registrationId: String, + user: OAuth2User, + ): OAuth2UserInfo { val attributes = user.attributes return when (registrationId.lowercase()) { diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt similarity index 77% rename from src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index c602211..3ea81b4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,19 +10,17 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection { - return listOf(SimpleGrantedAuthority("ROLE_USER")) - } + override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) - override fun getPassword(): String = user.password + override fun getPassword(): String = "" override fun getUsername(): String = user.email @@ -35,8 +33,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal { - return UserPrincipal(user, attributes) - } + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt similarity index 84% rename from src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt index e2c55c0..9b55f35 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt @@ -3,16 +3,14 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class VkOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - - override fun getProviderId(): String { - return (attributes["response"] as? List<*>) + override fun getProviderId(): String = + (attributes["response"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("id") ?.toString() ?: "" - } override fun getEmail(): String = attributes["email"]?.toString() ?: "" diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt similarity index 80% rename from src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt index 59bf3eb..dc71df9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt @@ -3,18 +3,16 @@ package com.project.movienight.adapters.security import com.project.movienight.application.ports.input.security.OAuth2UserInfo class YandexOAuth2UserInfo( - private val attributes: Map + private val attributes: Map, ) : OAuth2UserInfo { - override fun getProviderId(): String = attributes["id"]?.toString() ?: "" - override fun getEmail(): String { - return (attributes["emails"] as? List<*>) + override fun getEmail(): String = + (attributes["emails"] as? List<*>) ?.firstOrNull() ?.let { it as? Map<*, *> } ?.get("value") ?.toString() ?: "" - } override fun getName(): String = attributes["display_name"]?.toString() ?: "" diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 998a330..c2ed037 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -68,6 +68,7 @@ class FilmLibraryController( getFilmLibraryUseCase.getLibrary( GetFilmLibraryQuery(userId = userId), ) + val film = getFilmByIdUseCase.getById(library.filmId) return listOf(FilmResponse.fromDomain(film)) } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt similarity index 98% rename from src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt index c081592..e45db2b 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt @@ -2,8 +2,12 @@ package com.project.movienight.application.ports.input.security interface OAuth2UserInfo { fun getProviderId(): String + fun getEmail(): String + fun getName(): String + fun getProvider(): String + fun getAttributes(): Map } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index 678cea0..dd69728 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,10 +7,6 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User - fun saveWithOAuth2(user: User, provider: String, providerId: String): User - - fun findByProviderAndProviderId(provider: String, providerId: String): User? - fun findById(id: UUID): User? fun findByEmail(email: String): User? diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index e6f22be..da2a84b 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -36,7 +36,6 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - password = "", library = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index db9142b..b4f2d9b 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,6 +6,5 @@ data class User( val id: UUID, val name: String, val email: String, - val password: String, val library: FilmLibrary?, ) diff --git a/src/main/resources/db/migration/V2__add_oauth2_fields.sql b/src/main/resources/db/migration/V2__add_oauth2_fields.sql deleted file mode 100644 index 0db4084..0000000 --- a/src/main/resources/db/migration/V2__add_oauth2_fields.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE public.users ADD COLUMN provider VARCHAR(20); -ALTER TABLE public.users ADD COLUMN provider_id VARCHAR(255); -ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL; - -CREATE UNIQUE INDEX idx_users_provider_provider_id - ON public.users(provider, provider_id); - -CREATE INDEX idx_users_email ON public.users(email); - From 82a0cec1ccd81a2813bc108502b88c386f9ae258 Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 07:58:11 +0300 Subject: [PATCH 047/106] =?UTF-8?q?=D0=B4=D0=BE=D0=BF=D0=B8=D1=81=D0=B0?= =?UTF-8?q?=D0=BB=D0=B0=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 +- .../movienight/MovieNightApplication.kt | 2 +- .../persistence/jdbc/UserRepository.kt | 93 ++++++++++--------- .../SecurityConfiguration.kt | 42 +++++++++ .../security.disabled/UserPrincipal.kt | 13 ++- .../movienight/adapters/web/UserController.kt | 9 ++ src/main/resources/application.yaml | 32 +++++++ .../db/migration/V2__add_oauth2_index.sql | 3 + 8 files changed, 146 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt create mode 100644 src/main/resources/db/migration/V2__add_oauth2_index.sql diff --git a/build.gradle.kts b/build.gradle.kts index 6fcffe6..8468c3a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -49,8 +49,8 @@ dependencies { implementation(libs.spring.grpc.starter) implementation(libs.grpc.services) - // Temporarily disabled due to OAuth2 configuration issues - // implementation(libs.spring.boot.starter.oauth2.client) + + implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.h2) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index f6c7d33..a792cd2 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAu import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication -@SpringBootApplication(exclude = [OAuth2ClientAutoConfiguration::class]) +@SpringBootApplication @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 95d123f..dc5364e 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -9,6 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet +import java.time.LocalDateTime import java.util.UUID @Repository @@ -28,21 +29,33 @@ class UserRepository( } override fun save(user: User): User { - val entity = user.toEntity() - val updatedRows = - jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - user.password, - entity.provider, - entity.providerId, - entity.id, + val existingUser = findById(user.id) + + val entity = if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, ) + } else { + user.toEntity() + } + + val updatedRows = jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + user.password, + entity.provider, + entity.providerId, + entity.id, + ) + if (updatedRows == 0) { jdbc.update( """ @@ -62,31 +75,28 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = - jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = - jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc - .query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -137,17 +147,16 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = - jdbc.query( - """ - SELECT id, name, email, password, provider, provider_id, created_at - FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = jdbc.query( + """ + SELECT id, name, email, password, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt new file mode 100644 index 0000000..d885b91 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt @@ -0,0 +1,42 @@ +package com.project.movienight.adapters.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +@EnableWebSecurity +class SecurityConfiguration( + private val customOAuth2UserService: CustomOAuth2UserService, +) { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .oauth2Login { oauth2 -> + oauth2 + .userInfoEndpoint { userInfo -> + userInfo.userService(customOAuth2UserService) + } + .defaultSuccessUrl("/api/users/me", true) + } + .authorizeHttpRequests { auth -> + auth + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() + .requestMatchers("/api/users/me").authenticated() + .requestMatchers("/api/**").authenticated() + .anyRequest().authenticated() + } + .headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + .csrf { csrf -> + csrf.disable() + } + + return http.build() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt index 3ea81b4..b5d7eb9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt @@ -10,15 +10,16 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, - UserDetails { +) : OAuth2User, UserDetails { + fun getId(): UUID = user.id override fun getName(): String = user.name override fun getAttributes(): Map = attributes ?: emptyMap() - override fun getAuthorities(): Collection = listOf(SimpleGrantedAuthority("ROLE_USER")) + override fun getAuthorities(): Collection = + listOf(SimpleGrantedAuthority("ROLE_USER")) override fun getPassword(): String = "" @@ -33,9 +34,7 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create( - user: User, - attributes: Map? = null, - ): UserPrincipal = UserPrincipal(user, attributes) + fun create(user: User, attributes: Map? = null): UserPrincipal = + UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..4d838fb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController +//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -73,4 +74,12 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) + + /* + @GetMapping("/me") + fun getCurrentUser(principal: UserPrincipal): UserResponse = + UserResponse.fromDomain( + getUserByIdUseCase.getById(principal.getId()) + ) + */ } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index f36c77d..86c806c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -25,6 +25,38 @@ spring: console: enabled: ${SPRING_H2_CONSOLE_ENABLED:true} path: /h2-console + security: + oauth2: + client: + registration: + google: + client-id: ${OAUTH2_GOOGLE_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_GOOGLE_CLIENT_SECRET:test-secret} + scope: email,profile + yandex: + client-id: ${OAUTH2_YANDEX_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_YANDEX_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: login:email,login:avatar + vk: + client-id: ${OAUTH2_VK_CLIENT_ID:test-client-id} + client-secret: ${OAUTH2_VK_CLIENT_SECRET:test-secret} + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + scope: email + provider: + yandex: + authorization-uri: https://oauth.yandex.ru/authorize + token-uri: https://oauth.yandex.ru/token + user-info-uri: https://login.yandex.ru/info + user-name-attribute: id + vk: + authorization-uri: https://oauth.vk.com/authorize + token-uri: https://oauth.vk.com/access_token + user-info-uri: https://api.vk.com/method/users.get?v=5.131&fields=photo_200 + user-name-attribute: response server: shutdown: graceful diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql new file mode 100644 index 0000000..d416108 --- /dev/null +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -0,0 +1,3 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id +ON users(provider, provider_id) +WHERE provider IS NOT NULL AND provider_id IS NOT NULL; From aecf19aa116e7fd13475acbf2f76b0714ee6221f Mon Sep 17 00:00:00 2001 From: Elena Ponomareva Date: Thu, 7 May 2026 08:01:24 +0300 Subject: [PATCH 048/106] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BB=D0=B0=20=D0=BD=D0=B0=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=BF=D0=BA=D0=B8=20=D0=B8=20=D1=83=D0=B1=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/kotlin/com/project/movienight/MovieNightApplication.kt | 2 +- .../movienight/adapters/persistence/jdbc/UserRepository.kt | 2 +- .../{security.disabled => security}/CustomOAuth2UserService.kt | 0 .../{security.disabled => security}/GoogleOAuth2UserInfo.kt | 0 .../{security.disabled => security}/OAuth2UserInfoFactory.kt | 0 .../{security.disabled => security}/SecurityConfiguration.kt | 0 .../adapters/{security.disabled => security}/UserPrincipal.kt | 0 .../{security.disabled => security}/VkOAuth2UserInfo.kt | 0 .../{security.disabled => security}/YandexOAuth2UserInfo.kt | 0 9 files changed, 2 insertions(+), 2 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/CustomOAuth2UserService.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/GoogleOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/OAuth2UserInfoFactory.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/SecurityConfiguration.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/UserPrincipal.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/VkOAuth2UserInfo.kt (100%) rename src/main/kotlin/com/project/movienight/adapters/{security.disabled => security}/YandexOAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index a792cd2..6ac4f10 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,7 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration +//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index dc5364e..fe1a044 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -9,7 +9,7 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -import java.time.LocalDateTime +//import java.time.LocalDateTime import java.util.UUID @Repository diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/CustomOAuth2UserService.kt rename to src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/GoogleOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/OAuth2UserInfoFactory.kt rename to src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/SecurityConfiguration.kt rename to src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/UserPrincipal.kt rename to src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/VkOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/adapters/security.disabled/YandexOAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt From d4dbc108c8d0131263e1fa87e9b2ae982bd7c00e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 8 May 2026 17:37:35 +0000 Subject: [PATCH 049/106] fix: resolve CI compile failure and complete OAuth2 review fixes Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/62eaa8e4-560b-4737-be4b-478f1a4c484a Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../movienight/MovieNightApplication.kt | 1 - .../persistence/jdbc/UserRepository.kt | 101 +++++++++--------- .../security/SecurityConfiguration.kt | 24 ++--- .../adapters/security/UserPrincipal.kt | 14 ++- .../movienight/adapters/web/UserController.kt | 9 -- .../OAuth2UserInfo.kt | 0 6 files changed, 74 insertions(+), 75 deletions(-) rename src/main/kotlin/com/project/movienight/application/ports/input/{security.disabled => security}/OAuth2UserInfo.kt (100%) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 6ac4f10..39274c8 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -1,7 +1,6 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication -//import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index fe1a044..1d05ec5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -9,7 +9,6 @@ import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet -//import java.time.LocalDateTime import java.util.UUID @Repository @@ -31,30 +30,32 @@ class UserRepository( override fun save(user: User): User { val existingUser = findById(user.id) - val entity = if (existingUser != null) { - val existingEntity = existingUser.toEntity() - user.toEntity( - provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, - providerId = existingEntity.providerId, - createdAt = existingEntity.createdAt, - ) - } else { - user.toEntity() - } + val entity = + if (existingUser != null) { + val existingEntity = existingUser.toEntity() + user.toEntity( + provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, + providerId = existingEntity.providerId, + createdAt = existingEntity.createdAt, + ) + } else { + user.toEntity() + } - val updatedRows = jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - entity.name, - entity.email, - user.password, - entity.provider, - entity.providerId, - entity.id, - ) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + user.password, + entity.provider, + entity.providerId, + entity.id, + ) if (updatedRows == 0) { jdbc.update( @@ -75,28 +76,31 @@ class UserRepository( } override fun findById(id: UUID): User? { - val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", - userEntityRowMapper, - id, - ) + val entities = + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", + userEntityRowMapper, + id, + ) return entities.firstOrNull()?.toDomain() } override fun findByEmail(email: String): User? { - val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", - userEntityRowMapper, - email, - ) + val entities = + jdbc.query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + userEntityRowMapper, + email, + ) return entities.firstOrNull()?.toDomain() } override fun findAll(): List = - jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users", - userEntityRowMapper, - ).map { it.toDomain() } + jdbc + .query( + "SELECT id, name, email, password, provider, provider_id, created_at FROM users", + userEntityRowMapper, + ).map { it.toDomain() } override fun deleteById(id: UUID) { jdbc.update("DELETE FROM users WHERE id = ?", id) @@ -147,16 +151,17 @@ class UserRepository( provider: AuthProvider, providerId: String, ): User? { - val entities = jdbc.query( - """ - SELECT id, name, email, password, provider, provider_id, created_at - FROM users - WHERE provider = ? AND provider_id = ? - """.trimIndent(), - userEntityRowMapper, - provider.name, - providerId, - ) + val entities = + jdbc.query( + """ + SELECT id, name, email, password, provider, provider_id, created_at + FROM users + WHERE provider = ? AND provider_id = ? + """.trimIndent(), + userEntityRowMapper, + provider.name, + providerId, + ) return entities.firstOrNull()?.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index d885b91..0bcb1b3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -18,22 +18,22 @@ class SecurityConfiguration( oauth2 .userInfoEndpoint { userInfo -> userInfo.userService(customOAuth2UserService) - } - .defaultSuccessUrl("/api/users/me", true) - } - .authorizeHttpRequests { auth -> + }.defaultSuccessUrl("/api/users/me", true) + }.authorizeHttpRequests { auth -> auth - .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health").permitAll() - .requestMatchers("/api/users/me").authenticated() - .requestMatchers("/api/**").authenticated() - .anyRequest().authenticated() - } - .headers { headers -> + .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") + .permitAll() + .requestMatchers("/api/users/me") + .authenticated() + .requestMatchers("/api/**") + .authenticated() + .anyRequest() + .authenticated() + }.headers { headers -> headers.frameOptions { frameOptions -> frameOptions.sameOrigin() } - } - .csrf { csrf -> + }.csrf { csrf -> csrf.disable() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt index b5d7eb9..dd8eb93 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt @@ -10,8 +10,8 @@ import java.util.UUID class UserPrincipal( private val user: User, private val attributes: Map? = null, -) : OAuth2User, UserDetails { - +) : OAuth2User, + UserDetails { fun getId(): UUID = user.id override fun getName(): String = user.name @@ -19,7 +19,9 @@ class UserPrincipal( override fun getAttributes(): Map = attributes ?: emptyMap() override fun getAuthorities(): Collection = - listOf(SimpleGrantedAuthority("ROLE_USER")) + listOf( + SimpleGrantedAuthority("ROLE_USER"), + ) override fun getPassword(): String = "" @@ -34,7 +36,9 @@ class UserPrincipal( override fun isEnabled(): Boolean = true companion object { - fun create(user: User, attributes: Map? = null): UserPrincipal = - UserPrincipal(user, attributes) + fun create( + user: User, + attributes: Map? = null, + ): UserPrincipal = UserPrincipal(user, attributes) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index 4d838fb..bccf5bc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -20,7 +20,6 @@ import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -//import com.project.movienight.adapters.security.UserPrincipal import java.util.UUID @RestController @@ -74,12 +73,4 @@ class UserController( fun delete( @PathVariable id: UUID, ) = deleteUserUseCase.delete(id) - - /* - @GetMapping("/me") - fun getCurrentUser(principal: UserPrincipal): UserResponse = - UserResponse.fromDomain( - getUserByIdUseCase.getById(principal.getId()) - ) - */ } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt similarity index 100% rename from src/main/kotlin/com/project/movienight/application/ports/input/security.disabled/OAuth2UserInfo.kt rename to src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt From a4f99bbe8a127d99428587dd865cdb0a721b6420 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 21:18:46 +0300 Subject: [PATCH 050/106] =?UTF-8?q?*=20=D0=A1=D0=B8=D0=BD=D1=85=D1=80?= =?UTF-8?q?=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20?= =?UTF-8?q?`UserEntity`=20=D0=B8=20`UserRepository`=20=D1=81=20=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D1=83=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BC?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C=D1=8E=20`User`=20=D0=B8=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=BA=D1=82=D0=BE=D0=BC=20`UserR?= =?UTF-8?q?epositoryPort`:=20=D1=83=D0=B1=D1=80=D0=B0=D0=BB=20=D1=83=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D1=83=D1=8E=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=BA=D1=83=20=D1=81=20`password`=20=D0=B8=20?= =?UTF-8?q?=D0=B8=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=20=D0=BD=D0=B5?= =?UTF-8?q?=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=BD=D1=8B=D0=B5=20`override`.?= =?UTF-8?q?=20*=20=D0=A3=D0=B1=D1=80=D0=B0=D0=BB=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=84=D0=BB=D0=B8=D0=BA=D1=82=D1=83=D1=8E=D1=89=D0=B8=D0=B5=20?= =?UTF-8?q?gRPC-=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8,=20=D0=BA=D0=BE=D1=82=D0=BE=D1=80=D1=8B=D0=B5=20?= =?UTF-8?q?=D1=82=D1=8F=D0=BD=D1=83=D0=BB=D0=B8=20=D0=BD=D0=B5=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC=D1=8B=D0=B9=20runtim?= =?UTF-8?q?e/test=20stack.=20*=20=D0=A1=D0=B4=D0=B5=D0=BB=D0=B0=D0=BB=20Fl?= =?UTF-8?q?yway-=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8E=20`V2`=20?= =?UTF-8?q?=D1=81=20OAuth2-=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=81=D0=BE=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=81=20H2.=20*=20=D0=9E=D1=82=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D0=BB=20security-=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=D1=8B=20=D0=B2=20controller-=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D1=85=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20`MockMvc`?= =?UTF-8?q?,=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20=D1=83=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B5=D0=B4=D0=B8=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE=D1=80=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D0=B8=20=D0=BD=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D0=B2.=20*=20=D0=92=D0=BE=D1=81=D1=81=D1=82=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B8=D0=BB=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D0=B8=D0=B7=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BB=20=D1=82=D0=B5=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B9=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 5 -- .../adapters/persistence/entity/UserEntity.kt | 2 - .../persistence/jdbc/UserRepository.kt | 58 +++---------------- .../db/migration/V2__add_oauth2_index.sql | 3 +- .../controllers/FilmControllerTest.kt | 2 +- .../controllers/FilmLibraryControllerTest.kt | 2 +- .../controllers/UserControllerTest.kt | 2 +- 7 files changed, 11 insertions(+), 63 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 8468c3a..a3b3bf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,6 @@ java { dependencies { implementation(platform(libs.sentry.bom)) - implementation(platform(libs.spring.grpc.bom)) implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) @@ -47,9 +46,6 @@ dependencies { implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) - implementation(libs.spring.grpc.starter) - implementation(libs.grpc.services) - implementation(libs.spring.boot.starter.oauth2.client) runtimeOnly(libs.micrometer.registry.prometheus) @@ -60,7 +56,6 @@ dependencies { testImplementation(libs.spring.boot.starter.test) testImplementation(libs.kotlin.test.junit5) - testImplementation(libs.spring.grpc.test) testImplementation(libs.mockk) testRuntimeOnly(libs.junit.platform.launcher) } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index f606ea1..0beda74 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -9,7 +9,6 @@ data class UserEntity( val id: UUID, val name: String, val email: String, - val password: String?, val provider: String?, val providerId: String?, val createdAt: LocalDateTime, @@ -32,7 +31,6 @@ fun User.toEntity( id = id, name = name, email = email, - password = password, provider = provider?.name, providerId = providerId, createdAt = createdAt, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 1d05ec5..82fcb93 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -20,7 +20,6 @@ class UserRepository( id = UUID.fromString(rs.getString("id")), name = rs.getString("name"), email = rs.getString("email"), - password = rs.getString("password"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), @@ -46,12 +45,11 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? + SET name = ?, email = ?, provider = ?, provider_id = ? WHERE id = ? """.trimIndent(), entity.name, entity.email, - user.password, entity.provider, entity.providerId, entity.id, @@ -60,13 +58,12 @@ class UserRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO users (id, name, email, password, provider, provider_id, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, name, email, provider, provider_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, entity.name, entity.email, - user.password, entity.provider, entity.providerId, entity.createdAt, @@ -78,7 +75,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE id = ?", + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", userEntityRowMapper, id, ) @@ -88,7 +85,7 @@ class UserRepository( override fun findByEmail(email: String): User? { val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE email = ?", + "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", userEntityRowMapper, email, ) @@ -98,7 +95,7 @@ class UserRepository( override fun findAll(): List = jdbc .query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users", + "SELECT id, name, email, provider, provider_id, created_at FROM users", userEntityRowMapper, ).map { it.toDomain() } @@ -106,47 +103,6 @@ class UserRepository( jdbc.update("DELETE FROM users WHERE id = ?", id) } - override fun saveWithOAuth2(user: User, provider: String, providerId: String): User { - val updatedRows = jdbc.update( - """ - UPDATE users - SET name = ?, email = ?, password = ?, provider = ?, provider_id = ? - WHERE id = ? - """.trimIndent(), - user.name, - user.email, - user.password, - provider, - providerId, - user.id, - ) - if (updatedRows == 0) { - jdbc.update( - """ - INSERT INTO users (id, name, email, password, provider, provider_id) - VALUES (?, ?, ?, ?, ?, ?) - """.trimIndent(), - user.id, - user.name, - user.email, - user.password, - provider, - providerId, - ) - } - return user - } - - override fun findByProviderAndProviderId(provider: String, providerId: String): User? { - val entities = jdbc.query( - "SELECT id, name, email, password, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ?", - userEntityRowMapper, - provider, - providerId, - ) - return entities.firstOrNull()?.toDomain() - } - override fun findByProviderAndProviderId( provider: AuthProvider, providerId: String, @@ -154,7 +110,7 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, password, provider, provider_id, created_at + SELECT id, name, email, provider, provider_id, created_at FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), diff --git a/src/main/resources/db/migration/V2__add_oauth2_index.sql b/src/main/resources/db/migration/V2__add_oauth2_index.sql index d416108..b92102c 100644 --- a/src/main/resources/db/migration/V2__add_oauth2_index.sql +++ b/src/main/resources/db/migration/V2__add_oauth2_index.sql @@ -1,3 +1,2 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_provider_id -ON users(provider, provider_id) -WHERE provider IS NOT NULL AND provider_id IS NOT NULL; +ON users(provider, provider_id); diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt index 18a952a..c91e49e 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmControllerTest.kt @@ -17,7 +17,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @SpringBootTest -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) class FilmControllerTest { @Autowired private lateinit var mockMvc: MockMvc diff --git a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt index 560cf38..16d835d 100644 --- a/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/FilmLibraryControllerTest.kt @@ -17,7 +17,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @Transactional class FilmLibraryControllerTest { @Autowired diff --git a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt index 7a2dca3..342fda9 100644 --- a/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/UserControllerTest.kt @@ -17,7 +17,7 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status import org.springframework.transaction.annotation.Transactional @SpringBootTest -@AutoConfigureMockMvc +@AutoConfigureMockMvc(addFilters = false) @Transactional class UserControllerTest { @Autowired From bc06654c84650df27adccbdc4890741527b11d2e Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 22:20:03 +0300 Subject: [PATCH 051/106] =?UTF-8?q?-=20=20=20=20=20exclude("**/security.di?= =?UTF-8?q?sabled/**")=20-=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD=D0=BE=20-?= =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=D0=B8=D0=BD=20org.graalvm.buildtools.native=20?= =?UTF-8?q?=D0=B2=20build.gradle.kts,=20=D1=87=D1=82=D0=BE=D0=B1=D1=8B=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=8F=D0=B2=D0=B8=D0=BB=D0=B8=D1=81=D1=8C=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B8=20processAot=20=D0=B8=20nativeCo?= =?UTF-8?q?mpile.=20-=20=D0=9E=D1=82=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD?= =?UTF-8?q?=20configuration-cache=20=D0=B2=20gradle.properties,=20=D1=87?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=8B=20=D1=83=D0=B1=D1=80=D0=B0=D1=82=D1=8C?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=84=D0=BB=D0=B8=D0=BA=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20nativeCompile=20=D0=BD=D0=B0=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BA=D1=83=D1=89=D0=B5=D0=BC=20toolchain.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 7 +------ gradle.properties | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a3b3bf6..630e545 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,15 +9,13 @@ plugins { alias(libs.plugins.kotlin.spring) alias(libs.plugins.spring.boot) alias(libs.plugins.spring.dependency.management) + id("org.graalvm.buildtools.native") version "0.10.5" alias(libs.plugins.protobuf) alias(libs.plugins.ktlint) alias(libs.plugins.detekt) jacoco } -// Temporarily disabled due to OAuth2 AOT processing issues -// apply(plugin = "org.springframework.boot.aot") - apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" @@ -73,7 +71,6 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } - exclude("**/security.disabled/**") } tasks.withType { @@ -156,7 +153,6 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") - exclude("**/security.disabled/**") } } @@ -170,7 +166,6 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" - exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/gradle.properties b/gradle.properties index b185c69..7ef89b3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ kotlin.incremental=true kotlin.incremental.js=true kotlin.incremental.multiplatform=true -org.gradle.configuration-cache=true +org.gradle.configuration-cache=false org.gradle.unsafe.configuration-cache-problems=warn From d2821a843906043b0e75fa4814e2cc48973bad49 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 22:40:50 +0300 Subject: [PATCH 052/106] =?UTF-8?q?Revert=20"-=20=20=20=20=20exclude("**/s?= =?UTF-8?q?ecurity.disabled/**")=20-=20=D1=83=D0=B1=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D0=BE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bc06654c84650df27adccbdc4890741527b11d2e. --- build.gradle.kts | 7 ++++++- gradle.properties | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 630e545..a3b3bf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,13 +9,15 @@ plugins { alias(libs.plugins.kotlin.spring) alias(libs.plugins.spring.boot) alias(libs.plugins.spring.dependency.management) - id("org.graalvm.buildtools.native") version "0.10.5" alias(libs.plugins.protobuf) alias(libs.plugins.ktlint) alias(libs.plugins.detekt) jacoco } +// Temporarily disabled due to OAuth2 AOT processing issues +// apply(plugin = "org.springframework.boot.aot") + apply(from = "$rootDir/gradle/docker.gradle.kts") group = "com.project" @@ -71,6 +73,7 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } + exclude("**/security.disabled/**") } tasks.withType { @@ -153,6 +156,7 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") + exclude("**/security.disabled/**") } } @@ -166,6 +170,7 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" + exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) diff --git a/gradle.properties b/gradle.properties index 7ef89b3..b185c69 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,5 +8,5 @@ kotlin.incremental=true kotlin.incremental.js=true kotlin.incremental.multiplatform=true -org.gradle.configuration-cache=false +org.gradle.configuration-cache=true org.gradle.unsafe.configuration-cache-problems=warn From 56f1f0a46b063d24dfa7ba33371fef4407320cf7 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 15 May 2026 22:46:38 +0300 Subject: [PATCH 053/106] . --- build.gradle.kts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index a3b3bf6..c0f9dfd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -15,8 +15,7 @@ plugins { jacoco } -// Temporarily disabled due to OAuth2 AOT processing issues -// apply(plugin = "org.springframework.boot.aot") +apply(plugin = "org.springframework.boot.aot") apply(from = "$rootDir/gradle/docker.gradle.kts") @@ -73,7 +72,6 @@ tasks.withType { jvmTarget.set(JvmTarget.JVM_21) allWarningsAsErrors.set(false) } - exclude("**/security.disabled/**") } tasks.withType { @@ -156,7 +154,6 @@ ktlint { filter { exclude("**/build/**") exclude("**/generated/**") - exclude("**/security.disabled/**") } } @@ -170,7 +167,6 @@ detekt { tasks.withType().configureEach { jvmTarget = "21" - exclude("**/security.disabled/**") reports { html.required.set(true) xml.required.set(true) From e956c058ac3410794a53e30e339470390b08d5c3 Mon Sep 17 00:00:00 2001 From: skettiks Date: Tue, 19 May 2026 16:13:10 +0300 Subject: [PATCH 054/106] add Film observability with trace id and metrics (#44) * add Film observability with trace id and metrics * test: provide meter registry in FilmServiceTest --- .../adapters/web/ApiExceptionHandler.kt | 52 +++++- .../movienight/adapters/web/TraceIdFilter.kt | 27 +++ .../application/services/FilmService.kt | 163 +++++++++++++++--- src/main/resources/application.yaml | 3 + .../application/services/FilmServiceTest.kt | 5 +- 5 files changed, 219 insertions(+), 31 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 27a236d..40b3362 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -3,6 +3,8 @@ package com.project.movienight.adapters.web import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException +import org.slf4j.LoggerFactory +import org.slf4j.MDC import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus @@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ApiExceptionHandler { + private val log = LoggerFactory.getLogger(javaClass) + @ExceptionHandler(EntityNotFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) - fun handleNotFound(exception: EntityNotFoundException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Entity not found") + fun handleNotFound(exception: EntityNotFoundException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Entity not found", + traceId = traceId, + ) + } @ExceptionHandler(BlockedValueException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleBlockedValue(exception: BlockedValueException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Blocked value") + fun handleBlockedValue(exception: BlockedValueException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Blocked value", + traceId = traceId, + ) + } @ExceptionHandler(DomainException::class) @ResponseStatus(HttpStatus.BAD_REQUEST) - fun handleDomainException(exception: DomainException): ErrorResponse = - ErrorResponse(message = exception.message ?: "Domain error") + fun handleDomainException(exception: DomainException): ErrorResponse { + val traceId = currentTraceId() + log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message) + + return ErrorResponse( + message = exception.message ?: "Domain error", + traceId = traceId, + ) + } + + @ExceptionHandler(Exception::class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + fun handleUnexpectedException(exception: Exception): ErrorResponse { + val traceId = currentTraceId() + log.error("Unexpected error: traceId='{}'", traceId, exception) + + return ErrorResponse( + message = "Internal server error", + traceId = traceId, + ) + } + + private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown" } data class ErrorResponse( val message: String, + val traceId: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt new file mode 100644 index 0000000..596cb47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +class TraceIdFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val traceId = UUID.randomUUID().toString() + MDC.put("traceId", traceId) + + try { + filterChain.doFilter(request, response) + } finally { + MDC.remove("traceId") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index f775de3..bbd32b1 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -14,6 +14,10 @@ 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 org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -22,48 +26,117 @@ class FilmService( private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, private val filmConfig: FilmServiceProperties, + private val meterRegistry: MeterRegistry, ) : CreateFilmUseCase, EditFilmUseCase, DeleteFilmUseCase, GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - override fun create(command: CreateFilmCommand): Film { - if (filmConfig.isBlocked(command.title)) { - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - throw BlockedValueException(target = "Film", field = "description") - } + private val log = LoggerFactory.getLogger(javaClass) - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, + override fun create(command: CreateFilmCommand): Film { + val sample = Timer.start(meterRegistry) + + try { + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, ) - return filmRepository.save(film) + + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + ) + val saved = filmRepository.save(film) + + filmCreatedCounter.increment() + + log.info("Film created: id='{}', title='{}'", saved.id, saved.title) + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( id: UUID, command: EditFilmCommand, ): Film { - if (filmConfig.isBlocked(command.title)) { - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - throw BlockedValueException(target = "Film", field = "description") - } + val sample = Timer.start(meterRegistry) - var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - film = film.copy(title = command.title, description = command.description) - return filmRepository.save(film) + 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() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + filmBlockedCounter.increment() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for edit: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } + + val updatedFilm = + film.copy( + title = command.title, + description = command.description, + ) + val saved = filmRepository.save(updatedFilm) + + filmEditedCounter.increment() + + log.info("Film edited: id='{}'", saved.id) + return saved + } finally { + sample.stop(editFilmTimer) + } } override fun delete(id: UUID) { - filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - filmRepository.deleteById(id) + val sample = Timer.start(meterRegistry) + + try { + log.debug("Delete film with id: {}", id) + + val film = filmRepository.findById(id) + + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) + } + + filmRepository.deleteById(id) + + filmDeletedCounter.increment() + + log.info("Film deleted: id='{}'", id) + } finally { + sample.stop(deleteFilmTimer) + } } override fun getById(id: UUID): Film = @@ -72,4 +145,46 @@ class FilmService( override fun getAll(): List = filmRepository.findAll() override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) + + private val filmCreatedCounter = + Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) + + private val filmEditedCounter = + Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) + + private val filmDeletedCounter = + Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) + + private val filmBlockedCounter = + Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) + + private val createFilmTimer = + Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) + + private val editFilmTimer = + Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) + + private val deleteFilmTimer = + Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 86c806c..284e69a 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -106,3 +106,6 @@ services: - censored - epstein - python +logging: + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index fcc5613..e0f96be 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -8,6 +8,7 @@ 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 @@ -23,6 +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 filmService: FilmService @BeforeEach @@ -30,7 +32,8 @@ class FilmServiceTest { filmRepository = mockk() idGenerator = mockk() filmConfig = mockk() - filmService = FilmService(filmRepository, idGenerator, filmConfig) + meterRegistry = SimpleMeterRegistry() + filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry) } @Test From 75bbe27198a119a6ffcdf1486705b0df613128a4 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:50:22 +0300 Subject: [PATCH 055/106] feat(migrations): extended data structures --- src/main/resources/db/migration/V1__init.sql | 49 ++++++++++++++++++- .../db/migration/V2__jellyfin_events.sql | 14 ++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V2__jellyfin_events.sql diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 900f6b5..e98c8ff 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -4,13 +4,24 @@ CREATE TABLE IF NOT EXISTS public.users ( email VARCHAR(320) NOT NULL UNIQUE, provider VARCHAR(64), provider_id VARCHAR(255), + jellyfin_user_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, - description TEXT NOT NULL + description TEXT NOT NULL, + content_type VARCHAR(32) NOT NULL DEFAULT 'FILM', + release_year INT, + genres TEXT NOT NULL DEFAULT '', + cast_members TEXT NOT NULL DEFAULT '', + directors TEXT NOT NULL DEFAULT '', + imdb_rating DOUBLE PRECISION, + platform_rating DOUBLE PRECISION, + external_url TEXT, + jellyfin_item_id VARCHAR(255), + jellyfin_library_id VARCHAR(255) ); CREATE TABLE IF NOT EXISTS public.favorites ( @@ -19,6 +30,42 @@ CREATE TABLE IF NOT EXISTS public.favorites ( film_id UUID NOT NULL, comment VARCHAR(1024), is_viewed BOOLEAN NOT NULL DEFAULT FALSE, + watched_at TIMESTAMP, CONSTRAINT favorites_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, CONSTRAINT favorites_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE ); + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V2__jellyfin_events.sql new file mode 100644 index 0000000..9f7b8fa --- /dev/null +++ b/src/main/resources/db/migration/V2__jellyfin_events.sql @@ -0,0 +1,14 @@ +-- Create table to store Jellyfin events for idempotency and auditing +CREATE TABLE IF NOT EXISTS jellyfin_events ( + event_id VARCHAR(255) PRIMARY KEY, + server_id VARCHAR(255), + event_type VARCHAR(255) NOT NULL, + occurred_at TIMESTAMP WITH TIME ZONE, + jellyfin_user_id VARCHAR(255), + jellyfin_item_id VARCHAR(255), + payload JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_user ON jellyfin_events(jellyfin_user_id); +CREATE INDEX IF NOT EXISTS idx_jellyfin_events_item ON jellyfin_events(jellyfin_item_id); From 54305f7d162e884e286385b6d27fe739c17db697 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:51:09 +0300 Subject: [PATCH 056/106] chore(persistence): actualized jdbc adapters according to new data structures --- .../persistence/jdbc/FilmLibraryRepository.kt | 27 ++++-- .../persistence/jdbc/FilmRatingRepository.kt | 85 +++++++++++++++++++ .../persistence/jdbc/FilmRepository.kt | 67 +++++++++++++-- .../jdbc/JellyfinEventRepository.kt | 43 ++++++++++ .../jdbc/JellyfinSyncStateRepository.kt | 75 ++++++++++++++++ .../jdbc/UserPreferencesRepository.kt | 74 ++++++++++++++++ .../persistence/jdbc/UserRepository.kt | 15 ++-- .../jdbc/support/DelimitedValueCodec.kt | 38 +++++++++ 8 files changed, 408 insertions(+), 16 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt index f5603cb..fa7f153 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt @@ -18,6 +18,7 @@ class FilmLibraryRepository( filmId = UUID.fromString(rs.getString("film_id")), comment = rs.getString("comment"), isViewed = rs.getBoolean("is_viewed"), + watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(), ) } @@ -26,26 +27,28 @@ class FilmLibraryRepository( jdbc.update( """ UPDATE favorites - SET user_id = ?, film_id = ?, comment = ?, is_viewed = ? + 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, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO favorites (id, user_id, film_id, comment, is_viewed) - VALUES (?, ?, ?, ?, ?) + 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, ) } return filmLibrary @@ -54,16 +57,30 @@ class FilmLibraryRepository( override fun findById(id: UUID): FilmLibrary? { val entries = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?", + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?", filmLibraryRowMapper, id, ) return entries.firstOrNull() } + override fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? { + val entries = + jdbc.query( + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ?", + filmLibraryRowMapper, + userId, + filmId, + ) + return entries.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed FROM favorites", + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", filmLibraryRowMapper, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt new file mode 100644 index 0000000..fac103b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -0,0 +1,85 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.FilmRatingEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.domain.model.FilmRating +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.LocalDateTime +import java.util.UUID + +@Repository +class FilmRatingRepository( + private val jdbc: JdbcTemplate, +) : FilmRatingRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + FilmRatingEntity( + id = UUID.fromString(rs.getString("id")), + userId = UUID.fromString(rs.getString("user_id")), + filmId = UUID.fromString(rs.getString("film_id")), + score = rs.getInt("score"), + note = rs.getString("note"), + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), + updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(), + ) + } + + override fun save(rating: FilmRating): FilmRating { + val entity = rating.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE film_ratings + SET score = ?, note = ?, updated_at = ? + WHERE user_id = ? AND film_id = ? + """.trimIndent(), + entity.score, + entity.note, + LocalDateTime.now(), + entity.userId, + entity.filmId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO film_ratings (id, user_id, film_id, score, note, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.id, + entity.userId, + entity.filmId, + entity.score, + entity.note, + entity.createdAt, + entity.updatedAt, + ) + } + + return rating + } + + override fun findByUserId(userId: UUID): List = + jdbc + .query( + "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ?", + rowMapper, + userId, + ).map { it.toDomain() } + + override fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmRating? = + jdbc + .query( + "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ? AND film_id = ?", + rowMapper, + userId, + filmId, + ).firstOrNull() + ?.toDomain() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 2883aca..8da5f94 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -1,6 +1,8 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository @@ -16,6 +18,21 @@ class FilmRepository( id = UUID.fromString(rs.getString("id")), title = rs.getString("title"), description = rs.getString("description"), + contentType = + runCatching { + ContentType.valueOf( + rs.getString("content_type"), + ) + }.getOrDefault(ContentType.FILM), + releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() }, + genres = DelimitedValueCodec.decodeList(rs.getString("genres")), + cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")), + directors = DelimitedValueCodec.decodeList(rs.getString("directors")), + imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() }, + platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() }, + externalUrl = rs.getString("external_url"), + jellyfinItemId = rs.getString("jellyfin_item_id"), + jellyfinLibraryId = rs.getString("jellyfin_library_id"), ) } @@ -24,22 +41,42 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ? + SET title = ?, description = ?, content_type = ?, release_year = ?, genres = ?, cast_members = ?, directors = ?, imdb_rating = ?, platform_rating = ?, external_url = ?, jellyfin_item_id = ?, jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, film.id, ) if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description) - VALUES (?, ?, ?) + INSERT INTO films (id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, film.title, film.description, + film.contentType.name, + film.releaseYear, + DelimitedValueCodec.encodeList(film.genres), + DelimitedValueCodec.encodeList(film.cast), + DelimitedValueCodec.encodeList(film.directors), + film.imdbRating, + film.platformRating, + film.externalUrl, + film.jellyfinItemId, + film.jellyfinLibraryId, ) } return film @@ -48,16 +85,36 @@ class FilmRepository( override fun findById(id: UUID): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE id = ?", + "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 id = ?", filmRowMapper, id, ) return films.firstOrNull() } + override fun findByJellyfinItemId(jellyfinItemId: 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_item_id = ?", + filmRowMapper, + jellyfinItemId, + ) + 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 = ?", + filmRowMapper, + jellyfinLibraryId, + ) + return films.firstOrNull() + } + override fun findAll(): List = jdbc.query( - "SELECT id, title, description FROM films", + "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", filmRowMapper, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt new file mode 100644 index 0000000..6092c47 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -0,0 +1,43 @@ +package com.project.movienight.adapters.persistence.jdbc + +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate +import org.springframework.stereotype.Repository + +@Repository +class JellyfinEventRepository( + private val jdbc: NamedParameterJdbcTemplate, +) { + fun exists(eventId: String): Boolean { + val sql = "SELECT 1 FROM jellyfin_events WHERE event_id = :eventId" + val params = MapSqlParameterSource().addValue("eventId", eventId) + return jdbc.query(sql, params) { rs, _ -> rs.getInt(1) }.any() + } + + fun save( + eventId: String, + serverId: String?, + eventType: String, + occurredAt: java.time.OffsetDateTime?, + jellyfinUserId: String?, + jellyfinItemId: String?, + payload: String?, + ) { + val sql = """ + INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) + VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb)) + ON CONFLICT (event_id) DO NOTHING + """.trimIndent() + + val params = MapSqlParameterSource() + .addValue("eventId", eventId) + .addValue("serverId", serverId) + .addValue("eventType", eventType) + .addValue("occurredAt", occurredAt) + .addValue("jellyfinUserId", jellyfinUserId) + .addValue("jellyfinItemId", jellyfinItemId) + .addValue("payload", payload) + + jdbc.update(sql, params) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt new file mode 100644 index 0000000..151b525 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -0,0 +1,75 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort +import com.project.movienight.domain.model.JellyfinSyncState +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.UUID + +@Repository +class JellyfinSyncStateRepository( + private val jdbc: JdbcTemplate, +) : JellyfinSyncStateRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + JellyfinSyncStateEntity( + userId = UUID.fromString(rs.getString("user_id")), + lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(), + lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(), + lastError = rs.getString("last_error"), + syncedItemCount = rs.getInt("synced_item_count"), + ) + } + + override fun save(state: JellyfinSyncState): JellyfinSyncState { + val entity = state.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE jellyfin_sync_state + SET last_synced_at = ?, last_successful_sync_at = ?, last_error = ?, synced_item_count = ?, updated_at = CURRENT_TIMESTAMP + WHERE user_id = ? + """.trimIndent(), + entity.lastSyncedAt, + entity.lastSuccessfulSyncAt, + entity.lastError, + entity.syncedItemCount, + entity.userId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO jellyfin_sync_state (user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count) + VALUES (?, ?, ?, ?, ?) + """.trimIndent(), + entity.userId, + entity.lastSyncedAt, + entity.lastSuccessfulSyncAt, + entity.lastError, + entity.syncedItemCount, + ) + } + + return state + } + + override fun findByUserId(userId: UUID): JellyfinSyncState? = + jdbc + .query( + "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state WHERE user_id = ?", + rowMapper, + userId, + ).firstOrNull() + ?.toDomain() + + override fun findAll(): List = + jdbc + .query( + "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", + rowMapper, + ).map { it.toDomain() } +} \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt new file mode 100644 index 0000000..58e056e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -0,0 +1,74 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.adapters.persistence.entity.UserPreferencesEntity +import com.project.movienight.adapters.persistence.entity.toDomain +import com.project.movienight.adapters.persistence.entity.toEntity +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.UUID + +@Repository +class UserPreferencesRepository( + private val jdbc: JdbcTemplate, +) : UserPreferencesRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + UserPreferencesEntity( + userId = UUID.fromString(rs.getString("user_id")), + weightedGenres = rs.getString("weighted_genres"), + plotTypes = rs.getString("plot_types"), + eras = rs.getString("eras"), + castAndDirectors = rs.getString("cast_and_directors"), + moods = rs.getString("moods"), + contentTypes = rs.getString("content_types"), + ) + } + + override fun save(preferences: UserPreferences): UserPreferences { + val entity = preferences.toEntity() + val updatedRows = + jdbc.update( + """ + UPDATE user_preferences + SET weighted_genres = ?, plot_types = ?, eras = ?, cast_and_directors = ?, moods = ?, content_types = ? + WHERE user_id = ? + """.trimIndent(), + entity.weightedGenres, + entity.plotTypes, + entity.eras, + entity.castAndDirectors, + entity.moods, + entity.contentTypes, + entity.userId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO user_preferences (user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.userId, + entity.weightedGenres, + entity.plotTypes, + entity.eras, + entity.castAndDirectors, + entity.moods, + entity.contentTypes, + ) + } + + return preferences + } + + override fun findByUserId(userId: UUID): UserPreferences? = + jdbc + .query( + "SELECT user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types FROM user_preferences WHERE user_id = ?", + rowMapper, + userId, + ).firstOrNull() + ?.toDomain() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index 6c70b4c..f6e261a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -22,6 +22,7 @@ class UserRepository( email = rs.getString("email"), provider = rs.getString("provider"), providerId = rs.getString("provider_id"), + jellyfinUserId = rs.getString("jellyfin_user_id"), createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -32,26 +33,28 @@ class UserRepository( jdbc.update( """ UPDATE users - SET name = ?, email = ?, provider = ?, provider_id = ? + 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, created_at) - VALUES (?, ?, ?, ?, ?, ?) + 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, ) } @@ -61,7 +64,7 @@ class UserRepository( override fun findById(id: UUID): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?", + "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?", userEntityRowMapper, id, ) @@ -71,7 +74,7 @@ class UserRepository( override fun findAll(): List = jdbc .query( - "SELECT id, name, email, provider, provider_id, created_at FROM users", + "SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users", userEntityRowMapper, ).map { it.toDomain() } @@ -86,7 +89,7 @@ class UserRepository( val entities = jdbc.query( """ - SELECT id, name, email, provider, provider_id, created_at FROM users + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE provider = ? AND provider_id = ? """.trimIndent(), userEntityRowMapper, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt new file mode 100644 index 0000000..d671f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt @@ -0,0 +1,38 @@ +package com.project.movienight.adapters.persistence.jdbc.support + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +object DelimitedValueCodec { + fun encodeList(values: List): String = values.joinToString("|") { encode(it) } + + fun decodeList(value: String?): List = + value + ?.takeIf { it.isNotBlank() } + ?.split("|") + ?.map { decode(it) } + ?: emptyList() + + fun encodeWeightedMap(values: Map): String = + values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" } + + fun decodeWeightedMap(value: String?): Map { + if (value.isNullOrBlank()) return emptyMap() + + return value + .split("|") + .mapNotNull { pair -> + val parts = pair.split(":", limit = 2) + if (parts.size != 2) return@mapNotNull null + + val key = decode(parts[0]) + val weight = parts[1].toIntOrNull() ?: return@mapNotNull null + key to weight + }.toMap() + } + + private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + + private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8) +} From 8e89fe5f7fba13c9458f633c729d2c3c7776eb57 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:51:39 +0300 Subject: [PATCH 057/106] feat(entities): actualized entities and added new --- .../persistence/entity/FilmRatingEntity.kt | 37 +++++++++++++++++ .../entity/JellyfinSyncStateEntity.kt | 31 ++++++++++++++ .../adapters/persistence/entity/UserEntity.kt | 4 ++ .../entity/UserPreferencesEntity.kt | 41 +++++++++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt new file mode 100644 index 0000000..10a86db --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt @@ -0,0 +1,37 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingEntity( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) + +fun FilmRatingEntity.toDomain(): FilmRating = + FilmRating( + id = id, + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = createdAt, + updatedAt = updatedAt, + ) + +fun FilmRating.toEntity(): FilmRatingEntity = + FilmRatingEntity( + id = id, + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = createdAt, + updatedAt = updatedAt, + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt new file mode 100644 index 0000000..5720ba9 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -0,0 +1,31 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.domain.model.JellyfinSyncState +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncStateEntity( + val userId: UUID, + val lastSyncedAt: LocalDateTime?, + val lastSuccessfulSyncAt: LocalDateTime?, + val lastError: String?, + val syncedItemCount: Int, +) + +fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState = + JellyfinSyncState( + userId = userId, + lastSyncedAt = lastSyncedAt, + lastSuccessfulSyncAt = lastSuccessfulSyncAt, + lastError = lastError, + syncedItemCount = syncedItemCount, + ) + +fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity = + JellyfinSyncStateEntity( + userId = userId, + lastSyncedAt = lastSyncedAt, + lastSuccessfulSyncAt = lastSuccessfulSyncAt, + lastError = lastError, + syncedItemCount = syncedItemCount, + ) \ No newline at end of file diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 0beda74..58e2c3c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -11,6 +11,7 @@ data class UserEntity( val email: String, val provider: String?, val providerId: String?, + val jellyfinUserId: String?, val createdAt: LocalDateTime, ) @@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User = name = name, email = email, library = null, + preferences = null, + jellyfinUserId = jellyfinUserId, ) fun User.toEntity( @@ -33,5 +36,6 @@ fun User.toEntity( email = email, provider = provider?.name, providerId = providerId, + jellyfinUserId = jellyfinUserId, createdAt = createdAt, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt new file mode 100644 index 0000000..706d175 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt @@ -0,0 +1,41 @@ +package com.project.movienight.adapters.persistence.entity + +import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesEntity( + val userId: UUID, + val weightedGenres: String, + val plotTypes: String, + val eras: String, + val castAndDirectors: String, + val moods: String, + val contentTypes: String, +) + +fun UserPreferencesEntity.toDomain(): UserPreferences = + UserPreferences( + userId = userId, + weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres), + plotTypes = DelimitedValueCodec.decodeList(plotTypes), + eras = DelimitedValueCodec.decodeList(eras), + castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors), + moods = DelimitedValueCodec.decodeList(moods), + contentTypes = + DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value -> + runCatching { ContentType.valueOf(value) }.getOrNull() + }, + ) + +fun UserPreferences.toEntity(): UserPreferencesEntity = + UserPreferencesEntity( + userId = userId, + weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres), + plotTypes = DelimitedValueCodec.encodeList(plotTypes), + eras = DelimitedValueCodec.encodeList(eras), + castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors), + moods = DelimitedValueCodec.encodeList(moods), + contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }), + ) From 432659500b00ebdd2ef180e7ec814013144a5c48 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:57:46 +0300 Subject: [PATCH 058/106] feat(domain): extended and actualized domain models --- .../project/movienight/domain/model/Film.kt | 17 +++++++++++++++++ .../movienight/domain/model/FilmLibrary.kt | 2 ++ .../movienight/domain/model/FilmRating.kt | 14 ++++++++++++++ .../domain/model/JellyfinSyncState.kt | 19 +++++++++++++++++++ .../domain/model/RecommendationContext.kt | 16 ++++++++++++++++ .../project/movienight/domain/model/User.kt | 2 ++ .../domain/model/UserPreferences.kt | 13 +++++++++++++ 7 files changed, 83 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt diff --git a/src/main/kotlin/com/project/movienight/domain/model/Film.kt b/src/main/kotlin/com/project/movienight/domain/model/Film.kt index 32122de..76f2657 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/Film.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/Film.kt @@ -6,4 +6,21 @@ data class Film( val id: UUID, val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) + +enum class ContentType { + FILM, + SERIES, + EPISODE, + OTHER, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt index 868f57a..8d7861c 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt @@ -1,5 +1,6 @@ package com.project.movienight.domain.model +import java.time.LocalDateTime import java.util.UUID data class FilmLibrary( @@ -8,4 +9,5 @@ data class FilmLibrary( val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: LocalDateTime? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt new file mode 100644 index 0000000..380060d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt @@ -0,0 +1,14 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRating( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), + val updatedAt: LocalDateTime = createdAt, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt new file mode 100644 index 0000000..d2395fa --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt @@ -0,0 +1,19 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class JellyfinSyncState( + val userId: UUID, + val lastSyncedAt: LocalDateTime? = null, + val lastSuccessfulSyncAt: LocalDateTime? = null, + val lastError: String? = null, + val syncedItemCount: Int = 0, +) + +data class JellyfinSyncSummary( + val syncedUsers: Int, + val skippedUsers: Int, + val syncedItems: Int, + val durationMs: Long, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt new file mode 100644 index 0000000..1049513 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -0,0 +1,16 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class RecommendationContext( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val limit: Int = 10, +) + +data class RecommendationResult( + val film: Film, + val score: Double, + val reasons: List, +) diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index b4f2d9b..236a698 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -7,4 +7,6 @@ data class User( val name: String, val email: String, val library: FilmLibrary?, + val preferences: UserPreferences? = null, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt new file mode 100644 index 0000000..451e227 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt @@ -0,0 +1,13 @@ +package com.project.movienight.domain.model + +import java.util.UUID + +data class UserPreferences( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) From 967c1b818cd671b5fbd684084cfca8cd7584edb1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 02:58:26 +0300 Subject: [PATCH 059/106] chore(style): reformatted some files --- .../entity/JellyfinSyncStateEntity.kt | 2 +- .../jdbc/JellyfinEventRepository.kt | 22 ++++++++++--------- .../jdbc/JellyfinSyncStateRepository.kt | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt index 5720ba9..5edd5c9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt @@ -28,4 +28,4 @@ fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity = lastSuccessfulSyncAt = lastSuccessfulSyncAt, lastError = lastError, syncedItemCount = syncedItemCount, - ) \ No newline at end of file + ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 6092c47..37f58ca 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -23,20 +23,22 @@ class JellyfinEventRepository( jellyfinItemId: String?, payload: String?, ) { - val sql = """ + val sql = + """ INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb)) ON CONFLICT (event_id) DO NOTHING - """.trimIndent() + """.trimIndent() - val params = MapSqlParameterSource() - .addValue("eventId", eventId) - .addValue("serverId", serverId) - .addValue("eventType", eventType) - .addValue("occurredAt", occurredAt) - .addValue("jellyfinUserId", jellyfinUserId) - .addValue("jellyfinItemId", jellyfinItemId) - .addValue("payload", payload) + val params = + MapSqlParameterSource() + .addValue("eventId", eventId) + .addValue("serverId", serverId) + .addValue("eventType", eventType) + .addValue("occurredAt", occurredAt) + .addValue("jellyfinUserId", jellyfinUserId) + .addValue("jellyfinItemId", jellyfinItemId) + .addValue("payload", payload) jdbc.update(sql, params) } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt index 151b525..2f12ad5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -72,4 +72,4 @@ class JellyfinSyncStateRepository( "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", rowMapper, ).map { it.toDomain() } -} \ No newline at end of file +} From b94599a6520b5c9b46f5c7c4f1cdcccd669ef3d8 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:24:37 +0300 Subject: [PATCH 060/106] style(): yet another formatting improvements --- config/detekt/detekt.yaml | 9 ++ .../persistence/jdbc/FilmLibraryRepository.kt | 5 +- .../persistence/jdbc/FilmRatingRepository.kt | 42 ++++++- .../persistence/jdbc/FilmRepository.kt | 108 ++++++++++++++++-- .../jdbc/JellyfinSyncStateRepository.kt | 33 +++++- .../jdbc/UserPreferencesRepository.kt | 29 ++++- 6 files changed, 206 insertions(+), 20 deletions(-) diff --git a/config/detekt/detekt.yaml b/config/detekt/detekt.yaml index 9b7c718..1cb8259 100644 --- a/config/detekt/detekt.yaml +++ b/config/detekt/detekt.yaml @@ -7,3 +7,12 @@ comments: active: false UndocumentedPublicProperty: active: false + +style: + MagicNumber: + active: false + ReturnCount: + max: 3 + +complexity: + active: false diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt index fa7f153..9fa474d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt @@ -70,7 +70,10 @@ class FilmLibraryRepository( ): FilmLibrary? { val entries = jdbc.query( - "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ?", + """ + SELECT id, user_id, film_id, comment, is_viewed, watched_at + FROM favorites WHERE user_id = ? AND film_id = ? + """.trimIndent(), filmLibraryRowMapper, userId, filmId, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt index fac103b..85334d0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt @@ -33,8 +33,11 @@ class FilmRatingRepository( jdbc.update( """ UPDATE film_ratings - SET score = ?, note = ?, updated_at = ? - WHERE user_id = ? AND film_id = ? + SET score = ?, + note = ?, + updated_at = ? + WHERE user_id = ? + AND film_id = ? """.trimIndent(), entity.score, entity.note, @@ -46,7 +49,15 @@ class FilmRatingRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO film_ratings (id, user_id, film_id, score, note, created_at, updated_at) + INSERT INTO film_ratings ( + id, + user_id, + film_id, + score, + note, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.id, @@ -65,7 +76,17 @@ class FilmRatingRepository( override fun findByUserId(userId: UUID): List = jdbc .query( - "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ?", + """ + SELECT id, + user_id, + film_id, + score, + note, + created_at, + updated_at + FROM film_ratings + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).map { it.toDomain() } @@ -76,7 +97,18 @@ class FilmRatingRepository( ): FilmRating? = jdbc .query( - "SELECT id, user_id, film_id, score, note, created_at, updated_at FROM film_ratings WHERE user_id = ? AND film_id = ?", + """ + SELECT id, + user_id, + film_id, + score, + note, + created_at, + updated_at + FROM film_ratings + WHERE user_id = ? + AND film_id = ? + """.trimIndent(), rowMapper, userId, filmId, diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index 8da5f94..93f580a 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -41,7 +41,18 @@ class FilmRepository( jdbc.update( """ UPDATE films - SET title = ?, description = ?, content_type = ?, release_year = ?, genres = ?, cast_members = ?, directors = ?, imdb_rating = ?, platform_rating = ?, external_url = ?, jellyfin_item_id = ?, jellyfin_library_id = ? + SET title = ?, + description = ?, + content_type = ?, + release_year = ?, + genres = ?, + cast_members = ?, + directors = ?, + imdb_rating = ?, + platform_rating = ?, + external_url = ?, + jellyfin_item_id = ?, + jellyfin_library_id = ? WHERE id = ? """.trimIndent(), film.title, @@ -61,7 +72,21 @@ class FilmRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO films (id, title, description, content_type, release_year, genres, cast_members, directors, imdb_rating, platform_rating, external_url, jellyfin_item_id, jellyfin_library_id) + INSERT INTO films ( + id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), film.id, @@ -85,7 +110,23 @@ class FilmRepository( override fun findById(id: UUID): 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 id = ?", + """ + 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 id = ? + """.trimIndent(), filmRowMapper, id, ) @@ -95,7 +136,23 @@ class FilmRepository( override fun findByJellyfinItemId(jellyfinItemId: 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_item_id = ?", + """ + 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_item_id = ? + """.trimIndent(), filmRowMapper, jellyfinItemId, ) @@ -105,7 +162,23 @@ class FilmRepository( 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 = ?", + """ + 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, ) @@ -114,11 +187,32 @@ class FilmRepository( override fun findAll(): List = 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", + """ + 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 + """.trimIndent(), filmRowMapper, ) override fun deleteById(id: UUID) { - jdbc.update("DELETE FROM films WHERE id = ?", id) + jdbc.update( + """ + DELETE FROM films + WHERE id = ? + """.trimIndent(), + id, + ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt index 2f12ad5..29deb18 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt @@ -30,7 +30,11 @@ class JellyfinSyncStateRepository( jdbc.update( """ UPDATE jellyfin_sync_state - SET last_synced_at = ?, last_successful_sync_at = ?, last_error = ?, synced_item_count = ?, updated_at = CURRENT_TIMESTAMP + SET last_synced_at = ?, + last_successful_sync_at = ?, + last_error = ?, + synced_item_count = ?, + updated_at = CURRENT_TIMESTAMP WHERE user_id = ? """.trimIndent(), entity.lastSyncedAt, @@ -43,7 +47,13 @@ class JellyfinSyncStateRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO jellyfin_sync_state (user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count) + INSERT INTO jellyfin_sync_state ( + user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + ) VALUES (?, ?, ?, ?, ?) """.trimIndent(), entity.userId, @@ -60,7 +70,15 @@ class JellyfinSyncStateRepository( override fun findByUserId(userId: UUID): JellyfinSyncState? = jdbc .query( - "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state WHERE user_id = ?", + """ + SELECT user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + FROM jellyfin_sync_state + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).firstOrNull() @@ -69,7 +87,14 @@ class JellyfinSyncStateRepository( override fun findAll(): List = jdbc .query( - "SELECT user_id, last_synced_at, last_successful_sync_at, last_error, synced_item_count FROM jellyfin_sync_state", + """ + SELECT user_id, + last_synced_at, + last_successful_sync_at, + last_error, + synced_item_count + FROM jellyfin_sync_state + """.trimIndent(), rowMapper, ).map { it.toDomain() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt index 58e056e..33ec0cc 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt @@ -32,7 +32,12 @@ class UserPreferencesRepository( jdbc.update( """ UPDATE user_preferences - SET weighted_genres = ?, plot_types = ?, eras = ?, cast_and_directors = ?, moods = ?, content_types = ? + SET weighted_genres = ?, + plot_types = ?, + eras = ?, + cast_and_directors = ?, + moods = ?, + content_types = ? WHERE user_id = ? """.trimIndent(), entity.weightedGenres, @@ -47,7 +52,15 @@ class UserPreferencesRepository( if (updatedRows == 0) { jdbc.update( """ - INSERT INTO user_preferences (user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types) + INSERT INTO user_preferences ( + user_id, + weighted_genres, + plot_types, + eras, + cast_and_directors, + moods, + content_types + ) VALUES (?, ?, ?, ?, ?, ?, ?) """.trimIndent(), entity.userId, @@ -66,7 +79,17 @@ class UserPreferencesRepository( override fun findByUserId(userId: UUID): UserPreferences? = jdbc .query( - "SELECT user_id, weighted_genres, plot_types, eras, cast_and_directors, moods, content_types FROM user_preferences WHERE user_id = ?", + """ + SELECT user_id, + weighted_genres, + plot_types, + eras, + cast_and_directors, + moods, + content_types + FROM user_preferences + WHERE user_id = ? + """.trimIndent(), rowMapper, userId, ).firstOrNull() From aa89455e503ce0d3e80b087d59907deb486aba5a Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:24:58 +0300 Subject: [PATCH 061/106] fix(): test fix after restructuring --- .../adapters/persistence/entity/UserEntityMappingTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index cdf0c42..4e65569 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -18,6 +18,7 @@ class UserEntityMappingTest { email = "john@email.com", provider = "GOOGLE", providerId = "google1234", + jellyfinUserId = null, createdAt = LocalDateTime.now(), ) val user = entity.toDomain() @@ -26,6 +27,7 @@ class UserEntityMappingTest { assertEquals(entity.name, user.name) assertEquals(entity.email, user.email) assertNull(user.library) + assertNull(user.jellyfinUserId) } @Test From a48463b723427576a2ba16f5a751eae877fe24db Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:34:56 +0300 Subject: [PATCH 062/106] feat(ports): extended and actualized repository interfaces --- .../ports/output/FilmLibraryRepositoryPort.kt | 5 +++++ .../ports/output/FilmRatingRepositoryPort.kt | 15 +++++++++++++++ .../ports/output/FilmRepositoryPort.kt | 4 ++++ .../output/JellyfinSyncStateRepositoryPort.kt | 12 ++++++++++++ .../ports/output/UserPreferencesRepositoryPort.kt | 10 ++++++++++ 5 files changed, 46 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt index a3eb9c9..933f45c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt @@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort { fun findById(id: UUID): FilmLibrary? + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt new file mode 100644 index 0000000..908e5ff --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt @@ -0,0 +1,15 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface FilmRatingRepositoryPort { + fun save(rating: FilmRating): FilmRating + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmRating? +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index c0d3938..d18b2e6 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -8,6 +8,10 @@ interface FilmRepositoryPort { fun findById(id: UUID): Film? + fun findByJellyfinItemId(jellyfinItemId: String): Film? + + fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? + fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt new file mode 100644 index 0000000..78d75b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt @@ -0,0 +1,12 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncState +import java.util.UUID + +interface JellyfinSyncStateRepositoryPort { + fun save(state: JellyfinSyncState): JellyfinSyncState + + fun findByUserId(userId: UUID): JellyfinSyncState? + + fun findAll(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt new file mode 100644 index 0000000..0d110b5 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UserPreferencesRepositoryPort { + fun save(preferences: UserPreferences): UserPreferences + + fun findByUserId(userId: UUID): UserPreferences? +} From ea7e66c1a00bc4bfa69be7d0a79e986e93d39963 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 03:38:30 +0300 Subject: [PATCH 063/106] (scope): [body] [footer(s)] --- .../movienight/MovieNightApplication.kt | 2 + .../movienight/adapters/web/FilmController.kt | 26 ++++ .../adapters/web/FilmLibraryController.kt | 35 ++++-- .../movienight/adapters/web/UserController.kt | 1 + .../web/dto/request/CreateFilmRequest.kt | 10 ++ .../web/dto/request/EditFilmRequest.kt | 10 ++ .../web/dto/request/EditUserRequest.kt | 1 + .../web/dto/response/FilmLibraryResponse.kt | 2 + .../adapters/web/dto/response/FilmResponse.kt | 21 ++++ .../adapters/web/dto/response/UserResponse.kt | 2 + .../ports/input/FilmLibraryUseCase.kt | 15 +++ .../application/ports/input/FilmUseCase.kt | 21 ++++ .../application/ports/input/UserUseCase.kt | 1 + .../services/FilmLibraryService.kt | 117 +++++++++++++----- .../application/services/FilmService.kt | 61 ++++----- .../application/services/UserService.kt | 9 +- src/main/resources/application.yaml | 8 ++ .../services/FilmLibraryServiceTest.kt | 36 ++++-- 18 files changed, 291 insertions(+), 87 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt index 39274c8..6db897a 100644 --- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt +++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt @@ -3,8 +3,10 @@ package com.project.movienight import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication +@EnableScheduling @ConfigurationPropertiesScan("com.project.movienight.config") class MovieNightApplication diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 3699e33..9bec40b 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -25,6 +25,12 @@ 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( @@ -45,6 +51,16 @@ class FilmController( CreateFilmCommand( title = request.title, description = request.description, + contentType = request.contentType.toContentTypeOrFilm(), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) @@ -61,6 +77,16 @@ class FilmController( EditFilmCommand( title = request.title, description = request.description, + contentType = request.contentType.toContentTypeOrFilm(), + releaseYear = request.releaseYear, + genres = request.genres, + cast = request.cast, + directors = request.directors, + imdbRating = request.imdbRating, + platformRating = request.platformRating, + externalUrl = request.externalUrl, + jellyfinItemId = request.jellyfinItemId, + jellyfinLibraryId = request.jellyfinLibraryId, ), ), ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index c2ed037..12c2c5e 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -11,6 +11,9 @@ 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.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 @@ -30,10 +33,10 @@ import java.util.UUID class FilmLibraryController( private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, + private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val getFilmByIdUseCase: GetFilmByIdUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, + private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -60,18 +63,10 @@ class FilmLibraryController( ), ) - @GetMapping("/films") - fun getAllFilmsInLibrary( + @GetMapping("/entries") + fun list( @PathVariable userId: UUID, - ): List { - val library = - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - - val film = getFilmByIdUseCase.getById(library.filmId) - return listOf(FilmResponse.fromDomain(film)) - } + ): List = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) @@ -88,6 +83,20 @@ class FilmLibraryController( ), ) + @PostMapping("/films/{filmId}/viewed") + fun markViewed( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): FilmLibraryResponse = + FilmLibraryResponse.fromDomain( + markFilmViewedUseCase.markViewed( + MarkFilmViewedCommand( + userId = userId, + filmId = filmId, + ), + ), + ) + @DeleteMapping("/films/{filmId}") @ResponseStatus(HttpStatus.NO_CONTENT) fun removeFilm( diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index bccf5bc..e06954c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -64,6 +64,7 @@ class UserController( command = EditUserCommand( name = request.name, + jellyfinUserId = request.jellyfinUserId, ), ), ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 994429d..82f7348 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request data class CreateFilmRequest( val title: String, val description: String, + val contentType: String = "FILM", + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 9e476c3..60eddce 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request data class EditFilmRequest( val title: String, val description: String, + val contentType: String = "FILM", + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 83ddd24..358e0e4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request data class EditUserRequest( val name: String, + val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt index 8ba6c01..90a339d 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt @@ -9,6 +9,7 @@ data class FilmLibraryResponse( val filmId: UUID, val comment: String?, val isViewed: Boolean, + val watchedAt: java.time.LocalDateTime?, ) { companion object { fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = @@ -18,6 +19,7 @@ data class FilmLibraryResponse( filmId = filmLibrary.filmId, comment = filmLibrary.comment, isViewed = filmLibrary.isViewed, + watchedAt = filmLibrary.watchedAt, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt index 239196d..4948540 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.web.dto.response +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -7,6 +8,16 @@ data class FilmResponse( val id: UUID, val title: String, val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val imdbRating: Double?, + val platformRating: Double?, + val externalUrl: String?, + val jellyfinItemId: String?, + val jellyfinLibraryId: String?, ) { companion object { fun fromDomain(film: Film): FilmResponse = @@ -14,6 +25,16 @@ data class FilmResponse( id = film.id, title = film.title, description = film.description, + contentType = film.contentType, + releaseYear = film.releaseYear, + genres = film.genres, + cast = film.cast, + directors = film.directors, + imdbRating = film.imdbRating, + platformRating = film.platformRating, + externalUrl = film.externalUrl, + jellyfinItemId = film.jellyfinItemId, + jellyfinLibraryId = film.jellyfinLibraryId, ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt index 48f5dd8..b1b94f4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt @@ -7,6 +7,7 @@ data class UserResponse( val id: UUID, val name: String, val email: String, + val jellyfinUserId: String?, ) { companion object { fun fromDomain(user: User): UserResponse = @@ -14,6 +15,7 @@ data class UserResponse( id = user.id, name = user.name, email = user.email, + jellyfinUserId = user.jellyfinUserId, ) } } diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index cf9a0b8..3100547 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,6 +1,7 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.FilmLibrary +import java.time.LocalDateTime import java.util.UUID interface CreateFilmLibraryUseCase { @@ -21,6 +22,16 @@ 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 } @@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase { data class GetFilmLibraryQuery( val userId: UUID, ) + +interface ListFilmLibraryEntriesUseCase { + fun list(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index db3f4b0..27098c7 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.ports.input +import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID @@ -10,6 +11,16 @@ interface CreateFilmUseCase { data class CreateFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) interface EditFilmUseCase { @@ -22,6 +33,16 @@ interface EditFilmUseCase { data class EditFilmCommand( val title: String, val description: String, + val contentType: ContentType = ContentType.FILM, + val releaseYear: Int? = null, + val genres: List = emptyList(), + val cast: List = emptyList(), + val directors: List = emptyList(), + val imdbRating: Double? = null, + val platformRating: Double? = null, + val externalUrl: String? = null, + val jellyfinItemId: String? = null, + val jellyfinLibraryId: String? = null, ) interface DeleteFilmUseCase { diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b417525..b066a4f 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -21,6 +21,7 @@ interface EditUserUseCase { data class EditUserCommand( val name: String, + val jellyfinUserId: String? = null, ) interface DeleteUserUseCase { diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index ba64e2a..13ec743 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,11 +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.AddFilmToLibraryUseCase import com.project.movienight.application.ports.input.CreateFilmLibraryCommand import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort @@ -20,70 +24,119 @@ import java.util.UUID class FilmLibraryService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, ) : CreateFilmLibraryUseCase, AddFilmToLibraryUseCase, + MarkFilmViewedUseCase, RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase { + GetFilmLibraryUseCase, + ListFilmLibraryEntriesUseCase { override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary != null) { - return existingLibrary - } + findByUserId(command.userId)?.let { return it } - return filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = idGenerator.generateId(), - comment = command.name, - isViewed = false, - ), - ) + val libraryId = idGenerator.generateId() + val saved = + filmLibraryRepository.save( + FilmLibrary( + id = libraryId, + userId = command.userId, + filmId = libraryId, + comment = command.name, + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - val existingLibrary = findByUserId(command.userId) - if (existingLibrary == null) { - return filmLibraryRepository.save( + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + val saved = + filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + return saved + } + + val saved = + filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, isViewed = false, + watchedAt = null, ), ) - } - - return filmLibraryRepository.save( - existingLibrary.copy( - filmId = command.filmId, - isViewed = false, - ), - ) + businessMetricsService.recordLibraryEvent() + return saved } override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { val existingLibrary = - findByUserId(command.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + if (command.libraryId != null) { + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } - if (command.libraryId != null && command.libraryId != existingLibrary.id) { - throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } - - if (existingLibrary.filmId != command.filmId) { + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { throw DomainException("Film with id ${command.filmId} not found in user's library") } filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() return existingLibrary } + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + val saved = + if (existingEntry == null) { + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } else { + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + businessMetricsService.recordLibraryEvent() + 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 = filmLibraryRepository.findAll().filter { it.userId == userId } + private fun findByUserId(userId: UUID): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + + private fun findByUserAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index bbd32b1..564667f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -56,21 +56,23 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - ) - val saved = filmRepository.save(film) - - filmCreatedCounter.increment() - - log.info("Film created: id='{}', title='{}'", saved.id, saved.title) - return saved - } finally { - sample.stop(createFilmTimer) - } + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + return filmRepository.save(film) } override fun edit( @@ -100,20 +102,23 @@ class FilmService( throw EntityNotFoundException(entity = "Film", id = id.toString()) } - val updatedFilm = - film.copy( - title = command.title, - description = command.description, - ) - val saved = filmRepository.save(updatedFilm) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - filmEditedCounter.increment() - - log.info("Film edited: id='{}'", saved.id) - return saved - } finally { - sample.stop(editFilmTimer) - } + return filmRepository.save(film) } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index da2a84b..684da5f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -37,6 +37,7 @@ class UserService( name = command.name, email = command.email, library = null, + jellyfinUserId = null, ) return userRepository.save(user) } @@ -50,7 +51,13 @@ class UserService( } var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = user.copy(name = command.name) + + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) + return userRepository.save(user) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 284e69a..9f562dc 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -95,6 +95,14 @@ info: description: MovieNight backend service version: ${project.version:unknown} +integrations: + jellyfin: + enabled: ${JELLYFIN_SYNC_ENABLED:false} + base-url: ${JELLYFIN_BASE_URL:} + api-key: ${JELLYFIN_API_KEY:} + sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} + request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} + services: user: blocked-names: diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 1556f83..b029c84 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -1,5 +1,6 @@ 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 @@ -23,32 +24,33 @@ import java.util.UUID class FilmLibraryServiceTest { private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort private lateinit var idGenerator: IdGenerator + private lateinit var businessMetricsService: BusinessMetricsService private lateinit var filmLibraryService: FilmLibraryService @BeforeEach fun setup() { filmLibraryRepository = mockk() idGenerator = mockk() - filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator) + businessMetricsService = mockk(relaxed = true) + filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService) } @Test fun `should create new film library when user has no library`() { val userId = UUID.randomUUID() val libraryId = UUID.randomUUID() - val filmId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") val expectedLibrary = FilmLibrary( id = libraryId, userId = userId, - filmId = filmId, + filmId = libraryId, comment = "My Films", isViewed = false, ) every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returnsMany listOf(libraryId, filmId) + every { idGenerator.generateId() } returns libraryId every { filmLibraryRepository.save( match { @@ -62,11 +64,11 @@ class FilmLibraryServiceTest { assertNotNull(result) assertEquals(libraryId, result.id) assertEquals(userId, result.userId) - assertEquals(filmId, result.filmId) + assertEquals(libraryId, result.filmId) assertEquals("My Films", result.comment) verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 2) { idGenerator.generateId() } + verify(exactly = 1) { idGenerator.generateId() } verify(exactly = 1) { filmLibraryRepository.save(any()) } } @@ -131,7 +133,7 @@ class FilmLibraryServiceTest { } @Test - fun `should add film to existing library`() { + fun `should add film as a new library entry when another film already exists`() { val userId = UUID.randomUUID() val oldFilmId = UUID.randomUUID() val newFilmId = UUID.randomUUID() @@ -144,16 +146,24 @@ class FilmLibraryServiceTest { isViewed = true, ) val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) - val updatedLibrary = existingLibrary.copy(filmId = newFilmId, isViewed = false) + val createdLibrary = + FilmLibrary( + id = UUID.randomUUID(), + userId = userId, + filmId = newFilmId, + comment = null, + isViewed = false, + ) every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { idGenerator.generateId() } returns createdLibrary.id every { filmLibraryRepository.save( match { - it.filmId == newFilmId && it.isViewed == false + it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false }, ) - } returns updatedLibrary + } returns createdLibrary val result = filmLibraryService.addFilm(command) @@ -161,7 +171,7 @@ class FilmLibraryServiceTest { assertEquals(false, result.isViewed) verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { idGenerator.generateId() } verify(exactly = 1) { filmLibraryRepository.save(any()) } } @@ -253,13 +263,13 @@ class FilmLibraryServiceTest { libraryId = wrongLibraryId, ) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryRepository.findById(wrongLibraryId) } returns null assertThrows { filmLibraryService.removeFilm(command) } - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) } verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } } From 5f2a6d12ac982e245aaddd1169ec275653a430f1 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:19 +0300 Subject: [PATCH 064/106] core: restore compilation and tests (ports, DTOs, repo fixes, metrics) --- .../metrics/BusinessMetricsService.kt | 66 +++++++ .../persistence/jdbc/FilmRepository.kt | 20 ++- .../adapters/web/FilmLibraryController.kt | 1 + .../adapters/web/FilmRatingController.kt | 46 +++++ .../adapters/web/RecommendationController.kt | 34 ++++ .../adapters/web/UserPreferencesController.kt | 53 ++++++ .../web/dto/request/RateFilmRequest.kt | 6 + .../request/UpsertUserPreferencesRequest.kt | 10 ++ .../web/dto/response/FilmRatingResponse.kt | 28 +++ .../dto/response/UserPreferencesResponse.kt | 28 +++ .../ports/input/FilmRatingUseCase.kt | 19 ++ .../ports/input/GetRecommendationsUseCase.kt | 16 ++ .../ports/input/UserPreferencesUseCase.kt | 23 +++ .../application/services/FilmRatingService.kt | 57 ++++++ .../application/services/FilmService.kt | 81 +++++---- .../services/RecommendationService.kt | 117 ++++++++++++ .../services/UserPreferencesService.kt | 29 +++ .../movienight/RecommendationSmokeTest.kt | 168 ++++++++++++++++++ 18 files changed, 766 insertions(+), 36 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt create mode 100644 src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt new file mode 100644 index 0000000..6a8f2d0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -0,0 +1,66 @@ +package com.project.movienight.adapters.metrics + +import com.project.movienight.domain.model.JellyfinSyncSummary +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import io.micrometer.core.instrument.Timer +import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicInteger + +@Service +class BusinessMetricsService( + meterRegistry: MeterRegistry, +) { + private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") + private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") + private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total") + private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total") + private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total") + private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total") + private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total") + private val jellyfinSyncDuration: Timer = + Timer + .builder("business_jellyfin_sync_duration_seconds") + .publishPercentileHistogram() + .register(meterRegistry) + private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total") + private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0) + + init { + meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue) + } + + private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") + + fun recordRecommendationRequest() { + recommendationRequests.increment() + } + + fun recordRatingSubmitted() { + ratingsSubmitted.increment() + } + + fun recordLibraryEvent() { + libraryEvents.increment() + } + + fun recordJellyfinSync(summary: JellyfinSyncSummary) { + jellyfinSyncRuns.increment() + jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) + jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) + jellyfinSyncedItems.increment(summary.syncedItems.toDouble()) + jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) + } + + fun recordJellyfinSyncFailure() { + jellyfinSyncFailures.increment() + } + + fun recordJellyfinUnmappedUser() { + jellyfinUnmappedUsersGaugeValue.incrementAndGet() + } + + fun recordBackendWriteFailure() { + backendWriteFailures.increment() + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index a661396..b1d4ab2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -209,7 +209,25 @@ class FilmRepository( override fun findByTitle(title: String): Film? { val films = jdbc.query( - "SELECT id, title, description FROM films WHERE title = ? ORDER BY id LIMIT 1", + """ + SELECT id, + title, + description, + content_type, + release_year, + genres, + cast_members, + directors, + imdb_rating, + platform_rating, + external_url, + jellyfin_item_id, + jellyfin_library_id + FROM films + WHERE title = ? + ORDER BY id + LIMIT 1 + """.trimIndent(), filmRowMapper, title, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 12c2c5e..81115cd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -36,6 +36,7 @@ class FilmLibraryController( private val markFilmViewedUseCase: MarkFilmViewedUseCase, private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, private val getFilmLibraryUseCase: GetFilmLibraryUseCase, + private val getAllFilmsUseCase: GetAllFilmsUseCase, private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, ) { @PostMapping diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt new file mode 100644 index 0000000..fec4889 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -0,0 +1,46 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/ratings") +class FilmRatingController( + private val rateFilmUseCase: RateFilmUseCase, + private val getFilmRatingsUseCase: GetFilmRatingsUseCase, +) { + @PostMapping("/films/{filmId}") + @ResponseStatus(HttpStatus.CREATED) + fun rate( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + @RequestBody request: RateFilmRequest, + ): FilmRatingResponse = + FilmRatingResponse.fromDomain( + rateFilmUseCase.rate( + RateFilmCommand( + userId = userId, + filmId = filmId, + score = request.score, + note = request.note, + ), + ), + ) + + @GetMapping + fun list( + @PathVariable userId: UUID, + ): List = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt new file mode 100644 index 0000000..8cf7823 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -0,0 +1,34 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationResult +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendations") +class RecommendationController( + private val getRecommendationsUseCase: GetRecommendationsUseCase, +) { + @GetMapping + fun recommend( + @PathVariable userId: UUID, + @RequestParam(required = false) contentType: String?, + @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "10") limit: Int, + ): List = + getRecommendationsUseCase.recommend( + RecommendationQuery( + userId = userId, + contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + mood = mood, + limit = limit, + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt new file mode 100644 index 0000000..276565b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -0,0 +1,53 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.domain.model.ContentType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/preferences") +class UserPreferencesController( + private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase, + private val getUserPreferencesUseCase: GetUserPreferencesUseCase, +) { + @PutMapping + fun upsert( + @PathVariable userId: UUID, + @RequestBody request: UpsertUserPreferencesRequest, + ): UserPreferencesResponse = + UserPreferencesResponse.fromDomain( + upsertUserPreferencesUseCase.upsert( + UpsertUserPreferencesCommand( + userId = userId, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = + request.contentTypes.mapNotNull { + runCatching { + ContentType.valueOf( + it, + ) + }.getOrNull() + }, + ), + ), + ) + + @GetMapping + fun get( + @PathVariable userId: UUID, + ): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt new file mode 100644 index 0000000..1f44e39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -0,0 +1,6 @@ +package com.project.movienight.adapters.web.dto.request + +data class RateFilmRequest( + val score: Int, + val note: String? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt new file mode 100644 index 0000000..38c809e --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpsertUserPreferencesRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt new file mode 100644 index 0000000..8f276fc --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmRating +import java.time.LocalDateTime +import java.util.UUID + +data class FilmRatingResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String?, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(rating: FilmRating): FilmRatingResponse = + FilmRatingResponse( + id = rating.id, + userId = rating.userId, + filmId = rating.filmId, + score = rating.score, + note = rating.note, + createdAt = rating.createdAt, + updatedAt = rating.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt new file mode 100644 index 0000000..2388d3f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt @@ -0,0 +1,28 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +data class UserPreferencesResponse( + val userId: UUID, + val weightedGenres: Map, + val plotTypes: List, + val eras: List, + val castAndDirectors: List, + val moods: List, + val contentTypes: List, +) { + companion object { + fun fromDomain(preferences: UserPreferences): UserPreferencesResponse = + UserPreferencesResponse( + userId = preferences.userId, + weightedGenres = preferences.weightedGenres, + plotTypes = preferences.plotTypes, + eras = preferences.eras, + castAndDirectors = preferences.castAndDirectors, + moods = preferences.moods, + contentTypes = preferences.contentTypes, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt new file mode 100644 index 0000000..37c8226 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -0,0 +1,19 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.FilmRating +import java.util.UUID + +interface RateFilmUseCase { + fun rate(command: RateFilmCommand): FilmRating +} + +data class RateFilmCommand( + val userId: UUID, + val filmId: UUID, + val score: Int, + val note: String? = null, +) + +interface GetFilmRatingsUseCase { + fun getRatings(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt new file mode 100644 index 0000000..de9f91f --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -0,0 +1,16 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +interface GetRecommendationsUseCase { + fun recommend(query: RecommendationQuery): List +} + +data class RecommendationQuery( + val userId: UUID, + val contentType: ContentType? = null, + val mood: String? = null, + val limit: Int = 10, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt new file mode 100644 index 0000000..b44820c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -0,0 +1,23 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.UserPreferences +import java.util.UUID + +interface UpsertUserPreferencesUseCase { + fun upsert(command: UpsertUserPreferencesCommand): UserPreferences +} + +data class UpsertUserPreferencesCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), +) + +interface GetUserPreferencesUseCase { + fun get(userId: UUID): UserPreferences? +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt new file mode 100644 index 0000000..738bada --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -0,0 +1,57 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RateFilmUseCase +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmRating +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class FilmRatingService( + private val filmRepository: FilmRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) : RateFilmUseCase, + GetFilmRatingsUseCase { + override fun rate(command: RateFilmCommand): FilmRating { + if (command.score !in 1..10) { + throw DomainException("Film rating score must be between 1 and 10") + } + + filmRepository.findById(command.filmId) + ?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString()) + + val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId) + val now = LocalDateTime.now() + + val rating = + if (existingRating == null) { + FilmRating( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + score = command.score, + note = command.note, + createdAt = now, + updatedAt = now, + ) + } else { + existingRating.copy(score = command.score, note = command.note, updatedAt = now) + } + + val savedRating = filmRatingRepository.save(rating) + businessMetricsService.recordRatingSubmitted() + return savedRating + } + + override fun getRatings(userId: UUID): List = filmRatingRepository.findByUserId(userId) +} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 564667f..7424e84 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}", + "Create film request received: title='{}', descriptionLength={}'", command.title, command.description.length, ) @@ -56,23 +56,29 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) - return filmRepository.save(film) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) + + val saved = filmRepository.save(film) + filmCreatedCounter.increment() + return saved + } finally { + sample.stop(createFilmTimer) + } } override fun edit( @@ -95,30 +101,35 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = filmRepository.findById(id) + var film = filmRepository.findById(id) if (film == null) { log.debug("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - film = - film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) - return filmRepository.save(film) + val saved = filmRepository.save(film) + filmEditedCounter.increment() + return saved + } finally { + sample.stop(editFilmTimer) + } } override fun delete(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt new file mode 100644 index 0000000..cc6bc39 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -0,0 +1,117 @@ +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.RecommendationResult +import org.springframework.stereotype.Service + +@Service +class RecommendationService( + private val filmRepository: FilmRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val businessMetricsService: BusinessMetricsService, +) : GetRecommendationsUseCase { + override fun recommend(query: RecommendationQuery): List { + businessMetricsService.recordRecommendationRequest() + val preferences = userPreferencesRepository.findByUserId(query.userId) + val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId } + val watchedFilmIds = + filmLibraryRepository + .findAll() + .filter { + it.userId == query.userId && it.isViewed + }.map { it.filmId } + .toSet() + + return filmRepository + .findAll() + .asSequence() + .filter { film -> query.contentType == null || film.contentType == query.contentType } + .map { film -> + scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id)) + }.sortedByDescending { it.score } + .take(query.limit.coerceAtLeast(1)) + .toList() + } + + private fun scoreFilm( + film: Film, + mood: String?, + preferences: com.project.movienight.domain.model.UserPreferences?, + hasUserRating: Boolean, + watched: Boolean, + ): RecommendationResult { + var score = 0.0 + val reasons = mutableListOf() + + preferences?.contentTypes?.let { + if (it.isEmpty() || it.contains(film.contentType)) { + score += 2.0 + reasons += "Matches content preference" + } + } + + preferences?.weightedGenres?.forEach { (genre, weight) -> + if (film.genres.any { it.equals(genre, ignoreCase = true) }) { + score += weight + reasons += "Matches genre $genre" + } + } + + preferences?.castAndDirectors?.forEach { favorite -> + val found = + film.cast.any { it.equals(favorite, ignoreCase = true) } || + film.directors.any { it.equals(favorite, ignoreCase = true) } + if (found) { + score += 1.5 + reasons += "Matches favorite creator or cast member $favorite" + } + } + + preferences?.moods?.forEach { preferredMood -> + if (mood != null && preferredMood.equals(mood, ignoreCase = true)) { + score += 1.25 + reasons += "Matches requested mood $mood" + } + } + + film.imdbRating?.let { + score += it / 2.0 + reasons += "Strong IMDb signal" + } + + film.platformRating?.let { + score += it + reasons += "Strong platform signal" + } + + if (hasUserRating) { + score += 2.0 + reasons += "User has already rated similar content" + } + + if (watched) { + score -= 3.0 + reasons += "Already watched" + } + + if (mood != null && film.title.contains(mood, ignoreCase = true)) { + score += 0.5 + } + + if (reasons.isEmpty()) { + reasons += "Baseline recommendation from library catalog" + } + + return RecommendationResult(film = film, score = score, reasons = reasons) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt new file mode 100644 index 0000000..de388ce --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -0,0 +1,29 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.GetUserPreferencesUseCase +import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand +import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +import com.project.movienight.domain.model.UserPreferences +import org.springframework.stereotype.Service + +@Service +class UserPreferencesService( + private val userPreferencesRepository: UserPreferencesRepositoryPort, +) : UpsertUserPreferencesUseCase, + GetUserPreferencesUseCase { + override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId) +} diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt new file mode 100644 index 0000000..a7ab288 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -0,0 +1,168 @@ +package com.project.movienight + +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.adapters.web.dto.request.CreateFilmRequest +import com.project.movienight.adapters.web.dto.request.CreateUserRequest +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.context.ActiveProfiles +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.get +import org.springframework.test.web.servlet.post +import org.springframework.test.web.servlet.put +import java.util.UUID + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class RecommendationSmokeTest { + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Autowired + private lateinit var jdbcTemplate: JdbcTemplate + + @BeforeEach + fun setup() { + cleanDatabase() + } + + @AfterEach + fun cleanup() { + cleanDatabase() + } + + @Test + fun `should create data and return a ranked recommendation`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Jane", email = "jane@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "jane@example.com", + ), + ) + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Orbital Drift", + description = "A science-fiction rescue mission", + contentType = "FILM", + genres = listOf("SCI-FI", "THRILLER"), + directors = listOf("Nora Finch"), + imdbRating = 8.7, + platformRating = 9.0, + externalUrl = "https://example.com/orbital-drift", + ), + ) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = "Small Town Summer", + description = "A grounded family drama", + contentType = "FILM", + genres = listOf("DRAMA"), + directors = listOf("Ava Reed"), + imdbRating = 7.1, + platformRating = 6.8, + ), + ) + }.andExpect { + status { isCreated() } + } + + val createdFilms = jdbcTemplate.queryForList("SELECT id, title FROM films ORDER BY title") + val filmIdByTitle = + createdFilms.associate { row -> + row["title"].toString() to UUID.fromString(row["id"].toString()) + } + val firstFilmId = filmIdByTitle.getValue("Orbital Drift") + val secondFilmId = filmIdByTitle.getValue("Small Town Summer") + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("SCI-FI" to 5), + moods = listOf("focused"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.weightedGenres['SCI-FI']") { value(5) } + } + + mockMvc + .post("/api/users/$userId/ratings/films/$firstFilmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Great fit")) + }.andExpect { + status { isCreated() } + jsonPath("$.score") { value(10) } + } + + mockMvc + .post("/api/users/$userId/library/films/$secondFilmId/viewed") + .andExpect { + status { isOk() } + jsonPath("$.viewed") { value(true) } + } + + mockMvc + .get("/api/users/$userId/ratings") + .andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].film.id") { value(firstFilmId.toString()) } + } + } + + private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM film_ratings") + jdbcTemplate.execute("DELETE FROM user_preferences") + jdbcTemplate.execute("DELETE FROM favorites") + jdbcTemplate.execute("DELETE FROM films") + jdbcTemplate.execute("DELETE FROM users") + } +} From d0860cf137a088fcb0140b53b74fa88bfd1858f3 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 16:57:53 +0300 Subject: [PATCH 065/106] integrations(jellyfin): add event ingestion and sync scaffolding + migration --- .../adapters/jellyfin/JellyfinApiClient.kt | 143 ++++++++++++++++ .../adapters/web/JellyfinEventsController.kt | 52 ++++++ .../adapters/web/JellyfinSyncController.kt | 21 +++ .../web/dto/request/JellyfinEventRequest.kt | 21 +++ .../services/JellyfinEventService.kt | 70 ++++++++ .../services/JellyfinSyncService.kt | 153 ++++++++++++++++++ .../config/JellyfinIntegrationProperties.kt | 13 ++ ...fin_events.sql => V3__jellyfin_events.sql} | 0 8 files changed, 473 insertions(+) create mode 100644 src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt create mode 100644 src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt rename src/main/resources/db/migration/{V2__jellyfin_events.sql => V3__jellyfin_events.sql} (100%) diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt new file mode 100644 index 0000000..a21d103 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -0,0 +1,143 @@ +package com.project.movienight.adapters.jellyfin + +import com.fasterxml.jackson.databind.JsonNode +import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.model.ContentType +import org.springframework.stereotype.Service +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.time.Duration + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) + +@Service +class JellyfinApiClient( + private val properties: JellyfinIntegrationProperties, + private val objectMapper: ObjectMapper, +) { + private val httpClient: HttpClient = + HttpClient + .newBuilder() + .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) + .build() + + fun fetchUsers(): List = + request("Users") + .asItems() + .mapNotNull { node -> + val id = node.fieldText("Id") ?: return@mapNotNull null + JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) + } + + fun fetchLibraryItems(userId: String): List = + @Suppress("MaxLineLength") + request( + "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", + ).asItems().mapNotNull { node -> + val itemId = node.fieldText("Id") ?: return@mapNotNull null + val providerIds = node["ProviderIds"] + val imdbId = providerIds?.fieldText("Imdb") + val people = node["People"] + val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList() + val directors = people?.peopleByType("Director") ?: emptyList() + JellyfinLibraryItemSnapshot( + jellyfinItemId = itemId, + title = node.fieldText("Name") ?: itemId, + description = node.fieldText("Overview") ?: "", + contentType = mapContentType(node.fieldText("Type")), + releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(), + genres = node["Genres"]?.textList() ?: emptyList(), + cast = cast, + directors = directors, + platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(), + imdbRating = null, + externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" }, + jellyfinLibraryId = node.fieldText("ParentId"), + isPlayed = + node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false, + ) + } + + private fun request(path: String): JsonNode { + val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path") + val request = + HttpRequest + .newBuilder(uri) + .timeout(Duration.ofMillis(properties.requestTimeoutMs)) + .header("Accept", "application/json") + .header("X-Emby-Token", properties.apiKey) + .GET() + .build() + + val response = + try { + httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + } catch ( + @Suppress("TooGenericExceptionCaught") exception: Exception, + ) { + throw IllegalStateException("Failed to call Jellyfin at $uri", exception) + } + + check(response.statusCode() !in 200..299) { + "Jellyfin request failed with status ${response.statusCode()} for $uri" + } + + return objectMapper.readTree(response.body()) + } + + private fun JsonNode.asItems(): List = + when { + isArray -> map { it } + has("Items") && this["Items"].isArray -> this["Items"].map { it } + else -> emptyList() + } + + private fun JsonNode.fieldText(name: String): String? = + get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() } + + private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean() + + private fun JsonNode.textList(): List = + takeIf { it.isArray }?.mapNotNull { item -> + item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() } + } + ?: emptyList() + + private fun JsonNode.peopleByType(vararg types: String): List { + if (!isArray) return emptyList() + return mapNotNull { person -> + val type = person.fieldText("Type") ?: return@mapNotNull null + if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null + } + } + + private fun mapContentType(value: String?): ContentType = + when (value?.lowercase()) { + "movie" -> ContentType.FILM + "series" -> ContentType.SERIES + "episode" -> ContentType.EPISODE + else -> ContentType.OTHER + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt new file mode 100644 index 0000000..3708530 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -0,0 +1,52 @@ +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.config.JellyfinIntegrationProperties +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinEventsController( + private val jellyfinEventService: JellyfinEventService, + private val properties: JellyfinIntegrationProperties, +) { + private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) + + @PostMapping("/events") + @ResponseStatus(HttpStatus.OK) + fun receiveEvent( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @RequestBody request: JellyfinEventRequest, + ) { + if (properties.pluginToken.isNotBlank()) { + if (token == null || token != properties.pluginToken) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") + } + } + + log.debug( + "Received Jellyfin event {} for user {} item {}", + request.eventId, + request.jellyfinUserId, + request.itemId, + ) + jellyfinEventService.handleEvent( + eventId = request.eventId, + serverId = null, + eventType = request.eventType, + occurredAt = request.occurredAt, + jellyfinUserId = request.jellyfinUserId, + itemId = request.itemId, + payload = request.payload, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt new file mode 100644 index 0000000..74aac90 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.application.services.JellyfinSyncService +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinSyncController( + private val jellyfinSyncService: JellyfinSyncService, +) { + @PostMapping("/sync") + fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow() + + @GetMapping("/sync-state") + fun syncState(): List = jellyfinSyncService.getSyncStates() +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt new file mode 100644 index 0000000..68abfe8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web.dto.request + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.OffsetDateTime + +data class JellyfinEventRequest( + @JsonProperty("event_id") + val eventId: String, + @JsonProperty("event_type") + val eventType: String, + @JsonProperty("occurred_at") + val occurredAt: OffsetDateTime, + @JsonProperty("jellyfin_user_id") + val jellyfinUserId: String, + @JsonProperty("item_id") + val itemId: String, + @JsonProperty("payload_version") + val payloadVersion: Int = 1, + @JsonProperty("payload") + val payload: Map? = null, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt new file mode 100644 index 0000000..8ff3f6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -0,0 +1,70 @@ +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.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.UserRepositoryPort +import org.springframework.stereotype.Service +import java.time.OffsetDateTime + +@Service +class JellyfinEventService( + private val jellyfinEventRepository: JellyfinEventRepository, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val markFilmViewedUseCase: MarkFilmViewedUseCase, + private val objectMapper: ObjectMapper, + private val businessMetricsService: BusinessMetricsService, +) { + 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?, + ) { + if (jellyfinEventRepository.exists(eventId = eventId)) { + return + } + + val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson) + + try { + if (playbackEventTypes.contains(eventType)) { + val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } + if (localUser == null) { + businessMetricsService.recordJellyfinUnmappedUser() + return + } + + val film = filmRepository.findByJellyfinItemId(itemId) + if (film == null) { + businessMetricsService.recordBackendWriteFailure() + return + } + + markFilmViewedUseCase.markViewed( + MarkFilmViewedCommand( + userId = localUser.id, + filmId = film.id, + watchedAt = occurredAt.toLocalDateTime(), + ), + ) + businessMetricsService.recordLibraryEvent() + } + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordBackendWriteFailure() + throw ex + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt new file mode 100644 index 0000000..46faa88 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -0,0 +1,153 @@ +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.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +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.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.time.Duration +import java.time.Instant +import java.time.LocalDateTime + +@Service +class JellyfinSyncService( + private val properties: JellyfinIntegrationProperties, + private val jellyfinApiClient: JellyfinApiClient, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val syncStateRepository: JellyfinSyncStateRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) { + @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") + fun scheduledSync() { + if (properties.enabled) { + syncNow() + } + } + + fun syncNow(): JellyfinSyncSummary { + if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) { + return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) + } + + val startedAt = Instant.now() + val remoteUsers = jellyfinApiClient.fetchUsers() + val localUsersByJellyfinId = + userRepository + .findAll() + .mapNotNull { user -> + user.jellyfinUserId?.let { it to user } + }.toMap() + + var syncedUsers = 0 + var skippedUsers = 0 + var syncedItems = 0 + + remoteUsers.forEach { remoteUser -> + val localUser = localUsersByJellyfinId[remoteUser.id] + if (localUser == null) { + skippedUsers += 1 + return@forEach + } + + val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id) + items.forEach { item -> + syncItem(localUser.id, item) + syncedItems += 1 + } + + val now = LocalDateTime.now() + syncStateRepository.save( + JellyfinSyncState( + userId = localUser.id, + lastSyncedAt = now, + lastSuccessfulSyncAt = now, + lastError = null, + syncedItemCount = items.size, + ), + ) + syncedUsers += 1 + } + + val summary = + JellyfinSyncSummary( + syncedUsers = syncedUsers, + skippedUsers = skippedUsers, + syncedItems = syncedItems, + durationMs = Duration.between(startedAt, Instant.now()).toMillis(), + ) + businessMetricsService.recordJellyfinSync(summary) + return summary + } + + fun getSyncStates(): List = syncStateRepository.findAll() + + private fun syncItem( + userId: java.util.UUID, + item: JellyfinLibraryItemSnapshot, + ) { + val film = + filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( + title = item.title, + description = item.description, + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) ?: Film( + id = idGenerator.generateId(), + title = item.title, + description = item.description, + contentType = item.contentType, + releaseYear = item.releaseYear, + genres = item.genres, + cast = item.cast, + directors = item.directors, + imdbRating = item.imdbRating, + platformRating = item.platformRating, + externalUrl = item.externalUrl, + jellyfinItemId = item.jellyfinItemId, + jellyfinLibraryId = item.jellyfinLibraryId, + ) + + val savedFilm = filmRepository.save(film) + + if (item.isPlayed) { + val watchedAt = LocalDateTime.now() + val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id) + filmLibraryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibrary( + id = idGenerator.generateId(), + userId = userId, + filmId = savedFilm.id, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt new file mode 100644 index 0000000..5a59400 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -0,0 +1,13 @@ +package com.project.movienight.config + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "integrations.jellyfin") +data class JellyfinIntegrationProperties( + val enabled: Boolean = false, + val baseUrl: String = "", + val apiKey: String = "", + val syncIntervalMs: Long = 1_800_000, + val requestTimeoutMs: Long = 20_000, + val pluginToken: String = "", +) diff --git a/src/main/resources/db/migration/V2__jellyfin_events.sql b/src/main/resources/db/migration/V3__jellyfin_events.sql similarity index 100% rename from src/main/resources/db/migration/V2__jellyfin_events.sql rename to src/main/resources/db/migration/V3__jellyfin_events.sql From eec2814bc3f7e78c9bea92a2b9c99794dd037fff Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 19:48:44 +0300 Subject: [PATCH 066/106] feat(migrations): added migrations for ratings and added some jellyfin ids --- .../db/migration/V4__add_ratings_table.sql | 17 +++++++++++++++++ .../migration/V5__add_jellyfin_id_columns.sql | 12 ++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/resources/db/migration/V4__add_ratings_table.sql create mode 100644 src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql diff --git a/src/main/resources/db/migration/V4__add_ratings_table.sql b/src/main/resources/db/migration/V4__add_ratings_table.sql new file mode 100644 index 0000000..be1f105 --- /dev/null +++ b/src/main/resources/db/migration/V4__add_ratings_table.sql @@ -0,0 +1,17 @@ +-- Create ratings table to store user film ratings +CREATE TABLE ratings ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + rating NUMERIC(3, 1) NOT NULL CHECK (rating >= 0 AND rating <= 10), + source VARCHAR(50) NOT NULL DEFAULT 'MOVIENIGHT', + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_user_film_rating UNIQUE (user_id, film_id) +); + +-- Create index on user_id for efficient lookups by user +CREATE INDEX idx_ratings_user_id ON ratings(user_id); + +-- Create index on film_id for efficient lookups by film +CREATE INDEX idx_ratings_film_id ON ratings(film_id); diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql new file mode 100644 index 0000000..c7c990b --- /dev/null +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -0,0 +1,12 @@ +-- Add jellyfin_id columns for mapping between MovieNight and Jellyfin +ALTER TABLE films + ADD COLUMN jellyfin_id UUID UNIQUE NULL, + ADD COLUMN jellyfin_library_id UUID NULL; + +CREATE INDEX idx_films_jellyfin_id ON films(jellyfin_id); + +-- Add jellyfin_id to users for sync mapping +ALTER TABLE users + ADD COLUMN jellyfin_id UUID UNIQUE NULL; + +CREATE INDEX idx_users_jellyfin_id ON users(jellyfin_id); From 1f22be340190d9415ae37c682198fd0e0f0963d0 Mon Sep 17 00:00:00 2001 From: ITQ Date: Wed, 20 May 2026 20:06:17 +0300 Subject: [PATCH 067/106] migrations: fix jellyfin id DDL for h2 compatibility --- .../db/migration/V5__add_jellyfin_id_columns.sql | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql index c7c990b..25d719c 100644 --- a/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql +++ b/src/main/resources/db/migration/V5__add_jellyfin_id_columns.sql @@ -1,12 +1,17 @@ -- Add jellyfin_id columns for mapping between MovieNight and Jellyfin ALTER TABLE films - ADD COLUMN jellyfin_id UUID UNIQUE NULL, - ADD COLUMN jellyfin_library_id UUID NULL; + ADD COLUMN jellyfin_id UUID; + +ALTER TABLE films + ADD CONSTRAINT uq_films_jellyfin_id UNIQUE (jellyfin_id); CREATE INDEX idx_films_jellyfin_id ON films(jellyfin_id); -- Add jellyfin_id to users for sync mapping ALTER TABLE users - ADD COLUMN jellyfin_id UUID UNIQUE NULL; + ADD COLUMN jellyfin_id UUID; + +ALTER TABLE users + ADD CONSTRAINT uq_users_jellyfin_id UNIQUE (jellyfin_id); CREATE INDEX idx_users_jellyfin_id ON users(jellyfin_id); From 50923e2e5abb73cf4b906ecd256bf8852ec5432f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 18:33:34 +0000 Subject: [PATCH 068/106] fix: address PR review thread issues Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/d4d6ebbb-2508-484e-accf-c891be54750f Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../adapters/jellyfin/JellyfinApiClient.kt | 2 +- .../jdbc/JellyfinEventRepository.kt | 14 ++-- .../adapters/web/JellyfinEventsController.kt | 4 ++ .../services/FilmLibraryService.kt | 16 +---- .../application/services/FilmService.kt | 2 +- .../services/JellyfinEventService.kt | 19 +++-- src/main/resources/db/migration/V1__init.sql | 49 +------------ .../db/migration/V6__extend_schema.sql | 70 +++++++++++++++++++ .../services/FilmLibraryServiceTest.kt | 33 ++------- 9 files changed, 106 insertions(+), 103 deletions(-) create mode 100644 src/main/resources/db/migration/V6__extend_schema.sql diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index a21d103..71670be 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -100,7 +100,7 @@ class JellyfinApiClient( throw IllegalStateException("Failed to call Jellyfin at $uri", exception) } - check(response.statusCode() !in 200..299) { + check(response.statusCode() in 200..299) { "Jellyfin request failed with status ${response.statusCode()} for $uri" } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 37f58ca..153eba7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -8,12 +8,6 @@ import org.springframework.stereotype.Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, ) { - fun exists(eventId: String): Boolean { - val sql = "SELECT 1 FROM jellyfin_events WHERE event_id = :eventId" - val params = MapSqlParameterSource().addValue("eventId", eventId) - return jdbc.query(sql, params) { rs, _ -> rs.getInt(1) }.any() - } - fun save( eventId: String, serverId: String?, @@ -22,7 +16,7 @@ class JellyfinEventRepository( jellyfinUserId: String?, jellyfinItemId: String?, payload: String?, - ) { + ): Int { val sql = """ INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) @@ -40,6 +34,12 @@ class JellyfinEventRepository( .addValue("jellyfinItemId", jellyfinItemId) .addValue("payload", 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) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 3708530..81ee52c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -27,6 +27,10 @@ class JellyfinEventsController( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @RequestBody request: JellyfinEventRequest, ) { + if (!properties.enabled) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") + } + if (properties.pluginToken.isNotBlank()) { if (token == null || token != properties.pluginToken) { throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 13ec743..2924922 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -33,21 +33,7 @@ class FilmLibraryService( ListFilmLibraryEntriesUseCase { override fun create(command: CreateFilmLibraryCommand): FilmLibrary { findByUserId(command.userId)?.let { return it } - - val libraryId = idGenerator.generateId() - val saved = - filmLibraryRepository.save( - FilmLibrary( - id = libraryId, - userId = command.userId, - filmId = libraryId, - comment = command.name, - isViewed = false, - watchedAt = null, - ), - ) - businessMetricsService.recordLibraryEvent() - return saved + throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 7424e84..d69a6c8 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -40,7 +40,7 @@ class FilmService( try { log.debug( - "Create film request received: title='{}', descriptionLength={}'", + "Create film request received: title='{}', descriptionLength={}", command.title, command.description.length, ) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 8ff3f6b..64be033 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -30,23 +30,33 @@ class JellyfinEventService( itemId: String, payload: Map?, ) { - if (jellyfinEventRepository.exists(eventId = eventId)) { + val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + val inserted = + jellyfinEventRepository.save( + eventId = eventId, + serverId = serverId, + eventType = eventType, + occurredAt = occurredAt, + jellyfinUserId = jellyfinUserId, + jellyfinItemId = itemId, + payload = payloadJson, + ) + if (inserted != 1) { return } - val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } - jellyfinEventRepository.save(eventId, serverId, eventType, occurredAt, jellyfinUserId, itemId, payloadJson) - try { if (playbackEventTypes.contains(eventType)) { val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } if (localUser == null) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordJellyfinUnmappedUser() return } val film = filmRepository.findByJellyfinItemId(itemId) if (film == null) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() return } @@ -63,6 +73,7 @@ class JellyfinEventService( } catch ( @Suppress("TooGenericExceptionCaught") ex: RuntimeException, ) { + jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() throw ex } diff --git a/src/main/resources/db/migration/V1__init.sql b/src/main/resources/db/migration/V1__init.sql index 1dcc5c9..ee51933 100644 --- a/src/main/resources/db/migration/V1__init.sql +++ b/src/main/resources/db/migration/V1__init.sql @@ -5,24 +5,13 @@ CREATE TABLE IF NOT EXISTS public.users ( password VARCHAR(255), provider VARCHAR(64), provider_id VARCHAR(255), - jellyfin_user_id VARCHAR(255), created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS public.films ( id UUID PRIMARY KEY, title VARCHAR(255) NOT NULL, - description TEXT NOT NULL, - content_type VARCHAR(32) NOT NULL DEFAULT 'FILM', - release_year INT, - genres TEXT NOT NULL DEFAULT '', - cast_members TEXT NOT NULL DEFAULT '', - directors TEXT NOT NULL DEFAULT '', - imdb_rating DOUBLE PRECISION, - platform_rating DOUBLE PRECISION, - external_url TEXT, - jellyfin_item_id VARCHAR(255), - jellyfin_library_id VARCHAR(255) + description TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS public.favorites ( @@ -31,42 +20,6 @@ CREATE TABLE IF NOT EXISTS public.favorites ( film_id UUID NOT NULL, comment VARCHAR(1024), is_viewed BOOLEAN NOT NULL DEFAULT FALSE, - watched_at TIMESTAMP, CONSTRAINT favorites_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, CONSTRAINT favorites_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE ); - -CREATE TABLE IF NOT EXISTS public.user_preferences ( - user_id UUID PRIMARY KEY, - weighted_genres TEXT NOT NULL DEFAULT '', - plot_types TEXT NOT NULL DEFAULT '', - eras TEXT NOT NULL DEFAULT '', - cast_and_directors TEXT NOT NULL DEFAULT '', - moods TEXT NOT NULL DEFAULT '', - content_types TEXT NOT NULL DEFAULT '', - CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS public.film_ratings ( - id UUID PRIMARY KEY, - user_id UUID NOT NULL, - film_id UUID NOT NULL, - score INT NOT NULL, - note VARCHAR(2048), - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, - CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, - CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), - CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) -); - -CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( - user_id UUID PRIMARY KEY, - last_synced_at TIMESTAMP, - last_successful_sync_at TIMESTAMP, - last_error TEXT, - synced_item_count INT NOT NULL DEFAULT 0, - updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE -); diff --git a/src/main/resources/db/migration/V6__extend_schema.sql b/src/main/resources/db/migration/V6__extend_schema.sql new file mode 100644 index 0000000..3c7189f --- /dev/null +++ b/src/main/resources/db/migration/V6__extend_schema.sql @@ -0,0 +1,70 @@ +ALTER TABLE public.users + ADD COLUMN IF NOT EXISTS jellyfin_user_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS content_type VARCHAR(32) NOT NULL DEFAULT 'FILM'; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS release_year INT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS genres TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS cast_members TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS directors TEXT NOT NULL DEFAULT ''; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS imdb_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS platform_rating DOUBLE PRECISION; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS external_url TEXT; + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_item_id VARCHAR(255); + +ALTER TABLE public.films + ADD COLUMN IF NOT EXISTS jellyfin_library_id VARCHAR(255); + +ALTER TABLE public.favorites + ADD COLUMN IF NOT EXISTS watched_at TIMESTAMP; + +CREATE TABLE IF NOT EXISTS public.user_preferences ( + user_id UUID PRIMARY KEY, + weighted_genres TEXT NOT NULL DEFAULT '', + plot_types TEXT NOT NULL DEFAULT '', + eras TEXT NOT NULL DEFAULT '', + cast_and_directors TEXT NOT NULL DEFAULT '', + moods TEXT NOT NULL DEFAULT '', + content_types TEXT NOT NULL DEFAULT '', + CONSTRAINT user_preferences_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS public.film_ratings ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + score INT NOT NULL, + note VARCHAR(2048), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT film_ratings_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE, + CONSTRAINT film_ratings_score_range CHECK (score >= 1 AND score <= 10), + CONSTRAINT film_ratings_user_film_unique UNIQUE (user_id, film_id) +); + +CREATE TABLE IF NOT EXISTS public.jellyfin_sync_state ( + user_id UUID PRIMARY KEY, + last_synced_at TIMESTAMP, + last_successful_sync_at TIMESTAMP, + last_error TEXT, + synced_item_count INT NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT jellyfin_sync_state_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index b029c84..7145507 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -36,40 +36,19 @@ class FilmLibraryServiceTest { } @Test - fun `should create new film library when user has no library`() { + fun `should throw EntityNotFoundException when creating library for user with no entries`() { val userId = UUID.randomUUID() - val libraryId = UUID.randomUUID() val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - val expectedLibrary = - FilmLibrary( - id = libraryId, - userId = userId, - filmId = libraryId, - comment = "My Films", - isViewed = false, - ) every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returns libraryId - every { - filmLibraryRepository.save( - match { - it.userId == userId && it.comment == "My Films" && it.isViewed == false - }, - ) - } returns expectedLibrary - val result = filmLibraryService.create(command) - - assertNotNull(result) - assertEquals(libraryId, result.id) - assertEquals(userId, result.userId) - assertEquals(libraryId, result.filmId) - assertEquals("My Films", result.comment) + assertThrows { + filmLibraryService.create(command) + } verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 0) { filmLibraryRepository.save(any()) } } @Test From 00f53bc949d853621f5563457f059109f0cc5708 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 13:58:14 +0300 Subject: [PATCH 069/106] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=81=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0:=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B3=D0=B8?= =?UTF-8?q?=D0=B1=D1=80=D0=B8=D0=B4=D0=BD=D1=8B=D0=B9=20=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D1=84=D0=B8=D0=BB=D1=8C=D0=BC=D0=BE?= =?UTF-8?q?=D0=B2,=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B9,=20accept/reject=20endpoints,=20watchUrl=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20Jellyfin,=20API=20DTO=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BB=D0=BE=D0=B3=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B2=D1=8B=D0=B4=D0=B0=D1=87=D0=B8?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jdbc/RecommendationEventRepository.kt | 65 ++ .../adapters/web/RecommendationController.kt | 63 +- .../response/RecommendationEventResponse.kt | 27 + .../dto/response/RecommendationResponse.kt | 32 + .../ports/input/GetRecommendationsUseCase.kt | 20 + .../RecommendationEventRepositoryPort.kt | 10 + .../services/RecommendationService.kt | 563 +++++++++++++++--- .../config/JellyfinIntegrationProperties.kt | 1 + .../domain/model/RecommendationContext.kt | 1 + .../domain/model/RecommendationEvent.kt | 19 + src/main/resources/application.yaml | 1 + .../migration/V7__recommendation_events.sql | 19 + .../movienight/RecommendationSmokeTest.kt | 33 + src/test/resources/application-test.yaml | 4 + 14 files changed, 778 insertions(+), 80 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt create mode 100644 src/main/resources/db/migration/V7__recommendation_events.sql diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt new file mode 100644 index 0000000..339c05c --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -0,0 +1,65 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.UUID + +@Repository +class RecommendationEventRepository( + private val jdbc: JdbcTemplate, +) : RecommendationEventRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + RecommendationEvent( + id = UUID.fromString(rs.getString("id")), + userId = UUID.fromString(rs.getString("user_id")), + filmId = UUID.fromString(rs.getString("film_id")), + eventType = RecommendationEventType.valueOf(rs.getString("event_type")), + score = rs.getObject("score")?.let { (it as Number).toDouble() }, + createdAt = rs.getTimestamp("created_at").toLocalDateTime(), + ) + } + + override fun save(event: RecommendationEvent): RecommendationEvent { + jdbc.update( + """ + INSERT INTO recommendation_events ( + id, + user_id, + film_id, + event_type, + score, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?) + """.trimIndent(), + event.id, + event.userId, + event.filmId, + event.eventType.name, + event.score, + event.createdAt, + ) + return event + } + + override fun findByUserId(userId: UUID): List = + jdbc.query( + """ + SELECT id, + user_id, + film_id, + event_type, + score, + created_at + FROM recommendation_events + WHERE user_id = ? + ORDER BY created_at DESC + """.trimIndent(), + rowMapper, + userId, + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 8cf7823..5f306b0 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -1,34 +1,91 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse +import com.project.movienight.adapters.web.dto.response.RecommendationResponse +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.RejectRecommendationCommand +import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType -import com.project.movienight.domain.model.RecommendationResult import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RestController +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/recommendations") class RecommendationController( private val getRecommendationsUseCase: GetRecommendationsUseCase, + private val acceptRecommendationUseCase: AcceptRecommendationUseCase, + private val rejectRecommendationUseCase: RejectRecommendationUseCase, + private val jellyfinProperties: JellyfinIntegrationProperties, ) { @GetMapping fun recommend( @PathVariable userId: UUID, @RequestParam(required = false) contentType: String?, @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, @RequestParam(required = false, defaultValue = "10") limit: Int, - ): List = + ): List = getRecommendationsUseCase.recommend( RecommendationQuery( userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it) }.getOrNull() }, + contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, mood = mood, + libraryOnly = libraryOnly, limit = limit, ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } + + @PostMapping("/{filmId}/accept") + fun accept( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): RecommendationEventResponse = + RecommendationEventResponse.fromDomain( + acceptRecommendationUseCase.accept( + AcceptRecommendationCommand( + userId = userId, + filmId = filmId, + ), + ), ) + + @PostMapping("/{filmId}/reject") + fun reject( + @PathVariable userId: UUID, + @PathVariable filmId: UUID, + ): RecommendationEventResponse = + RecommendationEventResponse.fromDomain( + rejectRecommendationUseCase.reject( + RejectRecommendationCommand( + userId = userId, + filmId = filmId, + ), + ), + ) + + private fun buildWatchUrl(jellyfinItemId: String?): String? { + if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) { + return null + } + + val baseUrl = jellyfinProperties.webUrl.trimEnd('/') + val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8) + return "$baseUrl/web/#/details?id=$encodedItemId" + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt new file mode 100644 index 0000000..2b90ef8 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType +import java.time.LocalDateTime +import java.util.UUID + +data class RecommendationEventResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val eventType: RecommendationEventType, + val score: Double?, + val createdAt: LocalDateTime, +) { + companion object { + fun fromDomain(event: RecommendationEvent): RecommendationEventResponse = + RecommendationEventResponse( + id = event.id, + userId = event.userId, + filmId = event.filmId, + eventType = event.eventType, + score = event.score, + createdAt = event.createdAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt new file mode 100644 index 0000000..da78e72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt @@ -0,0 +1,32 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.RecommendationResult +import java.util.UUID + +data class RecommendationResponse( + val filmId: UUID, + val title: String, + val score: Double, + val reasons: List, + val jellyfinItemId: String?, + val watchUrl: String?, + val film: FilmResponse, +) { + companion object { + fun fromDomain( + recommendation: RecommendationResult, + watchUrl: String?, + ): RecommendationResponse { + val film = recommendation.film + return RecommendationResponse( + filmId = film.id, + title = film.title, + score = recommendation.score, + reasons = recommendation.reasons, + jellyfinItemId = film.jellyfinItemId, + watchUrl = watchUrl, + film = FilmResponse.fromDomain(film), + ) + } + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt index de9f91f..146e3bc 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt @@ -1,6 +1,7 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationEvent import com.project.movienight.domain.model.RecommendationResult import java.util.UUID @@ -12,5 +13,24 @@ data class RecommendationQuery( val userId: UUID, val contentType: ContentType? = null, val mood: String? = null, + val libraryOnly: Boolean = false, val limit: Int = 10, ) + +interface AcceptRecommendationUseCase { + fun accept(command: AcceptRecommendationCommand): RecommendationEvent +} + +data class AcceptRecommendationCommand( + val userId: UUID, + val filmId: UUID, +) + +interface RejectRecommendationUseCase { + fun reject(command: RejectRecommendationCommand): RecommendationEvent +} + +data class RejectRecommendationCommand( + val userId: UUID, + val filmId: UUID, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt new file mode 100644 index 0000000..aaf37f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.RecommendationEvent +import java.util.UUID + +interface RecommendationEventRepositoryPort { + fun save(event: RecommendationEvent): RecommendationEvent + + fun findByUserId(userId: UUID): List +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index cc6bc39..1f9b104 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -1,16 +1,33 @@ 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.RejectRecommendationCommand +import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort -import com.project.movienight.domain.model.ContentType +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.FilmRating +import com.project.movienight.domain.model.RecommendationEvent +import com.project.movienight.domain.model.RecommendationEventType import com.project.movienight.domain.model.RecommendationResult +import com.project.movienight.domain.model.UserPreferences +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.Locale +import java.util.UUID +import kotlin.math.sqrt @Service class RecommendationService( @@ -18,100 +35,492 @@ class RecommendationService( private val filmLibraryRepository: FilmLibraryRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val userRepository: UserRepositoryPort, + private val recommendationEventRepository: RecommendationEventRepositoryPort, + private val idGenerator: IdGenerator, private val businessMetricsService: BusinessMetricsService, -) : GetRecommendationsUseCase { +) : GetRecommendationsUseCase, + AcceptRecommendationUseCase, + RejectRecommendationUseCase { + private val log = LoggerFactory.getLogger(javaClass) + override fun recommend(query: RecommendationQuery): List { businessMetricsService.recordRecommendationRequest() - val preferences = userPreferencesRepository.findByUserId(query.userId) - val ratings = filmRatingRepository.findByUserId(query.userId).associateBy { it.filmId } - val watchedFilmIds = - filmLibraryRepository - .findAll() - .filter { - it.userId == query.userId && it.isViewed - }.map { it.filmId } - .toSet() + userRepository.findById(query.userId) + ?: throw EntityNotFoundException(entity = "User", id = query.userId.toString()) - return filmRepository - .findAll() - .asSequence() - .filter { film -> query.contentType == null || film.contentType == query.contentType } - .map { film -> - scoreFilm(film, query.mood, preferences, ratings[film.id] != null, watchedFilmIds.contains(film.id)) - }.sortedByDescending { it.score } - .take(query.limit.coerceAtLeast(1)) - .toList() + val preferences = userPreferencesRepository.findByUserId(query.userId) + val ratings = filmRatingRepository.findByUserId(query.userId) + val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId } + val libraryFilmIds = libraryEntries.map { it.filmId }.toSet() + val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() + val films = filmRepository.findAll() + val filmsById = films.associateBy { it.id } + val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById) + + val candidates = + films + .asSequence() + .filter { film -> query.contentType == null || film.contentType == query.contentType } + .filter { film -> film.id !in watchedFilmIds } + .filter { film -> !query.libraryOnly || film.id in libraryFilmIds } + .toList() + val recommendations = + candidates + .asSequence() + .map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) } + .sortedWith(compareByDescending { it.score }.thenBy { it.film.title }) + .take(query.limit.coerceAtLeast(1)) + .toList() + + recommendations.forEach { recommendation -> + saveEvent( + userId = query.userId, + filmId = recommendation.film.id, + eventType = RecommendationEventType.RECOMMENDED, + score = recommendation.score, + ) + } + + log.info( + RECOMMENDATION_COMPLETED_LOG, + query.userId, + query.contentType, + !query.mood.isNullOrBlank(), + query.libraryOnly, + query.limit, + candidates.size, + recommendations.size, + ) + if (log.isDebugEnabled) { + log.debug( + "Recommendation top results: userId='{}', results='{}'", + query.userId, + recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" }, + ) + } + + return recommendations + } + + override fun accept(command: AcceptRecommendationCommand): RecommendationEvent = + saveFeedbackEvent( + userId = command.userId, + filmId = command.filmId, + eventType = RecommendationEventType.ACCEPTED, + ) + + override fun reject(command: RejectRecommendationCommand): RecommendationEvent = + saveFeedbackEvent( + userId = command.userId, + filmId = command.filmId, + eventType = RecommendationEventType.REJECTED, + ) + + private fun saveFeedbackEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + ): RecommendationEvent { + userRepository.findById(userId) + ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + filmRepository.findById(filmId) + ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + + val event = + saveEvent( + userId = userId, + filmId = filmId, + eventType = eventType, + score = null, + ) + + log.info( + RECOMMENDATION_FEEDBACK_SAVED_LOG, + userId, + filmId, + eventType, + ) + + return event + } + + private fun saveEvent( + userId: UUID, + filmId: UUID, + eventType: RecommendationEventType, + score: Double?, + ): RecommendationEvent = + recommendationEventRepository.save( + RecommendationEvent( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + eventType = eventType, + score = score, + createdAt = LocalDateTime.now(), + ), + ) + + private fun buildUserProfile( + preferences: UserPreferences?, + ratings: List, + libraryEntries: List, + filmsById: Map, + ): SparseVector { + val profile = MutableSparseVector() + + preferences?.weightedGenres.orEmpty().forEach { (genre, weight) -> + profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) + } + preferences?.plotTypes.orEmpty().forEach { plotType -> + tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } + } + preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } + preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) } + preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } + preferences?.contentTypes.orEmpty().forEach { profile.add(feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT) } + + ratings.forEach { rating -> + val film = filmsById[rating.filmId] ?: return@forEach + val signal = ratingSignal(rating.score) + profile.add(buildFilmVector(film).scale(signal)) + } + + libraryEntries.filterNot { it.isViewed }.forEach { entry -> + val film = filmsById[entry.filmId] ?: return@forEach + profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT)) + } + + return profile.toSparseVector() } private fun scoreFilm( film: Film, - mood: String?, - preferences: com.project.movienight.domain.model.UserPreferences?, - hasUserRating: Boolean, - watched: Boolean, + query: RecommendationQuery, + preferences: UserPreferences?, + userProfile: SparseVector, + inLibrary: Boolean, ): RecommendationResult { - var score = 0.0 val reasons = mutableListOf() + val filmVector = buildFilmVector(film) + val preferenceScore = cosineSimilarity(userProfile, filmVector) + val qualityScore = qualityScore(film) + val contextScore = contextScore(film, query, preferences) + val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE + val diversityScore = diversityScore(film, preferences) + val score = + RELEVANCE_WEIGHT * preferenceScore + + QUALITY_WEIGHT * qualityScore + + CONTEXT_WEIGHT * contextScore + + NOVELTY_WEIGHT * noveltyScore + + DIVERSITY_WEIGHT * diversityScore - preferences?.contentTypes?.let { - if (it.isEmpty() || it.contains(film.contentType)) { - score += 2.0 - reasons += "Matches content preference" + if (preferenceScore > STRONG_REASON_THRESHOLD) { + reasons += "Similar to user preferences and rating history" + } + matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> + reasons += "Matches preferred genre: $genre" + } + matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person -> + reasons += "Matches preferred cast or director: $person" + } + query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood -> + reasons += "Matches requested mood: $mood" + } + film.releaseYear?.let { year -> + if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) { + reasons += "Matches preferred era: ${decadeOf(year)}" } } - - preferences?.weightedGenres?.forEach { (genre, weight) -> - if (film.genres.any { it.equals(genre, ignoreCase = true) }) { - score += weight - reasons += "Matches genre $genre" - } + if (qualityScore >= QUALITY_REASON_THRESHOLD) { + reasons += "High rating signal" } - - preferences?.castAndDirectors?.forEach { favorite -> - val found = - film.cast.any { it.equals(favorite, ignoreCase = true) } || - film.directors.any { it.equals(favorite, ignoreCase = true) } - if (found) { - score += 1.5 - reasons += "Matches favorite creator or cast member $favorite" - } - } - - preferences?.moods?.forEach { preferredMood -> - if (mood != null && preferredMood.equals(mood, ignoreCase = true)) { - score += 1.25 - reasons += "Matches requested mood $mood" - } - } - - film.imdbRating?.let { - score += it / 2.0 - reasons += "Strong IMDb signal" - } - - film.platformRating?.let { - score += it - reasons += "Strong platform signal" - } - - if (hasUserRating) { - score += 2.0 - reasons += "User has already rated similar content" - } - - if (watched) { - score -= 3.0 - reasons += "Already watched" - } - - if (mood != null && film.title.contains(mood, ignoreCase = true)) { - score += 0.5 + if (inLibrary) { + reasons += "Already in user library" } if (reasons.isEmpty()) { - reasons += "Baseline recommendation from library catalog" + reasons += "Baseline recommendation from catalog quality" } - return RecommendationResult(film = film, score = score, reasons = reasons) + return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()) + } + + private fun buildFilmVector(film: Film): SparseVector { + val vector = MutableSparseVector() + val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } + val plotTokens = tokenize("${film.title} ${film.description}") + val moods = inferredMoods(film) + val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } + + vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT) + distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT) + distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT) + distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT) + film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) } + distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT) + + return vector.toSparseVector() + } + + private fun contextScore( + film: Film, + query: RecommendationQuery, + preferences: UserPreferences?, + ): Double { + var score = 0.0 + var checks = 0 + + query.mood?.let { + checks += 1 + if (inferredMoods(film).contains(normalize(it))) { + score += 1.0 + } + } + preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let { + checks += 1 + if (film.contentType in it) { + score += 1.0 + } + } + preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras -> + film.releaseYear?.let { + checks += 1 + if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) { + score += 1.0 + } + } + } + + return if (checks == 0) BASE_CONTEXT_SCORE else score / checks + } + + private fun qualityScore(film: Film): Double { + val normalizedRatings = + listOfNotNull( + film.imdbRating?.let { normalizeRating(it) }, + film.platformRating?.let { normalizeRating(it) }, + ) + return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE + } + + private fun diversityScore( + film: Film, + preferences: UserPreferences?, + ): Double { + val preferredGenres = preferences?.weightedGenres.orEmpty().keys.map(::normalize).toSet() + val filmGenres = film.genres.map(::normalize).toSet() + return when { + preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE + filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE + filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE + else -> LOW_DIVERSITY_SCORE + } + } + + private fun inferredMoods(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return moodLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun matchingGenres( + film: Film, + preferences: UserPreferences?, + ): List { + val filmGenres = film.genres.associateBy { normalize(it) } + return preferences + ?.weightedGenres + .orEmpty() + .keys + .map(::normalize) + .mapNotNull { filmGenres[it] } + } + + private fun matchingPeople( + film: Film, + preferences: UserPreferences?, + ): List { + val people = (film.cast + film.directors).associateBy { normalize(it) } + return preferences + ?.castAndDirectors + .orEmpty() + .map(::normalize) + .mapNotNull { people[it] } + } + + private fun distribute( + vector: MutableSparseVector, + namespace: String, + values: Collection, + totalWeight: Double, + ) { + val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct() + if (uniqueValues.isEmpty()) { + return + } + val itemWeight = totalWeight / uniqueValues.size + uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) } + } + + private fun ratingSignal(score: Int): Double = + when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) { + 10 -> 1.0 + 9 -> 0.9 + 8 -> 0.7 + 7 -> 0.4 + 6 -> 0.1 + 5 -> 0.0 + 4 -> -0.3 + 3 -> -0.5 + 2 -> -0.8 + else -> -1.0 + } + + private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0) + + private fun decadeOf(year: Int): String = "${year / 10 * 10}s" + + private fun tokenize(text: String): List = + normalize(text) + .split(tokenSeparatorRegex) + .asSequence() + .filter { it.length >= MIN_TOKEN_LENGTH } + .filterNot { it in stopWords } + .distinct() + .toList() + + private fun feature( + namespace: String, + value: String, + ): String = "$namespace:${normalize(value)}" + + private fun normalize(value: String): String = + value + .trim() + .lowercase(Locale.getDefault()) + + private fun cosineSimilarity( + left: SparseVector, + right: SparseVector, + ): Double { + if (left.values.isEmpty() || right.values.isEmpty()) { + return 0.0 + } + + val dot = + left.values + .entries + .sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) } + val leftNorm = sqrt(left.values.values.sumOf { it * it }) + val rightNorm = sqrt(right.values.values.sumOf { it * it }) + if (leftNorm == 0.0 || rightNorm == 0.0) { + return 0.0 + } + + return dot / (leftNorm * rightNorm) + } + + private fun roundScore(score: Double): Double = kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR + + private fun Iterable.averageOrNull(): Double? { + val values = toList() + return values.takeIf { it.isNotEmpty() }?.average() + } + + private data class SparseVector( + val values: Map, + ) { + fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight }) + } + + private class MutableSparseVector { + private val values = mutableMapOf() + + fun add( + feature: String, + weight: Double, + ) { + if (weight == 0.0) { + return + } + values[feature] = (values[feature] ?: 0.0) + weight + } + + fun add(vector: SparseVector) { + vector.values.forEach { (feature, weight) -> add(feature, weight) } + } + + fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 }) + } + + private companion object { + private const val RECOMMENDATION_COMPLETED_LOG = + "Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " + + "libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}" + private const val RECOMMENDATION_FEEDBACK_SAVED_LOG = + "Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'" + + private const val MAX_PREFERENCE_WEIGHT = 5.0 + private const val MAX_RATING_VALUE = 10.0 + private const val MIN_USER_RATING = 1 + private const val MAX_USER_RATING = 10 + private const val MIN_TOKEN_LENGTH = 3 + private const val MAX_REASON_ITEMS = 2 + private const val SCORE_ROUNDING_FACTOR = 1000.0 + + private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05 + private const val GENRE_VECTOR_WEIGHT = 0.25 + private const val PLOT_VECTOR_WEIGHT = 0.35 + private const val MOOD_VECTOR_WEIGHT = 0.15 + private const val ERA_VECTOR_WEIGHT = 0.10 + private const val PEOPLE_VECTOR_WEIGHT = 0.10 + + private const val PREFERENCE_PLOT_WEIGHT = 0.6 + private const val PREFERENCE_ERA_WEIGHT = 0.7 + private const val PREFERENCE_PERSON_WEIGHT = 0.8 + private const val PREFERENCE_MOOD_WEIGHT = 0.8 + private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 + private const val LIBRARY_SIGNAL_WEIGHT = 0.25 + + private const val RELEVANCE_WEIGHT = 0.55 + private const val QUALITY_WEIGHT = 0.15 + private const val CONTEXT_WEIGHT = 0.10 + private const val NOVELTY_WEIGHT = 0.10 + private const val DIVERSITY_WEIGHT = 0.10 + + private const val LIBRARY_NOVELTY_SCORE = 0.85 + private const val CATALOG_NOVELTY_SCORE = 0.65 + private const val BASE_CONTEXT_SCORE = 0.5 + private const val BASE_QUALITY_SCORE = 0.5 + private const val BASE_DIVERSITY_SCORE = 0.5 + private const val HIGH_DIVERSITY_SCORE = 1.0 + private const val MEDIUM_DIVERSITY_SCORE = 0.6 + private const val LOW_DIVERSITY_SCORE = 0.3 + private const val STRONG_REASON_THRESHOLD = 0.15 + private const val QUALITY_REASON_THRESHOLD = 0.75 + + private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") + private val stopWords = + setOf( + "and", + "the", + "for", + "with", + "about", + "into", + "from", + ) + private val moodLexicon = + mapOf( + "tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"), + "slow-burn" to listOf("slow", "meditative", "grounded"), + "feel-good" to listOf("comedy", "family", "summer", "kind", "warm"), + "dark" to listOf("dark", "noir", "horror", "murder", "crime"), + "romantic" to listOf("romance", "love", "relationship"), + "focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"), + ) } } diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index 5a59400..a4dc555 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -6,6 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class JellyfinIntegrationProperties( val enabled: Boolean = false, val baseUrl: String = "", + val webUrl: String = "", val apiKey: String = "", val syncIntervalMs: Long = 1_800_000, val requestTimeoutMs: Long = 20_000, diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt index 1049513..142754a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt @@ -6,6 +6,7 @@ data class RecommendationContext( val userId: UUID, val contentType: ContentType? = null, val mood: String? = null, + val libraryOnly: Boolean = false, val limit: Int = 10, ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt new file mode 100644 index 0000000..aff907d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -0,0 +1,19 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class RecommendationEvent( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val eventType: RecommendationEventType, + val score: Double? = null, + val createdAt: LocalDateTime = LocalDateTime.now(), +) + +enum class RecommendationEventType { + RECOMMENDED, + ACCEPTED, + REJECTED, +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9f562dc..c2af423 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -99,6 +99,7 @@ integrations: jellyfin: enabled: ${JELLYFIN_SYNC_ENABLED:false} base-url: ${JELLYFIN_BASE_URL:} + web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} api-key: ${JELLYFIN_API_KEY:} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} diff --git a/src/main/resources/db/migration/V7__recommendation_events.sql b/src/main/resources/db/migration/V7__recommendation_events.sql new file mode 100644 index 0000000..3ecd8ba --- /dev/null +++ b/src/main/resources/db/migration/V7__recommendation_events.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS public.recommendation_events ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + film_id UUID NOT NULL, + event_type VARCHAR(64) NOT NULL, + score DOUBLE PRECISION, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT recommendation_events_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE, + CONSTRAINT recommendation_events_film_fk FOREIGN KEY (film_id) REFERENCES public.films(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_created + ON public.recommendation_events(user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_film + ON public.recommendation_events(film_id); + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_type + ON public.recommendation_events(event_type); diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index a7ab288..25f0211 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -76,6 +76,7 @@ class RecommendationSmokeTest { imdbRating = 8.7, platformRating = 9.0, externalUrl = "https://example.com/orbital-drift", + jellyfinItemId = "orbital-drift-item", ), ) }.andExpect { @@ -154,11 +155,43 @@ class RecommendationSmokeTest { param("limit", "2") }.andExpect { status { isOk() } + jsonPath("$[0].filmId") { value(firstFilmId.toString()) } jsonPath("$[0].film.id") { value(firstFilmId.toString()) } + jsonPath("$[0].watchUrl") { + value("https://jellyfin.example.test/web/#/details?id=orbital-drift-item") + } + jsonPath("$[0].reasons[0]") { exists() } + } + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/accept") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("ACCEPTED") } + } + + mockMvc + .post("/api/users/$userId/recommendations/$firstFilmId/reject") + .andExpect { + status { isOk() } + jsonPath("$.filmId") { value(firstFilmId.toString()) } + jsonPath("$.eventType") { value("REJECTED") } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("libraryOnly", "true") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$") { isEmpty() } } } private fun cleanDatabase() { + jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM film_ratings") jdbcTemplate.execute("DELETE FROM user_preferences") jdbcTemplate.execute("DELETE FROM favorites") diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 9aabcb5..03e6517 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -26,3 +26,7 @@ services: - censored - epstein - python + +integrations: + jellyfin: + web-url: https://jellyfin.example.test From a615450896afc0c332282fe32cb058b0f74fab5f Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 14:57:41 +0300 Subject: [PATCH 070/106] =?UTF-8?q?=D0=9F=D0=BE=D1=84=D0=B8=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D0=BB=20=D0=BF=D1=80=D0=BE=D0=B1=D0=BB=D0=B5=D0=BC=D1=8B?= =?UTF-8?q?=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapters/web/RecommendationController.kt | 31 ++++++++++--------- .../services/RecommendationService.kt | 23 +++++++++++--- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 5f306b0..6d5bdd9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -5,9 +5,9 @@ import com.project.movienight.adapters.web.dto.response.RecommendationResponse 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.input.RecommendationQuery import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType import org.springframework.web.bind.annotation.GetMapping @@ -36,20 +36,21 @@ class RecommendationController( @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, @RequestParam(required = false, defaultValue = "10") limit: Int, ): List = - getRecommendationsUseCase.recommend( - RecommendationQuery( - userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, - mood = mood, - libraryOnly = libraryOnly, - limit = limit, - ), - ).map { recommendation -> - RecommendationResponse.fromDomain( - recommendation = recommendation, - watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), - ) - } + getRecommendationsUseCase + .recommend( + RecommendationQuery( + userId = userId, + contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, + mood = mood, + libraryOnly = libraryOnly, + limit = limit, + ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } @PostMapping("/{filmId}/accept") fun accept( diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index 1f9b104..cff99d2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -4,9 +4,9 @@ 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.input.RecommendationQuery import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort @@ -179,7 +179,15 @@ class RecommendationService( preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) } preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } - preferences?.contentTypes.orEmpty().forEach { profile.add(feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT) } + preferences + ?.contentTypes + .orEmpty() + .forEach { + profile.add( + feature("type", it.name), + PREFERENCE_CONTENT_TYPE_WEIGHT, + ) + } ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach @@ -309,7 +317,13 @@ class RecommendationService( film: Film, preferences: UserPreferences?, ): Double { - val preferredGenres = preferences?.weightedGenres.orEmpty().keys.map(::normalize).toSet() + val preferredGenres = + preferences + ?.weightedGenres + .orEmpty() + .keys + .map(::normalize) + .toSet() val filmGenres = film.genres.map(::normalize).toSet() return when { preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE @@ -423,7 +437,8 @@ class RecommendationService( return dot / (leftNorm * rightNorm) } - private fun roundScore(score: Double): Double = kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR + private fun roundScore(score: Double): Double = + kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR private fun Iterable.averageOrNull(): Double? { val values = toList() From 0dca1f70318347eb409cbfdb042466d68730cc92 Mon Sep 17 00:00:00 2001 From: skettiks Date: Thu, 21 May 2026 17:03:33 +0300 Subject: [PATCH 071/106] =?UTF-8?q?-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=81=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B5=D0=B9=20=D0=B8=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=D0=BC=D0=B8=20-=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BC?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20user=5Frecommendation=5Fweights=20=D0=B8=20breakdown-=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=D0=B9=20recommendation=5Fevents=20-=20=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=82=D0=B5=D0=BF=D0=B5=D1=80=D1=8C=20=D0=B8=D1=81?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D1=8E=D1=82=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B5=20score/vector=20=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20-=20feedback=20ACCEPTED/REJECTED=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20score-=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8F=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D1=81=D0=BB?= =?UTF-8?q?=D0=B5=D0=B4=D0=BD=D0=B5=D0=B9=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B8=20-=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=20API=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20=D1=87=D1=82=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D1=80=D1=83?= =?UTF-8?q?=D1=87=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B9=20-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20onboarding=20endpoint=20=D0=B4=D0=BB=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B1=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8F=20?= =?UTF-8?q?-=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20?= =?UTF-8?q?=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9:=20balanced,=20quali?= =?UTF-8?q?ty=20first,=20mood=20first,=20discovery,=20similar=20to=20favor?= =?UTF-8?q?ites=20-=20onboarding=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=B8=D1=8F,=20=D0=BB=D0=B0=D0=B9=D0=BA?= =?UTF-8?q?=D0=B8/=D0=B4=D0=B8=D0=B7=D0=BB=D0=B0=D0=B9=D0=BA=D0=B8,=20?= =?UTF-8?q?=D0=B1=D0=B8=D0=B1=D0=BB=D0=B8=D0=BE=D1=82=D0=B5=D0=BA=D1=83,?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D1=84=D0=B8=D0=BB=D1=8C=D0=BC=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=BE=D0=B2=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B2=D0=B5=D1=81=D0=B0=20-=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=BC=D0=B5=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D0=B5=D1=81=D0=BE=D0=B2=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?smoke/unit=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/BusinessMetricsService.kt | 11 +- .../jdbc/RecommendationEventRepository.kt | 53 +++- .../UserRecommendationWeightsRepository.kt | 130 ++++++++++ .../web/RecommendationOnboardingController.kt | 52 ++++ .../UserRecommendationWeightsController.kt | 53 ++++ .../RecommendationOnboardingRequest.kt | 17 ++ .../UpdateUserRecommendationWeightsRequest.kt | 15 ++ .../response/RecommendationEventResponse.kt | 10 + .../RecommendationOnboardingResponse.kt | 27 ++ .../UserRecommendationWeightsResponse.kt | 40 +++ .../input/RecommendationOnboardingUseCase.kt | 36 +++ .../input/UserRecommendationWeightsUseCase.kt | 27 ++ .../RecommendationEventRepositoryPort.kt | 5 + ...UserRecommendationWeightsRepositoryPort.kt | 10 + .../RecommendationOnboardingService.kt | 150 +++++++++++ .../services/RecommendationService.kt | 219 ++++++++++++---- .../UserRecommendationWeightsService.kt | 51 ++++ .../domain/model/RecommendationEvent.kt | 5 + .../domain/model/RecommendationStyle.kt | 9 + .../domain/model/UserRecommendationWeights.kt | 233 ++++++++++++++++++ .../V8__user_recommendation_weights.sql | 35 +++ .../movienight/RecommendationSmokeTest.kt | 208 ++++++++++++++++ .../model/UserRecommendationWeightsTest.kt | 74 ++++++ 23 files changed, 1425 insertions(+), 45 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt create mode 100644 src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt create mode 100644 src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt create mode 100644 src/main/resources/db/migration/V8__user_recommendation_weights.sql create mode 100644 src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index 6a8f2d0..b3912f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -1,6 +1,7 @@ package com.project.movienight.adapters.metrics import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.RecommendationEventType import io.micrometer.core.instrument.Counter import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.Timer @@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger @Service class BusinessMetricsService( - meterRegistry: MeterRegistry, + private val meterRegistry: MeterRegistry, ) { private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") @@ -36,6 +37,14 @@ class BusinessMetricsService( recommendationRequests.increment() } + fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) { + Counter + .builder("recommendation_weights_updated_total") + .tag("eventType", eventType.name) + .register(meterRegistry) + .increment() + } + fun recordRatingSubmitted() { ratingsSubmitted.increment() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt index 339c05c..ab0f0f8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt @@ -19,6 +19,11 @@ class RecommendationEventRepository( filmId = UUID.fromString(rs.getString("film_id")), eventType = RecommendationEventType.valueOf(rs.getString("event_type")), score = rs.getObject("score")?.let { (it as Number).toDouble() }, + relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() }, + qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() }, + contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() }, + noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() }, + diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() }, createdAt = rs.getTimestamp("created_at").toLocalDateTime(), ) } @@ -32,15 +37,25 @@ class RecommendationEventRepository( film_id, event_type, score, + relevance_score, + quality_score, + context_score, + novelty_score, + diversity_score, created_at ) - VALUES (?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent(), event.id, event.userId, event.filmId, event.eventType.name, event.score, + event.relevanceScore, + event.qualityScore, + event.contextScore, + event.noveltyScore, + event.diversityScore, event.createdAt, ) return event @@ -54,6 +69,11 @@ class RecommendationEventRepository( film_id, event_type, score, + relevance_score, + quality_score, + context_score, + novelty_score, + diversity_score, created_at FROM recommendation_events WHERE user_id = ? @@ -62,4 +82,35 @@ class RecommendationEventRepository( rowMapper, userId, ) + + override fun findLatestRecommended( + userId: UUID, + filmId: UUID, + ): RecommendationEvent? = + jdbc + .query( + """ + SELECT id, + user_id, + film_id, + event_type, + score, + relevance_score, + quality_score, + context_score, + novelty_score, + diversity_score, + created_at + FROM recommendation_events + WHERE user_id = ? + AND film_id = ? + AND event_type = ? + ORDER BY created_at DESC + LIMIT 1 + """.trimIndent(), + rowMapper, + userId, + filmId, + RecommendationEventType.RECOMMENDED.name, + ).firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt new file mode 100644 index 0000000..c549925 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt @@ -0,0 +1,130 @@ +package com.project.movienight.adapters.persistence.jdbc + +import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.time.LocalDateTime +import java.util.UUID + +@Repository +class UserRecommendationWeightsRepository( + private val jdbc: JdbcTemplate, +) : UserRecommendationWeightsRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + UserRecommendationWeights( + userId = UUID.fromString(rs.getString("user_id")), + relevanceWeight = rs.getDouble("relevance_weight"), + qualityWeight = rs.getDouble("quality_weight"), + contextWeight = rs.getDouble("context_weight"), + noveltyWeight = rs.getDouble("novelty_weight"), + diversityWeight = rs.getDouble("diversity_weight"), + genreVectorWeight = rs.getDouble("genre_vector_weight"), + plotVectorWeight = rs.getDouble("plot_vector_weight"), + moodVectorWeight = rs.getDouble("mood_vector_weight"), + eraVectorWeight = rs.getDouble("era_vector_weight"), + peopleVectorWeight = rs.getDouble("people_vector_weight"), + contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"), + updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(), + ) + } + + override fun findByUserId(userId: UUID): UserRecommendationWeights? = + jdbc + .query( + """ + SELECT user_id, + relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight, + genre_vector_weight, + plot_vector_weight, + mood_vector_weight, + era_vector_weight, + people_vector_weight, + content_type_vector_weight, + updated_at + FROM user_recommendation_weights + WHERE user_id = ? + """.trimIndent(), + rowMapper, + userId, + ).firstOrNull() + + override fun save(weights: UserRecommendationWeights): UserRecommendationWeights { + val normalized = weights.normalized(updatedAt = LocalDateTime.now()) + val updatedRows = + jdbc.update( + """ + UPDATE user_recommendation_weights + SET relevance_weight = ?, + quality_weight = ?, + context_weight = ?, + novelty_weight = ?, + diversity_weight = ?, + genre_vector_weight = ?, + plot_vector_weight = ?, + mood_vector_weight = ?, + era_vector_weight = ?, + people_vector_weight = ?, + content_type_vector_weight = ?, + updated_at = ? + WHERE user_id = ? + """.trimIndent(), + normalized.relevanceWeight, + normalized.qualityWeight, + normalized.contextWeight, + normalized.noveltyWeight, + normalized.diversityWeight, + normalized.genreVectorWeight, + normalized.plotVectorWeight, + normalized.moodVectorWeight, + normalized.eraVectorWeight, + normalized.peopleVectorWeight, + normalized.contentTypeVectorWeight, + normalized.updatedAt, + normalized.userId, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO user_recommendation_weights ( + user_id, + relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight, + genre_vector_weight, + plot_vector_weight, + mood_vector_weight, + era_vector_weight, + people_vector_weight, + content_type_vector_weight, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + normalized.userId, + normalized.relevanceWeight, + normalized.qualityWeight, + normalized.contextWeight, + normalized.noveltyWeight, + normalized.diversityWeight, + normalized.genreVectorWeight, + normalized.plotVectorWeight, + normalized.moodVectorWeight, + normalized.eraVectorWeight, + normalized.peopleVectorWeight, + normalized.contentTypeVectorWeight, + normalized.updatedAt, + ) + } + + return normalized + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt new file mode 100644 index 0000000..0ff38f6 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt @@ -0,0 +1,52 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationStyle +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.RestController +import java.util.Locale +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendation-onboarding") +class RecommendationOnboardingController( + private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase, +) { + @PostMapping + fun complete( + @PathVariable userId: UUID, + @RequestBody request: RecommendationOnboardingRequest, + ): RecommendationOnboardingResponse = + RecommendationOnboardingResponse.fromApplication( + completeRecommendationOnboardingUseCase.complete( + CompleteRecommendationOnboardingCommand( + userId = userId, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = request.contentTypes.mapNotNull(::parseContentType), + likedFilmIds = request.likedFilmIds, + dislikedFilmIds = request.dislikedFilmIds, + libraryFilmIds = request.libraryFilmIds, + watchedFilmIds = request.watchedFilmIds, + recommendationStyle = parseRecommendationStyle(request.recommendationStyle), + ), + ), + ) + + private fun parseContentType(value: String): ContentType? = + runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull() + + private fun parseRecommendationStyle(value: String): RecommendationStyle = + runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) } + .getOrDefault(RecommendationStyle.BALANCED) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt new file mode 100644 index 0000000..4c5dc1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt @@ -0,0 +1,53 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest +import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse +import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/users/{userId}/recommendation-weights") +class UserRecommendationWeightsController( + private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase, + private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase, +) { + @GetMapping + fun get( + @PathVariable userId: UUID, + ): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse.fromDomain( + getUserRecommendationWeightsUseCase.get(userId), + ) + + @PutMapping + fun update( + @PathVariable userId: UUID, + @RequestBody request: UpdateUserRecommendationWeightsRequest, + ): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse.fromDomain( + updateUserRecommendationWeightsUseCase.update( + UpdateUserRecommendationWeightsCommand( + userId = userId, + relevanceWeight = request.relevanceWeight, + qualityWeight = request.qualityWeight, + contextWeight = request.contextWeight, + noveltyWeight = request.noveltyWeight, + diversityWeight = request.diversityWeight, + genreVectorWeight = request.genreVectorWeight, + plotVectorWeight = request.plotVectorWeight, + moodVectorWeight = request.moodVectorWeight, + eraVectorWeight = request.eraVectorWeight, + peopleVectorWeight = request.peopleVectorWeight, + contentTypeVectorWeight = request.contentTypeVectorWeight, + ), + ), + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt new file mode 100644 index 0000000..0480531 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt @@ -0,0 +1,17 @@ +package com.project.movienight.adapters.web.dto.request + +import java.util.UUID + +data class RecommendationOnboardingRequest( + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: String = "BALANCED", +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt new file mode 100644 index 0000000..3c0846b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt @@ -0,0 +1,15 @@ +package com.project.movienight.adapters.web.dto.request + +data class UpdateUserRecommendationWeightsRequest( + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt index 2b90ef8..4fc12ca 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt @@ -11,6 +11,11 @@ data class RecommendationEventResponse( val filmId: UUID, val eventType: RecommendationEventType, val score: Double?, + val relevanceScore: Double?, + val qualityScore: Double?, + val contextScore: Double?, + val noveltyScore: Double?, + val diversityScore: Double?, val createdAt: LocalDateTime, ) { companion object { @@ -21,6 +26,11 @@ data class RecommendationEventResponse( filmId = event.filmId, eventType = event.eventType, score = event.score, + relevanceScore = event.relevanceScore, + qualityScore = event.qualityScore, + contextScore = event.contextScore, + noveltyScore = event.noveltyScore, + diversityScore = event.diversityScore, createdAt = event.createdAt, ) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt new file mode 100644 index 0000000..6cd2810 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt @@ -0,0 +1,27 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.application.ports.input.RecommendationOnboardingResult +import java.util.UUID + +data class RecommendationOnboardingResponse( + val userId: UUID, + val preferences: UserPreferencesResponse, + val weights: UserRecommendationWeightsResponse, + val likedFilmsCount: Int, + val dislikedFilmsCount: Int, + val libraryFilmsCount: Int, + val watchedFilmsCount: Int, +) { + companion object { + fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse = + RecommendationOnboardingResponse( + userId = result.userId, + preferences = UserPreferencesResponse.fromDomain(result.preferences), + weights = UserRecommendationWeightsResponse.fromDomain(result.weights), + likedFilmsCount = result.likedFilmsCount, + dislikedFilmsCount = result.dislikedFilmsCount, + libraryFilmsCount = result.libraryFilmsCount, + watchedFilmsCount = result.watchedFilmsCount, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt new file mode 100644 index 0000000..0b22037 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt @@ -0,0 +1,40 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.time.LocalDateTime +import java.util.UUID + +data class UserRecommendationWeightsResponse( + val userId: UUID, + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, + val updatedAt: LocalDateTime, +) { + companion object { + fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse = + UserRecommendationWeightsResponse( + userId = weights.userId, + relevanceWeight = weights.relevanceWeight, + qualityWeight = weights.qualityWeight, + contextWeight = weights.contextWeight, + noveltyWeight = weights.noveltyWeight, + diversityWeight = weights.diversityWeight, + genreVectorWeight = weights.genreVectorWeight, + plotVectorWeight = weights.plotVectorWeight, + moodVectorWeight = weights.moodVectorWeight, + eraVectorWeight = weights.eraVectorWeight, + peopleVectorWeight = weights.peopleVectorWeight, + contentTypeVectorWeight = weights.contentTypeVectorWeight, + updatedAt = weights.updatedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt new file mode 100644 index 0000000..0d54caf --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt @@ -0,0 +1,36 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.ContentType +import com.project.movienight.domain.model.RecommendationStyle +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface CompleteRecommendationOnboardingUseCase { + fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult +} + +data class CompleteRecommendationOnboardingCommand( + val userId: UUID, + val weightedGenres: Map = emptyMap(), + val plotTypes: List = emptyList(), + val eras: List = emptyList(), + val castAndDirectors: List = emptyList(), + val moods: List = emptyList(), + val contentTypes: List = emptyList(), + val likedFilmIds: List = emptyList(), + val dislikedFilmIds: List = emptyList(), + val libraryFilmIds: List = emptyList(), + val watchedFilmIds: List = emptyList(), + val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED, +) + +data class RecommendationOnboardingResult( + val userId: UUID, + val preferences: UserPreferences, + val weights: UserRecommendationWeights, + val likedFilmsCount: Int, + val dislikedFilmsCount: Int, + val libraryFilmsCount: Int, + val watchedFilmsCount: Int, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt new file mode 100644 index 0000000..9bfaa6b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt @@ -0,0 +1,27 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface GetUserRecommendationWeightsUseCase { + fun get(userId: UUID): UserRecommendationWeights +} + +interface UpdateUserRecommendationWeightsUseCase { + fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights +} + +data class UpdateUserRecommendationWeightsCommand( + val userId: UUID, + val relevanceWeight: Double, + val qualityWeight: Double, + val contextWeight: Double, + val noveltyWeight: Double, + val diversityWeight: Double, + val genreVectorWeight: Double, + val plotVectorWeight: Double, + val moodVectorWeight: Double, + val eraVectorWeight: Double, + val peopleVectorWeight: Double, + val contentTypeVectorWeight: Double, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt index aaf37f6..5903a4c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt @@ -7,4 +7,9 @@ interface RecommendationEventRepositoryPort { fun save(event: RecommendationEvent): RecommendationEvent fun findByUserId(userId: UUID): List + + fun findLatestRecommended( + userId: UUID, + filmId: UUID, + ): RecommendationEvent? } diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt new file mode 100644 index 0000000..f4bade2 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt @@ -0,0 +1,10 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.UserRecommendationWeights +import java.util.UUID + +interface UserRecommendationWeightsRepositoryPort { + fun findByUserId(userId: UUID): UserRecommendationWeights? + + fun save(weights: UserRecommendationWeights): UserRecommendationWeights +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt new file mode 100644 index 0000000..8f9fe04 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -0,0 +1,150 @@ +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.FilmRatingRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +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.FilmRating +import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.util.UUID + +@Service +class RecommendationOnboardingService( + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val userPreferencesRepository: UserPreferencesRepositoryPort, + private val filmRatingRepository: FilmRatingRepositoryPort, + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val idGenerator: IdGenerator, +) : CompleteRecommendationOnboardingUseCase { + override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult { + userRepository.findById(command.userId) + ?: throw EntityNotFoundException(entity = "User", id = command.userId.toString()) + + val filmIds = + ( + command.likedFilmIds + + command.dislikedFilmIds + + command.libraryFilmIds + + command.watchedFilmIds + ).distinct() + ensureFilmsExist(filmIds) + + val preferences = + userPreferencesRepository.save( + UserPreferences( + userId = command.userId, + weightedGenres = command.weightedGenres, + plotTypes = command.plotTypes, + eras = command.eras, + castAndDirectors = command.castAndDirectors, + moods = command.moods, + contentTypes = command.contentTypes, + ), + ) + + command.likedFilmIds.distinct().forEach { filmId -> + saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE) + } + command.dislikedFilmIds.distinct().forEach { filmId -> + saveRating( + userId = command.userId, + filmId = filmId, + score = DISLIKED_SCORE, + note = ONBOARDING_DISLIKED_NOTE, + ) + } + command.libraryFilmIds.distinct().forEach { filmId -> + saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false) + } + command.watchedFilmIds.distinct().forEach { filmId -> + saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true) + } + + val weights = + userRecommendationWeightsRepository.save( + UserRecommendationWeights.forStyle( + userId = command.userId, + style = command.recommendationStyle, + ), + ) + + return RecommendationOnboardingResult( + userId = command.userId, + preferences = preferences, + weights = weights, + likedFilmsCount = command.likedFilmIds.distinct().size, + dislikedFilmsCount = command.dislikedFilmIds.distinct().size, + libraryFilmsCount = command.libraryFilmIds.distinct().size, + watchedFilmsCount = command.watchedFilmIds.distinct().size, + ) + } + + private fun ensureFilmsExist(filmIds: List) { + filmIds.forEach { filmId -> + filmRepository.findById(filmId) + ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + } + } + + private fun saveRating( + userId: UUID, + filmId: UUID, + score: Int, + note: String, + ): FilmRating { + val now = LocalDateTime.now() + val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId) + return filmRatingRepository.save( + existing?.copy(score = score, note = note, updatedAt = now) + ?: FilmRating( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + score = score, + note = note, + createdAt = now, + updatedAt = now, + ), + ) + } + + private fun saveLibraryEntry( + userId: UUID, + filmId: UUID, + isViewed: Boolean, + ): FilmLibrary { + val watchedAt = LocalDateTime.now().takeIf { isViewed } + val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId) + return filmLibraryRepository.save( + existing?.copy(isViewed = isViewed, watchedAt = watchedAt) + ?: FilmLibrary( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = isViewed, + watchedAt = watchedAt, + ), + ) + } + + private companion object { + private const val LIKED_SCORE = 10 + private const val DISLIKED_SCORE = 2 + private const val ONBOARDING_LIKED_NOTE = "Onboarding liked" + private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked" + } +} diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index cff99d2..e8a7722 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -13,6 +13,7 @@ import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort +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.Film @@ -22,6 +23,7 @@ import com.project.movienight.domain.model.RecommendationEvent import com.project.movienight.domain.model.RecommendationEventType import com.project.movienight.domain.model.RecommendationResult import com.project.movienight.domain.model.UserPreferences +import com.project.movienight.domain.model.UserRecommendationWeights import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.time.LocalDateTime @@ -37,6 +39,7 @@ class RecommendationService( private val userPreferencesRepository: UserPreferencesRepositoryPort, private val userRepository: UserRepositoryPort, private val recommendationEventRepository: RecommendationEventRepositoryPort, + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, private val idGenerator: IdGenerator, private val businessMetricsService: BusinessMetricsService, ) : GetRecommendationsUseCase, @@ -56,7 +59,8 @@ class RecommendationService( val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() val films = filmRepository.findAll() val filmsById = films.associateBy { it.id } - val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById) + val weights = findWeights(query.userId) + val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights) val candidates = films @@ -65,20 +69,30 @@ class RecommendationService( .filter { film -> film.id !in watchedFilmIds } .filter { film -> !query.libraryOnly || film.id in libraryFilmIds } .toList() - val recommendations = - candidates - .asSequence() - .map { film -> scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds) } - .sortedWith(compareByDescending { it.score }.thenBy { it.film.title }) + val scoredCandidates = + candidates.map { film -> + scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights) + } + val recommendationComparator = + compareByDescending { it.result.score }.thenBy { + it.result.film.title + } + val scoredRecommendations = + scoredCandidates + .sortedWith(recommendationComparator) .take(query.limit.coerceAtLeast(1)) - .toList() - recommendations.forEach { recommendation -> + scoredRecommendations.forEach { recommendation -> saveEvent( userId = query.userId, - filmId = recommendation.film.id, + filmId = recommendation.result.film.id, eventType = RecommendationEventType.RECOMMENDED, - score = recommendation.score, + score = recommendation.result.score, + relevanceScore = recommendation.relevanceScore, + qualityScore = recommendation.qualityScore, + contextScore = recommendation.contextScore, + noveltyScore = recommendation.noveltyScore, + diversityScore = recommendation.diversityScore, ) } @@ -90,17 +104,17 @@ class RecommendationService( query.libraryOnly, query.limit, candidates.size, - recommendations.size, + scoredRecommendations.size, ) if (log.isDebugEnabled) { log.debug( "Recommendation top results: userId='{}', results='{}'", query.userId, - recommendations.joinToString(separator = ",") { "${it.film.id}:${it.score}" }, + scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" }, ) } - return recommendations + return scoredRecommendations.map { it.result } } override fun accept(command: AcceptRecommendationCommand): RecommendationEvent = @@ -127,14 +141,35 @@ class RecommendationService( filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId) val event = saveEvent( userId = userId, filmId = filmId, eventType = eventType, - score = null, + score = lastRecommendation?.score, + relevanceScore = lastRecommendation?.relevanceScore, + qualityScore = lastRecommendation?.qualityScore, + contextScore = lastRecommendation?.contextScore, + noveltyScore = lastRecommendation?.noveltyScore, + diversityScore = lastRecommendation?.diversityScore, ) + if (lastRecommendation != null) { + updateRecommendationWeights( + userId = userId, + eventType = eventType, + recommendation = lastRecommendation, + ) + } else { + log.info( + "Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'", + userId, + filmId, + eventType, + ) + } + log.info( RECOMMENDATION_FEEDBACK_SAVED_LOG, userId, @@ -150,6 +185,11 @@ class RecommendationService( filmId: UUID, eventType: RecommendationEventType, score: Double?, + relevanceScore: Double? = null, + qualityScore: Double? = null, + contextScore: Double? = null, + noveltyScore: Double? = null, + diversityScore: Double? = null, ): RecommendationEvent = recommendationEventRepository.save( RecommendationEvent( @@ -158,15 +198,89 @@ class RecommendationService( filmId = filmId, eventType = eventType, score = score, + relevanceScore = relevanceScore, + qualityScore = qualityScore, + contextScore = contextScore, + noveltyScore = noveltyScore, + diversityScore = diversityScore, createdAt = LocalDateTime.now(), ), ) + private fun findWeights(userId: UUID): UserRecommendationWeights = + ( + userRecommendationWeightsRepository.findByUserId(userId) + ?: UserRecommendationWeights.defaultFor(userId) + ).normalized() + + private fun updateRecommendationWeights( + userId: UUID, + eventType: RecommendationEventType, + recommendation: RecommendationEvent, + ) { + val current = findWeights(userId) + val contributions = scoreContributions(recommendation, current) ?: return + val direction = + when (eventType) { + RecommendationEventType.ACCEPTED -> 1.0 + RecommendationEventType.REJECTED -> -1.0 + RecommendationEventType.RECOMMENDED -> return + } + + val updated = + current + .copy( + relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance, + qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality, + contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context, + noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty, + diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity, + ).normalized(updatedAt = LocalDateTime.now()) + + val saved = userRecommendationWeightsRepository.save(updated) + businessMetricsService.recordRecommendationWeightsUpdated(eventType) + log.info( + RECOMMENDATION_WEIGHTS_UPDATED_LOG, + userId, + eventType, + current.hashCode(), + saved.hashCode(), + ) + } + + private fun scoreContributions( + recommendation: RecommendationEvent, + weights: UserRecommendationWeights, + ): ScoreContributions? { + val rawContributions = + listOf( + weights.relevanceWeight to recommendation.relevanceScore, + weights.qualityWeight to recommendation.qualityScore, + weights.contextWeight to recommendation.contextScore, + weights.noveltyWeight to recommendation.noveltyScore, + weights.diversityWeight to recommendation.diversityScore, + ).map { (weight, score) -> + weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0) + } + val total = rawContributions.sum() + if (total <= 0.0) { + return null + } + return ScoreContributions( + relevance = rawContributions[0] / total, + quality = rawContributions[1] / total, + context = rawContributions[2] / total, + novelty = rawContributions[3] / total, + diversity = rawContributions[4] / total, + ) + } + private fun buildUserProfile( preferences: UserPreferences?, ratings: List, libraryEntries: List, filmsById: Map, + weights: UserRecommendationWeights, ): SparseVector { val profile = MutableSparseVector() @@ -192,12 +306,12 @@ class RecommendationService( ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach val signal = ratingSignal(rating.score) - profile.add(buildFilmVector(film).scale(signal)) + profile.add(buildFilmVector(film, weights).scale(signal)) } libraryEntries.filterNot { it.isViewed }.forEach { entry -> val film = filmsById[entry.filmId] ?: return@forEach - profile.add(buildFilmVector(film).scale(LIBRARY_SIGNAL_WEIGHT)) + profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) } return profile.toSparseVector() @@ -209,20 +323,21 @@ class RecommendationService( preferences: UserPreferences?, userProfile: SparseVector, inLibrary: Boolean, - ): RecommendationResult { + weights: UserRecommendationWeights, + ): ScoredRecommendation { val reasons = mutableListOf() - val filmVector = buildFilmVector(film) + val filmVector = buildFilmVector(film, weights) val preferenceScore = cosineSimilarity(userProfile, filmVector) val qualityScore = qualityScore(film) val contextScore = contextScore(film, query, preferences) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) val score = - RELEVANCE_WEIGHT * preferenceScore + - QUALITY_WEIGHT * qualityScore + - CONTEXT_WEIGHT * contextScore + - NOVELTY_WEIGHT * noveltyScore + - DIVERSITY_WEIGHT * diversityScore + weights.relevanceWeight * preferenceScore + + weights.qualityWeight * qualityScore + + weights.contextWeight * contextScore + + weights.noveltyWeight * noveltyScore + + weights.diversityWeight * diversityScore if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" @@ -252,22 +367,32 @@ class RecommendationService( reasons += "Baseline recommendation from catalog quality" } - return RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()) + return ScoredRecommendation( + result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()), + relevanceScore = preferenceScore, + qualityScore = qualityScore, + contextScore = contextScore, + noveltyScore = noveltyScore, + diversityScore = diversityScore, + ) } - private fun buildFilmVector(film: Film): SparseVector { + private fun buildFilmVector( + film: Film, + weights: UserRecommendationWeights, + ): SparseVector { val vector = MutableSparseVector() val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } val plotTokens = tokenize("${film.title} ${film.description}") val moods = inferredMoods(film) val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } - vector.add(feature("type", film.contentType.name), CONTENT_TYPE_VECTOR_WEIGHT) - distribute(vector, "genre", normalizedGenres, GENRE_VECTOR_WEIGHT) - distribute(vector, "plot", plotTokens, PLOT_VECTOR_WEIGHT) - distribute(vector, "mood", moods, MOOD_VECTOR_WEIGHT) - film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), ERA_VECTOR_WEIGHT) } - distribute(vector, "person", people, PEOPLE_VECTOR_WEIGHT) + vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight) + distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight) + distribute(vector, "plot", plotTokens, weights.plotVectorWeight) + distribute(vector, "mood", moods, weights.moodVectorWeight) + film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) } + distribute(vector, "person", people, weights.peopleVectorWeight) return vector.toSparseVector() } @@ -445,6 +570,23 @@ class RecommendationService( return values.takeIf { it.isNotEmpty() }?.average() } + private data class ScoredRecommendation( + val result: RecommendationResult, + val relevanceScore: Double, + val qualityScore: Double, + val contextScore: Double, + val noveltyScore: Double, + val diversityScore: Double, + ) + + private data class ScoreContributions( + val relevance: Double, + val quality: Double, + val context: Double, + val novelty: Double, + val diversity: Double, + ) + private data class SparseVector( val values: Map, ) { @@ -477,6 +619,8 @@ class RecommendationService( "libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}" private const val RECOMMENDATION_FEEDBACK_SAVED_LOG = "Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'" + private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG = + "Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}" private const val MAX_PREFERENCE_WEIGHT = 5.0 private const val MAX_RATING_VALUE = 10.0 @@ -486,13 +630,6 @@ class RecommendationService( private const val MAX_REASON_ITEMS = 2 private const val SCORE_ROUNDING_FACTOR = 1000.0 - private const val CONTENT_TYPE_VECTOR_WEIGHT = 0.05 - private const val GENRE_VECTOR_WEIGHT = 0.25 - private const val PLOT_VECTOR_WEIGHT = 0.35 - private const val MOOD_VECTOR_WEIGHT = 0.15 - private const val ERA_VECTOR_WEIGHT = 0.10 - private const val PEOPLE_VECTOR_WEIGHT = 0.10 - private const val PREFERENCE_PLOT_WEIGHT = 0.6 private const val PREFERENCE_ERA_WEIGHT = 0.7 private const val PREFERENCE_PERSON_WEIGHT = 0.8 @@ -500,11 +637,7 @@ class RecommendationService( private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val LIBRARY_SIGNAL_WEIGHT = 0.25 - private const val RELEVANCE_WEIGHT = 0.55 - private const val QUALITY_WEIGHT = 0.15 - private const val CONTEXT_WEIGHT = 0.10 - private const val NOVELTY_WEIGHT = 0.10 - private const val DIVERSITY_WEIGHT = 0.10 + private const val LEARNING_RATE = 0.03 private const val LIBRARY_NOVELTY_SCORE = 0.85 private const val CATALOG_NOVELTY_SCORE = 0.65 diff --git a/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt new file mode 100644 index 0000000..fc30d72 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt @@ -0,0 +1,51 @@ +package com.project.movienight.application.services + +import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand +import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase +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.UserRecommendationWeights +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class UserRecommendationWeightsService( + private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, + private val userRepository: UserRepositoryPort, +) : GetUserRecommendationWeightsUseCase, + UpdateUserRecommendationWeightsUseCase { + override fun get(userId: UUID): UserRecommendationWeights { + ensureUserExists(userId) + return ( + userRecommendationWeightsRepository.findByUserId(userId) + ?: UserRecommendationWeights.defaultFor(userId) + ).normalized() + } + + override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights { + ensureUserExists(command.userId) + return userRecommendationWeightsRepository.save( + UserRecommendationWeights( + userId = command.userId, + relevanceWeight = command.relevanceWeight, + qualityWeight = command.qualityWeight, + contextWeight = command.contextWeight, + noveltyWeight = command.noveltyWeight, + diversityWeight = command.diversityWeight, + genreVectorWeight = command.genreVectorWeight, + plotVectorWeight = command.plotVectorWeight, + moodVectorWeight = command.moodVectorWeight, + eraVectorWeight = command.eraVectorWeight, + peopleVectorWeight = command.peopleVectorWeight, + contentTypeVectorWeight = command.contentTypeVectorWeight, + ), + ) + } + + private fun ensureUserExists(userId: UUID) { + userRepository.findById(userId) + ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + } +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt index aff907d..3549398 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt @@ -9,6 +9,11 @@ data class RecommendationEvent( val filmId: UUID, val eventType: RecommendationEventType, val score: Double? = null, + val relevanceScore: Double? = null, + val qualityScore: Double? = null, + val contextScore: Double? = null, + val noveltyScore: Double? = null, + val diversityScore: Double? = null, val createdAt: LocalDateTime = LocalDateTime.now(), ) diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt new file mode 100644 index 0000000..fda5b1d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt @@ -0,0 +1,9 @@ +package com.project.movienight.domain.model + +enum class RecommendationStyle { + BALANCED, + QUALITY_FIRST, + MOOD_FIRST, + DISCOVERY, + SIMILAR_TO_FAVORITES, +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt new file mode 100644 index 0000000..ebc8635 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt @@ -0,0 +1,233 @@ +package com.project.movienight.domain.model + +import java.time.LocalDateTime +import java.util.UUID + +data class UserRecommendationWeights( + val userId: UUID, + val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT, + val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT, + val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT, + val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT, + val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT, + val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT, + val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT, + val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT, + val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT, + val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT, + val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT, + val updatedAt: LocalDateTime = LocalDateTime.now(), +) { + fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights { + val scoreWeights = + normalizeBounded( + values = + listOf( + relevanceWeight, + qualityWeight, + contextWeight, + noveltyWeight, + diversityWeight, + ), + defaults = DEFAULT_SCORE_WEIGHTS, + min = MIN_SCORE_WEIGHT, + max = MAX_SCORE_WEIGHT, + ) + val vectorWeights = + normalizeBounded( + values = + listOf( + genreVectorWeight, + plotVectorWeight, + moodVectorWeight, + eraVectorWeight, + peopleVectorWeight, + contentTypeVectorWeight, + ), + defaults = DEFAULT_VECTOR_WEIGHTS, + min = MIN_VECTOR_WEIGHT, + max = MAX_VECTOR_WEIGHT, + ) + + return copy( + relevanceWeight = scoreWeights[0], + qualityWeight = scoreWeights[1], + contextWeight = scoreWeights[2], + noveltyWeight = scoreWeights[3], + diversityWeight = scoreWeights[4], + genreVectorWeight = vectorWeights[0], + plotVectorWeight = vectorWeights[1], + moodVectorWeight = vectorWeights[2], + eraVectorWeight = vectorWeights[3], + peopleVectorWeight = vectorWeights[4], + contentTypeVectorWeight = vectorWeights[5], + updatedAt = updatedAt, + ) + } + + companion object { + const val DEFAULT_RELEVANCE_WEIGHT = 0.55 + const val DEFAULT_QUALITY_WEIGHT = 0.15 + const val DEFAULT_CONTEXT_WEIGHT = 0.10 + const val DEFAULT_NOVELTY_WEIGHT = 0.10 + const val DEFAULT_DIVERSITY_WEIGHT = 0.10 + + const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25 + const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35 + const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15 + const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10 + const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10 + const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05 + + const val MIN_SCORE_WEIGHT = 0.05 + const val MAX_SCORE_WEIGHT = 0.75 + const val MIN_VECTOR_WEIGHT = 0.03 + const val MAX_VECTOR_WEIGHT = 0.60 + + private val DEFAULT_SCORE_WEIGHTS = + listOf( + DEFAULT_RELEVANCE_WEIGHT, + DEFAULT_QUALITY_WEIGHT, + DEFAULT_CONTEXT_WEIGHT, + DEFAULT_NOVELTY_WEIGHT, + DEFAULT_DIVERSITY_WEIGHT, + ) + private val DEFAULT_VECTOR_WEIGHTS = + listOf( + DEFAULT_GENRE_VECTOR_WEIGHT, + DEFAULT_PLOT_VECTOR_WEIGHT, + DEFAULT_MOOD_VECTOR_WEIGHT, + DEFAULT_ERA_VECTOR_WEIGHT, + DEFAULT_PEOPLE_VECTOR_WEIGHT, + DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT, + ) + + fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId) + + fun forStyle( + userId: UUID, + style: RecommendationStyle, + ): UserRecommendationWeights = + when (style) { + RecommendationStyle.BALANCED -> { + defaultFor(userId) + } + + RecommendationStyle.QUALITY_FIRST -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.40, + qualityWeight = 0.35, + contextWeight = 0.10, + noveltyWeight = 0.05, + diversityWeight = 0.10, + ) + } + + RecommendationStyle.MOOD_FIRST -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.45, + qualityWeight = 0.10, + contextWeight = 0.25, + noveltyWeight = 0.10, + diversityWeight = 0.10, + moodVectorWeight = 0.30, + ) + } + + RecommendationStyle.DISCOVERY -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.30, + qualityWeight = 0.10, + contextWeight = 0.10, + noveltyWeight = 0.25, + diversityWeight = 0.25, + ) + } + + RecommendationStyle.SIMILAR_TO_FAVORITES -> { + UserRecommendationWeights( + userId = userId, + relevanceWeight = 0.70, + qualityWeight = 0.10, + contextWeight = 0.10, + noveltyWeight = 0.05, + diversityWeight = 0.05, + genreVectorWeight = 0.30, + plotVectorWeight = 0.40, + peopleVectorWeight = 0.15, + ) + } + }.normalized() + + private fun normalizeBounded( + values: List, + defaults: List, + min: Double, + max: Double, + ): List { + val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 } + val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults + val normalized = source.map { it / source.sum() } + return projectToBounds(normalized, min, max) + } + + private fun projectToBounds( + values: List, + min: Double, + max: Double, + ): List { + val result = values.map { it.coerceIn(min, max) }.toMutableList() + var iterations = 0 + var adjusting = true + + while (iterations < values.size * 2 && adjusting) { + iterations += 1 + val diff = 1.0 - result.sum() + if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) { + adjusting = false + } else { + adjusting = redistribute(result, diff, min, max) + } + } + + return result + } + + private fun redistribute( + result: MutableList, + diff: Double, + min: Double, + max: Double, + ): Boolean = + if (diff > 0.0) { + val candidates = result.indices.filter { result[it] < max } + val capacity = candidates.sumOf { max - result[it] } + if (capacity > 0.0) { + candidates.forEach { index -> + val increment = diff * ((max - result[index]) / capacity) + result[index] = (result[index] + increment).coerceAtMost(max) + } + true + } else { + false + } + } else { + val candidates = result.indices.filter { result[it] > min } + val capacity = candidates.sumOf { result[it] - min } + if (capacity > 0.0) { + candidates.forEach { index -> + val decrement = -diff * ((result[index] - min) / capacity) + result[index] = (result[index] - decrement).coerceAtLeast(min) + } + true + } else { + false + } + } + + private const val NORMALIZATION_EPSILON = 0.0000001 + } +} diff --git a/src/main/resources/db/migration/V8__user_recommendation_weights.sql b/src/main/resources/db/migration/V8__user_recommendation_weights.sql new file mode 100644 index 0000000..238870e --- /dev/null +++ b/src/main/resources/db/migration/V8__user_recommendation_weights.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS public.user_recommendation_weights ( + user_id UUID PRIMARY KEY, + relevance_weight DOUBLE PRECISION NOT NULL DEFAULT 0.55, + quality_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15, + context_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + novelty_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + diversity_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + genre_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.25, + plot_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.35, + mood_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.15, + era_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + people_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.10, + content_type_vector_weight DOUBLE PRECISION NOT NULL DEFAULT 0.05, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_recommendation_weights_user_fk + FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE +); + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS relevance_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS quality_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS context_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS novelty_score DOUBLE PRECISION; + +ALTER TABLE public.recommendation_events + ADD COLUMN IF NOT EXISTS diversity_score DOUBLE PRECISION; + +CREATE INDEX IF NOT EXISTS idx_recommendation_events_user_film_type_created + ON public.recommendation_events(user_id, film_id, event_type, created_at DESC); diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index 25f0211..c8aaae2 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -4,8 +4,12 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.CreateUserRequest import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired @@ -110,6 +114,39 @@ class RecommendationSmokeTest { val firstFilmId = filmIdByTitle.getValue("Orbital Drift") val secondFilmId = filmIdByTitle.getValue("Small Town Summer") + mockMvc + .get("/api/users/$userId/recommendation-weights") + .andExpect { + status { isOk() } + jsonPath("$.relevanceWeight") { value(0.55) } + jsonPath("$.plotVectorWeight") { value(0.35) } + } + + mockMvc + .put("/api/users/$userId/recommendation-weights") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpdateUserRecommendationWeightsRequest( + relevanceWeight = 0.60, + qualityWeight = 0.10, + contextWeight = 0.15, + noveltyWeight = 0.10, + diversityWeight = 0.05, + genreVectorWeight = 0.30, + plotVectorWeight = 0.30, + moodVectorWeight = 0.20, + eraVectorWeight = 0.05, + peopleVectorWeight = 0.10, + contentTypeVectorWeight = 0.05, + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.relevanceWeight") { value(0.6) } + jsonPath("$.genreVectorWeight") { value(0.3) } + } + mockMvc .put("/api/users/$userId/preferences") { contentType = MediaType.APPLICATION_JSON @@ -163,14 +200,38 @@ class RecommendationSmokeTest { jsonPath("$[0].reasons[0]") { exists() } } + val recommendedBreakdownCount = + jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) + FROM recommendation_events + WHERE user_id = ? + AND film_id = ? + AND event_type = 'RECOMMENDED' + AND relevance_score IS NOT NULL + AND quality_score IS NOT NULL + """.trimIndent(), + Int::class.java, + userId, + firstFilmId, + ) + assertTrue((recommendedBreakdownCount ?: 0) > 0) + + val weightsBeforeFeedback = findScoreWeights(userId) + mockMvc .post("/api/users/$userId/recommendations/$firstFilmId/accept") .andExpect { status { isOk() } jsonPath("$.filmId") { value(firstFilmId.toString()) } jsonPath("$.eventType") { value("ACCEPTED") } + jsonPath("$.relevanceScore") { exists() } } + val weightsAfterAccept = findScoreWeights(userId) + assertNotEquals(weightsBeforeFeedback, weightsAfterAccept) + assertTrue(weightsAfterAccept.all { it in 0.05..0.75 }) + mockMvc .post("/api/users/$userId/recommendations/$firstFilmId/reject") .andExpect { @@ -190,12 +251,159 @@ class RecommendationSmokeTest { } } + @Test + fun `should complete recommendation onboarding`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Alex", email = "alex@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "alex@example.com", + ), + ) + + val likedFilmId = createFilm(title = "Neon Rescue", genres = listOf("SCI-FI"), imdbRating = 8.8) + val dislikedFilmId = createFilm(title = "Quiet Village", genres = listOf("DRAMA"), imdbRating = 5.0) + val libraryFilmId = createFilm(title = "Space Trial", genres = listOf("SCI-FI"), imdbRating = 7.8) + val watchedFilmId = createFilm(title = "Old Mission", genres = listOf("THRILLER"), imdbRating = 8.1) + + mockMvc + .post("/api/users/$userId/recommendation-onboarding") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + RecommendationOnboardingRequest( + weightedGenres = mapOf("SCI-FI" to 5, "THRILLER" to 3), + moods = listOf("focused", "tense"), + contentTypes = listOf("FILM"), + likedFilmIds = listOf(likedFilmId), + dislikedFilmIds = listOf(dislikedFilmId), + libraryFilmIds = listOf(libraryFilmId), + watchedFilmIds = listOf(watchedFilmId), + recommendationStyle = "DISCOVERY", + ), + ) + }.andExpect { + status { isOk() } + jsonPath("$.preferences.weightedGenres['SCI-FI']") { value(5) } + jsonPath("$.weights.noveltyWeight") { value(0.25) } + jsonPath("$.weights.diversityWeight") { value(0.25) } + jsonPath("$.likedFilmsCount") { value(1) } + jsonPath("$.dislikedFilmsCount") { value(1) } + jsonPath("$.libraryFilmsCount") { value(1) } + jsonPath("$.watchedFilmsCount") { value(1) } + } + + assertDatabaseCount( + """ + SELECT COUNT(*) + FROM film_ratings + WHERE user_id = ? + AND film_id IN (?, ?) + """.trimIndent(), + userId, + likedFilmId, + dislikedFilmId, + ) + assertDatabaseCount( + """ + SELECT COUNT(*) + FROM favorites + WHERE user_id = ? + AND film_id = ? + AND is_viewed = TRUE + """.trimIndent(), + userId, + watchedFilmId, + ) + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "3") + }.andExpect { + status { isOk() } + jsonPath("$[0].reasons[0]") { exists() } + } + } + private fun cleanDatabase() { jdbcTemplate.execute("DELETE FROM recommendation_events") + jdbcTemplate.execute("DELETE FROM user_recommendation_weights") jdbcTemplate.execute("DELETE FROM film_ratings") jdbcTemplate.execute("DELETE FROM user_preferences") jdbcTemplate.execute("DELETE FROM favorites") jdbcTemplate.execute("DELETE FROM films") jdbcTemplate.execute("DELETE FROM users") } + + private fun findScoreWeights(userId: UUID): List = + jdbcTemplate + .queryForMap( + """ + SELECT relevance_weight, + quality_weight, + context_weight, + novelty_weight, + diversity_weight + FROM user_recommendation_weights + WHERE user_id = ? + """.trimIndent(), + userId, + ).let { row -> + listOf( + row.getValue("RELEVANCE_WEIGHT"), + row.getValue("QUALITY_WEIGHT"), + row.getValue("CONTEXT_WEIGHT"), + row.getValue("NOVELTY_WEIGHT"), + row.getValue("DIVERSITY_WEIGHT"), + ).map { (it as Number).toDouble() } + } + + private fun createFilm( + title: String, + genres: List, + imdbRating: Double, + ): UUID { + mockMvc + .post("/api/films") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + CreateFilmRequest( + title = title, + description = "$title description", + contentType = "FILM", + genres = genres, + imdbRating = imdbRating, + ), + ) + }.andExpect { + status { isCreated() } + } + + return UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM films WHERE title = ?", + String::class.java, + title, + ), + ) + } + + private fun assertDatabaseCount( + sql: String, + vararg args: Any, + ) { + val count = jdbcTemplate.queryForObject(sql, Int::class.java, *args) + assertTrue((count ?: 0) > 0) + } } diff --git a/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt new file mode 100644 index 0000000..7f2ee9e --- /dev/null +++ b/src/test/kotlin/com/project/movienight/domain/model/UserRecommendationWeightsTest.kt @@ -0,0 +1,74 @@ +package com.project.movienight.domain.model + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.UUID + +class UserRecommendationWeightsTest { + @Test + fun `should keep default weights normalized`() { + val weights = UserRecommendationWeights.defaultFor(UUID.randomUUID()).normalized() + + assertEquals(1.0, weights.scoreWeightSum(), EPSILON) + assertEquals(1.0, weights.vectorWeightSum(), EPSILON) + assertEquals(0.55, weights.relevanceWeight, EPSILON) + assertEquals(0.35, weights.plotVectorWeight, EPSILON) + } + + @Test + fun `should normalize and bound invalid weights`() { + val weights = + UserRecommendationWeights( + userId = UUID.randomUUID(), + relevanceWeight = 100.0, + qualityWeight = -5.0, + contextWeight = 0.0, + noveltyWeight = 0.0, + diversityWeight = 0.0, + genreVectorWeight = 100.0, + plotVectorWeight = 0.0, + moodVectorWeight = 0.0, + eraVectorWeight = 0.0, + peopleVectorWeight = 0.0, + contentTypeVectorWeight = 0.0, + ).normalized() + + assertEquals(1.0, weights.scoreWeightSum(), EPSILON) + assertEquals(1.0, weights.vectorWeightSum(), EPSILON) + assertTrue( + listOf( + weights.relevanceWeight, + weights.qualityWeight, + weights.contextWeight, + weights.noveltyWeight, + weights.diversityWeight, + ).all { it in UserRecommendationWeights.MIN_SCORE_WEIGHT..UserRecommendationWeights.MAX_SCORE_WEIGHT }, + ) + assertTrue( + listOf( + weights.genreVectorWeight, + weights.plotVectorWeight, + weights.moodVectorWeight, + weights.eraVectorWeight, + weights.peopleVectorWeight, + weights.contentTypeVectorWeight, + ).all { it in UserRecommendationWeights.MIN_VECTOR_WEIGHT..UserRecommendationWeights.MAX_VECTOR_WEIGHT }, + ) + } + + private fun UserRecommendationWeights.scoreWeightSum(): Double = + relevanceWeight + qualityWeight + contextWeight + noveltyWeight + diversityWeight + + private fun UserRecommendationWeights.vectorWeightSum(): Double = + genreVectorWeight + + plotVectorWeight + + moodVectorWeight + + eraVectorWeight + + peopleVectorWeight + + contentTypeVectorWeight + + private companion object { + private const val EPSILON = 0.000001 + } +} From c3fc2568e0667433bf56fe94b424513d6232ec98 Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 14:20:45 +0300 Subject: [PATCH 072/106] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 + .../services/FilmLibraryService.kt | 168 +++++++++------ .../application/services/FilmService.kt | 198 ++++++++++-------- .../application/services/UserService.kt | 81 ++++--- src/main/resources/application.yaml | 7 +- src/main/resources/logback-spring.xml | 23 ++ 6 files changed, 295 insertions(+), 184 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..468b2ce 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ dependencies { implementation(libs.flyway.database.postgresql) implementation(libs.kotlin.reflect) + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation(libs.micrometer.tracing.bridge.otel) implementation(libs.opentelemetry.exporter.otlp) implementation(libs.sentry.spring.boot.starter) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..b7f24dd 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -17,6 +17,7 @@ import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.FilmLibrary +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -31,98 +32,133 @@ class FilmLibraryService( RemoveFilmFromLibraryUseCase, GetFilmLibraryUseCase, ListFilmLibraryEntriesUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - findByUserId(command.userId)?.let { return it } + log.info("Creating film library for user: {}", command.userId) + log.debug("Create library request: userId={}, name={}", command.userId, command.name) + + val existing = findByUserId(command.userId) + if (existing != null) { + log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) + return existing + } + + log.warn("Library not found for user {}, cannot create", command.userId) throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { + log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) if (existingEntry != null) { - val saved = - filmLibraryRepository.save( - existingEntry.copy( - isViewed = false, - watchedAt = null, - ), + log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) + val saved = filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, ) + ) businessMetricsService.recordLibraryEvent() + log.info("Film re-added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) return saved } - val saved = + val saved = filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = false, + watchedAt = null, + ) + ) + businessMetricsService.recordLibraryEvent() + log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) + return saved + } + + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { + log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) + + val existingLibrary = if (command.libraryId != null) { + log.debug("Looking up by libraryId: {}", command.libraryId) + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + log.debug("Looking up by userId and filmId") + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } + + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) + throw DomainException("Film with id ${command.filmId} not found in user's library") + } + + filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() + log.info("Film removed from library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, existingLibrary.id) + return existingLibrary + } + + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + log.debug("Marking as viewed at: {}", watchedAt) + + val saved = if (existingEntry == null) { + log.debug("Film not in library, creating new entry as viewed") filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, comment = null, - isViewed = false, - watchedAt = null, - ), + isViewed = true, + watchedAt = watchedAt, + ) + ) + } else { + log.debug("Updating existing entry: entryId={}, was viewed={}", existingEntry.id, existingEntry.isViewed) + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ) ) - businessMetricsService.recordLibraryEvent() - return saved - } - - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - val existingLibrary = - if (command.libraryId != null) { - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } else { - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) - } - - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - throw DomainException("Film with id ${command.filmId} not found in user's library") } - - filmLibraryRepository.deleteById(existingLibrary.id) - businessMetricsService.recordLibraryEvent() - return existingLibrary - } - - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - - val saved = - if (existingEntry == null) { - filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ), - ) - } else { - filmLibraryRepository.save( - existingEntry.copy( - isViewed = true, - watchedAt = watchedAt, - ), - ) - } businessMetricsService.recordLibraryEvent() + log.info("Film marked as viewed: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = - findByUserId(query.userId) + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { + log.debug("Getting library for user: {}", query.userId) + val library = findByUserId(query.userId) ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) + return library + } - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun list(userId: UUID): List { + log.debug("Listing all library entries for user: {}", userId) + val entries = filmLibraryRepository.findAll().filter { it.userId == userId } + log.info("User {} has {} films in library", userId, entries.size) + return entries + } - private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + private fun findByUserId(userId: UUID): FilmLibrary? { + return filmLibraryRepository.findAll().firstOrNull { it.userId == userId } + } - private fun findByUserAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + private fun findByUserAndFilmId(userId: UUID, filmId: UUID): FilmLibrary? { + return filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index d69a6c8..c1efda4 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -33,70 +33,68 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { + private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { + log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) + log.debug("Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", + command.title, command.description.length, command.genres, command.releaseYear) + val sample = Timer.start(meterRegistry) try { - log.debug( - "Create film request received: title='{}', descriptionLength={}", - command.title, - command.description.length, - ) - if (filmConfig.isBlocked(command.title)) { - log.debug("Create film blocked by title policy: title='{}'", command.title) + log.warn("Film creation blocked: title contains blocked pattern '{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.debug("Create film blocked by description policy") + log.warn("Film creation blocked: description contains blocked pattern") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + val film = Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmCreatedCounter.increment() + log.info("Film created successfully: id={}, title='{}'", saved.id, saved.title) return saved } finally { sample.stop(createFilmTimer) } } - override fun edit( - id: UUID, - command: EditFilmCommand, - ): Film { + override fun edit(id: UUID, command: EditFilmCommand): Film { + log.info("Editing film: id={}", id) + log.debug("Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", + id, command.title, command.description.length, command.genres) + val sample = Timer.start(meterRegistry) try { - log.debug("Edit film with id: {}", id) - if (filmConfig.isBlocked(command.title)) { - log.debug("Edit film blocked by title policy: title='{}'", command.title) + log.warn("Film edit blocked: title contains blocked pattern '{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.debug("Edit film blocked by description policy") + log.warn("Film edit blocked: description contains blocked pattern") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -104,28 +102,30 @@ class FilmService( var film = filmRepository.findById(id) if (film == null) { - log.debug("Film not found for edit: id='{}'", id) + log.warn("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - film = - film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) + + film = film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmEditedCounter.increment() + log.info("Film edited successfully: id={}, new title='{}'", saved.id, saved.title) return saved } finally { sample.stop(editFilmTimer) @@ -133,74 +133,86 @@ class FilmService( } override fun delete(id: UUID) { + log.info("Deleting film: id={}", id) + val sample = Timer.start(meterRegistry) try { - log.debug("Delete film with id: {}", id) - val film = filmRepository.findById(id) if (film == null) { - log.debug("Film not found for delete: id='{}'", id) + log.warn("Film not found for delete: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } + log.debug("Film found for deletion: id={}, title='{}'", film.id, film.title) + filmRepository.deleteById(id) - filmDeletedCounter.increment() - - log.info("Film deleted: id='{}'", id) + log.info("Film deleted successfully: id={}, title='{}'", id, film.title) } finally { sample.stop(deleteFilmTimer) } } - override fun getById(id: UUID): Film = - filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + override fun getById(id: UUID): Film { + log.debug("Fetching film by id: {}", id) + val film = filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + log.debug("Film found: id={}, title='{}'", film.id, film.title) + return film + } - override fun getAll(): List = filmRepository.findAll() + override fun getAll(): List { + log.debug("Fetching all films") + val films = filmRepository.findAll() + log.info("Retrieved {} films from database", films.size) + return films + } - override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) + override fun searchByTitle(title: String): Film? { + log.debug("Searching film by title: '{}'", title) + val film = filmRepository.findByTitle(title) + if (film != null) { + log.info("Film found by title '{}': id={}", title, film.id) + } else { + log.debug("No film found with title: '{}'", title) + } + return film + } - private val filmCreatedCounter = - Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) + private val filmCreatedCounter = Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) - private val filmEditedCounter = - Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) + private val filmEditedCounter = Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) - private val filmDeletedCounter = - Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) + private val filmDeletedCounter = Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) - private val filmBlockedCounter = - Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) + private val filmBlockedCounter = Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) - private val createFilmTimer = - Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) + private val createFilmTimer = Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) - private val editFilmTimer = - Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) + private val editFilmTimer = Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) - private val deleteFilmTimer = - Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) + private val deleteFilmTimer = Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..8926742 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -13,6 +13,7 @@ import com.project.movienight.config.UserServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.User +import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -26,48 +27,80 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { + + private val log = LoggerFactory.getLogger(javaClass) + override fun create(command: CreateUserCommand): User { + log.info("Creating new user with email: {}", command.email) + log.debug("Create user request: name='{}', email='{}'", command.name, command.email) + if (userConfig.isBlocked(command.name)) { + log.warn("User creation blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - val user = - User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - jellyfinUserId = null, - ) - return userRepository.save(user) + val user = User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + jellyfinUserId = null, + ) + val saved = userRepository.save(user) + + log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) + return saved } - override fun edit( - id: UUID, - command: EditUserCommand, - ): User { + override fun edit(id: UUID, command: EditUserCommand): User { + log.info("Editing user: id={}", id) + log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) + if (userConfig.isBlocked(command.name)) { + log.warn("User edit blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + var user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - user = - user.copy( - name = command.name, - jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, - ) + log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) - return userRepository.save(user) + user = user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) + + val saved = userRepository.save(user) + log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) + return saved } override fun delete(id: UUID) { - userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + log.info("Deleting user: id={}", id) + log.debug("Delete user request: id={}", id) + + val user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + + log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) + userRepository.deleteById(id) + log.info("User deleted successfully: id={}", id) } - override fun getById(id: UUID): User = - userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + override fun getById(id: UUID): User { + log.debug("Fetching user by id: {}", id) + val user = userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) + return user + } - override fun getAll(): List = userRepository.findAll() + override fun getAll(): List { + log.debug("Fetching all users") + val users = userRepository.findAll() + log.info("Retrieved {} users from database", users.size) + return users + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c2af423..e277aa3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -115,6 +115,11 @@ services: - censored - epstein - python + logging: + level: + com.project.movienight: DEBUG + org.springframework: WARN + org.flywaydb: WARN pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%X{traceId}] %logger{36} - %msg%n" + console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n" diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..dd704c8 --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,23 @@ + + + + %d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) - %msg%n + + + + + logs/app.json + + logs/app-%d{yyyy-MM-dd}.json + 30 + + + + + + + + + + + From ef995422dda6c232049dc5aabc2347830ba7957a Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 14:43:26 +0300 Subject: [PATCH 073/106] =?UTF-8?q?refactor:=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20use=20case=20=D1=81=D0=BB=D0=BE?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=20Jellyfin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + .../adapters/jellyfin/JellyfinApiClient.kt | 30 +-- .../metrics/BusinessMetricsService.kt | 39 ++- .../adapters/persistence/entity/UserEntity.kt | 1 - ...itory.kt => FilmLibraryEntryRepository.kt} | 75 +++--- .../persistence/jdbc/FilmRepository.kt | 26 -- .../jdbc/JellyfinEventRepository.kt | 36 +-- .../persistence/jdbc/UserRepository.kt | 108 +++++++- .../security/CustomOAuth2UserService.kt | 25 +- .../security/SecurityConfiguration.kt | 5 + .../adapters/web/ApiExceptionHandler.kt | 41 +++ .../adapters/web/ContentTypeParser.kt | 10 + .../movienight/adapters/web/FilmController.kt | 41 +-- .../adapters/web/FilmLibraryController.kt | 97 ++----- .../adapters/web/FilmRatingController.kt | 13 +- .../adapters/web/JellyfinEventsController.kt | 26 +- .../adapters/web/JellyfinSyncController.kt | 8 +- .../adapters/web/RecommendationController.kt | 3 +- .../movienight/adapters/web/UserController.kt | 34 +-- .../adapters/web/UserPreferencesController.kt | 22 +- .../dto/request/CreateFilmLibraryRequest.kt | 5 - .../web/dto/request/CreateFilmRequest.kt | 15 ++ .../web/dto/request/CreateUserRequest.kt | 9 + .../web/dto/request/EditFilmRequest.kt | 15 ++ .../web/dto/request/EditUserRequest.kt | 6 + .../web/dto/request/JellyfinEventRequest.kt | 5 + .../web/dto/request/RateFilmRequest.kt | 7 + .../dto/response/FilmLibraryEntryResponse.kt | 26 ++ .../web/dto/response/FilmLibraryResponse.kt | 25 -- .../ports/input/FilmLibraryUseCase.kt | 42 +-- .../ports/input/FilmRatingUseCase.kt | 8 +- .../application/ports/input/FilmUseCase.kt | 38 +-- .../ports/input/JellyfinUseCase.kt | 25 ++ .../ports/input/UserPreferencesUseCase.kt | 8 +- .../application/ports/input/UserUseCase.kt | 32 +-- .../ports/output/BusinessMetricsPort.kt | 30 +++ .../output/FilmLibraryEntryRepositoryPort.kt | 21 ++ .../ports/output/FilmLibraryRepositoryPort.kt | 19 -- .../ports/output/FilmRepositoryPort.kt | 2 - .../ports/output/JellyfinCatalogPort.kt | 30 +++ .../ports/output/JellyfinEventStorePort.kt | 17 ++ .../ports/output/UserRepositoryPort.kt | 14 + .../services/FilmLibraryService.kt | 95 +++---- .../application/services/FilmRatingService.kt | 10 +- .../application/services/FilmService.kt | 225 ++++++---------- .../services/JellyfinEventService.kt | 66 +++-- .../services/JellyfinSyncService.kt | 96 ++++--- .../RecommendationOnboardingService.kt | 14 +- .../services/RecommendationService.kt | 14 +- .../services/UserPreferencesService.kt | 6 +- .../application/services/UserService.kt | 13 +- .../config/JellyfinIntegrationProperties.kt | 2 +- .../movienight/config/MetricsConfiguration.kt | 12 + .../{FilmLibrary.kt => FilmLibraryEntry.kt} | 2 +- .../project/movienight/domain/model/User.kt | 1 - src/main/resources/db/ER.md | 59 +++++ .../migration/V9__cleanup_legacy_schema.sql | 11 + .../com/project/movienight/ClassLoaderTest.kt | 17 ++ .../entity/UserEntityMappingTest.kt | 4 - ...mLibraryEntryRepositoryIntegrationTest.kt} | 78 +++--- .../jdbc/UserRepositoryIntegrationTest.kt | 53 +++- .../adapters/web/FilmControllerSearchTest.kt | 26 +- .../services/FilmLibraryServiceTest.kt | 249 ++++++------------ .../application/services/FilmServiceTest.kt | 8 +- .../application/services/UserServiceTest.kt | 8 +- .../config/TestSecurityConfiguration.kt | 29 ++ 67 files changed, 1144 insertions(+), 995 deletions(-) rename src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepository.kt => FilmLibraryEntryRepository.kt} (60%) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt delete mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt create mode 100644 src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt rename src/main/kotlin/com/project/movienight/domain/model/{FilmLibrary.kt => FilmLibraryEntry.kt} (89%) create mode 100644 src/main/resources/db/migration/V9__cleanup_legacy_schema.sql create mode 100644 src/test/kotlin/com/project/movienight/ClassLoaderTest.kt rename src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepositoryIntegrationTest.kt => FilmLibraryEntryRepositoryIntegrationTest.kt} (61%) create mode 100644 src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..37564ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.actuator) + implementation(libs.spring.boot.starter.aop) implementation(libs.spring.boot.starter.security) implementation(libs.spring.boot.starter.cache) implementation(libs.spring.boot.starter.data.jdbc) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72e81e2..8dbb373 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,7 @@ mockk = "1.13.13" spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" } +spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" } spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" } spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" } spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" } diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index 71670be..2aea71c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper +import com.project.movienight.application.ports.output.JellyfinCatalogPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot +import com.project.movienight.application.ports.output.JellyfinRemoteUser import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.domain.model.ContentType import org.springframework.stereotype.Service @@ -11,39 +14,18 @@ import java.net.http.HttpRequest import java.net.http.HttpResponse import java.time.Duration -data class JellyfinRemoteUser( - val id: String, - val name: String, -) - -data class JellyfinLibraryItemSnapshot( - val jellyfinItemId: String, - val title: String, - val description: String, - val contentType: ContentType, - val releaseYear: Int?, - val genres: List, - val cast: List, - val directors: List, - val platformRating: Double?, - val imdbRating: Double?, - val externalUrl: String?, - val jellyfinLibraryId: String?, - val isPlayed: Boolean, -) - @Service class JellyfinApiClient( private val properties: JellyfinIntegrationProperties, private val objectMapper: ObjectMapper, -) { +) : JellyfinCatalogPort { private val httpClient: HttpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) .build() - fun fetchUsers(): List = + override fun fetchUsers(): List = request("Users") .asItems() .mapNotNull { node -> @@ -51,7 +33,7 @@ class JellyfinApiClient( JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) } - fun fetchLibraryItems(userId: String): List = + override fun fetchLibraryItems(userId: String): List = @Suppress("MaxLineLength") request( "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index b3912f1..973071c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.metrics +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.RecommendationEventType import io.micrometer.core.instrument.Counter @@ -11,8 +12,12 @@ import java.util.concurrent.atomic.AtomicInteger @Service class BusinessMetricsService( private val meterRegistry: MeterRegistry, -) { +) : BusinessMetricsPort { private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total") + private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total") + private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total") + private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total") + private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_total") private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total") private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total") private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total") @@ -33,11 +38,27 @@ class BusinessMetricsService( private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total") - fun recordRecommendationRequest() { + override fun recordFilmCreated() { + filmsCreated.increment() + } + + override fun recordFilmEdited() { + filmsEdited.increment() + } + + override fun recordFilmDeleted() { + filmsDeleted.increment() + } + + override fun recordFilmBlocked() { + filmsBlocked.increment() + } + + override fun recordRecommendationRequest() { recommendationRequests.increment() } - fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) { + override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) { Counter .builder("recommendation_weights_updated_total") .tag("eventType", eventType.name) @@ -45,15 +66,15 @@ class BusinessMetricsService( .increment() } - fun recordRatingSubmitted() { + override fun recordRatingSubmitted() { ratingsSubmitted.increment() } - fun recordLibraryEvent() { + override fun recordLibraryEvent() { libraryEvents.increment() } - fun recordJellyfinSync(summary: JellyfinSyncSummary) { + override fun recordJellyfinSync(summary: JellyfinSyncSummary) { jellyfinSyncRuns.increment() jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble()) jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble()) @@ -61,15 +82,15 @@ class BusinessMetricsService( jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS) } - fun recordJellyfinSyncFailure() { + override fun recordJellyfinSyncFailure() { jellyfinSyncFailures.increment() } - fun recordJellyfinUnmappedUser() { + override fun recordJellyfinUnmappedUser() { jellyfinUnmappedUsersGaugeValue.incrementAndGet() } - fun recordBackendWriteFailure() { + override fun recordBackendWriteFailure() { backendWriteFailures.increment() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 58e2c3c..2a9c0c6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User = id = id, name = name, email = email, - library = null, preferences = null, jellyfinUserId = jellyfinUserId, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt similarity index 60% rename from src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt rename to src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt index 9fa474d..beaf6cb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt @@ -1,18 +1,18 @@ package com.project.movienight.adapters.persistence.jdbc -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.domain.model.FilmLibraryEntry import org.springframework.jdbc.core.JdbcTemplate import org.springframework.stereotype.Repository import java.sql.ResultSet import java.util.UUID @Repository -class FilmLibraryRepository( +class FilmLibraryEntryRepository( private val jdbc: JdbcTemplate, -) : FilmLibraryRepositoryPort { - private val filmLibraryRowMapper = { rs: ResultSet, _: Int -> - FilmLibrary( +) : FilmLibraryEntryRepositoryPort { + private val rowMapper = { rs: ResultSet, _: Int -> + FilmLibraryEntry( id = UUID.fromString(rs.getString("id")), userId = UUID.fromString(rs.getString("user_id")), filmId = UUID.fromString(rs.getString("film_id")), @@ -22,7 +22,7 @@ class FilmLibraryRepository( ) } - override fun save(filmLibrary: FilmLibrary): FilmLibrary { + override fun save(entry: FilmLibraryEntry): FilmLibraryEntry { val updatedRows = jdbc.update( """ @@ -30,12 +30,12 @@ class FilmLibraryRepository( SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ? WHERE id = ? """.trimIndent(), - filmLibrary.userId, - filmLibrary.filmId, - filmLibrary.comment, - filmLibrary.isViewed, - filmLibrary.watchedAt, - filmLibrary.id, + entry.userId, + entry.filmId, + entry.comment, + entry.isViewed, + entry.watchedAt, + entry.id, ) if (updatedRows == 0) { jdbc.update( @@ -43,48 +43,51 @@ class FilmLibraryRepository( INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at) VALUES (?, ?, ?, ?, ?, ?) """.trimIndent(), - filmLibrary.id, - filmLibrary.userId, - filmLibrary.filmId, - filmLibrary.comment, - filmLibrary.isViewed, - filmLibrary.watchedAt, + entry.id, + entry.userId, + entry.filmId, + entry.comment, + entry.isViewed, + entry.watchedAt, ) } - return filmLibrary + return entry } - override fun findById(id: UUID): FilmLibrary? { - val entries = - jdbc.query( + override fun findById(id: UUID): FilmLibraryEntry? = + jdbc + .query( "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?", - filmLibraryRowMapper, + rowMapper, id, - ) - return entries.firstOrNull() - } + ).firstOrNull() + + override fun findByUserId(userId: UUID): List = + jdbc.query( + "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?", + rowMapper, + userId, + ) override fun findByUserIdAndFilmId( userId: UUID, filmId: UUID, - ): FilmLibrary? { - val entries = - jdbc.query( + ): FilmLibraryEntry? = + jdbc + .query( """ SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ? AND film_id = ? """.trimIndent(), - filmLibraryRowMapper, + rowMapper, userId, filmId, - ) - return entries.firstOrNull() - } + ).firstOrNull() - override fun findAll(): List = + override fun findAll(): List = jdbc.query( "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", - filmLibraryRowMapper, + rowMapper, ) override fun deleteById(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index b1d4ab2..cdfc6b9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -159,32 +159,6 @@ class FilmRepository( return films.firstOrNull() } - override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? { - val films = - jdbc.query( - """ - SELECT id, - title, - description, - content_type, - release_year, - genres, - cast_members, - directors, - imdb_rating, - platform_rating, - external_url, - jellyfin_item_id, - jellyfin_library_id - FROM films - WHERE jellyfin_library_id = ? - """.trimIndent(), - filmRowMapper, - jellyfinLibraryId, - ) - return films.firstOrNull() - } - override fun findAll(): List = jdbc.query( """ diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..a3ec81f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -1,5 +1,7 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.application.ports.output.JellyfinEventRecord +import com.project.movienight.application.ports.output.JellyfinEventStorePort import org.springframework.jdbc.core.namedparam.MapSqlParameterSource import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate import org.springframework.stereotype.Repository @@ -7,16 +9,8 @@ import org.springframework.stereotype.Repository @Repository class JellyfinEventRepository( private val jdbc: NamedParameterJdbcTemplate, -) { - fun save( - eventId: String, - serverId: String?, - eventType: String, - occurredAt: java.time.OffsetDateTime?, - jellyfinUserId: String?, - jellyfinItemId: String?, - payload: String?, - ): Int { +) : JellyfinEventStorePort { + override fun save(event: JellyfinEventRecord): Boolean { val sql = """ INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload) @@ -26,20 +20,14 @@ class JellyfinEventRepository( val params = MapSqlParameterSource() - .addValue("eventId", eventId) - .addValue("serverId", serverId) - .addValue("eventType", eventType) - .addValue("occurredAt", occurredAt) - .addValue("jellyfinUserId", jellyfinUserId) - .addValue("jellyfinItemId", jellyfinItemId) - .addValue("payload", payload) + .addValue("eventId", event.eventId) + .addValue("serverId", event.serverId) + .addValue("eventType", event.eventType) + .addValue("occurredAt", event.occurredAt) + .addValue("jellyfinUserId", event.jellyfinUserId) + .addValue("jellyfinItemId", event.jellyfinItemId) + .addValue("payload", event.payload) - return jdbc.update(sql, params) - } - - fun delete(eventId: String) { - val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId" - val params = MapSqlParameterSource().addValue("eventId", eventId) - jdbc.update(sql, params) + return jdbc.update(sql, params) == 1 } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index ae34f89..02949ed 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -4,6 +4,7 @@ import com.project.movienight.adapters.persistence.entity.UserEntity import com.project.movienight.adapters.persistence.entity.toDomain import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.springframework.jdbc.core.JdbcTemplate @@ -32,11 +33,9 @@ class UserRepository( val entity = if (existingUser != null) { - val existingEntity = existingUser.toEntity() user.toEntity( - provider = existingEntity.provider?.let { AuthProvider.valueOf(it) }, - providerId = existingEntity.providerId, - createdAt = existingEntity.createdAt, + provider = findProviderById(user.id), + providerId = findProviderIdById(user.id), ) } else { user.toEntity() @@ -75,6 +74,70 @@ class UserRepository( return user } + override fun createOAuthUser( + user: User, + provider: AuthProvider, + providerId: String, + ): User { + val entity = user.toEntity(provider = provider, providerId = providerId) + val updatedRows = + jdbc.update( + """ + UPDATE users + SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ? + WHERE id = ? + """.trimIndent(), + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.id, + ) + + if (updatedRows == 0) { + jdbc.update( + """ + INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """.trimIndent(), + entity.id, + entity.name, + entity.email, + entity.provider, + entity.providerId, + entity.jellyfinUserId, + entity.createdAt, + ) + } + + return findById(user.id) ?: user + } + + override fun linkOAuthAccount( + userId: UUID, + provider: AuthProvider, + providerId: String, + ): User { + val updatedRows = + jdbc.update( + """ + UPDATE users + SET provider = ?, provider_id = ? + WHERE id = ? + """.trimIndent(), + provider.name, + providerId, + userId, + ) + + if (updatedRows == 0) { + throw EntityNotFoundException(entity = "User", id = userId.toString()) + } + + return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString()) + } + override fun findById(id: UUID): User? { val entities = jdbc.query( @@ -88,13 +151,31 @@ class UserRepository( override fun findByEmail(email: String): User? { val entities = jdbc.query( - "SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?", + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at + FROM users + WHERE email = ? + """.trimIndent(), userEntityRowMapper, email, ) return entities.firstOrNull()?.toDomain() } + override fun findByJellyfinUserId(jellyfinUserId: String): User? { + val entities = + jdbc.query( + """ + SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at + FROM users + WHERE jellyfin_user_id = ? + """.trimIndent(), + userEntityRowMapper, + jellyfinUserId, + ) + return entities.firstOrNull()?.toDomain() + } + override fun findAll(): List = jdbc .query( @@ -122,4 +203,21 @@ class UserRepository( ) return entities.firstOrNull()?.toDomain() } + + private fun findProviderById(id: UUID): AuthProvider? = + jdbc + .query( + "SELECT provider FROM users WHERE id = ?", + { rs: ResultSet, _: Int -> rs.getString("provider") }, + id, + ).firstOrNull() + ?.let { AuthProvider.valueOf(it) } + + private fun findProviderIdById(id: UUID): String? = + jdbc + .query( + "SELECT provider_id FROM users WHERE id = ?", + { rs: ResultSet, _: Int -> rs.getString("provider_id") }, + id, + ).firstOrNull() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index b64ea71..7b90df8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -1,7 +1,5 @@ package com.project.movienight.adapters.security -import com.project.movienight.adapters.persistence.entity.toDomain -import com.project.movienight.adapters.persistence.entity.toEntity import com.project.movienight.application.ports.input.security.OAuth2UserInfo import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort @@ -59,12 +57,11 @@ class CustomOAuth2UserService( if (userByEmail != null) { log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail()) - val entity = - userByEmail.toEntity( - provider = provider, - providerId = userInfo.getProviderId(), - ) - userRepository.save(entity.toDomain()) + userRepository.linkOAuthAccount( + userId = userByEmail.id, + provider = provider, + providerId = userInfo.getProviderId(), + ) } else { log.debug("Creating new user for provider: {}", userInfo.getProvider()) val newUser = @@ -72,14 +69,12 @@ class CustomOAuth2UserService( id = idGenerator.generateId(), name = userInfo.getName(), email = userInfo.getEmail(), - library = null, ) - val entity = - newUser.toEntity( - provider = provider, - providerId = userInfo.getProviderId(), - ) - userRepository.save(entity.toDomain()) + userRepository.createOAuthUser( + user = newUser, + provider = provider, + providerId = userInfo.getProviderId(), + ) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 0bcb1b3..92fb02c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -23,6 +23,11 @@ class SecurityConfiguration( auth .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") .permitAll() + .requestMatchers( + "/api/integrations/jellyfin/events", + "/api/integrations/jellyfin/sync", + "/api/integrations/jellyfin/sync-state", + ).permitAll() .requestMatchers("/api/users/me") .authenticated() .requestMatchers("/api/**") diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 40b3362..29131e3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -6,9 +6,12 @@ import com.project.movienight.domain.exception.EntityNotFoundException import org.slf4j.LoggerFactory import org.slf4j.MDC import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.server.ResponseStatusException @RestControllerAdvice class ApiExceptionHandler { @@ -50,6 +53,44 @@ class ApiExceptionHandler { ) } + @ExceptionHandler(MethodArgumentNotValidException::class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse { + val traceId = currentTraceId() + val details = + exception + .bindingResult + .fieldErrors + .joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" } + .ifBlank { "Invalid request" } + log.warn("Validation error: traceId='{}', message='{}'", traceId, details) + + return ErrorResponse( + message = details, + traceId = traceId, + ) + } + + @ExceptionHandler(ResponseStatusException::class) + fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity { + val traceId = currentTraceId() + log.warn( + "HTTP error: traceId='{}', status='{}', message='{}'", + traceId, + exception.statusCode, + exception.reason, + ) + + return ResponseEntity + .status(exception.statusCode) + .body( + ErrorResponse( + message = exception.reason ?: exception.message, + traceId = traceId, + ), + ) + } + @ExceptionHandler(Exception::class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) fun handleUnexpectedException(exception: Exception): ErrorResponse { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt new file mode 100644 index 0000000..cf8ed0b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt @@ -0,0 +1,10 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.model.ContentType + +fun parseContentType(value: String): ContentType = + runCatching { ContentType.valueOf(value.uppercase()) } + .getOrElse { throw DomainException("Unsupported content type: $value") } + +fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 9bec40b..1a35dea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -4,13 +4,9 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest import com.project.movienight.adapters.web.dto.request.EditFilmRequest import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.CreateFilmCommand -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand -import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.application.ports.input.FilmUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.DeleteMapping @@ -25,33 +21,22 @@ import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController import java.util.UUID -private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType = - runCatching { - com.project.movienight.domain.model.ContentType - .valueOf(this) - }.getOrDefault(com.project.movienight.domain.model.ContentType.FILM) - @RestController @RequestMapping("/api/films") class FilmController( - private val createFilmUseCase: CreateFilmUseCase, - private val editFilmUseCase: EditFilmUseCase, - private val deleteFilmUseCase: DeleteFilmUseCase, - private val getFilmByIdUseCase: GetFilmByIdUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, - private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase, + private val filmUseCase: FilmUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestBody request: CreateFilmRequest, + @Valid @RequestBody request: CreateFilmRequest, ): FilmResponse = FilmResponse.fromDomain( - createFilmUseCase.create( + filmUseCase.create( CreateFilmCommand( title = request.title, description = request.description, - contentType = request.contentType.toContentTypeOrFilm(), + contentType = parseContentType(request.contentType), releaseYear = request.releaseYear, genres = request.genres, cast = request.cast, @@ -68,16 +53,16 @@ class FilmController( @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, - @RequestBody request: EditFilmRequest, + @Valid @RequestBody request: EditFilmRequest, ): FilmResponse = FilmResponse.fromDomain( - editFilmUseCase.edit( + filmUseCase.edit( id = id, command = EditFilmCommand( title = request.title, description = request.description, - contentType = request.contentType.toContentTypeOrFilm(), + contentType = parseContentType(request.contentType), releaseYear = request.releaseYear, genres = request.genres, cast = request.cast, @@ -95,21 +80,21 @@ class FilmController( @ResponseStatus(HttpStatus.NO_CONTENT) fun delete( @PathVariable id: UUID, - ) = deleteFilmUseCase.delete(id) + ) = filmUseCase.delete(id) @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id)) + ): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id)) @GetMapping - fun getAll(): List = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = filmUseCase.getAll().map { FilmResponse.fromDomain(it) } @GetMapping("/search") fun searchByTitle( @RequestParam title: String, ): ResponseEntity { - val film = searchFilmByTitleUseCase.searchByTitle(title) + val film = filmUseCase.searchByTitle(title) return if (film != null) { ResponseEntity.ok(FilmResponse.fromDomain(film)) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 81115cd..e955445 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -1,28 +1,16 @@ package com.project.movienight.adapters.web -import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest -import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse +import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse import com.project.movienight.adapters.web.dto.response.FilmResponse import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.domain.exception.EntityNotFoundException import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController @@ -31,52 +19,29 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/library") class FilmLibraryController( - private val createFilmLibraryUseCase: CreateFilmLibraryUseCase, - private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase, - private val markFilmViewedUseCase: MarkFilmViewedUseCase, - private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase, - private val getFilmLibraryUseCase: GetFilmLibraryUseCase, - private val getAllFilmsUseCase: GetAllFilmsUseCase, - private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, ) { - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - fun create( - @PathVariable userId: UUID, - @RequestBody request: CreateFilmLibraryRequest, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - createFilmLibraryUseCase.create( - CreateFilmLibraryCommand( - userId = userId, - name = request.name, - ), - ), - ) - @GetMapping - fun get( - @PathVariable userId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ), - ) - - @GetMapping("/entries") fun list( @PathVariable userId: UUID, - ): List = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } + ): List = + filmLibraryUseCase + .list(userId) + .map { entry -> FilmLibraryEntryResponse.fromDomain(entry) } + + @GetMapping("/entries") + fun listEntries( + @PathVariable userId: UUID, + ): List = list(userId) @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun addFilm( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - addFilmToLibraryUseCase.addFilm( + ): FilmLibraryEntryResponse = + FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.addFilm( AddFilmToLibraryCommand( userId = userId, filmId = filmId, @@ -88,9 +53,9 @@ class FilmLibraryController( fun markViewed( @PathVariable userId: UUID, @PathVariable filmId: UUID, - ): FilmLibraryResponse = - FilmLibraryResponse.fromDomain( - markFilmViewedUseCase.markViewed( + ): FilmLibraryEntryResponse = + FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.markViewed( MarkFilmViewedCommand( userId = userId, filmId = filmId, @@ -104,7 +69,7 @@ class FilmLibraryController( @PathVariable userId: UUID, @PathVariable filmId: UUID, ) { - removeFilmFromLibraryUseCase.removeFilm( + filmLibraryUseCase.removeFilm( RemoveFilmFromLibraryCommand( userId = userId, filmId = filmId, @@ -115,27 +80,5 @@ class FilmLibraryController( @GetMapping("/available-films") fun getAvailableFilms( @PathVariable userId: UUID, - ): List { - val userLibrary = - runCatching { - getFilmLibraryUseCase.getLibrary( - GetFilmLibraryQuery(userId = userId), - ) - }.onFailure { exception -> - if (exception !is EntityNotFoundException) { - throw exception - } - }.getOrNull() - - val allFilms = getAllFilmsUseCase.getAll() - - val availableFilms = - if (userLibrary != null) { - allFilms.filter { it.id != userLibrary.filmId } - } else { - allFilms - } - - return availableFilms.map { FilmResponse.fromDomain(it) } - } + ): List = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt index fec4889..b86b5f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -2,9 +2,9 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.RateFilmRequest import com.project.movienight.adapters.web.dto.response.FilmRatingResponse -import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.FilmRatingUseCase import com.project.movienight.application.ports.input.RateFilmCommand -import com.project.movienight.application.ports.input.RateFilmUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable @@ -18,18 +18,17 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/ratings") class FilmRatingController( - private val rateFilmUseCase: RateFilmUseCase, - private val getFilmRatingsUseCase: GetFilmRatingsUseCase, + private val filmRatingUseCase: FilmRatingUseCase, ) { @PostMapping("/films/{filmId}") @ResponseStatus(HttpStatus.CREATED) fun rate( @PathVariable userId: UUID, @PathVariable filmId: UUID, - @RequestBody request: RateFilmRequest, + @Valid @RequestBody request: RateFilmRequest, ): FilmRatingResponse = FilmRatingResponse.fromDomain( - rateFilmUseCase.rate( + filmRatingUseCase.rate( RateFilmCommand( userId = userId, filmId = filmId, @@ -42,5 +41,5 @@ class FilmRatingController( @GetMapping fun list( @PathVariable userId: UUID, - ): List = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } + ): List = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 81ee52c..0d7a294 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -1,8 +1,10 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest -import com.project.movienight.application.services.JellyfinEventService +import com.project.movienight.application.ports.input.HandleJellyfinEventCommand +import com.project.movienight.application.ports.input.JellyfinEventUseCase import com.project.movienight.config.JellyfinIntegrationProperties +import jakarta.validation.Valid import org.slf4j.LoggerFactory import org.springframework.http.HttpStatus import org.springframework.web.bind.annotation.PostMapping @@ -16,7 +18,7 @@ import org.springframework.web.server.ResponseStatusException @RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinEventsController( - private val jellyfinEventService: JellyfinEventService, + private val jellyfinEventUseCase: JellyfinEventUseCase, private val properties: JellyfinIntegrationProperties, ) { private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) @@ -25,7 +27,7 @@ class JellyfinEventsController( @ResponseStatus(HttpStatus.OK) fun receiveEvent( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, - @RequestBody request: JellyfinEventRequest, + @Valid @RequestBody request: JellyfinEventRequest, ) { if (!properties.enabled) { throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") @@ -43,14 +45,16 @@ class JellyfinEventsController( request.jellyfinUserId, request.itemId, ) - jellyfinEventService.handleEvent( - eventId = request.eventId, - serverId = null, - eventType = request.eventType, - occurredAt = request.occurredAt, - jellyfinUserId = request.jellyfinUserId, - itemId = request.itemId, - payload = request.payload, + jellyfinEventUseCase.handle( + HandleJellyfinEventCommand( + eventId = request.eventId, + serverId = null, + eventType = request.eventType, + occurredAt = request.occurredAt, + jellyfinUserId = request.jellyfinUserId, + itemId = request.itemId, + payload = request.payload, + ), ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index 74aac90..aa107b7 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -1,6 +1,6 @@ package com.project.movienight.adapters.web -import com.project.movienight.application.services.JellyfinSyncService +import com.project.movienight.application.ports.input.JellyfinSyncUseCase import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary import org.springframework.web.bind.annotation.GetMapping @@ -11,11 +11,11 @@ import org.springframework.web.bind.annotation.RestController @RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinSyncController( - private val jellyfinSyncService: JellyfinSyncService, + private val jellyfinSyncUseCase: JellyfinSyncUseCase, ) { @PostMapping("/sync") - fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow() + fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow() @GetMapping("/sync-state") - fun syncState(): List = jellyfinSyncService.getSyncStates() + fun syncState(): List = jellyfinSyncUseCase.getSyncStates() } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 6d5bdd9..cd37beb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -9,7 +9,6 @@ import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.input.RejectRecommendationCommand import com.project.movienight.application.ports.input.RejectRecommendationUseCase import com.project.movienight.config.JellyfinIntegrationProperties -import com.project.movienight.domain.model.ContentType import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PostMapping @@ -40,7 +39,7 @@ class RecommendationController( .recommend( RecommendationQuery( userId = userId, - contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() }, + contentType = parseOptionalContentType(contentType), mood = mood, libraryOnly = libraryOnly, limit = limit, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index e06954c..ee74235 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -1,16 +1,15 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.security.UserPrincipal import com.project.movienight.adapters.web.dto.request.CreateUserRequest import com.project.movienight.adapters.web.dto.request.EditUserRequest import com.project.movienight.adapters.web.dto.response.UserResponse import com.project.movienight.application.ports.input.CreateUserCommand -import com.project.movienight.application.ports.input.CreateUserUseCase -import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand -import com.project.movienight.application.ports.input.EditUserUseCase -import com.project.movienight.application.ports.input.GetAllUsersUseCase -import com.project.movienight.application.ports.input.GetUserByIdUseCase +import com.project.movienight.application.ports.input.UserUseCase +import jakarta.validation.Valid import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal import org.springframework.web.bind.annotation.DeleteMapping import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PatchMapping @@ -25,19 +24,15 @@ import java.util.UUID @RestController @RequestMapping("/api/users") class UserController( - private val createUserUseCase: CreateUserUseCase, - private val editUserUseCase: EditUserUseCase, - private val deleteUserUseCase: DeleteUserUseCase, - private val getUserByIdUseCase: GetUserByIdUseCase, - private val getAllUsersUseCase: GetAllUsersUseCase, + private val userUseCase: UserUseCase, ) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun create( - @RequestBody request: CreateUserRequest, + @Valid @RequestBody request: CreateUserRequest, ): UserResponse = UserResponse.fromDomain( - createUserUseCase.create( + userUseCase.create( CreateUserCommand( name = request.name, email = request.email, @@ -46,20 +41,25 @@ class UserController( ) @GetMapping - fun getAll(): List = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = userUseCase.getAll().map { UserResponse.fromDomain(it) } + + @GetMapping("/me") + fun getMe( + @AuthenticationPrincipal principal: UserPrincipal, + ): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId())) @GetMapping("/{id}") fun getById( @PathVariable id: UUID, - ): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id)) + ): UserResponse = UserResponse.fromDomain(userUseCase.getById(id)) @PatchMapping("/{id}") fun edit( @PathVariable id: UUID, - @RequestBody request: EditUserRequest, + @Valid @RequestBody request: EditUserRequest, ): UserResponse = UserResponse.fromDomain( - editUserUseCase.edit( + userUseCase.edit( id = id, command = EditUserCommand( @@ -73,5 +73,5 @@ class UserController( @ResponseStatus(HttpStatus.NO_CONTENT) fun delete( @PathVariable id: UUID, - ) = deleteUserUseCase.delete(id) + ) = userUseCase.delete(id) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt index 276565b..074fb15 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -2,10 +2,9 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse -import com.project.movienight.application.ports.input.GetUserPreferencesUseCase import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand -import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase -import com.project.movienight.domain.model.ContentType +import com.project.movienight.application.ports.input.UserPreferencesUseCase +import jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PathVariable import org.springframework.web.bind.annotation.PutMapping @@ -17,16 +16,15 @@ import java.util.UUID @RestController @RequestMapping("/api/users/{userId}/preferences") class UserPreferencesController( - private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase, - private val getUserPreferencesUseCase: GetUserPreferencesUseCase, + private val userPreferencesUseCase: UserPreferencesUseCase, ) { @PutMapping fun upsert( @PathVariable userId: UUID, - @RequestBody request: UpsertUserPreferencesRequest, + @Valid @RequestBody request: UpsertUserPreferencesRequest, ): UserPreferencesResponse = UserPreferencesResponse.fromDomain( - upsertUserPreferencesUseCase.upsert( + userPreferencesUseCase.upsert( UpsertUserPreferencesCommand( userId = userId, weightedGenres = request.weightedGenres, @@ -35,13 +33,7 @@ class UserPreferencesController( castAndDirectors = request.castAndDirectors, moods = request.moods, contentTypes = - request.contentTypes.mapNotNull { - runCatching { - ContentType.valueOf( - it, - ) - }.getOrNull() - }, + request.contentTypes.map { parseContentType(it) }, ), ), ) @@ -49,5 +41,5 @@ class UserPreferencesController( @GetMapping fun get( @PathVariable userId: UUID, - ): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } + ): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt deleted file mode 100644 index dc6cb11..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.project.movienight.adapters.web.dto.request - -data class CreateFilmLibraryRequest( - val name: String = "My films", -) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 82f7348..a07f049 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -1,14 +1,29 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class CreateFilmRequest( + @field:NotBlank + @field:Size(max = 255) val title: String, + @field:NotBlank val description: String, + @field:NotBlank val contentType: String = "FILM", + @field:Min(1888) + @field:Max(3000) val releaseYear: Int? = null, val genres: List = emptyList(), val cast: List = emptyList(), val directors: List = emptyList(), + @field:Min(0) + @field:Max(10) val imdbRating: Double? = null, + @field:Min(0) + @field:Max(10) val platformRating: Double? = null, val externalUrl: String? = null, val jellyfinItemId: String? = null, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt index 73dcb0f..94308b2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt @@ -1,6 +1,15 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Email +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class CreateUserRequest( + @field:NotBlank + @field:Size(max = 255) val name: String, + @field:Email + @field:NotBlank + @field:Size(max = 320) val email: String, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 60eddce..20d88e5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -1,14 +1,29 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class EditFilmRequest( + @field:NotBlank + @field:Size(max = 255) val title: String, + @field:NotBlank val description: String, + @field:NotBlank val contentType: String = "FILM", + @field:Min(1888) + @field:Max(3000) val releaseYear: Int? = null, val genres: List = emptyList(), val cast: List = emptyList(), val directors: List = emptyList(), + @field:Min(0) + @field:Max(10) val imdbRating: Double? = null, + @field:Min(0) + @field:Max(10) val platformRating: Double? = null, val externalUrl: String? = null, val jellyfinItemId: String? = null, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 358e0e4..b459396 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -1,6 +1,12 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size + data class EditUserRequest( + @field:NotBlank + @field:Size(max = 255) val name: String, + @field:Size(max = 255) val jellyfinUserId: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt index 68abfe8..973f7b8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -1,18 +1,23 @@ package com.project.movienight.adapters.web.dto.request import com.fasterxml.jackson.annotation.JsonProperty +import jakarta.validation.constraints.NotBlank import java.time.OffsetDateTime data class JellyfinEventRequest( @JsonProperty("event_id") + @field:NotBlank val eventId: String, @JsonProperty("event_type") + @field:NotBlank val eventType: String, @JsonProperty("occurred_at") val occurredAt: OffsetDateTime, @JsonProperty("jellyfin_user_id") + @field:NotBlank val jellyfinUserId: String, @JsonProperty("item_id") + @field:NotBlank val itemId: String, @JsonProperty("payload_version") val payloadVersion: Int = 1, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt index 1f44e39..c2a8077 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -1,6 +1,13 @@ package com.project.movienight.adapters.web.dto.request +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Size + data class RateFilmRequest( + @field:Min(1) + @field:Max(10) val score: Int, + @field:Size(max = 2048) val note: String? = null, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt new file mode 100644 index 0000000..2a6d670 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt @@ -0,0 +1,26 @@ +package com.project.movienight.adapters.web.dto.response + +import com.project.movienight.domain.model.FilmLibraryEntry +import java.time.LocalDateTime +import java.util.UUID + +data class FilmLibraryEntryResponse( + val id: UUID, + val userId: UUID, + val filmId: UUID, + val comment: String?, + val isViewed: Boolean, + val watchedAt: LocalDateTime?, +) { + companion object { + fun fromDomain(entry: FilmLibraryEntry): FilmLibraryEntryResponse = + FilmLibraryEntryResponse( + id = entry.id, + userId = entry.userId, + filmId = entry.filmId, + comment = entry.comment, + isViewed = entry.isViewed, + watchedAt = entry.watchedAt, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt deleted file mode 100644 index 90a339d..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.project.movienight.adapters.web.dto.response - -import com.project.movienight.domain.model.FilmLibrary -import java.util.UUID - -data class FilmLibraryResponse( - val id: UUID, - val userId: UUID, - val filmId: UUID, - val comment: String?, - val isViewed: Boolean, - val watchedAt: java.time.LocalDateTime?, -) { - companion object { - fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse = - FilmLibraryResponse( - id = filmLibrary.id, - userId = filmLibrary.userId, - filmId = filmLibrary.filmId, - comment = filmLibrary.comment, - isViewed = filmLibrary.isViewed, - watchedAt = filmLibrary.watchedAt, - ) - } -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index 3100547..39d9688 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,20 +1,20 @@ package com.project.movienight.application.ports.input -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import java.time.LocalDateTime import java.util.UUID -interface CreateFilmLibraryUseCase { - fun create(command: CreateFilmLibraryCommand): FilmLibrary -} +interface FilmLibraryUseCase { + fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry -data class CreateFilmLibraryCommand( - val userId: UUID, - val name: String = "Мои фильмы", -) + fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry -interface AddFilmToLibraryUseCase { - fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary + fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry + + fun list(userId: UUID): List + + fun listAvailableFilms(userId: UUID): List } data class AddFilmToLibraryCommand( @@ -22,34 +22,14 @@ data class AddFilmToLibraryCommand( val filmId: UUID, ) -interface MarkFilmViewedUseCase { - fun markViewed(command: MarkFilmViewedCommand): FilmLibrary -} - data class MarkFilmViewedCommand( val userId: UUID, val filmId: UUID, val watchedAt: LocalDateTime? = null, ) -interface RemoveFilmFromLibraryUseCase { - fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary -} - data class RemoveFilmFromLibraryCommand( val userId: UUID, val filmId: UUID, - val libraryId: UUID? = null, + val entryId: UUID? = null, ) - -interface GetFilmLibraryUseCase { - fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary -} - -data class GetFilmLibraryQuery( - val userId: UUID, -) - -interface ListFilmLibraryEntriesUseCase { - fun list(userId: UUID): List -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt index 37c8226..5a411a1 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -3,8 +3,10 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.FilmRating import java.util.UUID -interface RateFilmUseCase { +interface FilmRatingUseCase { fun rate(command: RateFilmCommand): FilmRating + + fun getRatings(userId: UUID): List } data class RateFilmCommand( @@ -13,7 +15,3 @@ data class RateFilmCommand( val score: Int, val note: String? = null, ) - -interface GetFilmRatingsUseCase { - fun getRatings(userId: UUID): List -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 27098c7..c88bc2d 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -4,8 +4,21 @@ import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film import java.util.UUID -interface CreateFilmUseCase { +interface FilmUseCase { fun create(command: CreateFilmCommand): Film + + fun edit( + id: UUID, + command: EditFilmCommand, + ): Film + + fun delete(id: UUID) + + fun getById(id: UUID): Film + + fun getAll(): List + + fun searchByTitle(title: String): Film? } data class CreateFilmCommand( @@ -23,13 +36,6 @@ data class CreateFilmCommand( val jellyfinLibraryId: String? = null, ) -interface EditFilmUseCase { - fun edit( - id: UUID, - command: EditFilmCommand, - ): Film -} - data class EditFilmCommand( val title: String, val description: String, @@ -44,19 +50,3 @@ data class EditFilmCommand( val jellyfinItemId: String? = null, val jellyfinLibraryId: String? = null, ) - -interface DeleteFilmUseCase { - fun delete(id: UUID) -} - -interface GetFilmByIdUseCase { - fun getById(id: UUID): Film -} - -interface GetAllFilmsUseCase { - fun getAll(): List -} - -interface SearchFilmByTitleUseCase { - fun searchByTitle(title: String): Film? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt new file mode 100644 index 0000000..b28e68d --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -0,0 +1,25 @@ +package com.project.movienight.application.ports.input + +import com.project.movienight.domain.model.JellyfinSyncState +import com.project.movienight.domain.model.JellyfinSyncSummary +import java.time.OffsetDateTime + +interface JellyfinEventUseCase { + fun handle(command: HandleJellyfinEventCommand) +} + +data class HandleJellyfinEventCommand( + val eventId: String, + val serverId: String?, + val eventType: String, + val occurredAt: OffsetDateTime, + val jellyfinUserId: String, + val itemId: String, + val payload: Map?, +) + +interface JellyfinSyncUseCase { + fun syncNow(): JellyfinSyncSummary + + fun getSyncStates(): List +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt index b44820c..fcf539c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -4,8 +4,10 @@ import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.UserPreferences import java.util.UUID -interface UpsertUserPreferencesUseCase { +interface UserPreferencesUseCase { fun upsert(command: UpsertUserPreferencesCommand): UserPreferences + + fun get(userId: UUID): UserPreferences? } data class UpsertUserPreferencesCommand( @@ -17,7 +19,3 @@ data class UpsertUserPreferencesCommand( val moods: List = emptyList(), val contentTypes: List = emptyList(), ) - -interface GetUserPreferencesUseCase { - fun get(userId: UUID): UserPreferences? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b066a4f..2cef36c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -3,8 +3,19 @@ package com.project.movienight.application.ports.input import com.project.movienight.domain.model.User import java.util.UUID -interface CreateUserUseCase { +interface UserUseCase { fun create(command: CreateUserCommand): User + + fun edit( + id: UUID, + command: EditUserCommand, + ): User + + fun delete(id: UUID) + + fun getById(id: UUID): User + + fun getAll(): List } data class CreateUserCommand( @@ -12,26 +23,7 @@ data class CreateUserCommand( val email: String, ) -interface EditUserUseCase { - fun edit( - id: UUID, - command: EditUserCommand, - ): User -} - data class EditUserCommand( val name: String, val jellyfinUserId: String? = null, ) - -interface DeleteUserUseCase { - fun delete(id: UUID) -} - -interface GetUserByIdUseCase { - fun getById(id: UUID): User -} - -interface GetAllUsersUseCase { - fun getAll(): List -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..516fafd --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.RecommendationEventType + +interface BusinessMetricsPort { + fun recordFilmCreated() + + fun recordFilmEdited() + + fun recordFilmDeleted() + + fun recordFilmBlocked() + + fun recordRecommendationRequest() + + fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) + + fun recordRatingSubmitted() + + fun recordLibraryEvent() + + fun recordJellyfinSync(summary: JellyfinSyncSummary) + + fun recordJellyfinSyncFailure() + + fun recordJellyfinUnmappedUser() + + fun recordBackendWriteFailure() +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt new file mode 100644 index 0000000..825bb06 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt @@ -0,0 +1,21 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.FilmLibraryEntry +import java.util.UUID + +interface FilmLibraryEntryRepositoryPort { + fun save(entry: FilmLibraryEntry): FilmLibraryEntry + + fun findById(id: UUID): FilmLibraryEntry? + + fun findByUserId(userId: UUID): List + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibraryEntry? + + fun findAll(): List + + fun deleteById(id: UUID) +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt deleted file mode 100644 index 933f45c..0000000 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.project.movienight.application.ports.output - -import com.project.movienight.domain.model.FilmLibrary -import java.util.UUID - -interface FilmLibraryRepositoryPort { - fun save(filmLibrary: FilmLibrary): FilmLibrary - - fun findById(id: UUID): FilmLibrary? - - fun findByUserIdAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? - - fun findAll(): List - - fun deleteById(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index d18b2e6..1883fe9 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,8 +10,6 @@ interface FilmRepositoryPort { fun findByJellyfinItemId(jellyfinItemId: String): Film? - fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? - fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt new file mode 100644 index 0000000..0e48699 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +interface JellyfinCatalogPort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt new file mode 100644 index 0000000..b7b7751 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt @@ -0,0 +1,17 @@ +package com.project.movienight.application.ports.output + +import java.time.OffsetDateTime + +interface JellyfinEventStorePort { + fun save(event: JellyfinEventRecord): Boolean +} + +data class JellyfinEventRecord( + val eventId: String, + val serverId: String?, + val eventType: String, + val occurredAt: OffsetDateTime?, + val jellyfinUserId: String?, + val jellyfinItemId: String?, + val payload: String?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index dd69728..980ec30 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -7,10 +7,24 @@ import java.util.UUID interface UserRepositoryPort { fun save(user: User): User + fun createOAuthUser( + user: User, + provider: AuthProvider, + providerId: String, + ): User + + fun linkOAuthAccount( + userId: UUID, + provider: AuthProvider, + providerId: String, + ): User + fun findById(id: UUID): User? fun findByEmail(email: String): User? + fun findByJellyfinUserId(jellyfinUserId: String): User? + fun findAll(): List fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..c78336e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,46 +1,34 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import org.springframework.stereotype.Service import java.util.UUID @Service class FilmLibraryService( - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, + private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : CreateFilmLibraryUseCase, - AddFilmToLibraryUseCase, - MarkFilmViewedUseCase, - RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase, - ListFilmLibraryEntriesUseCase { - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - findByUserId(command.userId)?.let { return it } - throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) - } + private val businessMetricsService: BusinessMetricsPort, +) : FilmLibraryUseCase { + override fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry { + ensureFilmExists(command.filmId) - override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) if (existingEntry != null) { val saved = - filmLibraryRepository.save( + filmLibraryEntryRepository.save( existingEntry.copy( isViewed = false, watchedAt = null, @@ -51,8 +39,8 @@ class FilmLibraryService( } val saved = - filmLibraryRepository.save( - FilmLibrary( + filmLibraryEntryRepository.save( + FilmLibraryEntry( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, @@ -65,33 +53,35 @@ class FilmLibraryService( return saved } - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - val existingLibrary = - if (command.libraryId != null) { - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry { + val existingEntry = + if (command.entryId != null) { + filmLibraryEntryRepository.findById(command.entryId) + ?: throw EntityNotFoundException(entity = "Film library entry", id = command.entryId.toString()) } else { - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library entry", id = command.filmId.toString()) } - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + if (existingEntry.userId != command.userId || existingEntry.filmId != command.filmId) { throw DomainException("Film with id ${command.filmId} not found in user's library") } - filmLibraryRepository.deleteById(existingLibrary.id) + filmLibraryEntryRepository.deleteById(existingEntry.id) businessMetricsService.recordLibraryEvent() - return existingLibrary + return existingEntry } - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + override fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry { + ensureFilmExists(command.filmId) + + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId) val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() val saved = if (existingEntry == null) { - filmLibraryRepository.save( - FilmLibrary( + filmLibraryEntryRepository.save( + FilmLibraryEntry( id = idGenerator.generateId(), userId = command.userId, filmId = command.filmId, @@ -101,7 +91,7 @@ class FilmLibraryService( ), ) } else { - filmLibraryRepository.save( + filmLibraryEntryRepository.save( existingEntry.copy( isViewed = true, watchedAt = watchedAt, @@ -112,17 +102,14 @@ class FilmLibraryService( return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = - findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + override fun list(userId: UUID): List = filmLibraryEntryRepository.findByUserId(userId) - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun listAvailableFilms(userId: UUID): List { + val libraryFilmIds = list(userId).map { it.filmId }.toSet() + return filmRepository.findAll().filter { it.id !in libraryFilmIds } + } - private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { it.userId == userId } - - private fun findByUserAndFilmId( - userId: UUID, - filmId: UUID, - ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } + private fun ensureFilmExists(filmId: UUID) { + filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString()) + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt index 738bada..0653171 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -1,9 +1,8 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.application.ports.input.GetFilmRatingsUseCase +import com.project.movienight.application.ports.input.FilmRatingUseCase import com.project.movienight.application.ports.input.RateFilmCommand -import com.project.movienight.application.ports.input.RateFilmUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -19,9 +18,8 @@ class FilmRatingService( private val filmRepository: FilmRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : RateFilmUseCase, - GetFilmRatingsUseCase { + private val businessMetricsService: BusinessMetricsPort, +) : FilmRatingUseCase { override fun rate(command: RateFilmCommand): FilmRating { if (command.score !in 1..10) { throw DomainException("Film rating score must be between 1 and 10") diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index d69a6c8..30715f2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -1,22 +1,16 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateFilmCommand -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase import com.project.movienight.application.ports.input.EditFilmCommand -import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.application.ports.input.FilmUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film -import io.micrometer.core.instrument.Counter -import io.micrometer.core.instrument.MeterRegistry -import io.micrometer.core.instrument.Timer +import io.micrometer.core.annotation.Timed import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -26,89 +20,81 @@ class FilmService( private val filmRepository: FilmRepositoryPort, private val idGenerator: IdGenerator, private val filmConfig: FilmServiceProperties, - private val meterRegistry: MeterRegistry, -) : CreateFilmUseCase, - EditFilmUseCase, - DeleteFilmUseCase, - GetFilmByIdUseCase, - GetAllFilmsUseCase, - SearchFilmByTitleUseCase { + private val businessMetricsService: BusinessMetricsPort, +) : FilmUseCase { private val log = LoggerFactory.getLogger(javaClass) + @Timed( + value = "business_films_create_duration_seconds", + description = "Film creation duration", + ) override fun create(command: CreateFilmCommand): Film { - val sample = Timer.start(meterRegistry) + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, + ) - try { - log.debug( - "Create film request received: title='{}', descriptionLength={}", - command.title, - command.description.length, + if (filmConfig.isBlocked(command.title)) { + log.debug("Create film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Create film blocked by description policy") + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "description") + } + + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, ) - if (filmConfig.isBlocked(command.title)) { - log.debug("Create film blocked by title policy: title='{}'", command.title) - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - log.debug("Create film blocked by description policy") - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "description") - } - - val film = - Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) - - val saved = filmRepository.save(film) - filmCreatedCounter.increment() - return saved - } finally { - sample.stop(createFilmTimer) - } + val saved = filmRepository.save(film) + businessMetricsService.recordFilmCreated() + return saved } + @Timed( + value = "business_films_edit_duration_seconds", + description = "Film edit duration", + ) override fun edit( id: UUID, command: EditFilmCommand, ): Film { - val sample = Timer.start(meterRegistry) + log.debug("Edit film with id: {}", id) - try { - log.debug("Edit film with id: {}", id) + if (filmConfig.isBlocked(command.title)) { + log.debug("Edit film blocked by title policy: title='{}'", command.title) + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "title") + } + if (filmConfig.isBlocked(command.description)) { + log.debug("Edit film blocked by description policy") + businessMetricsService.recordFilmBlocked() + throw BlockedValueException(target = "Film", field = "description") + } - if (filmConfig.isBlocked(command.title)) { - log.debug("Edit film blocked by title policy: title='{}'", command.title) - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "title") - } - if (filmConfig.isBlocked(command.description)) { - log.debug("Edit film blocked by description policy") - filmBlockedCounter.increment() - throw BlockedValueException(target = "Film", field = "description") - } + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - var film = filmRepository.findById(id) - - if (film == null) { - log.debug("Film not found for edit: id='{}'", id) - throw EntityNotFoundException(entity = "Film", id = id.toString()) - } - - film = + val saved = + filmRepository.save( film.copy( title = command.title, description = command.description, @@ -122,37 +108,30 @@ class FilmService( externalUrl = command.externalUrl, jellyfinItemId = command.jellyfinItemId, jellyfinLibraryId = command.jellyfinLibraryId, - ) - - val saved = filmRepository.save(film) - filmEditedCounter.increment() - return saved - } finally { - sample.stop(editFilmTimer) - } + ), + ) + businessMetricsService.recordFilmEdited() + return saved } + @Timed( + value = "business_films_delete_duration_seconds", + description = "Film deletion duration", + ) override fun delete(id: UUID) { - val sample = Timer.start(meterRegistry) + log.debug("Delete film with id: {}", id) - try { - log.debug("Delete film with id: {}", id) + val film = filmRepository.findById(id) - val film = filmRepository.findById(id) - - if (film == null) { - log.debug("Film not found for delete: id='{}'", id) - throw EntityNotFoundException(entity = "Film", id = id.toString()) - } - - filmRepository.deleteById(id) - - filmDeletedCounter.increment() - - log.info("Film deleted: id='{}'", id) - } finally { - sample.stop(deleteFilmTimer) + if (film == null) { + log.debug("Film not found for delete: id='{}'", id) + throw EntityNotFoundException(entity = "Film", id = id.toString()) } + + filmRepository.deleteById(id) + businessMetricsService.recordFilmDeleted() + + log.info("Film deleted: id='{}'", id) } override fun getById(id: UUID): Film = @@ -161,46 +140,4 @@ class FilmService( override fun getAll(): List = filmRepository.findAll() override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) - - private val filmCreatedCounter = - Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) - - private val filmEditedCounter = - Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) - - private val filmDeletedCounter = - Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) - - private val filmBlockedCounter = - Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) - - private val createFilmTimer = - Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) - - private val editFilmTimer = - Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) - - private val deleteFilmTimer = - Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 64be033..b35c94c 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -1,79 +1,73 @@ package com.project.movienight.application.services import com.fasterxml.jackson.databind.ObjectMapper -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.HandleJellyfinEventCommand +import com.project.movienight.application.ports.input.JellyfinEventUseCase import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.JellyfinEventRecord +import com.project.movienight.application.ports.output.JellyfinEventStorePort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service -import java.time.OffsetDateTime +import org.springframework.transaction.annotation.Transactional @Service class JellyfinEventService( - private val jellyfinEventRepository: JellyfinEventRepository, + private val jellyfinEventStore: JellyfinEventStorePort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, - private val markFilmViewedUseCase: MarkFilmViewedUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, private val objectMapper: ObjectMapper, - private val businessMetricsService: BusinessMetricsService, -) { + private val businessMetricsService: BusinessMetricsPort, +) : JellyfinEventUseCase { private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed") - fun handleEvent( - eventId: String, - serverId: String?, - eventType: String, - occurredAt: OffsetDateTime, - jellyfinUserId: String, - itemId: String, - payload: Map?, - ) { - val payloadJson = payload?.let { objectMapper.writeValueAsString(it) } + @Transactional + override fun handle(command: HandleJellyfinEventCommand) { + val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val inserted = - jellyfinEventRepository.save( - eventId = eventId, - serverId = serverId, - eventType = eventType, - occurredAt = occurredAt, - jellyfinUserId = jellyfinUserId, - jellyfinItemId = itemId, - payload = payloadJson, + jellyfinEventStore.save( + JellyfinEventRecord( + eventId = command.eventId, + serverId = command.serverId, + eventType = command.eventType, + occurredAt = command.occurredAt, + jellyfinUserId = command.jellyfinUserId, + jellyfinItemId = command.itemId, + payload = payloadJson, + ), ) - if (inserted != 1) { + if (!inserted) { return } try { - if (playbackEventTypes.contains(eventType)) { - val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId } + if (playbackEventTypes.contains(command.eventType)) { + val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) if (localUser == null) { - jellyfinEventRepository.delete(eventId) businessMetricsService.recordJellyfinUnmappedUser() return } - val film = filmRepository.findByJellyfinItemId(itemId) + val film = filmRepository.findByJellyfinItemId(command.itemId) if (film == null) { - jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() return } - markFilmViewedUseCase.markViewed( + filmLibraryUseCase.markViewed( MarkFilmViewedCommand( userId = localUser.id, filmId = film.id, - watchedAt = occurredAt.toLocalDateTime(), + watchedAt = command.occurredAt.toLocalDateTime(), ), ) - businessMetricsService.recordLibraryEvent() } } catch ( @Suppress("TooGenericExceptionCaught") ex: RuntimeException, ) { - jellyfinEventRepository.delete(eventId) businessMetricsService.recordBackendWriteFailure() throw ex } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index 46faa88..cc852d4 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,18 +1,17 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.jellyfin.JellyfinApiClient -import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot -import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.input.JellyfinSyncUseCase +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.JellyfinCatalogPort +import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.JellyfinIntegrationProperties -import com.project.movienight.domain.model.ContentType import com.project.movienight.domain.model.Film -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary import org.springframework.scheduling.annotation.Scheduled @@ -20,18 +19,19 @@ import org.springframework.stereotype.Service import java.time.Duration import java.time.Instant import java.time.LocalDateTime +import java.util.UUID @Service class JellyfinSyncService( private val properties: JellyfinIntegrationProperties, - private val jellyfinApiClient: JellyfinApiClient, + private val jellyfinCatalog: JellyfinCatalogPort, private val userRepository: UserRepositoryPort, private val filmRepository: FilmRepositoryPort, - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val syncStateRepository: JellyfinSyncStateRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) { + private val businessMetricsService: BusinessMetricsPort, +) : JellyfinSyncUseCase { @Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}") fun scheduledSync() { if (properties.enabled) { @@ -39,13 +39,26 @@ class JellyfinSyncService( } } - fun syncNow(): JellyfinSyncSummary { + override fun syncNow(): JellyfinSyncSummary { if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) { return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) } + return try { + runSync() + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordJellyfinSyncFailure() + throw ex + } + } + + override fun getSyncStates(): List = syncStateRepository.findAll() + + private fun runSync(): JellyfinSyncSummary { val startedAt = Instant.now() - val remoteUsers = jellyfinApiClient.fetchUsers() + val remoteUsers = jellyfinCatalog.fetchUsers() val localUsersByJellyfinId = userRepository .findAll() @@ -64,7 +77,7 @@ class JellyfinSyncService( return@forEach } - val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id) + val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id) items.forEach { item -> syncItem(localUser.id, item) syncedItems += 1 @@ -94,12 +107,22 @@ class JellyfinSyncService( return summary } - fun getSyncStates(): List = syncStateRepository.findAll() - private fun syncItem( - userId: java.util.UUID, + userId: UUID, item: JellyfinLibraryItemSnapshot, ) { + val savedFilm = upsertFilm(item) + + if (item.isPlayed) { + markFilmViewed( + userId = userId, + filmId = savedFilm.id, + watchedAt = LocalDateTime.now(), + ) + } + } + + private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { val film = filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( title = item.title, @@ -130,24 +153,27 @@ class JellyfinSyncService( jellyfinLibraryId = item.jellyfinLibraryId, ) - val savedFilm = filmRepository.save(film) + return filmRepository.save(film) + } - if (item.isPlayed) { - val watchedAt = LocalDateTime.now() - val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id) - filmLibraryRepository.save( - existingEntry?.copy( - isViewed = true, - watchedAt = watchedAt, - ) ?: FilmLibrary( - id = idGenerator.generateId(), - userId = userId, - filmId = savedFilm.id, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ), - ) - } + private fun markFilmViewed( + userId: UUID, + filmId: UUID, + watchedAt: LocalDateTime, + ) { + val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + filmLibraryEntryRepository.save( + existingEntry?.copy( + isViewed = true, + watchedAt = watchedAt, + ) ?: FilmLibraryEntry( + id = idGenerator.generateId(), + userId = userId, + filmId = filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) } } diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt index 8f9fe04..ee02a92 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -3,7 +3,7 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase import com.project.movienight.application.ports.input.RecommendationOnboardingResult -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -11,7 +11,7 @@ import com.project.movienight.application.ports.output.UserPreferencesRepository import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmRating import com.project.movienight.domain.model.UserPreferences import com.project.movienight.domain.model.UserRecommendationWeights @@ -25,7 +25,7 @@ class RecommendationOnboardingService( private val filmRepository: FilmRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, private val idGenerator: IdGenerator, ) : CompleteRecommendationOnboardingUseCase { @@ -125,12 +125,12 @@ class RecommendationOnboardingService( userId: UUID, filmId: UUID, isViewed: Boolean, - ): FilmLibrary { + ): FilmLibraryEntry { val watchedAt = LocalDateTime.now().takeIf { isViewed } - val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId) - return filmLibraryRepository.save( + val existing = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) + return filmLibraryEntryRepository.save( existing?.copy(isViewed = isViewed, watchedAt = watchedAt) - ?: FilmLibrary( + ?: FilmLibraryEntry( id = idGenerator.generateId(), userId = userId, filmId = filmId, diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index e8a7722..da8dd01 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -1,13 +1,13 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AcceptRecommendationCommand import com.project.movienight.application.ports.input.AcceptRecommendationUseCase import com.project.movienight.application.ports.input.GetRecommendationsUseCase import com.project.movienight.application.ports.input.RecommendationQuery import com.project.movienight.application.ports.input.RejectRecommendationCommand import com.project.movienight.application.ports.input.RejectRecommendationUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmRatingRepositoryPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator @@ -17,7 +17,7 @@ import com.project.movienight.application.ports.output.UserRecommendationWeights import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmRating import com.project.movienight.domain.model.RecommendationEvent import com.project.movienight.domain.model.RecommendationEventType @@ -34,14 +34,14 @@ import kotlin.math.sqrt @Service class RecommendationService( private val filmRepository: FilmRepositoryPort, - private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort, private val filmRatingRepository: FilmRatingRepositoryPort, private val userPreferencesRepository: UserPreferencesRepositoryPort, private val userRepository: UserRepositoryPort, private val recommendationEventRepository: RecommendationEventRepositoryPort, private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort, private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, + private val businessMetricsService: BusinessMetricsPort, ) : GetRecommendationsUseCase, AcceptRecommendationUseCase, RejectRecommendationUseCase { @@ -54,7 +54,7 @@ class RecommendationService( val preferences = userPreferencesRepository.findByUserId(query.userId) val ratings = filmRatingRepository.findByUserId(query.userId) - val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId } + val libraryEntries = filmLibraryEntryRepository.findByUserId(query.userId) val libraryFilmIds = libraryEntries.map { it.filmId }.toSet() val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet() val films = filmRepository.findAll() @@ -278,7 +278,7 @@ class RecommendationService( private fun buildUserProfile( preferences: UserPreferences?, ratings: List, - libraryEntries: List, + libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, ): SparseVector { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt index de388ce..02fb178 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -1,8 +1,7 @@ package com.project.movienight.application.services -import com.project.movienight.application.ports.input.GetUserPreferencesUseCase import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand -import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase +import com.project.movienight.application.ports.input.UserPreferencesUseCase import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort import com.project.movienight.domain.model.UserPreferences import org.springframework.stereotype.Service @@ -10,8 +9,7 @@ import org.springframework.stereotype.Service @Service class UserPreferencesService( private val userPreferencesRepository: UserPreferencesRepositoryPort, -) : UpsertUserPreferencesUseCase, - GetUserPreferencesUseCase { +) : UserPreferencesUseCase { override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences = userPreferencesRepository.save( UserPreferences( diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..dc0e1e0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -1,12 +1,8 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateUserCommand -import com.project.movienight.application.ports.input.CreateUserUseCase -import com.project.movienight.application.ports.input.DeleteUserUseCase import com.project.movienight.application.ports.input.EditUserCommand -import com.project.movienight.application.ports.input.EditUserUseCase -import com.project.movienight.application.ports.input.GetAllUsersUseCase -import com.project.movienight.application.ports.input.GetUserByIdUseCase +import com.project.movienight.application.ports.input.UserUseCase import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.config.UserServiceProperties @@ -21,11 +17,7 @@ class UserService( private val userRepository: UserRepositoryPort, private val idGenerator: IdGenerator, private val userConfig: UserServiceProperties, -) : CreateUserUseCase, - EditUserUseCase, - DeleteUserUseCase, - GetUserByIdUseCase, - GetAllUsersUseCase { +) : UserUseCase { override fun create(command: CreateUserCommand): User { if (userConfig.isBlocked(command.name)) { throw BlockedValueException(target = "User", field = "name") @@ -36,7 +28,6 @@ class UserService( id = idGenerator.generateId(), name = command.name, email = command.email, - library = null, jellyfinUserId = null, ) return userRepository.save(user) diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index a4dc555..efb1f22 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -6,7 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties data class JellyfinIntegrationProperties( val enabled: Boolean = false, val baseUrl: String = "", - val webUrl: String = "", + val webUrl: String = baseUrl, val apiKey: String = "", val syncIntervalMs: Long = 1_800_000, val requestTimeoutMs: Long = 20_000, diff --git a/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt new file mode 100644 index 0000000..3971bc3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt @@ -0,0 +1,12 @@ +package com.project.movienight.config + +import io.micrometer.core.aop.TimedAspect +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +@Configuration +class MetricsConfiguration { + @Bean + fun timedAspect(meterRegistry: MeterRegistry): TimedAspect = TimedAspect(meterRegistry) +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt similarity index 89% rename from src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt rename to src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt index 8d7861c..cc81c1a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt @@ -3,7 +3,7 @@ package com.project.movienight.domain.model import java.time.LocalDateTime import java.util.UUID -data class FilmLibrary( +data class FilmLibraryEntry( val id: UUID, val userId: UUID, val filmId: UUID, diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index 236a698..10bc566 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -6,7 +6,6 @@ data class User( val id: UUID, val name: String, val email: String, - val library: FilmLibrary?, val preferences: UserPreferences? = null, val jellyfinUserId: String? = null, ) diff --git a/src/main/resources/db/ER.md b/src/main/resources/db/ER.md index 4fecb18..5c0a829 100644 --- a/src/main/resources/db/ER.md +++ b/src/main/resources/db/ER.md @@ -6,12 +6,26 @@ erDiagram UUID id PK VARCHAR name VARCHAR email + VARCHAR provider + VARCHAR provider_id + VARCHAR jellyfin_user_id + TIMESTAMP created_at } films { UUID id PK VARCHAR title TEXT description + VARCHAR content_type + INT release_year + TEXT genres + TEXT cast_members + TEXT directors + DOUBLE imdb_rating + DOUBLE platform_rating + TEXT external_url + VARCHAR jellyfin_item_id + VARCHAR jellyfin_library_id } favorites { @@ -20,8 +34,53 @@ erDiagram UUID film_id FK VARCHAR comment BOOLEAN is_viewed + TIMESTAMP watched_at + } + + user_preferences { + UUID user_id PK,FK + TEXT weighted_genres + TEXT plot_types + TEXT eras + TEXT cast_and_directors + TEXT moods + TEXT content_types + } + + film_ratings { + UUID id PK + UUID user_id FK + UUID film_id FK + INT score + VARCHAR note + TIMESTAMP created_at + TIMESTAMP updated_at + } + + jellyfin_events { + VARCHAR event_id PK + VARCHAR server_id + VARCHAR event_type + TIMESTAMP occurred_at + VARCHAR jellyfin_user_id + VARCHAR jellyfin_item_id + JSON payload + TIMESTAMP created_at + } + + jellyfin_sync_state { + UUID user_id PK,FK + TIMESTAMP last_synced_at + TIMESTAMP last_successful_sync_at + TEXT last_error + INT synced_item_count + TIMESTAMP updated_at } users ||--o{ favorites : has films ||--o{ favorites : linked + users ||--o{ film_ratings : rates + films ||--o{ film_ratings : rated + users ||--|| user_preferences : configures + users ||--|| jellyfin_sync_state : syncs ``` diff --git a/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql new file mode 100644 index 0000000..9415580 --- /dev/null +++ b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql @@ -0,0 +1,11 @@ +DROP TABLE IF EXISTS public.ratings; + +ALTER TABLE public.users + DROP COLUMN IF EXISTS jellyfin_id; + +ALTER TABLE public.films + DROP COLUMN IF EXISTS jellyfin_id; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_jellyfin_user_id ON public.users(jellyfin_user_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_films_jellyfin_item_id ON public.films(jellyfin_item_id); +CREATE INDEX IF NOT EXISTS idx_films_jellyfin_library_id ON public.films(jellyfin_library_id); diff --git a/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt new file mode 100644 index 0000000..a963e59 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt @@ -0,0 +1,17 @@ +package com.project.movienight + +import org.junit.jupiter.api.Test +import kotlin.test.assertNotNull + +class ClassLoaderTest { + @Test + fun `can load OAuth2ClientProperties class`() { + val clazz = + Class.forName( + "org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties", + ) + assertNotNull(clazz) + println("Successfully loaded: ${clazz.name}") + println("ClassLoader: ${clazz.classLoader}") + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index 4e65569..859d9a4 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -26,7 +26,6 @@ class UserEntityMappingTest { assertEquals(entity.id, user.id) assertEquals(entity.name, user.name) assertEquals(entity.email, user.email) - assertNull(user.library) assertNull(user.jellyfinUserId) } @@ -37,7 +36,6 @@ class UserEntityMappingTest { id = UUID.randomUUID(), name = "Jane", email = "jane@mail.com", - library = null, ) val entity = user.toEntity(AuthProvider.YANDEX, "yandex456") @@ -56,7 +54,6 @@ class UserEntityMappingTest { id = UUID.randomUUID(), name = "Bob", email = "bob@mail.com", - library = null, ) val entity = user.toEntity() @@ -72,7 +69,6 @@ class UserEntityMappingTest { id = UUID.randomUUID(), name = "Alice", email = "alice@email.com", - library = null, ) val mapped = original.toEntity().toDomain() diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt similarity index 61% rename from src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt rename to src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt index d880f02..fa8c616 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt @@ -1,7 +1,7 @@ package com.project.movienight.adapters.persistence.jdbc import com.project.movienight.domain.model.Film -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.User import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -19,9 +19,9 @@ import java.util.UUID @SpringBootTest @ActiveProfiles("test") -class FilmLibraryRepositoryIntegrationTest { +class FilmLibraryEntryRepositoryIntegrationTest { @Autowired - private lateinit var filmLibraryRepository: FilmLibraryRepository + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepository @Autowired private lateinit var userRepository: UserRepository @@ -62,7 +62,7 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save new film library entry and return saved entry`() { val entry = - FilmLibrary( + FilmLibraryEntry( id = UUID.randomUUID(), userId = testUser.id, filmId = testFilm.id, @@ -70,7 +70,7 @@ class FilmLibraryRepositoryIntegrationTest { isViewed = false, ) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertEquals(entry.id, savedEntry.id) @@ -83,17 +83,17 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should update existing film library entry`() { val entryId = UUID.randomUUID() - val originalEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false) - filmLibraryRepository.save(originalEntry) + val originalEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false) + filmLibraryEntryRepository.save(originalEntry) - val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) - val result = filmLibraryRepository.save(updatedEntry) + val updatedEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true) + val result = filmLibraryEntryRepository.save(updatedEntry) assertEquals(entryId, result.id) assertEquals("Уже посмотрел, потрясающе!", result.comment) assertTrue(result.isViewed) - val foundEntry = filmLibraryRepository.findById(entryId) + val foundEntry = filmLibraryEntryRepository.findById(entryId) assertNotNull(foundEntry) assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment) assertTrue(foundEntry?.isViewed ?: false) @@ -101,10 +101,10 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should find film library entry by id`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false) + filmLibraryEntryRepository.save(entry) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNotNull(foundEntry) assertEquals(entry.id, foundEntry?.id) @@ -118,22 +118,22 @@ class FilmLibraryRepositoryIntegrationTest { fun `should return null when film library entry not found by id`() { val nonExistentId = UUID.randomUUID() - val foundEntry = filmLibraryRepository.findById(nonExistentId) + val foundEntry = filmLibraryEntryRepository.findById(nonExistentId) assertNull(foundEntry) } @Test fun `should find all film library entries`() { - val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false) - val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true) - val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + val entry1 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false) + val entry2 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true) + val entry3 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false) - filmLibraryRepository.save(entry1) - filmLibraryRepository.save(entry2) - filmLibraryRepository.save(entry3) + filmLibraryEntryRepository.save(entry1) + filmLibraryEntryRepository.save(entry2) + filmLibraryEntryRepository.save(entry3) - val allEntries = filmLibraryRepository.findAll() + val allEntries = filmLibraryEntryRepository.findAll() assertEquals(3, allEntries.size) assertTrue(allEntries.any { it.id == entry1.id }) @@ -143,19 +143,19 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should return empty list when no film library entries exist`() { - val allEntries = filmLibraryRepository.findAll() + val allEntries = filmLibraryEntryRepository.findAll() assertTrue(allEntries.isEmpty()) } @Test fun `should delete film library entry by id`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false) + filmLibraryEntryRepository.save(entry) - filmLibraryRepository.deleteById(entry.id) + filmLibraryEntryRepository.deleteById(entry.id) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNull(foundEntry) } @@ -163,14 +163,14 @@ class FilmLibraryRepositoryIntegrationTest { fun `should not throw exception when deleting non-existent entry`() { val nonExistentId = UUID.randomUUID() - filmLibraryRepository.deleteById(nonExistentId) + filmLibraryEntryRepository.deleteById(nonExistentId) } @Test fun `should save entry with null comment`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertNull(savedEntry.comment) @@ -178,9 +178,9 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save entry with isViewed true`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertTrue(savedEntry.isViewed) @@ -188,9 +188,9 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should save entry with isViewed false`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false) - val savedEntry = filmLibraryRepository.save(entry) + val savedEntry = filmLibraryEntryRepository.save(entry) assertNotNull(savedEntry) assertFalse(savedEntry.isViewed) @@ -198,23 +198,23 @@ class FilmLibraryRepositoryIntegrationTest { @Test fun `should cascade delete entries when user is deleted`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false) + filmLibraryEntryRepository.save(entry) userRepository.deleteById(testUser.id) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNull(foundEntry) } @Test fun `should cascade delete entries when film is deleted`() { - val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false) - filmLibraryRepository.save(entry) + val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false) + filmLibraryEntryRepository.save(entry) filmRepository.deleteById(testFilm.id) - val foundEntry = filmLibraryRepository.findById(entry.id) + val foundEntry = filmLibraryEntryRepository.findById(entry.id) assertNull(foundEntry) } } diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt index 02b588a..7d0c2bd 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -1,5 +1,6 @@ package com.project.movienight.adapters.persistence.jdbc +import com.project.movienight.domain.model.AuthProvider import com.project.movienight.domain.model.User import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -46,7 +47,6 @@ class UserRepositoryIntegrationTest { id = UUID.randomUUID(), name = "John Doe", email = "john@example.com", - library = null, ) val savedUser = userRepository.save(user) @@ -61,10 +61,10 @@ class UserRepositoryIntegrationTest { fun `should update existing user`() { // given val userId = UUID.randomUUID() - val originalUser = User(userId, "John Doe", "john@example.com", null) + val originalUser = User(userId, "John Doe", "john@example.com") userRepository.save(originalUser) - val updatedUser = User(userId, "Jane Doe", "jane@example.com", null) + val updatedUser = User(userId, "Jane Doe", "jane@example.com") val result = userRepository.save(updatedUser) assertEquals(userId, result.id) @@ -80,7 +80,7 @@ class UserRepositoryIntegrationTest { @Test fun `should find user by id`() { // given - val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + val user = User(UUID.randomUUID(), "John Doe", "john@example.com") userRepository.save(user) // when @@ -108,9 +108,9 @@ class UserRepositoryIntegrationTest { @Test fun `should find all users`() { // given - val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com", null) - val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null) - val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null) + val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com") + val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com") + val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com") userRepository.save(user1) userRepository.save(user2) @@ -138,7 +138,7 @@ class UserRepositoryIntegrationTest { @Test fun `should delete user by id`() { // given - val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null) + val user = User(UUID.randomUUID(), "John Doe", "john@example.com") userRepository.save(user) // when @@ -157,4 +157,41 @@ class UserRepositoryIntegrationTest { // when & then (no exception should be thrown) userRepository.deleteById(nonExistentId) } + + @Test + fun `should create OAuth user with provider identity`() { + val user = User(UUID.randomUUID(), "OAuth User", "oauth@example.com") + + val savedUser = userRepository.createOAuthUser(user, AuthProvider.GOOGLE, "google-123") + + assertEquals(user.id, savedUser.id) + assertEquals(user.email, savedUser.email) + + val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123") + assertNotNull(foundByProvider) + assertEquals(user.id, foundByProvider?.id) + } + + @Test + fun `should link OAuth account to existing user`() { + val user = userRepository.save(User(UUID.randomUUID(), "Link User", "link@example.com")) + + val linkedUser = userRepository.linkOAuthAccount(user.id, AuthProvider.YANDEX, "yandex-456") + + assertEquals(user.id, linkedUser.id) + val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.YANDEX, "yandex-456") + assertNotNull(foundByProvider) + assertEquals(user.id, foundByProvider?.id) + } + + @Test + fun `find by email should include jellyfin user id`() { + val user = userRepository.save(User(UUID.randomUUID(), "Jellyfin User", "jellyfin@example.com")) + jdbcTemplate.update("UPDATE users SET jellyfin_user_id = ? WHERE id = ?", "jellyfin-789", user.id) + + val foundUser = userRepository.findByEmail(user.email) + + assertNotNull(foundUser) + assertEquals("jellyfin-789", foundUser?.jellyfinUserId) + } } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt index 033cccd..04a794d 100644 --- a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -1,11 +1,6 @@ package com.project.movienight.adapters.web -import com.project.movienight.application.ports.input.CreateFilmUseCase -import com.project.movienight.application.ports.input.DeleteFilmUseCase -import com.project.movienight.application.ports.input.EditFilmUseCase -import com.project.movienight.application.ports.input.GetAllFilmsUseCase -import com.project.movienight.application.ports.input.GetFilmByIdUseCase -import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase +import com.project.movienight.application.ports.input.FilmUseCase import com.project.movienight.domain.model.Film import io.mockk.every import io.mockk.mockk @@ -19,20 +14,15 @@ import java.util.UUID class FilmControllerSearchTest { private lateinit var mockMvc: MockMvc - private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase + private lateinit var filmUseCase: FilmUseCase @BeforeEach fun setup() { - searchFilmByTitleUseCase = mockk() + filmUseCase = mockk() val controller = FilmController( - createFilmUseCase = mockk(), - editFilmUseCase = mockk(), - deleteFilmUseCase = mockk(), - getFilmByIdUseCase = mockk(), - getAllFilmsUseCase = mockk(), - searchFilmByTitleUseCase = searchFilmByTitleUseCase, + filmUseCase = filmUseCase, ) mockMvc = MockMvcBuilders.standaloneSetup(controller).build() @@ -43,7 +33,7 @@ class FilmControllerSearchTest { val title = "Inception" val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist") - every { searchFilmByTitleUseCase.searchByTitle(title) } returns film + every { filmUseCase.searchByTitle(title) } returns film mockMvc .get("/api/films/search") { @@ -55,14 +45,14 @@ class FilmControllerSearchTest { jsonPath("$.description") { value("A dream heist") } } - verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + verify(exactly = 1) { filmUseCase.searchByTitle(title) } } @Test fun `search returns 404 when title is not found`() { val title = "Unknown Title" - every { searchFilmByTitleUseCase.searchByTitle(title) } returns null + every { filmUseCase.searchByTitle(title) } returns null mockMvc .get("/api/films/search") { @@ -72,6 +62,6 @@ class FilmControllerSearchTest { content { string("") } } - verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) } + verify(exactly = 1) { filmUseCase.searchByTitle(title) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 7145507..beb7ec6 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -1,15 +1,15 @@ package com.project.movienight.application.services -import com.project.movienight.adapters.metrics.BusinessMetricsService import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.GetFilmLibraryQuery import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.BusinessMetricsPort +import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort +import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.FilmLibraryEntry import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -22,83 +22,52 @@ import org.junit.jupiter.api.assertThrows import java.util.UUID class FilmLibraryServiceTest { - private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort + private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort + private lateinit var filmRepository: FilmRepositoryPort private lateinit var idGenerator: IdGenerator - private lateinit var businessMetricsService: BusinessMetricsService + private lateinit var businessMetricsService: BusinessMetricsPort private lateinit var filmLibraryService: FilmLibraryService @BeforeEach fun setup() { - filmLibraryRepository = mockk() + filmLibraryEntryRepository = mockk() + filmRepository = mockk() idGenerator = mockk() businessMetricsService = mockk(relaxed = true) - filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService) - } - - @Test - fun `should throw EntityNotFoundException when creating library for user with no entries`() { - val userId = UUID.randomUUID() - val command = CreateFilmLibraryCommand(userId = userId, name = "My Films") - - every { filmLibraryRepository.findAll() } returns emptyList() - - assertThrows { - filmLibraryService.create(command) - } - - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } - verify(exactly = 0) { filmLibraryRepository.save(any()) } - } - - @Test - fun `should return existing library when user already has one`() { - val userId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = UUID.randomUUID(), - comment = "Existing Library", - isViewed = false, + filmLibraryService = + FilmLibraryService( + filmLibraryEntryRepository, + filmRepository, + idGenerator, + businessMetricsService, ) - val command = CreateFilmLibraryCommand(userId = userId, name = "New Library") - - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - - val result = filmLibraryService.create(command) - - assertEquals(existingLibrary, result) - - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { idGenerator.generateId() } - verify(exactly = 0) { filmLibraryRepository.save(any()) } } @Test - fun `should add film to new library when user has no library`() { + fun `should add film as new library entry`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() - val libraryId = UUID.randomUUID() + val entryId = UUID.randomUUID() val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId) - val expectedLibrary = - FilmLibrary( - id = libraryId, + val expectedEntry = + FilmLibraryEntry( + id = entryId, userId = userId, filmId = filmId, comment = null, isViewed = false, ) - every { filmLibraryRepository.findAll() } returns emptyList() - every { idGenerator.generateId() } returns libraryId + every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description") + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null + every { idGenerator.generateId() } returns entryId every { - filmLibraryRepository.save( + filmLibraryEntryRepository.save( match { it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false }, ) - } returns expectedLibrary + } returns expectedEntry val result = filmLibraryService.addFilm(command) @@ -106,62 +75,46 @@ class FilmLibraryServiceTest { assertEquals(filmId, result.filmId) assertEquals(userId, result.userId) - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 1) { filmLibraryEntryRepository.save(any()) } } @Test - fun `should add film as a new library entry when another film already exists`() { + fun `should reset viewed state when adding existing entry`() { val userId = UUID.randomUUID() - val oldFilmId = UUID.randomUUID() - val newFilmId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( + val filmId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( id = UUID.randomUUID(), userId = userId, - filmId = oldFilmId, + filmId = filmId, comment = "My Library", isViewed = true, ) - val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId) - val createdLibrary = - FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = newFilmId, - comment = null, - isViewed = false, - ) + val updatedEntry = existingEntry.copy(isViewed = false, watchedAt = null) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - every { idGenerator.generateId() } returns createdLibrary.id - every { - filmLibraryRepository.save( - match { - it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false - }, - ) - } returns createdLibrary + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry + every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description") + every { filmLibraryEntryRepository.save(updatedEntry) } returns updatedEntry - val result = filmLibraryService.addFilm(command) + val result = filmLibraryService.addFilm(AddFilmToLibraryCommand(userId = userId, filmId = filmId)) - assertEquals(newFilmId, result.filmId) - assertEquals(false, result.isViewed) + assertEquals(updatedEntry, result) - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { idGenerator.generateId() } - verify(exactly = 1) { filmLibraryRepository.save(any()) } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 0) { idGenerator.generateId() } + verify(exactly = 1) { filmLibraryEntryRepository.save(updatedEntry) } } @Test fun `should remove film from library successfully`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() - val libraryId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = libraryId, + val entryId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = entryId, userId = userId, filmId = filmId, comment = "My Library", @@ -169,122 +122,70 @@ class FilmLibraryServiceTest { ) val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - justRun { filmLibraryRepository.deleteById(libraryId) } + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry + justRun { filmLibraryEntryRepository.deleteById(entryId) } val result = filmLibraryService.removeFilm(command) - assertEquals(existingLibrary, result) + assertEquals(existingEntry, result) - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 1) { filmLibraryEntryRepository.deleteById(entryId) } } @Test - fun `should throw EntityNotFoundException when removing film from non-existent library`() { + fun `should throw EntityNotFoundException when removing non-existent entry`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId) - every { filmLibraryRepository.findAll() } returns emptyList() + every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null assertThrows { filmLibraryService.removeFilm(command) } - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } + verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) } } @Test - fun `should throw DomainException when removing film that is not in library`() { - val userId = UUID.randomUUID() - val libraryFilmId = UUID.randomUUID() - val differentFilmId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = UUID.randomUUID(), - userId = userId, - filmId = libraryFilmId, - comment = "My Library", - isViewed = false, - ) - val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId) - - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) - - assertThrows { - filmLibraryService.removeFilm(command) - } - - verify(exactly = 1) { filmLibraryRepository.findAll() } - verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } - } - - @Test - fun `should throw EntityNotFoundException when libraryId does not match`() { + fun `should throw DomainException when entry id belongs to another film`() { val userId = UUID.randomUUID() val filmId = UUID.randomUUID() - val actualLibraryId = UUID.randomUUID() - val wrongLibraryId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = actualLibraryId, - userId = userId, - filmId = filmId, - comment = "My Library", - isViewed = false, - ) - val command = - RemoveFilmFromLibraryCommand( - userId = userId, - filmId = filmId, - libraryId = wrongLibraryId, - ) - - every { filmLibraryRepository.findById(wrongLibraryId) } returns null - - assertThrows { - filmLibraryService.removeFilm(command) - } - - verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) } - verify(exactly = 0) { filmLibraryRepository.deleteById(any()) } - } - - @Test - fun `should get library successfully`() { - val userId = UUID.randomUUID() - val existingLibrary = - FilmLibrary( - id = UUID.randomUUID(), + val entryId = UUID.randomUUID() + val existingEntry = + FilmLibraryEntry( + id = entryId, userId = userId, filmId = UUID.randomUUID(), comment = "My Library", isViewed = false, ) - val query = GetFilmLibraryQuery(userId = userId) + val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId, entryId = entryId) - every { filmLibraryRepository.findAll() } returns listOf(existingLibrary) + every { filmLibraryEntryRepository.findById(entryId) } returns existingEntry - val result = filmLibraryService.getLibrary(query) + assertThrows { + filmLibraryService.removeFilm(command) + } - assertEquals(existingLibrary, result) - - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findById(entryId) } + verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) } } @Test - fun `should throw EntityNotFoundException when getting non-existent library`() { + fun `should list entries by user`() { val userId = UUID.randomUUID() - val query = GetFilmLibraryQuery(userId = userId) + val entries = + listOf( + FilmLibraryEntry(UUID.randomUUID(), userId, UUID.randomUUID(), null, false), + ) - every { filmLibraryRepository.findAll() } returns emptyList() + every { filmLibraryEntryRepository.findByUserId(userId) } returns entries - assertThrows { - filmLibraryService.getLibrary(query) - } + assertEquals(entries, filmLibraryService.list(userId)) - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index e0f96be..5c48c2c 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -2,13 +2,13 @@ package com.project.movienight.application.services import com.project.movienight.application.ports.input.CreateFilmCommand import com.project.movienight.application.ports.input.EditFilmCommand +import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmRepositoryPort import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.config.FilmServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.Film -import io.micrometer.core.instrument.simple.SimpleMeterRegistry import io.mockk.every import io.mockk.justRun import io.mockk.mockk @@ -24,7 +24,7 @@ class FilmServiceTest { private lateinit var filmRepository: FilmRepositoryPort private lateinit var idGenerator: IdGenerator private lateinit var filmConfig: FilmServiceProperties - private lateinit var meterRegistry: SimpleMeterRegistry + private lateinit var businessMetricsService: BusinessMetricsPort private lateinit var filmService: FilmService @BeforeEach @@ -32,8 +32,8 @@ class FilmServiceTest { filmRepository = mockk() idGenerator = mockk() filmConfig = mockk() - meterRegistry = SimpleMeterRegistry() - filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry) + businessMetricsService = mockk(relaxed = true) + filmService = FilmService(filmRepository, idGenerator, filmConfig, businessMetricsService) } @Test diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt index c54a909..ce94f2e 100644 --- a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -37,7 +37,7 @@ class UserServiceTest { fun `should create user successfully`() { val command = CreateUserCommand(name = "John Doe", email = "john@example.com") val userId = UUID.randomUUID() - val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com") every { userConfig.isBlocked("John Doe") } returns false every { idGenerator.generateId() } returns userId @@ -74,8 +74,8 @@ class UserServiceTest { fun `should edit user successfully`() { val userId = UUID.randomUUID() val command = EditUserCommand(name = "Jane Doe") - val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) - val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null) + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com") + val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com") every { userConfig.isBlocked("Jane Doe") } returns false every { userRepository.findById(userId) } returns existingUser @@ -128,7 +128,7 @@ class UserServiceTest { @Test fun `should delete user successfully`() { val userId = UUID.randomUUID() - val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null) + val existingUser = User(id = userId, name = "John Doe", email = "john@example.com") every { userRepository.findById(userId) } returns existingUser justRun { userRepository.deleteById(userId) } diff --git a/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt new file mode 100644 index 0000000..34ae9c7 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt @@ -0,0 +1,29 @@ +package com.project.movienight.config + +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.web.SecurityFilterChain + +@TestConfiguration +@EnableWebSecurity +class TestSecurityConfiguration { + @Bean + @Primary + fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .authorizeHttpRequests { auth -> + auth.anyRequest().permitAll() + }.csrf { csrf -> + csrf.disable() + }.headers { headers -> + headers.frameOptions { frameOptions -> + frameOptions.sameOrigin() + } + } + + return http.build() + } +} From df8d3ee404c154104201cf9a7a32f6165e94962b Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:07:03 +0300 Subject: [PATCH 074/106] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../movienight/application/services/FilmLibraryService.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index b7f24dd..bdc184e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -62,7 +62,9 @@ class FilmLibraryService( ) ) businessMetricsService.recordLibraryEvent() - log.info("Film re-added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) + log.info( + "Film re-added to library: userId={}, filmId={}, entryId={}", + command.userId, command.filmId, saved.id) return saved } @@ -101,7 +103,9 @@ class FilmLibraryService( filmLibraryRepository.deleteById(existingLibrary.id) businessMetricsService.recordLibraryEvent() - log.info("Film removed from library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, existingLibrary.id) + log.info( + "Film removed from library: userId={}, filmId={}, entryId={}", + command.userId, command.filmId, existingLibrary.id) return existingLibrary } From 9e9e2244abbfdb7190c9663eeac2f8d81579b5d3 Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:38:28 +0300 Subject: [PATCH 075/106] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/FilmLibraryService.kt | 360 ++++++++++-------- .../application/services/FilmService.kt | 152 ++++---- .../application/services/UserService.kt | 45 ++- 3 files changed, 305 insertions(+), 252 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index bdc184e..07d8d5a 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,168 +1,192 @@ -package com.project.movienight.application.services - -import com.project.movienight.adapters.metrics.BusinessMetricsService -import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -import com.project.movienight.application.ports.input.GetFilmLibraryQuery -import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase -import com.project.movienight.application.ports.input.MarkFilmViewedCommand -import com.project.movienight.application.ports.input.MarkFilmViewedUseCase -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -import com.project.movienight.application.ports.output.IdGenerator -import com.project.movienight.domain.exception.DomainException -import com.project.movienight.domain.exception.EntityNotFoundException -import com.project.movienight.domain.model.FilmLibrary -import org.slf4j.LoggerFactory -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class FilmLibraryService( - private val filmLibraryRepository: FilmLibraryRepositoryPort, - private val idGenerator: IdGenerator, - private val businessMetricsService: BusinessMetricsService, -) : CreateFilmLibraryUseCase, - AddFilmToLibraryUseCase, - MarkFilmViewedUseCase, - RemoveFilmFromLibraryUseCase, - GetFilmLibraryUseCase, - ListFilmLibraryEntriesUseCase { - - private val log = LoggerFactory.getLogger(javaClass) - - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - log.info("Creating film library for user: {}", command.userId) - log.debug("Create library request: userId={}, name={}", command.userId, command.name) - - val existing = findByUserId(command.userId) - if (existing != null) { - log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) - return existing - } - - log.warn("Library not found for user {}, cannot create", command.userId) - throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) - } - - override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) - - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - if (existingEntry != null) { - log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) - val saved = filmLibraryRepository.save( - existingEntry.copy( - isViewed = false, - watchedAt = null, - ) - ) - businessMetricsService.recordLibraryEvent() - log.info( - "Film re-added to library: userId={}, filmId={}, entryId={}", - command.userId, command.filmId, saved.id) - return saved - } - - val saved = filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = false, - watchedAt = null, - ) - ) - businessMetricsService.recordLibraryEvent() - log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) - return saved - } - - override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) - - val existingLibrary = if (command.libraryId != null) { - log.debug("Looking up by libraryId: {}", command.libraryId) - filmLibraryRepository.findById(command.libraryId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) - } else { - log.debug("Looking up by userId and filmId") - findByUserAndFilmId(command.userId, command.filmId) - ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) - } - - if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) - throw DomainException("Film with id ${command.filmId} not found in user's library") - } - - filmLibraryRepository.deleteById(existingLibrary.id) - businessMetricsService.recordLibraryEvent() - log.info( - "Film removed from library: userId={}, filmId={}, entryId={}", - command.userId, command.filmId, existingLibrary.id) - return existingLibrary - } - - override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) - - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) - val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - - log.debug("Marking as viewed at: {}", watchedAt) - - val saved = if (existingEntry == null) { - log.debug("Film not in library, creating new entry as viewed") - filmLibraryRepository.save( - FilmLibrary( - id = idGenerator.generateId(), - userId = command.userId, - filmId = command.filmId, - comment = null, - isViewed = true, - watchedAt = watchedAt, - ) - ) - } else { - log.debug("Updating existing entry: entryId={}, was viewed={}", existingEntry.id, existingEntry.isViewed) - filmLibraryRepository.save( - existingEntry.copy( - isViewed = true, - watchedAt = watchedAt, - ) - ) - } - businessMetricsService.recordLibraryEvent() - log.info("Film marked as viewed: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) - return saved - } - - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { - log.debug("Getting library for user: {}", query.userId) - val library = findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) - return library - } - - override fun list(userId: UUID): List { - log.debug("Listing all library entries for user: {}", userId) - val entries = filmLibraryRepository.findAll().filter { it.userId == userId } - log.info("User {} has {} films in library", userId, entries.size) - return entries - } - - private fun findByUserId(userId: UUID): FilmLibrary? { - return filmLibraryRepository.findAll().firstOrNull { it.userId == userId } - } - - private fun findByUserAndFilmId(userId: UUID, filmId: UUID): FilmLibrary? { - return filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } - } -} +//package com.project.movienight.application.services +// +//import com.project.movienight.adapters.metrics.BusinessMetricsService +//import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +//import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase +//import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +//import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +//import com.project.movienight.application.ports.input.GetFilmLibraryQuery +//import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +//import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +//import com.project.movienight.application.ports.input.MarkFilmViewedCommand +//import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +//import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +//import com.project.movienight.application.ports.output.IdGenerator +//import com.project.movienight.domain.exception.DomainException +//import com.project.movienight.domain.exception.EntityNotFoundException +//import com.project.movienight.domain.model.FilmLibrary +//import org.slf4j.LoggerFactory +//import org.springframework.stereotype.Service +//import java.util.UUID +// +//@Service +//class FilmLibraryService( +// private val filmLibraryRepository: FilmLibraryRepositoryPort, +// private val idGenerator: IdGenerator, +// private val businessMetricsService: BusinessMetricsService, +//) : CreateFilmLibraryUseCase, +// AddFilmToLibraryUseCase, +// MarkFilmViewedUseCase, +// RemoveFilmFromLibraryUseCase, +// GetFilmLibraryUseCase, +// ListFilmLibraryEntriesUseCase { +// private val log = LoggerFactory.getLogger(javaClass) +// +// override fun create(command: CreateFilmLibraryCommand): FilmLibrary { +// log.info("Creating film library for user: {}", command.userId) +// log.debug("Create library request: userId={}, name={}", command.userId, command.name) +// +// val existing = findByUserId(command.userId) +// if (existing != null) { +// log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) +// return existing +// } +// +// log.warn("Library not found for user {}, cannot create", command.userId) +// throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) +// } +// +// override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { +// log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) +// if (existingEntry != null) { +// log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) +// val saved = +// filmLibraryRepository.save( +// existingEntry.copy( +// isViewed = false, +// watchedAt = null, +// ), +// ) +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film re-added to library: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// saved.id, +// ) +// return saved +// } +// +// val saved = +// filmLibraryRepository.save( +// FilmLibrary( +// id = idGenerator.generateId(), +// userId = command.userId, +// filmId = command.filmId, +// comment = null, +// isViewed = false, +// watchedAt = null, +// ), +// ) +// businessMetricsService.recordLibraryEvent() +// log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) +// return saved +// } +// +// override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { +// log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingLibrary = +// if (command.libraryId != null) { +// log.debug("Looking up by libraryId: {}", command.libraryId) +// filmLibraryRepository.findById(command.libraryId) +// ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) +// } else { +// log.debug("Looking up by userId and filmId") +// findByUserAndFilmId(command.userId, command.filmId) +// ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) +// } +// +// if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { +// log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) +// throw DomainException("Film with id ${command.filmId} not found in user's library") +// } +// +// filmLibraryRepository.deleteById(existingLibrary.id) +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film removed from library: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// existingLibrary.id, +// ) +// return existingLibrary +// } +// +// override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { +// log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) +// +// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) +// val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() +// +// log.debug("Marking as viewed at: {}", watchedAt) +// +// val saved = +// if (existingEntry == null) { +// log.debug("Film not in library, creating new entry as viewed") +// filmLibraryRepository.save( +// FilmLibrary( +// id = idGenerator.generateId(), +// userId = command.userId, +// filmId = command.filmId, +// comment = null, +// isViewed = true, +// watchedAt = watchedAt, +// ), +// ) +// } else { +// log.debug( +// "Updating existing entry: entryId={}, was viewed={}", +// existingEntry.id, +// existingEntry.isViewed, +// ) +// filmLibraryRepository.save( +// existingEntry.copy( +// isViewed = true, +// watchedAt = watchedAt, +// ), +// ) +// } +// businessMetricsService.recordLibraryEvent() +// log.info( +// "Film marked as viewed: userId={}, filmId={}, entryId={}", +// command.userId, +// command.filmId, +// saved.id, +// ) +// return saved +// } +// +// override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { +// log.debug("Getting library for user: {}", query.userId) +// val library = +// findByUserId(query.userId) +// ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) +// log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) +// return library +// } +// +// override fun list(userId: UUID): List { +// log.debug("Listing all library entries for user: {}", userId) +// val entries = filmLibraryRepository.findAll().filter { it.userId == userId } +// log.info("User {} has {} films in library", userId, entries.size) +// return entries +// } +// +// private fun findByUserId(userId: UUID): FilmLibrary? = +// filmLibraryRepository.findAll().firstOrNull { +// it.userId == userId +// } +// +// private fun findByUserAndFilmId( +// userId: UUID, +// filmId: UUID, +// ): FilmLibrary? = +// filmLibraryRepository.findAll().firstOrNull { +// it.userId == userId && it.filmId == filmId +// } +//} diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index c1efda4..93de089 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -33,13 +33,17 @@ class FilmService( GetFilmByIdUseCase, GetAllFilmsUseCase, SearchFilmByTitleUseCase { - private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) - log.debug("Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", - command.title, command.description.length, command.genres, command.releaseYear) + log.debug( + "Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", + command.title, + command.description.length, + command.genres, + command.releaseYear, + ) val sample = Timer.start(meterRegistry) @@ -55,21 +59,22 @@ class FilmService( throw BlockedValueException(target = "Film", field = "description") } - val film = Film( - id = idGenerator.generateId(), - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + val film = + Film( + id = idGenerator.generateId(), + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmCreatedCounter.increment() @@ -80,10 +85,18 @@ class FilmService( } } - override fun edit(id: UUID, command: EditFilmCommand): Film { + override fun edit( + id: UUID, + command: EditFilmCommand, + ): Film { log.info("Editing film: id={}", id) - log.debug("Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", - id, command.title, command.description.length, command.genres) + log.debug( + "Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", + id, + command.title, + command.description.length, + command.genres, + ) val sample = Timer.start(meterRegistry) @@ -108,20 +121,21 @@ class FilmService( log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) - film = film.copy( - title = command.title, - description = command.description, - contentType = command.contentType, - releaseYear = command.releaseYear, - genres = command.genres, - cast = command.cast, - directors = command.directors, - imdbRating = command.imdbRating, - platformRating = command.platformRating, - externalUrl = command.externalUrl, - jellyfinItemId = command.jellyfinItemId, - jellyfinLibraryId = command.jellyfinLibraryId, - ) + film = + film.copy( + title = command.title, + description = command.description, + contentType = command.contentType, + releaseYear = command.releaseYear, + genres = command.genres, + cast = command.cast, + directors = command.directors, + imdbRating = command.imdbRating, + platformRating = command.platformRating, + externalUrl = command.externalUrl, + jellyfinItemId = command.jellyfinItemId, + jellyfinLibraryId = command.jellyfinLibraryId, + ) val saved = filmRepository.save(film) filmEditedCounter.increment() @@ -157,8 +171,9 @@ class FilmService( override fun getById(id: UUID): Film { log.debug("Fetching film by id: {}", id) - val film = filmRepository.findById(id) - ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) + val film = + filmRepository.findById(id) + ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) log.debug("Film found: id={}, title='{}'", film.id, film.title) return film } @@ -181,38 +196,45 @@ class FilmService( return film } - private val filmCreatedCounter = Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) + private val filmCreatedCounter = + Counter + .builder("film_created_total") + .description("Total number of created films") + .register(meterRegistry) - private val filmEditedCounter = Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) + private val filmEditedCounter = + Counter + .builder("film_edited_total") + .description("Total number of successfully edited films") + .register(meterRegistry) - private val filmDeletedCounter = Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) + private val filmDeletedCounter = + Counter + .builder("film_deleted_total") + .description("Total number of successfully deleted films") + .register(meterRegistry) - private val filmBlockedCounter = Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) + private val filmBlockedCounter = + Counter + .builder("films.blocked") + .description("Total blocked film operations") + .register(meterRegistry) - private val createFilmTimer = Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) + private val createFilmTimer = + Timer + .builder("films.create.duration") + .description("Film creation duration") + .register(meterRegistry) - private val editFilmTimer = Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) + private val editFilmTimer = + Timer + .builder("films.edit.duration") + .description("Film edit duration") + .register(meterRegistry) - private val deleteFilmTimer = Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) + private val deleteFilmTimer = + Timer + .builder("films.delete.duration") + .description("Film deletion duration") + .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 8926742..ba8fd76 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -27,7 +27,6 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateUserCommand): User { @@ -39,20 +38,24 @@ class UserService( throw BlockedValueException(target = "User", field = "name") } - val user = User( - id = idGenerator.generateId(), - name = command.name, - email = command.email, - library = null, - jellyfinUserId = null, - ) + val user = + User( + id = idGenerator.generateId(), + name = command.name, + email = command.email, + library = null, + jellyfinUserId = null, + ) val saved = userRepository.save(user) log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) return saved } - override fun edit(id: UUID, command: EditUserCommand): User { + override fun edit( + id: UUID, + command: EditUserCommand, + ): User { log.info("Editing user: id={}", id) log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) @@ -61,15 +64,17 @@ class UserService( throw BlockedValueException(target = "User", field = "name") } - var user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + var user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) - user = user.copy( - name = command.name, - jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, - ) + user = + user.copy( + name = command.name, + jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, + ) val saved = userRepository.save(user) log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) @@ -80,8 +85,9 @@ class UserService( log.info("Deleting user: id={}", id) log.debug("Delete user request: id={}", id) - val user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + val user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) @@ -91,8 +97,9 @@ class UserService( override fun getById(id: UUID): User { log.debug("Fetching user by id: {}", id) - val user = userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) + val user = + userRepository.findById(id) + ?: throw EntityNotFoundException(entity = "User", id = id.toString()) log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) return user } From 6d35b2f5a61568533e71122051b625b5feb3c35d Mon Sep 17 00:00:00 2001 From: Elena Date: Fri, 22 May 2026 15:41:24 +0300 Subject: [PATCH 076/106] =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/FilmLibraryService.kt | 389 +++++++++--------- 1 file changed, 197 insertions(+), 192 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 07d8d5a..381f3bf 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -1,192 +1,197 @@ -//package com.project.movienight.application.services -// -//import com.project.movienight.adapters.metrics.BusinessMetricsService -//import com.project.movienight.application.ports.input.AddFilmToLibraryCommand -//import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase -//import com.project.movienight.application.ports.input.CreateFilmLibraryCommand -//import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase -//import com.project.movienight.application.ports.input.GetFilmLibraryQuery -//import com.project.movienight.application.ports.input.GetFilmLibraryUseCase -//import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase -//import com.project.movienight.application.ports.input.MarkFilmViewedCommand -//import com.project.movienight.application.ports.input.MarkFilmViewedUseCase -//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand -//import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase -//import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort -//import com.project.movienight.application.ports.output.IdGenerator -//import com.project.movienight.domain.exception.DomainException -//import com.project.movienight.domain.exception.EntityNotFoundException -//import com.project.movienight.domain.model.FilmLibrary -//import org.slf4j.LoggerFactory -//import org.springframework.stereotype.Service -//import java.util.UUID -// -//@Service -//class FilmLibraryService( -// private val filmLibraryRepository: FilmLibraryRepositoryPort, -// private val idGenerator: IdGenerator, -// private val businessMetricsService: BusinessMetricsService, -//) : CreateFilmLibraryUseCase, -// AddFilmToLibraryUseCase, -// MarkFilmViewedUseCase, -// RemoveFilmFromLibraryUseCase, -// GetFilmLibraryUseCase, -// ListFilmLibraryEntriesUseCase { -// private val log = LoggerFactory.getLogger(javaClass) -// -// override fun create(command: CreateFilmLibraryCommand): FilmLibrary { -// log.info("Creating film library for user: {}", command.userId) -// log.debug("Create library request: userId={}, name={}", command.userId, command.name) -// -// val existing = findByUserId(command.userId) -// if (existing != null) { -// log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) -// return existing -// } -// -// log.warn("Library not found for user {}, cannot create", command.userId) -// throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) -// } -// -// override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { -// log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) -// if (existingEntry != null) { -// log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) -// val saved = -// filmLibraryRepository.save( -// existingEntry.copy( -// isViewed = false, -// watchedAt = null, -// ), -// ) -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film re-added to library: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// saved.id, -// ) -// return saved -// } -// -// val saved = -// filmLibraryRepository.save( -// FilmLibrary( -// id = idGenerator.generateId(), -// userId = command.userId, -// filmId = command.filmId, -// comment = null, -// isViewed = false, -// watchedAt = null, -// ), -// ) -// businessMetricsService.recordLibraryEvent() -// log.info("Film added to library: userId={}, filmId={}, entryId={}", command.userId, command.filmId, saved.id) -// return saved -// } -// -// override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { -// log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingLibrary = -// if (command.libraryId != null) { -// log.debug("Looking up by libraryId: {}", command.libraryId) -// filmLibraryRepository.findById(command.libraryId) -// ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) -// } else { -// log.debug("Looking up by userId and filmId") -// findByUserAndFilmId(command.userId, command.filmId) -// ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) -// } -// -// if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { -// log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) -// throw DomainException("Film with id ${command.filmId} not found in user's library") -// } -// -// filmLibraryRepository.deleteById(existingLibrary.id) -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film removed from library: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// existingLibrary.id, -// ) -// return existingLibrary -// } -// -// override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { -// log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) -// -// val existingEntry = findByUserAndFilmId(command.userId, command.filmId) -// val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() -// -// log.debug("Marking as viewed at: {}", watchedAt) -// -// val saved = -// if (existingEntry == null) { -// log.debug("Film not in library, creating new entry as viewed") -// filmLibraryRepository.save( -// FilmLibrary( -// id = idGenerator.generateId(), -// userId = command.userId, -// filmId = command.filmId, -// comment = null, -// isViewed = true, -// watchedAt = watchedAt, -// ), -// ) -// } else { -// log.debug( -// "Updating existing entry: entryId={}, was viewed={}", -// existingEntry.id, -// existingEntry.isViewed, -// ) -// filmLibraryRepository.save( -// existingEntry.copy( -// isViewed = true, -// watchedAt = watchedAt, -// ), -// ) -// } -// businessMetricsService.recordLibraryEvent() -// log.info( -// "Film marked as viewed: userId={}, filmId={}, entryId={}", -// command.userId, -// command.filmId, -// saved.id, -// ) -// return saved -// } -// -// override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { -// log.debug("Getting library for user: {}", query.userId) -// val library = -// findByUserId(query.userId) -// ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) -// log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) -// return library -// } -// -// override fun list(userId: UUID): List { -// log.debug("Listing all library entries for user: {}", userId) -// val entries = filmLibraryRepository.findAll().filter { it.userId == userId } -// log.info("User {} has {} films in library", userId, entries.size) -// return entries -// } -// -// private fun findByUserId(userId: UUID): FilmLibrary? = -// filmLibraryRepository.findAll().firstOrNull { -// it.userId == userId -// } -// -// private fun findByUserAndFilmId( -// userId: UUID, -// filmId: UUID, -// ): FilmLibrary? = -// filmLibraryRepository.findAll().firstOrNull { -// it.userId == userId && it.filmId == filmId -// } -//} +package com.project.movienight.application.services + +import com.project.movienight.adapters.metrics.BusinessMetricsService +import com.project.movienight.application.ports.input.AddFilmToLibraryCommand +import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase +import com.project.movienight.application.ports.input.CreateFilmLibraryCommand +import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase +import com.project.movienight.application.ports.input.GetFilmLibraryQuery +import com.project.movienight.application.ports.input.GetFilmLibraryUseCase +import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.MarkFilmViewedUseCase +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand +import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase +import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.domain.exception.DomainException +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.FilmLibrary +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class FilmLibraryService( + private val filmLibraryRepository: FilmLibraryRepositoryPort, + private val idGenerator: IdGenerator, + private val businessMetricsService: BusinessMetricsService, +) : CreateFilmLibraryUseCase, + AddFilmToLibraryUseCase, + MarkFilmViewedUseCase, + RemoveFilmFromLibraryUseCase, + GetFilmLibraryUseCase, + ListFilmLibraryEntriesUseCase { + private val log = LoggerFactory.getLogger(javaClass) + + override fun create(command: CreateFilmLibraryCommand): FilmLibrary { + log.info("Creating film library for user: {}", command.userId) + log.debug("Create library request: userId={}, name={}", command.userId, command.name) + + val existing = findByUserId(command.userId) + if (existing != null) { + log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) + return existing + } + + log.warn("Library not found for user {}, cannot create", command.userId) + throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) + } + + override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { + log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + if (existingEntry != null) { + log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) + val saved = + filmLibraryRepository.save( + existingEntry.copy( + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + log.info( + "Film re-added to library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + val saved = + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = false, + watchedAt = null, + ), + ) + businessMetricsService.recordLibraryEvent() + log.info( + "Film added to library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { + log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) + + val existingLibrary = + if (command.libraryId != null) { + log.debug("Looking up by libraryId: {}", command.libraryId) + filmLibraryRepository.findById(command.libraryId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) + } else { + log.debug("Looking up by userId and filmId") + findByUserAndFilmId(command.userId, command.filmId) + ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) + } + + if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { + log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) + throw DomainException("Film with id ${command.filmId} not found in user's library") + } + + filmLibraryRepository.deleteById(existingLibrary.id) + businessMetricsService.recordLibraryEvent() + log.info( + "Film removed from library: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + existingLibrary.id, + ) + return existingLibrary + } + + override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { + log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) + + val existingEntry = findByUserAndFilmId(command.userId, command.filmId) + val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() + + log.debug("Marking as viewed at: {}", watchedAt) + + val saved = + if (existingEntry == null) { + log.debug("Film not in library, creating new entry as viewed") + filmLibraryRepository.save( + FilmLibrary( + id = idGenerator.generateId(), + userId = command.userId, + filmId = command.filmId, + comment = null, + isViewed = true, + watchedAt = watchedAt, + ), + ) + } else { + log.debug( + "Updating existing entry: entryId={}, was viewed={}", + existingEntry.id, + existingEntry.isViewed, + ) + filmLibraryRepository.save( + existingEntry.copy( + isViewed = true, + watchedAt = watchedAt, + ), + ) + } + businessMetricsService.recordLibraryEvent() + log.info( + "Film marked as viewed: userId={}, filmId={}, entryId={}", + command.userId, + command.filmId, + saved.id, + ) + return saved + } + + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { + log.debug("Getting library for user: {}", query.userId) + val library = + findByUserId(query.userId) + ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) + log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) + return library + } + + override fun list(userId: UUID): List { + log.debug("Listing all library entries for user: {}", userId) + val entries = filmLibraryRepository.findAll().filter { it.userId == userId } + log.info("User {} has {} films in library", userId, entries.size) + return entries + } + + private fun findByUserId(userId: UUID): FilmLibrary? = + filmLibraryRepository.findAll().firstOrNull { + it.userId == userId + } + + private fun findByUserAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibrary? = + filmLibraryRepository.findAll().firstOrNull { + it.userId == userId && it.filmId == filmId + } +} From 573fa12e6299c860054046aa5c513517c767886e Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 11:22:10 +0300 Subject: [PATCH 077/106] feat(misc): added script to generate library for Jellyfin --- scripts/generate_library.py | 182 ++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 scripts/generate_library.py diff --git a/scripts/generate_library.py b/scripts/generate_library.py new file mode 100644 index 0000000..1f84920 --- /dev/null +++ b/scripts/generate_library.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 + +import os +import sys +import urllib.request +import argparse +import logging +import re +import random + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout) + ] +) +logger = logging.getLogger(__name__) + +DEFAULT_DATASET_URL = "https://raw.githubusercontent.com/sidooms/MovieTweetings/master/latest/movies.dat" +DEFAULT_OUTPUT_DIR = "./Jellyfin_Movies" +DEFAULT_COUNT = 1000 + +def download_dataset(url): + logger.info(f"Downloading dataset from {url}...") + try: + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req) as response: + data = response.read().decode('utf-8') + logger.info("Dataset downloaded successfully.") + return data.splitlines() + except Exception as e: + logger.error(f"Failed to download dataset: {e}") + sys.exit(1) + +def parse_movies(lines): + """ + Parse the movies.dat file. + Format: IMDbID::Title (Year)::Genres + Example: 0000008::Edison Kinetoscopic Record of a Sneeze (1894)::Documentary|Short + """ + movies = [] + # Regex to extract Title and Year from "Title (Year)" + title_year_pattern = re.compile(r'(.*)\s+\((\d{4})\)$') + + for line in lines: + line = line.strip() + if not line: + continue + + parts = line.split('::') + if len(parts) >= 2: + imdb_id_raw = parts[0] + title_year_raw = parts[1] + + # Format IMDb ID to ttXXXXXXX + if imdb_id_raw.isdigit(): + imdb_id = f"tt{imdb_id_raw.zfill(7)}" + else: + continue + + match = title_year_pattern.match(title_year_raw) + if match: + title = match.group(1).strip() + year = match.group(2) + + # Clean title for filesystem (remove invalid characters) + safe_title = re.sub(r'[\\/*?:"<>|]', "", title) + safe_title = safe_title.strip() + + if safe_title: + movies.append({ + 'imdb_id': imdb_id, + 'title': safe_title, + 'year': year + }) + + logger.info(f"Parsed {len(movies)} valid movies from dataset.") + return movies + +def create_dummy_video(filepath): + """Create a minimal valid dummy video file (mp4).""" + try: + mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom\x00\x00\x00\x00moov\x00\x00\x00\x08mvhd" + with open(filepath, 'wb') as f: + f.write(mp4_header) + return True + except Exception as e: + logger.error(f"Failed to create dummy video {filepath}: {e}") + return False + +def generate_library(movies, output_dir, count): + """Generate the folder structure and dummy files.""" + if not os.path.exists(output_dir): + os.makedirs(output_dir) + logger.info(f"Created output directory: {output_dir}") + + generated_imdb_ids = set() + created_count = 0 + skipped_count = 0 + failed_count = 0 + + logger.info(f"Starting generation of up to {count} movies...") + + # Shuffle to get diverse movies + random.shuffle(movies) + + for movie in movies: + if created_count >= count: + break + + if movie['imdb_id'] in generated_imdb_ids: + skipped_count += 1 + continue + + # Jellyfin naming convention: Movie Name (year) [imdbid-tt1234567] + folder_name = f"{movie['title']} ({movie['year']}) [imdbid-{movie['imdb_id']}]" + folder_path = os.path.join(output_dir, folder_name) + + file_name = f"{folder_name}.mp4" + file_path = os.path.join(folder_path, file_name) + + if os.path.exists(file_path): + skipped_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + continue + + try: + os.makedirs(folder_path, exist_ok=True) + if create_dummy_video(file_path): + created_count += 1 + generated_imdb_ids.add(movie['imdb_id']) + else: + failed_count += 1 + except Exception as e: + logger.error(f"Error processing {folder_name}: {e}") + failed_count += 1 + + logger.info("--- Generation Summary ---") + logger.info(f"Target count: {count}") + logger.info(f"Successfully created: {created_count}") + logger.info(f"Skipped (already exists or duplicate): {skipped_count}") + logger.info(f"Failed: {failed_count}") + + return created_count > 0 + +def main(): + parser = argparse.ArgumentParser(description="Generate a dummy Jellyfin movie library.") + parser.add_argument("--output-dir", type=str, default=DEFAULT_OUTPUT_DIR, + help=f"Directory to create the library in (default: {DEFAULT_OUTPUT_DIR})") + parser.add_argument("--count", type=int, default=DEFAULT_COUNT, + help=f"Number of movies to generate (default: {DEFAULT_COUNT})") + parser.add_argument("--dataset-url", type=str, default=DEFAULT_DATASET_URL, + help="URL to the movies.dat file") + + args = parser.parse_args() + + lines = download_dataset(args.dataset_url) + if not lines: + logger.error("No dataset lines to process.") + sys.exit(1) + + movies = parse_movies(lines) + + if not movies: + logger.error("No movies parsed from the dataset.") + sys.exit(1) + + if len(movies) < args.count: + logger.warning(f"Requested {args.count} movies, but only {len(movies)} available.") + args.count = len(movies) + + success = generate_library(movies, args.output_dir, args.count) + + if success: + logger.info(f"Library generation complete. You can now mount '{os.path.abspath(args.output_dir)}' into Jellyfin.") + else: + logger.error("Library generation failed.") + sys.exit(1) + +if __name__ == "__main__": + main() From ee530d666e01ec3494301f000ee38b8c2d71acea Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 16:55:20 +0300 Subject: [PATCH 078/106] fix(): merge conflicts --- .../services/FilmLibraryService.kt | 83 ++----------------- .../application/services/FilmService.kt | 80 +++++------------- .../application/services/UserService.kt | 54 ++---------- 3 files changed, 37 insertions(+), 180 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 381f3bf..2924922 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -17,7 +17,6 @@ import com.project.movienight.application.ports.output.IdGenerator import com.project.movienight.domain.exception.DomainException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.FilmLibrary -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -32,28 +31,14 @@ class FilmLibraryService( RemoveFilmFromLibraryUseCase, GetFilmLibraryUseCase, ListFilmLibraryEntriesUseCase { - private val log = LoggerFactory.getLogger(javaClass) - override fun create(command: CreateFilmLibraryCommand): FilmLibrary { - log.info("Creating film library for user: {}", command.userId) - log.debug("Create library request: userId={}, name={}", command.userId, command.name) - - val existing = findByUserId(command.userId) - if (existing != null) { - log.debug("Library already exists for user {}: libraryId={}", command.userId, existing.id) - return existing - } - - log.warn("Library not found for user {}, cannot create", command.userId) + findByUserId(command.userId)?.let { return it } throw EntityNotFoundException(entity = "Film library", id = command.userId.toString()) } override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary { - log.info("Adding film to library: userId={}, filmId={}", command.userId, command.filmId) - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) if (existingEntry != null) { - log.debug("Film already in library, resetting as not viewed: entryId={}", existingEntry.id) val saved = filmLibraryRepository.save( existingEntry.copy( @@ -62,12 +47,6 @@ class FilmLibraryService( ), ) businessMetricsService.recordLibraryEvent() - log.info( - "Film re-added to library: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - saved.id, - ) return saved } @@ -83,56 +62,34 @@ class FilmLibraryService( ), ) businessMetricsService.recordLibraryEvent() - log.info( - "Film added to library: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - saved.id, - ) return saved } override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary { - log.info("Removing film from library: userId={}, filmId={}", command.userId, command.filmId) - val existingLibrary = if (command.libraryId != null) { - log.debug("Looking up by libraryId: {}", command.libraryId) filmLibraryRepository.findById(command.libraryId) ?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString()) } else { - log.debug("Looking up by userId and filmId") findByUserAndFilmId(command.userId, command.filmId) ?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString()) } if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) { - log.warn("Film not found in user's library: userId={}, filmId={}", command.userId, command.filmId) throw DomainException("Film with id ${command.filmId} not found in user's library") } filmLibraryRepository.deleteById(existingLibrary.id) businessMetricsService.recordLibraryEvent() - log.info( - "Film removed from library: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - existingLibrary.id, - ) return existingLibrary } override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary { - log.info("Marking film as viewed: userId={}, filmId={}", command.userId, command.filmId) - val existingEntry = findByUserAndFilmId(command.userId, command.filmId) val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now() - log.debug("Marking as viewed at: {}", watchedAt) - val saved = if (existingEntry == null) { - log.debug("Film not in library, creating new entry as viewed") filmLibraryRepository.save( FilmLibrary( id = idGenerator.generateId(), @@ -144,11 +101,6 @@ class FilmLibraryService( ), ) } else { - log.debug( - "Updating existing entry: entryId={}, was viewed={}", - existingEntry.id, - existingEntry.isViewed, - ) filmLibraryRepository.save( existingEntry.copy( isViewed = true, @@ -157,41 +109,20 @@ class FilmLibraryService( ) } businessMetricsService.recordLibraryEvent() - log.info( - "Film marked as viewed: userId={}, filmId={}, entryId={}", - command.userId, - command.filmId, - saved.id, - ) return saved } - override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary { - log.debug("Getting library for user: {}", query.userId) - val library = - findByUserId(query.userId) - ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - log.debug("Library found: userId={}, libraryId={}", query.userId, library.id) - return library - } + override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary = + findByUserId(query.userId) + ?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString()) - override fun list(userId: UUID): List { - log.debug("Listing all library entries for user: {}", userId) - val entries = filmLibraryRepository.findAll().filter { it.userId == userId } - log.info("User {} has {} films in library", userId, entries.size) - return entries - } + override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } private fun findByUserId(userId: UUID): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { - it.userId == userId - } + filmLibraryRepository.findAll().firstOrNull { it.userId == userId } private fun findByUserAndFilmId( userId: UUID, filmId: UUID, - ): FilmLibrary? = - filmLibraryRepository.findAll().firstOrNull { - it.userId == userId && it.filmId == filmId - } + ): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index 93de089..d69a6c8 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -36,25 +36,22 @@ class FilmService( private val log = LoggerFactory.getLogger(javaClass) override fun create(command: CreateFilmCommand): Film { - log.info("Creating new film: title='{}', contentType={}", command.title, command.contentType) - log.debug( - "Create film request details: title='{}', descriptionLength={}, genres={}, releaseYear={}", - command.title, - command.description.length, - command.genres, - command.releaseYear, - ) - val sample = Timer.start(meterRegistry) try { + log.debug( + "Create film request received: title='{}', descriptionLength={}", + command.title, + command.description.length, + ) + if (filmConfig.isBlocked(command.title)) { - log.warn("Film creation blocked: title contains blocked pattern '{}'", command.title) + log.debug("Create film blocked by title policy: title='{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.warn("Film creation blocked: description contains blocked pattern") + log.debug("Create film blocked by description policy") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -78,7 +75,6 @@ class FilmService( val saved = filmRepository.save(film) filmCreatedCounter.increment() - log.info("Film created successfully: id={}, title='{}'", saved.id, saved.title) return saved } finally { sample.stop(createFilmTimer) @@ -89,25 +85,18 @@ class FilmService( id: UUID, command: EditFilmCommand, ): Film { - log.info("Editing film: id={}", id) - log.debug( - "Edit film request details: id={}, title='{}', descriptionLength={}, genres={}", - id, - command.title, - command.description.length, - command.genres, - ) - val sample = Timer.start(meterRegistry) try { + log.debug("Edit film with id: {}", id) + if (filmConfig.isBlocked(command.title)) { - log.warn("Film edit blocked: title contains blocked pattern '{}'", command.title) + log.debug("Edit film blocked by title policy: title='{}'", command.title) filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "title") } if (filmConfig.isBlocked(command.description)) { - log.warn("Film edit blocked: description contains blocked pattern") + log.debug("Edit film blocked by description policy") filmBlockedCounter.increment() throw BlockedValueException(target = "Film", field = "description") } @@ -115,12 +104,10 @@ class FilmService( var film = filmRepository.findById(id) if (film == null) { - log.warn("Film not found for edit: id='{}'", id) + log.debug("Film not found for edit: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - log.debug("Existing film found: id={}, current title='{}'", film.id, film.title) - film = film.copy( title = command.title, @@ -139,7 +126,6 @@ class FilmService( val saved = filmRepository.save(film) filmEditedCounter.increment() - log.info("Film edited successfully: id={}, new title='{}'", saved.id, saved.title) return saved } finally { sample.stop(editFilmTimer) @@ -147,54 +133,34 @@ class FilmService( } override fun delete(id: UUID) { - log.info("Deleting film: id={}", id) - val sample = Timer.start(meterRegistry) try { + log.debug("Delete film with id: {}", id) + val film = filmRepository.findById(id) if (film == null) { - log.warn("Film not found for delete: id='{}'", id) + log.debug("Film not found for delete: id='{}'", id) throw EntityNotFoundException(entity = "Film", id = id.toString()) } - log.debug("Film found for deletion: id={}, title='{}'", film.id, film.title) - filmRepository.deleteById(id) + filmDeletedCounter.increment() - log.info("Film deleted successfully: id={}, title='{}'", id, film.title) + + log.info("Film deleted: id='{}'", id) } finally { sample.stop(deleteFilmTimer) } } - override fun getById(id: UUID): Film { - log.debug("Fetching film by id: {}", id) - val film = - filmRepository.findById(id) - ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - log.debug("Film found: id={}, title='{}'", film.id, film.title) - return film - } + override fun getById(id: UUID): Film = + filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString()) - override fun getAll(): List { - log.debug("Fetching all films") - val films = filmRepository.findAll() - log.info("Retrieved {} films from database", films.size) - return films - } + override fun getAll(): List = filmRepository.findAll() - override fun searchByTitle(title: String): Film? { - log.debug("Searching film by title: '{}'", title) - val film = filmRepository.findByTitle(title) - if (film != null) { - log.info("Film found by title '{}': id={}", title, film.id) - } else { - log.debug("No film found with title: '{}'", title) - } - return film - } + override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) private val filmCreatedCounter = Counter diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index ba8fd76..684da5f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -13,7 +13,6 @@ import com.project.movienight.config.UserServiceProperties import com.project.movienight.domain.exception.BlockedValueException import com.project.movienight.domain.exception.EntityNotFoundException import com.project.movienight.domain.model.User -import org.slf4j.LoggerFactory import org.springframework.stereotype.Service import java.util.UUID @@ -27,14 +26,8 @@ class UserService( DeleteUserUseCase, GetUserByIdUseCase, GetAllUsersUseCase { - private val log = LoggerFactory.getLogger(javaClass) - override fun create(command: CreateUserCommand): User { - log.info("Creating new user with email: {}", command.email) - log.debug("Create user request: name='{}', email='{}'", command.name, command.email) - if (userConfig.isBlocked(command.name)) { - log.warn("User creation blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } @@ -46,29 +39,18 @@ class UserService( library = null, jellyfinUserId = null, ) - val saved = userRepository.save(user) - - log.info("User created successfully: id={}, email='{}'", saved.id, saved.email) - return saved + return userRepository.save(user) } override fun edit( id: UUID, command: EditUserCommand, ): User { - log.info("Editing user: id={}", id) - log.debug("Edit user request: id={}, name='{}', jellyfinUserId={}", id, command.name, command.jellyfinUserId) - if (userConfig.isBlocked(command.name)) { - log.warn("User edit blocked: name contains blocked pattern '{}'", command.name) throw BlockedValueException(target = "User", field = "name") } - var user = - userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - - log.debug("Existing user found: id={}, current name='{}'", user.id, user.name) + var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) user = user.copy( @@ -76,38 +58,16 @@ class UserService( jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId, ) - val saved = userRepository.save(user) - log.info("User edited successfully: id={}, new name='{}'", saved.id, saved.name) - return saved + return userRepository.save(user) } override fun delete(id: UUID) { - log.info("Deleting user: id={}", id) - log.debug("Delete user request: id={}", id) - - val user = - userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - - log.debug("User found for deletion: id={}, email='{}'", user.id, user.email) - + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) userRepository.deleteById(id) - log.info("User deleted successfully: id={}", id) } - override fun getById(id: UUID): User { - log.debug("Fetching user by id: {}", id) - val user = - userRepository.findById(id) - ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - log.debug("User found: id={}, name='{}', email='{}'", user.id, user.name, user.email) - return user - } + override fun getById(id: UUID): User = + userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString()) - override fun getAll(): List { - log.debug("Fetching all users") - val users = userRepository.findAll() - log.info("Retrieved {} users from database", users.size) - return users - } + override fun getAll(): List = userRepository.findAll() } From 42008900f69199569d3cb3e1257efb5cba0dff21 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 17:01:28 +0300 Subject: [PATCH 079/106] ci(docker): enabled push to registry when PR base br is main --- .github/workflows/ci.yaml | 2 +- guideline.md | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 guideline.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ece7eb1..14107d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: contents: read packages: write with: - push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event.pull_request.base.ref == 'main' }} secrets: inherit notify-main: diff --git a/guideline.md b/guideline.md new file mode 100644 index 0000000..963c094 --- /dev/null +++ b/guideline.md @@ -0,0 +1,63 @@ +--- +name: karpathy-guidelines +description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria. +license: MIT +--- + +# Karpathy Guidelines + +Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + \ No newline at end of file From c8acede152c55a94b0f8fc2054820adfdc9c6f68 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 17:09:04 +0300 Subject: [PATCH 080/106] feat(jellyfin): added jellyfin plugin --- plugins/jellyfin/.gitignore | 2 + .../Configuration/PluginConfiguration.cs | 50 ++ .../Configuration/config.js | 94 ++++ .../Configuration/configPage.html | 74 +++ .../Configuration/ui.js | 450 ++++++++++++++++++ .../Controllers/MovieNightController.cs | 255 ++++++++++ .../Jellyfin.Plugin.MovieNight.csproj | 36 ++ .../Jellyfin.Plugin.MovieNight/Plugin.cs | 73 +++ .../PluginServiceRegistrator.cs | 21 + .../Services/MovieNightBackendClient.cs | 309 ++++++++++++ .../Services/MovieNightEventPayload.cs | 31 ++ .../Services/MovieNightPeriodicSyncService.cs | 74 +++ .../MovieNightPlaybackEventService.cs | 104 ++++ .../Services/MovieNightSyncService.cs | 105 ++++ plugins/jellyfin/README.md | 34 ++ plugins/jellyfin/build.yaml | 14 + 16 files changed, 1726 insertions(+) create mode 100644 plugins/jellyfin/.gitignore create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs create mode 100644 plugins/jellyfin/README.md create mode 100644 plugins/jellyfin/build.yaml diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore new file mode 100644 index 0000000..5967294 --- /dev/null +++ b/plugins/jellyfin/.gitignore @@ -0,0 +1,2 @@ +**/bin/ +**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..2e97960 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.MovieNight.Configuration; + +///

+/// MovieNight plugin settings persisted by Jellyfin. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets a value indicating whether integration calls are enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the MovieNight backend base URL. + /// + public string BackendBaseUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the backend plugin token. + /// + public string ApiToken { get; set; } = string.Empty; + + /// + /// Gets or sets the periodic sync interval in minutes. + /// + public int SyncIntervalMinutes { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether playback stop events are pushed to MovieNight. + /// + public bool EnablePlaybackEvents { get; set; } = true; + + /// + /// Gets or sets a value indicating whether periodic backend sync is enabled. + /// + public bool EnablePeriodicSync { get; set; } = true; + + /// + /// Gets or sets enabled Jellyfin library ids. Empty means all libraries. + /// + public List EnabledLibraryIds { get; set; } = new(); + + /// + /// Gets or sets the path where .strm files will be created. + /// + public string StrmOutputPath { get; set; } = string.Empty; +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js new file mode 100644 index 0000000..7e38881 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -0,0 +1,94 @@ +const movieNightConfigPage = { + pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb", + + loadConfiguration(view) { + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(this.pluginId) + .then((config) => { + view.querySelector("#BackendBaseUrl").value = + config.BackendBaseUrl || ""; + view.querySelector("#ApiToken").value = config.ApiToken || ""; + view.querySelector("#SyncIntervalMinutes").value = + config.SyncIntervalMinutes || 30; + view.querySelector("#StrmOutputPath").value = + config.StrmOutputPath || ""; + view.querySelector("#Enabled").checked = config.Enabled || false; + view.querySelector("#EnablePeriodicSync").checked = + config.EnablePeriodicSync !== false; + view.querySelector("#EnablePlaybackEvents").checked = + config.EnablePlaybackEvents !== false; + + const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", { + name: "MovieNight.ui.js", + }); + view.querySelector("#UIScriptUrl").innerText = uiScriptUrl; + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, + + saveConfiguration(view) { + const form = view.querySelector("#MovieNightConfigForm"); + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(this.pluginId) + .then((config) => { + config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value; + config.ApiToken = form.querySelector("#ApiToken").value; + config.SyncIntervalMinutes = parseInt( + form.querySelector("#SyncIntervalMinutes").value || "30", + 10, + ); + config.StrmOutputPath = form.querySelector("#StrmOutputPath").value; + config.Enabled = form.querySelector("#Enabled").checked; + config.EnablePeriodicSync = + form.querySelector("#EnablePeriodicSync").checked; + config.EnablePlaybackEvents = + form.querySelector("#EnablePlaybackEvents").checked; + + return ApiClient.updatePluginConfiguration(this.pluginId, config); + }) + .then((result) => { + Dashboard.processPluginConfigurationUpdateResult(result); + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, + + testConnection() { + Dashboard.showLoadingMsg(); + + return ApiClient.ajax({ + type: "POST", + url: ApiClient.getUrl("MovieNight/TestConnection"), + }) + .then((result) => { + Dashboard.alert((result && result.message) || "OK"); + }) + .catch(() => { + Dashboard.alert("MovieNight connection test failed"); + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, +}; + +export default function (view) { + movieNightConfigPage.loadConfiguration(view); + + view + .querySelector("#MovieNightConfigForm") + .addEventListener("submit", (event) => { + event.preventDefault(); + movieNightConfigPage.saveConfiguration(view); + }); + + view.querySelector("#TestConnection").addEventListener("click", (event) => { + event.preventDefault(); + movieNightConfigPage.testConnection(); + }); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html new file mode 100644 index 0000000..adb20db --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -0,0 +1,74 @@ + + + + MovieNight + + +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Directory where .strm files will be created for new films.
+
+ + + + + + + +
+ +
+ +
+ +
+ +
+

UI Integration

+

To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General):

+ +
+
+
+
+
+ + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js new file mode 100644 index 0000000..e02f661 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -0,0 +1,450 @@ +(function () { + const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb"; + + function getAlert() { + if (typeof Dashboard !== 'undefined' && Dashboard.alert) { + return (options) => Dashboard.alert(options); + } + return (options) => { + const msg = typeof options === 'string' ? options : (options.text || options.title); + alert(msg); + }; + } + + const showMsg = getAlert(); + + function createTextButton(text, className, onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.is = 'emby-button'; + btn.className = `emby-button raised ${className}`; + btn.style.margin = '0.5em'; + btn.style.padding = '0.4em 1em'; + btn.innerHTML = `${text}`; + btn.onclick = onClick; + return btn; + } + + function createIconButton(icon, title, className, onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.is = 'emby-button'; + btn.className = `button-flat detailButton emby-button ${className}`; + btn.title = title; + btn.innerHTML = ` +
+ +
+ `; + btn.onclick = onClick; + return btn; + } + + async function injectUI() { + // Check for onboarding + await checkOnboarding(); + + // 1. Item Detail Page + const detailButtons = document.querySelector('.mainDetailButtons'); + if (detailButtons) { + const itemId = getItemIdFromUrl(); + if (itemId) { + // MovieNight Rating + if (!document.querySelector('.btnMovieNightRate')) { + const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { + e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); + }); + insertInDetailRow(detailButtons, rateBtn); + } + // Mark Viewed in MovieNight + if (!document.querySelector('.btnMovieNightMarkViewed')) { + const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { + e.preventDefault(); e.stopPropagation(); submitViewed(itemId); + }); + insertInDetailRow(detailButtons, viewedBtn); + } + } + } + + // 2. Library Pages - Add text buttons to toolbar + const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x'); + if (toolBar && !document.querySelector('.btnMovieNightRecommend')) { + toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { + e.preventDefault(); showRecommendation(); + })); + toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => { + e.preventDefault(); showAddMovieDialog(); + })); + } + + // 3. Home Page - Prepend a MovieNight section + const homeSections = document.querySelector('.sections.homeSectionsContainer'); + if (homeSections && !document.querySelector('.movieNightHomeButtons')) { + const section = document.createElement('div'); + section.className = 'verticalSection movieNightHomeButtons'; + section.style.padding = '0 var(--sidePadding)'; + section.innerHTML = ` +
+

MovieNight

+ +
+
+ `; + const btnContainer = section.querySelector('.movieNightBtnContainer'); + btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); + btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog)); + btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync)); + + homeSections.insertBefore(section, homeSections.firstChild); + updateSyncStatus(); + } + } + + function insertInDetailRow(container, btn) { + const moreBtn = container.querySelector('.btnMoreCommands'); + if (moreBtn) container.insertBefore(btn, moreBtn); + else container.appendChild(btn); + } + + function getItemIdFromUrl() { + const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search; + const params = new URLSearchParams(queryString); + return params.get('id') || params.get('itemId'); + } + + function createOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'dialogBackdrop dialogBackdropOpened'; + overlay.style.zIndex = '99998'; + overlay.style.backgroundColor = 'rgba(0,0,0,0.7)'; + overlay.style.position = 'fixed'; + overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; + overlay.style.backdropFilter = 'blur(8px)'; + overlay.style.opacity = '1'; + return overlay; + } + + function createDialogBase(title) { + const dialog = document.createElement('div'); + dialog.className = 'dialog'; + dialog.style.position = 'fixed'; + dialog.style.top = '50%'; dialog.style.left = '50%'; + dialog.style.transform = 'translate(-50%, -50%)'; + dialog.style.zIndex = '99999'; + dialog.style.padding = '2.5em'; + dialog.style.minWidth = '350px'; + dialog.style.backgroundColor = '#1a1a1a'; + dialog.style.borderRadius = '1.5em'; + dialog.style.color = 'white'; + dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)'; + dialog.style.border = '1px solid #444'; + dialog.style.opacity = '1'; + + dialog.innerHTML = ` +

${title}

+
+ + `; + return dialog; + } + + async function showRatingDialog(itemId) { + const overlay = createOverlay(); + const dialog = createDialogBase('Rate on MovieNight'); + const content = dialog.querySelector('.dialog-content'); + + content.innerHTML = `
`; + const grid = content.querySelector('.rating-grid'); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + for (let i = 1; i <= 10; i++) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.is = 'emby-button'; + btn.className = 'emby-button raised'; + btn.innerText = i; + btn.style.padding = '0.8em 0'; + btn.style.textAlign = 'center'; + btn.style.display = 'flex'; + btn.style.alignItems = 'center'; + btn.style.justifyContent = 'center'; + btn.style.fontSize = '1.2em'; + btn.onclick = async () => { cleanup(); await submitRating(itemId, i); }; + grid.appendChild(btn); + } + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function showAddMovieDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Add Movie (STRM)'); + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + const btnAdd = document.createElement('button'); + btnAdd.className = 'emby-button raised button-submit'; + btnAdd.style.flex = '2'; + btnAdd.style.backgroundColor = '#0064d2'; + btnAdd.innerHTML = 'Add Film'; + footer.insertBefore(btnAdd, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnAdd.onclick = async () => { + const title = dialog.querySelector('.txtTitle').value; + const year = dialog.querySelector('.txtYear').value; + const imdbId = dialog.querySelector('.txtImdb').value; + const url = dialog.querySelector('.txtUrl').value; + if (!title) return; + cleanup(); + await addMovie(title, url, year, imdbId); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('.txtTitle').focus(); + } + + async function showOnboardingDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Welcome to MovieNight!'); + dialog.style.minWidth = '450px'; + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +

Pick your preferences to get better recommendations.

+
+ +
+
+
+ +
+
+
+ +
+
+ `; + + const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"]; + const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"]; + const types = ["FILM", "SERIES"]; + + const selections = { genres: new Set(), eras: new Set(), types: new Set() }; + + const createChip = (text, container, type) => { + const chip = document.createElement('div'); + chip.innerText = text; + chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;'; + chip.onclick = () => { + if (selections[type].has(text)) { + selections[type].delete(text); + chip.style.backgroundColor = 'transparent'; + chip.style.borderColor = '#444'; + } else { + selections[type].add(text); + chip.style.backgroundColor = '#0064d2'; + chip.style.borderColor = '#0064d2'; + } + }; + container.appendChild(chip); + }; + + genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres')); + eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras')); + types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types')); + + const btnSave = document.createElement('button'); + btnSave.className = 'emby-button raised button-submit'; + btnSave.style.flex = '2'; + btnSave.style.backgroundColor = '#0064d2'; + btnSave.innerHTML = 'Save & Start'; + footer.insertBefore(btnSave, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnSave.onclick = async () => { + const payload = { + weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])), + eras: [...selections.eras], + contentTypes: [...selections.types] + }; + cleanup(); + await completeOnboarding(payload); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function checkOnboarding() { + if (window.movieNightOnboardingChecked) return; + window.movieNightOnboardingChecked = true; + + const userId = ApiClient.getCurrentUserId(); + if (!userId) return; + + try { + const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`)); + if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) { + showOnboardingDialog(); + } + } catch (err) { + if (err.status === 404) showOnboardingDialog(); + } + } + + async function completeOnboarding(payload) { + const userId = ApiClient.getCurrentUserId(); + try { + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`), + data: JSON.stringify(payload), + contentType: 'application/json' + }); + showMsg('Welcome! Your preferences have been saved.'); + } catch (err) { + showMsg('Failed to save onboarding preferences.'); + } + } + + async function showRecommendation() { + const userId = ApiClient.getCurrentUserId(); + try { + const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`)); + const recommendations = typeof response === 'string' ? JSON.parse(response) : response; + + if (recommendations && recommendations.length > 0) { + const rec = recommendations[0]; + const film = rec.film || rec; + showMsg({ + title: 'MovieNight Recommendation', + text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}` + }); + } else { + showMsg('No recommendations found at the moment.'); + } + } catch (err) { + console.error('Failed to get recommendations', err); + showMsg('Failed to get recommendations. Check your API token and MovieNight status.'); + } + } + + async function addMovie(title, url, year, imdbId) { + try { + const data = { title, url }; + if (year) data.year = parseInt(year); + if (imdbId) data.imdbId = imdbId; + + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Films`), + data: JSON.stringify(data), + contentType: 'application/json' + }); + showMsg(`STRM file created for "${title}". Refresh your library to see it.`); + } catch (err) { + console.error('Failed to create movie', err); + showMsg('Failed to create movie. Ensure STRM output path is configured.'); + } + } + + async function triggerSync() { + try { + await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) }); + showMsg('Library sync triggered!'); + setTimeout(updateSyncStatus, 2000); + } catch (err) { + showMsg('Failed to trigger sync.'); + } + } + + async function updateSyncStatus() { + const statusEl = document.querySelector('.movieNightSyncStatus'); + if (!statusEl) return; + try { + const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`)); + if (state && state.lastSyncAt) { + statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`; + } + } catch (err) { /* ignore */ } + } + + async function submitRating(itemId, score) { + const userId = ApiClient.getCurrentUserId(); + try { + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`), + data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }), + contentType: 'application/json' + }); + showMsg('Rating submitted to MovieNight!'); + } catch (err) { + showMsg('Failed to submit rating.'); + } + } + + async function submitViewed(itemId) { + const userId = ApiClient.getCurrentUserId(); + try { + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Users/${userId}/Library/Films/${itemId}/Viewed`), + data: JSON.stringify({ watchedAt: new Date().toISOString() }), + contentType: 'application/json' + }); + showMsg('Marked as viewed in MovieNight!'); + } catch (err) { + showMsg('Failed to mark as viewed.'); + } + } + + let timeout; + const throttledInject = () => { + if (timeout) return; + timeout = setTimeout(() => { + injectUI(); + timeout = null; + }, 100); + }; + + const observer = new MutationObserver(throttledInject); + observer.observe(document.body, { childList: true, subtree: true }); + + injectUI(); +})(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs new file mode 100644 index 0000000..8c9ff73 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -0,0 +1,255 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller.Library; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Jellyfin.Plugin.MovieNight.Controllers; + +/// +/// Admin endpoints for the MovieNight plugin. +/// +[ApiController] +[Route("MovieNight")] +public class MovieNightController : ControllerBase +{ + private readonly MovieNightBackendClient _backendClient; + private readonly MovieNightSyncService _syncService; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightController( + MovieNightBackendClient backendClient, + MovieNightSyncService syncService) + { + _backendClient = backendClient; + _syncService = syncService; + } + + /// + /// Ping endpoint for connectivity checks. + /// + [HttpGet("Ping")] + public ActionResult Ping() => Ok("Pong"); + + /// + /// Returns plugin status. + /// + /// Status response. + [HttpGet("Status")] + [Authorize] + public ActionResult GetStatus() + { + var configuration = Plugin.Instance?.Configuration; + return new MovieNightPluginStatus( + Enabled: configuration?.Enabled ?? false, + BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty, + PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false, + PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false, + SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30); + } + + /// + /// Tests backend connectivity. + /// + /// Cancellation token. + /// Connection result. + [HttpPost("TestConnection")] + [Authorize] + public async Task> TestConnection(CancellationToken cancellationToken) + { + return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Triggers backend sync. + /// + /// Cancellation token. + /// Backend response. + [HttpPost("Sync")] + [Authorize] + public async Task> Sync(CancellationToken cancellationToken) + { + await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); + return Ok("Sync triggered"); + } + + /// + /// Gets backend sync state. + /// + /// Cancellation token. + /// Backend response. + [HttpGet("SyncState")] + [Authorize] + public async Task> SyncState(CancellationToken cancellationToken) + { + return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets recommendations for the current user. + /// + [HttpGet("Users/{userId}/Recommendations")] + [Authorize] + public async Task> GetRecommendations( + [FromRoute] string userId, + [FromQuery] string? contentType, + [FromQuery] string? mood, + [FromQuery] int limit = 10, + CancellationToken cancellationToken = default) + { + return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + } + + /// + /// Posts a rating for a film. + /// + [HttpPost("Users/{userId}/Ratings/Films/{filmId}")] + [Authorize] + public async Task PostRating( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] RatingRequest request, + CancellationToken cancellationToken) + { + await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Marks a film as viewed. + /// + [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] + [Authorize] + public async Task MarkViewed( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] ViewedRequest request, + CancellationToken cancellationToken) + { + await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Gets user preferences. + /// + [HttpGet("Users/{userId}/Preferences")] + [Authorize] + public async Task> GetPreferences( + [FromRoute] string userId, + CancellationToken cancellationToken) + { + return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Completes onboarding for a user. + /// + [HttpPost("Users/{userId}/Onboarding")] + [Authorize] + public async Task CompleteOnboarding( + [FromRoute] string userId, + [FromBody] object payload, + CancellationToken cancellationToken) + { + await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Creates a new film by generating a .strm file in a folder-per-movie structure. + /// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm + /// + [HttpPost("Films")] + [Authorize] + public async Task CreateFilm([FromBody] CreateFilmRequest request) + { + var config = Plugin.Instance?.Configuration; + if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath)) + { + return BadRequest("STRM output path is not configured."); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + return BadRequest("Movie title is required."); + } + + try + { + // Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]" + var folderName = request.Title.Trim(); + if (request.Year.HasValue) + { + folderName += $" ({request.Year})"; + } + if (!string.IsNullOrWhiteSpace(request.ImdbId)) + { + var ttId = request.ImdbId.Trim().ToLowerInvariant(); + if (!ttId.StartsWith("tt")) ttId = "tt" + ttId; + folderName += $" [imdbid-{ttId}]"; + } + + // Sanitize for file system + var invalidChars = Path.GetInvalidFileNameChars(); + var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); + + var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName); + if (!Directory.Exists(movieDirectory)) + { + Directory.CreateDirectory(movieDirectory); + } + + var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm"); + + var strmContent = string.IsNullOrWhiteSpace(request.Url) + ? "http://placeholder.url/upload_me_later" + : request.Url.Trim(); + + await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false); + + return Ok(new { FilePath = filePath, FolderName = safeFolderName }); + } + catch (Exception ex) + { + return StatusCode(500, $"Failed to create film: {ex.Message}"); + } + } +} + +/// +/// Create film request. +/// +public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); + +/// +/// Rating request. +/// +public sealed record RatingRequest(int Score, string? Note); + +/// +/// Viewed request. +/// +public sealed record ViewedRequest(DateTimeOffset? WatchedAt); + +/// +/// MovieNight plugin status response. +/// +/// Whether integration is enabled. +/// Backend base URL. +/// Whether periodic sync is enabled. +/// Whether playback events are enabled. +/// Sync interval in minutes. +public sealed record MovieNightPluginStatus( + bool Enabled, + string BackendBaseUrl, + bool PeriodicSyncEnabled, + bool PlaybackEventsEnabled, + int SyncIntervalMinutes); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj new file mode 100644 index 0000000..a8e6ec0 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + Jellyfin.Plugin.MovieNight + Jellyfin.Plugin.MovieNight + 1.0.0.1 + GPL-3.0-or-later + enable + true + false + + + + + + runtime + + + runtime + + + runtime + + + + + + + + + + + + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs new file mode 100644 index 0000000..73a5718 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.MovieNight.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// MovieNight Jellyfin plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "MovieNight"; + + /// + public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb"); + + /// + public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend."; + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace) + }, + new PluginPageInfo + { + Name = Name + ".js", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.config.js", + GetType().Namespace) + }, + new PluginPageInfo + { + Name = Name + ".ui.js", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.ui.js", + GetType().Namespace) + } + ]; + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs new file mode 100644 index 0000000..b4fc366 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs @@ -0,0 +1,21 @@ +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// Registers MovieNight services with Jellyfin. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs new file mode 100644 index 0000000..deca123 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -0,0 +1,309 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Thin HTTP client for the MovieNight backend. +/// +public class MovieNightBackendClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public MovieNightBackendClient(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + /// + /// Calls backend health. + /// + /// Cancellation token. + /// Connection result. + public async Task TestConnectionAsync(CancellationToken cancellationToken) + { + var payload = new MovieNightEventPayload( + EventId: $"plugin-test:{Guid.NewGuid():N}", + EventType: "playback.stopped", + OccurredAt: DateTimeOffset.UtcNow, + JellyfinUserId: "movienight-plugin-test-user", + ItemId: "movienight-plugin-test-item", + PayloadVersion: 1, + Payload: new Dictionary + { + ["source"] = "config-test" + }); + var request = CreateEventRequest(payload); + if (request is null) + { + return MovieNightConnectionResult.Failed("Plugin is not configured."); + } + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode + ? MovieNightConnectionResult.Ok() + : MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}."); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogWarning(ex, "MovieNight connection test failed"); + return MovieNightConnectionResult.Failed(ex.Message); + } + } + + /// + /// Pushes library sync data to the backend. + /// + /// Sync payload. + /// Cancellation token. + /// Backend response body. + public async Task SyncAsync(object payload, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync"); + if (request is null) + { + return "Plugin is not configured."; + } + + request.Content = JsonContent.Create(payload, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Gets recommendations for a user. + /// + public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) + { + var query = $"?limit={limit}"; + if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}"; + if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}"; + + var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/recommendations{query}"); + if (request is null) return "Plugin is not configured."; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Posts a rating for a film. + /// + public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}"); + if (request is null) return; + + request.Content = JsonContent.Create(new { score, note }, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// Gets ratings for a user. + /// + public async Task GetRatingsAsync(string userId, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings"); + if (request is null) return "[]"; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Marks a film as viewed. + /// + public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed"); + if (request is null) return; + + request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// Reads backend sync state. + /// + /// Cancellation token. + /// Backend response body. + public async Task GetSyncStateAsync(CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state"); + if (request is null) + { + return "Plugin is not configured."; + } + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Gets user preferences. + /// + public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences"); + if (request is null) return null; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return body; + } + + /// + /// Completes onboarding for a user. + /// + public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding"); + if (request is null) return; + + request.Content = JsonContent.Create(payload, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// Pushes an event payload to the backend event endpoint. + /// + /// Event payload. + /// Cancellation token. + /// A task. + public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken) + { + for (var attempt = 1; attempt <= 3; attempt++) + { + var request = CreateEventRequest(payload); + if (request is null) + { + return; + } + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + + if ((int)response.StatusCode == 401) + { + _logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized"); + return; + } + + _logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt); + } + + if (attempt < 3) + { + await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false); + } + } + } + + private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload) + { + var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events"); + if (request is null) + { + return null; + } + + request.Content = JsonContent.Create(payload, options: JsonOptions); + return request; + } + + private static string? GetBaseUrl() + { + var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); + } + + private static bool IsEnabled() + { + var configuration = Plugin.Instance?.Configuration; + return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl); + } + + private static HttpRequestMessage? CreateRequest(HttpMethod method, string path) + { + if (!IsEnabled()) + { + return null; + } + + var baseUrl = GetBaseUrl(); + if (baseUrl is null) + { + return null; + } + + var request = new HttpRequestMessage(method, new Uri(baseUrl + path)); + var token = Plugin.Instance?.Configuration.ApiToken; + if (!string.IsNullOrWhiteSpace(token)) + { + request.Headers.Add("X-MovieNight-Plugin-Token", token); + } + + return request; + } +} + +/// +/// Backend connection result. +/// +/// Whether the call succeeded. +/// Result message. +public sealed record MovieNightConnectionResult(bool Success, string Message) +{ + /// + /// Creates a successful result. + /// + /// Connection result. + public static MovieNightConnectionResult Ok() => new(true, "OK"); + + /// + /// Creates a failed result. + /// + /// Failure message. + /// Connection result. + public static MovieNightConnectionResult Failed(string message) => new(false, message); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs new file mode 100644 index 0000000..926199b --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Event payload sent to MovieNight. +/// +/// Idempotency key. +/// Event type. +/// Event timestamp. +/// Jellyfin user id. +/// Jellyfin item id. +/// Payload version. +/// Extra event data. +public sealed record MovieNightEventPayload( + [property: JsonPropertyName("event_id")] + string EventId, + [property: JsonPropertyName("event_type")] + string EventType, + [property: JsonPropertyName("occurred_at")] + DateTimeOffset OccurredAt, + [property: JsonPropertyName("jellyfin_user_id")] + string JellyfinUserId, + [property: JsonPropertyName("item_id")] + string ItemId, + [property: JsonPropertyName("payload_version")] + int PayloadVersion, + [property: JsonPropertyName("payload")] + IReadOnlyDictionary Payload); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs new file mode 100644 index 0000000..bd72c5e --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Jellyfin.Data.Enums; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Periodically asks MovieNight to run its current Jellyfin sync. +/// +public sealed class MovieNightPeriodicSyncService : BackgroundService +{ + private readonly MovieNightSyncService _syncService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightPeriodicSyncService( + MovieNightSyncService syncService, + ILogger logger) + { + _syncService = syncService; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var delay = GetDelay(); + try + { + await Task.Delay(delay, stoppingToken).ConfigureAwait(false); + if (!ShouldRun()) + { + continue; + } + + await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MovieNight periodic sync failed"); + } + } + } + + private static bool ShouldRun() + { + var configuration = Plugin.Instance?.Configuration; + return configuration is { Enabled: true, EnablePeriodicSync: true }; + } + + private static TimeSpan GetDelay() + { + var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30; + return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440)); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs new file mode 100644 index 0000000..89a5f84 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Subscribes to Jellyfin playback events and forwards thin payloads. +/// +public sealed class MovieNightPlaybackEventService : IHostedService +{ + private readonly ISessionManager _sessionManager; + private readonly MovieNightBackendClient _backendClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Jellyfin session manager. + /// Backend client. + /// Logger. + public MovieNightPlaybackEventService( + ISessionManager sessionManager, + MovieNightBackendClient backendClient, + ILogger logger) + { + _sessionManager = sessionManager; + _backendClient = backendClient; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped += OnPlaybackStopped; + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped -= OnPlaybackStopped; + return Task.CompletedTask; + } + + private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e) + { + if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true }) + { + return; + } + + if (!e.PlayedToCompletion) + { + return; + } + + var userId = e.Users?.FirstOrDefault()?.Id.ToString("N"); + var itemId = e.Item?.Id.ToString("N"); + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId)) + { + return; + } + + var occurredAt = DateTimeOffset.UtcNow; + var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId) + ? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}" + : $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}"; + + var payload = new MovieNightEventPayload( + EventId: eventId, + EventType: "playback.stopped", + OccurredAt: occurredAt, + JellyfinUserId: userId, + ItemId: itemId, + PayloadVersion: 1, + Payload: new Dictionary + { + ["itemName"] = e.Item?.Name, + ["playSessionId"] = e.PlaySessionId, + ["positionTicks"] = e.PlaybackPositionTicks, + ["playedToCompletion"] = e.PlayedToCompletion + }); + + _ = Task.Run( + async () => + { + try + { + await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "MovieNight playback event push failed"); + } + }); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs new file mode 100644 index 0000000..4c1ec05 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Jellyfin.Data.Enums; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Service for synchronizing the Jellyfin library with MovieNight. +/// +public class MovieNightSyncService +{ + private readonly MovieNightBackendClient _backendClient; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightSyncService( + MovieNightBackendClient backendClient, + ILibraryManager libraryManager, + IUserManager userManager, + IUserDataManager userDataManager, + ILogger logger) + { + _backendClient = backendClient; + _libraryManager = libraryManager; + _userManager = userManager; + _userDataManager = userDataManager; + _logger = logger; + } + + /// + /// Performs a full library sync. + /// + public async Task PerformSyncAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting MovieNight library sync"); + + var config = Plugin.Instance?.Configuration; + var enabledLibraryIds = config?.EnabledLibraryIds ?? new List(); + + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + Recursive = true + }; + + if (enabledLibraryIds.Count > 0) + { + query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray(); + } + + var items = _libraryManager.GetItemList(query); + var users = _userManager.Users; + var syncItems = new List(); + + foreach (var item in items) + { + if (item is not Movie movie) continue; + + var jellyfinItemId = movie.Id.ToString("N"); + + var itemData = new Dictionary + { + ["jellyfinItemId"] = jellyfinItemId, + ["title"] = movie.Name, + ["originalTitle"] = movie.OriginalTitle, + ["description"] = movie.Overview, + ["year"] = movie.ProductionYear, + ["duration"] = movie.RunTimeTicks, + ["genres"] = movie.Genres, + ["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary", + ["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb), + ["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb), + ["userStates"] = users.Select(u => { + var userData = _userDataManager.GetUserData(u, movie); + return new { + jellyfinUserId = u.Id.ToString("N"), + isViewed = userData?.Played ?? false, + playCount = userData?.PlayCount ?? 0, + lastPlayedAt = userData?.LastPlayedDate, + userRating = userData?.Rating + }; + }).ToList() + }; + + syncItems.Add(itemData); + } + + await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("MovieNight library sync completed"); + } +} diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md new file mode 100644 index 0000000..6a01e6d --- /dev/null +++ b/plugins/jellyfin/README.md @@ -0,0 +1,34 @@ +# MovieNight Jellyfin Plugin + +Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend. + +## Build + +```bash +cd plugins/jellyfin/Jellyfin.Plugin.MovieNight +dotnet publish -c Release +``` + +Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`. + +## Backend Contract Used + +Current implemented calls: + +- `POST /api/integrations/jellyfin/sync` +- `GET /api/integrations/jellyfin/sync-state` +- `POST /api/integrations/jellyfin/events` + +Event requests use JSON with: + +- `event_id` +- `event_type` +- `occurred_at` +- `jellyfin_user_id` +- `item_id` +- `payload_version` +- `payload` + +The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`. + +The config page test action posts a small synthetic event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure. diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml new file mode 100644 index 0000000..3c846f3 --- /dev/null +++ b/plugins/jellyfin/build.yaml @@ -0,0 +1,14 @@ +--- +name: "MovieNight" +guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb" +version: 2 +targetAbi: "10.11.0.0" +framework: net9.0 +owner: "movienight" +overview: "Bridge Jellyfin events and sync triggers to MovieNight" +description: "Thin Jellyfin plugin for MovieNight backend integration" +category: "General" +artifacts: + - "Jellyfin.Plugin.MovieNight.dll" +changelog: |- + - Initial plugin implementation. From 4af7da25e580c611f5dd32a6addfad7d338067ba Mon Sep 17 00:00:00 2001 From: ITQ <118541411+devitq@users.noreply.github.com> Date: Fri, 22 May 2026 17:25:25 +0300 Subject: [PATCH 081/106] ci(docker): allow push for develop branch as well --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 14107d1..dd165c3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -42,7 +42,7 @@ jobs: contents: read packages: write with: - push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event.pull_request.base.ref == 'main' }} + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') || github.event.pull_request.base.ref == 'main' || github.event.pull_request.base.ref == 'develop' }} secrets: inherit notify-main: From 5b9b4ce03ace32e445131934e57a03ceeb2e98dd Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 17:28:32 +0300 Subject: [PATCH 082/106] ci(plugin): added CI for jellyfin plugin --- .github/workflows/jellyfin-plugin.yaml | 58 ++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 .github/workflows/jellyfin-plugin.yaml diff --git a/.github/workflows/jellyfin-plugin.yaml b/.github/workflows/jellyfin-plugin.yaml new file mode 100644 index 0000000..a809b4a --- /dev/null +++ b/.github/workflows/jellyfin-plugin.yaml @@ -0,0 +1,58 @@ +name: Jellyfin Plugin +run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}" + +on: + push: + branches: [develop, main] + tags: ["v*"] + paths: + - ".github/workflows/jellyfin-plugin.yaml" + - "plugins/jellyfin/**" + pull_request: + branches: [develop, main] + paths: + - ".github/workflows/jellyfin-plugin.yaml" + - "plugins/jellyfin/**" + +concurrency: + group: jellyfin-plugin-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build Jellyfin Plugin + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - name: Checkout source + uses: actions/checkout@v6 + + - name: Set up .NET 9 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "9.0.x" + + - name: Restore plugin dependencies + run: dotnet restore plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj + + - name: Publish plugin + run: | + dotnet publish \ + plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj \ + -c Release \ + --no-restore \ + -o artifacts/jellyfin-plugin/MovieNight + + - name: Set artifact name + id: meta + run: echo "artifact-name=jellyfin-plugin-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + + - name: Upload plugin artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.meta.outputs.artifact-name }} + path: artifacts/jellyfin-plugin/** + retention-days: 7 + if-no-files-found: error From 92add56cf70f504193cba212f5dd6eddc0e76ed6 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 18:04:28 +0300 Subject: [PATCH 083/106] fix(jellyfin): fixes in jellyfin integration --- .../Configuration/ui.js | 10 +- .../Controllers/MovieNightController.cs | 15 +- .../Services/MovieNightBackendClient.cs | 28 ++- .../Services/MovieNightSyncService.cs | 10 +- plugins/jellyfin/README.md | 32 ++- .../security/SecurityConfiguration.kt | 4 +- .../adapters/web/JellyfinEventsController.kt | 14 +- .../web/JellyfinPluginAuthenticator.kt | 21 ++ .../adapters/web/JellyfinPluginController.kt | 227 ++++++++++++++++++ .../adapters/web/JellyfinSyncController.kt | 63 ++++- .../web/dto/request/JellyfinSyncRequest.kt | 43 ++++ .../ports/input/JellyfinUseCase.kt | 33 +++ .../services/JellyfinEventService.kt | 15 +- .../services/JellyfinSyncService.kt | 188 ++++++++++++--- src/main/resources/application.yaml | 3 +- .../controllers/JellyfinPluginContractTest.kt | 146 +++++++++++ 16 files changed, 785 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt create mode 100644 src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js index e02f661..022ad33 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -398,8 +398,14 @@ if (!statusEl) return; try { const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`)); - if (state && state.lastSyncAt) { - statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`; + const states = Array.isArray(state) ? state : []; + const latest = states + .map(s => s.lastSuccessfulSyncAt || s.lastSyncedAt) + .filter(Boolean) + .sort() + .pop(); + if (latest) { + statusEl.innerText = `Last sync: ${new Date(latest).toLocaleString()}`; } } catch (err) { /* ignore */ } } diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs index 8c9ff73..7d94e7c 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -87,9 +87,10 @@ public class MovieNightController : ControllerBase /// Backend response. [HttpGet("SyncState")] [Authorize] - public async Task> SyncState(CancellationToken cancellationToken) + public async Task SyncState(CancellationToken cancellationToken) { - return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); } /// @@ -97,14 +98,15 @@ public class MovieNightController : ControllerBase /// [HttpGet("Users/{userId}/Recommendations")] [Authorize] - public async Task> GetRecommendations( + public async Task GetRecommendations( [FromRoute] string userId, [FromQuery] string? contentType, [FromQuery] string? mood, [FromQuery] int limit = 10, CancellationToken cancellationToken = default) { - return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); } /// @@ -142,11 +144,12 @@ public class MovieNightController : ControllerBase /// [HttpGet("Users/{userId}/Preferences")] [Authorize] - public async Task> GetPreferences( + public async Task GetPreferences( [FromRoute] string userId, CancellationToken cancellationToken) { - return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + return body is null ? NotFound() : Content(body, "application/json"); } /// diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs index deca123..90381b9 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -40,7 +40,7 @@ public class MovieNightBackendClient { var payload = new MovieNightEventPayload( EventId: $"plugin-test:{Guid.NewGuid():N}", - EventType: "playback.stopped", + EventType: "plugin.test", OccurredAt: DateTimeOffset.UtcNow, JellyfinUserId: "movienight-plugin-test-user", ItemId: "movienight-plugin-test-item", @@ -95,11 +95,12 @@ public class MovieNightBackendClient /// public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) { + userId = NormalizeJellyfinId(userId); var query = $"?limit={limit}"; if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}"; if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}"; - var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/recommendations{query}"); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/recommendations{query}"); if (request is null) return "Plugin is not configured."; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -113,7 +114,9 @@ public class MovieNightBackendClient /// public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}"); + userId = NormalizeJellyfinId(userId); + filmId = NormalizeJellyfinId(filmId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/ratings/items/{filmId}"); if (request is null) return; request.Content = JsonContent.Create(new { score, note }, options: JsonOptions); @@ -126,7 +129,8 @@ public class MovieNightBackendClient /// public async Task GetRatingsAsync(string userId, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings"); + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/ratings"); if (request is null) return "[]"; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -140,7 +144,9 @@ public class MovieNightBackendClient /// public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed"); + userId = NormalizeJellyfinId(userId); + filmId = NormalizeJellyfinId(filmId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/library/items/{filmId}/viewed"); if (request is null) return; request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions); @@ -172,13 +178,15 @@ public class MovieNightBackendClient /// public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences"); + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/preferences"); if (request is null) return null; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); return body; } @@ -187,7 +195,8 @@ public class MovieNightBackendClient /// public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding"); + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/recommendation-onboarding"); if (request is null) return; request.Content = JsonContent.Create(payload, options: JsonOptions); @@ -257,6 +266,11 @@ public class MovieNightBackendClient return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); } + private static string NormalizeJellyfinId(string value) + { + return Guid.TryParse(value, out var guid) ? guid.ToString("N") : value; + } + private static bool IsEnabled() { var configuration = Plugin.Instance?.Configuration; diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs index 4c1ec05..93da778 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -64,6 +64,11 @@ public class MovieNightSyncService var items = _libraryManager.GetItemList(query); var users = _userManager.Users; + var syncUsers = users.Select(u => new + { + jellyfinUserId = u.Id.ToString("N"), + name = u.Username + }).ToList(); var syncItems = new List(); foreach (var item in items) @@ -71,11 +76,12 @@ public class MovieNightSyncService if (item is not Movie movie) continue; var jellyfinItemId = movie.Id.ToString("N"); + var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name; var itemData = new Dictionary { ["jellyfinItemId"] = jellyfinItemId, - ["title"] = movie.Name, + ["title"] = title, ["originalTitle"] = movie.OriginalTitle, ["description"] = movie.Overview, ["year"] = movie.ProductionYear, @@ -99,7 +105,7 @@ public class MovieNightSyncService syncItems.Add(itemData); } - await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false); + await _backendClient.SyncAsync(new { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false); _logger.LogInformation("MovieNight library sync completed"); } } diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md index 6a01e6d..2c3b06a 100644 --- a/plugins/jellyfin/README.md +++ b/plugins/jellyfin/README.md @@ -18,6 +18,34 @@ Current implemented calls: - `POST /api/integrations/jellyfin/sync` - `GET /api/integrations/jellyfin/sync-state` - `POST /api/integrations/jellyfin/events` +- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed` +- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding` + +Configure the backend with: + +- `JELLYFIN_INTEGRATION_ENABLED=true` +- `JELLYFIN_PLUGIN_TOKEN=` +- `JELLYFIN_WEB_URL=` + +`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`. + +Optional backend-pull sync values: + +- `JELLYFIN_BASE_URL=` +- `JELLYFIN_API_KEY=` + +The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls. + +Configure the plugin with: + +- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080` +- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value +- Enable MovieNight integration: checked +- Enable periodic backend sync: checked if the plugin should push library state on an interval +- Send playback stop events: checked if completed playback should mark films viewed in MovieNight Event requests use JSON with: @@ -31,4 +59,6 @@ Event requests use JSON with: The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`. -The config page test action posts a small synthetic event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure. +Sync requests push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items. + +The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure. diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 92fb02c..4caacdd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -24,9 +24,7 @@ class SecurityConfiguration( .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") .permitAll() .requestMatchers( - "/api/integrations/jellyfin/events", - "/api/integrations/jellyfin/sync", - "/api/integrations/jellyfin/sync-state", + "/api/integrations/jellyfin/**", ).permitAll() .requestMatchers("/api/users/me") .authenticated() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 0d7a294..2d33a64 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -3,7 +3,6 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest 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 @@ -13,13 +12,12 @@ import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.server.ResponseStatusException @RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinEventsController( private val jellyfinEventUseCase: JellyfinEventUseCase, - private val properties: JellyfinIntegrationProperties, + private val authenticator: JellyfinPluginAuthenticator, ) { private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) @@ -29,15 +27,7 @@ class JellyfinEventsController( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @Valid @RequestBody request: JellyfinEventRequest, ) { - if (!properties.enabled) { - throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") - } - - if (properties.pluginToken.isNotBlank()) { - if (token == null || token != properties.pluginToken) { - throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") - } - } + authenticator.authenticate(token) log.debug( "Received Jellyfin event {} for user {} item {}", diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt new file mode 100644 index 0000000..c2bbb1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.config.JellyfinIntegrationProperties +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +class JellyfinPluginAuthenticator( + private val properties: JellyfinIntegrationProperties, +) { + fun authenticate(token: String?) { + if (!properties.enabled) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") + } + + if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) { + throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt new file mode 100644 index 0000000..10bda37 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt @@ -0,0 +1,227 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse +import com.project.movienight.adapters.web.dto.response.RecommendationResponse +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.FilmRatingUseCase +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.input.UserPreferencesUseCase +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.RecommendationStyle +import com.project.movienight.domain.model.User +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.OffsetDateTime +import java.util.Locale +import java.util.UUID + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinPluginController( + private val authenticator: JellyfinPluginAuthenticator, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val idGenerator: IdGenerator, + private val getRecommendationsUseCase: GetRecommendationsUseCase, + private val filmRatingUseCase: FilmRatingUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, + private val userPreferencesUseCase: UserPreferencesUseCase, + private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase, + private val jellyfinProperties: JellyfinIntegrationProperties, +) { + @GetMapping("/users/{jellyfinUserId}/recommendations") + fun recommend( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @RequestParam(required = false) contentType: String?, + @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, + @RequestParam(required = false, defaultValue = "10") limit: Int, + ): List { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return getRecommendationsUseCase + .recommend( + RecommendationQuery( + userId = user.id, + contentType = parseOptionalContentType(contentType), + mood = mood, + libraryOnly = libraryOnly, + limit = limit, + ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } + } + + @PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}") + fun rate( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @PathVariable jellyfinItemId: String, + @Valid @RequestBody request: RateFilmRequest, + ): FilmRatingResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + val film = resolveFilm(jellyfinItemId) + return FilmRatingResponse.fromDomain( + filmRatingUseCase.rate( + RateFilmCommand( + userId = user.id, + filmId = film.id, + score = request.score, + note = request.note, + ), + ), + ) + } + + @GetMapping("/users/{jellyfinUserId}/ratings") + fun ratings( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + ): List { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) } + } + + @PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed") + fun markViewed( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @PathVariable jellyfinItemId: String, + @RequestBody(required = false) request: JellyfinViewedRequest?, + ): FilmLibraryEntryResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + val film = resolveFilm(jellyfinItemId) + return FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.markViewed( + MarkFilmViewedCommand( + userId = user.id, + filmId = film.id, + watchedAt = request?.watchedAt?.toLocalDateTime(), + ), + ), + ) + } + + @GetMapping("/users/{jellyfinUserId}/preferences") + fun preferences( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + ): UserPreferencesResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found") + } + + @PostMapping("/users/{jellyfinUserId}/recommendation-onboarding") + fun completeOnboarding( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @RequestBody request: RecommendationOnboardingRequest, + ): RecommendationOnboardingResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return RecommendationOnboardingResponse.fromApplication( + completeRecommendationOnboardingUseCase.complete( + CompleteRecommendationOnboardingCommand( + userId = user.id, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() }, + likedFilmIds = request.likedFilmIds, + dislikedFilmIds = request.dislikedFilmIds, + libraryFilmIds = request.libraryFilmIds, + watchedFilmIds = request.watchedFilmIds, + recommendationStyle = parseRecommendationStyle(request.recommendationStyle), + ), + ), + ) + } + + private fun resolveOrCreateUser(jellyfinUserId: String): User = + normalizeJellyfinId(jellyfinUserId).let { normalizedId -> + userRepository.findByJellyfinUserId(normalizedId) + ?: userRepository.save( + User( + id = idGenerator.generateId(), + name = "Jellyfin User", + email = syntheticJellyfinEmail(normalizedId), + jellyfinUserId = normalizedId, + ), + ) + } + + private fun resolveFilm(jellyfinItemId: String): Film = + normalizeJellyfinId(jellyfinItemId).let { normalizedId -> + filmRepository.findByJellyfinItemId(normalizedId) + ?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId) + } + + private fun buildWatchUrl(jellyfinItemId: String?): String? { + if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) { + return null + } + + val baseUrl = jellyfinProperties.webUrl.trimEnd('/') + val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8) + return "$baseUrl/web/#/details?id=$encodedItemId" + } + + private fun parseRecommendationStyle(value: String): RecommendationStyle = + runCatching { RecommendationStyle.valueOf(value.uppercase()) } + .getOrDefault(RecommendationStyle.BALANCED) + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) + + private fun syntheticJellyfinEmail(jellyfinUserId: String): String { + val safeId = + jellyfinUserId + .lowercase(Locale.getDefault()) + .replace(Regex("[^a-z0-9._%+-]"), "-") + .take(240) + return "jellyfin-$safeId@movienight.local" + } +} + +data class JellyfinViewedRequest( + val watchedAt: OffsetDateTime? = null, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index aa107b7..29d5585 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -1,10 +1,18 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest +import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand +import com.project.movienight.application.ports.input.JellyfinSyncItemCommand import com.project.movienight.application.ports.input.JellyfinSyncUseCase +import com.project.movienight.application.ports.input.JellyfinSyncUserCommand +import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary +import jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController @@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinSyncController( private val jellyfinSyncUseCase: JellyfinSyncUseCase, + private val authenticator: JellyfinPluginAuthenticator, ) { @PostMapping("/sync") - fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow() + fun syncNow( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @Valid @RequestBody(required = false) request: JellyfinSyncRequest?, + ): JellyfinSyncSummary { + authenticator.authenticate(token) + return if (request == null) { + jellyfinSyncUseCase.syncNow() + } else { + jellyfinSyncUseCase.ingest(request.toCommand()) + } + } @GetMapping("/sync-state") - fun syncState(): List = jellyfinSyncUseCase.getSyncStates() + fun syncState( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + ): List { + authenticator.authenticate(token) + return jellyfinSyncUseCase.getSyncStates() + } + + private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand = + IngestJellyfinSyncCommand( + users = + users.map { user -> + JellyfinSyncUserCommand( + jellyfinUserId = user.jellyfinUserId, + name = user.name, + ) + }, + items = + items.map { item -> + JellyfinSyncItemCommand( + jellyfinItemId = item.jellyfinItemId, + title = item.title, + originalTitle = item.originalTitle, + description = item.description, + year = item.year, + genres = item.genres, + imdbId = item.imdbId, + tmdbId = item.tmdbId, + jellyfinLibraryId = item.jellyfinLibraryId, + userStates = + item.userStates.map { state -> + JellyfinSyncUserStateCommand( + jellyfinUserId = state.jellyfinUserId, + isViewed = state.isViewed, + playCount = state.playCount, + lastPlayedAt = state.lastPlayedAt, + userRating = state.userRating, + ) + }, + ) + }, + ) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt new file mode 100644 index 0000000..d916f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -0,0 +1,43 @@ +package com.project.movienight.adapters.web.dto.request + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import java.time.OffsetDateTime + +data class JellyfinSyncRequest( + @field:Valid + val users: List = emptyList(), + @field:Valid + val items: List = emptyList(), +) + +data class JellyfinSyncUserRequest( + @field:NotBlank + val jellyfinUserId: String, + val name: String? = null, +) + +data class JellyfinSyncItemRequest( + @field:NotBlank + val jellyfinItemId: String, + @field:NotBlank + val title: String, + val originalTitle: String? = null, + val description: String? = null, + val year: Int? = null, + val genres: List = emptyList(), + val imdbId: String? = null, + val tmdbId: String? = null, + val jellyfinLibraryId: String? = null, + @field:Valid + val userStates: List = emptyList(), +) + +data class JellyfinSyncUserStateRequest( + @field:NotBlank + val jellyfinUserId: String, + val isViewed: Boolean = false, + val playCount: Int = 0, + val lastPlayedAt: OffsetDateTime? = null, + val userRating: Double? = null, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt index b28e68d..6be11a2 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand( interface JellyfinSyncUseCase { fun syncNow(): JellyfinSyncSummary + fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary + fun getSyncStates(): List } + +data class IngestJellyfinSyncCommand( + val users: List = emptyList(), + val items: List = emptyList(), +) + +data class JellyfinSyncUserCommand( + val jellyfinUserId: String, + val name: String?, +) + +data class JellyfinSyncItemCommand( + val jellyfinItemId: String, + val title: String, + val originalTitle: String?, + val description: String?, + val year: Int?, + val genres: List, + val imdbId: String?, + val tmdbId: String?, + val jellyfinLibraryId: String?, + val userStates: List, +) + +data class JellyfinSyncUserStateCommand( + val jellyfinUserId: String, + val isViewed: Boolean, + val playCount: Int, + val lastPlayedAt: OffsetDateTime?, + val userRating: Double?, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index b35c94c..2747229 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.UUID @Service class JellyfinEventService( @@ -26,6 +27,8 @@ class JellyfinEventService( @Transactional override fun handle(command: HandleJellyfinEventCommand) { + val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId) + val jellyfinItemId = normalizeJellyfinId(command.itemId) val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val inserted = jellyfinEventStore.save( @@ -34,8 +37,8 @@ class JellyfinEventService( serverId = command.serverId, eventType = command.eventType, occurredAt = command.occurredAt, - jellyfinUserId = command.jellyfinUserId, - jellyfinItemId = command.itemId, + jellyfinUserId = jellyfinUserId, + jellyfinItemId = jellyfinItemId, payload = payloadJson, ), ) @@ -45,13 +48,13 @@ class JellyfinEventService( try { if (playbackEventTypes.contains(command.eventType)) { - val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) + val localUser = userRepository.findByJellyfinUserId(jellyfinUserId) if (localUser == null) { businessMetricsService.recordJellyfinUnmappedUser() return } - val film = filmRepository.findByJellyfinItemId(command.itemId) + val film = filmRepository.findByJellyfinItemId(jellyfinItemId) if (film == null) { businessMetricsService.recordBackendWriteFailure() return @@ -72,4 +75,8 @@ class JellyfinEventService( throw ex } } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index cc852d4..56fe3fe 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.services +import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand import com.project.movienight.application.ports.input.JellyfinSyncUseCase import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort @@ -10,15 +11,18 @@ import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapsh 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.FilmLibraryEntry import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.User import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.time.Duration import java.time.Instant import java.time.LocalDateTime +import java.util.Locale import java.util.UUID @Service @@ -56,6 +60,21 @@ class JellyfinSyncService( override fun getSyncStates(): List = syncStateRepository.findAll() + override fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary { + if (!properties.enabled) { + return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) + } + + return try { + ingestPluginSync(command) + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordJellyfinSyncFailure() + throw ex + } + } + private fun runSync(): JellyfinSyncSummary { val startedAt = Instant.now() val remoteUsers = jellyfinCatalog.fetchUsers() @@ -63,7 +82,7 @@ class JellyfinSyncService( userRepository .findAll() .mapNotNull { user -> - user.jellyfinUserId?.let { it to user } + user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } }.toMap() var syncedUsers = 0 @@ -71,7 +90,7 @@ class JellyfinSyncService( var syncedItems = 0 remoteUsers.forEach { remoteUser -> - val localUser = localUsersByJellyfinId[remoteUser.id] + val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)] if (localUser == null) { skippedUsers += 1 return@forEach @@ -123,34 +142,39 @@ class JellyfinSyncService( } private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { + val normalizedItem = + item.copy( + jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId), + jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId), + ) val film = - filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, + filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy( + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, ) ?: Film( id = idGenerator.generateId(), - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, ) return filmRepository.save(film) @@ -176,4 +200,114 @@ class JellyfinSyncService( ), ) } + + private fun ingestPluginSync(command: IngestJellyfinSyncCommand): JellyfinSyncSummary { + val startedAt = Instant.now() + upsertPluginUsers(command) + val localUsersByJellyfinId = + userRepository + .findAll() + .mapNotNull { user -> user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } } + .toMap() + + val skippedUserIds = mutableSetOf() + val syncedCountsByUserId = mutableMapOf() + + command.items.forEach { item -> + val savedFilm = + upsertFilm( + JellyfinLibraryItemSnapshot( + jellyfinItemId = item.jellyfinItemId, + title = item.title, + description = item.description ?: item.originalTitle ?: "", + contentType = ContentType.FILM, + releaseYear = item.year, + genres = item.genres, + cast = emptyList(), + directors = emptyList(), + platformRating = null, + imdbRating = null, + externalUrl = item.imdbId?.let { "https://www.imdb.com/title/$it/" }, + jellyfinLibraryId = item.jellyfinLibraryId, + isPlayed = false, + ), + ) + + item.userStates.forEach { state -> + val stateUserId = normalizeJellyfinId(state.jellyfinUserId) + val localUser = localUsersByJellyfinId[stateUserId] + if (localUser == null) { + skippedUserIds += stateUserId + return@forEach + } + + syncedCountsByUserId[localUser.id] = syncedCountsByUserId.getOrDefault(localUser.id, 0) + 1 + if (state.isViewed || state.playCount > 0) { + markFilmViewed( + userId = localUser.id, + filmId = savedFilm.id, + watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(), + ) + } + } + } + + val now = LocalDateTime.now() + syncedCountsByUserId.forEach { (userId, itemCount) -> + syncStateRepository.save( + JellyfinSyncState( + userId = userId, + lastSyncedAt = now, + lastSuccessfulSyncAt = now, + lastError = null, + syncedItemCount = itemCount, + ), + ) + } + + val summary = + JellyfinSyncSummary( + syncedUsers = syncedCountsByUserId.size, + skippedUsers = skippedUserIds.size, + syncedItems = command.items.size, + durationMs = Duration.between(startedAt, Instant.now()).toMillis(), + ) + businessMetricsService.recordJellyfinSync(summary) + return summary + } + + private fun upsertPluginUsers(command: IngestJellyfinSyncCommand) { + command.users.forEach { remoteUser -> + val jellyfinUserId = + remoteUser.jellyfinUserId + .takeIf { it.isNotBlank() } + ?.let(::normalizeJellyfinId) + ?: return@forEach + if (userRepository.findByJellyfinUserId(jellyfinUserId) != null) { + return@forEach + } + + userRepository.save( + User( + id = idGenerator.generateId(), + name = remoteUser.name?.takeIf { it.isNotBlank() } ?: "Jellyfin User", + email = syntheticJellyfinEmail(jellyfinUserId), + jellyfinUserId = jellyfinUserId, + ), + ) + } + } + + private fun syntheticJellyfinEmail(jellyfinUserId: String): String { + val safeId = + jellyfinUserId + .lowercase(Locale.getDefault()) + .replace(Regex("[^a-z0-9._%+-]"), "-") + .take(240) + return "jellyfin-$safeId@movienight.local" + } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e277aa3..c4248b1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -97,12 +97,13 @@ info: integrations: jellyfin: - enabled: ${JELLYFIN_SYNC_ENABLED:false} + enabled: ${JELLYFIN_INTEGRATION_ENABLED:false} base-url: ${JELLYFIN_BASE_URL:} web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} api-key: ${JELLYFIN_API_KEY:} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} + plugin-token: ${JELLYFIN_PLUGIN_TOKEN:} services: user: diff --git a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt new file mode 100644 index 0000000..d041f0c --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt @@ -0,0 +1,146 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest( + properties = [ + "integrations.jellyfin.enabled=true", + "integrations.jellyfin.plugin-token=test-token", + "integrations.jellyfin.web-url=https://jellyfin.example.test", + ], +) +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class JellyfinPluginContractTest { + private val jellyfinUserId = "11111111111111111111111111111111" + private val dashedJellyfinUserId = "11111111-1111-1111-1111-111111111111" + private val jellyfinItemId = "22222222222222222222222222222222" + private val dashedJellyfinItemId = "22222222-2222-2222-2222-222222222222" + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `plugin sync payload creates mapped user and film`() { + postSyncPayload() + + mockMvc + .perform( + get("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendations") + .header("X-MovieNight-Plugin-Token", "test-token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[0].title").value("Jellyfin Contract Film")) + .andExpect(jsonPath("$[0].jellyfinItemId").value(jellyfinItemId)) + .andExpect( + jsonPath("$[0].watchUrl") + .value("https://jellyfin.example.test/web/#/details?id=$jellyfinItemId"), + ) + + mockMvc + .perform( + post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/ratings/items/$dashedJellyfinItemId") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"score":8,"note":"From Jellyfin UI"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.score").value(8)) + + val viewedPath = + "/api/integrations/jellyfin/users/$dashedJellyfinUserId/library/items/" + + "$dashedJellyfinItemId/viewed" + + mockMvc + .perform( + post(viewedPath) + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"watchedAt":"2026-05-22T10:15:30Z"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewed").value(true)) + + mockMvc + .perform( + get("/api/integrations/jellyfin/sync-state") + .header("X-MovieNight-Plugin-Token", "test-token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[0].syncedItemCount").value(1)) + } + + @Test + fun `plugin token is required when configured`() { + mockMvc + .perform( + post("/api/integrations/jellyfin/sync") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(syncPayload())), + ).andExpect(status().isUnauthorized) + } + + @Test + fun `plugin onboarding can create user before first sync`() { + mockMvc + .perform( + post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendation-onboarding") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"weightedGenres":{"Drama":5},"contentTypes":["FILM"]}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.userId").exists()) + } + + private fun postSyncPayload() { + mockMvc + .perform( + post("/api/integrations/jellyfin/sync") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(syncPayload())), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.syncedUsers").value(1)) + .andExpect(jsonPath("$.syncedItems").value(1)) + } + + private fun syncPayload(): Map = + mapOf( + "users" to + listOf( + mapOf( + "jellyfinUserId" to jellyfinUserId, + "name" to "Jellyfin User", + ), + ), + "items" to + listOf( + mapOf( + "jellyfinItemId" to jellyfinItemId, + "title" to "Jellyfin Contract Film", + "description" to "Synced from plugin payload", + "year" to 2026, + "genres" to listOf("Drama"), + "imdbId" to "tt1234567", + "userStates" to + listOf( + mapOf( + "jellyfinUserId" to jellyfinUserId, + "isViewed" to false, + "playCount" to 0, + ), + ), + ), + ), + ) +} From 8350239a1a4746dc99b91f404685b4951c5bfff9 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 18:06:28 +0300 Subject: [PATCH 084/106] feat(helm): added helm chart for movienight --- deploy/helm/movienight/Chart.yaml | 6 + deploy/helm/movienight/templates/NOTES.txt | 25 ++ deploy/helm/movienight/templates/_helpers.tpl | 125 +++++++++ .../templates/backend/deployment.yaml | 92 +++++++ .../movienight/templates/backend/service.yaml | 21 ++ .../movienight/templates/gateway/gateway.yaml | 50 ++++ .../templates/gateway/httproute.yaml | 29 +++ .../templates/postgres/cluster.yaml | 36 +++ .../templates/rbac/serviceaccount.yaml | 12 + deploy/helm/movienight/values.schema.json | 246 ++++++++++++++++++ deploy/helm/movienight/values.yaml | 118 +++++++++ 11 files changed, 760 insertions(+) create mode 100644 deploy/helm/movienight/Chart.yaml create mode 100644 deploy/helm/movienight/templates/NOTES.txt create mode 100644 deploy/helm/movienight/templates/_helpers.tpl create mode 100644 deploy/helm/movienight/templates/backend/deployment.yaml create mode 100644 deploy/helm/movienight/templates/backend/service.yaml create mode 100644 deploy/helm/movienight/templates/gateway/gateway.yaml create mode 100644 deploy/helm/movienight/templates/gateway/httproute.yaml create mode 100644 deploy/helm/movienight/templates/postgres/cluster.yaml create mode 100644 deploy/helm/movienight/templates/rbac/serviceaccount.yaml create mode 100644 deploy/helm/movienight/values.schema.json create mode 100644 deploy/helm/movienight/values.yaml diff --git a/deploy/helm/movienight/Chart.yaml b/deploy/helm/movienight/Chart.yaml new file mode 100644 index 0000000..6280bbb --- /dev/null +++ b/deploy/helm/movienight/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: movienight +description: MovieNight backend +type: application +version: 0.1.0 +appVersion: "0.0.1" diff --git a/deploy/helm/movienight/templates/NOTES.txt b/deploy/helm/movienight/templates/NOTES.txt new file mode 100644 index 0000000..9eb3212 --- /dev/null +++ b/deploy/helm/movienight/templates/NOTES.txt @@ -0,0 +1,25 @@ +MovieNight backend has been deployed. + +Backend: + Service: {{ include "movienight.fullname" . }}-backend + Port: {{ .Values.backend.service.port }} + +Postgres: +{{- if .Values.postgres.url }} + Using explicit SPRING_DATASOURCE_URL. +{{- else if .Values.postgres.existingSecret.name }} + Using secret {{ .Values.postgres.existingSecret.name }}. +{{- else if .Values.postgres.cluster.enabled }} + CNPG Cluster: {{ include "movienight.postgresClusterName" . }} + JDBC URL: {{ include "movienight.postgresJdbcUrl" . }} +{{- else }} + No Postgres values provided. The app will fall back to its embedded H2 defaults. +{{- end }} + +Gateway: +{{- if .Values.gateway.enabled }} + Gateway: {{ include "movienight.gatewayName" . }} + GatewayClass: {{ .Values.gateway.className }} +{{- else }} + Disabled. +{{- end }} diff --git a/deploy/helm/movienight/templates/_helpers.tpl b/deploy/helm/movienight/templates/_helpers.tpl new file mode 100644 index 0000000..5479b62 --- /dev/null +++ b/deploy/helm/movienight/templates/_helpers.tpl @@ -0,0 +1,125 @@ +{{- define "movienight.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "movienight.fullname" -}} +{{- if .Values.fullnameOverride -}} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- $name := include "movienight.name" . -}} +{{- if contains $name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} +{{- end -}} + +{{- define "movienight.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" -}} +{{- end -}} + +{{- define "movienight.labels" -}} +helm.sh/chart: {{ include "movienight.chart" . }} +{{ include "movienight.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- with .Values.global.labels }} +{{ toYaml . }} +{{- end }} +{{- end -}} + +{{- define "movienight.selectorLabels" -}} +app.kubernetes.io/name: {{ include "movienight.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "movienight.componentLabels" -}} +{{- $root := .root -}} +{{- $component := .component -}} +{{ include "movienight.labels" $root }} +app.kubernetes.io/component: {{ $component }} +{{- end -}} + +{{- define "movienight.componentSelectorLabels" -}} +{{- $root := .root -}} +{{- $component := .component -}} +{{ include "movienight.selectorLabels" $root }} +app.kubernetes.io/component: {{ $component }} +{{- end -}} + +{{- define "movienight.serviceAccountName" -}} +{{- if .Values.serviceAccount.create -}} +{{- default (include "movienight.fullname" .) .Values.serviceAccount.name -}} +{{- else -}} +{{- default "default" .Values.serviceAccount.name -}} +{{- end -}} +{{- end -}} + +{{- define "movienight.gatewayName" -}} +{{- if .Values.gateway.name -}} +{{- .Values.gateway.name -}} +{{- else -}} +{{- printf "%s-gateway" (include "movienight.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "movienight.postgresClusterName" -}} +{{- if .Values.postgres.cluster.name -}} +{{- .Values.postgres.cluster.name -}} +{{- else -}} +{{- printf "%s-postgres" (include "movienight.fullname" .) -}} +{{- end -}} +{{- end -}} + +{{- define "movienight.postgresHost" -}} +{{- default (printf "%s-rw" (include "movienight.postgresClusterName" .)) .Values.postgres.cluster.host -}} +{{- end -}} + +{{- define "movienight.postgresJdbcUrl" -}} +{{- printf "jdbc:postgresql://%s:%v/%s" (include "movienight.postgresHost" .) (default 5432 .Values.postgres.cluster.port) .Values.postgres.cluster.database -}} +{{- end -}} + +{{- define "movienight.postgresEnv" -}} +{{- if .Values.postgres.url }} +- name: SPRING_DATASOURCE_URL + value: {{ .Values.postgres.url | quote }} +{{- if .Values.postgres.username }} +- name: SPRING_DATASOURCE_USERNAME + value: {{ .Values.postgres.username | quote }} +{{- end }} +{{- if .Values.postgres.password }} +- name: SPRING_DATASOURCE_PASSWORD + value: {{ .Values.postgres.password | quote }} +{{- end }} +{{- else if .Values.postgres.existingSecret.name }} +- name: SPRING_DATASOURCE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.postgres.existingSecret.name }} + key: {{ .Values.postgres.existingSecret.urlKey }} +- name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: {{ .Values.postgres.existingSecret.name }} + key: {{ .Values.postgres.existingSecret.usernameKey }} +- name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.postgres.existingSecret.name }} + key: {{ .Values.postgres.existingSecret.passwordKey }} +{{- else if .Values.postgres.cluster.enabled }} +- name: SPRING_DATASOURCE_URL + value: {{ include "movienight.postgresJdbcUrl" . | quote }} +- name: SPRING_DATASOURCE_USERNAME + valueFrom: + secretKeyRef: + name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }} + key: username +- name: SPRING_DATASOURCE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }} + key: password +{{- end -}} +{{- end -}} diff --git a/deploy/helm/movienight/templates/backend/deployment.yaml b/deploy/helm/movienight/templates/backend/deployment.yaml new file mode 100644 index 0000000..2a03d9d --- /dev/null +++ b/deploy/helm/movienight/templates/backend/deployment.yaml @@ -0,0 +1,92 @@ +{{- if .Values.backend.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "movienight.fullname" . }}-backend + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "backend") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: {{ .Values.backend.replicaCount }} + selector: + matchLabels: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 6 }} + template: + metadata: + labels: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 8 }} + {{- with .Values.backend.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "movienight.serviceAccountName" . }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- $postgresEnv := include "movienight.postgresEnv" . | trim }} + containers: + - name: backend + image: "{{ .Values.backend.image.repository }}:{{ .Values.backend.image.tag }}" + imagePullPolicy: {{ .Values.backend.image.pullPolicy }} + {{- with .Values.backend.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.backend.service.port }} + protocol: TCP + {{- if or $postgresEnv .Values.backend.env }} + env: +{{- if $postgresEnv }} +{{- $postgresEnv | nindent 12 }} +{{- end }} +{{- with .Values.backend.env }} +{{- toYaml . | nindent 12 }} +{{- end }} + {{- end }} + {{- with .Values.backend.envFrom }} + envFrom: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.startupProbe }} + startupProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.livenessProbe }} + livenessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.readinessProbe }} + readinessProbe: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.backend.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.backend.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/backend/service.yaml b/deploy/helm/movienight/templates/backend/service.yaml new file mode 100644 index 0000000..1a4ca83 --- /dev/null +++ b/deploy/helm/movienight/templates/backend/service.yaml @@ -0,0 +1,21 @@ +{{- if .Values.backend.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "movienight.fullname" . }}-backend + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "backend") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.backend.service.type }} + ports: + - name: http + port: {{ .Values.backend.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 4 }} +{{- end }} diff --git a/deploy/helm/movienight/templates/gateway/gateway.yaml b/deploy/helm/movienight/templates/gateway/gateway.yaml new file mode 100644 index 0000000..f2a2f4c --- /dev/null +++ b/deploy/helm/movienight/templates/gateway/gateway.yaml @@ -0,0 +1,50 @@ +{{- if .Values.gateway.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: {{ include "movienight.gatewayName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "gateway") | nindent 4 }} + {{- with .Values.gateway.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.gateway.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.gateway.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + gatewayClassName: {{ required "gateway.className is required when gateway.enabled=true" .Values.gateway.className | quote }} + listeners: + {{- if .Values.gateway.http.enabled }} + - name: http + protocol: HTTP + port: {{ .Values.gateway.http.port }} + {{- if .Values.gateway.listenerHostname }} + hostname: {{ .Values.gateway.listenerHostname | quote }} + {{- end }} + allowedRoutes: + namespaces: + from: Same + {{- end }} + {{- if .Values.gateway.https.enabled }} + - name: https + protocol: HTTPS + port: {{ .Values.gateway.https.port }} + {{- if .Values.gateway.listenerHostname }} + hostname: {{ .Values.gateway.listenerHostname | quote }} + {{- end }} + tls: + mode: Terminate + certificateRefs: + - kind: Secret + name: {{ required "gateway.https.secretName is required when gateway.https.enabled=true" .Values.gateway.https.secretName }} + allowedRoutes: + namespaces: + from: Same + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/gateway/httproute.yaml b/deploy/helm/movienight/templates/gateway/httproute.yaml new file mode 100644 index 0000000..5449768 --- /dev/null +++ b/deploy/helm/movienight/templates/gateway/httproute.yaml @@ -0,0 +1,29 @@ +{{- if and .Values.routes.enabled .Values.gateway.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "movienight.fullname" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "route") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + - name: {{ include "movienight.gatewayName" . }} + {{- if .Values.gateway.hostnames }} + hostnames: + {{- toYaml .Values.gateway.hostnames | nindent 4 }} + {{- end }} + rules: + {{- if and .Values.routes.backend.enabled .Values.backend.enabled }} + - matches: + - path: + type: PathPrefix + value: {{ .Values.routes.backend.pathPrefix | quote }} + backendRefs: + - name: {{ include "movienight.fullname" . }}-backend + port: {{ .Values.backend.service.port }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/postgres/cluster.yaml b/deploy/helm/movienight/templates/postgres/cluster.yaml new file mode 100644 index 0000000..57212c1 --- /dev/null +++ b/deploy/helm/movienight/templates/postgres/cluster.yaml @@ -0,0 +1,36 @@ +{{- if .Values.postgres.cluster.enabled }} +apiVersion: postgresql.cnpg.io/v1 +kind: Cluster +metadata: + name: {{ include "movienight.postgresClusterName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "postgres") | nindent 4 }} + {{- with .Values.postgres.cluster.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.postgres.cluster.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.postgres.cluster.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +spec: + instances: {{ .Values.postgres.cluster.instances }} + storage: + size: {{ .Values.postgres.cluster.storage.size | quote }} + {{- if .Values.postgres.cluster.storage.storageClass }} + storageClass: {{ .Values.postgres.cluster.storage.storageClass | quote }} + {{- end }} + bootstrap: + initdb: + database: {{ .Values.postgres.cluster.database | quote }} + owner: {{ .Values.postgres.cluster.owner | quote }} + secret: + name: {{ required "postgres.cluster.bootstrapSecretName is required when postgres.cluster.enabled=true" .Values.postgres.cluster.bootstrapSecretName }} + {{- with .Values.postgres.cluster.extraSpec }} + {{- toYaml . | nindent 2 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/templates/rbac/serviceaccount.yaml b/deploy/helm/movienight/templates/rbac/serviceaccount.yaml new file mode 100644 index 0000000..9930827 --- /dev/null +++ b/deploy/helm/movienight/templates/rbac/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "movienight.serviceAccountName" . }} + labels: + {{- include "movienight.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/deploy/helm/movienight/values.schema.json b/deploy/helm/movienight/values.schema.json new file mode 100644 index 0000000..395fdcc --- /dev/null +++ b/deploy/helm/movienight/values.schema.json @@ -0,0 +1,246 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "additionalProperties": true, + "definitions": { + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "envVar": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "valueFrom": { + "type": "object", + "additionalProperties": true + } + }, + "required": ["name"], + "additionalProperties": true + }, + "image": { + "type": "object", + "properties": { + "repository": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "pullPolicy": { + "type": "string" + } + }, + "additionalProperties": true + }, + "probe": { + "type": "object", + "additionalProperties": true + } + }, + "properties": { + "nameOverride": { + "type": "string" + }, + "fullnameOverride": { + "type": "string" + }, + "global": { + "type": "object", + "properties": { + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "labels": { + "$ref": "#/definitions/labels" + }, + "annotations": { + "$ref": "#/definitions/annotations" + } + }, + "additionalProperties": true + }, + "serviceAccount": { + "type": "object", + "properties": { + "create": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "annotations": { + "$ref": "#/definitions/annotations" + } + }, + "additionalProperties": true + }, + "postgres": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "existingSecret": { + "type": "object", + "additionalProperties": true + }, + "cluster": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "instances": { + "type": "integer" + }, + "database": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "bootstrapSecretName": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "storage": { + "type": "object", + "additionalProperties": true + }, + "labels": { + "$ref": "#/definitions/labels" + }, + "annotations": { + "$ref": "#/definitions/annotations" + }, + "extraSpec": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "backend": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "replicaCount": { + "type": "integer" + }, + "image": { + "$ref": "#/definitions/image" + }, + "service": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "port": { + "type": "integer" + } + }, + "additionalProperties": true + }, + "env": { + "type": "array", + "items": { + "$ref": "#/definitions/envVar" + } + }, + "envFrom": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "podAnnotations": { + "$ref": "#/definitions/annotations" + }, + "podLabels": { + "$ref": "#/definitions/labels" + }, + "resources": { + "type": "object", + "additionalProperties": true + }, + "securityContext": { + "type": "object", + "additionalProperties": true + }, + "podSecurityContext": { + "type": "object", + "additionalProperties": true + }, + "nodeSelector": { + "type": "object", + "additionalProperties": true + }, + "tolerations": { + "type": "array" + }, + "affinity": { + "type": "object", + "additionalProperties": true + }, + "livenessProbe": { + "$ref": "#/definitions/probe" + }, + "readinessProbe": { + "$ref": "#/definitions/probe" + }, + "startupProbe": { + "$ref": "#/definitions/probe" + } + }, + "additionalProperties": true + }, + "gateway": { + "type": "object", + "additionalProperties": true + }, + "routes": { + "type": "object", + "additionalProperties": true + } + } +} diff --git a/deploy/helm/movienight/values.yaml b/deploy/helm/movienight/values.yaml new file mode 100644 index 0000000..b787c63 --- /dev/null +++ b/deploy/helm/movienight/values.yaml @@ -0,0 +1,118 @@ +nameOverride: "" +fullnameOverride: "" + +global: + imagePullSecrets: [] + labels: {} + annotations: {} + +serviceAccount: + create: true + name: "" + annotations: {} + +postgres: + # Set url/username/password for a fixed database, or use existingSecret. + url: "" + username: "" + password: "" + existingSecret: + name: "" + urlKey: url + usernameKey: username + passwordKey: password + cluster: + enabled: false + name: "" + instances: 1 + database: postgres + owner: postgres + # Secret containing CNPG initdb owner credentials (username/password). + bootstrapSecretName: "" + host: "" + port: 5432 + storage: + size: 10Gi + storageClass: "" + labels: {} + annotations: {} + extraSpec: {} + +backend: + enabled: true + replicaCount: 1 + image: + repository: ghcr.io/devitq/movienight-backend + tag: latest + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 8080 + env: + - name: SERVER_PORT + value: "8080" + - name: SPRING_DATASOURCE_DRIVER_CLASS_NAME + value: org.postgresql.Driver + - name: SPRING_FLYWAY_ENABLED + value: "true" + - name: SPRING_FLYWAY_LOCATIONS + value: classpath:db/migration + - name: SPRING_FLYWAY_BASELINE_ON_MIGRATE + value: "true" + - name: SPRING_H2_CONSOLE_ENABLED + value: "false" + envFrom: [] + podAnnotations: {} + podLabels: {} + resources: {} + securityContext: {} + podSecurityContext: {} + nodeSelector: {} + tolerations: [] + affinity: {} + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: http + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: http + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 5 + startupProbe: + httpGet: + path: /actuator/health + port: http + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 5 + failureThreshold: 24 + +gateway: + enabled: false + name: "" + className: "" + labels: {} + annotations: {} + listenerHostname: "" + hostnames: [] + http: + enabled: true + port: 80 + https: + enabled: false + port: 443 + secretName: "" + +routes: + enabled: true + backend: + enabled: true + pathPrefix: / From ea6b90bc6742a3afcb84523ac8a169c226d9f4a7 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 18:07:31 +0300 Subject: [PATCH 085/106] feat(argocd): added required apps for movienight to operate --- deploy/argocd/cloudnative-pg.yaml | 26 ++++++++++++++ deploy/argocd/external-secrets-operator.yaml | 32 +++++++++++++++++ deploy/argocd/jellyfin.yaml | 36 ++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 deploy/argocd/cloudnative-pg.yaml create mode 100644 deploy/argocd/external-secrets-operator.yaml create mode 100644 deploy/argocd/jellyfin.yaml diff --git a/deploy/argocd/cloudnative-pg.yaml b/deploy/argocd/cloudnative-pg.yaml new file mode 100644 index 0000000..8aae1e4 --- /dev/null +++ b/deploy/argocd/cloudnative-pg.yaml @@ -0,0 +1,26 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cloudnative-pg + namespace: argocd +spec: + project: default + source: + repoURL: https://cloudnative-pg.github.io/charts + targetRevision: 0.27.1 + chart: cloudnative-pg + + destination: + server: https://kubernetes.default.svc + namespace: cloudnative-pg + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/external-secrets-operator.yaml b/deploy/argocd/external-secrets-operator.yaml new file mode 100644 index 0000000..9d0f430 --- /dev/null +++ b/deploy/argocd/external-secrets-operator.yaml @@ -0,0 +1,32 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: external-secrets-operator + namespace: argocd +spec: + project: default + source: + repoURL: https://charts.external-secrets.io + targetRevision: 2.5.0 + chart: external-secrets + helm: + valuesObject: + webhook: + create: false + certController: + create: false + + destination: + server: https://kubernetes.default.svc + namespace: external-secrets + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/jellyfin.yaml b/deploy/argocd/jellyfin.yaml new file mode 100644 index 0000000..898df16 --- /dev/null +++ b/deploy/argocd/jellyfin.yaml @@ -0,0 +1,36 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: jellyfin + namespace: argocd +spec: + project: default + source: + repoURL: https://jellyfin.github.io/jellyfin-helm + targetRevision: 2.7.0 + chart: jellyfin + helm: + valuesObject: + replicaCount: 1 + persistence: + config: + size: 4Gi + media: + size: 20Gi + metrics: + enabled: true + + destination: + server: https://kubernetes.default.svc + namespace: jellyfin + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true From 551b795d6e48314e1d6b87a70960592eeb3af95b Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 18:24:58 +0300 Subject: [PATCH 086/106] feat(manifests): added secrets for movienight --- deploy/manifests/backend-secret.yaml | 60 ++++++++++++++++++++++ deploy/manifests/bootstrap-secret.yaml | 30 +++++++++++ deploy/manifests/cluster-secret-store.yaml | 23 +++++++++ deploy/manifests/ns.yaml | 11 ++++ 4 files changed, 124 insertions(+) create mode 100644 deploy/manifests/backend-secret.yaml create mode 100644 deploy/manifests/bootstrap-secret.yaml create mode 100644 deploy/manifests/cluster-secret-store.yaml create mode 100644 deploy/manifests/ns.yaml diff --git a/deploy/manifests/backend-secret.yaml b/deploy/manifests/backend-secret.yaml new file mode 100644 index 0000000..cd0d074 --- /dev/null +++ b/deploy/manifests/backend-secret.yaml @@ -0,0 +1,60 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: movienight-backend + namespace: movienight +spec: + secretStoreRef: + name: infisical + kind: ClusterSecretStore + + target: + name: movienight-backend + creationPolicy: Owner + template: + engineVersion: v2 + type: Opaque + data: + JELLYFIN_INTEGRATION_ENABLED: "true" + JELLYFIN_BASE_URL: "http://jellyfin.jellyfin.svc.cluster.local:8096" + JELLYFIN_WEB_URL: "{{ .jellyfinWebUrl }}" + JELLYFIN_PLUGIN_TOKEN: "{{ .jellyfinPluginToken }}" + JELLYFIN_API_KEY: "{{ .jellyfinApiKey }}" + OAUTH2_GOOGLE_CLIENT_ID: "{{ .googleClientId }}" + OAUTH2_GOOGLE_CLIENT_SECRET: "{{ .googleClientSecret }}" + OAUTH2_YANDEX_CLIENT_ID: "{{ .yandexClientId }}" + OAUTH2_YANDEX_CLIENT_SECRET: "{{ .yandexClientSecret }}" + OAUTH2_VK_CLIENT_ID: "{{ .vkClientId }}" + OAUTH2_VK_CLIENT_SECRET: "{{ .vkClientSecret }}" + + data: + - secretKey: jellyfinWebUrl + remoteRef: + key: /movienight/MOVIENIGHT_JELLYFIN_WEB_URL + - secretKey: jellyfinPluginToken + remoteRef: + key: /movienight/MOVIENIGHT_JELLYFIN_PLUGIN_TOKEN + - secretKey: jellyfinApiKey + remoteRef: + key: /movienight/MOVIENIGHT_JELLYFIN_API_KEY + - secretKey: googleClientId + remoteRef: + key: /movienight/MOVIENIGHT_OAUTH2_GOOGLE_CLIENT_ID + - secretKey: googleClientSecret + remoteRef: + key: /movienight/MOVIENIGHT_OAUTH2_GOOGLE_CLIENT_SECRET + - secretKey: yandexClientId + remoteRef: + key: /movienight/MOVIENIGHT_OAUTH2_YANDEX_CLIENT_ID + - secretKey: yandexClientSecret + remoteRef: + key: /movienight/MOVIENIGHT_OAUTH2_YANDEX_CLIENT_SECRET + - secretKey: vkClientId + remoteRef: + key: /movienight/MOVIENIGHT_OAUTH2_VK_CLIENT_ID + - secretKey: vkClientSecret + remoteRef: + key: /movienight/MOVIENIGHT_OAUTH2_VK_CLIENT_SECRET + + refreshInterval: 1h diff --git a/deploy/manifests/bootstrap-secret.yaml b/deploy/manifests/bootstrap-secret.yaml new file mode 100644 index 0000000..34d42b9 --- /dev/null +++ b/deploy/manifests/bootstrap-secret.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: movienight-cnpg-bootstrap + namespace: movienight +spec: + secretStoreRef: + name: infisical + kind: ClusterSecretStore + + target: + name: movienight-cnpg-bootstrap + creationPolicy: Owner + template: + engineVersion: v2 + type: kubernetes.io/basic-auth + data: + username: "{{ .dbUsername }}" + password: "{{ .dbPassword }}" + + data: + - secretKey: dbUsername + remoteRef: + key: /movienight/MOVIENIGHT_DB_USERNAME + - secretKey: dbPassword + remoteRef: + key: /movienight/MOVIENIGHT_DB_PASSWORD + + refreshInterval: 1h diff --git a/deploy/manifests/cluster-secret-store.yaml b/deploy/manifests/cluster-secret-store.yaml new file mode 100644 index 0000000..177cf89 --- /dev/null +++ b/deploy/manifests/cluster-secret-store.yaml @@ -0,0 +1,23 @@ +--- +apiVersion: external-secrets.io/v1 +kind: ClusterSecretStore +metadata: + name: infisical +spec: + provider: + infisical: + hostAPI: https://vault.itqdev.xyz + auth: + universalAuthCredentials: + clientId: + name: infisical-secret + key: clientId + namespace: external-secrets + clientSecret: + name: infisical-secret + key: clientSecret + namespace: external-secrets + secretsScope: + projectSlug: default-c-nay + environmentSlug: prod + secretsPath: / diff --git a/deploy/manifests/ns.yaml b/deploy/manifests/ns.yaml new file mode 100644 index 0000000..b395506 --- /dev/null +++ b/deploy/manifests/ns.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: jellyfin + +--- +apiVersion: v1 +kind: Namespace +metadata: + name: movienight From fdbe47341ce8cf769e305c46d54ffc3c9140e20e Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 18:46:30 +0300 Subject: [PATCH 087/106] fix(logback): disabled file logging by default --- src/main/resources/logback-spring.xml | 32 +++++++++++++++++---------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index dd704c8..21a05b1 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -5,19 +5,27 @@ - - logs/app.json - - logs/app-%d{yyyy-MM-dd}.json - 30 - - - + + + + + - - - - + + + logs/app.json + + logs/app-%d{yyyy-MM-dd}.json + 30 + + + + + + + + + From 57cf91004224018e48ce879178b92ba363bcde12 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 19:04:26 +0300 Subject: [PATCH 088/106] fix(migrations): fixed migrations --- src/main/resources/db/migration/V4__add_ratings_table.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/db/migration/V4__add_ratings_table.sql b/src/main/resources/db/migration/V4__add_ratings_table.sql index be1f105..2141e8f 100644 --- a/src/main/resources/db/migration/V4__add_ratings_table.sql +++ b/src/main/resources/db/migration/V4__add_ratings_table.sql @@ -1,8 +1,8 @@ -- Create ratings table to store user film ratings CREATE TABLE ratings ( id BIGSERIAL PRIMARY KEY, - user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - film_id BIGINT NOT NULL REFERENCES films(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + film_id UUID NOT NULL REFERENCES films(id) ON DELETE CASCADE, rating NUMERIC(3, 1) NOT NULL CHECK (rating >= 0 AND rating <= 10), source VARCHAR(50) NOT NULL DEFAULT 'MOVIENIGHT', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, From ecafbef1874cb1f91f556fce3e30d62caa1fb1ab Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 19:38:17 +0300 Subject: [PATCH 089/106] fix(security): made swagger endpoints publicly available --- .../movienight/adapters/security/SecurityConfiguration.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 92fb02c..a5c8dcb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -23,6 +23,8 @@ class SecurityConfiguration( auth .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") .permitAll() + .requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**") + .permitAll() .requestMatchers( "/api/integrations/jellyfin/events", "/api/integrations/jellyfin/sync", From 939d2ac736470a44f71d0b89224c07d1f1d23213 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 19:45:25 +0300 Subject: [PATCH 090/106] ci(): some publishing fixes in CI --- .github/workflows/jellyfin-plugin.yaml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/jellyfin-plugin.yaml b/.github/workflows/jellyfin-plugin.yaml index a809b4a..874d201 100644 --- a/.github/workflows/jellyfin-plugin.yaml +++ b/.github/workflows/jellyfin-plugin.yaml @@ -43,16 +43,12 @@ jobs: plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj \ -c Release \ --no-restore \ - -o artifacts/jellyfin-plugin/MovieNight - - - name: Set artifact name - id: meta - run: echo "artifact-name=jellyfin-plugin-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + -o artifacts/MovieNight - name: Upload plugin artifact uses: actions/upload-artifact@v7 with: - name: ${{ steps.meta.outputs.artifact-name }} - path: artifacts/jellyfin-plugin/** + name: MovieNight + path: artifacts/MovieNight/* retention-days: 7 if-no-files-found: error From a4b2da66bff2ec6d184c660b017ae94627c06234 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 19:57:22 +0300 Subject: [PATCH 091/106] ci(): reordered steps to fix artifacts uploading problem --- .github/workflows/build.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8cd8d59..e7960c4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -30,13 +30,13 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 - - name: Run CI quality gate - run: ./gradlew clean check bootJar --stacktrace --no-daemon - - name: Set artifact name id: meta run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" + - name: Run CI quality gate + run: ./gradlew clean check bootJar --stacktrace --no-daemon + - name: Upload build artifacts if: always() uses: actions/upload-artifact@v7 From a9c75b8e992038e68912db99a7c307ab45e4f2e9 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 19:57:54 +0300 Subject: [PATCH 092/106] tests(contract): fixed sync test --- .../controllers/JellyfinPluginContractTest.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt index d041f0c..0a98a25 100644 --- a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt +++ b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt @@ -1,6 +1,7 @@ package com.project.movienight.controllers import com.fasterxml.jackson.databind.ObjectMapper +import org.hamcrest.Matchers.hasItem import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc @@ -38,17 +39,18 @@ class JellyfinPluginContractTest { fun `plugin sync payload creates mapped user and film`() { postSyncPayload() + val syncedRecommendationTitlePath = "$[?(@.jellyfinItemId == '$jellyfinItemId')].title" + val syncedRecommendationWatchUrlPath = "$[?(@.jellyfinItemId == '$jellyfinItemId')].watchUrl" + val expectedWatchUrl = "https://jellyfin.example.test/web/#/details?id=$jellyfinItemId" + mockMvc .perform( get("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendations") .header("X-MovieNight-Plugin-Token", "test-token"), ).andExpect(status().isOk) - .andExpect(jsonPath("$[0].title").value("Jellyfin Contract Film")) - .andExpect(jsonPath("$[0].jellyfinItemId").value(jellyfinItemId)) - .andExpect( - jsonPath("$[0].watchUrl") - .value("https://jellyfin.example.test/web/#/details?id=$jellyfinItemId"), - ) + .andExpect(jsonPath("$[*].jellyfinItemId").value(hasItem(jellyfinItemId))) + .andExpect(jsonPath(syncedRecommendationTitlePath).value(hasItem("Jellyfin Contract Film"))) + .andExpect(jsonPath(syncedRecommendationWatchUrlPath).value(hasItem(expectedWatchUrl))) mockMvc .perform( From 19163e79335ea87b00c663cb40067977052b97f5 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 19:40:03 +0300 Subject: [PATCH 093/106] fix(secrets): fixed secrets provisioning --- deploy/manifests/backend-secret.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deploy/manifests/backend-secret.yaml b/deploy/manifests/backend-secret.yaml index cd0d074..7c92aa2 100644 --- a/deploy/manifests/backend-secret.yaml +++ b/deploy/manifests/backend-secret.yaml @@ -16,8 +16,7 @@ spec: engineVersion: v2 type: Opaque data: - JELLYFIN_INTEGRATION_ENABLED: "true" - JELLYFIN_BASE_URL: "http://jellyfin.jellyfin.svc.cluster.local:8096" + JELLYFIN_BASE_URL: "{{ .jellyfinBaseUrl }}" JELLYFIN_WEB_URL: "{{ .jellyfinWebUrl }}" JELLYFIN_PLUGIN_TOKEN: "{{ .jellyfinPluginToken }}" JELLYFIN_API_KEY: "{{ .jellyfinApiKey }}" @@ -29,6 +28,9 @@ spec: OAUTH2_VK_CLIENT_SECRET: "{{ .vkClientSecret }}" data: + - secretKey: jellyfinBaseUrl + remoteRef: + key: /movienight/MOVIENIGHT_JELLYFIN_BASE_URL - secretKey: jellyfinWebUrl remoteRef: key: /movienight/MOVIENIGHT_JELLYFIN_WEB_URL From 4ab99d01373a89c4e5dcce3b7655cd6a0680343c Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 21:50:30 +0300 Subject: [PATCH 094/106] fix(exception): 500 error on root --- .../movienight/adapters/web/ApiExceptionHandler.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 29131e3..0d9a087 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -12,19 +12,20 @@ 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 +import org.springframework.web.servlet.resource.NoResourceFoundException @RestControllerAdvice class ApiExceptionHandler { private val log = LoggerFactory.getLogger(javaClass) - @ExceptionHandler(EntityNotFoundException::class) + @ExceptionHandler(EntityNotFoundException::class, NoResourceFoundException::class) @ResponseStatus(HttpStatus.NOT_FOUND) - fun handleNotFound(exception: EntityNotFoundException): ErrorResponse { + fun handleNotFound(exception: Exception): ErrorResponse { val traceId = currentTraceId() - log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message) + log.warn("Resource not found: traceId='{}', message='{}'", traceId, exception.message) return ErrorResponse( - message = exception.message ?: "Entity not found", + message = exception.message ?: "Resource not found", traceId = traceId, ) } From afbff24cd5de4f9612815ad72eea20d00fd080ec Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 21:50:47 +0300 Subject: [PATCH 095/106] fix(jdbc): fixed default jdbc settings --- src/main/resources/application.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c4248b1..a3b844c 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -7,7 +7,6 @@ spring: url: ${SPRING_DATASOURCE_URL:jdbc:h2:mem:movienight;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE} username: ${SPRING_DATASOURCE_USERNAME:sa} password: ${SPRING_DATASOURCE_PASSWORD:} - driver-class-name: ${SPRING_DATASOURCE_DRIVER_CLASS_NAME:org.h2.Driver} hikari: maximum-pool-size: ${SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE:20} minimum-idle: ${SPRING_DATASOURCE_HIKARI_MINIMUM_IDLE:5} From f2aebe4dea66d04ae7011f6ac2eadd1d12d0211f Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 21:51:05 +0300 Subject: [PATCH 096/106] chore(ui): small improvements to plugin UI --- .../Jellyfin.Plugin.MovieNight/Configuration/ui.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js index 022ad33..32e0f1f 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -41,10 +41,9 @@ } async function injectUI() { - // Check for onboarding await checkOnboarding(); - // 1. Item Detail Page + // Item Detail Page const detailButtons = document.querySelector('.mainDetailButtons'); if (detailButtons) { const itemId = getItemIdFromUrl(); @@ -66,18 +65,18 @@ } } - // 2. Library Pages - Add text buttons to toolbar + // Library Pages const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x'); if (toolBar && !document.querySelector('.btnMovieNightRecommend')) { toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { e.preventDefault(); showRecommendation(); })); - toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => { + toolBar.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', (e) => { e.preventDefault(); showAddMovieDialog(); })); } - // 3. Home Page - Prepend a MovieNight section + // Home Page const homeSections = document.querySelector('.sections.homeSectionsContainer'); if (homeSections && !document.querySelector('.movieNightHomeButtons')) { const section = document.createElement('div'); @@ -92,7 +91,7 @@ `; const btnContainer = section.querySelector('.movieNightBtnContainer'); btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); - btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog)); + btnContainer.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', showAddMovieDialog)); btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync)); homeSections.insertBefore(section, homeSections.firstChild); @@ -183,7 +182,7 @@ async function showAddMovieDialog() { const overlay = createOverlay(); - const dialog = createDialogBase('Add Movie (STRM)'); + const dialog = createDialogBase('Add Movie'); const content = dialog.querySelector('.dialog-content'); const footer = dialog.querySelector('.dialog-footer'); From ef1a11404ed453deabb13f225fbc8aca514ef401 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 23:29:50 +0300 Subject: [PATCH 097/106] =?UTF-8?q?=D0=A3=D1=81=D0=B8=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BF=D0=B5=D1=80=D1=81=D0=BE=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8E=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Рекомендации теперь сильнее опираются на явно высоко оценённые фильмы, штрафуют похожесть на негативные оценки и ослабляют широкие онбординг-фильтры при слабой релевантности. Добавлен регрессионный тест для сценария, где фильмы, похожие на любимые, должны ранжироваться выше простых совпадений по жанру и эпохе. --- .../services/RecommendationService.kt | 156 +++++++++++++++--- .../movienight/RecommendationSmokeTest.kt | 98 ++++++++++- 2 files changed, 231 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index da8dd01..6f310ee 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -281,23 +281,28 @@ class RecommendationService( libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, - ): SparseVector { - val profile = MutableSparseVector() + ): UserTasteProfile { + val preferenceProfile = MutableSparseVector() + val positiveChoiceProfile = MutableSparseVector() + val negativeChoiceProfile = MutableSparseVector() + val libraryProfile = MutableSparseVector() preferences?.weightedGenres.orEmpty().forEach { (genre, weight) -> - profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) + preferenceProfile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT) } preferences?.plotTypes.orEmpty().forEach { plotType -> - tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } + tokenize(plotType).forEach { preferenceProfile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) } } - preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } - preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) } - preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } + preferences?.eras.orEmpty().forEach { preferenceProfile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) } + preferences?.castAndDirectors.orEmpty().forEach { + preferenceProfile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) + } + preferences?.moods.orEmpty().forEach { preferenceProfile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) } preferences ?.contentTypes .orEmpty() .forEach { - profile.add( + preferenceProfile.add( feature("type", it.name), PREFERENCE_CONTENT_TYPE_WEIGHT, ) @@ -306,30 +311,47 @@ class RecommendationService( ratings.forEach { rating -> val film = filmsById[rating.filmId] ?: return@forEach val signal = ratingSignal(rating.score) - profile.add(buildFilmVector(film, weights).scale(signal)) + val filmVector = buildFilmVector(film, weights) + when { + signal >= POSITIVE_CHOICE_SIGNAL_THRESHOLD -> positiveChoiceProfile.add(filmVector.scale(signal)) + signal <= NEGATIVE_CHOICE_SIGNAL_THRESHOLD -> negativeChoiceProfile.add(filmVector.scale(-signal)) + } } libraryEntries.filterNot { it.isViewed }.forEach { entry -> val film = filmsById[entry.filmId] ?: return@forEach - profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) + libraryProfile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT)) } - return profile.toSparseVector() + val overallProfile = MutableSparseVector() + overallProfile.add(preferenceProfile.toSparseVector()) + overallProfile.add(positiveChoiceProfile.toSparseVector().scale(EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(negativeChoiceProfile.toSparseVector().scale(-EXPLICIT_CHOICE_PROFILE_WEIGHT)) + overallProfile.add(libraryProfile.toSparseVector()) + + return UserTasteProfile( + overall = overallProfile.toSparseVector(), + preferences = preferenceProfile.toSparseVector(), + positiveChoices = positiveChoiceProfile.toSparseVector(), + negativeChoices = negativeChoiceProfile.toSparseVector(), + library = libraryProfile.toSparseVector(), + ) } private fun scoreFilm( film: Film, query: RecommendationQuery, preferences: UserPreferences?, - userProfile: SparseVector, + userProfile: UserTasteProfile, inLibrary: Boolean, weights: UserRecommendationWeights, ): ScoredRecommendation { val reasons = mutableListOf() val filmVector = buildFilmVector(film, weights) - val preferenceScore = cosineSimilarity(userProfile, filmVector) + val relevanceBreakdown = relevanceScore(userProfile, filmVector) + val preferenceScore = relevanceBreakdown.combined val qualityScore = qualityScore(film) - val contextScore = contextScore(film, query, preferences) + val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) val score = @@ -339,7 +361,9 @@ class RecommendationService( weights.noveltyWeight * noveltyScore + weights.diversityWeight * diversityScore - if (preferenceScore > STRONG_REASON_THRESHOLD) { + if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) { + reasons += "Similar to films you rated highly" + } else if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" } matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> @@ -397,10 +421,58 @@ class RecommendationService( return vector.toSparseVector() } + private fun relevanceScore( + userProfile: UserTasteProfile, + filmVector: SparseVector, + ): RelevanceBreakdown { + val overallSimilarity = cosineSimilarity(userProfile.overall, filmVector) + val preferenceSimilarity = cosineSimilarity(userProfile.preferences, filmVector) + val positiveSimilarity = cosineSimilarity(userProfile.positiveChoices, filmVector).coerceAtLeast(0.0) + val negativeSimilarity = cosineSimilarity(userProfile.negativeChoices, filmVector).coerceAtLeast(0.0) + val librarySimilarity = cosineSimilarity(userProfile.library, filmVector).coerceAtLeast(0.0) + + if (!userProfile.hasExplicitChoices) { + return RelevanceBreakdown( + combined = overallSimilarity, + positiveSimilarity = positiveSimilarity, + ) + } + + val positiveComponent = + if (userProfile.hasPositiveChoices) { + positiveSimilarity * POSITIVE_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val preferenceComponent = preferenceSimilarity.coerceAtLeast(0.0) * BROAD_PREFERENCE_RELEVANCE_WEIGHT + val libraryComponent = + if (userProfile.hasLibraryChoices) { + librarySimilarity * LIBRARY_CHOICE_RELEVANCE_WEIGHT + } else { + 0.0 + } + val fallbackComponent = overallSimilarity.coerceAtLeast(0.0) * OVERALL_RELEVANCE_FALLBACK_WEIGHT + val negativePenalty = + if (userProfile.hasNegativeChoices) { + negativeSimilarity * NEGATIVE_CHOICE_RELEVANCE_PENALTY + } else { + 0.0 + } + + return RelevanceBreakdown( + combined = + (positiveComponent + preferenceComponent + libraryComponent + fallbackComponent - negativePenalty) + .coerceIn(MIN_RELEVANCE_SCORE, MAX_RELEVANCE_SCORE), + positiveSimilarity = positiveSimilarity, + ) + } + private fun contextScore( film: Film, query: RecommendationQuery, preferences: UserPreferences?, + userProfile: UserTasteProfile, + relevanceScore: Double, ): Double { var score = 0.0 var checks = 0 @@ -426,7 +498,16 @@ class RecommendationService( } } - return if (checks == 0) BASE_CONTEXT_SCORE else score / checks + val baseScore = if (checks == 0) BASE_CONTEXT_SCORE else score / checks + if (!userProfile.hasExplicitChoices) { + return baseScore + } + + val relevanceGate = + MIN_CONTEXT_RELEVANCE_GATE + + (MAX_CONTEXT_RELEVANCE_GATE - MIN_CONTEXT_RELEVANCE_GATE) * + relevanceScore.coerceIn(0.0, 1.0) + return baseScore * relevanceGate } private fun qualityScore(film: Film): Double { @@ -452,9 +533,9 @@ class RecommendationService( val filmGenres = film.genres.map(::normalize).toSet() return when { preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE - filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE - filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE - else -> LOW_DIVERSITY_SCORE + filmGenres.none { it in preferredGenres } -> LOW_DIVERSITY_SCORE + filmGenres.size > 1 -> HIGH_DIVERSITY_SCORE + else -> MEDIUM_DIVERSITY_SCORE } } @@ -579,6 +660,24 @@ class RecommendationService( val diversityScore: Double, ) + private data class RelevanceBreakdown( + val combined: Double, + val positiveSimilarity: Double, + ) + + private data class UserTasteProfile( + val overall: SparseVector, + val preferences: SparseVector, + val positiveChoices: SparseVector, + val negativeChoices: SparseVector, + val library: SparseVector, + ) { + val hasPositiveChoices: Boolean = positiveChoices.values.isNotEmpty() + val hasNegativeChoices: Boolean = negativeChoices.values.isNotEmpty() + val hasLibraryChoices: Boolean = library.values.isNotEmpty() + val hasExplicitChoices: Boolean = hasPositiveChoices || hasNegativeChoices || hasLibraryChoices + } + private data class ScoreContributions( val relevance: Double, val quality: Double, @@ -636,6 +735,7 @@ class RecommendationService( private const val PREFERENCE_MOOD_WEIGHT = 0.8 private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val LIBRARY_SIGNAL_WEIGHT = 0.25 + private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8 private const val LEARNING_RATE = 0.03 @@ -644,11 +744,23 @@ class RecommendationService( private const val BASE_CONTEXT_SCORE = 0.5 private const val BASE_QUALITY_SCORE = 0.5 private const val BASE_DIVERSITY_SCORE = 0.5 - private const val HIGH_DIVERSITY_SCORE = 1.0 - private const val MEDIUM_DIVERSITY_SCORE = 0.6 - private const val LOW_DIVERSITY_SCORE = 0.3 + private const val HIGH_DIVERSITY_SCORE = 0.75 + private const val MEDIUM_DIVERSITY_SCORE = 0.45 + private const val LOW_DIVERSITY_SCORE = 0.15 private const val STRONG_REASON_THRESHOLD = 0.15 + private const val EXPLICIT_CHOICE_REASON_THRESHOLD = 0.12 private const val QUALITY_REASON_THRESHOLD = 0.75 + private const val POSITIVE_CHOICE_SIGNAL_THRESHOLD = 0.4 + private const val NEGATIVE_CHOICE_SIGNAL_THRESHOLD = -0.3 + private const val POSITIVE_CHOICE_RELEVANCE_WEIGHT = 0.78 + private const val BROAD_PREFERENCE_RELEVANCE_WEIGHT = 0.12 + private const val LIBRARY_CHOICE_RELEVANCE_WEIGHT = 0.08 + private const val OVERALL_RELEVANCE_FALLBACK_WEIGHT = 0.08 + private const val NEGATIVE_CHOICE_RELEVANCE_PENALTY = 0.65 + private const val MIN_RELEVANCE_SCORE = -1.0 + private const val MAX_RELEVANCE_SCORE = 1.0 + private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35 + private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0 private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") private val stopWords = diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index c8aaae2..b11b744 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -335,6 +335,99 @@ class RecommendationSmokeTest { } } + @Test + fun `should rank films similar to highly rated choices above broad onboarding matches`() { + mockMvc + .post("/api/users") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(CreateUserRequest(name = "Harry", email = "harry@example.com")) + }.andExpect { + status { isCreated() } + } + + val userId = + UUID.fromString( + jdbcTemplate.queryForObject( + "SELECT id FROM users WHERE email = ?", + String::class.java, + "harry@example.com", + ), + ) + + val likedFirstFilmId = + createFilm( + title = "Wizard School Stone", + description = "A young wizard discovers a magic school, spells, friendship, and a hidden dark force.", + releaseYear = 2001, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.0, + ) + val likedSecondFilmId = + createFilm( + title = "Chamber of Magic", + description = "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", + releaseYear = 2002, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 8.1, + ) + val magicCandidateId = + createFilm( + title = "Academy of Spells", + description = "A group of friends learns spells at a magic academy while facing a dark wizard.", + releaseYear = 2005, + genres = listOf("Fantasy", "Adventure", "Family"), + imdbRating = 7.0, + ) + createFilm( + title = "Highway Strike", + description = "An elite agent chases criminals through explosions, heists, and street fights.", + releaseYear = 2005, + genres = listOf("Action"), + imdbRating = 9.4, + ) + + mockMvc + .put("/api/users/$userId/preferences") { + contentType = MediaType.APPLICATION_JSON + content = + objectMapper.writeValueAsString( + UpsertUserPreferencesRequest( + weightedGenres = mapOf("Action" to 5), + eras = listOf("2000s"), + contentTypes = listOf("FILM"), + ), + ) + }.andExpect { + status { isOk() } + } + + listOf(likedFirstFilmId, likedSecondFilmId).forEach { filmId -> + mockMvc + .post("/api/users/$userId/ratings/films/$filmId") { + contentType = MediaType.APPLICATION_JSON + content = objectMapper.writeValueAsString(RateFilmRequest(score = 10, note = "Favorite")) + }.andExpect { + status { isCreated() } + } + + mockMvc + .post("/api/users/$userId/library/films/$filmId/viewed") + .andExpect { + status { isOk() } + } + } + + mockMvc + .get("/api/users/$userId/recommendations") { + param("contentType", "FILM") + param("limit", "2") + }.andExpect { + status { isOk() } + jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } + jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + } + } + private fun cleanDatabase() { jdbcTemplate.execute("DELETE FROM recommendation_events") jdbcTemplate.execute("DELETE FROM user_recommendation_weights") @@ -370,6 +463,8 @@ class RecommendationSmokeTest { private fun createFilm( title: String, + description: String = "$title description", + releaseYear: Int? = null, genres: List, imdbRating: Double, ): UUID { @@ -380,8 +475,9 @@ class RecommendationSmokeTest { objectMapper.writeValueAsString( CreateFilmRequest( title = title, - description = "$title description", + description = description, contentType = "FILM", + releaseYear = releaseYear, genres = genres, imdbRating = imdbRating, ), From eb3f0cd2063cdb50de11afe53dba40797e44a93e Mon Sep 17 00:00:00 2001 From: skettiks Date: Sat, 23 May 2026 00:14:18 +0300 Subject: [PATCH 098/106] =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=80=D0=B0=D0=BD=D0=B6=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлены семантические теги вкуса для фильмов, штраф за слабое совпадение с явно понравившимися фильмами и проверка кейса, где реальные оценки должны быть важнее широких onboarding-предпочтений. --- .../services/RecommendationService.kt | 82 ++++++++++++++++++- .../movienight/RecommendationSmokeTest.kt | 5 +- 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index 6f310ee..f0eeb12 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -354,18 +354,22 @@ class RecommendationService( val contextScore = contextScore(film, query, preferences, userProfile, preferenceScore) val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE val diversityScore = diversityScore(film, preferences) - val score = + val rawScore = weights.relevanceWeight * preferenceScore + weights.qualityWeight * qualityScore + weights.contextWeight * contextScore + weights.noveltyWeight * noveltyScore + weights.diversityWeight * diversityScore + val score = rawScore - explicitChoiceMisfitPenalty(userProfile, relevanceBreakdown) if (relevanceBreakdown.positiveSimilarity > EXPLICIT_CHOICE_REASON_THRESHOLD) { reasons += "Similar to films you rated highly" } else if (preferenceScore > STRONG_REASON_THRESHOLD) { reasons += "Similar to user preferences and rating history" } + matchingPositiveTasteTags(film, userProfile).take(MAX_REASON_ITEMS).forEach { tag -> + reasons += "Shares taste signal: ${tag.toReasonLabel()}" + } matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre -> reasons += "Matches preferred genre: $genre" } @@ -409,11 +413,13 @@ class RecommendationService( val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() } val plotTokens = tokenize("${film.title} ${film.description}") val moods = inferredMoods(film) + val semanticTags = semanticTags(film) val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() } vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight) distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight) distribute(vector, "plot", plotTokens, weights.plotVectorWeight) + distribute(vector, "tag", semanticTags, weights.plotVectorWeight * SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER) distribute(vector, "mood", moods, weights.moodVectorWeight) film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) } distribute(vector, "person", people, weights.peopleVectorWeight) @@ -467,6 +473,23 @@ class RecommendationService( ) } + private fun explicitChoiceMisfitPenalty( + userProfile: UserTasteProfile, + relevanceBreakdown: RelevanceBreakdown, + ): Double { + if (!userProfile.hasPositiveChoices) { + return 0.0 + } + val fit = relevanceBreakdown.positiveSimilarity + if (fit >= POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) { + return 0.0 + } + val missingFitRatio = + ((POSITIVE_CHOICE_SOFT_FIT_THRESHOLD - fit) / POSITIVE_CHOICE_SOFT_FIT_THRESHOLD) + .coerceIn(0.0, 1.0) + return EXPLICIT_CHOICE_MISFIT_MAX_PENALTY * missingFitRatio + } + private fun contextScore( film: Film, query: RecommendationQuery, @@ -516,7 +539,7 @@ class RecommendationService( film.imdbRating?.let { normalizeRating(it) }, film.platformRating?.let { normalizeRating(it) }, ) - return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE + return normalizedRatings.averageOrNull() ?: UNKNOWN_QUALITY_SCORE } private fun diversityScore( @@ -546,6 +569,25 @@ class RecommendationService( .keys } + private fun semanticTags(film: Film): Set { + val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}") + return semanticTagLexicon + .filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } } + .keys + } + + private fun matchingPositiveTasteTags( + film: Film, + userProfile: UserTasteProfile, + ): List { + if (!userProfile.hasPositiveChoices) { + return emptyList() + } + return semanticTags(film) + .filter { tag -> userProfile.positiveChoices.values.containsKey(feature("tag", tag)) } + .sorted() + } + private fun matchingGenres( film: Film, preferences: UserPreferences?, @@ -622,6 +664,10 @@ class RecommendationService( .trim() .lowercase(Locale.getDefault()) + private fun String.toReasonLabel(): String = + split("-") + .joinToString(" ") { token -> token.replaceFirstChar { char -> char.titlecase(Locale.getDefault()) } } + private fun cosineSimilarity( left: SparseVector, right: SparseVector, @@ -736,13 +782,14 @@ class RecommendationService( private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5 private const val LIBRARY_SIGNAL_WEIGHT = 0.25 private const val EXPLICIT_CHOICE_PROFILE_WEIGHT = 1.8 + private const val SEMANTIC_TAG_VECTOR_WEIGHT_MULTIPLIER = 0.9 private const val LEARNING_RATE = 0.03 private const val LIBRARY_NOVELTY_SCORE = 0.85 private const val CATALOG_NOVELTY_SCORE = 0.65 private const val BASE_CONTEXT_SCORE = 0.5 - private const val BASE_QUALITY_SCORE = 0.5 + private const val UNKNOWN_QUALITY_SCORE = 0.42 private const val BASE_DIVERSITY_SCORE = 0.5 private const val HIGH_DIVERSITY_SCORE = 0.75 private const val MEDIUM_DIVERSITY_SCORE = 0.45 @@ -761,6 +808,8 @@ class RecommendationService( private const val MAX_RELEVANCE_SCORE = 1.0 private const val MIN_CONTEXT_RELEVANCE_GATE = 0.35 private const val MAX_CONTEXT_RELEVANCE_GATE = 1.0 + private const val POSITIVE_CHOICE_SOFT_FIT_THRESHOLD = 0.10 + private const val EXPLICIT_CHOICE_MISFIT_MAX_PENALTY = 0.12 private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+") private val stopWords = @@ -782,5 +831,32 @@ class RecommendationService( "romantic" to listOf("romance", "love", "relationship"), "focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"), ) + private val semanticTagLexicon = + mapOf( + "magic-fantasy" to + listOf( + "magic", + "magical", + "wizard", + "witch", + "spell", + "sorcer", + "fantasy", + "enchanted", + "dragon", + ), + "wizard-school" to listOf("wizard school", "magic school", "academy", "school of magic"), + "young-adult" to listOf("young", "teen", "teenage", "teenager", "student", "coming of age"), + "family-adventure" to listOf("family", "friendship", "friends", "adventure", "quest"), + "quest-adventure" to listOf("quest", "journey", "treasure", "relic", "map", "kingdom"), + "heist-crime" to listOf("heist", "thief", "robbery", "criminal", "crime", "gang"), + "space-opera" to listOf("space", "spaceship", "galaxy", "planet", "alien", "starship"), + "superhero" to listOf("superhero", "hero", "masked", "powers", "mutant"), + "martial-arts" to listOf("martial", "kung fu", "samurai", "ninja", "warrior", "sword"), + "war-epic" to listOf("war", "battle", "army", "soldier", "general", "rebel"), + "mystery-investigation" to listOf("mystery", "detective", "investigation", "secret", "clue"), + "dark-fantasy" to listOf("dark force", "curse", "underworld", "demon", "monster"), + "animated-anime" to listOf("animation", "animated", "anime"), + ) } } diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index b11b744..2b24632 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -372,8 +372,8 @@ class RecommendationSmokeTest { ) val magicCandidateId = createFilm( - title = "Academy of Spells", - description = "A group of friends learns spells at a magic academy while facing a dark wizard.", + title = "Sorcerer Academy", + description = "A teenage student joins an academy with friends and faces an enchanted threat.", releaseYear = 2005, genres = listOf("Fantasy", "Adventure", "Family"), imdbRating = 7.0, @@ -425,6 +425,7 @@ class RecommendationSmokeTest { status { isOk() } jsonPath("$[0].filmId") { value(magicCandidateId.toString()) } jsonPath("$[0].reasons[0]") { value("Similar to films you rated highly") } + jsonPath("$[0].reasons[1]") { value("Shares taste signal: Family Adventure") } } } From 360e2aa819940b8c8916aa7df693a2156f8aa148 Mon Sep 17 00:00:00 2001 From: skettiks Date: Sat, 23 May 2026 00:22:19 +0300 Subject: [PATCH 099/106] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=20=D1=80=D0=B5=D0=BA=D0=BE=D0=BC=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разбиты длинные строки в RecommendationSmokeTest, из-за которых CI падал на ktlint и detekt. --- .../com/project/movienight/RecommendationSmokeTest.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt index 2b24632..23e2518 100644 --- a/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt +++ b/src/test/kotlin/com/project/movienight/RecommendationSmokeTest.kt @@ -340,7 +340,13 @@ class RecommendationSmokeTest { mockMvc .post("/api/users") { contentType = MediaType.APPLICATION_JSON - content = objectMapper.writeValueAsString(CreateUserRequest(name = "Harry", email = "harry@example.com")) + content = + objectMapper.writeValueAsString( + CreateUserRequest( + name = "Harry", + email = "harry@example.com", + ), + ) }.andExpect { status { isCreated() } } @@ -365,7 +371,8 @@ class RecommendationSmokeTest { val likedSecondFilmId = createFilm( title = "Chamber of Magic", - description = "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", + description = + "Young friends return to a wizard school and uncover a secret chamber full of magical danger.", releaseYear = 2002, genres = listOf("Fantasy", "Adventure", "Family"), imdbRating = 8.1, From a008cb115eb89a3f9e1a1a2f369f31d9a15b6fa9 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 01:02:44 +0300 Subject: [PATCH 100/106] chore(): refactor and improve plugin UI --- .../Configuration/ui.js | 655 +++++++++++++++--- 1 file changed, 568 insertions(+), 87 deletions(-) diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js index 32e0f1f..156eb1c 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -1,26 +1,249 @@ (function () { + if (typeof window.movieNightUiCleanup === 'function') { + window.movieNightUiCleanup(); + } + const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb"; + const ROUTE_RETRY_DELAYS_MS = [0, 100, 300, 700, 1500, 3000]; function getAlert() { if (typeof Dashboard !== 'undefined' && Dashboard.alert) { - return (options) => Dashboard.alert(options); + return (options) => Dashboard.alert(formatAlertMessage(options)); } return (options) => { - const msg = typeof options === 'string' ? options : (options.text || options.title); - alert(msg); + alert(formatAlertMessage(options)); }; } const showMsg = getAlert(); - function createTextButton(text, className, onClick) { + function formatAlertMessage(options) { + if (typeof options === 'string') return options; + if (!options) return ''; + return [options.title, options.text || options.message].filter(Boolean).join('\n\n'); + } + + function ensureMovieNightStyles() { + if (document.getElementById('movieNightUiStyles')) return; + + const style = document.createElement('style'); + style.id = 'movieNightUiStyles'; + style.textContent = ` + .movieNightActionButton { + align-items: center; + border: var(--defaultLighterBorder, 1px solid rgba(255,255,255,.16)); + border-radius: var(--smallRadius, 8px); + display: inline-flex; + gap: .55em; + min-height: 2.65em; + padding: .55em .9em; + transition: background-color .16s ease, border-color .16s ease, color .16s ease; + } + .movieNightActionButton:hover, + .movieNightActionButton:focus { + border-color: var(--dimTextColor, rgba(255,255,255,.45)); + color: #fff; + } + .movieNightActionButton .material-icons { + font-size: 1.35em; + } + .movieNightDetailButton { + color: var(--textColor, #fff); + } + .movieNightDetailButton .detailButton-content { + border-radius: 999px; + outline: 1px solid rgba(255,255,255,.16); + outline-offset: -1px; + } + .movieNightDetailButton:focus .detailButton-content, + .movieNightDetailButton:hover .detailButton-content { + background: rgba(255,255,255,.18); + } + .movieNightHomeButtons { + margin-bottom: 1.25em; + } + .movieNightPanel { + background: color-mix(in srgb, var(--headerColor, #202020) 78%, transparent); + border: var(--defaultBorder, 1px solid rgba(255,255,255,.12)); + border-radius: var(--smallRadius, 8px); + box-sizing: border-box; + padding: 1em; + } + .movieNightPanelHeader { + align-items: center; + display: flex; + gap: 1em; + justify-content: space-between; + margin-bottom: .75em; + } + .movieNightPanelHeader .sectionTitle { + margin: 0; + } + .movieNightSyncStatus { + color: var(--dimTextColor, rgba(255,255,255,.65)); + font-size: .86em; + text-align: right; + } + .movieNightBtnContainer { + display: flex; + flex-wrap: wrap; + gap: .65em; + } + .movieNightDialog { + background: color-mix(in srgb, var(--drawerColor, #1f1f1f) 92%, transparent) !important; + border: var(--defaultBorder, 1px solid rgba(255,255,255,.15)) !important; + border-radius: var(--smallRadius, 8px) !important; + box-shadow: var(--shadow, 0 18px 55px rgba(0,0,0,.55)) !important; + box-sizing: border-box; + color: var(--textColor, #fff) !important; + max-width: calc(100vw - 2em); + } + .movieNightDialogTitle { + align-items: center; + display: flex; + gap: .55em; + margin: 0; + font-size: 1.35em; + font-weight: 500; + } + .movieNightDialogTitle .material-icons { + color: var(--uiAccentColor, #00a4dc); + font-size: 1.25em; + } + .movieNightDialog .dialog-content { + color: var(--textColor, #fff); + } + .movieNightDialog .dialog-footer { + justify-content: flex-end; + } + .movieNightRecommendationList { + display: grid; + gap: .8em; + } + .movieNightRecommendation { + background: rgba(255,255,255,.055); + border: var(--defaultLighterBorder, 1px solid rgba(255,255,255,.14)); + border-radius: var(--smallRadius, 8px); + display: grid; + gap: .9em; + grid-template-columns: 76px minmax(0, 1fr); + padding: .75em; + } + .movieNightRecommendationPoster { + align-self: start; + aspect-ratio: 2 / 3; + background: rgba(255,255,255,.08); + border-radius: var(--smallerRadius, 6px); + object-fit: cover; + overflow: hidden; + width: 76px; + } + .movieNightRecommendationBody { + min-width: 0; + } + .movieNightRecommendationHeader { + align-items: start; + display: flex; + gap: 1em; + justify-content: space-between; + } + .movieNightRecommendationTitle { + color: #fff; + font-size: 1.08em; + font-weight: 600; + line-height: 1.25; + overflow-wrap: anywhere; + } + .movieNightRecommendationMeta, + .movieNightRecommendationReason { + color: var(--dimTextColor, rgba(255,255,255,.68)); + font-size: .9em; + margin-top: .25em; + } + .movieNightRecommendationScore { + background: rgba(255,255,255,.1); + border-radius: 999px; + color: #fff; + flex: 0 0 auto; + font-size: .82em; + padding: .28em .65em; + white-space: nowrap; + } + .movieNightRecommendationDescription { + color: rgba(255,255,255,.84); + display: -webkit-box; + line-height: 1.35; + margin-top: .65em; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + } + .movieNightRecommendationActions { + display: flex; + flex-wrap: wrap; + gap: .5em; + margin-top: .75em; + } + .movieNightRecommendationActions .emby-button { + min-height: 2.35em; + } + .movieNightField { + margin-bottom: 1em; + } + .movieNightField label { + color: var(--dimTextColor, rgba(255,255,255,.72)); + display: block; + font-size: .9em; + margin-bottom: .35em; + } + .movieNightFieldRow { + display: grid; + gap: 1em; + grid-template-columns: minmax(6em, .7fr) minmax(0, 1.3fr); + } + .movieNightDialog .emby-input { + box-sizing: border-box; + width: 100%; + } + @media (max-width: 42em) { + .movieNightRecommendation { + grid-template-columns: 56px minmax(0, 1fr); + } + .movieNightRecommendationPoster { + width: 56px; + } + .movieNightRecommendationHeader { + display: block; + } + .movieNightRecommendationScore { + display: inline-flex; + margin-top: .45em; + } + .movieNightPanelHeader { + align-items: flex-start; + flex-direction: column; + gap: .35em; + } + .movieNightSyncStatus { + text-align: left; + } + .movieNightFieldRow { + grid-template-columns: 1fr; + gap: 0; + } + } + `; + document.head.appendChild(style); + } + + function createTextButton(text, className, onClick, icon) { const btn = document.createElement('button'); btn.type = 'button'; btn.is = 'emby-button'; - btn.className = `emby-button raised ${className}`; - btn.style.margin = '0.5em'; - btn.style.padding = '0.4em 1em'; - btn.innerHTML = `${text}`; + btn.className = `emby-button raised movieNightActionButton ${className}`; + btn.innerHTML = icon + ? `${text}` + : `${text}`; btn.onclick = onClick; return btn; } @@ -29,7 +252,7 @@ const btn = document.createElement('button'); btn.type = 'button'; btn.is = 'emby-button'; - btn.className = `button-flat detailButton emby-button ${className}`; + btn.className = `button-flat detailButton emby-button movieNightDetailButton ${className}`; btn.title = title; btn.innerHTML = `
@@ -41,62 +264,76 @@ } async function injectUI() { + ensureMovieNightStyles(); await checkOnboarding(); // Item Detail Page - const detailButtons = document.querySelector('.mainDetailButtons'); - if (detailButtons) { - const itemId = getItemIdFromUrl(); - if (itemId) { - // MovieNight Rating - if (!document.querySelector('.btnMovieNightRate')) { - const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { - e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); - }); - insertInDetailRow(detailButtons, rateBtn); - } - // Mark Viewed in MovieNight - if (!document.querySelector('.btnMovieNightMarkViewed')) { - const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { - e.preventDefault(); e.stopPropagation(); submitViewed(itemId); - }); - insertInDetailRow(detailButtons, viewedBtn); - } + const itemId = getItemIdFromUrl(); + document.querySelectorAll('.mainDetailButtons').forEach((detailButtons) => { + if (!itemId) return; + + // MovieNight Rating + const existingRateBtn = detailButtons.querySelector('.btnMovieNightRate'); + if (!existingRateBtn || existingRateBtn.dataset.movieNightItemId !== itemId) { + existingRateBtn?.remove(); + const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { + e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); + }); + rateBtn.dataset.movieNightItemId = itemId; + insertInDetailRow(detailButtons, rateBtn); } - } + // Mark Viewed in MovieNight + const existingViewedBtn = detailButtons.querySelector('.btnMovieNightMarkViewed'); + if (!existingViewedBtn || existingViewedBtn.dataset.movieNightItemId !== itemId) { + existingViewedBtn?.remove(); + const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { + e.preventDefault(); e.stopPropagation(); submitViewed(itemId); + }); + viewedBtn.dataset.movieNightItemId = itemId; + insertInDetailRow(detailButtons, viewedBtn); + } + }); // Library Pages - const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x'); - if (toolBar && !document.querySelector('.btnMovieNightRecommend')) { - toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { - e.preventDefault(); showRecommendation(); - })); - toolBar.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', (e) => { - e.preventDefault(); showAddMovieDialog(); - })); - } + document + .querySelectorAll('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x') + .forEach((toolBar) => { + if (!toolBar.querySelector('.btnMovieNightRecommend')) { + toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { + e.preventDefault(); showRecommendation(); + }, 'auto_awesome')); + } + if (!toolBar.querySelector('.btnMovieNightAddMovie')) { + toolBar.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', (e) => { + e.preventDefault(); showAddMovieDialog(); + }, 'add')); + } + }); // Home Page - const homeSections = document.querySelector('.sections.homeSectionsContainer'); - if (homeSections && !document.querySelector('.movieNightHomeButtons')) { + document.querySelectorAll('.sections.homeSectionsContainer').forEach((homeSections) => { + if (homeSections.querySelector('.movieNightHomeButtons')) return; + const section = document.createElement('div'); section.className = 'verticalSection movieNightHomeButtons'; section.style.padding = '0 var(--sidePadding)'; section.innerHTML = ` -
-

MovieNight

- +
+
+

MovieNight

+ +
+
-
`; const btnContainer = section.querySelector('.movieNightBtnContainer'); - btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); - btnContainer.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', showAddMovieDialog)); - btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync)); + btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation, 'auto_awesome')); + btnContainer.appendChild(createTextButton('Add Movie', 'btnMovieNightAddMovie', showAddMovieDialog, 'add')); + btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync, 'sync')); homeSections.insertBefore(section, homeSections.firstChild); updateSyncStatus(); - } + }); } function insertInDetailRow(container, btn) { @@ -125,23 +362,21 @@ function createDialogBase(title) { const dialog = document.createElement('div'); - dialog.className = 'dialog'; + dialog.className = 'dialog movieNightDialog'; dialog.style.position = 'fixed'; dialog.style.top = '50%'; dialog.style.left = '50%'; dialog.style.transform = 'translate(-50%, -50%)'; dialog.style.zIndex = '99999'; - dialog.style.padding = '2.5em'; + dialog.style.padding = '1.25em'; dialog.style.minWidth = '350px'; - dialog.style.backgroundColor = '#1a1a1a'; - dialog.style.borderRadius = '1.5em'; - dialog.style.color = 'white'; - dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)'; - dialog.style.border = '1px solid #444'; dialog.style.opacity = '1'; dialog.innerHTML = ` -

${title}

-
+

+ + ${title} +

+
@@ -187,23 +422,23 @@ const footer = dialog.querySelector('.dialog-footer'); content.innerHTML = ` -
- - +
+ +
-
-
- - +
+
+ +
-
- - +
+ +
-
- - +
+ +
`; @@ -311,10 +546,10 @@ async function checkOnboarding() { if (window.movieNightOnboardingChecked) return; - window.movieNightOnboardingChecked = true; const userId = ApiClient.getCurrentUserId(); if (!userId) return; + window.movieNightOnboardingChecked = true; try { const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`)); @@ -345,15 +580,10 @@ const userId = ApiClient.getCurrentUserId(); try { const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`)); - const recommendations = typeof response === 'string' ? JSON.parse(response) : response; + const recommendations = normalizeRecommendations(response); if (recommendations && recommendations.length > 0) { - const rec = recommendations[0]; - const film = rec.film || rec; - showMsg({ - title: 'MovieNight Recommendation', - text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}` - }); + showRecommendationsDialog(recommendations); } else { showMsg('No recommendations found at the moment.'); } @@ -363,6 +593,187 @@ } } + function normalizeRecommendations(response) { + if (typeof response === 'string') { + return JSON.parse(response); + } + + if (Array.isArray(response)) { + return response; + } + + return response?.items || response?.recommendations || []; + } + + function getRecommendationFilm(recommendation) { + return recommendation?.film || recommendation || {}; + } + + function getRecommendationTitle(recommendation) { + const film = getRecommendationFilm(recommendation); + return film.title || recommendation?.title || 'Untitled'; + } + + function getRecommendationItemId(recommendation) { + const film = getRecommendationFilm(recommendation); + return recommendation?.jellyfinItemId || film.jellyfinItemId; + } + + function getRecommendationWatchUrl(recommendation) { + const itemId = getRecommendationItemId(recommendation); + if (recommendation?.watchUrl) return recommendation.watchUrl; + return itemId ? `${window.location.origin}/web/#/details?id=${encodeURIComponent(itemId)}` : null; + } + + function getRecommendationPosterUrl(recommendation) { + const itemId = getRecommendationItemId(recommendation); + if (!itemId) return null; + + if (typeof ApiClient !== 'undefined' && ApiClient.getUrl) { + return ApiClient.getUrl(`Items/${itemId}/Images/Primary`, { + fillHeight: 330, + fillWidth: 220, + quality: 90 + }); + } + + return `/Items/${encodeURIComponent(itemId)}/Images/Primary?fillHeight=330&fillWidth=220&quality=90`; + } + + function formatRecommendationScore(score) { + return typeof score === 'number' ? `${Math.round(score * 100)}% match` : ''; + } + + function showRecommendationsDialog(recommendations) { + const overlay = createOverlay(); + const dialog = createDialogBase('MovieNight Recommendations'); + dialog.style.width = 'min(760px, calc(100vw - 2em))'; + dialog.style.maxHeight = 'min(760px, calc(100vh - 2em))'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + content.style.overflowY = 'auto'; + content.style.paddingRight = '.25em'; + content.style.marginBottom = '1em'; + footer.querySelector('.btnCancel').textContent = 'Close'; + + const list = document.createElement('div'); + list.className = 'movieNightRecommendationList'; + content.replaceChildren(list); + + recommendations.forEach((recommendation) => { + list.appendChild(createRecommendationRow(recommendation, cleanup)); + }); + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + function createRecommendationRow(recommendation, cleanup) { + const film = getRecommendationFilm(recommendation); + const itemId = getRecommendationItemId(recommendation); + const watchUrl = getRecommendationWatchUrl(recommendation); + const posterUrl = getRecommendationPosterUrl(recommendation); + + const row = document.createElement('div'); + row.className = 'movieNightRecommendation'; + + const poster = document.createElement('img'); + poster.className = 'movieNightRecommendationPoster'; + poster.alt = ''; + if (posterUrl) { + poster.src = posterUrl; + } + poster.onerror = () => { + poster.removeAttribute('src'); + }; + + const body = document.createElement('div'); + body.className = 'movieNightRecommendationBody'; + + const header = document.createElement('div'); + header.className = 'movieNightRecommendationHeader'; + + const titleBlock = document.createElement('div'); + titleBlock.style.minWidth = '0'; + + const title = document.createElement('div'); + title.className = 'movieNightRecommendationTitle'; + title.textContent = getRecommendationTitle(recommendation); + titleBlock.appendChild(title); + + const meta = [film.releaseYear, ...(film.genres || [])].filter(Boolean).join(' · '); + if (meta) { + const metaEl = document.createElement('div'); + metaEl.className = 'movieNightRecommendationMeta'; + metaEl.textContent = meta; + titleBlock.appendChild(metaEl); + } + + const score = document.createElement('div'); + score.className = 'movieNightRecommendationScore'; + score.textContent = formatRecommendationScore(recommendation?.score); + + header.appendChild(titleBlock); + if (score.textContent) header.appendChild(score); + body.appendChild(header); + + if (film.description) { + const description = document.createElement('div'); + description.className = 'movieNightRecommendationDescription'; + description.textContent = film.description; + body.appendChild(description); + } + + const reasons = recommendation?.reasons || []; + if (reasons.length) { + const reason = document.createElement('div'); + reason.className = 'movieNightRecommendationReason'; + reason.textContent = reasons.join(', '); + body.appendChild(reason); + } + + const actions = document.createElement('div'); + actions.className = 'movieNightRecommendationActions'; + + if (watchUrl) { + actions.appendChild(createRecommendationAction('Open', 'play_arrow', () => { + cleanup(); + window.location.href = watchUrl; + })); + } + + if (itemId) { + actions.appendChild(createRecommendationAction('Rate', 'star_rate', () => { + cleanup(); + showRatingDialog(itemId); + })); + actions.appendChild(createRecommendationAction('Viewed', 'visibility', () => { + cleanup(); + submitViewed(itemId); + })); + } + + body.appendChild(actions); + row.appendChild(poster); + row.appendChild(body); + return row; + } + + function createRecommendationAction(text, icon, onClick) { + return createTextButton(text, 'movieNightRecommendationAction', (e) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + }, icon); + } + async function addMovie(title, url, year, imdbId) { try { const data = { title, url }; @@ -439,17 +850,87 @@ } } - let timeout; - const throttledInject = () => { - if (timeout) return; - timeout = setTimeout(() => { - injectUI(); - timeout = null; - }, 100); - }; + let injectTimeout; + let injectInFlight = false; + let rerunAfterInject = false; + let routeRetryTimeouts = []; + const routeEventListeners = []; + const patchedHistoryMethods = []; - const observer = new MutationObserver(throttledInject); + async function runInject() { + if (injectInFlight) { + rerunAfterInject = true; + return; + } + + injectInFlight = true; + try { + await injectUI(); + } catch (err) { + console.error('MovieNight UI injection failed', err); + } finally { + injectInFlight = false; + if (rerunAfterInject) { + rerunAfterInject = false; + scheduleInject(); + } + } + } + + function scheduleInject(delay = 100) { + if (injectTimeout) return; + injectTimeout = setTimeout(() => { + injectTimeout = null; + runInject(); + }, delay); + } + + function scheduleRouteInject() { + routeRetryTimeouts.forEach(clearTimeout); + routeRetryTimeouts = ROUTE_RETRY_DELAYS_MS.map((delay) => { + return setTimeout(() => runInject(), delay); + }); + } + + function patchHistoryMethod(name) { + const current = history[name]; + const original = current?._movieNightOriginal || current; + if (typeof original !== 'function') return; + + history[name] = function () { + const result = original.apply(this, arguments); + scheduleRouteInject(); + return result; + }; + history[name]._movieNightOriginal = original; + patchedHistoryMethods.push(name); + } + + function addRouteEventListener(name) { + window.addEventListener(name, scheduleRouteInject); + routeEventListeners.push(name); + } + + patchHistoryMethod('pushState'); + patchHistoryMethod('replaceState'); + addRouteEventListener('hashchange'); + addRouteEventListener('popstate'); + addRouteEventListener('pageshow'); + + const observer = new MutationObserver(() => scheduleInject()); observer.observe(document.body, { childList: true, subtree: true }); - injectUI(); + window.movieNightUiCleanup = () => { + observer.disconnect(); + if (injectTimeout) clearTimeout(injectTimeout); + routeRetryTimeouts.forEach(clearTimeout); + routeEventListeners.forEach((name) => window.removeEventListener(name, scheduleRouteInject)); + patchedHistoryMethods.forEach((name) => { + const original = history[name]?._movieNightOriginal; + if (original) history[name] = original; + }); + window.movieNightUiCleanup = null; + }; + + scheduleRouteInject(); })(); From f3754d11907ab0c9e51752a08bc66ba2b65f8c8f Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 01:09:15 +0300 Subject: [PATCH 101/106] style(): reformatted --- .../adapters/security/SecurityConfiguration.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 1801681..537fdc8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -21,8 +21,15 @@ class SecurityConfiguration( }.defaultSuccessUrl("/api/users/me", true) }.authorizeHttpRequests { auth -> auth - .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") - .permitAll() + .requestMatchers( + "/", + "/login/**", + "/oauth2/**", + "/h2-console/**", + "/actuator/health", + "/actuator/health/**", + "/actuator/prometheus", + ).permitAll() .requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**") .permitAll() .requestMatchers( From 5fcaeadcaf58c0d87e2157ede7b8278d57911b47 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 01:11:38 +0300 Subject: [PATCH 102/106] feat(helm): added observability to helm chart --- deploy/argocd/grafana.yaml | 79 ++ deploy/helm/movienight/templates/_helpers.tpl | 32 + .../templates/backend/deployment.yaml | 19 +- .../movienight/templates/backend/service.yaml | 6 + .../grafana-dashboard-business.yaml | 1092 +++++++++++++++++ .../observability/grafana-dashboard-red.yaml | 828 +++++++++++++ .../observability/grafana-datasource.yaml | 39 + .../observability/victoriametrics.yaml | 142 +++ .../templates/observability/vmagent.yaml | 169 +++ deploy/helm/movienight/values.schema.json | 37 + deploy/helm/movienight/values.yaml | 77 +- 11 files changed, 2516 insertions(+), 4 deletions(-) create mode 100644 deploy/argocd/grafana.yaml create mode 100644 deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml create mode 100644 deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml create mode 100644 deploy/helm/movienight/templates/observability/grafana-datasource.yaml create mode 100644 deploy/helm/movienight/templates/observability/victoriametrics.yaml create mode 100644 deploy/helm/movienight/templates/observability/vmagent.yaml diff --git a/deploy/argocd/grafana.yaml b/deploy/argocd/grafana.yaml new file mode 100644 index 0000000..9d34053 --- /dev/null +++ b/deploy/argocd/grafana.yaml @@ -0,0 +1,79 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: grafana + namespace: argocd +spec: + project: default + source: + repoURL: oci://ghcr.io/grafana-community/helm-charts/grafana + path: . + targetRevision: 12.3.0 + helm: + valuesObject: + envValueFrom: + GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: + secretKeyRef: + name: grafana-secrets + key: client-secret + + grafana.ini: + server: + root_url: https://grafana.internal.itqdev.xyz + + auth: + disable_login_form: false + oauth_auto_login: false + + auth.generic_oauth: + enabled: true + name: Keycloak + allow_sign_up: true + client_id: grafana-private + client_secret: ${GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET} + scopes: openid profile email + use_pkce: true + + auth_url: https://id.itqdev.xyz/realms/master/protocol/openid-connect/auth + token_url: https://id.itqdev.xyz/realms/master/protocol/openid-connect/token + api_url: https://id.itqdev.xyz/realms/master/protocol/openid-connect/userinfo + + role_attribute_path: > + contains(groups[*], 'admin') && 'Admin' || + contains(groups[*], 'editor') && 'Editor' || + 'Viewer' + role_attribute_strict: false + + use_refresh_token: true + id_token_attribute_name: preferred_username + + sidecar: + dashboards: + enabled: true + label: grafana_dashboard + labelValue: "1" + searchNamespace: ALL + folderAnnotation: grafana_folder + provider: + folder: MovieNight + allowUiUpdates: false + datasources: + enabled: true + label: grafana_datasource + labelValue: "1" + searchNamespace: ALL + + destination: + server: https://kubernetes.default.svc + namespace: grafana + + syncPolicy: + automated: + prune: true + selfHeal: true + enabled: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/helm/movienight/templates/_helpers.tpl b/deploy/helm/movienight/templates/_helpers.tpl index 5479b62..e32e83f 100644 --- a/deploy/helm/movienight/templates/_helpers.tpl +++ b/deploy/helm/movienight/templates/_helpers.tpl @@ -123,3 +123,35 @@ app.kubernetes.io/component: {{ $component }} key: password {{- end -}} {{- end -}} + +{{- define "movienight.victoriaMetricsName" -}} +{{- printf "%s-victoriametrics" (include "movienight.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "movienight.vmagentName" -}} +{{- printf "%s-vmagent" (include "movienight.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "movienight.victoriaMetricsURL" -}} +{{- if .Values.observability.grafana.datasource.url -}} +{{- .Values.observability.grafana.datasource.url -}} +{{- else -}} +{{- printf "http://%s.%s.svc:%v" (include "movienight.victoriaMetricsName" .) .Release.Namespace .Values.observability.victoriaMetrics.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "movienight.vmagentRemoteWriteURL" -}} +{{- if .Values.observability.vmagent.remoteWriteUrl -}} +{{- .Values.observability.vmagent.remoteWriteUrl -}} +{{- else -}} +{{- printf "http://%s:%v/api/v1/write" (include "movienight.victoriaMetricsName" .) .Values.observability.victoriaMetrics.service.port -}} +{{- end -}} +{{- end -}} + +{{- define "movienight.backendMetricsTarget" -}} +{{- if .Values.backend.management.enabled -}} +{{- printf "%s-backend:%v" (include "movienight.fullname" .) .Values.backend.management.port -}} +{{- else -}} +{{- printf "%s-backend:%v" (include "movienight.fullname" .) .Values.backend.service.port -}} +{{- end -}} +{{- end -}} diff --git a/deploy/helm/movienight/templates/backend/deployment.yaml b/deploy/helm/movienight/templates/backend/deployment.yaml index 2a03d9d..4f8d8b5 100644 --- a/deploy/helm/movienight/templates/backend/deployment.yaml +++ b/deploy/helm/movienight/templates/backend/deployment.yaml @@ -48,8 +48,25 @@ spec: - name: http containerPort: {{ .Values.backend.service.port }} protocol: TCP - {{- if or $postgresEnv .Values.backend.env }} + {{- if .Values.backend.management.enabled }} + - name: management + containerPort: {{ .Values.backend.management.port }} + protocol: TCP + {{- end }} + {{- if or $postgresEnv .Values.backend.env .Values.backend.management.enabled .Values.observability.enabled }} env: +{{- if .Values.backend.management.enabled }} + - name: MANAGEMENT_SERVER_PORT + value: {{ .Values.backend.management.port | quote }} +{{- end }} +{{- if .Values.observability.enabled }} + - name: MANAGEMENT_METRICS_TAGS_APPLICATION + value: {{ .Values.observability.metrics.applicationTag | quote }} +{{- if .Values.observability.metrics.httpServerRequestsHistogram }} + - name: MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILES_HISTOGRAM_HTTP_SERVER_REQUESTS + value: "true" +{{- end }} +{{- end }} {{- if $postgresEnv }} {{- $postgresEnv | nindent 12 }} {{- end }} diff --git a/deploy/helm/movienight/templates/backend/service.yaml b/deploy/helm/movienight/templates/backend/service.yaml index 1a4ca83..4e3b62e 100644 --- a/deploy/helm/movienight/templates/backend/service.yaml +++ b/deploy/helm/movienight/templates/backend/service.yaml @@ -16,6 +16,12 @@ spec: port: {{ .Values.backend.service.port }} targetPort: http protocol: TCP + {{- if .Values.backend.management.enabled }} + - name: management + port: {{ .Values.backend.management.port }} + targetPort: management + protocol: TCP + {{- end }} selector: {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "backend") | nindent 4 }} {{- end }} diff --git a/deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml b/deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml new file mode 100644 index 0000000..a70fab4 --- /dev/null +++ b/deploy/helm/movienight/templates/observability/grafana-dashboard-business.yaml @@ -0,0 +1,1092 @@ +{{- if and .Values.observability.enabled .Values.observability.grafana.dashboards.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "movienight.fullname" . }}-grafana-business-dashboard + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "grafana-dashboard") | nindent 4 }} + {{- with .Values.observability.grafana.dashboards.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.observability.grafana.dashboards.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.observability.grafana.dashboards.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +data: + movienight-business.json: | + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_recommendation_requests_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "recommendations", + "range": true, + "refId": "A" + } + ], + "title": "Recommendation Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_ratings_submitted_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "ratings", + "range": true, + "refId": "A" + } + ], + "title": "Rating Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_library_events_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "library events", + "range": true, + "refId": "A" + } + ], + "title": "Library Event Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 1 + }, + { + "color": "red", + "value": 5 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_blocked_total(_total)?|business_jellyfin_backend_write_failures_total(_total)?|business_jellyfin_sync_failures_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "failures", + "range": true, + "refId": "A" + } + ], + "title": "Business Failure Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 4 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_recommendation_requests_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "recommendations", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_ratings_submitted_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "ratings", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_library_events_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "library", + "range": true, + "refId": "C" + } + ], + "title": "Core Business Throughput", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_created_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "created", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_edited_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "edited", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_deleted_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "deleted", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_films_blocked_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "blocked", + "range": true, + "refId": "D" + } + ], + "title": "Film Mutations", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_sync_runs_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "sync runs", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_synced_users_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "synced users", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_synced_items_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "synced items", + "range": true, + "refId": "C" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_skipped_users_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "skipped users", + "range": true, + "refId": "D" + } + ], + "title": "Jellyfin Sync Activity", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_sync_failures_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "sync failures", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate({__name__=~\"business_jellyfin_backend_write_failures_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "write failures", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(business_jellyfin_unmapped_users{job=~\"$job\"})", + "instant": false, + "legendFormat": "unmapped users", + "range": true, + "refId": "C" + } + ], + "title": "Jellyfin Problems", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 20 + }, + "id": 9, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le) (rate(business_jellyfin_sync_duration_seconds_bucket{job=~\"$job\"}[$__rate_interval])))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(business_jellyfin_sync_duration_seconds_sum{job=~\"$job\"}[$__rate_interval])) / clamp_min(sum(rate(business_jellyfin_sync_duration_seconds_count{job=~\"$job\"}[$__rate_interval])), 0.001)", + "instant": false, + "legendFormat": "mean", + "range": true, + "refId": "B" + } + ], + "title": "Jellyfin Sync Duration", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 20 + }, + "id": 10, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by (eventType) (rate({__name__=~\"recommendation_weights_updated_total(_total)?\",job=~\"$job\"}[$__rate_interval]))", + "instant": false, + "range": true, + "refId": "A" + } + ], + "title": "Recommendation Weight Updates", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 42, + "tags": [ + "movienight", + "business" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": {{ .Values.observability.grafana.datasource.name | quote }}, + "value": {{ .Values.observability.grafana.datasource.uid | quote }} + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "selected": false, + "text": {{ .Values.observability.vmagent.backendJobName | quote }}, + "value": {{ .Values.observability.vmagent.backendJobName | quote }} + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(up, job)", + "hide": 0, + "includeAll": false, + "label": "Job", + "multi": false, + "name": "job", + "options": [], + "query": { + "query": "label_values(up, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "/movienight-backend/", + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "MovieNight Business", + "uid": "movienight-business", + "version": 1, + "weekStart": "" + } +{{- end }} diff --git a/deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml b/deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml new file mode 100644 index 0000000..8ba66fc --- /dev/null +++ b/deploy/helm/movienight/templates/observability/grafana-dashboard-red.yaml @@ -0,0 +1,828 @@ +{{- if and .Values.observability.enabled .Values.observability.grafana.dashboards.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "movienight.fullname" . }}-grafana-red-dashboard + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "grafana-dashboard") | nindent 4 }} + {{- with .Values.observability.grafana.dashboards.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.observability.grafana.dashboards.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.observability.grafana.dashboards.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +data: + movienight-red.json: | + { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "avg(up{job=~\"$job\"})", + "instant": false, + "legendFormat": "up", + "range": true, + "refId": "A" + } + ], + "title": "Availability", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "requests", + "range": true, + "refId": "A" + } + ], + "title": "Request Rate", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.01 + }, + { + "color": "red", + "value": 0.05 + } + ] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\",uri!~\"/actuator.*\"}[$__rate_interval])) / clamp_min(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])), 0.001)", + "instant": false, + "legendFormat": "5xx ratio", + "range": true, + "refId": "A" + } + ], + "title": "Error Ratio", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])) / clamp_min(sum(rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])), 0.001)", + "instant": false, + "legendFormat": "mean", + "range": true, + "refId": "A" + } + ], + "title": "Mean Duration", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 4 + }, + "id": 5, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by (method, uri, status) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval]))", + "instant": false, + "range": true, + "refId": "A" + } + ], + "title": "Request Rate by Route", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 4 + }, + "id": 6, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, sum by (le, uri) (rate(http_server_requests_seconds_bucket{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])))", + "instant": false, + "legendFormat": "p95", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum by (uri) (rate(http_server_requests_seconds_sum{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])) / clamp_min(sum by (uri) (rate(http_server_requests_seconds_count{job=~\"$job\",uri!~\"/actuator.*\"}[$__rate_interval])), 0.001)", + "instant": false, + "legendFormat": "mean", + "range": true, + "refId": "B" + } + ], + "title": "Duration by Route", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 12 + }, + "id": 7, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"2..\",uri!~\"/actuator.*\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "2xx", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"4..\",uri!~\"/actuator.*\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "4xx", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(rate(http_server_requests_seconds_count{job=~\"$job\",status=~\"5..\",uri!~\"/actuator.*\"}[$__rate_interval]))", + "instant": false, + "legendFormat": "5xx", + "range": true, + "refId": "C" + } + ], + "title": "Requests by Status Class", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 12 + }, + "id": 8, + "options": { + "legend": { + "calcs": [ + "lastNotNull" + ], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "multi", + "sort": "desc" + } + }, + "pluginVersion": "12.3.0", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(process_cpu_usage{job=~\"$job\"})", + "instant": false, + "legendFormat": "process CPU", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "editorMode": "code", + "expr": "sum(jvm_memory_used_bytes{job=~\"$job\",area=\"heap\"})", + "instant": false, + "legendFormat": "heap used", + "range": true, + "refId": "B" + } + ], + "title": "Runtime Signals", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 42, + "tags": [ + "movienight", + "red" + ], + "templating": { + "list": [ + { + "current": { + "selected": false, + "text": {{ .Values.observability.grafana.datasource.name | quote }}, + "value": {{ .Values.observability.grafana.datasource.uid | quote }} + }, + "hide": 0, + "includeAll": false, + "label": "Datasource", + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "type": "datasource" + }, + { + "current": { + "selected": false, + "text": {{ .Values.observability.vmagent.backendJobName | quote }}, + "value": {{ .Values.observability.vmagent.backendJobName | quote }} + }, + "datasource": { + "type": "prometheus", + "uid": "${datasource}" + }, + "definition": "label_values(http_server_requests_seconds_count, job)", + "hide": 0, + "includeAll": false, + "label": "Job", + "multi": false, + "name": "job", + "options": [], + "query": { + "query": "label_values(http_server_requests_seconds_count, job)", + "refId": "PrometheusVariableQueryEditor-VariableQuery" + }, + "refresh": 1, + "regex": "", + "sort": 1, + "type": "query" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "MovieNight RED", + "uid": "movienight-red", + "version": 1, + "weekStart": "" + } +{{- end }} diff --git a/deploy/helm/movienight/templates/observability/grafana-datasource.yaml b/deploy/helm/movienight/templates/observability/grafana-datasource.yaml new file mode 100644 index 0000000..2ebc49c --- /dev/null +++ b/deploy/helm/movienight/templates/observability/grafana-datasource.yaml @@ -0,0 +1,39 @@ +{{- if and .Values.observability.enabled .Values.observability.grafana.datasource.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "movienight.fullname" . }}-grafana-datasource + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "grafana-datasource") | nindent 4 }} + {{- with .Values.observability.grafana.datasource.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if or .Values.global.annotations .Values.observability.grafana.datasource.annotations }} + annotations: + {{- with .Values.global.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.observability.grafana.datasource.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- end }} +data: + victoriametrics.yaml: | + apiVersion: 1 + prune: true + + datasources: + - name: {{ .Values.observability.grafana.datasource.name | quote }} + type: prometheus + access: proxy + orgId: 1 + uid: {{ .Values.observability.grafana.datasource.uid | quote }} + url: {{ include "movienight.victoriaMetricsURL" . | quote }} + basicAuth: false + isDefault: {{ .Values.observability.grafana.datasource.isDefault }} + editable: false + jsonData: + httpMethod: POST + queryTimeout: 10s + timeInterval: {{ .Values.observability.vmagent.scrapeInterval | quote }} +{{- end }} diff --git a/deploy/helm/movienight/templates/observability/victoriametrics.yaml b/deploy/helm/movienight/templates/observability/victoriametrics.yaml new file mode 100644 index 0000000..e829ec3 --- /dev/null +++ b/deploy/helm/movienight/templates/observability/victoriametrics.yaml @@ -0,0 +1,142 @@ +{{- if and .Values.observability.enabled .Values.observability.victoriaMetrics.enabled }} +{{- if .Values.observability.victoriaMetrics.persistence.enabled }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "movienight.victoriaMetricsName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: {{ .Values.observability.victoriaMetrics.persistence.size }} + {{- with .Values.observability.victoriaMetrics.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} +--- +{{- end }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "movienight.victoriaMetricsName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "victoriametrics") | nindent 6 }} + template: + metadata: + labels: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "victoriametrics") | nindent 8 }} + {{- with .Values.observability.victoriaMetrics.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.victoriaMetrics.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "movienight.serviceAccountName" . }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.victoriaMetrics.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: victoriametrics + image: "{{ .Values.observability.victoriaMetrics.image.repository }}:{{ .Values.observability.victoriaMetrics.image.tag }}" + imagePullPolicy: {{ .Values.observability.victoriaMetrics.image.pullPolicy }} + args: + - -storageDataPath=/var/lib/victoriametrics + - -retentionPeriod={{ .Values.observability.victoriaMetrics.retentionPeriod }} + - -httpListenAddr=:{{ .Values.observability.victoriaMetrics.service.port }} + {{- with .Values.observability.victoriaMetrics.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.observability.victoriaMetrics.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.observability.victoriaMetrics.service.port }} + protocol: TCP + readinessProbe: + httpGet: + path: /-/ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /-/healthy + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + {{- with .Values.observability.victoriaMetrics.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: data + mountPath: /var/lib/victoriametrics + volumes: + - name: data + {{- if .Values.observability.victoriaMetrics.persistence.enabled }} + persistentVolumeClaim: + claimName: {{ include "movienight.victoriaMetricsName" . }} + {{- else }} + emptyDir: {} + {{- end }} + {{- with .Values.observability.victoriaMetrics.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.victoriaMetrics.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.victoriaMetrics.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "movienight.victoriaMetricsName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.observability.victoriaMetrics.service.type }} + ports: + - name: http + port: {{ .Values.observability.victoriaMetrics.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} +{{- end }} diff --git a/deploy/helm/movienight/templates/observability/vmagent.yaml b/deploy/helm/movienight/templates/observability/vmagent.yaml new file mode 100644 index 0000000..fdce0e4 --- /dev/null +++ b/deploy/helm/movienight/templates/observability/vmagent.yaml @@ -0,0 +1,169 @@ +{{- if and .Values.observability.enabled .Values.observability.vmagent.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "movienight.vmagentName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "vmagent") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +data: + scrape.yaml: | + global: + scrape_interval: {{ .Values.observability.vmagent.scrapeInterval }} + scrape_timeout: {{ .Values.observability.vmagent.scrapeTimeout }} + + scrape_configs: + {{- if .Values.backend.enabled }} + - job_name: {{ .Values.observability.vmagent.backendJobName | quote }} + metrics_path: /actuator/prometheus + static_configs: + - targets: + - {{ include "movienight.backendMetricsTarget" . | quote }} + labels: + app: {{ include "movienight.name" . | quote }} + release: {{ .Release.Name | quote }} + namespace: {{ .Release.Namespace | quote }} + {{- end }} + - job_name: {{ printf "%s-vmagent" (include "movienight.name" .) | quote }} + static_configs: + - targets: + - {{ printf "%s:%v" (include "movienight.vmagentName" .) .Values.observability.vmagent.service.port | quote }} + labels: + app: {{ include "movienight.name" . | quote }} + release: {{ .Release.Name | quote }} + namespace: {{ .Release.Namespace | quote }} + {{- if .Values.observability.victoriaMetrics.enabled }} + - job_name: {{ printf "%s-victoriametrics" (include "movienight.name" .) | quote }} + static_configs: + - targets: + - {{ printf "%s:%v" (include "movienight.victoriaMetricsName" .) .Values.observability.victoriaMetrics.service.port | quote }} + labels: + app: {{ include "movienight.name" . | quote }} + release: {{ .Release.Name | quote }} + namespace: {{ .Release.Namespace | quote }} + {{- end }} + {{- with .Values.observability.vmagent.extraScrapeConfigs }} + {{- toYaml . | nindent 6 }} + {{- end }} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "movienight.vmagentName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "vmagent") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + replicas: 1 + selector: + matchLabels: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "vmagent") | nindent 6 }} + template: + metadata: + labels: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "vmagent") | nindent 8 }} + {{- with .Values.observability.vmagent.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.vmagent.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "movienight.serviceAccountName" . }} + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.vmagent.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: vmagent + image: "{{ .Values.observability.vmagent.image.repository }}:{{ .Values.observability.vmagent.image.tag }}" + imagePullPolicy: {{ .Values.observability.vmagent.image.pullPolicy }} + args: + - -promscrape.config=/etc/vmagent/scrape.yaml + - -remoteWrite.url={{ include "movienight.vmagentRemoteWriteURL" . }} + - -httpListenAddr=:{{ .Values.observability.vmagent.service.port }} + {{- with .Values.observability.vmagent.extraArgs }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- with .Values.observability.vmagent.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: http + containerPort: {{ .Values.observability.vmagent.service.port }} + protocol: TCP + readinessProbe: + httpGet: + path: /-/ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + livenessProbe: + httpGet: + path: /-/healthy + port: http + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + {{- with .Values.observability.vmagent.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + - name: config + mountPath: /etc/vmagent + readOnly: true + - name: tmp + mountPath: /tmp + volumes: + - name: config + configMap: + name: {{ include "movienight.vmagentName" . }} + - name: tmp + emptyDir: {} + {{- with .Values.observability.vmagent.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.vmagent.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.observability.vmagent.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "movienight.vmagentName" . }} + labels: + {{- include "movienight.componentLabels" (dict "root" . "component" "vmagent") | nindent 4 }} + {{- with .Values.global.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.observability.vmagent.service.type }} + ports: + - name: http + port: {{ .Values.observability.vmagent.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "vmagent") | nindent 4 }} +{{- end }} diff --git a/deploy/helm/movienight/values.schema.json b/deploy/helm/movienight/values.schema.json index 395fdcc..61677a9 100644 --- a/deploy/helm/movienight/values.schema.json +++ b/deploy/helm/movienight/values.schema.json @@ -180,6 +180,18 @@ }, "additionalProperties": true }, + "management": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "port": { + "type": "integer" + } + }, + "additionalProperties": true + }, "env": { "type": "array", "items": { @@ -241,6 +253,31 @@ "routes": { "type": "object", "additionalProperties": true + }, + "observability": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "metrics": { + "type": "object", + "additionalProperties": true + }, + "victoriaMetrics": { + "type": "object", + "additionalProperties": true + }, + "vmagent": { + "type": "object", + "additionalProperties": true + }, + "grafana": { + "type": "object", + "additionalProperties": true + } + }, + "additionalProperties": true } } } diff --git a/deploy/helm/movienight/values.yaml b/deploy/helm/movienight/values.yaml index b787c63..60e1b33 100644 --- a/deploy/helm/movienight/values.yaml +++ b/deploy/helm/movienight/values.yaml @@ -48,6 +48,9 @@ backend: service: type: ClusterIP port: 8080 + management: + enabled: true + port: 8081 env: - name: SERVER_PORT value: "8080" @@ -73,7 +76,7 @@ backend: livenessProbe: httpGet: path: /actuator/health/liveness - port: http + port: management initialDelaySeconds: 20 periodSeconds: 10 timeoutSeconds: 5 @@ -81,7 +84,7 @@ backend: readinessProbe: httpGet: path: /actuator/health/readiness - port: http + port: management initialDelaySeconds: 10 periodSeconds: 10 timeoutSeconds: 5 @@ -89,7 +92,7 @@ backend: startupProbe: httpGet: path: /actuator/health - port: http + port: management initialDelaySeconds: 5 periodSeconds: 5 timeoutSeconds: 5 @@ -116,3 +119,71 @@ routes: backend: enabled: true pathPrefix: / + +observability: + enabled: false + metrics: + applicationTag: movienight + httpServerRequestsHistogram: true + victoriaMetrics: + enabled: true + image: + repository: docker.io/victoriametrics/victoria-metrics + tag: v1.134.0 + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 8428 + retentionPeriod: 30d + extraArgs: [] + persistence: + enabled: true + size: 10Gi + storageClass: "" + podAnnotations: {} + podLabels: {} + resources: {} + securityContext: {} + podSecurityContext: {} + nodeSelector: {} + tolerations: [] + affinity: {} + vmagent: + enabled: true + image: + repository: docker.io/victoriametrics/vmagent + tag: v1.135.0 + pullPolicy: IfNotPresent + service: + type: ClusterIP + port: 8429 + scrapeInterval: 5s + scrapeTimeout: 5s + remoteWriteUrl: "" + backendJobName: movienight-backend + extraArgs: [] + extraScrapeConfigs: [] + podAnnotations: {} + podLabels: {} + resources: {} + securityContext: {} + podSecurityContext: {} + nodeSelector: {} + tolerations: [] + affinity: {} + grafana: + datasource: + enabled: true + name: VictoriaMetrics + uid: victoriametrics + labels: + grafana_datasource: "1" + annotations: {} + url: "" + isDefault: true + dashboards: + enabled: true + labels: + grafana_dashboard: "1" + annotations: + grafana_folder: MovieNight From 2b1946a7a59ee109b3fa9a6dab9e04b7ffd62aa6 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 01:47:14 +0300 Subject: [PATCH 103/106] fix(helm): some dumb fix --- deploy/helm/movienight/templates/backend/deployment.yaml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/deploy/helm/movienight/templates/backend/deployment.yaml b/deploy/helm/movienight/templates/backend/deployment.yaml index 4f8d8b5..96d4b1d 100644 --- a/deploy/helm/movienight/templates/backend/deployment.yaml +++ b/deploy/helm/movienight/templates/backend/deployment.yaml @@ -62,10 +62,6 @@ spec: {{- if .Values.observability.enabled }} - name: MANAGEMENT_METRICS_TAGS_APPLICATION value: {{ .Values.observability.metrics.applicationTag | quote }} -{{- if .Values.observability.metrics.httpServerRequestsHistogram }} - - name: MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILES_HISTOGRAM_HTTP_SERVER_REQUESTS - value: "true" -{{- end }} {{- end }} {{- if $postgresEnv }} {{- $postgresEnv | nindent 12 }} From 5621fa8652073bcfeb4d12527a6db74d6702caf4 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 09:26:44 +0300 Subject: [PATCH 104/106] feat(argocd): added victoriametrics stack --- deploy/argocd/victoriametrics-agent.yaml | 51 +++++++++++++++++++++ deploy/argocd/victoriametrics-alert.yaml | 37 +++++++++++++++ deploy/argocd/victoriametrics-operator.yaml | 34 ++++++++++++++ deploy/argocd/victoriametrics-single.yaml | 32 +++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 deploy/argocd/victoriametrics-agent.yaml create mode 100644 deploy/argocd/victoriametrics-alert.yaml create mode 100644 deploy/argocd/victoriametrics-operator.yaml create mode 100644 deploy/argocd/victoriametrics-single.yaml diff --git a/deploy/argocd/victoriametrics-agent.yaml b/deploy/argocd/victoriametrics-agent.yaml new file mode 100644 index 0000000..53491cb --- /dev/null +++ b/deploy/argocd/victoriametrics-agent.yaml @@ -0,0 +1,51 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: victoriametrics-agent + namespace: argocd +spec: + project: default + source: + repoURL: https://victoriametrics.github.io/helm-charts/ + chart: victoria-metrics-agent + targetRevision: 0.11.0 + helm: + releaseName: vmagent + valuesObject: + remoteWrite: + - url: http://vmsingle-victoria-metrics-single.observability.svc:8428/api/v1/write + config: + scrape_interval: 10s + scrape_configs: + - job_name: 'movienight-backend' + metrics_path: /actuator/prometheus + kubernetes_sd_configs: + - role: pod + relabel_configs: + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component] + action: keep + regex: backend + - source_labels: [__meta_kubernetes_pod_container_port_name] + action: keep + regex: management + - action: labelmap + regex: __meta_kubernetes_pod_label_(.+) + - source_labels: [__meta_kubernetes_namespace] + action: replace + target_label: namespace + - source_labels: [__meta_kubernetes_pod_name] + action: replace + target_label: pod + + destination: + server: https://kubernetes.default.svc + namespace: observability + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/victoriametrics-alert.yaml b/deploy/argocd/victoriametrics-alert.yaml new file mode 100644 index 0000000..19d68ad --- /dev/null +++ b/deploy/argocd/victoriametrics-alert.yaml @@ -0,0 +1,37 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: victoriametrics-alert + namespace: argocd +spec: + project: default + source: + repoURL: https://victoriametrics.github.io/helm-charts/ + chart: victoria-metrics-alert + targetRevision: 0.12.0 + helm: + releaseName: vmalert + valuesObject: + server: + datasource: + url: http://vmsingle-victoria-metrics-single.observability.svc:8428 + notifier: + config: | + route: + group_by: ['alertname'] + receiver: 'blackhole' + receivers: + - name: 'blackhole' + + destination: + server: https://kubernetes.default.svc + namespace: observability + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/victoriametrics-operator.yaml b/deploy/argocd/victoriametrics-operator.yaml new file mode 100644 index 0000000..f80a759 --- /dev/null +++ b/deploy/argocd/victoriametrics-operator.yaml @@ -0,0 +1,34 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: victoriametrics-operator + namespace: argocd +spec: + project: default + source: + repoURL: https://victoriametrics.github.io/helm-charts/ + chart: victoria-metrics-operator + targetRevision: 0.38.0 + helm: + releaseName: victoriametrics-operator + valuesObject: + admissionWebhooks: + enabled: false + createCRD: true + operator: + # This enables the operator to watch for CRs in all namespaces + # or we can specify namespaces. + disableNamespaceRestriction: true + + destination: + server: https://kubernetes.default.svc + namespace: observability + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true diff --git a/deploy/argocd/victoriametrics-single.yaml b/deploy/argocd/victoriametrics-single.yaml new file mode 100644 index 0000000..64979b4 --- /dev/null +++ b/deploy/argocd/victoriametrics-single.yaml @@ -0,0 +1,32 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: victoriametrics-single + namespace: argocd +spec: + project: default + source: + repoURL: https://victoriametrics.github.io/helm-charts/ + chart: victoria-metrics-single + targetRevision: 0.20.0 + helm: + releaseName: vmsingle + valuesObject: + server: + retentionPeriod: 30d + persistentVolume: + enabled: true + size: 4Gi + + destination: + server: https://kubernetes.default.svc + namespace: observability + + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - ApplyOutOfSyncOnly=true + - ServerSideApply=true From 26619c6399291a39520ca9496a4aeb0f477ee369 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 09:44:01 +0300 Subject: [PATCH 105/106] hotfix(helm): --- deploy/argocd/victoriametrics-agent.yaml | 13 +- deploy/argocd/victoriametrics-alert.yaml | 46 ++++- deploy/helm/movienight/templates/_helpers.tpl | 18 +- .../observability/victoriametrics.yaml | 142 --------------- .../templates/observability/vmagent.yaml | 169 ------------------ deploy/helm/movienight/values.yaml | 55 +----- .../metrics/BusinessMetricsService.kt | 2 +- src/main/resources/application.yaml | 9 + 8 files changed, 67 insertions(+), 387 deletions(-) delete mode 100644 deploy/helm/movienight/templates/observability/victoriametrics.yaml delete mode 100644 deploy/helm/movienight/templates/observability/vmagent.yaml diff --git a/deploy/argocd/victoriametrics-agent.yaml b/deploy/argocd/victoriametrics-agent.yaml index 53491cb..b984493 100644 --- a/deploy/argocd/victoriametrics-agent.yaml +++ b/deploy/argocd/victoriametrics-agent.yaml @@ -12,10 +12,11 @@ spec: helm: releaseName: vmagent valuesObject: - remoteWrite: - - url: http://vmsingle-victoria-metrics-single.observability.svc:8428/api/v1/write + remoteWriteUrls: + - http://vmsingle-victoria-metrics-single-server.observability.svc:8428/api/v1/write config: - scrape_interval: 10s + global: + scrape_interval: 10s scrape_configs: - job_name: 'movienight-backend' metrics_path: /actuator/prometheus @@ -25,6 +26,12 @@ spec: - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_component] action: keep regex: backend + - source_labels: [__meta_kubernetes_namespace] + action: keep + regex: movienight + - source_labels: [__meta_kubernetes_pod_label_app_kubernetes_io_instance] + action: keep + regex: movienight - source_labels: [__meta_kubernetes_pod_container_port_name] action: keep regex: management diff --git a/deploy/argocd/victoriametrics-alert.yaml b/deploy/argocd/victoriametrics-alert.yaml index 19d68ad..3035280 100644 --- a/deploy/argocd/victoriametrics-alert.yaml +++ b/deploy/argocd/victoriametrics-alert.yaml @@ -14,14 +14,44 @@ spec: valuesObject: server: datasource: - url: http://vmsingle-victoria-metrics-single.observability.svc:8428 - notifier: - config: | - route: - group_by: ['alertname'] - receiver: 'blackhole' - receivers: - - name: 'blackhole' + url: http://vmsingle-victoria-metrics-single-server.observability.svc:8428 + config: + alerts: + groups: + - name: movienight.rules + rules: + - alert: MovieNightBackendDown + expr: avg(up{job="movienight-backend"}) < 1 + for: 2m + labels: + severity: critical + annotations: + summary: MovieNight backend is not fully available + description: vmagent is scraping fewer healthy MovieNight backend targets than expected. + - alert: MovieNightHigh5xxRatio + expr: sum(rate(http_server_requests_seconds_count{job="movienight-backend",status=~"5..",uri!~"/actuator.*"}[5m])) / clamp_min(sum(rate(http_server_requests_seconds_count{job="movienight-backend",uri!~"/actuator.*"}[5m])), 0.001) > 0.05 + for: 5m + labels: + severity: warning + annotations: + summary: MovieNight backend 5xx ratio is high + description: More than 5 percent of non-actuator HTTP requests are returning 5xx responses. + - alert: MovieNightNoScrapeData + expr: absent(up{job="movienight-backend"}) + for: 5m + labels: + severity: warning + annotations: + summary: MovieNight backend scrape data is missing + description: VictoriaMetrics has no up metric for the movienight-backend scrape job. + alertmanager: + enabled: true + config: + route: + group_by: ['alertname'] + receiver: blackhole + receivers: + - name: blackhole destination: server: https://kubernetes.default.svc diff --git a/deploy/helm/movienight/templates/_helpers.tpl b/deploy/helm/movienight/templates/_helpers.tpl index e32e83f..f86ef93 100644 --- a/deploy/helm/movienight/templates/_helpers.tpl +++ b/deploy/helm/movienight/templates/_helpers.tpl @@ -136,22 +136,6 @@ app.kubernetes.io/component: {{ $component }} {{- if .Values.observability.grafana.datasource.url -}} {{- .Values.observability.grafana.datasource.url -}} {{- else -}} -{{- printf "http://%s.%s.svc:%v" (include "movienight.victoriaMetricsName" .) .Release.Namespace .Values.observability.victoriaMetrics.service.port -}} -{{- end -}} -{{- end -}} - -{{- define "movienight.vmagentRemoteWriteURL" -}} -{{- if .Values.observability.vmagent.remoteWriteUrl -}} -{{- .Values.observability.vmagent.remoteWriteUrl -}} -{{- else -}} -{{- printf "http://%s:%v/api/v1/write" (include "movienight.victoriaMetricsName" .) .Values.observability.victoriaMetrics.service.port -}} -{{- end -}} -{{- end -}} - -{{- define "movienight.backendMetricsTarget" -}} -{{- if .Values.backend.management.enabled -}} -{{- printf "%s-backend:%v" (include "movienight.fullname" .) .Values.backend.management.port -}} -{{- else -}} -{{- printf "%s-backend:%v" (include "movienight.fullname" .) .Values.backend.service.port -}} +{{- .Values.observability.victoriaMetrics.url -}} {{- end -}} {{- end -}} diff --git a/deploy/helm/movienight/templates/observability/victoriametrics.yaml b/deploy/helm/movienight/templates/observability/victoriametrics.yaml deleted file mode 100644 index e829ec3..0000000 --- a/deploy/helm/movienight/templates/observability/victoriametrics.yaml +++ /dev/null @@ -1,142 +0,0 @@ -{{- if and .Values.observability.enabled .Values.observability.victoriaMetrics.enabled }} -{{- if .Values.observability.victoriaMetrics.persistence.enabled }} -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: {{ include "movienight.victoriaMetricsName" . }} - labels: - {{- include "movienight.componentLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} - {{- with .Values.global.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: {{ .Values.observability.victoriaMetrics.persistence.size }} - {{- with .Values.observability.victoriaMetrics.persistence.storageClass }} - storageClassName: {{ . | quote }} - {{- end }} ---- -{{- end }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "movienight.victoriaMetricsName" . }} - labels: - {{- include "movienight.componentLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} - {{- with .Values.global.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - replicas: 1 - strategy: - type: Recreate - selector: - matchLabels: - {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "victoriametrics") | nindent 6 }} - template: - metadata: - labels: - {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "victoriametrics") | nindent 8 }} - {{- with .Values.observability.victoriaMetrics.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.victoriaMetrics.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - serviceAccountName: {{ include "movienight.serviceAccountName" . }} - {{- with .Values.global.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.victoriaMetrics.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - containers: - - name: victoriametrics - image: "{{ .Values.observability.victoriaMetrics.image.repository }}:{{ .Values.observability.victoriaMetrics.image.tag }}" - imagePullPolicy: {{ .Values.observability.victoriaMetrics.image.pullPolicy }} - args: - - -storageDataPath=/var/lib/victoriametrics - - -retentionPeriod={{ .Values.observability.victoriaMetrics.retentionPeriod }} - - -httpListenAddr=:{{ .Values.observability.victoriaMetrics.service.port }} - {{- with .Values.observability.victoriaMetrics.extraArgs }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.observability.victoriaMetrics.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - name: http - containerPort: {{ .Values.observability.victoriaMetrics.service.port }} - protocol: TCP - readinessProbe: - httpGet: - path: /-/ready - port: http - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 5 - livenessProbe: - httpGet: - path: /-/healthy - port: http - initialDelaySeconds: 10 - periodSeconds: 30 - timeoutSeconds: 5 - {{- with .Values.observability.victoriaMetrics.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - volumeMounts: - - name: data - mountPath: /var/lib/victoriametrics - volumes: - - name: data - {{- if .Values.observability.victoriaMetrics.persistence.enabled }} - persistentVolumeClaim: - claimName: {{ include "movienight.victoriaMetricsName" . }} - {{- else }} - emptyDir: {} - {{- end }} - {{- with .Values.observability.victoriaMetrics.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.victoriaMetrics.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.victoriaMetrics.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "movienight.victoriaMetricsName" . }} - labels: - {{- include "movienight.componentLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} - {{- with .Values.global.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - type: {{ .Values.observability.victoriaMetrics.service.type }} - ports: - - name: http - port: {{ .Values.observability.victoriaMetrics.service.port }} - targetPort: http - protocol: TCP - selector: - {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "victoriametrics") | nindent 4 }} -{{- end }} diff --git a/deploy/helm/movienight/templates/observability/vmagent.yaml b/deploy/helm/movienight/templates/observability/vmagent.yaml deleted file mode 100644 index fdce0e4..0000000 --- a/deploy/helm/movienight/templates/observability/vmagent.yaml +++ /dev/null @@ -1,169 +0,0 @@ -{{- if and .Values.observability.enabled .Values.observability.vmagent.enabled }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "movienight.vmagentName" . }} - labels: - {{- include "movienight.componentLabels" (dict "root" . "component" "vmagent") | nindent 4 }} - {{- with .Values.global.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -data: - scrape.yaml: | - global: - scrape_interval: {{ .Values.observability.vmagent.scrapeInterval }} - scrape_timeout: {{ .Values.observability.vmagent.scrapeTimeout }} - - scrape_configs: - {{- if .Values.backend.enabled }} - - job_name: {{ .Values.observability.vmagent.backendJobName | quote }} - metrics_path: /actuator/prometheus - static_configs: - - targets: - - {{ include "movienight.backendMetricsTarget" . | quote }} - labels: - app: {{ include "movienight.name" . | quote }} - release: {{ .Release.Name | quote }} - namespace: {{ .Release.Namespace | quote }} - {{- end }} - - job_name: {{ printf "%s-vmagent" (include "movienight.name" .) | quote }} - static_configs: - - targets: - - {{ printf "%s:%v" (include "movienight.vmagentName" .) .Values.observability.vmagent.service.port | quote }} - labels: - app: {{ include "movienight.name" . | quote }} - release: {{ .Release.Name | quote }} - namespace: {{ .Release.Namespace | quote }} - {{- if .Values.observability.victoriaMetrics.enabled }} - - job_name: {{ printf "%s-victoriametrics" (include "movienight.name" .) | quote }} - static_configs: - - targets: - - {{ printf "%s:%v" (include "movienight.victoriaMetricsName" .) .Values.observability.victoriaMetrics.service.port | quote }} - labels: - app: {{ include "movienight.name" . | quote }} - release: {{ .Release.Name | quote }} - namespace: {{ .Release.Namespace | quote }} - {{- end }} - {{- with .Values.observability.vmagent.extraScrapeConfigs }} - {{- toYaml . | nindent 6 }} - {{- end }} ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "movienight.vmagentName" . }} - labels: - {{- include "movienight.componentLabels" (dict "root" . "component" "vmagent") | nindent 4 }} - {{- with .Values.global.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - replicas: 1 - selector: - matchLabels: - {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "vmagent") | nindent 6 }} - template: - metadata: - labels: - {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "vmagent") | nindent 8 }} - {{- with .Values.observability.vmagent.podLabels }} - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.vmagent.podAnnotations }} - annotations: - {{- toYaml . | nindent 8 }} - {{- end }} - spec: - serviceAccountName: {{ include "movienight.serviceAccountName" . }} - {{- with .Values.global.imagePullSecrets }} - imagePullSecrets: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.vmagent.podSecurityContext }} - securityContext: - {{- toYaml . | nindent 8 }} - {{- end }} - containers: - - name: vmagent - image: "{{ .Values.observability.vmagent.image.repository }}:{{ .Values.observability.vmagent.image.tag }}" - imagePullPolicy: {{ .Values.observability.vmagent.image.pullPolicy }} - args: - - -promscrape.config=/etc/vmagent/scrape.yaml - - -remoteWrite.url={{ include "movienight.vmagentRemoteWriteURL" . }} - - -httpListenAddr=:{{ .Values.observability.vmagent.service.port }} - {{- with .Values.observability.vmagent.extraArgs }} - {{- toYaml . | nindent 12 }} - {{- end }} - {{- with .Values.observability.vmagent.securityContext }} - securityContext: - {{- toYaml . | nindent 12 }} - {{- end }} - ports: - - name: http - containerPort: {{ .Values.observability.vmagent.service.port }} - protocol: TCP - readinessProbe: - httpGet: - path: /-/ready - port: http - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 5 - livenessProbe: - httpGet: - path: /-/healthy - port: http - initialDelaySeconds: 10 - periodSeconds: 30 - timeoutSeconds: 5 - {{- with .Values.observability.vmagent.resources }} - resources: - {{- toYaml . | nindent 12 }} - {{- end }} - volumeMounts: - - name: config - mountPath: /etc/vmagent - readOnly: true - - name: tmp - mountPath: /tmp - volumes: - - name: config - configMap: - name: {{ include "movienight.vmagentName" . }} - - name: tmp - emptyDir: {} - {{- with .Values.observability.vmagent.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.vmagent.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.observability.vmagent.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} ---- -apiVersion: v1 -kind: Service -metadata: - name: {{ include "movienight.vmagentName" . }} - labels: - {{- include "movienight.componentLabels" (dict "root" . "component" "vmagent") | nindent 4 }} - {{- with .Values.global.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - type: {{ .Values.observability.vmagent.service.type }} - ports: - - name: http - port: {{ .Values.observability.vmagent.service.port }} - targetPort: http - protocol: TCP - selector: - {{- include "movienight.componentSelectorLabels" (dict "root" . "component" "vmagent") | nindent 4 }} -{{- end }} diff --git a/deploy/helm/movienight/values.yaml b/deploy/helm/movienight/values.yaml index 60e1b33..d6a7dbc 100644 --- a/deploy/helm/movienight/values.yaml +++ b/deploy/helm/movienight/values.yaml @@ -93,10 +93,10 @@ backend: httpGet: path: /actuator/health port: management - initialDelaySeconds: 5 + initialDelaySeconds: 10 periodSeconds: 5 timeoutSeconds: 5 - failureThreshold: 24 + failureThreshold: 12 gateway: enabled: false @@ -121,56 +121,17 @@ routes: pathPrefix: / observability: - enabled: false + enabled: true metrics: applicationTag: movienight httpServerRequestsHistogram: true - victoriaMetrics: - enabled: true - image: - repository: docker.io/victoriametrics/victoria-metrics - tag: v1.134.0 - pullPolicy: IfNotPresent - service: - type: ClusterIP - port: 8428 - retentionPeriod: 30d - extraArgs: [] - persistence: - enabled: true - size: 10Gi - storageClass: "" - podAnnotations: {} - podLabels: {} - resources: {} - securityContext: {} - podSecurityContext: {} - nodeSelector: {} - tolerations: [] - affinity: {} vmagent: - enabled: true - image: - repository: docker.io/victoriametrics/vmagent - tag: v1.135.0 - pullPolicy: IfNotPresent - service: - type: ClusterIP - port: 8429 - scrapeInterval: 5s - scrapeTimeout: 5s - remoteWriteUrl: "" + scrapeInterval: 10s backendJobName: movienight-backend - extraArgs: [] - extraScrapeConfigs: [] - podAnnotations: {} - podLabels: {} - resources: {} - securityContext: {} - podSecurityContext: {} - nodeSelector: {} - tolerations: [] - affinity: {} + victoriaMetrics: + url: "http://vmsingle-victoria-metrics-single-server.observability.svc:8428" + operator: + enabled: true grafana: datasource: enabled: true diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index 973071c..012c9e3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -26,7 +26,7 @@ class BusinessMetricsService( private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total") private val jellyfinSyncDuration: Timer = Timer - .builder("business_jellyfin_sync_duration_seconds") + .builder("business_jellyfin_sync_duration") .publishPercentileHistogram() .register(meterRegistry) private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total") diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index a3b844c..2865e0d 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -58,9 +58,12 @@ spring: user-name-attribute: response server: + port: ${SERVER_PORT:8080} shutdown: graceful management: + server: + port: ${MANAGEMENT_SERVER_PORT:8081} endpoints: web: base-path: /actuator @@ -76,6 +79,12 @@ management: enabled: true readinessstate: enabled: true + metrics: + tags: + application: ${MANAGEMENT_METRICS_TAGS_APPLICATION:${spring.application.name}} + distribution: + percentiles-histogram: + http.server.requests: ${MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILES_HISTOGRAM_HTTP_SERVER_REQUESTS:false} info: env: enabled: true From 1e0e1776be09fec4898a94c85f9d57be09149424 Mon Sep 17 00:00:00 2001 From: ITQ Date: Sat, 23 May 2026 09:52:55 +0300 Subject: [PATCH 106/106] hotfix(): --- deploy/helm/movienight/templates/backend/deployment.yaml | 2 ++ deploy/helm/movienight/values.yaml | 2 +- src/main/resources/application.yaml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deploy/helm/movienight/templates/backend/deployment.yaml b/deploy/helm/movienight/templates/backend/deployment.yaml index 96d4b1d..a5337ed 100644 --- a/deploy/helm/movienight/templates/backend/deployment.yaml +++ b/deploy/helm/movienight/templates/backend/deployment.yaml @@ -62,6 +62,8 @@ spec: {{- if .Values.observability.enabled }} - name: MANAGEMENT_METRICS_TAGS_APPLICATION value: {{ .Values.observability.metrics.applicationTag | quote }} + - name: HTTP_SERVER_REQUESTS_HISTOGRAM_ENABLED + value: {{ .Values.observability.metrics.httpServerRequestsHistogram | quote }} {{- end }} {{- if $postgresEnv }} {{- $postgresEnv | nindent 12 }} diff --git a/deploy/helm/movienight/values.yaml b/deploy/helm/movienight/values.yaml index d6a7dbc..9d26eac 100644 --- a/deploy/helm/movienight/values.yaml +++ b/deploy/helm/movienight/values.yaml @@ -124,7 +124,7 @@ observability: enabled: true metrics: applicationTag: movienight - httpServerRequestsHistogram: true + httpServerRequestsHistogram: "true" vmagent: scrapeInterval: 10s backendJobName: movienight-backend diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 2865e0d..6778584 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -84,7 +84,7 @@ management: application: ${MANAGEMENT_METRICS_TAGS_APPLICATION:${spring.application.name}} distribution: percentiles-histogram: - http.server.requests: ${MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILES_HISTOGRAM_HTTP_SERVER_REQUESTS:false} + http.server.requests: ${HTTP_SERVER_REQUESTS_HISTOGRAM_ENABLED:false} info: env: enabled: true