From 4a3546988ae2f1f1ee5780400f24bdce072daeea Mon Sep 17 00:00:00 2001 From: Darius Robinson Date: Mon, 29 Sep 2025 10:41:59 -0400 Subject: [PATCH 1/5] feat: implement group creation with invite codes - Add backend API endpoints for creating and joining groups - Generate unique 6-character group codes - Create frontend components for group management - Add database schema documentation - Implement role-based access (admin/member) - Add copy-to-clipboard functionality for group codes Requires Supabase setup to be fully functional --- apps/web/DATABASE_SCHEMA.md | 124 +++++++++++++++ apps/web/app/api/groups/join/route.js | 80 ++++++++++ apps/web/app/api/groups/route.js | 158 ++++++++++++++++++++ apps/web/app/groups/page.jsx | 182 +++++++++++++++++++++++ apps/web/components/CreateGroupModal.jsx | 126 ++++++++++++++++ apps/web/components/JoinGroupModal.jsx | 115 ++++++++++++++ 6 files changed, 785 insertions(+) create mode 100644 apps/web/DATABASE_SCHEMA.md create mode 100644 apps/web/app/api/groups/join/route.js create mode 100644 apps/web/app/api/groups/route.js create mode 100644 apps/web/app/groups/page.jsx create mode 100644 apps/web/components/CreateGroupModal.jsx create mode 100644 apps/web/components/JoinGroupModal.jsx diff --git a/apps/web/DATABASE_SCHEMA.md b/apps/web/DATABASE_SCHEMA.md new file mode 100644 index 0000000..d7fc3fe --- /dev/null +++ b/apps/web/DATABASE_SCHEMA.md @@ -0,0 +1,124 @@ +# Database Schema for Groups Feature + +This document outlines the database tables needed for the group creation feature. + +## Required Tables + +### 1. `groups` table +```sql +CREATE TABLE groups ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT, + code VARCHAR(6) UNIQUE NOT NULL, + created_by UUID REFERENCES auth.users(id) ON DELETE CASCADE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Create index for faster code lookups +CREATE INDEX idx_groups_code ON groups(code); +CREATE INDEX idx_groups_created_by ON groups(created_by); +``` + +### 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); +``` + +## 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' + ) + ); +``` + +## Setup Instructions + +1. **Create the tables** in your Supabase SQL editor +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 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/groups/page.jsx b/apps/web/app/groups/page.jsx new file mode 100644 index 0000000..049f2be --- /dev/null +++ b/apps/web/app/groups/page.jsx @@ -0,0 +1,182 @@ +// app/groups/page.jsx +'use client'; + +import CreateGroupModal from '@/components/CreateGroupModal'; +import JoinGroupModal from '@/components/JoinGroupModal'; +import { Check, Copy, Plus, Users } from 'lucide-react'; +import { useEffect, useState } from 'react'; + +export default function GroupsPage() { + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(true); + const [showCreateModal, setShowCreateModal] = useState(false); + const [showJoinModal, setShowJoinModal] = useState(false); + const [copiedCode, setCopiedCode] = useState(null); + + useEffect(() => { + fetchGroups(); + }, []); + + const fetchGroups = async () => { + try { + const response = await fetch('/api/groups'); + const data = await response.json(); + + if (data.success) { + setGroups(data.groups); + } else { + console.error('Failed to fetch groups:', data.error); + } + } catch (error) { + console.error('Error fetching groups:', error); + } finally { + setLoading(false); + } + }; + + const handleGroupCreated = (newGroup) => { + setGroups(prev => [newGroup, ...prev]); + setShowCreateModal(false); + }; + + const handleGroupJoined = (newGroup) => { + setGroups(prev => [newGroup, ...prev]); + setShowJoinModal(false); + }; + + const copyGroupCode = async (code) => { + try { + await navigator.clipboard.writeText(code); + setCopiedCode(code); + setTimeout(() => setCopiedCode(null), 2000); + } catch (error) { + console.error('Failed to copy code:', error); + } + }; + + if (loading) { + return ( +
+
+
+

Loading groups...

+
+
+ ); + } + + return ( +
+
+
+

Groups

+

Create and join music groups with friends

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

No groups yet

+

Create your first group or join one with a code

+
+ + +
+
+ ) : ( +
+ {groups.map((group) => ( +
+
+
+

{group.name}

+ {group.description && ( +

{group.description}

+ )} +
+ + {group.role} + +
+ +
+
+ Code: + + {group.code} + +
+ +
+ +
+

+ Joined {new Date(group.joined_at).toLocaleDateString()} +

+
+
+ ))} +
+ )} + + {showCreateModal && ( + setShowCreateModal(false)} + onGroupCreated={handleGroupCreated} + /> + )} + + {showJoinModal && ( + setShowJoinModal(false)} + onGroupJoined={handleGroupJoined} + /> + )} +
+ ); +} 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

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