diff --git a/apps/web/DATABASE_SCHEMA.md b/apps/web/DATABASE_SCHEMA.md new file mode 100644 index 0000000..933c88a --- /dev/null +++ b/apps/web/DATABASE_SCHEMA.md @@ -0,0 +1,202 @@ +# Database Schema for Groups and Friends Features + +This document outlines the database tables needed for the group creation and friends features. + +## Required Tables + +### 1. `groups` table +```sql +CREATE TABLE groups ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + privacy VARCHAR(10) DEFAULT 'public' CHECK (privacy IN ('public', 'private')), + code VARCHAR(6) UNIQUE NOT NULL, + status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('pending', 'active', 'deleted')), + expires_at TIMESTAMP WITH TIME ZONE, + created_by UUID REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create indexes for faster lookups +CREATE INDEX idx_groups_code ON groups(code); +CREATE INDEX idx_groups_created_by ON groups(created_by); +CREATE INDEX idx_groups_status ON groups(status); +CREATE INDEX idx_groups_expires_at ON groups(expires_at); +CREATE INDEX idx_groups_privacy ON groups(privacy); +``` + +### 2. `group_members` table +```sql +CREATE TABLE group_members ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + group_id UUID REFERENCES groups(id) ON DELETE CASCADE, + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + role VARCHAR(20) DEFAULT 'member' CHECK (role IN ('admin', 'member')), + joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(group_id, user_id) +); + +-- Create indexes for better performance +CREATE INDEX idx_group_members_group_id ON group_members(group_id); +CREATE INDEX idx_group_members_user_id ON group_members(user_id); +``` + +### 3. `friends` table +```sql +CREATE TABLE friends ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user1_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + user2_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'blocked')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user1_id, user2_id), + CHECK (user1_id < user2_id) +); + +-- Create indexes for better performance +CREATE INDEX idx_friends_user1_id ON friends(user1_id); +CREATE INDEX idx_friends_user2_id ON friends(user2_id); +CREATE INDEX idx_friends_status ON friends(status); +``` + +## Row Level Security (RLS) Policies + +### Groups table policies +```sql +-- Enable RLS +ALTER TABLE groups ENABLE ROW LEVEL SECURITY; + +-- Users can view groups they are members of +CREATE POLICY "Users can view groups they belong to" ON groups + FOR SELECT USING ( + id IN ( + SELECT group_id FROM group_members + WHERE user_id = auth.uid() + ) + ); + +-- Users can create groups +CREATE POLICY "Users can create groups" ON groups + FOR INSERT WITH CHECK (created_by = auth.uid()); + +-- Only group admins can update groups +CREATE POLICY "Admins can update groups" ON groups + FOR UPDATE USING ( + created_by = auth.uid() OR + id IN ( + SELECT group_id FROM group_members + WHERE user_id = auth.uid() AND role = 'admin' + ) + ); + +-- Only group admins can delete groups +CREATE POLICY "Admins can delete groups" ON groups + FOR DELETE USING ( + created_by = auth.uid() OR + id IN ( + SELECT group_id FROM group_members + WHERE user_id = auth.uid() AND role = 'admin' + ) + ); +``` + +### Group members table policies +```sql +-- Enable RLS +ALTER TABLE group_members ENABLE ROW LEVEL SECURITY; + +-- Users can view members of groups they belong to +CREATE POLICY "Users can view group members" ON group_members + FOR SELECT USING ( + group_id IN ( + SELECT group_id FROM group_members + WHERE user_id = auth.uid() + ) + ); + +-- Users can join groups (insert themselves) +CREATE POLICY "Users can join groups" ON group_members + FOR INSERT WITH CHECK (user_id = auth.uid()); + +-- Users can leave groups (delete themselves) +CREATE POLICY "Users can leave groups" ON group_members + FOR DELETE USING (user_id = auth.uid()); + +-- Group admins can manage members +CREATE POLICY "Admins can manage members" ON group_members + FOR ALL USING ( + group_id IN ( + SELECT group_id FROM group_members + WHERE user_id = auth.uid() AND role = 'admin' + ) + ); +``` + +### Friends table policies +```sql +-- Enable RLS +ALTER TABLE friends ENABLE ROW LEVEL SECURITY; + +-- Users can view friendships they are part of +CREATE POLICY "Users can view their friendships" ON friends + FOR SELECT USING ( + user1_id = auth.uid() OR user2_id = auth.uid() + ); + +-- Users can send friend requests (as user1_id where user1_id is less than user2_id) +CREATE POLICY "Users can send friend requests" ON friends + FOR INSERT WITH CHECK (user1_id = auth.uid()); + +-- Users can accept friend requests (update where they are user2_id) +CREATE POLICY "Users can accept friend requests" ON friends + FOR UPDATE USING ( + (user1_id = auth.uid() OR user2_id = auth.uid()) + AND status = 'pending' + ); + +-- Users can delete friendships (cancel requests, unfriend) +CREATE POLICY "Users can delete friendships" ON friends + FOR DELETE USING ( + user1_id = auth.uid() OR user2_id = auth.uid() + ); +``` + +## Migration Commands (If Tables Already Exist) + +If you already have the `groups` table, run these commands to add the new columns: + +```sql +-- Add privacy column +ALTER TABLE groups ADD COLUMN privacy VARCHAR(10) DEFAULT 'public' CHECK (privacy IN ('public', 'private')); + +-- Add status column for temporary groups +ALTER TABLE groups ADD COLUMN status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('pending', 'active', 'deleted')); + +-- Add expiration column for cleanup +ALTER TABLE groups ADD COLUMN expires_at TIMESTAMP WITH TIME ZONE; + +-- Add indexes for new columns +CREATE INDEX idx_groups_status ON groups(status); +CREATE INDEX idx_groups_expires_at ON groups(expires_at); +CREATE INDEX idx_groups_privacy ON groups(privacy); +``` + +## Setup Instructions + +1. **Create the tables** in your Supabase SQL editor (or run migration commands if tables exist) +2. **Set up RLS policies** for security +3. **Test the policies** by creating a group and joining it +4. **Update your `.env.local`** with Supabase credentials + +## Notes + +- Group codes are 6 characters long (A-Z, 0-9) +- The creator automatically becomes an admin member +- RLS ensures users can only see groups they belong to +- All timestamps use UTC timezone +- **Privacy levels**: 'public' (anyone can join) or 'private' (invite only) +- **Status levels**: 'pending' (temporary, expires in 3 days), 'active' (permanent), 'deleted' (soft delete) +- **Temporary groups**: Groups created without members start as 'pending' and expire after 3 days +- **Group activation**: When someone joins a pending group, it becomes 'active' and expires_at is set to null diff --git a/apps/web/app/api/friends/requests/route.js b/apps/web/app/api/friends/requests/route.js new file mode 100644 index 0000000..9e78467 --- /dev/null +++ b/apps/web/app/api/friends/requests/route.js @@ -0,0 +1,163 @@ +// app/api/friends/requests/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get pending friend requests (both sent and received) + const { data: friendships, error: requestsError } = await supabase + .from('friendships') + .select('id, user_id, friend_id, status, created_at') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`) + .eq('status', 'pending'); + + if (requestsError) { + console.error('Error fetching friend requests:', requestsError); + return NextResponse.json({ error: 'Failed to fetch friend requests' }, { status: 500 }); + } + + // Categorize requests + const sent = []; + const received = []; + + // Get all unique friend IDs + const friendIds = [...new Set(friendships.map(f => f.user_id === user.id ? f.friend_id : f.user_id))]; + + // Fetch user details from the public users table + for (const friendId of friendIds) { + const { data: friendUser } = await supabase + .from('users') + .select('id, username, display_name') + .eq('id', friendId) + .single(); + + if (friendUser) { + const friendship = friendships.find(f => + (f.user_id === user.id && f.friend_id === friendId) || + (f.user_id === friendId && f.friend_id === user.id) + ); + + if (friendship) { + const friendInfo = { + id: friendUser.id, + email: '', + name: friendUser.display_name || friendUser.username, + username: friendUser.username, + friendship_id: friendship.id, + created_at: friendship.created_at + }; + + if (friendship.user_id === user.id) { + sent.push(friendInfo); + } else { + received.push(friendInfo); + } + } + } + } + + return NextResponse.json({ + success: true, + sent, + received + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function PATCH(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { friendshipId, action } = body; + + if (!friendshipId || !action) { + return NextResponse.json({ error: 'Friendship ID and action are required' }, { status: 400 }); + } + + if (!['accept', 'reject'].includes(action)) { + return NextResponse.json({ error: 'Action must be accept or reject' }, { status: 400 }); + } + + // Check if friendship exists and user is the recipient + const { data: friendship, error: checkError } = await supabase + .from('friendships') + .select('id, user_id, friend_id, status') + .eq('id', friendshipId) + .eq('status', 'pending') + .single(); + + if (checkError || !friendship) { + return NextResponse.json({ error: 'Friend request not found or already processed' }, { status: 404 }); + } + + // Verify user is the recipient (friend_id) + if (friendship.friend_id !== user.id) { + return NextResponse.json({ error: 'Unauthorized to perform this action' }, { status: 403 }); + } + + if (action === 'accept') { + // Update status to accepted + const { error: updateError } = await supabase + .from('friendships') + .update({ + status: 'accepted', + updated_at: new Date().toISOString() + }) + .eq('id', friendshipId); + + if (updateError) { + console.error('Error accepting friend request:', updateError); + return NextResponse.json({ error: 'Failed to accept friend request' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Friend request accepted' + }); + + } else if (action === 'reject') { + // Delete the friendship record + const { error: deleteError } = await supabase + .from('friendships') + .delete() + .eq('id', friendshipId); + + if (deleteError) { + console.error('Error rejecting friend request:', deleteError); + return NextResponse.json({ error: 'Failed to reject friend request' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + message: 'Friend request rejected' + }); + } + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/apps/web/app/api/friends/route.js b/apps/web/app/api/friends/route.js new file mode 100644 index 0000000..44b5049 --- /dev/null +++ b/apps/web/app/api/friends/route.js @@ -0,0 +1,174 @@ +// app/api/friends/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get all accepted friends for the current user + const { data: friendships, error: friendsError } = await supabase + .from('friendships') + .select(` + id, + user_id, + friend_id, + status, + created_at + `) + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`) + .eq('status', 'accepted'); + + if (friendsError) { + console.error('Error fetching friends:', friendsError); + return NextResponse.json({ error: 'Failed to fetch friends' }, { status: 500 }); + } + + console.log('Friendships found:', friendships); + + // Get unique friend IDs + const friendIds = [...new Set(friendships.map(f => f.user_id === user.id ? f.friend_id : f.user_id))]; + + // Fetch user details from the public users table + const friends = []; + for (const friendId of friendIds) { + const { data: friendUser } = await supabase + .from('users') + .select('id, username, display_name') + .eq('id', friendId) + .single(); + + if (friendUser) { + const friendship = friendships.find(f => f.user_id === friendId || f.friend_id === friendId); + friends.push({ + id: friendUser.id, + email: '', + name: friendUser.display_name || friendUser.username, + username: friendUser.username, + friendship_id: friendship?.id, + created_at: friendship?.created_at + }); + } + } + + return NextResponse.json({ + success: true, + friends + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { friendId } = body; + + console.log('POST /api/friends - friendId:', friendId); + + if (!friendId) { + return NextResponse.json({ error: 'Friend ID is required' }, { status: 400 }); + } + + if (friendId === user.id) { + return NextResponse.json({ error: 'You cannot send a friend request to yourself' }, { status: 400 }); + } + + // Check if user exists in the public users table (don't need admin for this) + const { data: friendUser, error: userError } = await supabase + .from('users') + .select('id') + .eq('id', friendId) + .single(); + + if (userError || !friendUser) { + console.error('User not found in users table:', userError); + return NextResponse.json({ error: 'User not found' }, { status: 404 }); + } + + // Check if friendship already exists (in either direction) + const { data: existingFriendships } = await supabase + .from('friendships') + .select('id, status') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`); + + // Filter to check if there's a friendship with the specific friend + const existingFriendship = existingFriendships?.find(f => + (f.user_id === user.id && f.friend_id === friendId) || + (f.user_id === friendId && f.friend_id === user.id) + ); + + if (existingFriendship) { + if (existingFriendship.status === 'accepted') { + return NextResponse.json({ error: 'You are already friends' }, { status: 400 }); + } else if (existingFriendship.status === 'pending') { + return NextResponse.json({ error: 'Friend request already sent or pending' }, { status: 400 }); + } + } + + // Create friend request (user_id is sender, friend_id is receiver) + console.log('Creating friend request:', { user_id: user.id, friend_id: friendId }); + + const { data: friendship, error: friendshipError } = await supabase + .from('friendships') + .insert({ + user_id: user.id, + friend_id: friendId, + status: 'pending' + }) + .select() + .single(); + + if (friendshipError) { + console.error('Error creating friend request:', friendshipError); + return NextResponse.json({ error: 'Failed to send friend request', details: friendshipError }, { status: 500 }); + } + + console.log('✅ Friend request created successfully in database:', { + id: friendship.id, + user_id: friendship.user_id, + friend_id: friendship.friend_id, + status: friendship.status + }); + + // Verify it was saved by querying it back + const { data: verify } = await supabase + .from('friendships') + .select('*') + .eq('id', friendship.id) + .single(); + + console.log('🔍 Verified in database:', verify ? 'EXISTS' : 'NOT FOUND'); + + return NextResponse.json({ + success: true, + message: 'Friend request sent', + friendship + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/apps/web/app/api/groups/join/route.js b/apps/web/app/api/groups/join/route.js new file mode 100644 index 0000000..d9d2350 --- /dev/null +++ b/apps/web/app/api/groups/join/route.js @@ -0,0 +1,80 @@ +// app/api/groups/join/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { code } = body; + + if (!code || code.trim().length === 0) { + return NextResponse.json({ error: 'Group code is required' }, { status: 400 }); + } + + // Find the group by code + const { data: group, error: groupError } = await supabase + .from('groups') + .select('id, name, description, code') + .eq('code', code.trim().toUpperCase()) + .single(); + + if (groupError || !group) { + return NextResponse.json({ error: 'Invalid group code' }, { status: 404 }); + } + + // Check if user is already a member + const { data: existingMember } = await supabase + .from('group_members') + .select('id') + .eq('group_id', group.id) + .eq('user_id', user.id) + .single(); + + if (existingMember) { + return NextResponse.json({ error: 'You are already a member of this group' }, { status: 400 }); + } + + // Add user as a member + const { data: member, error: memberError } = await supabase + .from('group_members') + .insert({ + group_id: group.id, + user_id: user.id, + role: 'member', + joined_at: new Date().toISOString(), + }) + .select() + .single(); + + if (memberError) { + console.error('Error joining group:', memberError); + return NextResponse.json({ error: 'Failed to join group' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + group: { + id: group.id, + name: group.name, + description: group.description, + code: group.code, + role: 'member', + joined_at: member.joined_at, + } + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/groups/route.js b/apps/web/app/api/groups/route.js new file mode 100644 index 0000000..f283931 --- /dev/null +++ b/apps/web/app/api/groups/route.js @@ -0,0 +1,158 @@ +// app/api/groups/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +// Generate a unique group code +function generateGroupCode() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export async function POST(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const body = await request.json(); + const { name, description } = body; + + if (!name || name.trim().length === 0) { + return NextResponse.json({ error: 'Group name is required' }, { status: 400 }); + } + + // Generate unique group code + let groupCode; + let isUnique = false; + let attempts = 0; + const maxAttempts = 10; + + while (!isUnique && attempts < maxAttempts) { + groupCode = generateGroupCode(); + + // Check if code already exists + const { data: existingGroup } = await supabase + .from('groups') + .select('id') + .eq('code', groupCode) + .single(); + + if (!existingGroup) { + isUnique = true; + } + attempts++; + } + + if (!isUnique) { + return NextResponse.json({ error: 'Failed to generate unique group code' }, { status: 500 }); + } + + // Create the group + const { data: group, error: groupError } = await supabase + .from('groups') + .insert({ + name: name.trim(), + description: description?.trim() || null, + code: groupCode, + created_by: user.id, + created_at: new Date().toISOString(), + }) + .select() + .single(); + + if (groupError) { + console.error('Error creating group:', groupError); + return NextResponse.json({ error: 'Failed to create group' }, { status: 500 }); + } + + // Add the creator as a member of the group + const { error: memberError } = await supabase + .from('group_members') + .insert({ + group_id: group.id, + user_id: user.id, + role: 'admin', + joined_at: new Date().toISOString(), + }); + + if (memberError) { + console.error('Error adding creator as member:', memberError); + // Don't fail the request, just log the error + } + + return NextResponse.json({ + success: true, + group: { + id: group.id, + name: group.name, + description: group.description, + code: group.code, + created_at: group.created_at, + } + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // Get groups where user is a member + const { data: groups, error: groupsError } = await supabase + .from('group_members') + .select(` + group_id, + role, + joined_at, + groups ( + id, + name, + description, + code, + created_at, + created_by + ) + `) + .eq('user_id', user.id) + .order('joined_at', { ascending: false }); + + if (groupsError) { + console.error('Error fetching groups:', groupsError); + return NextResponse.json({ error: 'Failed to fetch groups' }, { status: 500 }); + } + + return NextResponse.json({ + success: true, + groups: groups.map(member => ({ + ...member.groups, + role: member.role, + joined_at: member.joined_at, + })) + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/apps/web/app/api/import-playlist/route.js b/apps/web/app/api/import-playlist/route.js index 86b5d4d..0fd7fe4 100644 --- a/apps/web/app/api/import-playlist/route.js +++ b/apps/web/app/api/import-playlist/route.js @@ -240,7 +240,7 @@ async function importYouTubePlaylist(supabase, playlistUrl, userId) { async function importSpotifyPlaylist(supabase, playlistUrl, userId) { // Extract playlist ID from URL const playlistId = extractSpotifyPlaylistId(playlistUrl); - if (!playlistId) { + if (!playlistId || !isValidSpotifyPlaylistId(playlistId)) { throw new Error('Invalid Spotify playlist URL'); } @@ -320,6 +320,11 @@ function extractSpotifyPlaylistId(url) { return match ? match[1] : null; } +function isValidSpotifyPlaylistId(playlistId) { + // Spotify playlist IDs are 22 character base62 strings (alphanumeric, upper/lowercase) + return typeof playlistId === 'string' && /^[a-zA-Z0-9]{22}$/.test(playlistId); +} + function parseYouTubeDuration(duration) { // Parse ISO 8601 duration format (PT1H2M3S) const match = duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); diff --git a/apps/web/app/api/users/search/route.js b/apps/web/app/api/users/search/route.js new file mode 100644 index 0000000..6a2024d --- /dev/null +++ b/apps/web/app/api/users/search/route.js @@ -0,0 +1,125 @@ +// app/api/users/search/route.js +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export async function GET(request) { + try { + const cookieStore = await cookies(); + const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); + + // Get the current user + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const query = searchParams.get('q'); + + // If no query, return all users (for browsing) + if (!query || query.trim().length === 0) { + const { data: allUsers, error: allUsersError } = await supabase + .from('users') + .select('id, username, display_name') + .neq('id', user.id) + .limit(50); + + if (allUsersError) { + console.error('Error fetching all users:', allUsersError); + return NextResponse.json({ error: 'Failed to fetch users' }, { status: 500 }); + } + + if (!allUsers || allUsers.length === 0) { + return NextResponse.json({ + success: true, + users: [] + }); + } + + // Get existing friendships + const { data: existingFriends } = await supabase + .from('friendships') + .select('user_id, friend_id, status') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`); + + const users = allUsers.map(u => { + const friendship = existingFriends?.find(f => + (f.user_id === user.id && f.friend_id === u.id) || + (f.user_id === u.id && f.friend_id === user.id) + ); + + return { + id: u.id, + email: u.email || '', + name: u.display_name || u.username || 'User', + username: u.username || '', + friendship_status: friendship?.status || null + }; + }); + + return NextResponse.json({ + success: true, + users + }); + } + + // Search the public users table + const searchQuery = query.toLowerCase(); + + console.log('Searching for:', searchQuery); + + const { data: matchingUsers, error: searchError } = await supabase + .from('users') + .select('id, username, display_name') + .or(`username.ilike.%${searchQuery}%,display_name.ilike.%${searchQuery}%`) + .neq('id', user.id) + .limit(20); + + if (searchError) { + console.error('Error searching users:', searchError); + return NextResponse.json({ error: 'Failed to search users' }, { status: 500 }); + } + + console.log('Found users:', matchingUsers); + + if (!matchingUsers || matchingUsers.length === 0) { + return NextResponse.json({ + success: true, + users: [] + }); + } + + // Get existing friendships for these users to show status + const userIds = matchingUsers.map(u => u.id); + const { data: existingFriends } = await supabase + .from('friendships') + .select('user_id, friend_id, status') + .or(`user_id.eq.${user.id},friend_id.eq.${user.id}`); + + const users = matchingUsers.map(u => { + const friendship = existingFriends?.find(f => + (f.user_id === user.id && f.friend_id === u.id) || + (f.user_id === u.id && f.friend_id === user.id) + ); + + return { + id: u.id, + email: u.email || '', + name: u.display_name || u.username || 'User', + username: u.username || '', + friendship_status: friendship?.status || null + }; + }); + + return NextResponse.json({ + success: true, + users + }); + + } catch (error) { + console.error('Unexpected error:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/apps/web/app/groups/page.jsx b/apps/web/app/groups/page.jsx index 2fdf7fd..dba0c2d 100644 --- a/apps/web/app/groups/page.jsx +++ b/apps/web/app/groups/page.jsx @@ -1,9 +1,9 @@ 'use client'; -import { useState, useEffect } from 'react'; import { supabaseBrowser } from '@/lib/supabase/client'; +import { Plus, Users } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { Users, Plus } from 'lucide-react'; +import { useEffect, useState } from 'react'; export default function GroupsPage() { const supabase = supabaseBrowser(); diff --git a/apps/web/app/profile/page.jsx b/apps/web/app/profile/page.jsx new file mode 100644 index 0000000..f5b7c5a --- /dev/null +++ b/apps/web/app/profile/page.jsx @@ -0,0 +1,275 @@ +// app/profile/page.jsx +'use client'; + +import AddFriendsModal from '@/components/AddFriendsModal'; +import FriendRequestsModal from '@/components/FriendRequestsModal'; +import { supabaseBrowser } from '@/lib/supabase/client'; +import { Calendar, Heart, Mail, Music, Settings, UserPlus, Users } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +export default function ProfilePage() { + const supabase = supabaseBrowser(); + const router = useRouter(); + const [user, setUser] = useState(null); + const [friends, setFriends] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddFriendsModal, setShowAddFriendsModal] = useState(false); + const [showFriendRequestsModal, setShowFriendRequestsModal] = useState(false); + const [groupsCount, setGroupsCount] = useState(0); + + useEffect(() => { + checkAuth(); + fetchFriends(); + fetchGroupsCount(); + }, []); + + async function checkAuth() { + const { data: { session }, error } = await supabase.auth.getSession(); + + if (error || !session) { + router.push('/sign-in'); + return; + } + + setUser(session.user); + setLoading(false); + } + + const fetchFriends = async () => { + try { + const response = await fetch('/api/friends'); + const data = await response.json(); + + if (data.success) { + setFriends(data.friends || []); + } + } catch (error) { + console.error('Error fetching friends:', error); + } + }; + + const fetchGroupsCount = async () => { + try { + const response = await fetch('/api/groups'); + const data = await response.json(); + if (data.success) { + setGroupsCount(data.groups?.length || 0); + } + } catch (error) { + console.error('Error fetching groups:', error); + } + }; + + async function signOut() { + await supabase.auth.signOut(); + router.push('/sign-in'); + } + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + if (!user) return null; + + // Get user joined date + const joinedDate = new Date(user.created_at).toLocaleDateString(); + + return ( +
+
+ {/* Profile Header Card */} +
+
+ {/* Avatar */} +
+ {user.email?.charAt(0).toUpperCase() || 'U'} +
+ + {/* User Info */} +
+
+

