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...
+{user.email}
+Song Today
+Groups
+Friends
+Share your current favorite song with friends
+ +No friends yet
+ +{friend.name}
+@{friend.username}
++ +{friends.length - 5} more friends +
+ )} +{error}
++ Found {users.length} result{users.length !== 1 ? 's' : ''} +
+ {users.map((user) => ( +{user.name}
+{user.email}
+No users found
+Loading...
+{request.name}
+@{request.username}
+No pending requests
+ )} +{request.name}
+@{request.username}
+No sent requests
+ )} +