Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions apps/web/DATABASE_SCHEMA.md
Original file line number Diff line number Diff line change
@@ -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
163 changes: 163 additions & 0 deletions apps/web/app/api/friends/requests/route.js
Original file line number Diff line number Diff line change
@@ -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 });
}
}

Loading
Loading