feat: [US-044] - Project sharing and collaborator management

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gustavo Henrique Santos Souza de Miranda
2026-01-23 15:36:58 -03:00
co-authored by Claude Opus 4.5
parent 46b681462d
commit 37031c3889
7 changed files with 594 additions and 46 deletions
+227
View File
@@ -0,0 +1,227 @@
'use server'
import { createClient } from '@/lib/supabase/server'
export type Collaborator = {
id: string
user_id: string
role: 'owner' | 'editor' | 'viewer'
invited_at: string
accepted_at: string | null
display_name: string | null
email: string | null
}
export async function getCollaborators(
projectId: string
): Promise<{ success: boolean; data?: Collaborator[]; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.single()
if (!project) {
return { success: false, error: 'Project not found' }
}
const isOwner = project.user_id === user.id
// Check if user is a collaborator
if (!isOwner) {
const { data: collab } = await supabase
.from('project_collaborators')
.select('id')
.eq('project_id', projectId)
.eq('user_id', user.id)
.single()
if (!collab) {
return { success: false, error: 'Access denied' }
}
}
// Fetch collaborators with profile info
const { data: collaborators, error } = await supabase
.from('project_collaborators')
.select('id, user_id, role, invited_at, accepted_at, profiles(display_name, email)')
.eq('project_id', projectId)
if (error) {
return { success: false, error: error.message }
}
const result: Collaborator[] = (collaborators || []).map((c) => {
const profile = c.profiles as unknown as { display_name: string | null; email: string | null } | null
return {
id: c.id,
user_id: c.user_id,
role: c.role as 'owner' | 'editor' | 'viewer',
invited_at: c.invited_at,
accepted_at: c.accepted_at,
display_name: profile?.display_name || null,
email: profile?.email || null,
}
})
return { success: true, data: result }
}
export async function inviteCollaborator(
projectId: string,
email: string,
role: 'editor' | 'viewer'
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (!project) {
return { success: false, error: 'Only the project owner can invite collaborators' }
}
// Find the user by email in profiles
const { data: targetProfile } = await supabase
.from('profiles')
.select('id, email')
.eq('email', email)
.single()
if (!targetProfile) {
return { success: false, error: 'No user found with that email address' }
}
// Cannot invite yourself
if (targetProfile.id === user.id) {
return { success: false, error: 'You cannot invite yourself' }
}
// Check if already a collaborator
const { data: existing } = await supabase
.from('project_collaborators')
.select('id')
.eq('project_id', projectId)
.eq('user_id', targetProfile.id)
.single()
if (existing) {
return { success: false, error: 'This user is already a collaborator' }
}
// Insert collaborator
const { error: insertError } = await supabase
.from('project_collaborators')
.insert({
project_id: projectId,
user_id: targetProfile.id,
role,
invited_at: new Date().toISOString(),
accepted_at: new Date().toISOString(), // Auto-accept for now
})
if (insertError) {
return { success: false, error: insertError.message }
}
return { success: true }
}
export async function updateCollaboratorRole(
projectId: string,
collaboratorId: string,
role: 'editor' | 'viewer'
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (!project) {
return { success: false, error: 'Only the project owner can change roles' }
}
const { error } = await supabase
.from('project_collaborators')
.update({ role })
.eq('id', collaboratorId)
.eq('project_id', projectId)
if (error) {
return { success: false, error: error.message }
}
return { success: true }
}
export async function removeCollaborator(
projectId: string,
collaboratorId: string
): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
return { success: false, error: 'Not authenticated' }
}
// Verify user owns the project
const { data: project } = await supabase
.from('projects')
.select('id, user_id')
.eq('id', projectId)
.eq('user_id', user.id)
.single()
if (!project) {
return { success: false, error: 'Only the project owner can remove collaborators' }
}
const { error } = await supabase
.from('project_collaborators')
.delete()
.eq('id', collaboratorId)
.eq('project_id', projectId)
if (error) {
return { success: false, error: error.message }
}
return { success: true }
}