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/users.sql b/DB/users.sql
new file mode 100644
index 0000000..ef78b16
--- /dev/null
+++ b/DB/users.sql
@@ -0,0 +1,15 @@
+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,
+ 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);
\ No newline at end of file
diff --git a/components/home/Controls/GameButtons.vue b/components/home/Controls/GameButtons.vue
new file mode 100644
index 0000000..36344c6
--- /dev/null
+++ b/components/home/Controls/GameButtons.vue
@@ -0,0 +1,15 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/components/home/Turns/User.vue b/components/home/Turns/User.vue
new file mode 100644
index 0000000..824cbb5
--- /dev/null
+++ b/components/home/Turns/User.vue
@@ -0,0 +1,14 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/components/profile/Friendlist.vue b/components/profile/Friendlist.vue
new file mode 100644
index 0000000..dac4093
--- /dev/null
+++ b/components/profile/Friendlist.vue
@@ -0,0 +1,41 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/layouts/FooterView.vue b/layouts/FooterView.vue
new file mode 100644
index 0000000..6381c9d
--- /dev/null
+++ b/layouts/FooterView.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/layouts/HeaderFooterView.vue b/layouts/HeaderFooterView.vue
index 0cecbb1..11cadc7 100644
--- a/layouts/HeaderFooterView.vue
+++ b/layouts/HeaderFooterView.vue
@@ -15,8 +15,8 @@
-
+
\ No newline at end of file
diff --git a/pages/testfriends.vue b/pages/testfriends.vue
new file mode 100644
index 0000000..aa615e0
--- /dev/null
+++ b/pages/testfriends.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
Friend Requests:
+
+
{{ item.friend_id }}
+
+
+
+
+
+
Friends:
+
+ {{ item.friend_id }}
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/icons/playButton.svg b/public/icons/playButton.svg
new file mode 100644
index 0000000..2763488
--- /dev/null
+++ b/public/icons/playButton.svg
@@ -0,0 +1,7 @@
+
+
+
\ 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..637bf1b
--- /dev/null
+++ b/server/api/v1/user/friends/action.post.ts
@@ -0,0 +1,60 @@
+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()
+
+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..9942fd0
--- /dev/null
+++ b/server/api/v1/user/friends/index.get.ts
@@ -0,0 +1,31 @@
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {FriendError, GetFriendParam, GetFriendsResponse} from "~/types/api/user.friends";
+
+// Not relative to userid because you should always only be able to see your own friends
+// User id can be grabbed from the access token
+
+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: GetFriendsResponse[] | null,
+ error: FriendError | null
+ } = await client.rpc('get_friends', param as never);
+
+ // Handle errors
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ return data;
+});
\ 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..5c373bc
--- /dev/null
+++ b/server/api/v1/user/friends/index.post.ts
@@ -0,0 +1,37 @@
+// send friend invite
+import {z} from 'zod'
+import {serverSupabaseServiceRole, serverSupabaseUser} from "#supabase/server";
+import type {SendFriendRequestParam} from "~/types/api/user.friends";
+
+const userSchema = z.object({
+ receiver_id: z.string().uuid()
+}).readonly()
+
+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: SendFriendRequestParam = {sender_id: user.id, receiver_id: result.data.receiver_id};
+ const {error} = await client.rpc('send_friend_request', param as never);
+
+ // Handle errors
+ if (error) {
+ setResponseStatus(event, 500);
+ return {error: error.message};
+ }
+
+ return {};
+});
\ No newline at end of file
diff --git a/tsconfig.json b/tsconfig.json
index a746f2a..07b0aaa 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,4 +1,10 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
- "extends": "./.nuxt/tsconfig.json"
+ "extends": "./.nuxt/tsconfig.json",
+ "compilerOptions": {
+ "paths": {
+ "@/*": ["./*"],
+ "~/*": ["./*"]
+ }
+ },
}
diff --git a/types/api/user.friends.ts b/types/api/user.friends.ts
new file mode 100644
index 0000000..5add186
--- /dev/null
+++ b/types/api/user.friends.ts
@@ -0,0 +1,54 @@
+// ----- Internal Types -----
+import type * as url from "node:url";
+export interface FriendError {
+ message: string
+}
+
+export interface GetFriendParam {
+ user_id: string
+}
+
+export interface SendFriendRequestParam {
+ sender_id: string,
+ receiver_id: string
+}
+
+export interface FriendActionParam {
+ friendship_id_param: number,
+}
+
+// ----- For API Consumer -----
+export interface GetFriendsResponse {
+ friendship_id: number,
+ friend_id: string,
+ friend_username: string,
+ friend_avatar: url.URL | null,
+ friend_spotify_id: string,
+ friend_spotify_visibility: boolean,
+ created_at: string
+ updated_at: string
+ status: FriendshipStatus,
+ request_type: FriendshipType
+}
+
+export enum FriendshipAction {
+ ACCEPT = "accept",
+ DECLINE = "decline",
+ REMOVE = "remove"
+}
+
+export enum FriendshipStatus {
+ PENDING = "pending",
+ ACCEPTED = "accepted",
+ DECLINED = "declined"
+}
+
+export enum FriendshipType {
+ OUTGOING = "outgoing",
+ INCOMING = "incoming"
+}
+
+export interface FriendActionRequest {
+ action: FriendshipAction,
+ friendship_id: number
+}