+ {user.user_metadata?.full_name || user.email?.split('@')[0]} +

+

{user.email}

+
+ + + Joined {joinedDate} + +
+
+ + {/* Stats */} +
+
+
+ + 0 +
+

Song Today

+
+
+
+ + {groupsCount} +
+

Groups

+
+
+
+ + {friends.length} +
+

Friends

+
+
+
+ + {/* Action Buttons */} +
+ + + + + Settings + +
+
+
+ + {/* Song of the Day Section */} +
+
+

+ + Song of the Day +

+
+
+ +

No song of the day yet

+

Share your current favorite song with friends

+ +
+
+ + {/* Quick Stats */} +
+ {/* Recent Activity */} +
+

+ + Recent Activity +

+
+
+ Groups joined this week + 0 +
+
+ Songs shared this month + 0 +
+
+ Playlists collaborated on + 0 +
+
+
+ + {/* Friends Section */} +
+
+

+ + Friends ({friends.length}) +

+
+ + {friends.length === 0 ? ( +
+ +

No friends yet

+ +
+ ) : ( +
+ {friends.slice(0, 5).map((friend) => ( +
+
+ {friend.name?.charAt(0).toUpperCase() || 'F'} +
+
+

{friend.name}

+

@{friend.username}

+
+
+ ))} + {friends.length > 5 && ( +

+ +{friends.length - 5} more friends +

+ )} +
+ )} +
+
+ + {/* Bottom spacing */} +
+
+ + {/* Add Friends Modal */} + {showAddFriendsModal && ( + { + setShowAddFriendsModal(false); + fetchFriends(); // Refresh friends list after closing modal + }} + /> + )} + + {/* Friend Requests Modal */} + {showFriendRequestsModal && ( + { + setShowFriendRequestsModal(false); + fetchFriends(); // Refresh friends list after closing modal + }} + /> + )} +
+ ); +} diff --git a/apps/web/check_users_table.sql b/apps/web/check_users_table.sql new file mode 100644 index 0000000..b61d073 --- /dev/null +++ b/apps/web/check_users_table.sql @@ -0,0 +1,24 @@ +-- Run this in your Supabase SQL Editor to check if the users table has data + +-- Check if users table exists +SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'users' +) AS users_table_exists; + +-- Count users in public.users +SELECT COUNT(*) as user_count FROM users; + +-- Show all users +SELECT id, username, display_name, created_at FROM users LIMIT 10; + +-- Check auth.users (should have your actual users) +SELECT id, email, created_at FROM auth.users LIMIT 10; + +-- Compare: which auth.users don't have a profile yet? +SELECT au.id, au.email, au.created_at +FROM auth.users au +LEFT JOIN users u ON au.id = u.id +WHERE u.id IS NULL; + diff --git a/apps/web/components/AddFriendsModal.jsx b/apps/web/components/AddFriendsModal.jsx new file mode 100644 index 0000000..cdf4773 --- /dev/null +++ b/apps/web/components/AddFriendsModal.jsx @@ -0,0 +1,183 @@ +// components/AddFriendsModal.jsx +'use client'; + +import { Plus, Search, UserPlus, X } from 'lucide-react'; +import { useState } from 'react'; + +export default function AddFriendsModal({ onClose }) { + const [searchQuery, setSearchQuery] = useState(''); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [searching, setSearching] = useState(false); + const [error, setError] = useState(''); + const [view, setView] = useState('search'); // 'search', 'browse', 'invite' + + const handleSearch = async (e) => { + e.preventDefault(); + if (!searchQuery.trim()) return; + + setSearching(true); + setError(''); + + try { + const response = await fetch(`/api/users/search?q=${encodeURIComponent(searchQuery)}`); + const data = await response.json(); + + if (data.success) { + setUsers(data.users || []); + } else { + setError(data.error || 'Failed to search users'); + } + } catch (error) { + setError('Network error. Please try again.'); + } finally { + setSearching(false); + } + }; + + const handleBrowseAll = async () => { + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/users/search?q='); + const data = await response.json(); + + if (data.success) { + setUsers(data.users || []); + } + } catch (error) { + setError('Failed to load users'); + } finally { + setLoading(false); + } + }; + + const handleSendRequest = async (userId) => { + try { + const response = await fetch('/api/friends', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ friendId: userId }), + }); + + const data = await response.json(); + + if (data.success) { + // Update the user status in the list + setUsers(prev => prev.map(u => + u.id === userId ? { ...u, friendship_status: 'pending' } : u + )); + } else { + alert(data.error || 'Failed to send friend request'); + } + } catch (error) { + alert('Network error. Please try again.'); + } + }; + + return ( +
+
+
+
+
+ +
+

Add Friends

+
+ +
+ +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-400/50 focus:border-blue-400/50" + placeholder="Search by username..." + disabled={searching} + /> + +
+
+ + + + {error && ( +
+

{error}

+
+ )} + +
+ {users.length > 0 && ( + <> +

+ Found {users.length} result{users.length !== 1 ? 's' : ''} +

+ {users.map((user) => ( +
+
+

{user.name}

+

{user.email}

+
+ {user.friendship_status === null && ( + + )} + {user.friendship_status === 'pending' && ( + + Pending + + )} + {user.friendship_status === 'accepted' && ( + + Friends + + )} +
+ ))} + + )} + {users.length === 0 && searchQuery && !searching && ( +
+ +

No users found

+
+ )} +
+
+
+ ); +} + diff --git a/apps/web/components/CreateGroupModal.jsx b/apps/web/components/CreateGroupModal.jsx new file mode 100644 index 0000000..7d90708 --- /dev/null +++ b/apps/web/components/CreateGroupModal.jsx @@ -0,0 +1,126 @@ +// components/CreateGroupModal.jsx +'use client'; + +import { Users, X } from 'lucide-react'; +import { useState } from 'react'; + +export default function CreateGroupModal({ onClose, onGroupCreated }) { + const [formData, setFormData] = useState({ + name: '', + description: '', + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(''); + + try { + const response = await fetch('/api/groups', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(formData), + }); + + const data = await response.json(); + + if (data.success) { + onGroupCreated(data.group); + } else { + setError(data.error || 'Failed to create group'); + } + } catch (error) { + setError('Network error. Please try again.'); + } finally { + setLoading(false); + } + }; + + const handleChange = (e) => { + setFormData(prev => ({ + ...prev, + [e.target.name]: e.target.value, + })); + }; + + return ( +
+
+
+
+
+ +
+

Create Group

+
+ +
+ +
+
+ + +
+ +
+ +