From 0ab2ce5423d3d5632026a6b64d71d57811da52ac Mon Sep 17 00:00:00 2001 From: Lukas Lanzner Date: Tue, 10 Dec 2024 10:49:49 +0100 Subject: [PATCH] feat(game): Add get games endpoint --- DB/game.sql | 122 +++++++++++++++++++++--- server/api/v1/game/index.get.ts | 30 ++++++ server/utils/mapper/game-mapper.ts | 145 +++++++++++++++++++++++++++++ types/api/game.ts | 22 ++++- types/api/users.ts | 7 +- 5 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 server/api/v1/game/index.get.ts create mode 100644 server/utils/mapper/game-mapper.ts diff --git a/DB/game.sql b/DB/game.sql index f30114a..ec34e92 100644 --- a/DB/game.sql +++ b/DB/game.sql @@ -97,12 +97,18 @@ CREATE OR REPLACE FUNCTION init_game( playlist_id_input TEXT, player_ids UUID[], song_data game_song_input[] -) RETURNS TABLE(game_id INTEGER, created_at TIMESTAMP WITH TIME ZONE) AS +) + RETURNS TABLE + ( + game_id INTEGER, + created_at TIMESTAMP WITH TIME ZONE + ) +AS $$ DECLARE - new_game_id INTEGER; + new_game_id INTEGER; new_created_at TIMESTAMP WITH TIME ZONE; - player_id UUID; + player_id UUID; BEGIN -- Create game INSERT INTO games (playlist_id, status) @@ -118,7 +124,8 @@ BEGIN UPDATE game_players SET is_creator = true - WHERE game_players.game_id = new_game_id AND game_players.user_id = player_ids[1]; + WHERE game_players.game_id = new_game_id + AND game_players.user_id = player_ids[1]; -- Insert songs (assuming the songs already exist in the songs table) INSERT INTO game_rounds (game_id, @@ -127,17 +134,110 @@ BEGIN wrong_option_1, wrong_option_2, wrong_option_3) - SELECT - new_game_id, - row_number() OVER (ORDER BY song.spotify_song_id), -- Added ORDER BY - song.spotify_song_id, - song.wrong_option_1, - song.wrong_option_2, - song.wrong_option_3 + SELECT new_game_id, + row_number() OVER (ORDER BY song.spotify_song_id), -- Added ORDER BY + song.spotify_song_id, + song.wrong_option_1, + song.wrong_option_2, + song.wrong_option_3 FROM unnest(song_data) song; RETURN QUERY SELECT new_game_id AS game_id, new_created_at AS created_at; END; +$$ LANGUAGE plpgsql; + +-- Get all games for a user +CREATE OR REPLACE FUNCTION get_player_games(p_user_id UUID) + RETURNS TABLE + ( + -- Game basic info + game_id INTEGER, + status game_status, + playlist_id TEXT, + playlist_cover TEXT, + playlist_name TEXT, + created_at TIMESTAMPTZ, + creator_id UUID, + -- Opponent info (renamed from player_ to opponent_) + opponent_user_id UUID, + opponent_avatar_url TEXT, + opponent_username TEXT, + opponent_spotify_id TEXT, + opponent_spotify_visibility BOOLEAN, + opponent_daily_streak INTEGER, + opponent_daily_streak_updated_at TIMESTAMPTZ, + -- Round info + round_number INTEGER, + correct_song_id TEXT, + correct_song_name TEXT, + correct_song_artist TEXT, + correct_song_preview_url TEXT, + wrong_song_1_id TEXT, + wrong_song_1_name TEXT, + wrong_song_1_artist TEXT, + wrong_song_1_preview_url TEXT, + wrong_song_2_id TEXT, + wrong_song_2_name TEXT, + wrong_song_2_artist TEXT, + wrong_song_2_preview_url TEXT, + wrong_song_3_id TEXT, + wrong_song_3_name TEXT, + wrong_song_3_artist TEXT, + wrong_song_3_preview_url TEXT + ) +AS +$$ +BEGIN + RETURN QUERY + SELECT g.game_id, + g.status, + g.playlist_id, + p.cover, + p.name, + g.created_at, + gp_creator.user_id, + + -- Opponent information + u.id, + u.avatar_url, + u.username, + u.spotify_id, + u.spotify_visibility, + u.daily_streak, + u.daily_streak_updated_at, + -- Round information + gr.song_order, + cs.spotify_song_id, + cs.song_name, + cs.artist_name, + cs.preview_url, + w1.spotify_song_id, + w1.song_name, + w1.artist_name, + w1.preview_url, + w2.spotify_song_id, + w2.song_name, + w2.artist_name, + w2.preview_url, + w3.spotify_song_id, + w3.song_name, + w3.artist_name, + w3.preview_url + FROM games g + -- Join to get current user's games + JOIN game_players gp_current ON g.game_id = gp_current.game_id AND gp_current.user_id = p_user_id + -- Join to get opponent's info + JOIN game_players gp_opponent ON g.game_id = gp_opponent.game_id AND gp_opponent.user_id != p_user_id + LEFT JOIN game_players gp_creator ON g.game_id = gp_creator.game_id AND gp_creator.is_creator = true + JOIN users u ON gp_opponent.user_id = u.id + JOIN game_rounds gr ON g.game_id = gr.game_id + JOIN songs cs ON gr.song_id = cs.spotify_song_id + JOIN songs w1 ON gr.wrong_option_1 = w1.spotify_song_id + JOIN songs w2 ON gr.wrong_option_2 = w2.spotify_song_id + JOIN songs w3 ON gr.wrong_option_3 = w3.spotify_song_id + JOIN playlists p ON g.playlist_id = p.id + ORDER BY g.created_at DESC, gr.song_order ASC; +END; $$ LANGUAGE plpgsql; \ No newline at end of file diff --git a/server/api/v1/game/index.get.ts b/server/api/v1/game/index.get.ts new file mode 100644 index 0000000..e482ad7 --- /dev/null +++ b/server/api/v1/game/index.get.ts @@ -0,0 +1,30 @@ +import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server"; +import {mapDatabaseRowsToGames} from "~/server/utils/mapper/game-mapper"; +import {GameStatus} from "~/types/api/game"; + +export default defineEventHandler(async (event) => { + // Require user to be authenticated + const user = await serverSupabaseUser(event); + if (!user?.id) { + setResponseStatus(event, 401); + return {error: 'unauthenticated'}; + } + + // Get user's games + const client = serverSupabaseServiceRole(event); + const {data, error} = await client.rpc('get_player_games', {p_user_id: user.id} as never).select(); + + if (error) { + setResponseStatus(event, 500); + return {error: error.message}; + } + + const games = mapDatabaseRowsToGames(data); + + return { + active: games.filter(game => game.status === GameStatus.PLAYING && game.creator_id !== user.id), + waiting: games.filter(game => game.status === GameStatus.PLAYING && game.creator_id === user.id), + past: games.filter(game => game.status === GameStatus.FINISHED) + } + +}); \ No newline at end of file diff --git a/server/utils/mapper/game-mapper.ts b/server/utils/mapper/game-mapper.ts new file mode 100644 index 0000000..390f06c --- /dev/null +++ b/server/utils/mapper/game-mapper.ts @@ -0,0 +1,145 @@ +// Types from your original interfaces + +import type {Game, GameRound, Song} from "~/types/api/game"; +import type {GetUserResponse} from "~/types/api/users"; +import {GameStatus} from "~/types/api/game"; + +interface DatabaseGameRow { + // Game basic info + game_id: number; + status: string; + playlist_id: string; + playlist_cover: string; + playlist_name: string; + created_at: string; + creator_id: string; // who created the game and thus already started playing + + // Opponent info (the other player's data) + opponent_user_id: string; + opponent_avatar_url: string | null; + opponent_username: string; + opponent_spotify_id: string | null; + opponent_spotify_visibility: boolean; + opponent_daily_streak: number | null; + opponent_daily_streak_updated_at: string | null; + + // Round info (the song being played) + round_number: number; // From game_rounds table - the order of the song + correct_song_id: string; + correct_song_name: string; + correct_song_artist: string; + correct_song_preview_url: string | null; + + // Wrong options (the 3 incorrect choices) + wrong_song_1_id: string; + wrong_song_1_name: string; + wrong_song_1_artist: string; + wrong_song_1_preview_url: string | null; + + wrong_song_2_id: string; // Similar to wrong_song_1 + wrong_song_2_name: string; + wrong_song_2_artist: string; + wrong_song_2_preview_url: string | null; + + wrong_song_3_id: string; // Similar to wrong_song_1 + wrong_song_3_name: string; + wrong_song_3_artist: string; + wrong_song_3_preview_url: string | null; +} + +const createSongFromRow = ( + id: string, + name: string, + artist: string, + previewUrl: string | null +): Song => ({ + id, + name, + artists: [{ + id: 'placeholder', // You might want to adjust this based on your needs + name: artist + }], + preview_url: previewUrl, + is_playable: previewUrl !== null +}); + + +export const mapDatabaseRowsToGame = (rows: DatabaseGameRow[]): Game => { + if (rows.length === 0) { + throw new Error('No rows provided to map to Game'); + } + + const firstRow = rows[0]; + + // Map opponent info + const opponent: GetUserResponse = { + id: firstRow.opponent_user_id, + avatar_url: firstRow.opponent_avatar_url ?? undefined, + username: firstRow.opponent_username, + spotify_id: firstRow.opponent_spotify_id ?? undefined, + spotify_visibility: firstRow.opponent_spotify_visibility, + daily_streak: firstRow.opponent_daily_streak ?? undefined, + daily_streak_updated_at: firstRow.opponent_daily_streak_updated_at ?? undefined + }; + + // Map rounds + const rounds: GameRound[] = rows.map(row => ({ + round: row.round_number, + correct_song: createSongFromRow( + row.correct_song_id, + row.correct_song_name, + row.correct_song_artist, + row.correct_song_preview_url + ), + wrong_songs: [ + createSongFromRow( + row.wrong_song_1_id, + row.wrong_song_1_name, + row.wrong_song_1_artist, + row.wrong_song_1_preview_url + ), + createSongFromRow( + row.wrong_song_2_id, + row.wrong_song_2_name, + row.wrong_song_2_artist, + row.wrong_song_2_preview_url + ), + createSongFromRow( + row.wrong_song_3_id, + row.wrong_song_3_name, + row.wrong_song_3_artist, + row.wrong_song_3_preview_url + ) + ] + })); + + // Construct final game object with players instead of player_ids + return { + game_id: firstRow.game_id, + status: firstRow.status as GameStatus, + creator_id: firstRow.creator_id, + playlist: { + id: firstRow.playlist_id, + name: firstRow.playlist_name, + cover: firstRow.playlist_cover + }, + opponents: [opponent], + songs: rounds, + created_at: firstRow.created_at + }; +}; + +export const mapDatabaseRowsToGames = (rows: DatabaseGameRow[]): Game[] => { + if (rows.length === 0) return []; + + // Group rows by game_id + const gameRows = new Map(); + rows.forEach(row => { + const currentRows = gameRows.get(row.game_id) ?? []; + gameRows.set(row.game_id, [...currentRows, row]); + }); + + // Map each group of rows to a Game + return Array.from(gameRows.values()) + .map(gameRows => mapDatabaseRowsToGame(gameRows)); +}; \ No newline at end of file diff --git a/types/api/game.ts b/types/api/game.ts index b858ffe..71539bd 100644 --- a/types/api/game.ts +++ b/types/api/game.ts @@ -1,3 +1,5 @@ +import type {GetUserResponse} from "@/types/api/users"; + export interface SpotifySong { track: Song; } @@ -23,13 +25,24 @@ export interface GameRound { export interface Game { game_id: number; - playlist_id: string; - player_ids: string[]; + status: GameStatus; + creator_id: string; // who created the game and thus already played its turn + playlist: { + id: string; + name: string; + cover?: string; + }; + opponents: GetUserResponse[]; songs: GameRound[]; created_at: string; stats?: GameStats[]; } +export enum GameStatus { + PLAYING = 'playing', + FINISHED = 'finished' +} + export interface GameStats { user_id: string; score: number; @@ -55,4 +68,9 @@ export interface GameInitResponse { export interface GameInitRequest { playlist_id: string; opponent_id: string; +} + +export interface GetGameResponse { + active: Game[]; + past: Game[]; } \ No newline at end of file diff --git a/types/api/users.ts b/types/api/users.ts index 44f7eda..7931ecd 100644 --- a/types/api/users.ts +++ b/types/api/users.ts @@ -1,11 +1,10 @@ -import type url from "node:url"; export interface GetUserResponse { id: string, - avatar_url?: url.URL | null, + avatar_url?: string, username: string, spotify_id?: string, spotify_visibility: boolean, - daily_streak: number, - daily_streak_updated_at: string + daily_streak?: number, + daily_streak_updated_at?: string } \ No newline at end of file