diff --git a/app/src/main/frontend/App.tsx b/app/src/main/frontend/App.tsx index 1aadb07..4cee138 100644 --- a/app/src/main/frontend/App.tsx +++ b/app/src/main/frontend/App.tsx @@ -47,10 +47,10 @@ function ViewWithAuth() { initializeLibraryState(); initializeGameState(); initializeGameRequestState(); + initializePluginState(); if (isAdmin(auth)) { initializeScanState(); - initializePluginState(); } }, [auth]); diff --git a/app/src/main/frontend/routes.tsx b/app/src/main/frontend/routes.tsx index 75a9ccc..db7e443 100644 --- a/app/src/main/frontend/routes.tsx +++ b/app/src/main/frontend/routes.tsx @@ -24,6 +24,7 @@ import RecentlyAddedView from "Frontend/views/RecentlyAddedView"; import LibraryView from "Frontend/views/LibraryView"; import {RouterConfigurationBuilder} from "@vaadin/hilla-file-router/runtime.js"; import ErrorView from "Frontend/views/ErrorView"; +import GameRequestView from "Frontend/views/GameRequestView"; export const {router, routes} = new RouterConfigurationBuilder() .withReactRoutes([ @@ -47,6 +48,11 @@ export const {router, routes} = new RouterConfigurationBuilder() element: , handle: {title: 'Recently Added'} }, + { + path: '/requests', + element: , + handle: {title: 'Game requests'} + }, { path: 'library/:libraryId', element: diff --git a/app/src/main/frontend/state/GameRequestState.ts b/app/src/main/frontend/state/GameRequestState.ts index 00d47a4..bdf0dfa 100644 --- a/app/src/main/frontend/state/GameRequestState.ts +++ b/app/src/main/frontend/state/GameRequestState.ts @@ -38,13 +38,19 @@ export async function initializeGameRequestState() { case "created": case "updated": //@ts-ignore - gameRequestState.state[gameRequestEvent.id] = gameRequestEvent; + gameRequestState.state[gameRequestEvent.gameRequest.id] = gameRequestEvent.gameRequest; break; case "deleted": //@ts-ignore - delete gameRequestState.state[gameRequestEvent.id]; + delete gameRequestState.state[gameRequestEvent.gameRequestId]; break; } }) }); -} \ No newline at end of file +} + + + + + + diff --git a/app/src/main/frontend/views/GameRequestView.tsx b/app/src/main/frontend/views/GameRequestView.tsx new file mode 100644 index 0000000..4346cbc --- /dev/null +++ b/app/src/main/frontend/views/GameRequestView.tsx @@ -0,0 +1,277 @@ +import { + Button, + Chip, + Input, + Pagination, + Select, + SelectItem, + SortDescriptor, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tooltip, + useDisclosure +} from "@heroui/react"; +import RequestGameModal from "Frontend/components/general/modals/RequestGameModal"; +import {ArrowUp, Check, PlusCircle, X} from "@phosphor-icons/react"; +import React, {useMemo, useState} from "react"; +import {useAuth} from "Frontend/util/auth"; +import {GameRequestEndpoint} from "Frontend/generated/endpoints"; +import {gameRequestState} from "Frontend/state/GameRequestState"; +import {useSnapshot} from "valtio/react"; +import GameRequestDto from "Frontend/generated/org/gameyfin/app/requests/dto/GameRequestDto"; +import GameRequestStatus from "Frontend/generated/org/gameyfin/app/requests/status/GameRequestStatus"; +import {isAdmin} from "Frontend/util/utils"; + +export default function GameRequestView() { + const rowsPerPage = 25; + + const auth = useAuth(); + const requestGameModal = useDisclosure(); + const gameRequests = useSnapshot(gameRequestState).gameRequests + + const [searchTerm, setSearchTerm] = useState(""); + const [filters, setFilters] = useState<"all" | GameRequestStatus[]>([GameRequestStatus.PENDING, GameRequestStatus.APPROVED, GameRequestStatus.REJECTED]); + const [sortDescriptor, setSortDescriptor] = useState({column: "votes", direction: "descending"}); + + const [page, setPage] = useState(1); + const pages = useMemo(() => { + return Math.ceil(getFilteredRequests().length / rowsPerPage); + }, [gameRequests, filters]); + + const filteredItems = useMemo(() => { + return getFilteredRequests(); + }, [gameRequests, filters, searchTerm]); + + const sortedItems = useMemo(() => { + return (filteredItems as GameRequestDto[]).slice().sort((a, b) => { + let cmp: number; + + switch (sortDescriptor.column) { + case "title": + cmp = a.title.localeCompare(b.title); + break; + case "votes": + cmp = a.voters.length - b.voters.length; + if (cmp === 0) { + // If votes are equal, sort by creation date (newest first) + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + } + break; + case "status": + const statusOrder = { + [GameRequestStatus.PENDING]: 1, + [GameRequestStatus.APPROVED]: 2, + [GameRequestStatus.REJECTED]: 3, + [GameRequestStatus.FULFILLED]: 4 + }; + cmp = (statusOrder[a.status] || 99) - (statusOrder[b.status] || 99); + break; + case "createdAt": + cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case "updatedAt": + cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + break; + default: + return 0; // No sorting if the column is not recognized + } + + if (sortDescriptor.direction === "descending") { + cmp *= -1; // Reverse the comparison if sorting in descending order + } + + return cmp; + }); + }, [filteredItems, sortDescriptor]); + + const pagedItems = useMemo(() => { + const start = (page - 1) * rowsPerPage; + const end = start + rowsPerPage; + return sortedItems.slice(start, end); + }, [page, sortedItems]); + + + function getFilteredRequests() { + let filteredRequests = (gameRequests as GameRequestDto[]).filter((gameRequest) => { + return gameRequest.title.toLowerCase().includes(searchTerm.toLowerCase()) || + (gameRequest.requester && gameRequest.requester.username.toLowerCase().includes(searchTerm.toLowerCase())); + }); + + filteredRequests = filteredRequests.filter((gameRequest) => { + return filters.includes(gameRequest.status); + }); + + return filteredRequests; + } + + async function toggleVote(gameRequestId: number) { + await GameRequestEndpoint.toggleVote(gameRequestId); + } + + async function toggleApprove(gameRequest: GameRequestDto) { + if (gameRequest.status == GameRequestStatus.FULFILLED) return; + const newStatus = gameRequest.status === GameRequestStatus.APPROVED ? GameRequestStatus.PENDING : GameRequestStatus.APPROVED; + await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus); + } + + async function toggleReject(gameRequest: GameRequestDto) { + if (gameRequest.status == GameRequestStatus.FULFILLED) return; + const newStatus = gameRequest.status === GameRequestStatus.REJECTED ? GameRequestStatus.PENDING : GameRequestStatus.REJECTED; + await GameRequestEndpoint.changeStatus(gameRequest.id, newStatus); + } + + function hasUserVotedForRequest(gameRequest: GameRequestDto): boolean { + if (!auth.state.user) return false; + return gameRequest.voters.map(v => v.id).includes(auth.state.user.id); + } + + function statusToBadge(status: GameRequestStatus) { + switch (status) { + case GameRequestStatus.APPROVED: + return Approved; + case GameRequestStatus.FULFILLED: + return Fulfilled; + case GameRequestStatus.REJECTED: + return Rejected; + case GameRequestStatus.PENDING: + default: + return Pending; + } + } + + return (<> +
+

Game Requests

+
+ +
+
+ + +
+ setSearchTerm(e.target.value)} + onClear={() => setSearchTerm("")} + /> + +
+ + + {pagedItems.length > 0 && + setPage(page)} + />} + + } + > + + Title & Release + Submitted by + Submitted + Updated + Status + {/* width={1} keeps the column as far to the right as possible*/} + Votes + + + {(item) => ( + + + {item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"}) + + +

+ {item.requester ? + item.requester.username : + "Guest" + } +

+
+ + {new Date(item.createdAt).toLocaleDateString()} + + + {new Date(item.updatedAt).toLocaleDateString()} + + + {statusToBadge(item.status)} + + +
+ + + + {isAdmin(auth) &&
+ + + + + + +
} +
+
+
+ )} +
+
+ + + + ) +} \ 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 b7c1acc..268454d 100644 --- a/app/src/main/frontend/views/MainLayout.tsx +++ b/app/src/main/frontend/views/MainLayout.tsx @@ -1,21 +1,11 @@ import {useEffect, useState} from 'react'; import ProfileMenu from "Frontend/components/ProfileMenu"; -import { - Button, - Divider, - Link, - Navbar, - NavbarBrand, - NavbarContent, - NavbarItem, - Tooltip, - useDisclosure -} from "@heroui/react"; +import {Button, Divider, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem, Tooltip} 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, PlusCircle, SignIn} from "@phosphor-icons/react"; +import {ArrowLeft, DiceSix, Disc, Heart, House, ListMagnifyingGlass, 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"; @@ -24,7 +14,6 @@ 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(); @@ -37,8 +26,6 @@ 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)) @@ -110,9 +97,11 @@ export default function MainLayout() { {auth.state.user && - @@ -165,10 +154,6 @@ export default function MainLayout() {

- - - ); } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt index 50ddede..395c038 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigEndpoint.kt @@ -55,4 +55,8 @@ class ConfigEndpoint( @DynamicPublicAccess @AnonymousAllowed fun isPublicAccessEnabled(): Boolean = configService.get(ConfigProperties.Libraries.AllowPublicAccess) == true + + @DynamicPublicAccess + @AnonymousAllowed + fun areGameRequestsEnabled(): Boolean = configService.get(ConfigProperties.Requests.Games.Enabled) == true } diff --git a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt index a65cf0a..d8db3b3 100644 --- a/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt +++ b/app/src/main/kotlin/org/gameyfin/app/config/ConfigProperties.kt @@ -103,6 +103,32 @@ sealed class ConfigProperties( } } + /** Requests */ + sealed class Requests { + sealed class Games { + data object Enabled : ConfigProperties( + Boolean::class, + "requests.games.enabled", + "Enable game requests", + true + ) + + data object AllowGuestsToRequestGames : ConfigProperties( + Boolean::class, + "requests.games.allow-guests-to-request-games", + "Allow guests (not logged in) to create game requests", + false + ) + + data object MaxOpenRequestsPerUser : ConfigProperties( + Int::class, + "requests.games.max-open-requests-per-user", + "Maximum number of open (not yet fulfilled or rejected) requests per user. Set to 0 for unlimited.", + 10 + ) + } + } + /** User management */ sealed class Users { sealed class SignUps { diff --git a/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt index 94be2f4..049db72 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/plugins/PluginEndpoint.kt @@ -1,41 +1,46 @@ package org.gameyfin.app.core.plugins +import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint -import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed import org.gameyfin.app.core.Role +import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.core.plugins.dto.PluginUpdateDto -import org.gameyfin.app.core.security.isCurrentUserAdmin import org.gameyfin.pluginapi.core.config.PluginConfigValidationResult import reactor.core.publisher.Flux @Endpoint -@RolesAllowed(Role.Names.ADMIN) +@DynamicPublicAccess +@AnonymousAllowed class PluginEndpoint( private val pluginService: PluginService, ) { - @PermitAll fun subscribe(): Flux> { - return if (isCurrentUserAdmin()) PluginService.subscribe() - else Flux.empty() + return PluginService.subscribe() } fun getAll() = pluginService.getAll().sortedByDescending { it.priority } + @RolesAllowed(Role.Names.ADMIN) fun enablePlugin(pluginId: String) = pluginService.enablePlugin(pluginId) + @RolesAllowed(Role.Names.ADMIN) fun disablePlugin(pluginId: String) = pluginService.disablePlugin(pluginId) + @RolesAllowed(Role.Names.ADMIN) fun setPluginPriorities(pluginPriorities: Map) = pluginService.setPluginPriorities(pluginPriorities) + @RolesAllowed(Role.Names.ADMIN) fun validatePluginConfig(pluginId: String): PluginConfigValidationResult = pluginService.validatePluginConfig(pluginId, true) + @RolesAllowed(Role.Names.ADMIN) fun validateNewConfig(pluginId: String, config: Map): PluginConfigValidationResult = pluginService.validatePluginConfig(pluginId, config) + @RolesAllowed(Role.Names.ADMIN) fun updateConfig(pluginId: String, updatedConfig: Map) = pluginService.updateConfig(pluginId, updatedConfig) } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt index 3caaa62..23933d9 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityConfig.kt @@ -53,6 +53,7 @@ class SecurityConfig( .requestMatchers("/game/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/library/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/search/**").access(DynamicPublicAccessAuthorizationManager(config)) + .requestMatchers("/requests/**").access(DynamicPublicAccessAuthorizationManager(config)) .requestMatchers("/download/**").access(DynamicPublicAccessAuthorizationManager(config)) } diff --git a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt index 25113b0..385162b 100644 --- a/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt +++ b/app/src/main/kotlin/org/gameyfin/app/core/security/SecurityUtils.kt @@ -9,6 +9,9 @@ fun getCurrentAuth(): Authentication? { } fun isCurrentUserAdmin(): Boolean { - return getCurrentAuth()?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } - ?: false + return getCurrentAuth()?.isAdmin() ?: false +} + +fun Authentication.isAdmin(): Boolean { + return this.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } ?: false } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt index 9f33c51..e7ec428 100644 --- a/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/games/GameEndpoint.kt @@ -34,6 +34,10 @@ class GameEndpoint( fun getAll(): List = gameService.getAll() + fun getPotentialMatches(searchTerm: String): List { + return gameService.getPotentialMatches(searchTerm) + } + @RolesAllowed(Role.Names.ADMIN) fun updateGame(game: GameUpdateDto) = gameService.edit(game) @@ -43,11 +47,6 @@ class GameEndpoint( gameService.delete(gameId) } - @RolesAllowed(Role.Names.ADMIN) - fun getPotentialMatches(searchTerm: String): List { - return gameService.getPotentialMatches(searchTerm) - } - @RolesAllowed(Role.Names.ADMIN) fun matchManually( originalIds: Map, 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 533cfdb..37d25a8 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestEndpoint.kt @@ -4,6 +4,7 @@ import com.vaadin.flow.server.auth.AnonymousAllowed import com.vaadin.hilla.Endpoint import jakarta.annotation.security.PermitAll import jakarta.annotation.security.RolesAllowed +import org.gameyfin.app.config.ConfigService import org.gameyfin.app.core.Role import org.gameyfin.app.core.annotations.DynamicPublicAccess import org.gameyfin.app.requests.dto.GameRequestCreationDto @@ -17,7 +18,8 @@ import reactor.core.publisher.Flux @DynamicPublicAccess @AnonymousAllowed class GameRequestEndpoint( - private val gameRequestService: GameRequestService + private val gameRequestService: GameRequestService, + private val config: ConfigService ) { fun subscribe(): Flux> { @@ -26,7 +28,6 @@ class GameRequestEndpoint( fun getAll() = gameRequestService.getAll() - @PermitAll fun create(gameRequest: GameRequestCreationDto) { gameRequestService.createRequest(gameRequest) } @@ -36,13 +37,13 @@ class GameRequestEndpoint( gameRequestService.toggleRequestVote(gameRequestId) } + @PermitAll + fun delete(gameRequestId: Long) { + gameRequestService.deleteRequest(gameRequestId) + } + @RolesAllowed(Role.Names.ADMIN) fun changeStatus(gameRequestId: Long, newStatus: GameRequestStatus) { gameRequestService.changeRequestStatus(gameRequestId, newStatus) } - - @RolesAllowed(Role.Names.ADMIN) - fun delete(gameRequestId: Long) { - gameRequestService.deleteRequest(gameRequestId) - } } \ No newline at end of file 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 12e9f43..433077a 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/GameRequestRepository.kt @@ -17,4 +17,13 @@ interface GameRequestRepository : JpaRepository { GameRequestStatus.REJECTED ) ): List + + @Query("SELECT g FROM GameRequest g WHERE g.requester.id = :requesterId AND g.status NOT IN (:excludedStatuses)") + fun findOpenRequestsByRequesterId( + @Param("requesterId") requesterId: Long?, + @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 0551c1d..665c5f6 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,11 @@ package org.gameyfin.app.requests import io.github.oshai.kotlinlogging.KotlinLogging +import org.gameyfin.app.config.ConfigProperties +import org.gameyfin.app.config.ConfigService import org.gameyfin.app.core.events.GameCreatedEvent import org.gameyfin.app.core.security.getCurrentAuth +import org.gameyfin.app.core.security.isAdmin import org.gameyfin.app.requests.dto.GameRequestCreationDto import org.gameyfin.app.requests.dto.GameRequestDto import org.gameyfin.app.requests.dto.GameRequestEvent @@ -11,9 +14,11 @@ 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.gameyfin.app.users.entities.User import org.springframework.context.event.EventListener import org.springframework.scheduling.annotation.Async import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import reactor.core.publisher.Flux import reactor.core.publisher.Sinks import kotlin.time.Duration.Companion.milliseconds @@ -22,7 +27,8 @@ import kotlin.time.toJavaDuration @Service class GameRequestService( private val gameRequestRepository: GameRequestRepository, - private val userService: UserService + private val userService: UserService, + private val config: ConfigService ) { companion object { @@ -54,33 +60,82 @@ class GameRequestService( } fun createRequest(gameRequest: GameRequestCreationDto) { - val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") - val currentUser = - userService.getByUsername(auth.name) ?: throw IllegalStateException("Current user not found") - val gameRequest = GameRequest( + // Check if requests are enabled + if (config.get(ConfigProperties.Requests.Games.Enabled) != true) { + throw IllegalStateException("Game requests are disabled") + } + + // Check if a request with the same title and release year already exists + val existingRequests = gameRequestRepository.findOpenRequestsByTitleAndReleaseYear( + gameRequest.title, + gameRequest.release, + emptyList() + ) + if (existingRequests.isNotEmpty()) { + throw IllegalStateException("A request for this game already exists (ID: ${existingRequests[0].id})") + } + + val auth = getCurrentAuth() + val currentUser = auth?.let { userService.getByUsername(it.name) } + + // Check if guests are allowed to create requests + if (config.get(ConfigProperties.Requests.Games.AllowGuestsToRequestGames) != true && currentUser == null) { + throw IllegalStateException("Only registered users can create game requests") + } + + // Check if user has too many open requests (0 means no limit per user) + // Note: All guests are treated as a single user with null ID and thus share their request limit + // Note: Admins are exempt from this limit + val openRequestsForUser = gameRequestRepository.findOpenRequestsByRequesterId(currentUser?.id) + val maxRequestsPerUser = config.get(ConfigProperties.Requests.Games.MaxOpenRequestsPerUser) ?: 0 + if (maxRequestsPerUser == 0 || (auth?.isAdmin() != true && openRequestsForUser.size >= maxRequestsPerUser)) { + throw IllegalStateException("You have reached the maximum number of open requests (${maxRequestsPerUser})") + } + + val newGameRequest = GameRequest( title = gameRequest.title, release = gameRequest.release, status = GameRequestStatus.PENDING, - requester = currentUser + requester = currentUser, + voters = mutableSetOf().apply { + currentUser?.let { add(it) } + } ) - gameRequestRepository.save(gameRequest) + gameRequestRepository.save(newGameRequest) } fun deleteRequest(id: Long) { val gameRequest = gameRequestRepository.findById(id) .orElseThrow { NoSuchElementException("No game request found with id $id") } + + val auth = getCurrentAuth() + val currentUser = auth?.let { userService.getByUsername(it.name) } + val requester = gameRequest.requester + + // Check if the current user is the requester or an admin + if (auth?.isAdmin() != true || requester == null || requester.id != currentUser?.id) { + throw IllegalStateException("Only the requester or an admin can delete a game request") + } + gameRequestRepository.delete(gameRequest) } fun changeRequestStatus(id: Long, status: GameRequestStatus) { val gameRequest = gameRequestRepository.findById(id) .orElseThrow { NoSuchElementException("No game request found with id $id") } + + if (gameRequest.status == GameRequestStatus.FULFILLED) { + log.debug { "Status of requests with status ${GameRequestStatus.FULFILLED} can't be changed" } + return + } + gameRequest.status = status gameRequestRepository.save(gameRequest) } + @Transactional fun toggleRequestVote(id: Long) { val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found") val currentUser = @@ -88,15 +143,17 @@ class GameRequestService( val gameRequest = gameRequestRepository.findById(id) .orElseThrow { NoSuchElementException("No game request found with id $id") } - if (gameRequest.requester.id == currentUser.id) { - throw IllegalStateException("You cannot vote for your own request") - } - - if (gameRequest.voters.contains(currentUser)) { - gameRequest.voters.remove(currentUser) + // Replace the voters collection to ensure Hibernate detects the change + val updatedVoters = gameRequest.voters.toMutableSet() + if (updatedVoters.contains(currentUser)) { + updatedVoters.remove(currentUser) } else { - gameRequest.voters.add(currentUser) + updatedVoters.add(currentUser) } + gameRequest.voters = updatedVoters + + // Ensure the entity is marked as dirty + gameRequest.status = gameRequest.status gameRequestRepository.save(gameRequest) } @@ -109,7 +166,7 @@ class GameRequestService( val gameRelease = game.release if (gameTitle == null) { - log.debug { "Game '${game.id}' is missing title, cannot complete matching requests" } + log.warn { "Game '${game.id}' is missing title, cannot complete matching requests" } return } 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 7932c90..e5ba423 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 @@ -11,6 +11,6 @@ class GameRequestDto( val status: GameRequestStatus, val requester: UserInfoDto?, val voters: List, - val createdAt: Instant?, - val updatedAt: Instant? + val createdAt: Instant, + val updatedAt: Instant ) \ No newline at end of file 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 0fbd7e9..5444092 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 @@ -4,12 +4,13 @@ import jakarta.persistence.* import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.users.entities.User import org.hibernate.annotations.CreationTimestamp +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction import org.hibernate.annotations.UpdateTimestamp -import org.springframework.data.jpa.domain.support.AuditingEntityListener import java.time.Instant @Entity -@EntityListeners(GameRequestEntityListener::class, AuditingEntityListener::class) +@EntityListeners(GameRequestEntityListener::class) class GameRequest( @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -25,11 +26,13 @@ class GameRequest( @Enumerated(EnumType.STRING) var status: GameRequestStatus, - @ManyToOne(fetch = FetchType.EAGER, cascade = [CascadeType.ALL]) - var requester: User, + @ManyToOne(fetch = FetchType.EAGER) + @OnDelete(action = OnDeleteAction.SET_NULL) + var requester: User? = null, - @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true) - var voters: MutableList = mutableListOf(), + @ManyToMany(fetch = FetchType.EAGER) + @OnDelete(action = OnDeleteAction.CASCADE) + var voters: MutableSet = mutableSetOf(), 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 d798387..e9e1746 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 @@ -10,10 +10,10 @@ fun GameRequest.toDto(): GameRequestDto { title = this.title, release = this.release, status = this.status, - requester = this.requester.toUserInfoDto(), + requester = this.requester?.toUserInfoDto(), voters = this.voters.map { it.toUserInfoDto() }, - createdAt = this.createdAt, - updatedAt = this.updatedAt + createdAt = this.createdAt!!, + updatedAt = this.updatedAt!! ) } diff --git a/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt b/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt index 290c677..ecbe7ea 100644 --- a/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt +++ b/app/src/main/kotlin/org/gameyfin/app/requests/status/GameRequestStatus.kt @@ -3,6 +3,6 @@ package org.gameyfin.app.requests.status enum class GameRequestStatus { PENDING, APPROVED, - FULFILLED, - REJECTED + REJECTED, + FULFILLED } \ No newline at end of file diff --git a/app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt b/app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt index d125756..3486e62 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/dto/ExtendedUserInfoDto.kt @@ -3,6 +3,7 @@ package org.gameyfin.app.users.dto import org.gameyfin.app.core.Role data class ExtendedUserInfoDto( + val id: Long, val username: String, val managedBySso: Boolean, val email: String, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt index d975a85..c1dc8e4 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/dto/UserInfoDto.kt @@ -1,6 +1,7 @@ package org.gameyfin.app.users.dto data class UserInfoDto( + val id: Long, val username: String, val hasAvatar: Boolean, val avatarId: Long? = null, diff --git a/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt b/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt index 77e715f..5265063 100644 --- a/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt +++ b/app/src/main/kotlin/org/gameyfin/app/users/extensions/UserExtensions.kt @@ -9,6 +9,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority fun User.toUserInfoDto(): UserInfoDto { return UserInfoDto( + id = this.id!!, username = this.username, hasAvatar = this.avatar != null, avatarId = this.avatar?.id @@ -17,6 +18,7 @@ fun User.toUserInfoDto(): UserInfoDto { fun User.toExtendedUserInfoDto(): ExtendedUserInfoDto { return ExtendedUserInfoDto( + id = this.id!!, username = this.username, email = this.email, emailConfirmed = this.emailConfirmed,