diff --git a/app/src/main/frontend/App.tsx b/app/src/main/frontend/App.tsx index 01f759e..1aadb07 100644 --- a/app/src/main/frontend/App.tsx +++ b/app/src/main/frontend/App.tsx @@ -15,6 +15,7 @@ import {initializePluginState} from "Frontend/state/PluginState"; import {isAdmin} from "Frontend/util/utils"; import {useRouteMetadata} from "Frontend/util/routing"; import {useEffect} from "react"; +import {initializeGameRequestState} from "Frontend/state/GameRequestState"; export default function App() { client.middlewares = [ErrorHandlingMiddleware]; @@ -45,6 +46,7 @@ function ViewWithAuth() { initializeLibraryState(); initializeGameState(); + initializeGameRequestState(); if (isAdmin(auth)) { initializeScanState(); diff --git a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx index f484648..6343ece 100644 --- a/app/src/main/frontend/components/general/modals/MatchGameModal.tsx +++ b/app/src/main/frontend/components/general/modals/MatchGameModal.tsx @@ -21,7 +21,7 @@ import {useSnapshot} from "valtio/react"; import {pluginState} from "Frontend/state/PluginState"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; -interface EditGameMetadataModalProps { +interface MatchGameModalProps { path: string; libraryId: number; replaceGameId?: number; @@ -37,7 +37,7 @@ export default function MatchGameModal({ initialSearchTerm, isOpen, onOpenChange - }: EditGameMetadataModalProps) { + }: MatchGameModalProps) { const [searchTerm, setSearchTerm] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); diff --git a/app/src/main/frontend/components/general/modals/RequestGameModal.tsx b/app/src/main/frontend/components/general/modals/RequestGameModal.tsx new file mode 100644 index 0000000..453e66b --- /dev/null +++ b/app/src/main/frontend/components/general/modals/RequestGameModal.tsx @@ -0,0 +1,162 @@ +import { + addToast, + Button, + Input, + Modal, + ModalBody, + ModalContent, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip +} from "@heroui/react"; +import React, {useEffect, useState} from "react"; +import {ArrowRight, MagnifyingGlass} from "@phosphor-icons/react"; +import {GameEndpoint, GameRequestEndpoint} from "Frontend/generated/endpoints"; +import GameSearchResultDto from "Frontend/generated/org/gameyfin/app/games/dto/GameSearchResultDto"; +import PluginIcon from "../plugin/PluginIcon"; +import {useSnapshot} from "valtio/react"; +import {pluginState} from "Frontend/state/PluginState"; +import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; +import GameRequestCreationDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestCreationDto"; + +interface RequestGameModalProps { + isOpen: boolean; + onOpenChange: () => void; +} + +export default function RequestGameModal({ + isOpen, + onOpenChange + }: RequestGameModalProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [isSearching, setIsSearching] = useState(false); + const [isRequesting, setIsRequesting] = useState(null); + + const plugins = useSnapshot(pluginState).state; + + useEffect(() => { + setSearchTerm(""); + setSearchResults([]); + }, [isOpen]); + + async function requestGame(game: GameSearchResultDto) { + const request: GameRequestCreationDto = { + title: game.title, + release: game.release + } + await GameRequestEndpoint.create(request); + + addToast({ + title: "Request submitted", + description: `Your request for "${game.title}" has been submitted.`, + color: "success" + }) + } + + async function search() { + setIsSearching(true); + const results = await GameEndpoint.getPotentialMatches(searchTerm); + setSearchResults(results); + setIsSearching(false); + } + + return ( + + + {(onClose) => ( + +
+

Request a game

+
+
+ { + if (e.key === "Enter") { + e.preventDefault(); + await search(); + } + }} + /> + +
+ +
+ + + Title & Release + Developer(s) + Publisher(s) + {/* width={1} keeps the column as far to the right as possible*/} + Sources + + + + {(item) => ( + + + {item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) + + +
+ {item.developers ? item.developers.map( + developer =>

{developer}

+ ) : "unknown"} +
+
+ +
+ {item.publishers ? item.publishers.map( + publisher =>

{publisher}

+ ) : "unknown"} +
+
+ +
+ {Object.values(item.originalIds).map( + originalId => + )} +
+
+ + + + + +
+ )} +
+
+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/app/src/main/frontend/state/GameRequestState.ts b/app/src/main/frontend/state/GameRequestState.ts new file mode 100644 index 0000000..00d47a4 --- /dev/null +++ b/app/src/main/frontend/state/GameRequestState.ts @@ -0,0 +1,50 @@ +import {Subscription} from "@vaadin/hilla-frontend"; +import {proxy} from "valtio/index"; +import {GameRequestEndpoint} from "Frontend/generated/endpoints"; +import GameRequestEvent from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestEvent"; +import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto"; + +type GameRequestState = { + subscription?: Subscription; + isLoaded: boolean; + state: Record; + gameRequests: GameRequestDto[]; +}; + +export const gameRequestState = proxy({ + get isLoaded() { + return this.subscription != null; + }, + state: {}, + get gameRequests() { + return Object.values(this.state); + } +}); + +/** Subscribe to and process state updates from backend **/ +export async function initializeGameRequestState() { + if (gameRequestState.isLoaded) return; + + // Fetch initial game request list + const initialEntries = await GameRequestEndpoint.getAll(); + initialEntries.forEach((gameRequest: GameRequestDto) => { + gameRequestState.state[gameRequest.id] = gameRequest; + }); + + // Subscribe to real-time updates + gameRequestState.subscription = GameRequestEndpoint.subscribe().onNext((gameRequestEvents: GameRequestEvent[]) => { + gameRequestEvents.forEach((gameRequestEvent: GameRequestEvent) => { + switch (gameRequestEvent.type) { + case "created": + case "updated": + //@ts-ignore + gameRequestState.state[gameRequestEvent.id] = gameRequestEvent; + break; + case "deleted": + //@ts-ignore + delete gameRequestState.state[gameRequestEvent.id]; + break; + } + }) + }); +} \ No newline at end of file diff --git a/app/src/main/frontend/views/MainLayout.tsx b/app/src/main/frontend/views/MainLayout.tsx index a0d632e..b7c1acc 100644 --- a/app/src/main/frontend/views/MainLayout.tsx +++ b/app/src/main/frontend/views/MainLayout.tsx @@ -1,11 +1,21 @@ import {useEffect, useState} from 'react'; import ProfileMenu from "Frontend/components/ProfileMenu"; -import {Button, Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem, Tooltip} from "@heroui/react"; +import { + Button, + Divider, + Link, + Navbar, + NavbarBrand, + NavbarContent, + NavbarItem, + Tooltip, + useDisclosure +} from "@heroui/react"; import GameyfinLogo from "Frontend/components/theming/GameyfinLogo"; import * as PackageJson from "../../../../package.json"; import {Outlet, useLocation, useNavigate} from "react-router"; import {useAuth} from "Frontend/util/auth"; -import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, SignIn} from "@phosphor-icons/react"; +import {ArrowLeft, DiceSix, Heart, House, ListMagnifyingGlass, PlusCircle, SignIn} from "@phosphor-icons/react"; import Confetti, {ConfettiProps} from "react-confetti-boom"; import {useTheme} from "next-themes"; import {useUserPreferenceService} from "Frontend/util/user-preference-service"; @@ -14,6 +24,7 @@ import {useSnapshot} from "valtio/react"; import {gameState} from "Frontend/state/GameState"; import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover"; import {isAdmin} from "Frontend/util/utils"; +import RequestGameModal from "Frontend/components/general/modals/RequestGameModal"; export default function MainLayout() { const navigate = useNavigate(); @@ -26,6 +37,8 @@ export default function MainLayout() { const [isExploding, setIsExploding] = useState(false); const games = useSnapshot(gameState).games; + const requestGameModal = useDisclosure(); + useEffect(() => { userPreferenceService.sync() .then(() => loadUserTheme().catch(console.error)) @@ -93,7 +106,17 @@ export default function MainLayout() { } - + + {auth.state.user && + + + + + + } {isAdmin(auth) && @@ -142,6 +165,10 @@ export default function MainLayout() {

+ + + ); } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/core/Role.kt b/app/src/main/kotlin/org/gameyfin/app/core/Role.kt index 0401300..76b33a4 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/Role.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/Role.kt @@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonCreator import com.fasterxml.jackson.annotation.JsonValue import org.gameyfin.app.users.RoleService import java.lang.Enum +import kotlin.Int +import kotlin.String enum class Role(val roleName: String, val powerLevel: Int) { @@ -21,12 +23,12 @@ enum class Role(val roleName: String, val powerLevel: Int) { @JsonCreator @JvmStatic fun fromValue(value: String): Role? { - val enumString = value.removePrefix(RoleService.Companion.INTERNAL_ROLE_PREFIX) + val enumString = value.removePrefix(RoleService.INTERNAL_ROLE_PREFIX) return entries.find { it.roleName == enumString } } fun safeValueOf(type: String): Role? { - val enumString = type.removePrefix(RoleService.Companion.INTERNAL_ROLE_PREFIX) + val enumString = type.removePrefix(RoleService.INTERNAL_ROLE_PREFIX) return Enum.valueOf(Role::class.java, enumString) } } @@ -34,9 +36,9 @@ enum class Role(val roleName: String, val powerLevel: Int) { // necessary for the ability to use the Roles class in the @RolesAllowed annotation class Names { companion object { - const val SUPERADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}SUPERADMIN" - const val ADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}ADMIN" - const val USER = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}USER" + const val SUPERADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}SUPERADMIN" + const val ADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}ADMIN" + const val USER = "${RoleService.INTERNAL_ROLE_PREFIX}USER" } } } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt b/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt index 1e30da0..f201ef6 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/events/Events.kt @@ -1,5 +1,6 @@ package org.gameyfin.app.core.events +import org.gameyfin.app.games.entities.Game import org.gameyfin.app.shared.token.Token import org.gameyfin.app.shared.token.TokenType import org.gameyfin.app.users.entities.User @@ -23,4 +24,6 @@ class PasswordResetRequestEvent(source: Any, val token: Token userService.getByUsernameNonNull(userDetails.username) is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername) - else -> throw IllegalStateException("Unkown user type: ${userDetails::class.java.name}") + else -> throw IllegalStateException("Unkown user type: ${userDetails?.javaClass?.name}") } // Update only non-null fields diff --git a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt index f1f8e53..b400493 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/entities/GameEntityListener.kt @@ -3,38 +3,21 @@ package org.gameyfin.app.games.entities import jakarta.persistence.PostPersist import jakarta.persistence.PostRemove import jakarta.persistence.PostUpdate +import org.gameyfin.app.core.events.GameCreatedEvent import org.gameyfin.app.games.GameService import org.gameyfin.app.games.dto.GameAdminEvent import org.gameyfin.app.games.dto.GameUserEvent import org.gameyfin.app.games.extensions.toAdminDto import org.gameyfin.app.games.extensions.toUserDto -import org.gameyfin.app.requests.GameRequestService -import org.springframework.context.ApplicationContext -import org.springframework.context.ApplicationContextAware -import org.springframework.stereotype.Component +import org.gameyfin.app.util.EventPublisherHolder -@Component -class GameEntityListener : ApplicationContextAware { - - companion object { - private lateinit var applicationContext: ApplicationContext - } - - override fun setApplicationContext(context: ApplicationContext) { - applicationContext = context - } - - private fun getGameRequestService(): GameRequestService { - return applicationContext.getBean(GameRequestService::class.java) - } +class GameEntityListener { @PostPersist fun created(game: Game) { GameService.emitUser(GameUserEvent.Created(game.toUserDto())) GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) - - // After a game is created, mark any matching game requests as FULFILLED - getGameRequestService().completeMatchingRequests(game) + EventPublisherHolder.publish(GameCreatedEvent(this, game)) } @PostUpdate diff --git a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt index 725df8a..df451b2 100644 --- a/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/media/ImageEndpoint.kt @@ -60,7 +60,7 @@ class ImageEndpoint( @PermitAll @PostMapping("/avatar/upload") fun uploadAvatar(@RequestParam("file") file: MultipartFile) { - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val image: Image = if (!userService.hasAvatar(auth.name)) { imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!) @@ -75,7 +75,7 @@ class ImageEndpoint( @PermitAll @PostMapping("/avatar/delete") fun deleteAvatar() { - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") userService.deleteAvatar(auth.name) } diff --git a/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt b/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt index a0e48b8..530c879 100644 --- a/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/messages/MessageService.kt @@ -60,7 +60,7 @@ class MessageService( } try { - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val template = templateService.getMessageTemplate(templateKey) sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders) diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt index 4cc235a..533cfdb 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt @@ -26,6 +26,7 @@ class GameRequestEndpoint( fun getAll() = gameRequestService.getAll() + @PermitAll fun create(gameRequest: GameRequestCreationDto) { gameRequestService.createRequest(gameRequest) } diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt index a8d2a95..12e9f43 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt @@ -1,14 +1,20 @@ package org.gameyfin.app.requests import org.gameyfin.app.requests.entities.GameRequest +import org.gameyfin.app.requests.status.GameRequestStatus import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.data.repository.query.Param import java.time.Instant interface GameRequestRepository : JpaRepository { - fun findByTitleAndRelease(title: String, release: Instant): List - - @Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)") - fun findByTitleAndReleaseYear(@Param("title") title: String, @Param("release") release: Instant): List + @Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release) AND g.status NOT IN (:excludedStatuses)") + fun findOpenRequestsByTitleAndReleaseYear( + @Param("title") title: String, + @Param("release") release: Instant?, + @Param("excludedStatuses") excludedStatuses: List = listOf( + GameRequestStatus.FULFILLED, + GameRequestStatus.REJECTED + ) + ): List } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt index 9860d84..0551c1d 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestService.kt @@ -1,8 +1,8 @@ package org.gameyfin.app.requests import io.github.oshai.kotlinlogging.KotlinLogging +import org.gameyfin.app.core.events.GameCreatedEvent import org.gameyfin.app.core.security.getCurrentAuth -import org.gameyfin.app.games.entities.Game import org.gameyfin.app.requests.dto.GameRequestCreationDto import org.gameyfin.app.requests.dto.GameRequestDto import org.gameyfin.app.requests.dto.GameRequestEvent @@ -11,6 +11,8 @@ import org.gameyfin.app.requests.extensions.toDto import org.gameyfin.app.requests.extensions.toDtos import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.users.UserService +import org.springframework.context.event.EventListener +import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service import reactor.core.publisher.Flux import reactor.core.publisher.Sinks @@ -52,13 +54,14 @@ class GameRequestService( } fun createRequest(gameRequest: GameRequestCreationDto) { - val currentUser = userService.getByUsername(getCurrentAuth().name) + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") + val currentUser = + userService.getByUsername(auth.name) ?: throw IllegalStateException("Current user not found") val gameRequest = GameRequest( title = gameRequest.title, release = gameRequest.release, status = GameRequestStatus.PENDING, - externalProviderIds = gameRequest.externalProviderIds, requester = currentUser ) @@ -79,12 +82,13 @@ class GameRequestService( } fun toggleRequestVote(id: Long) { + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val currentUser = - userService.getByUsername(getCurrentAuth().name) ?: throw IllegalStateException("Current user not found") + userService.getByUsername(auth.name) ?: throw IllegalStateException("Current user not found") val gameRequest = gameRequestRepository.findById(id) .orElseThrow { NoSuchElementException("No game request found with id $id") } - if (gameRequest.requester?.id == currentUser.id) { + if (gameRequest.requester.id == currentUser.id) { throw IllegalStateException("You cannot vote for your own request") } @@ -97,25 +101,24 @@ class GameRequestService( gameRequestRepository.save(gameRequest) } - fun completeMatchingRequests(game: Game) { + @Async + @EventListener(GameCreatedEvent::class) + fun completeMatchingRequests(gameCreatedEvent: GameCreatedEvent) { + val game = gameCreatedEvent.game val gameTitle = game.title val gameRelease = game.release - if (gameTitle == null || gameRelease == null) { - log.debug { "Game '${game.id}' is missing title and/or release date, cannot complete matching requests" } + if (gameTitle == null) { + log.debug { "Game '${game.id}' is missing title, cannot complete matching requests" } return } - // First match by exact title and release date, if not result could be found then by title and release year only - val matchingRequestsByExactRelease = gameRequestRepository.findByTitleAndRelease(gameTitle, gameRelease) - val matchingRequestsByReleaseYear = matchingRequestsByExactRelease.ifEmpty { - gameRequestRepository.findByTitleAndReleaseYear( - gameTitle, - gameRelease - ) - } + val matchingRequests = gameRequestRepository.findOpenRequestsByTitleAndReleaseYear( + gameTitle, + gameRelease + ) - matchingRequestsByReleaseYear.forEach { request -> + matchingRequests.forEach { request -> request.status = GameRequestStatus.FULFILLED request.linkedGameId = game.id val persistedRequest = gameRequestRepository.save(request) diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt index a31f8ed..8c2e22e 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestCreationDto.kt @@ -5,6 +5,5 @@ import java.time.Instant class GameRequestCreationDto( val title: String, - val release: Instant, - val externalProviderIds: Map + val release: Instant? ) \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt index 58f6743..7932c90 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/dto/GameRequestDto.kt @@ -1,6 +1,5 @@ package org.gameyfin.app.requests.dto -import org.gameyfin.app.requests.entities.ExternalProviderIds import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.users.dto.UserInfoDto import java.time.Instant @@ -8,8 +7,7 @@ import java.time.Instant class GameRequestDto( val id: Long, val title: String, - val release: Instant, - val externalProviderIds: ExternalProviderIds, + val release: Instant?, val status: GameRequestStatus, val requester: UserInfoDto?, val voters: List, diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt index 832322c..0fbd7e9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/entities/GameRequest.kt @@ -8,8 +8,6 @@ import org.hibernate.annotations.UpdateTimestamp import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Instant -typealias ExternalProviderIds = Map - @Entity @EntityListeners(GameRequestEntityListener::class, AuditingEntityListener::class) class GameRequest( @@ -21,18 +19,16 @@ class GameRequest( val title: String, @Column(nullable = false) - val release: Instant, - - @ElementCollection - val externalProviderIds: ExternalProviderIds, + val release: Instant?, @Column(nullable = false) + @Enumerated(EnumType.STRING) var status: GameRequestStatus, - @ManyToOne(fetch = FetchType.EAGER) - var requester: User? = null, + @ManyToOne(fetch = FetchType.EAGER, cascade = [CascadeType.ALL]) + var requester: User, - @OneToMany + @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true) var voters: MutableList = mutableListOf(), var linkedGameId: Long? = null, diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt index 7301814..d798387 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/extensions/GameRequestExtensions.kt @@ -9,9 +9,8 @@ fun GameRequest.toDto(): GameRequestDto { id = this.id!!, title = this.title, release = this.release, - externalProviderIds = this.externalProviderIds, status = this.status, - requester = this.requester?.toUserInfoDto(), + requester = this.requester.toUserInfoDto(), voters = this.voters.map { it.toUserInfoDto() }, createdAt = this.createdAt, updatedAt = this.updatedAt diff --git a/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt b/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt index 3aa2d6c..4a8fc95 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/SessionService.kt @@ -12,7 +12,7 @@ class SessionService(private val sessionRegistry: SessionRegistry) { fun logoutAllSessions() { val auth = getCurrentAuth() - val sessions: List = sessionRegistry.getAllSessions(auth.principal, false) + val sessions: List = sessionRegistry.getAllSessions(auth?.principal, false) for (sessionInfo in sessions) { sessionInfo.expireNow() } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt index d7c5739..b925817 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserEndpoint.kt @@ -20,13 +20,13 @@ class UserEndpoint( @AnonymousAllowed fun getUserInfo(): ExtendedUserInfoDto? { val auth = getCurrentAuth() - if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null + if (auth?.isAuthenticated == false || auth?.principal == "anonymousUser") return null return userService.getUserInfo() } @PermitAll fun updateUser(updates: UserUpdateDto) { - val auth: Authentication = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") userService.updateUser(auth.name, updates) } @@ -52,7 +52,7 @@ class UserEndpoint( @PermitAll fun deleteUser() { - val auth: Authentication = getCurrentAuth() + val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found") userService.deleteUser(auth.name) } @@ -68,7 +68,7 @@ class UserEndpoint( @RolesAllowed(Role.Names.ADMIN) fun getRolesBelow(): List { - val auth: Authentication = getCurrentAuth() + val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found") return roleService.getRolesBelowAuth(auth).map { it.roleName } } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt index dad6137..b98b41b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/UserService.kt @@ -94,7 +94,7 @@ class UserService( } fun getUserInfo(): ExtendedUserInfoDto { - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val principal = auth.principal if (principal is OidcUser) { @@ -238,7 +238,7 @@ class UserService( return RoleAssignmentResult.NO_ROLES_PROVIDED } - val currentUser = getCurrentAuth() + val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val targetUser = getByUsernameNonNull(username) if (!canManage(targetUser)) { @@ -266,7 +266,7 @@ class UserService( } fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean { - val currentUser = getCurrentAuth() + val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel return currentUserLevel > targetUserLevel diff --git a/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt index 4a84f83..0b913a3 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/emailconfirmation/EmailConfirmationEndpoint.kt @@ -19,7 +19,7 @@ class EmailConfirmationEndpoint( @PermitAll fun resendEmailConfirmation() { - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") userService.getByUsername(auth.name)?.let { emailConfirmationService.resendEmailConfirmation(it) } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt b/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt index 617b5c8..2d88f89 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/preferences/UserPreferencesService.kt @@ -135,7 +135,7 @@ class UserPreferencesService( } private fun id(key: String): UserPreferenceKey { - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val user = userService.getByUsernameNonNull(auth.name) return UserPreferenceKey(key, user.id!!) } diff --git a/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt b/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt index 81d1974..14db409 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/registration/InvitationService.kt @@ -29,7 +29,7 @@ class InvitationService( if (userService.existsByEmail(email)) throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered") - val auth = getCurrentAuth() + val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val payload = mapOf(EMAIL_KEY to email) val token = super.generateWithPayload(user, payload)