Files
Todo/lib/sharing-actions.ts
Paul Bailey b73f761308 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
2025-12-02 00:57:50 +00:00

609 lines
16 KiB
TypeScript

'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<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
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.')
}
}