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
+
+ }
+ onPress={requestGameModal.onOpen}>
+ Request a Game
+
+
+
+
+
+
+ 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,