diff --git a/.env.example b/.env.example
index a303c20..f50895e 100644
--- a/.env.example
+++ b/.env.example
@@ -1,2 +1,5 @@
SUPABASE_URL="https://yourhost.supabase.co"
SUPABASE_KEY="your-secret-key"
+SUPABASE_SERVICE_KEY="your-service-key"
+SPOTIFY_CLIENT_ID="your-client-id"
+SPOTIFY_CLIENT_SECRET="your-client-secret"
\ No newline at end of file
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..4f409d0
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,53 @@
+name: Build and Push Docker Image
+
+on:
+ push:
+ branches: [ "main" ]
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository_owner }}/beat-buzzer
+
+jobs:
+ build-and-push:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Log in to the Container registry
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=sha,format=short
+ type=ref,event=branch
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=semver,pattern={{major}}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v5
+ with:
+ context: .
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
\ No newline at end of file
diff --git a/.run/dev -- -o.run.xml b/.run/dev -- -o.run.xml
deleted file mode 100644
index e299611..0000000
--- a/.run/dev -- -o.run.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/DB/friendships.sql b/DB/friendships.sql
new file mode 100644
index 0000000..3e80df6
--- /dev/null
+++ b/DB/friendships.sql
@@ -0,0 +1,197 @@
+DO
+$$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'friendship_status') THEN
+ CREATE TYPE friendship_status AS ENUM ('pending', 'accepted', 'declined');
+ END IF;
+ END
+$$;
+
+CREATE TABLE IF NOT EXISTS friendships
+(
+ friendship_id SERIAL PRIMARY KEY,
+ user1_id UUID NOT NULL,
+ user2_id UUID NOT NULL,
+ status friendship_status NOT NULL,
+ action_user_id UUID NOT NULL, -- The user who performed the last action
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+ updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
+
+ -- Ensure user1_id is always less than user2_id to prevent duplicate friendships
+ CONSTRAINT ensure_user_order CHECK (user1_id < user2_id),
+ CONSTRAINT unique_friendship UNIQUE (user1_id, user2_id),
+
+ -- Foreign keys
+ CONSTRAINT fk_user1 FOREIGN KEY (user1_id) REFERENCES users (id) ON DELETE CASCADE,
+ CONSTRAINT fk_user2 FOREIGN KEY (user2_id) REFERENCES users (id) ON DELETE CASCADE,
+ CONSTRAINT fk_action_user FOREIGN KEY (action_user_id) REFERENCES users (id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_friendship_user1 ON friendships (user1_id, status);
+CREATE INDEX IF NOT EXISTS idx_friendship_user2 ON friendships (user2_id, status);
+
+-- Functions
+
+-- automatically update updated_at timestamp
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+ RETURNS TRIGGER AS
+$$
+BEGIN
+ NEW.updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE TRIGGER update_friendships_timestamp
+ BEFORE UPDATE
+ ON friendships
+ FOR EACH ROW
+EXECUTE FUNCTION update_updated_at_column();
+
+-- Send a friend request
+CREATE OR REPLACE FUNCTION send_friend_request(sender_id UUID, receiver_id UUID) RETURNS void AS
+$$
+DECLARE
+ smaller_id UUID;
+ larger_id UUID;
+BEGIN
+ -- Determine order of IDs
+ IF sender_id < receiver_id THEN
+ smaller_id := sender_id;
+ larger_id := receiver_id;
+ ELSE
+ smaller_id := receiver_id;
+ larger_id := sender_id;
+ END IF;
+
+ -- Insert friendship record
+ INSERT INTO friendships (user1_id, user2_id, status, action_user_id)
+ VALUES (smaller_id, larger_id, 'pending', sender_id)
+ ON CONFLICT (user1_id, user2_id) DO UPDATE
+ SET status = CASE
+ WHEN friendships.status = 'declined' THEN 'pending'::friendship_status
+ ELSE friendships.status
+ END,
+ action_user_id = sender_id;
+END;
+$$ LANGUAGE plpgsql;
+
+-- accept friendship request
+CREATE OR REPLACE FUNCTION accept_friend_request_by_id(friendship_id_param INT)
+ RETURNS void AS
+$$
+BEGIN
+ UPDATE friendships
+ SET status = 'accepted'
+ WHERE friendship_id = friendship_id_param
+ AND status = 'pending';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'No pending friend request found with this ID';
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+
+-- decline friendship request
+CREATE OR REPLACE FUNCTION decline_friend_request_by_id(friendship_id_param INT)
+ RETURNS void AS
+$$
+BEGIN
+ UPDATE friendships
+ SET status = 'declined'
+ WHERE friendship_id = friendship_id_param
+ AND status = 'pending';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'No pending friend request found with this ID';
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- delete friendship
+CREATE OR REPLACE FUNCTION remove_friend(user_id UUID, friend_id UUID) RETURNS void AS
+$$
+DECLARE
+ smaller_id UUID;
+ larger_id UUID;
+BEGIN
+ -- Determine order of IDs
+ IF friend_id < user_id THEN
+ smaller_id := friend_id;
+ larger_id := user_id;
+ ELSE
+ smaller_id := user_id;
+ larger_id := friend_id;
+ END IF;
+
+ DELETE
+ FROM friendships
+ WHERE user1_id = smaller_id
+ AND user2_id = larger_id
+ AND status = 'accepted';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'No active friendship found between these users';
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- 2nd version with id
+CREATE OR REPLACE FUNCTION remove_friend_by_id(friendship_id_param INT) RETURNS void AS
+$$
+BEGIN
+ DELETE
+ FROM friendships
+ WHERE friendship_id = friendship_id_param
+ AND status = 'accepted';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'No active friendship found with this ID';
+ END IF;
+END;
+$$ LANGUAGE plpgsql;
+
+-- retrieve all friends, incoming and outgoing friend requests
+CREATE OR REPLACE FUNCTION get_friends(user_id UUID)
+ RETURNS TABLE
+ (
+ friendship_id INT,
+ friend_id UUID,
+ friend_username TEXT,
+ friend_avatar TEXT,
+ friend_spotify_id TEXT,
+ friend_spotify_visibility BOOLEAN,
+ status friendship_status,
+ action_user_id UUID,
+ created_at TIMESTAMP WITH TIME ZONE,
+ updated_at TIMESTAMP WITH TIME ZONE,
+ request_type TEXT
+ )
+AS
+$$
+BEGIN
+ RETURN QUERY
+ SELECT f.friendship_id,
+ CASE WHEN f.user1_id = user_id THEN f.user2_id ELSE f.user1_id END AS friend_id,
+ u.username AS friend_username,
+ u.avatar_url AS friend_avatar,
+ u.spotify_id AS friend_spotify_id,
+ u.spotify_visibility AS friend_spotify_visibility,
+ f.status,
+ f.action_user_id,
+ f.created_at,
+ f.updated_at,
+ CASE WHEN f.action_user_id = user_id THEN 'outgoing' ELSE 'incoming' END AS request_type
+ FROM friendships f, users u
+ WHERE (f.user1_id = user_id OR f.user2_id = user_id)
+ AND (f.status != 'declined')
+ AND (CASE WHEN f.user1_id = user_id THEN f.user2_id ELSE f.user1_id END = u.id);
+END;
+$$ LANGUAGE plpgsql;
+-- examples
+-- SELECT send_friend_request('sender', 'receiver');
+-- SELECT accept_friend_request_by_id(4);
+-- SELECT decline_friend_request_by_id(4);
+-- SELECT remove_friend('friend_user');
+-- SELECT * FROM get_friends('user_id');
diff --git a/DB/game.sql b/DB/game.sql
new file mode 100644
index 0000000..712f557
--- /dev/null
+++ b/DB/game.sql
@@ -0,0 +1,448 @@
+DO
+$$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'game_status') THEN
+ CREATE TYPE game_status AS ENUM ('playing', 'finished');
+ END IF;
+ END
+$$;
+
+DO
+$$
+ BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'game_song_input') THEN
+ CREATE TYPE game_song_input AS
+ (
+ spotify_song_id TEXT,
+ wrong_option_1 TEXT,
+ wrong_option_2 TEXT,
+ wrong_option_3 TEXT
+ );
+ END IF;
+ END
+$$;
+
+-- Games
+CREATE TABLE IF NOT EXISTS games
+(
+ game_id SERIAL PRIMARY KEY,
+ playlist_id TEXT NOT NULL REFERENCES playlists (id) ON DELETE CASCADE,
+ status game_status NOT NULL DEFAULT 'playing',
+ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
+);
+
+-- Players of each game and their scores
+CREATE TABLE IF NOT EXISTS game_players
+(
+ game_id INTEGER REFERENCES games (game_id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users (id) ON DELETE SET DEFAULT DEFAULT '00000000-0000-0000-0000-000000000000',
+ score INTEGER NOT NULL DEFAULT 0,
+ is_creator BOOLEAN NOT NULL DEFAULT false,
+ PRIMARY KEY (game_id, user_id)
+);
+-- Index for ensuring only one creator per game or none
+CREATE UNIQUE INDEX one_creator_per_game ON game_players (game_id) WHERE is_creator = true;
+-- Index for finding players in a game
+CREATE INDEX idx_game_players_player_id ON game_players (user_id);
+-- Index for finding high scores by user
+CREATE INDEX idx_game_players_score ON game_players (user_id, score DESC);
+
+-- Songs
+-- mostly for reference after the game is over since we always fetch songs from Spotify
+CREATE TABLE IF NOT EXISTS songs
+(
+ spotify_song_id TEXT PRIMARY KEY,
+ song_name TEXT NOT NULL,
+ artist_name TEXT NOT NULL,
+ preview_url TEXT
+);
+
+-- Songs in each game
+CREATE TABLE IF NOT EXISTS game_rounds
+(
+ game_id INTEGER REFERENCES games (game_id) ON DELETE CASCADE,
+ song_order INTEGER NOT NULL,
+ song_id TEXT NOT NULL,
+ wrong_option_1 TEXT NOT NULL,
+ wrong_option_2 TEXT NOT NULL,
+ wrong_option_3 TEXT NOT NULL,
+ PRIMARY KEY (game_id, song_order),
+
+ CONSTRAINT fk_spotify_song_id FOREIGN KEY (song_id) REFERENCES songs (spotify_song_id),
+ CONSTRAINT fk_wrong_option_1 FOREIGN KEY (wrong_option_1) REFERENCES songs (spotify_song_id),
+ CONSTRAINT fk_wrong_option_2 FOREIGN KEY (wrong_option_2) REFERENCES songs (spotify_song_id),
+ CONSTRAINT fk_wrong_option_3 FOREIGN KEY (wrong_option_3) REFERENCES songs (spotify_song_id)
+);
+
+-- Statistics for each song of each game for each user
+CREATE TABLE IF NOT EXISTS game_player_song_stats
+(
+ game_id INTEGER REFERENCES games (game_id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users (id) ON DELETE SET DEFAULT DEFAULT '00000000-0000-0000-0000-000000000000',
+ song_order INTEGER NOT NULL,
+ time_to_guess NUMERIC,
+ correct_guess BOOLEAN NOT NULL DEFAULT false,
+ guessed_song_id TEXT,
+ PRIMARY KEY (game_id, user_id, song_order),
+ FOREIGN KEY (game_id, song_order) REFERENCES game_rounds (game_id, song_order)
+);
+
+CREATE INDEX idx_game_player_song_stats_game_user ON game_player_song_stats (game_id, user_id);
+
+--- Functions
+
+-- Initialize a game with a playlist and players
+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
+$$
+DECLARE
+ new_game_id INTEGER;
+ new_created_at TIMESTAMP WITH TIME ZONE;
+ player_id UUID;
+BEGIN
+ -- Create game
+ INSERT INTO games (playlist_id, status)
+ VALUES (playlist_id_input, 'playing')
+ RETURNING games.game_id, games.created_at INTO new_game_id, new_created_at;
+
+ -- Add all players from array
+ FOREACH player_id IN ARRAY player_ids
+ LOOP
+ INSERT INTO game_players (game_id, user_id, score)
+ VALUES (new_game_id, player_id, 0);
+ END LOOP;
+
+ UPDATE game_players
+ SET is_creator = true
+ WHERE game_players.game_id = new_game_id
+ AND game_players.user_id = player_ids[1];
+
+ -- Insert songs with preserved order
+ INSERT INTO game_rounds (game_id,
+ song_order,
+ song_id,
+ wrong_option_1,
+ wrong_option_2,
+ wrong_option_3)
+ SELECT new_game_id,
+ ordinality, -- This preserves the array order
+ song.spotify_song_id,
+ song.wrong_option_1,
+ song.wrong_option_2,
+ song.wrong_option_3
+ FROM unnest(song_data) WITH ORDINALITY as song;
+
+ -- Return the new game info
+ RETURN QUERY
+ SELECT new_game_id, new_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,
+
+ -- Player info
+ player_user_id UUID,
+ player_avatar_url TEXT,
+ player_username TEXT,
+ player_spotify_id TEXT,
+ player_spotify_visibility BOOLEAN,
+ player_daily_streak INTEGER,
+ player_daily_streak_updated_at TIMESTAMPTZ,
+ player_score INTEGER,
+
+ -- 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,
+
+ -- Round stats (directly associated with player)
+ time_to_guess NUMERIC,
+ correct_guess BOOLEAN,
+ guessed_song_id TEXT,
+ guessed_song_name TEXT,
+ guessed_song_artist TEXT,
+ guessed_song_preview_url TEXT
+ )
+AS
+$$
+BEGIN
+ RETURN QUERY
+ SELECT DISTINCT ON (g.game_id, gr.song_order, all_players.user_id)
+ g.game_id,
+ g.status,
+ g.playlist_id,
+ p.cover,
+ p.name,
+ g.created_at,
+ gp_creator.user_id,
+ -- Player information
+ all_players.user_id,
+ u.avatar_url,
+ u.username,
+ u.spotify_id,
+ u.spotify_visibility,
+ u.daily_streak,
+ u.daily_streak_updated_at,
+ gp_stats.score,
+ -- 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,
+ -- Stats information (associated with player)
+ gpss.time_to_guess,
+ gpss.correct_guess,
+ gs.spotify_song_id,
+ gs.song_name,
+ gs.artist_name,
+ gs.preview_url
+ FROM games g
+ -- Get current player's games
+ JOIN game_players gp_current ON g.game_id = gp_current.game_id
+ AND gp_current.user_id = p_user_id
+ -- Get all players in the game
+ JOIN game_players all_players ON g.game_id = all_players.game_id
+ -- Get user info for all players
+ JOIN users u ON all_players.user_id = u.id
+ -- Get creator info
+ LEFT JOIN game_players gp_creator ON g.game_id = gp_creator.game_id
+ AND gp_creator.is_creator = true
+ -- Get rounds info
+ 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
+ -- Get stats for all players
+ LEFT JOIN game_players gp_stats ON g.game_id = gp_stats.game_id
+ AND gp_stats.user_id = all_players.user_id
+ LEFT JOIN game_player_song_stats gpss ON g.game_id = gpss.game_id
+ AND gpss.song_order = gr.song_order
+ AND gpss.user_id = all_players.user_id
+ LEFT JOIN songs gs ON gpss.guessed_song_id = gs.spotify_song_id
+ ORDER BY g.game_id, gr.song_order, all_players.user_id, g.created_at DESC;
+END;
+$$ LANGUAGE plpgsql;
+
+-- Get a game by its ID
+-- Returns multiple rows, one for each player in the game
+CREATE OR REPLACE FUNCTION get_game(p_game_id INTEGER)
+ RETURNS TABLE
+ (
+ -- Game info
+ game_id INTEGER,
+ status game_status,
+ playlist_id TEXT,
+ created_at TIMESTAMPTZ,
+ -- Players array
+ player_id UUID,
+ is_creator BOOLEAN,
+ has_played BOOLEAN,
+ -- Round info
+ round_number INTEGER,
+ correct_song_id TEXT
+ )
+AS
+$$
+BEGIN
+ RETURN QUERY
+ SELECT g.game_id,
+ g.status,
+ g.playlist_id,
+ g.created_at,
+ gp.user_id,
+ gp.is_creator,
+ EXISTS(SELECT 1
+ FROM game_player_song_stats gpss
+ WHERE gpss.game_id = g.game_id
+ AND gpss.user_id = gp.user_id) AS has_played,
+ gr.song_order,
+ gr.song_id as correct_song_id
+ FROM games g
+ -- Join to get all players
+ JOIN game_players gp
+ ON g.game_id = gp.game_id
+ -- Join to get rounds
+ JOIN game_rounds gr
+ ON g.game_id = gr.game_id
+ WHERE g.game_id = p_game_id
+ ORDER BY gr.song_order;
+END;
+$$ LANGUAGE plpgsql;
+
+-- A player finished his rounds
+CREATE OR REPLACE FUNCTION game_handle_player_finish(
+ p_game_id INTEGER,
+ p_user_id UUID,
+ p_song_orders INTEGER[],
+ p_times_to_guess NUMERIC[],
+ p_correct_guesses BOOLEAN[],
+ p_guessed_song_ids TEXT[],
+ p_score INTEGER
+) RETURNS VOID AS
+$$
+DECLARE
+ i INTEGER;
+BEGIN
+ -- Insert all stats
+ FOR i IN 1..array_length(p_song_orders, 1)
+ LOOP
+ INSERT INTO game_player_song_stats (game_id,
+ user_id,
+ song_order,
+ time_to_guess,
+ correct_guess,
+ guessed_song_id)
+ VALUES (p_game_id,
+ p_user_id,
+ p_song_orders[i],
+ p_times_to_guess[i],
+ p_correct_guesses[i],
+ p_guessed_song_ids[i]);
+ END LOOP;
+
+ -- Update player's score with provided score
+ UPDATE game_players
+ SET score = p_score
+ WHERE game_id = p_game_id
+ AND user_id = p_user_id;
+
+ -- Check if all players have finished and update game status if needed
+ UPDATE games
+ SET status = 'finished'
+ WHERE game_id = p_game_id
+ AND NOT EXISTS (SELECT 1
+ FROM game_players gp
+ LEFT JOIN (SELECT DISTINCT user_id
+ FROM game_player_song_stats
+ WHERE game_id = p_game_id) gpss ON gp.user_id = gpss.user_id
+ WHERE gp.game_id = p_game_id
+ AND gpss.user_id IS NULL);
+END;
+$$ LANGUAGE plpgsql;
+
+-- Get a random opponent with recent activity
+CREATE OR REPLACE FUNCTION get_random_opponent(requesting_user_id UUID)
+ RETURNS UUID
+ LANGUAGE plpgsql
+AS $$
+DECLARE
+ found_user_id UUID;
+BEGIN
+ -- Try last 24 hours
+ WITH distinct_users AS (
+ SELECT DISTINCT gp.user_id
+ FROM games g
+ INNER JOIN game_player_song_stats gp ON g.game_id = gp.game_id
+ WHERE g.created_at > now() - INTERVAL '1 day'
+ AND gp.user_id != '00000000-0000-0000-0000-000000000000'
+ AND gp.user_id != requesting_user_id
+ )
+ SELECT user_id INTO found_user_id
+ FROM distinct_users
+ ORDER BY random()
+ LIMIT 1;
+
+ -- If not found, try last week
+ IF found_user_id IS NULL THEN
+ WITH distinct_users AS (
+ SELECT DISTINCT gp.user_id
+ FROM games g
+ INNER JOIN game_player_song_stats gp ON g.game_id = gp.game_id
+ WHERE g.created_at > now() - INTERVAL '7 days'
+ AND gp.user_id != '00000000-0000-0000-0000-000000000000'
+ AND gp.user_id != requesting_user_id
+ )
+ SELECT user_id INTO found_user_id
+ FROM distinct_users
+ ORDER BY random()
+ LIMIT 1;
+ END IF;
+
+ -- If still not found, try last month
+ IF found_user_id IS NULL THEN
+ WITH distinct_users AS (
+ SELECT DISTINCT gp.user_id
+ FROM games g
+ INNER JOIN game_player_song_stats gp ON g.game_id = gp.game_id
+ WHERE g.created_at > now() - INTERVAL '30 days'
+ AND gp.user_id != '00000000-0000-0000-0000-000000000000'
+ AND gp.user_id != requesting_user_id
+ )
+ SELECT user_id INTO found_user_id
+ FROM distinct_users
+ ORDER BY random()
+ LIMIT 1;
+ END IF;
+
+ -- If still not found, try all time
+ IF found_user_id IS NULL THEN
+ WITH distinct_users AS (
+ SELECT DISTINCT gp.user_id
+ FROM games g
+ INNER JOIN game_player_song_stats gp ON g.game_id = gp.game_id
+ WHERE gp.user_id != '00000000-0000-0000-0000-000000000000'
+ AND gp.user_id != requesting_user_id
+ )
+ SELECT user_id INTO found_user_id
+ FROM distinct_users
+ ORDER BY random()
+ LIMIT 1;
+ END IF;
+
+ -- If still no user found, raise exception
+ IF found_user_id IS NULL THEN
+ RAISE EXCEPTION 'No active users found';
+ END IF;
+
+ RETURN found_user_id;
+END;
+$$;
\ No newline at end of file
diff --git a/DB/playlists.sql b/DB/playlists.sql
new file mode 100644
index 0000000..031f240
--- /dev/null
+++ b/DB/playlists.sql
@@ -0,0 +1,17 @@
+create table playlists
+(
+ id text not null primary key,
+ "spotifyId" text not null,
+ name text not null,
+ cover text,
+ enabled boolean default false not null
+);
+
+CREATE UNIQUE INDEX enabled_playlist_unique_name ON playlists (name) WHERE enabled = true;
+
+create table categories
+(
+ name text not null,
+ "playlistId" text not null references playlists (id) on delete cascade,
+ primary key (name, "playlistId")
+);
diff --git a/DB/users.sql b/DB/users.sql
new file mode 100644
index 0000000..86cd03a
--- /dev/null
+++ b/DB/users.sql
@@ -0,0 +1,39 @@
+CREATE TABLE users
+(
+ id uuid not null references auth.users on delete cascade,
+ avatar_url text,
+ username text not null,
+ spotify_id text not null,
+ spotify_visibility boolean not null default false,
+ created_at timestamp with time zone default now(),
+ daily_streak integer not null default 0,
+ daily_streak_updated_at timestamp with time zone not null default now()-interval '1 day',
+ primary key (id)
+);
+
+ALTER TABLE users
+ ADD CONSTRAINT unique_username UNIQUE (username),
+ ADD CONSTRAINT valid_username check (username <> '' AND length(trim(username)) >= 4 AND
+ username ~ '^[a-zA-Z0-9_]+$');
+
+CREATE INDEX idx_username ON users (username);
+
+-- automatically update daily_streak_updated_at timestamp
+CREATE OR REPLACE FUNCTION user_update_streak_updated_at_column()
+ RETURNS TRIGGER AS
+$$
+BEGIN
+ NEW.daily_streak_updated_at = now();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE TRIGGER update_daily_streak_timestamp
+ BEFORE UPDATE
+ ON users
+ FOR EACH ROW
+EXECUTE FUNCTION user_update_streak_updated_at_column();
+
+--
+INSERT INTO users (id, username, spotify_id, spotify_visibility)
+VALUES ('00000000-0000-0000-0000-000000000000', 'delete_user', '', false);
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..fb8f9a2
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,38 @@
+# Build stage
+FROM node:20-alpine as builder
+
+WORKDIR /app
+
+# Install dependencies needed for node-gyp
+RUN apk add --no-cache python3 make g++
+
+# Copy package files first
+COPY package.json package-lock.json ./
+
+# Install dependencies (this layer will be cached if package files don't change)
+RUN npm ci
+
+# Copy all source files
+COPY . .
+
+# Build the application
+RUN npm run build
+
+# Production stage
+FROM node:20-alpine
+
+WORKDIR /app
+
+# Copy built assets from builder
+COPY --from=builder /app/.output /app/.output
+
+# Expose the port
+EXPOSE 3000
+
+# Set environment variables
+ENV PORT=3000
+ENV HOST=0.0.0.0
+ENV NODE_ENV=production
+
+# Start the application
+CMD ["node", ".output/server/index.mjs"]
\ No newline at end of file
diff --git a/README.md b/README.md
index eacb11b..65810bd 100644
--- a/README.md
+++ b/README.md
@@ -1,60 +1,57 @@
-# Nuxt 3 Minimal Starter
-
-Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
+# BeatBuzzer Music Game
## Setup
-Make sure to install the dependencies:
+Execute the following commands to run the project locally in development mode:
```bash
-# npm
-npm install
+npm install && npm run dev
+```
-# pnpm
-pnpm install
+The server will be available at `http://localhost:3000`.
-# yarn
-yarn install
+Run in **production mode** instead:
-# bun
-bun install
+```bash
+npm install && npm start
```
+Not tested as the project is still in development.
-## Development Server
+## TSDocs
-Start the development server on `http://localhost:3000`:
+To generate the TSDoc documentation, run the following command:
```bash
-# npm
-npm run dev
+npm run docs:serve
+```
-# pnpm
-pnpm run dev
+## Required Environment Variables
-# yarn
-yarn dev
+Check `.env.example` for the required environment variables.
-# bun
-bun run dev
-```
+Utilize ur systems capabilities to set the environment variables or create a `.env` file in the root of the project and
+set the variables there. Latter is suggested.
-## Production
+> [!WARNING] Careful
+> Make sure to never commit the `.env` file to the repository.
+> Check the `.gitignore` file to see the `.env` file is ignored.
-Build the application for production:
+## Project Structure
-```bash
-# npm
-npm run build
-```
-
-Locally preview production build:
+We are using the following project structure:
-```bash
-# npm
-npm run preview
+```
+.
+├── pages # For Vue pages
+├── components # For Vue components
+├── composables # For composables (Vue composition API)
+├── DB # All DDLs for the database
+├── layouts # For Vue layouts
+├── types # For TypeScript types
+└── server # For the Nitro server
+ ├── api # All API routes (route automatically defined as folder/file)
+ └── health.get.ts # eg, GET /api/health
+ └── utils # For server utilities
```
-Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
-
-## Required Environment Variables
-Check `.env.demo` for the required environment variables.
\ No newline at end of file
+Please see the [Nuxt Documentation](https://nuxt.com/docs/guide/directory-structure/app) for more detailed information.
\ No newline at end of file
diff --git a/components/UsersView.vue b/components/UsersView.vue
new file mode 100644
index 0000000..338752a
--- /dev/null
+++ b/components/UsersView.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/components/home/Game/VerticalGameList.vue b/components/home/Game/VerticalGameList.vue
new file mode 100644
index 0000000..529703d
--- /dev/null
+++ b/components/home/Game/VerticalGameList.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
Your turn(s)
+
+
+
+
+ {setGame(g); navigateTo('/play');}"/>
+
+
+
+
+
\ No newline at end of file
diff --git a/components/home/Loader.vue b/components/home/Loader.vue
new file mode 100644
index 0000000..6f18e31
--- /dev/null
+++ b/components/home/Loader.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/home/StartGameModal.vue b/components/home/StartGameModal.vue
new file mode 100644
index 0000000..33cf524
--- /dev/null
+++ b/components/home/StartGameModal.vue
@@ -0,0 +1,152 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Playing:
+
+
+
+
+
+
+ {{ selectedPlaylist.name }}
+ Click here to change
+
+
+
+
+
+
+
+
+
+
+
+
+
Select a Friend
+
+
+
+
+
+
+
+
+
Choose your Playlist
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/home/Users/UserBox.vue b/components/home/Users/UserBox.vue
new file mode 100644
index 0000000..04d8552
--- /dev/null
+++ b/components/home/Users/UserBox.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/login/ProviderButton.vue b/components/login/ProviderButton.vue
index 2ad842b..57f121b 100644
--- a/components/login/ProviderButton.vue
+++ b/components/login/ProviderButton.vue
@@ -20,6 +20,7 @@ async function signIn() {
provider: props.provider,
options: {
redirectTo: `http://${window.location.host}/confirm`,
+ scopes: `playlist-modify-public playlist-modify-private`
},
});
if (error) {
diff --git a/components/login/RegistrationModal.vue b/components/login/RegistrationModal.vue
new file mode 100644
index 0000000..1202117
--- /dev/null
+++ b/components/login/RegistrationModal.vue
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+
Register
+
+
+
+
+
+
+ This username is already in use
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/playlists/AddPlaylistsModal.vue b/components/playlists/AddPlaylistsModal.vue
new file mode 100644
index 0000000..80aa3b2
--- /dev/null
+++ b/components/playlists/AddPlaylistsModal.vue
@@ -0,0 +1,30 @@
+
+
+
+
+
+
Your Playlists
+
+
{{ playlistError }}
+
If you can't see your playlist here, make sure to add it to your Spotify profile and has more than 8 songs.
+
\ No newline at end of file
diff --git a/components/profile/FriendRequestModal.vue b/components/profile/FriendRequestModal.vue
new file mode 100644
index 0000000..f2068a2
--- /dev/null
+++ b/components/profile/FriendRequestModal.vue
@@ -0,0 +1,77 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/components/profile/ProfileInformation.vue b/components/profile/ProfileInformation.vue
new file mode 100644
index 0000000..3137f4b
--- /dev/null
+++ b/components/profile/ProfileInformation.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/components/profile/UserModal.vue b/components/profile/UserModal.vue
new file mode 100644
index 0000000..9ad073a
--- /dev/null
+++ b/components/profile/UserModal.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
Do you want to accept {{ name }}'s friend request?'
+
+
+
\ No newline at end of file
diff --git a/layouts/Game/GameSelectLayout.vue b/layouts/Game/GameSelectLayout.vue
new file mode 100644
index 0000000..161c4ec
--- /dev/null
+++ b/layouts/Game/GameSelectLayout.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/layouts/HeaderFooterView.vue b/layouts/HeaderFooterView.vue
new file mode 100644
index 0000000..5bed5c3
--- /dev/null
+++ b/layouts/HeaderFooterView.vue
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/playlists.vue b/pages/playlists.vue
new file mode 100644
index 0000000..dfdc2fe
--- /dev/null
+++ b/pages/playlists.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pages/profile.vue b/pages/profile.vue
new file mode 100644
index 0000000..219c49c
--- /dev/null
+++ b/pages/profile.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/icons/default_cover.jpg b/public/icons/default_cover.jpg
new file mode 100644
index 0000000..f41631d
Binary files /dev/null and b/public/icons/default_cover.jpg differ
diff --git a/server/api/v1/game/[uid]/play.post.ts b/server/api/v1/game/[uid]/play.post.ts
new file mode 100644
index 0000000..ae05d81
--- /dev/null
+++ b/server/api/v1/game/[uid]/play.post.ts
@@ -0,0 +1,228 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import {z} from "zod";
+import {GameStatus} from "~/types/api/game";
+import type {PostgrestError} from "@supabase/postgrest-js";
+import type {GameDB, GetGameDBRow} from "~/server/utils/mapper/game-mapper";
+import {mapGameRows} from "~/server/utils/mapper/game-mapper";
+import type {SupabaseClient} from "@supabase/supabase-js";
+
+const PlayGameSchema = z.object({
+ round: z.number(),
+ guess: z.string().regex(spotifyIDRegex, {message: 'Invalid Spotify ID'}),
+ time: z.number()
+}).readonly()
+
+
+// Calculate score based on time to guess
+// Upper bound is 500, lower bound is 18 (max time to guess is 30s)
+// Perfect score for guessing lower than 0.175 s
+const time_ceiling_for_perfect_score = Math.abs(0.4);
+
+function calculateScore(time_to_guess: number): number {
+ const reward = Math.floor(500 * Math.exp((-0.19 * (time_to_guess - time_ceiling_for_perfect_score))));
+ if (reward > 500) return 500;
+ return reward;
+}
+
+/**
+ * Submits a guess for a specific round in a game
+ * @param {string} uid - The game ID
+ * @param {Object} body - Request body
+ * @param {number} body.round - Round number
+ * @param {string} body.guess - Spotify ID of the guessed song
+ * @param {number} body.time - Time taken to make the guess in seconds
+ * @throws {400} Bad Request - Invalid game ID, round number, or player not in game
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {404} Not Found - Game not found
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {PlayGameResponse} Object containing game round results
+ */
+export default defineEventHandler(async (event) => {
+ const gameId = getRouterParam(event, 'uid');
+
+ // validate game id
+ if (!gameId) {
+ setResponseStatus(event, 400);
+ return {error: 'Invalid game id'};
+ }
+
+ // validate post-request body
+ const body = await readValidatedBody(event, body => PlayGameSchema.safeParse(body));
+ if (!body.success) {
+ setResponseStatus(event, 400);
+ return {error: body.error.issues};
+ }
+
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ let game = await useStorage().getItem('game:' + gameId);
+ const client = serverSupabaseServiceRole(event);
+
+ // Get game from database if not present in memory
+ if (!game) {
+ const {data: rows, error: getGameError}: {
+ data: GetGameDBRow[] | null,
+ error: PostgrestError | null
+ } = await client.rpc('get_game', {p_game_id: gameId} as never).select();
+
+ if (getGameError) {
+ setResponseStatus(event, 500);
+ return {error: 'Internal server error'};
+ }
+
+ if (!rows) {
+ setResponseStatus(event, 404);
+ return {error: 'Game not found'};
+ }
+
+ game = mapGameRows(rows);
+
+ // Save game to memory
+ useStorage().setItem('game:' + gameId, game).then(() => console.debug('Added game to memory:' + gameId));
+ }
+
+ if (!game) {
+ setResponseStatus(event, 500);
+ return {error: 'Game not found in memory'};
+ }
+
+ // Check if game is still active
+ if (game.status !== GameStatus.PLAYING) {
+ setResponseStatus(event, 400);
+ return {error: 'Game is not active'};
+ }
+
+ // Check if player is part of the game
+ if (!game.players.some(player => player.player_id === user.id)) {
+ setResponseStatus(event, 400);
+ return {error: 'Player not part of the game'};
+ }
+
+ // Check if round is valid
+ if (game.rounds.length < body.data.round) {
+ setResponseStatus(event, 400);
+ return {error: 'Invalid round'};
+ }
+
+ // Handle rounds
+ const round = game.rounds[body.data.round - 1];
+ const player = game.players.find(player => player.player_id === user.id);
+
+ // Check if round has already been played by player
+ if (game.rounds.some(round => round.round_number === body.data.round && round.time_to_guess) || player?.has_played) {
+ setResponseStatus(event, 400);
+ return {error: 'Round has already been played'};
+ }
+
+ if (round.correct_song_id === body.data.guess) {
+ round.correct_guess = true;
+ round.guess = body.data.guess;
+ round.time_to_guess = body.data.time;
+
+ const reward = calculateScore(body.data.time);
+
+ if (player && player.score) {
+ player.score += reward;
+ } else player!.score = reward;
+
+ } else {
+ round.correct_guess = false;
+ round.guess = body.data.guess;
+ round.time_to_guess = body.data.time;
+
+ if (!player!.score) {
+ player!.score = 0;
+ }
+ }
+
+ // Update game in memory
+ useStorage().setItem('game:' + gameId, game).then(() => console.debug('Updated game in memory:' + gameId));
+
+ // Handle Last round has been played
+ if (game.rounds.length === body.data.round) {
+
+ // persist game to database
+ const params = {
+ p_game_id: gameId,
+ p_user_id: user.id,
+ p_song_orders: game.rounds.map(round => round.round_number),
+ p_times_to_guess: game.rounds.map(round => round.time_to_guess),
+ p_correct_guesses: game.rounds.map(round => round.correct_guess),
+ p_guessed_song_ids: game.rounds.map(round => round.guess),
+ p_score: Math.floor(game.players.find(player => player.player_id === user.id)!.score!)
+ };
+ const {error} = await client.rpc('game_handle_player_finish', params as never);
+
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: 'Internal server error'};
+ }
+
+ // No await!. Asynchronously remove game from memory
+ useStorage().removeItem('game:' + gameId).then(() => console.debug('Removed game from memory:' + gameId));
+
+ // No await! Update or reset streak asynchronously
+ updateStreak(client, user.id);
+ }
+
+ return {
+ correct_guess: round.correct_guess,
+ score: player!.score,
+ was_last_round: game.rounds.length === body.data.round
+ };
+
+});
+
+const updateStreak = async (client: SupabaseClient, user_id: string) => {
+ client.from('users').select('*').eq('id', user_id).single()
+ .then(async ({data, error}: {
+ data: {
+ daily_streak: number,
+ daily_streak_updated_at: string,
+ } | null,
+ error: PostgrestError | null
+ }) => {
+ if (error) {
+ console.error('Error getting user', error);
+ return;
+ }
+ if (!data) return;
+
+ const lastUpdated = new Date(data.daily_streak_updated_at);
+ const now = new Date();
+ const hoursDifference = (now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60);
+
+ let newStreak = data.daily_streak;
+
+ // If last update was more than 24 hours ago but less than 48 hours ago
+ if (hoursDifference >= 24 && hoursDifference < 48) {
+ // Increment streak
+ newStreak = data.daily_streak + 1;
+ }
+ // If more than 48 hours passed, reset streak
+ else if (hoursDifference >= 48) {
+ newStreak = 0;
+ }
+ // If less than 24 hours, keep current streak
+
+ // Update streak if changed
+ if(newStreak === data.daily_streak) return;
+
+ const {error: updateError} = await client
+ .from('users')
+ .update({
+ daily_streak: newStreak,
+ // no need to update timestamp. DB hook will automatically set it
+ } as never)
+ .eq('id', user_id);
+
+ if (updateError) {
+ console.error('Error updating streak', updateError);
+ }
+ });
+}
diff --git a/server/api/v1/game/index.get.ts b/server/api/v1/game/index.get.ts
new file mode 100644
index 0000000..3ad5588
--- /dev/null
+++ b/server/api/v1/game/index.get.ts
@@ -0,0 +1,63 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import {mapDatabaseRowsToGames, mapGameToActiveGame} from "~/server/utils/mapper/game-mapper";
+import type {GetGameResponse} from "~/types/api/game";
+import {GameStatus} from "~/types/api/game";
+import {fetchPreviewUrl} from "~/server/utils/spotify";
+
+/**
+ * Retrieves all games for the authenticated user, categorized by status
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {GetGameResponse} Object containing arrays of:
+ * - active: Games where it's the user's turn to play
+ * - waiting: Games where user is waiting for opponent
+ * - past: Completed games
+ */
+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);
+ const activeGames = await Promise.all(
+ games
+ .filter(game => game.status === GameStatus.PLAYING && game.creator_id !== user.id)
+ .map(async (game) => {
+ // handle all the async preview URL fetches for this game
+ const updatedGame = {...game};
+ await Promise.all(
+ updatedGame.rounds.map(async (round) => {
+ if (!round.correct_song.preview_url) {
+ round.correct_song.preview_url = await fetchPreviewUrl(round.correct_song.id);
+ client.from('songs').update({preview_url: round.correct_song.preview_url} as never).eq('spotify_song_id', round.correct_song.id);
+ }
+ })
+ );
+ // Then map synchronously
+ return mapGameToActiveGame(updatedGame);
+ })
+ );
+
+ const response: GetGameResponse = {
+ active: activeGames,
+ waiting: games.filter(game => game.status === GameStatus.PLAYING && game.creator_id === user.id)
+ // dont show games where the user is waiting for a deleted player
+ .filter((g)=>!g.players.some((p)=>p.id === '00000000-0000-0000-0000-000000000000')),
+ past: games.filter(game => game.status === GameStatus.FINISHED)
+ };
+
+ return response;
+
+});
\ No newline at end of file
diff --git a/server/api/v1/game/index.post.ts b/server/api/v1/game/index.post.ts
new file mode 100644
index 0000000..9399d09
--- /dev/null
+++ b/server/api/v1/game/index.post.ts
@@ -0,0 +1,265 @@
+import {z} from "zod";
+import {spotifyIDRegex} from "~/server/utils/data-validation";
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import {fetchPreviewUrl, getSongsFromPlaylist} from "~/server/utils/spotify";
+import type {ActiveGame, ActiveGameRound, GameInitResponse, GameRound, Song, SpotifySong} from "~/types/api/game";
+import {GameStatus} from "~/types/api/game";
+import type {SupabaseClient} from "@supabase/supabase-js";
+import type {Playlist} from "~/types/api/playlist";
+import type {PostgrestError} from "@supabase/postgrest-js";
+import type {GetUserResponse} from "~/types/api/users";
+import type {GetPlaylistDBResponse} from "~/types/api/playlists";
+import type {EventHandlerRequest, H3Event} from "h3";
+
+const GameInitSchema = z.object({
+ playlist_id: z.string().regex(spotifyIDRegex, {message: 'Invalid Spotify ID'}),
+ opponent_id: z.string().uuid()
+}).readonly()
+
+const GameInitRdmSchema = z.object({
+ playlist_id: z.string().regex(spotifyIDRegex, {message: 'Invalid Spotify ID'}),
+}).readonly()
+
+/**
+ * Initializes a new game between two players using a specified playlist
+ * @param {Object} body - Request body
+ * @param {string} body.playlist_id - Spotify ID of the playlist to use
+ * @param {string} body.opponent_id - UUID of the opponent to play against
+ * @throws {400} Bad Request - Invalid playlist ID, self-play attempt, disabled playlist, or insufficient tracks
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {500} Internal Server Error - Database, Spotify API, or server error
+ * @returns {ActiveGame} Newly created game with randomized rounds and song options
+ */
+export default defineEventHandler(async (event) => {
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ const query = getQuery(event);
+
+ let playlist_id: string;
+ let opponent_id: string;
+
+ switch (query.type) {
+ case 'rdm_opponent': {
+ // validate post-request body
+ const result = await readValidatedBody(event, body => GameInitRdmSchema.safeParse(body))
+ if (!result.success) {
+ setResponseStatus(event, 400);
+ return {error: result.error.issues};
+ }
+ playlist_id = result.data.playlist_id;
+ opponent_id = await selectRandomOpponentId(event, user.id);
+ break;
+ }
+ case 'quickplay': {
+ playlist_id = await selectRandomPlaylistId(event);
+ opponent_id = await selectRandomOpponentId(event, user.id);
+ break;
+ }
+ default: {
+ console.debug('No recognized query type. Assuming opponent and playlist are provided');
+ // validate post-request body
+ const result = await readValidatedBody(event, body => GameInitSchema.safeParse(body))
+ if (!result.success) {
+ setResponseStatus(event, 400);
+ return {error: result.error.issues};
+ }
+ playlist_id = result.data.playlist_id;
+ opponent_id = result.data.opponent_id;
+ break
+ }
+ }
+
+ if (opponent_id! === user.id) {
+ setResponseStatus(event, 400);
+ return {error: 'You cannot play against yourself'};
+ }
+
+ const client = serverSupabaseServiceRole(event);
+ const {data: playlist, error: playlistError}: {
+ data: Playlist | null,
+ error: PostgrestError | null
+ } = await client.from('playlists').select().eq('id', playlist_id!).single();
+
+ if (playlistError) {
+ setResponseStatus(event, 500);
+ return {error: playlistError.message};
+ }
+
+ if (!playlist) {
+ setResponseStatus(event, 400);
+ return {error: 'Playlist not found'};
+ }
+
+ if (!playlist.enabled) {
+ setResponseStatus(event, 400);
+ return {error: 'Playlist is disabled'};
+ }
+
+ // Determine songs
+ const token = await getSpotifyToken();
+ const spotifySongs = await getSongsFromPlaylist(token, playlist_id!);
+
+ if (!spotifySongs) {
+ setResponseStatus(event, 500);
+ return {error: 'Failed to get songs from playlist'};
+ }
+
+ if (spotifySongs.total < 8) {
+ setResponseStatus(event, 400);
+ return {error: 'Insufficient tracks in playlist. Minimum 8 tracks required'};
+ }
+
+ const songs = spotifySongs.items.map((item: SpotifySong) => item.track).filter((track: Song) => track.is_playable);
+
+ try {
+ await saveSongsToDatabase(client, songs);
+ const gameRounds = await createGameRounds(songs);
+
+ const init_game_params = {
+ playlist_id_input: playlist_id!,
+ player_ids: [user.id, opponent_id!],
+ song_data: gameRounds.sort((gr) => gr.round).map(round => ({
+ spotify_song_id: round.correct_song.id,
+ wrong_option_1: round.wrong_songs[0].id,
+ wrong_option_2: round.wrong_songs[1].id,
+ wrong_option_3: round.wrong_songs[2].id
+ }))
+ }
+
+ const {data: initData, error: error}: {
+ data: GameInitResponse | null,
+ error: PostgrestError | null
+ } = await client.rpc('init_game', init_game_params as never).select().single();
+
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ const mappedGameRounds: ActiveGameRound[] = gameRounds.map((round) => ({
+ round: round.round,
+ preview_url: round.correct_song.preview_url!,
+ options: [round.correct_song, ...round.wrong_songs]
+ .sort(() => Math.random() - 0.5)
+ .map((song) => ({
+ id: song.id,
+ name: song.name,
+ artists: [{name: song.artists[0].name}],
+ is_playable: song.is_playable,
+ preview_url: null
+ }))
+ }));
+
+ // fetch player data
+ const {data: playerData, error: playerDataError}:{
+ data: GetUserResponse[] | null,
+ error: PostgrestError | null
+ } = await client.from('users').select().in('id', [user.id, opponent_id!]);
+
+ if (playerDataError) {
+ setResponseStatus(event, 500);
+ return {error: playerDataError.message};
+ }
+
+ if(!playerData || playerData?.length < 2) {
+ setResponseStatus(event, 500);
+ return {error: 'Failed to fetch player data'};
+ }
+
+ const game: ActiveGame = {
+ game_id: initData!.game_id,
+ status: GameStatus.PLAYING,
+ creator_id: user.id,
+ playlist: {
+ id: playlist_id!,
+ name: playlist.name,
+ cover: playlist.cover
+ },
+ players: playerData,
+ rounds: mappedGameRounds
+ }
+
+ setResponseStatus(event, 201);
+ return game;
+ } catch (error) {
+ setResponseStatus(event, 500);
+ return {error: error instanceof Error ? error.message : 'An unknown error occurred'};
+ }
+});
+
+async function saveSongsToDatabase(client: SupabaseClient, songs: Song[]) {
+ const {error: songError} = await client
+ .from('songs')
+ .upsert(songs.map((song: Song) => ({
+ spotify_song_id: song.id,
+ song_name: song.name,
+ artist_name: song.artists[0].name, // Using first artist only
+ preview_url: song.preview_url // Mostly null, spotify changed their API
+ })));
+
+ if (songError) {
+ throw new Error(songError.message);
+ }
+}
+
+async function createGameRounds(songs: Song[], rounds_to_play: number = 5): Promise {
+ const gameRounds: GameRound[] = [];
+ const shuffledSongs = [...songs].sort(() => Math.random() - 0.5);
+
+ for (let i = 0; i < rounds_to_play; i++) {
+ const selectedTrack = shuffledSongs.pop();
+ // Get preview url through parsing embed for selected track
+ selectedTrack!.preview_url = await fetchPreviewUrl(selectedTrack!.id);
+
+ // keep track of used IDs for this song option
+ const usedIds = new Set([selectedTrack!.id]);
+ const getUniqueWrong = () => {
+ let wrongTrack;
+ do {
+ wrongTrack = shuffledSongs[Math.floor(Math.random() * shuffledSongs.length)];
+ } while (usedIds.has(wrongTrack.id));
+ usedIds.add(wrongTrack.id);
+ return wrongTrack;
+ };
+
+ gameRounds.push({
+ round: i + 1, // 1-indexed since postgres functions are also 1-indexed
+ correct_song: selectedTrack!,
+ wrong_songs: [getUniqueWrong(), getUniqueWrong(), getUniqueWrong()]
+ });
+ }
+
+ return gameRounds;
+}
+
+async function selectRandomPlaylistId(event: H3Event): Promise {
+ const client = serverSupabaseServiceRole(event);
+ const {data}: {
+ data: GetPlaylistDBResponse | null
+ } = await client.from('playlists').select(`*`).limit(1).single();
+
+ if (data) return data.id;
+
+ throw new Error('No playlists found');
+}
+
+async function selectRandomOpponentId(event: H3Event, user_id: string): Promise {
+ const client = serverSupabaseServiceRole(event);
+
+ const {data} = await client
+ .rpc('get_random_opponent', {
+ requesting_user_id: user_id
+ } as never)
+ .single();
+
+ if (!data) {
+ throw new Error('No active users found');
+ }
+
+ return data;
+}
\ No newline at end of file
diff --git a/server/api/v1/health.ts b/server/api/v1/health.ts
index ab9acdf..3462c09 100644
--- a/server/api/v1/health.ts
+++ b/server/api/v1/health.ts
@@ -1,3 +1,15 @@
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+interface HealthCheckResponse {
+ status: string;
+ timestamp: string;
+ uptime: number;
+ memoryUsage: number;
+}
+
+/**
+ * Health check endpoint that provides basic server status information
+ * @returns {HealthCheckResponse} Server health status information
+ */
export default defineEventHandler(async () => {
return {
status: 'ok',
diff --git a/server/api/v1/playlist/[uid].get.ts b/server/api/v1/playlist/[uid].get.ts
index 3a13d4c..0bba066 100644
--- a/server/api/v1/playlist/[uid].get.ts
+++ b/server/api/v1/playlist/[uid].get.ts
@@ -1,10 +1,22 @@
-import {serverSupabaseUser} from "#supabase/server";
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {GetPlaylistDBResponse} from "~/types/api/playlists";
+import type {PostgrestError} from "@supabase/postgrest-js";
+
+/**
+ * Retrieves detailed information about a specific playlist
+ * @param {string} uid - Spotify playlist ID
+ * @throws {400} Bad Request - Invalid Spotify playlist ID format
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {404} Not Found - Playlist not found
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {GetPlaylistResponse} Playlist details including categories
+ */
export default defineEventHandler(async (event) => {
const playlistId = getRouterParam(event, 'uid')
// check regex playlistId
- if (!isValidSpotifyID(playlistId)) {
+ if (!playlistId || !isValidSpotifyID(playlistId!)) {
setResponseStatus(event, 400);
return {error: 'invalid playlistId'};
}
@@ -16,6 +28,24 @@ export default defineEventHandler(async (event) => {
return {error: 'unauthenticated'};
}
+ const client = serverSupabaseServiceRole(event);
+ const {data, error}: {
+ data: GetPlaylistDBResponse | null, error: PostgrestError | null
+ } = await client.from('playlists').select(`*, categories (name)`).eq('id', playlistId).single(); // join categories
+
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ if (!data) {
+ setResponseStatus(event, 404);
+ return {error: 'playlist not found'};
+ }
+
+ return {
+ ...data,
+ categories: data.categories.map((category: { name: string }) => category.name)
+ };
- return user;
})
\ No newline at end of file
diff --git a/server/api/v1/playlist/index.get.ts b/server/api/v1/playlist/index.get.ts
new file mode 100644
index 0000000..1c25c20
--- /dev/null
+++ b/server/api/v1/playlist/index.get.ts
@@ -0,0 +1,37 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {GetPlaylistDBResponse} from "~/types/api/playlists";
+import type {PostgrestError} from "@supabase/postgrest-js";
+
+/**
+ * Retrieves all available playlists with their categories
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {GetPlaylistResponse[]} Array of playlists with their details and categories
+ */
+export default defineEventHandler(async (event) => {
+
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ const client = serverSupabaseServiceRole(event);
+ const {data, error}: {
+ data: GetPlaylistDBResponse[] | null, error: PostgrestError | null
+ } = await client.from('playlists').select(`*, categories (name)`); // join categories
+
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ if(!data) return [];
+
+ return data.map(playlist => ({
+ ...playlist,
+ categories: playlist.categories.map(category => category.name),
+ }));
+
+})
\ No newline at end of file
diff --git a/server/api/v1/playlist/index.post.ts b/server/api/v1/playlist/index.post.ts
new file mode 100644
index 0000000..a143333
--- /dev/null
+++ b/server/api/v1/playlist/index.post.ts
@@ -0,0 +1,81 @@
+import {z} from 'zod'
+import {spotifyIDRegex} from "~/server/utils/data-validation";
+import {serverSupabaseServiceRole} from "#supabase/server";
+import {UNIQUE_VIOLATION} from "~/server/utils/postgres-errors";
+import {getPlaylistCover, getSpotifyToken} from "~/server/utils/spotify";
+import type {PostgrestError} from "@supabase/postgrest-js";
+import type {GetCategoryResponse} from "~/types/api/playlists";
+
+const schema = z.object({
+ id: z.string().regex(spotifyIDRegex),
+ name: z.string(),
+ spotifyId: z.string().regex(spotifyIDRegex),
+ categories: z.array(z.string()),
+ enabled: z.boolean().optional().default(true)
+})
+
+/**
+ * Creates a new playlist with categories
+ * @param {Object} body - Request body
+ * @param {string} body.id - Spotify playlist ID
+ * @param {string} body.name - Playlist name
+ * @param {string} body.spotifyId - Spotify playlist ID
+ * @param {string[]} body.categories - Array of category names
+ * @param {boolean} [body.enabled=true] - Whether the playlist is enabled
+ * @throws {400} Bad Request - Invalid request body format or duplicate playlist ID
+ * @throws {500} Internal Server Error - Database, Spotify API, or server error
+ * @returns {Object} Created playlist
+ * @status {201} Created successfully
+ */
+export default defineEventHandler(async (event) => {
+ const result = await readValidatedBody(event, body => schema.safeParse(body))
+
+ if (!result.success) {
+ setResponseStatus(event, 400);
+ return {error: result.error.issues};
+ }
+
+ const token = await getSpotifyToken();
+ const coverUrl = await getPlaylistCover(token, result.data.spotifyId);
+
+ const playlistInsert = {
+ id: result.data.id,
+ name: result.data.name,
+ spotifyId: result.data.spotifyId,
+ cover: coverUrl,
+ enabled: result.data.enabled
+ }
+
+ const categoriesInsert = result.data.categories.map((category) => ({
+ playlistId: result.data.id,
+ name: category,
+ }));
+
+ const client = serverSupabaseServiceRole(event);
+ const {
+ data: playlistData,
+ error: playlistError
+ } = await client.from('playlists').insert(playlistInsert as never).select().single(); //todo: fix type error!
+
+
+ if (playlistError) {
+ setResponseStatus(event, 400);
+ if (playlistError.code === UNIQUE_VIOLATION)
+ return {error: 'Playlist with this ID already exists'};
+ setResponseStatus(event, 500);
+ return {error: playlistError.message};
+ }
+
+ const {data: categoriesData, error: categoriesError}:
+ { data: GetCategoryResponse[] | null, error: PostgrestError | null } = await client
+ .from('categories')
+ .insert(categoriesInsert as never).select();
+
+ if (categoriesError) {
+ setResponseStatus(event, 500);
+ return {error: `Error inserting categories: ${categoriesError.message}`};
+ }
+
+ setResponseStatus(event, 201);
+ return {playlist: playlistData, categories: categoriesData?.map((cat) => cat.name)};
+})
\ No newline at end of file
diff --git a/server/api/v1/playlist/playlist.http b/server/api/v1/playlist/playlist.http
new file mode 100644
index 0000000..7598558
--- /dev/null
+++ b/server/api/v1/playlist/playlist.http
@@ -0,0 +1,36 @@
+@baseUrl = http://localhost:3000/api/v1
+@authCookie =
+
+### Get all Playlists
+GET {{baseUrl}}/playlist
+Cookie: {{authCookie}}
+
+
+### Get specific Playlist
+@playlistId = 37i9dQZF1EIYE32WUF6sxN
+
+GET {{baseUrl}}/playlist/{{playlistId}}
+Cookie: {{authCookie}}
+
+
+### Add a Spotify Playlist to our system
+POST {{baseUrl}}/playlist
+
+{
+"id": "37i9dQZF1DX2CtuHQcongT",
+"name": "This is SEGA SOUND TEAM",
+"spotifyId": "37i9dQZF1DX2CtuHQcongT",
+"categories": ["sega"]
+}
+
+### Add a Disabled Spotify Playlist to our system
+POST {{baseUrl}}/playlist
+
+{
+"id": "37i9dQZF1DX2CtuHQcongT",
+"name": "This is SEGA SOUND TEAM",
+"spotifyId": "37i9dQZF1DX2CtuHQcongT",
+"categories": ["sega"]
+"enabled": true
+}
+
diff --git a/server/api/v1/user/[uid].get.ts b/server/api/v1/user/[uid].get.ts
index dfdb9db..a923bb1 100644
--- a/server/api/v1/user/[uid].get.ts
+++ b/server/api/v1/user/[uid].get.ts
@@ -1,5 +1,54 @@
-export default defineEventHandler((event) => {
+import {isValidUUID} from "~/server/utils/data-validation";
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {GetUserResponse} from "~/types/api/users";
+import type {PostgrestError} from "@supabase/postgrest-js";
+
+/**
+ * Retrieves a specific user's profile information by their UUID
+ * @param {string} uid - The UUID of the user to retrieve
+ * @throws {400} Bad Request - Invalid UUID format
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {404} Not Found - User profile does not exist
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {GetUserResponse} User profile data with spotify_id conditionally removed based on visibility settings
+ */
+export default defineEventHandler(async (event) => {
const userId = getRouterParam(event, 'uid')
- return {user: userId};
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ if (!userId || !isValidUUID(userId)) {
+ setResponseStatus(event, 400);
+ return {error: 'Invalid user ID'};
+ }
+
+ // Send request
+ const client = serverSupabaseServiceRole(event);
+ const {data, error}: {
+ data: GetUserResponse | null,
+ error: PostgrestError | null
+ } = await client.from('users').select('*').eq('id', userId).maybeSingle();
+
+ if (!data) {
+ setResponseStatus(event, 404);
+ return {error: 'User not found'};
+ }
+
+ // Handle errors
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ // Hide spotify id if not visible
+ if (!data.spotify_visibility) {
+ delete data.spotify_id;
+ }
+
+ return data;
})
\ No newline at end of file
diff --git a/server/api/v1/user/friends/action.post.ts b/server/api/v1/user/friends/action.post.ts
new file mode 100644
index 0000000..58888ab
--- /dev/null
+++ b/server/api/v1/user/friends/action.post.ts
@@ -0,0 +1,71 @@
+import {z} from "zod";
+import type {FriendActionParam} from "~/types/api/user.friends";
+import {FriendshipAction} from "~/types/api/user.friends";
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {PostgrestError} from "@supabase/postgrest-js";
+
+const friendActionSchema = z.object({
+ friendship_id: z.number().int().positive(),
+ action: z.nativeEnum(FriendshipAction)
+}).readonly()
+
+
+/**
+ * Handles friend request actions (accept/decline/remove)
+ * @param {Object} body - Request body
+ * @param {number} body.friendship_id - ID of the friendship to act upon
+ * @param {FriendshipAction} body.action - Action to perform (accept/decline/remove)
+ * @throws {400} Bad Request - Invalid request body or invalid friendship state for action
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {Object} Empty object on success
+ */
+export default defineEventHandler(async (event) => {
+ // validate post-request body
+ const result = await readValidatedBody(event, body => friendActionSchema.safeParse(body))
+ if (!result.success) {
+ setResponseStatus(event, 400);
+ return {error: result.error.issues};
+ }
+
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ // Send request
+ const client = serverSupabaseServiceRole(event);
+ const param: FriendActionParam = {friendship_id_param: result.data.friendship_id};
+
+ let error: PostgrestError | null = null;
+ switch (result.data.action) {
+ case FriendshipAction.ACCEPT: {
+ const {error: acceptErr} = await client.rpc('accept_friend_request_by_id', param as never);
+ error = acceptErr;
+ break;
+ }
+ case FriendshipAction.DECLINE: {
+ const {error: declineErr} = await client.rpc('decline_friend_request_by_id', param as never);
+ error = declineErr;
+ break;
+ }
+ case FriendshipAction.REMOVE: {
+ const {error: declineErr} = await client.rpc('remove_friend_by_id', param as never);
+ error = declineErr;
+ break;
+ }
+ }
+
+ // Handle errors
+ if (error) {
+ if (error.code === 'P0001') {
+ setResponseStatus(event, 400)
+ } else setResponseStatus(event, 500);
+
+ return {error: error.message};
+ }
+
+ return {};
+});
\ No newline at end of file
diff --git a/server/api/v1/user/friends/index.get.ts b/server/api/v1/user/friends/index.get.ts
new file mode 100644
index 0000000..4a558d8
--- /dev/null
+++ b/server/api/v1/user/friends/index.get.ts
@@ -0,0 +1,64 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {FriendError, GetFriendParam, GetFriendsDBResponse} from "~/types/api/user.friends";
+
+/**
+ * Retrieves all friendships and friend requests for the authenticated user
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {GetFriendsResponse[]} Array of friendships with friend profile data
+ */
+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 friends
+ const client = serverSupabaseServiceRole(event);
+ const param: GetFriendParam = {user_id: user.id};
+
+ const {data, error}: {
+ data: GetFriendsDBResponse[] | null,
+ error: FriendError | null
+ } = await client.rpc('get_friends', param as never);
+
+ // Handle errors
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ if (data === null) return [];
+ // redundant, but seems to fix type error
+ const friendData: GetFriendsDBResponse[] = data
+
+ // Hide spotify id if not visible
+ // Yes this works even when the IDE marks it as an error
+ friendData.forEach(friend => {
+ if (!friend.friend_spotify_visibility) {
+ delete friend.friend_spotify_id;
+ }
+ });
+
+ const friends = friendData.map((friendship: GetFriendsDBResponse) => {
+ return {
+ friendship_id: friendship.friendship_id,
+ friend_id: friendship.friend_id,
+ created_at: friendship.created_at,
+ updated_at: friendship.updated_at,
+ status: friendship.status,
+ request_type: friendship.request_type,
+ user: {
+ id: friendship.friend_id,
+ avatar_url: friendship.friend_avatar,
+ username: friendship.friend_username,
+ spotify_id: friendship.friend_spotify_visibility ? friendship.friend_spotify_id : undefined,
+ spotify_visibility: friendship.friend_spotify_visibility
+ }
+ }
+ });
+
+ return friends;
+});
\ No newline at end of file
diff --git a/server/api/v1/user/friends/index.post.ts b/server/api/v1/user/friends/index.post.ts
new file mode 100644
index 0000000..18cfeea
--- /dev/null
+++ b/server/api/v1/user/friends/index.post.ts
@@ -0,0 +1,47 @@
+// send friend invite
+import {z} from 'zod'
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {SendFriendRequestNameParam} from "~/types/api/user.friends";
+
+const userSchema = z.object({
+ receiver_name: z.string()
+}).readonly()
+
+/**
+ * Sends a friend request to a user by their username
+ * @param {Object} body - Request body
+ * @param {string} body.receiver_name - Username of the friend request recipient
+ * @throws {400} Bad Request - Invalid request body format
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {Object} Empty object on success
+ */
+export default defineEventHandler(async (event) => {
+ // validate post-request body
+ const result = await readValidatedBody(event, body => userSchema.safeParse(body))
+ if (!result.success) {
+ setResponseStatus(event, 400);
+ return {error: result.error.issues};
+ }
+
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ // Send request
+ const client = serverSupabaseServiceRole(event);
+ const param: SendFriendRequestNameParam = {sender_id: user.id, receiver_name: result.data.receiver_name};
+ const {error} = await client.rpc('send_friend_request_by_name', param as never);
+
+ // Handle errors
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ setResponseStatus(event, 201);
+ return {};
+});
\ No newline at end of file
diff --git a/server/api/v1/user/index.get.ts b/server/api/v1/user/index.get.ts
new file mode 100644
index 0000000..2cbb186
--- /dev/null
+++ b/server/api/v1/user/index.get.ts
@@ -0,0 +1,45 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {GetUserResponse} from "~/types/api/users";
+import type {PostgrestError} from "@supabase/postgrest-js";
+
+/**
+ * Retrieves the authenticated user's profile information
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {404} Not Found - User profile does not exist
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {GetUserResponse} User profile data with spotify_id conditionally removed based on visibility settings
+ */
+export default defineEventHandler(async (event) => {
+
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ // Send request
+ const client = serverSupabaseServiceRole(event);
+ const {data, error}: {
+ data: GetUserResponse | null,
+ error: PostgrestError | null
+ } = await client.from('users').select('*').eq('id', user.id).maybeSingle();
+
+ if (!data) {
+ setResponseStatus(event, 404);
+ return {error: 'User not found'};
+ }
+
+ // Handle errors
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ // Hide spotify id if not visible
+ if (!data.spotify_visibility) {
+ delete data.spotify_id;
+ }
+
+ return data;
+})
\ No newline at end of file
diff --git a/server/api/v1/user/register.post.ts b/server/api/v1/user/register.post.ts
new file mode 100644
index 0000000..a70ce19
--- /dev/null
+++ b/server/api/v1/user/register.post.ts
@@ -0,0 +1,62 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import {z} from "zod";
+import type {PostgrestError} from "@supabase/postgrest-js";
+import {UNIQUE_VIOLATION} from "~/server/utils/postgres-errors";
+
+
+const registerSchema = z.object({
+ username: z.string()
+ .min(4, 'Username must be at least 4 characters long')
+ .regex(
+ /^[a-zA-Z0-9_]+$/,
+ 'Username can only contain letters, numbers, and underscores'
+ ),
+ spotify_visibility: z.boolean(),
+});
+
+/**
+ * Registers a new user profile with username and Spotify visibility settings
+ * @param {Object} body - Request body
+ * @param {string} body.username - Username (4+ chars, alphanumeric + underscore only)
+ * @param {boolean} body.spotify_visibility - Whether to show Spotify profile info publicly
+ * @throws {400} Bad Request - Invalid username format or missing required fields
+ * @throws {401} Unauthenticated - User is not logged in
+ * @throws {409} Conflict - Username already exists
+ * @throws {500} Internal Server Error - Database or server error
+ * @returns {Object} Empty object on success
+ */
+export default defineEventHandler(async (event) => {
+ const body = await readValidatedBody(event, body => registerSchema.safeParse(body))
+
+ if (!body.success) {
+ setResponseStatus(event, 400);
+ return {error: body.error.issues};
+ }
+
+ // Require user to be authenticated
+ const user = await serverSupabaseUser(event);
+ if (!user?.id) {
+ setResponseStatus(event, 401);
+ return {error: 'unauthenticated'};
+ }
+
+ const client = serverSupabaseServiceRole(event);
+ const {error}: { error: PostgrestError | null } = await client.from('users').insert({
+ id: user.id,
+ username: body.data.username,
+ spotify_id: user.user_metadata.provider_id,
+ spotify_visibility: body.data.spotify_visibility,
+ avatar_url: user.user_metadata.avatar_url,
+ } as never).single();
+
+ if (error) {
+ if (error.code === UNIQUE_VIOLATION) {
+ setResponseStatus(event, 409);
+ return {error: 'Username already taken'};
+ }
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ return {};
+});
\ No newline at end of file
diff --git a/server/utils/data-validation.ts b/server/utils/data-validation.ts
index d4c5cda..0c830e6 100644
--- a/server/utils/data-validation.ts
+++ b/server/utils/data-validation.ts
@@ -1,3 +1,23 @@
+/**
+ * Spotify ID regex
+ */
+export const spotifyIDRegex = /^[a-zA-Z0-9]+$/; // base62
+
+/**
+ * Check if a string is a valid Spotify ID
+ * @param spotifyID - The string to check
+ * @returns Whether the string is a valid Spotify ID
+ */
export function isValidSpotifyID(spotifyID: string): boolean {
- return spotifyID.match(/^[a-zA-Z0-9]+$/) !== null; // base62
+ return spotifyID.match(spotifyIDRegex) !== null; // base62
+}
+
+/**
+ * Check if a string is a valid UUID
+ * @param uuid - The string to check
+ * @returns Whether the string is a valid UUID
+ */
+export function isValidUUID(uuid: string): boolean {
+ if(uuid.length !== 36) return false;
+ return uuid.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/) !== null;
}
\ 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..7951364
--- /dev/null
+++ b/server/utils/mapper/game-mapper.ts
@@ -0,0 +1,275 @@
+// Types from your original interfaces
+
+import type {ActiveGame, Game, GameRound, Song, GameStatus, GameStats} from "~/types/api/game";
+import type {GetUserResponse} from "~/types/api/users";
+
+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
+
+ // Player info
+ player_user_id: string;
+ player_avatar_url: string | null;
+ player_username: string;
+ player_spotify_id: string | null;
+ player_spotify_visibility: boolean;
+ player_daily_streak: number | null;
+ player_daily_streak_updated_at: string | null;
+ player_score: number;
+
+ // 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;
+
+ // Player round stats
+ time_to_guess: number | null;
+ correct_guess: boolean | null;
+ guessed_song_id: string | null;
+ guessed_song_name: string | null;
+ guessed_song_artist: string | null;
+ guessed_song_preview_url: string | null;
+}
+
+export interface GetGameDBRow {
+ game_id: number;
+ status: GameStatus;
+ playlist_id: string;
+ created_at: string;
+ player_id: string;
+ is_creator: boolean;
+ has_played: boolean;
+ round_number: number;
+ correct_song_id: string;
+}
+
+interface GameDBPlayer {
+ player_id: string;
+ is_creator: boolean;
+ has_played: boolean;
+ score?: number;
+}
+
+interface GameDBRound {
+ round_number: number;
+ correct_song_id: string;
+ time_to_guess?: number;
+ correct_guess?: boolean;
+ guess?: string;
+}
+
+export interface GameDB {
+ game_id: number;
+ status: GameStatus;
+ playlist_id: string;
+ created_at: string;
+ players: GameDBPlayer[];
+ rounds: GameDBRound[];
+}
+
+const createSongFromRow = (
+ id: string,
+ name: string,
+ artist: string,
+ previewUrl: string | null
+): Song => ({
+ id,
+ name,
+ artists: [{
+ 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];
+
+ // Group all players from rows
+ const playersMap = new Map();
+
+ rows.forEach(row => {
+ const playerId = row.player_user_id;
+ if (!playersMap.has(playerId)) {
+ playersMap.set(playerId, {
+ id: row.player_user_id,
+ avatar_url: row.player_avatar_url ?? undefined,
+ username: row.player_username,
+ spotify_id: row.player_spotify_id ?? undefined,
+ spotify_visibility: row.player_spotify_visibility,
+ daily_streak: row.player_daily_streak ?? undefined,
+ daily_streak_updated_at: row.player_daily_streak_updated_at ?? undefined
+ });
+ }
+ });
+
+ // Map rounds
+ const uniqueRounds = new Map();
+ rows.forEach(row => {
+ if (!uniqueRounds.has(row.round_number)) {
+ uniqueRounds.set(row.round_number, {
+ 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
+ )
+ ]
+ });
+ }
+ });
+
+ // Map stats
+ const stats: GameStats[] = [];
+ const statsMap = new Map();
+
+ rows.forEach(row => {
+ if (!statsMap.has(row.player_user_id)) {
+ statsMap.set(row.player_user_id, {
+ user_id: row.player_user_id,
+ score: row.player_score,
+ guesses: []
+ });
+ }
+
+ const playerStats = statsMap.get(row.player_user_id)!;
+
+ // Only add guess if we have stats data for this round
+ if (row.time_to_guess !== null) {
+ playerStats.guesses.push({
+ round_number: row.round_number,
+ time_to_guess: row.time_to_guess,
+ correct_guess: row.correct_guess!,
+ song: createSongFromRow(
+ row.guessed_song_id!,
+ row.guessed_song_name!,
+ row.guessed_song_artist!,
+ row.guessed_song_preview_url
+ )
+ });
+ }
+ });
+
+ // Add all stats to the array
+ stats.push(...Array.from(statsMap.values()));
+
+ 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
+ },
+ players: Array.from(playersMap.values()),
+ rounds: Array.from(uniqueRounds.values()),
+ created_at: firstRow.created_at,
+ stats: stats.length > 0 ? stats : undefined
+ };
+};
+
+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));
+};
+
+export const mapGameToActiveGame = (game: Game): ActiveGame => {
+ const mappedRounds = game.rounds.map((round) => ({
+ round: round.round,
+ preview_url: round.correct_song.preview_url!,
+ options: [round.correct_song, ...round.wrong_songs].map(option => {
+ // Remove preview URL from all options to not identify the correct one
+ option.preview_url = null;
+ return option;
+ }).sort(() => Math.random() - 0.5)
+ }));
+
+ return {
+ game_id: game.game_id,
+ status: game.status,
+ creator_id: game.creator_id,
+ playlist: game.playlist,
+ players: game.players,
+ rounds: mappedRounds
+ };
+}
+
+export function mapGameRows(rows: GetGameDBRow[]): GameDB {
+ const firstRow = rows[0];
+
+ return {
+ game_id: firstRow.game_id,
+ status: firstRow.status,
+ playlist_id: firstRow.playlist_id,
+ created_at: firstRow.created_at,
+ players: [...new Set(rows.map(row => row.player_id))].map(playerId => ({
+ player_id: playerId,
+ is_creator: rows.find(r => r.player_id === playerId)!.is_creator,
+ has_played: rows.find(r => r.player_id === playerId)!.has_played
+ })),
+ rounds: [...new Set(rows.map(row => row.round_number))].map(roundNum => ({
+ round_number: roundNum,
+ correct_song_id: rows.find(r => r.round_number === roundNum)!.correct_song_id
+ }))
+ };
+}
\ No newline at end of file
diff --git a/server/utils/postgres-errors.ts b/server/utils/postgres-errors.ts
new file mode 100644
index 0000000..bbc1982
--- /dev/null
+++ b/server/utils/postgres-errors.ts
@@ -0,0 +1,4 @@
+export const UNIQUE_VIOLATION = '23505';
+export const FOREIGN_KEY_VIOLATION = '23503';
+export const NOT_NULL_VIOLATION = '23502';
+
diff --git a/server/utils/spotify.ts b/server/utils/spotify.ts
new file mode 100644
index 0000000..113b2b6
--- /dev/null
+++ b/server/utils/spotify.ts
@@ -0,0 +1,121 @@
+import jsonpath from 'jsonpath';
+
+let spotifyToken: string | null = null
+let tokenExpiry: number = 0
+
+export async function getSpotifyToken() {
+ // Return cached token if valid
+ if (spotifyToken && tokenExpiry > Date.now()) {
+ return spotifyToken
+ }
+
+ const auth = Buffer
+ .from(`${process.env.SPOTIFY_CLIENT_ID}:${process.env.SPOTIFY_CLIENT_SECRET}`)
+ .toString('base64')
+
+ try {
+ const res = await fetch('https://accounts.spotify.com/api/token', {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Basic ${auth}`,
+ 'Content-Type': 'application/x-www-form-urlencoded'
+ },
+ body: 'grant_type=client_credentials'
+ })
+
+ const data = await res.json()
+ spotifyToken = data.access_token
+ tokenExpiry = Date.now() + (data.expires_in - 60) * 1000
+
+ return spotifyToken
+ } catch (error) {
+ console.error('Spotify token error:', error)
+ throw createError({
+ statusCode: 500,
+ message: 'Failed to get Spotify access token'
+ })
+ }
+}
+
+export async function getPlaylistCover(token: string | null, playlistId: string): Promise {
+ if (!token) return undefined;
+
+ const res = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/images`, {
+ headers: {'Authorization': `Bearer ${token}`}
+ })
+ const data = await res.json()
+ console.log(data)
+ //Normally [0] = 640px, [1] = 300px, [2] = 60px,
+ //but sometimes only one image is returned
+ return data[1]?.url || data[0]?.url;
+}
+
+export async function getSongsFromPlaylist(token: string | null, playlistId: string) {
+ if (!token) return [];
+
+ const res = await fetch(`https://api.spotify.com/v1/playlists/${playlistId}/tracks?market=DE&fields=total%2Citems%28track%28artists%2Cname%2Cis_playable%2Cid%2Cpreview_url%29%29`, {
+ headers: {'Authorization': `Bearer ${token}`}
+ })
+ const data = await res.json()
+ // console.log(data)
+
+ return data;
+}
+
+// Workaround to get the preview URL from the Spotify embed page
+// based on https://stackoverflow.com/a/79238027/12822225
+export async function fetchPreviewUrl(trackId: string): Promise {
+ try {
+ const embedUrl = `https://open.spotify.com/embed/track/${trackId}`;
+
+ const response = await fetch(embedUrl, {
+ headers: {
+ 'Accept': 'text/html',
+ 'User-Agent': 'Mozilla/5.0 (compatible; Nuxt/3.0; +https://nuxtjs.org)'
+ }
+ });
+
+ if (!response.ok) {
+ console.error(`Failed to fetch embed page: ${response.status}`);
+ return null;
+ }
+
+ const html = await response.text();
+
+ // Extract all script tags content
+ const scriptRegex = /