Add minimal UI for game requests

Fix some minor bugs
This commit is contained in:
grimsi
2025-09-02 18:49:51 +02:00
parent 6198c143db
commit ae7a65ccbc
25 changed files with 318 additions and 87 deletions
+2
View File
@@ -15,6 +15,7 @@ import {initializePluginState} from "Frontend/state/PluginState";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
import {useRouteMetadata} from "Frontend/util/routing"; import {useRouteMetadata} from "Frontend/util/routing";
import {useEffect} from "react"; import {useEffect} from "react";
import {initializeGameRequestState} from "Frontend/state/GameRequestState";
export default function App() { export default function App() {
client.middlewares = [ErrorHandlingMiddleware]; client.middlewares = [ErrorHandlingMiddleware];
@@ -45,6 +46,7 @@ function ViewWithAuth() {
initializeLibraryState(); initializeLibraryState();
initializeGameState(); initializeGameState();
initializeGameRequestState();
if (isAdmin(auth)) { if (isAdmin(auth)) {
initializeScanState(); initializeScanState();
@@ -21,7 +21,7 @@ import {useSnapshot} from "valtio/react";
import {pluginState} from "Frontend/state/PluginState"; import {pluginState} from "Frontend/state/PluginState";
import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto"; import PluginDto from "Frontend/generated/org/gameyfin/app/core/plugins/dto/PluginDto";
interface EditGameMetadataModalProps { interface MatchGameModalProps {
path: string; path: string;
libraryId: number; libraryId: number;
replaceGameId?: number; replaceGameId?: number;
@@ -37,7 +37,7 @@ export default function MatchGameModal({
initialSearchTerm, initialSearchTerm,
isOpen, isOpen,
onOpenChange onOpenChange
}: EditGameMetadataModalProps) { }: MatchGameModalProps) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]); const [searchResults, setSearchResults] = useState<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false); const [isSearching, setIsSearching] = useState(false);
@@ -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<GameSearchResultDto[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isRequesting, setIsRequesting] = useState<string | null>(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 (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}
hideCloseButton
isDismissable={!isSearching && !isRequesting}
isKeyboardDismissDisabled={!isSearching && !isRequesting}
backdrop="opaque" size="5xl">
<ModalContent>
{(onClose) => (
<ModalBody className="my-4">
<div className="flex flex-col items-center">
<h2 className="text-xl font-semibold">Request a game</h2>
</div>
<div className="flex flex-row gap-2 mb-4">
<Input value={searchTerm}
onValueChange={setSearchTerm}
onKeyDown={async (e) => {
if (e.key === "Enter") {
e.preventDefault();
await search();
}
}}
/>
<Button isIconOnly onPress={search} color="primary" isLoading={isSearching}>
<MagnifyingGlass/>
</Button>
</div>
<div>
<Table removeWrapper isStriped isHeaderSticky
classNames={{
base: "h-80 overflow-y-auto",
}}
>
<TableHeader>
<TableColumn>Title & Release</TableColumn>
<TableColumn>Developer(s)</TableColumn>
<TableColumn>Publisher(s)</TableColumn>
{/* width={1} keeps the column as far to the right as possible*/}
<TableColumn>Sources</TableColumn>
<TableColumn width={1}> </TableColumn>
</TableHeader>
<TableBody emptyContent="Your search did not match any games." items={searchResults}>
{(item) => (
<TableRow key={item.id}>
<TableCell>
{item.title} ({item.release ? new Date(item.release).getFullYear() : "unknown"})
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.developers ? item.developers.map(
developer => <p>{developer}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col">
{item.publishers ? item.publishers.map(
publisher => <p>{publisher}</p>
) : "unknown"}
</div>
</TableCell>
<TableCell>
<div className="flex flex-row gap-2">
{Object.values(item.originalIds).map(
originalId => <PluginIcon
plugin={plugins[originalId.pluginId] as PluginDto}/>
)}
</div>
</TableCell>
<TableCell>
<Tooltip content="Pick this result">
<Button isIconOnly size="sm"
isDisabled={isRequesting !== null}
isLoading={isRequesting === item.id}
onPress={async () => {
setIsRequesting(item.id);
await requestGame(item);
setIsRequesting(null);
onClose();
}}>
<ArrowRight/>
</Button>
</Tooltip>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</ModalBody>
)}
</ModalContent>
</Modal>
);
}
@@ -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<GameRequestEvent[]>;
isLoaded: boolean;
state: Record<number, GameRequestDto>;
gameRequests: GameRequestDto[];
};
export const gameRequestState = proxy<GameRequestState>({
get isLoaded() {
return this.subscription != null;
},
state: {},
get gameRequests() {
return Object.values<GameRequestDto>(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;
}
})
});
}
+30 -3
View File
@@ -1,11 +1,21 @@
import {useEffect, useState} from 'react'; import {useEffect, useState} from 'react';
import ProfileMenu from "Frontend/components/ProfileMenu"; 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 GameyfinLogo from "Frontend/components/theming/GameyfinLogo";
import * as PackageJson from "../../../../package.json"; import * as PackageJson from "../../../../package.json";
import {Outlet, useLocation, useNavigate} from "react-router"; import {Outlet, useLocation, useNavigate} from "react-router";
import {useAuth} from "Frontend/util/auth"; 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 Confetti, {ConfettiProps} from "react-confetti-boom";
import {useTheme} from "next-themes"; import {useTheme} from "next-themes";
import {useUserPreferenceService} from "Frontend/util/user-preference-service"; import {useUserPreferenceService} from "Frontend/util/user-preference-service";
@@ -14,6 +24,7 @@ import {useSnapshot} from "valtio/react";
import {gameState} from "Frontend/state/GameState"; import {gameState} from "Frontend/state/GameState";
import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover"; import ScanProgressPopover from "Frontend/components/general/ScanProgressPopover";
import {isAdmin} from "Frontend/util/utils"; import {isAdmin} from "Frontend/util/utils";
import RequestGameModal from "Frontend/components/general/modals/RequestGameModal";
export default function MainLayout() { export default function MainLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -26,6 +37,8 @@ export default function MainLayout() {
const [isExploding, setIsExploding] = useState(false); const [isExploding, setIsExploding] = useState(false);
const games = useSnapshot(gameState).games; const games = useSnapshot(gameState).games;
const requestGameModal = useDisclosure();
useEffect(() => { useEffect(() => {
userPreferenceService.sync() userPreferenceService.sync()
.then(() => loadUserTheme().catch(console.error)) .then(() => loadUserTheme().catch(console.error))
@@ -93,7 +106,17 @@ export default function MainLayout() {
</Button> </Button>
</Tooltip> </Tooltip>
</NavbarContent>} </NavbarContent>}
<NavbarContent justify="end"> <NavbarContent justify="end" className="items-center">
{auth.state.user &&
<NavbarItem>
<Tooltip content="Request a game" placement="bottom">
<Button isIconOnly color="primary" variant="light"
onPress={requestGameModal.onOpen}>
<PlusCircle size={26} weight="fill"/>
</Button>
</Tooltip>
</NavbarItem>
}
{isAdmin(auth) && {isAdmin(auth) &&
<NavbarItem> <NavbarItem>
<ScanProgressPopover/> <ScanProgressPopover/>
@@ -142,6 +165,10 @@ export default function MainLayout() {
</p> </p>
</footer> </footer>
</div> </div>
<RequestGameModal isOpen={requestGameModal.isOpen}
onOpenChange={requestGameModal.onOpenChange}/>
</div> </div>
); );
} }
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.annotation.JsonCreator
import com.fasterxml.jackson.annotation.JsonValue import com.fasterxml.jackson.annotation.JsonValue
import org.gameyfin.app.users.RoleService import org.gameyfin.app.users.RoleService
import java.lang.Enum import java.lang.Enum
import kotlin.Int
import kotlin.String
enum class Role(val roleName: String, val powerLevel: Int) { enum class Role(val roleName: String, val powerLevel: Int) {
@@ -21,12 +23,12 @@ enum class Role(val roleName: String, val powerLevel: Int) {
@JsonCreator @JsonCreator
@JvmStatic @JvmStatic
fun fromValue(value: String): Role? { 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 } return entries.find { it.roleName == enumString }
} }
fun safeValueOf(type: String): Role? { 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) 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 // necessary for the ability to use the Roles class in the @RolesAllowed annotation
class Names { class Names {
companion object { companion object {
const val SUPERADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}SUPERADMIN" const val SUPERADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}SUPERADMIN"
const val ADMIN = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}ADMIN" const val ADMIN = "${RoleService.INTERNAL_ROLE_PREFIX}ADMIN"
const val USER = "${RoleService.Companion.INTERNAL_ROLE_PREFIX}USER" const val USER = "${RoleService.INTERNAL_ROLE_PREFIX}USER"
} }
} }
} }
@@ -1,5 +1,6 @@
package org.gameyfin.app.core.events 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.Token
import org.gameyfin.app.shared.token.TokenType import org.gameyfin.app.shared.token.TokenType
import org.gameyfin.app.users.entities.User import org.gameyfin.app.users.entities.User
@@ -24,3 +25,5 @@ class PasswordResetRequestEvent(source: Any, val token: Token<TokenType.Password
class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source) class AccountDeletedEvent(source: Any, val user: User, val baseUrl: String) : ApplicationEvent(source)
class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source) class LibraryScanScheduleUpdatedEvent(source: Any) : ApplicationEvent(source)
class GameCreatedEvent(source: Any, val game: Game) : ApplicationEvent(source)
@@ -4,11 +4,11 @@ import org.gameyfin.app.core.Role
import org.springframework.security.core.Authentication import org.springframework.security.core.Authentication
import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.core.context.SecurityContextHolder
fun getCurrentAuth(): Authentication { fun getCurrentAuth(): Authentication? {
return SecurityContextHolder.getContext().authentication return SecurityContextHolder.getContext().authentication
} }
fun isCurrentUserAdmin(): Boolean { fun isCurrentUserAdmin(): Boolean {
return getCurrentAuth().authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN } return getCurrentAuth()?.authorities?.any { it.authority == Role.Names.ADMIN || it.authority == Role.Names.SUPERADMIN }
?: false ?: false
} }
@@ -150,10 +150,10 @@ class GameService(
val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id) val existingGame = gameRepository.findByIdOrNull(gameUpdateDto.id)
?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found") ?: throw IllegalArgumentException("Game with ID $gameUpdateDto.id not found")
val user = when (val userDetails = getCurrentAuth().principal) { val user = when (val userDetails = getCurrentAuth()?.principal) {
is UserDetails -> userService.getByUsernameNonNull(userDetails.username) is UserDetails -> userService.getByUsernameNonNull(userDetails.username)
is OidcUser -> userService.getByUsernameNonNull(userDetails.preferredUsername) 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 // Update only non-null fields
@@ -3,38 +3,21 @@ package org.gameyfin.app.games.entities
import jakarta.persistence.PostPersist import jakarta.persistence.PostPersist
import jakarta.persistence.PostRemove import jakarta.persistence.PostRemove
import jakarta.persistence.PostUpdate import jakarta.persistence.PostUpdate
import org.gameyfin.app.core.events.GameCreatedEvent
import org.gameyfin.app.games.GameService import org.gameyfin.app.games.GameService
import org.gameyfin.app.games.dto.GameAdminEvent import org.gameyfin.app.games.dto.GameAdminEvent
import org.gameyfin.app.games.dto.GameUserEvent import org.gameyfin.app.games.dto.GameUserEvent
import org.gameyfin.app.games.extensions.toAdminDto import org.gameyfin.app.games.extensions.toAdminDto
import org.gameyfin.app.games.extensions.toUserDto import org.gameyfin.app.games.extensions.toUserDto
import org.gameyfin.app.requests.GameRequestService import org.gameyfin.app.util.EventPublisherHolder
import org.springframework.context.ApplicationContext
import org.springframework.context.ApplicationContextAware
import org.springframework.stereotype.Component
@Component class GameEntityListener {
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)
}
@PostPersist @PostPersist
fun created(game: Game) { fun created(game: Game) {
GameService.emitUser(GameUserEvent.Created(game.toUserDto())) GameService.emitUser(GameUserEvent.Created(game.toUserDto()))
GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto())) GameService.emitAdmin(GameAdminEvent.Created(game.toAdminDto()))
EventPublisherHolder.publish(GameCreatedEvent(this, game))
// After a game is created, mark any matching game requests as FULFILLED
getGameRequestService().completeMatchingRequests(game)
} }
@PostUpdate @PostUpdate
@@ -60,7 +60,7 @@ class ImageEndpoint(
@PermitAll @PermitAll
@PostMapping("/avatar/upload") @PostMapping("/avatar/upload")
fun uploadAvatar(@RequestParam("file") file: MultipartFile) { 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)) { val image: Image = if (!userService.hasAvatar(auth.name)) {
imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!) imageService.createFile(ImageType.AVATAR, file.inputStream, file.contentType!!)
@@ -75,7 +75,7 @@ class ImageEndpoint(
@PermitAll @PermitAll
@PostMapping("/avatar/delete") @PostMapping("/avatar/delete")
fun deleteAvatar() { fun deleteAvatar() {
val auth = getCurrentAuth() val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.deleteAvatar(auth.name) userService.deleteAvatar(auth.name)
} }
@@ -60,7 +60,7 @@ class MessageService(
} }
try { try {
val auth = getCurrentAuth() val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found") val user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
val template = templateService.getMessageTemplate(templateKey) val template = templateService.getMessageTemplate(templateKey)
sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders) sendNotification(user.email, "[Gameyfin] Test Notification", template, placeholders)
@@ -26,6 +26,7 @@ class GameRequestEndpoint(
fun getAll() = gameRequestService.getAll() fun getAll() = gameRequestService.getAll()
@PermitAll
fun create(gameRequest: GameRequestCreationDto) { fun create(gameRequest: GameRequestCreationDto) {
gameRequestService.createRequest(gameRequest) gameRequestService.createRequest(gameRequest)
} }
@@ -1,14 +1,20 @@
package org.gameyfin.app.requests package org.gameyfin.app.requests
import org.gameyfin.app.requests.entities.GameRequest 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.JpaRepository
import org.springframework.data.jpa.repository.Query import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param import org.springframework.data.repository.query.Param
import java.time.Instant import java.time.Instant
interface GameRequestRepository : JpaRepository<GameRequest, Long> { interface GameRequestRepository : JpaRepository<GameRequest, Long> {
fun findByTitleAndRelease(title: String, release: Instant): List<GameRequest> @Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release) AND g.status NOT IN (:excludedStatuses)")
fun findOpenRequestsByTitleAndReleaseYear(
@Query("SELECT g FROM GameRequest g WHERE g.title = :title AND YEAR(g.release) = YEAR(:release)") @Param("title") title: String,
fun findByTitleAndReleaseYear(@Param("title") title: String, @Param("release") release: Instant): List<GameRequest> @Param("release") release: Instant?,
@Param("excludedStatuses") excludedStatuses: List<GameRequestStatus> = listOf(
GameRequestStatus.FULFILLED,
GameRequestStatus.REJECTED
)
): List<GameRequest>
} }
@@ -1,8 +1,8 @@
package org.gameyfin.app.requests package org.gameyfin.app.requests
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.gameyfin.app.core.events.GameCreatedEvent
import org.gameyfin.app.core.security.getCurrentAuth 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.GameRequestCreationDto
import org.gameyfin.app.requests.dto.GameRequestDto import org.gameyfin.app.requests.dto.GameRequestDto
import org.gameyfin.app.requests.dto.GameRequestEvent 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.extensions.toDtos
import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.requests.status.GameRequestStatus
import org.gameyfin.app.users.UserService import org.gameyfin.app.users.UserService
import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import reactor.core.publisher.Flux import reactor.core.publisher.Flux
import reactor.core.publisher.Sinks import reactor.core.publisher.Sinks
@@ -52,13 +54,14 @@ class GameRequestService(
} }
fun createRequest(gameRequest: GameRequestCreationDto) { 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( val gameRequest = GameRequest(
title = gameRequest.title, title = gameRequest.title,
release = gameRequest.release, release = gameRequest.release,
status = GameRequestStatus.PENDING, status = GameRequestStatus.PENDING,
externalProviderIds = gameRequest.externalProviderIds,
requester = currentUser requester = currentUser
) )
@@ -79,12 +82,13 @@ class GameRequestService(
} }
fun toggleRequestVote(id: Long) { fun toggleRequestVote(id: Long) {
val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val currentUser = 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) val gameRequest = gameRequestRepository.findById(id)
.orElseThrow { NoSuchElementException("No game request found with id $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") throw IllegalStateException("You cannot vote for your own request")
} }
@@ -97,25 +101,24 @@ class GameRequestService(
gameRequestRepository.save(gameRequest) gameRequestRepository.save(gameRequest)
} }
fun completeMatchingRequests(game: Game) { @Async
@EventListener(GameCreatedEvent::class)
fun completeMatchingRequests(gameCreatedEvent: GameCreatedEvent) {
val game = gameCreatedEvent.game
val gameTitle = game.title val gameTitle = game.title
val gameRelease = game.release val gameRelease = game.release
if (gameTitle == null || gameRelease == null) { if (gameTitle == null) {
log.debug { "Game '${game.id}' is missing title and/or release date, cannot complete matching requests" } log.debug { "Game '${game.id}' is missing title, cannot complete matching requests" }
return return
} }
// First match by exact title and release date, if not result could be found then by title and release year only val matchingRequests = gameRequestRepository.findOpenRequestsByTitleAndReleaseYear(
val matchingRequestsByExactRelease = gameRequestRepository.findByTitleAndRelease(gameTitle, gameRelease) gameTitle,
val matchingRequestsByReleaseYear = matchingRequestsByExactRelease.ifEmpty { gameRelease
gameRequestRepository.findByTitleAndReleaseYear( )
gameTitle,
gameRelease
)
}
matchingRequestsByReleaseYear.forEach { request -> matchingRequests.forEach { request ->
request.status = GameRequestStatus.FULFILLED request.status = GameRequestStatus.FULFILLED
request.linkedGameId = game.id request.linkedGameId = game.id
val persistedRequest = gameRequestRepository.save(request) val persistedRequest = gameRequestRepository.save(request)
@@ -5,6 +5,5 @@ import java.time.Instant
class GameRequestCreationDto( class GameRequestCreationDto(
val title: String, val title: String,
val release: Instant, val release: Instant?
val externalProviderIds: Map<String, String>
) )
@@ -1,6 +1,5 @@
package org.gameyfin.app.requests.dto package org.gameyfin.app.requests.dto
import org.gameyfin.app.requests.entities.ExternalProviderIds
import org.gameyfin.app.requests.status.GameRequestStatus import org.gameyfin.app.requests.status.GameRequestStatus
import org.gameyfin.app.users.dto.UserInfoDto import org.gameyfin.app.users.dto.UserInfoDto
import java.time.Instant import java.time.Instant
@@ -8,8 +7,7 @@ import java.time.Instant
class GameRequestDto( class GameRequestDto(
val id: Long, val id: Long,
val title: String, val title: String,
val release: Instant, val release: Instant?,
val externalProviderIds: ExternalProviderIds,
val status: GameRequestStatus, val status: GameRequestStatus,
val requester: UserInfoDto?, val requester: UserInfoDto?,
val voters: List<UserInfoDto>, val voters: List<UserInfoDto>,
@@ -8,8 +8,6 @@ import org.hibernate.annotations.UpdateTimestamp
import org.springframework.data.jpa.domain.support.AuditingEntityListener import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.Instant import java.time.Instant
typealias ExternalProviderIds = Map<String, String>
@Entity @Entity
@EntityListeners(GameRequestEntityListener::class, AuditingEntityListener::class) @EntityListeners(GameRequestEntityListener::class, AuditingEntityListener::class)
class GameRequest( class GameRequest(
@@ -21,18 +19,16 @@ class GameRequest(
val title: String, val title: String,
@Column(nullable = false) @Column(nullable = false)
val release: Instant, val release: Instant?,
@ElementCollection
val externalProviderIds: ExternalProviderIds,
@Column(nullable = false) @Column(nullable = false)
@Enumerated(EnumType.STRING)
var status: GameRequestStatus, var status: GameRequestStatus,
@ManyToOne(fetch = FetchType.EAGER) @ManyToOne(fetch = FetchType.EAGER, cascade = [CascadeType.ALL])
var requester: User? = null, var requester: User,
@OneToMany @OneToMany(fetch = FetchType.EAGER, cascade = [CascadeType.ALL], orphanRemoval = true)
var voters: MutableList<User> = mutableListOf(), var voters: MutableList<User> = mutableListOf(),
var linkedGameId: Long? = null, var linkedGameId: Long? = null,
@@ -9,9 +9,8 @@ fun GameRequest.toDto(): GameRequestDto {
id = this.id!!, id = this.id!!,
title = this.title, title = this.title,
release = this.release, release = this.release,
externalProviderIds = this.externalProviderIds,
status = this.status, status = this.status,
requester = this.requester?.toUserInfoDto(), requester = this.requester.toUserInfoDto(),
voters = this.voters.map { it.toUserInfoDto() }, voters = this.voters.map { it.toUserInfoDto() },
createdAt = this.createdAt, createdAt = this.createdAt,
updatedAt = this.updatedAt updatedAt = this.updatedAt
@@ -12,7 +12,7 @@ class SessionService(private val sessionRegistry: SessionRegistry) {
fun logoutAllSessions() { fun logoutAllSessions() {
val auth = getCurrentAuth() val auth = getCurrentAuth()
val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth.principal, false) val sessions: List<SessionInformation> = sessionRegistry.getAllSessions(auth?.principal, false)
for (sessionInfo in sessions) { for (sessionInfo in sessions) {
sessionInfo.expireNow() sessionInfo.expireNow()
} }
@@ -20,13 +20,13 @@ class UserEndpoint(
@AnonymousAllowed @AnonymousAllowed
fun getUserInfo(): ExtendedUserInfoDto? { fun getUserInfo(): ExtendedUserInfoDto? {
val auth = getCurrentAuth() val auth = getCurrentAuth()
if (!auth.isAuthenticated || auth.principal == "anonymousUser") return null if (auth?.isAuthenticated == false || auth?.principal == "anonymousUser") return null
return userService.getUserInfo() return userService.getUserInfo()
} }
@PermitAll @PermitAll
fun updateUser(updates: UserUpdateDto) { fun updateUser(updates: UserUpdateDto) {
val auth: Authentication = getCurrentAuth() val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.updateUser(auth.name, updates) userService.updateUser(auth.name, updates)
} }
@@ -52,7 +52,7 @@ class UserEndpoint(
@PermitAll @PermitAll
fun deleteUser() { fun deleteUser() {
val auth: Authentication = getCurrentAuth() val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.deleteUser(auth.name) userService.deleteUser(auth.name)
} }
@@ -68,7 +68,7 @@ class UserEndpoint(
@RolesAllowed(Role.Names.ADMIN) @RolesAllowed(Role.Names.ADMIN)
fun getRolesBelow(): List<String> { fun getRolesBelow(): List<String> {
val auth: Authentication = getCurrentAuth() val auth: Authentication = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
return roleService.getRolesBelowAuth(auth).map { it.roleName } return roleService.getRolesBelowAuth(auth).map { it.roleName }
} }
@@ -94,7 +94,7 @@ class UserService(
} }
fun getUserInfo(): ExtendedUserInfoDto { fun getUserInfo(): ExtendedUserInfoDto {
val auth = getCurrentAuth() val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val principal = auth.principal val principal = auth.principal
if (principal is OidcUser) { if (principal is OidcUser) {
@@ -238,7 +238,7 @@ class UserService(
return RoleAssignmentResult.NO_ROLES_PROVIDED return RoleAssignmentResult.NO_ROLES_PROVIDED
} }
val currentUser = getCurrentAuth() val currentUser = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val targetUser = getByUsernameNonNull(username) val targetUser = getByUsernameNonNull(username)
if (!canManage(targetUser)) { if (!canManage(targetUser)) {
@@ -266,7 +266,7 @@ class UserService(
} }
fun canManage(targetUser: org.gameyfin.app.users.entities.User): Boolean { 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 currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
return currentUserLevel > targetUserLevel return currentUserLevel > targetUserLevel
@@ -19,7 +19,7 @@ class EmailConfirmationEndpoint(
@PermitAll @PermitAll
fun resendEmailConfirmation() { fun resendEmailConfirmation() {
val auth = getCurrentAuth() val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
userService.getByUsername(auth.name)?.let { userService.getByUsername(auth.name)?.let {
emailConfirmationService.resendEmailConfirmation(it) emailConfirmationService.resendEmailConfirmation(it)
} }
@@ -135,7 +135,7 @@ class UserPreferencesService(
} }
private fun id(key: String): UserPreferenceKey { private fun id(key: String): UserPreferenceKey {
val auth = getCurrentAuth() val auth = getCurrentAuth() ?: throw IllegalStateException("No authentication found")
val user = userService.getByUsernameNonNull(auth.name) val user = userService.getByUsernameNonNull(auth.name)
return UserPreferenceKey(key, user.id!!) return UserPreferenceKey(key, user.id!!)
} }
@@ -29,7 +29,7 @@ class InvitationService(
if (userService.existsByEmail(email)) if (userService.existsByEmail(email))
throw IllegalStateException("User with email ${Utils.Companion.maskEmail(email)} is already registered") 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 user = userService.getByUsername(auth.name) ?: throw IllegalStateException("User not found")
val payload = mapOf(EMAIL_KEY to email) val payload = mapOf(EMAIL_KEY to email)
val token = super.generateWithPayload(user, payload) val token = super.generateWithPayload(user, payload)