From b73f761308aeb2405f6ad949eb4eb8af69b266ea Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Tue, 2 Dec 2025 00:57:50 +0000 Subject: [PATCH] Implement invitation system with server actions - Add inviteUserToList: Create invitations with email validation and expiration - Add getInvitations: Retrieve pending invitations for current user - Add acceptInvitation: Accept invitation and create collaborator record - Add declineInvitation: Decline and remove invitation - Add getCollaborators: List all collaborators for a list - Add updateCollaboratorPermission: Change collaborator permission level - Add removeCollaborator: Revoke collaborator access - Include proper authorization checks and error handling - Add invitation expiration logic (7 days) - Implement transaction for atomic invitation acceptance Requirements: 5.1, 5.2, 5.3, 8.1, 8.5 --- lib/sharing-actions.ts | 608 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 lib/sharing-actions.ts diff --git a/lib/sharing-actions.ts b/lib/sharing-actions.ts new file mode 100644 index 0000000..ed1357c --- /dev/null +++ b/lib/sharing-actions.ts @@ -0,0 +1,608 @@ +'use server' + +import { prisma } from './db' +import { revalidatePath } from 'next/cache' +import { auth } from '@/auth' +import { z } from 'zod' + +// Validation schemas +const inviteSchema = z.object({ + email: z.string().email({ message: 'Invalid email address' }), + permission: z.enum(['editor', 'viewer'], { message: 'Permission must be either editor or viewer' }), +}) + +// Retry utility for database operations +async function retryOperation( + operation: () => Promise, + maxRetries: number = 3, + delayMs: number = 1000 +): Promise { + let lastError: Error | undefined + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await operation() + } catch (error) { + lastError = error as Error + + // Don't retry on validation errors or not found errors + if ( + error instanceof Error && + (error.message.includes('Unique constraint') || + error.message.includes('Record to update not found') || + error.message.includes('Record to delete does not exist')) + ) { + throw error + } + + // Wait before retrying (exponential backoff) + if (attempt < maxRetries - 1) { + await new Promise(resolve => setTimeout(resolve, delayMs * Math.pow(2, attempt))) + } + } + } + + throw lastError +} + +/** + * Invite a user to collaborate on a list + * Requirements: 5.1, 6.1 + */ +export async function inviteUserToList( + listId: string, + email: string, + permission: 'editor' | 'viewer' +) { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to invite users') + } + + const userId = session.user.id + + // Validate input + if (!listId) { + throw new Error('List ID is required') + } + + const validation = inviteSchema.safeParse({ email, permission }) + if (!validation.success) { + const errors = validation.error.issues.map((issue) => issue.message).join(', ') + throw new Error(errors) + } + + const { email: validEmail, permission: validPermission } = validation.data + + // Check if user is the owner of the list + const list = await retryOperation(() => + prisma.list.findFirst({ + where: { + id: listId, + ownerId: userId, + }, + }) + ) + + if (!list) { + throw new Error('List not found or you do not have permission to invite users') + } + + // Check if the invited email is the owner's email + if (validEmail === session.user.email) { + throw new Error('You cannot invite yourself to your own list') + } + + // Check if user is already a collaborator + const existingCollaborator = await retryOperation(() => + prisma.collaborator.findFirst({ + where: { + listId: listId, + user: { + email: validEmail, + }, + }, + }) + ) + + if (existingCollaborator) { + throw new Error('User is already a collaborator on this list') + } + + // Check if there's already a pending invitation + const existingInvitation = await retryOperation(() => + prisma.invitation.findFirst({ + where: { + listId: listId, + email: validEmail, + status: 'pending', + }, + }) + ) + + if (existingInvitation) { + throw new Error('An invitation has already been sent to this email') + } + + // Create invitation with expiration (7 days from now) + const expiresAt = new Date() + expiresAt.setDate(expiresAt.getDate() + 7) + + const invitation = await retryOperation(() => + prisma.invitation.create({ + data: { + email: validEmail, + listId: listId, + inviterId: userId, + permission: validPermission, + expiresAt: expiresAt, + }, + include: { + list: true, + inviter: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }) + ) + + revalidatePath(`/list/${listId}`) + return invitation + } catch (error) { + console.error('Error inviting user:', error) + if (error instanceof Error && ( + error.message.includes('required') || + error.message.includes('Invalid') || + error.message.includes('Unauthorized') || + error.message.includes('permission') || + error.message.includes('already') || + error.message.includes('cannot') + )) { + throw error + } + throw new Error('Failed to send invitation. Please try again.') + } +} + +/** + * Get pending invitations for the current user + * Requirements: 8.1, 8.2 + */ +export async function getInvitations() { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id || !session?.user?.email) { + throw new Error('Unauthorized: Please log in to view invitations') + } + + const userEmail = session.user.email + + // Get all pending invitations for this user's email + const invitations = await retryOperation(() => + prisma.invitation.findMany({ + where: { + email: userEmail, + status: 'pending', + expiresAt: { + gt: new Date(), // Only get non-expired invitations + }, + }, + include: { + list: true, + inviter: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }) + ) + + return invitations + } catch (error) { + console.error('Error fetching invitations:', error) + if (error instanceof Error && error.message.includes('Unauthorized')) { + throw error + } + throw new Error('Failed to load invitations. Please try again.') + } +} + +/** + * Accept an invitation and become a collaborator + * Requirements: 5.2 + */ +export async function acceptInvitation(invitationId: string) { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id || !session?.user?.email) { + throw new Error('Unauthorized: Please log in to accept invitations') + } + + const userId = session.user.id + const userEmail = session.user.email + + if (!invitationId) { + throw new Error('Invitation ID is required') + } + + // Get the invitation + const invitation = await retryOperation(() => + prisma.invitation.findUnique({ + where: { id: invitationId }, + include: { list: true }, + }) + ) + + if (!invitation) { + throw new Error('Invitation not found') + } + + // Verify the invitation is for this user + if (invitation.email !== userEmail) { + throw new Error('This invitation is not for you') + } + + // Check if invitation is still pending + if (invitation.status !== 'pending') { + throw new Error('This invitation has already been processed') + } + + // Check if invitation has expired + if (invitation.expiresAt < new Date()) { + throw new Error('This invitation has expired') + } + + // Check if user is already a collaborator (shouldn't happen, but just in case) + const existingCollaborator = await retryOperation(() => + prisma.collaborator.findFirst({ + where: { + userId: userId, + listId: invitation.listId, + }, + }) + ) + + if (existingCollaborator) { + // Update invitation status and return + await retryOperation(() => + prisma.invitation.update({ + where: { id: invitationId }, + data: { status: 'accepted' }, + }) + ) + throw new Error('You are already a collaborator on this list') + } + + // Create collaborator and update invitation status in a transaction + const result = await retryOperation(() => + prisma.$transaction(async (tx) => { + // Create collaborator + const collaborator = await tx.collaborator.create({ + data: { + userId: userId, + listId: invitation.listId, + permission: invitation.permission, + }, + }) + + // Update invitation status + await tx.invitation.update({ + where: { id: invitationId }, + data: { status: 'accepted' }, + }) + + return collaborator + }) + ) + + revalidatePath('/') + revalidatePath(`/list/${invitation.listId}`) + return result + } catch (error) { + console.error('Error accepting invitation:', error) + if (error instanceof Error && ( + error.message.includes('required') || + error.message.includes('not found') || + error.message.includes('Unauthorized') || + error.message.includes('not for you') || + error.message.includes('already') || + error.message.includes('expired') + )) { + throw error + } + throw new Error('Failed to accept invitation. Please try again.') + } +} + +/** + * Decline an invitation + * Requirements: 5.3 + */ +export async function declineInvitation(invitationId: string) { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id || !session?.user?.email) { + throw new Error('Unauthorized: Please log in to decline invitations') + } + + const userEmail = session.user.email + + if (!invitationId) { + throw new Error('Invitation ID is required') + } + + // Get the invitation + const invitation = await retryOperation(() => + prisma.invitation.findUnique({ + where: { id: invitationId }, + }) + ) + + if (!invitation) { + throw new Error('Invitation not found') + } + + // Verify the invitation is for this user + if (invitation.email !== userEmail) { + throw new Error('This invitation is not for you') + } + + // Check if invitation is still pending + if (invitation.status !== 'pending') { + throw new Error('This invitation has already been processed') + } + + // Delete the invitation (as per requirement 5.3 - remove the invitation record) + await retryOperation(() => + prisma.invitation.delete({ + where: { id: invitationId }, + }) + ) + + revalidatePath('/') + return { success: true } + } catch (error) { + console.error('Error declining invitation:', error) + if (error instanceof Error && ( + error.message.includes('required') || + error.message.includes('not found') || + error.message.includes('Unauthorized') || + error.message.includes('not for you') || + error.message.includes('already') + )) { + throw error + } + throw new Error('Failed to decline invitation. Please try again.') + } +} + +/** + * Get all collaborators for a list + * Requirements: 5.4 + */ +export async function getCollaborators(listId: string) { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to view collaborators') + } + + const userId = session.user.id + + if (!listId) { + throw new Error('List ID is required') + } + + // Check if user has access to the list + const list = await retryOperation(() => + prisma.list.findFirst({ + where: { + id: listId, + OR: [ + { ownerId: userId }, + { + collaborators: { + some: { + userId: userId, + }, + }, + }, + ], + }, + }) + ) + + if (!list) { + throw new Error('List not found or you do not have permission to view collaborators') + } + + // Get all collaborators + const collaborators = await retryOperation(() => + prisma.collaborator.findMany({ + where: { + listId: listId, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + orderBy: { + createdAt: 'asc', + }, + }) + ) + + return collaborators + } catch (error) { + console.error('Error fetching collaborators:', error) + if (error instanceof Error && ( + error.message.includes('required') || + error.message.includes('Unauthorized') || + error.message.includes('permission') + )) { + throw error + } + throw new Error('Failed to load collaborators. Please try again.') + } +} + +/** + * Update a collaborator's permission level + * Requirements: 6.5 + */ +export async function updateCollaboratorPermission( + listId: string, + collaboratorId: string, + permission: 'editor' | 'viewer' +) { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to update permissions') + } + + const userId = session.user.id + + if (!listId || !collaboratorId) { + throw new Error('List ID and collaborator ID are required') + } + + const validation = z.enum(['editor', 'viewer']).safeParse(permission) + if (!validation.success) { + throw new Error('Permission must be either editor or viewer') + } + + // Check if user is the owner of the list + const list = await retryOperation(() => + prisma.list.findFirst({ + where: { + id: listId, + ownerId: userId, + }, + }) + ) + + if (!list) { + throw new Error('List not found or you do not have permission to update permissions') + } + + // Update the collaborator's permission + const updated = await retryOperation(() => + prisma.collaborator.update({ + where: { + id: collaboratorId, + listId: listId, // Ensure the collaborator belongs to this list + }, + data: { + permission: permission, + }, + include: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }) + ) + + revalidatePath(`/list/${listId}`) + return updated + } catch (error) { + console.error('Error updating collaborator permission:', error) + if (error instanceof Error && ( + error.message.includes('required') || + error.message.includes('Unauthorized') || + error.message.includes('permission') || + error.message.includes('not found') + )) { + throw error + } + throw new Error('Failed to update permission. Please try again.') + } +} + +/** + * Remove a collaborator from a list + * Requirements: 5.5 + */ +export async function removeCollaborator( + listId: string, + collaboratorId: string +) { + try { + // Get current user session + const session = await auth() + if (!session?.user?.id) { + throw new Error('Unauthorized: Please log in to remove collaborators') + } + + const userId = session.user.id + + if (!listId || !collaboratorId) { + throw new Error('List ID and collaborator ID are required') + } + + // Check if user is the owner of the list + const list = await retryOperation(() => + prisma.list.findFirst({ + where: { + id: listId, + ownerId: userId, + }, + }) + ) + + if (!list) { + throw new Error('List not found or you do not have permission to remove collaborators') + } + + // Delete the collaborator + await retryOperation(() => + prisma.collaborator.delete({ + where: { + id: collaboratorId, + listId: listId, // Ensure the collaborator belongs to this list + }, + }) + ) + + revalidatePath(`/list/${listId}`) + return { success: true } + } catch (error) { + console.error('Error removing collaborator:', error) + if (error instanceof Error && ( + error.message.includes('required') || + error.message.includes('Unauthorized') || + error.message.includes('permission') || + error.message.includes('not found') + )) { + throw error + } + throw new Error('Failed to remove collaborator. Please try again.') + } +}