'use server' import { prisma } from './db' import { revalidatePath } from 'next/cache' import { getServerSession } from 'next-auth' import { authOptions } from '@/pages/api/auth/[...nextauth]' import { z } from 'zod' import { checkRateLimit, INVITATION_RATE_LIMIT } from './rate-limit' // 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 getServerSession(authOptions) if (!session?.user?.id) { throw new Error('Unauthorized: Please log in to invite users') } const userId = session.user.id // Rate limiting - Requirement 9.3 const rateLimitKey = `invite:${userId}` const rateLimit = checkRateLimit(rateLimitKey, INVITATION_RATE_LIMIT) if (!rateLimit.allowed) { const minutesUntilReset = Math.ceil((rateLimit.retryAfter || 0) / 60000) throw new Error(`Too many invitation attempts. Please try again in ${minutesUntilReset} minutes.`) } // 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 getServerSession(authOptions) 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 getServerSession(authOptions) 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 getServerSession(authOptions) 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 getServerSession(authOptions) 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 getServerSession(authOptions) 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 getServerSession(authOptions) 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.') } }