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
This commit is contained in:
608
lib/sharing-actions.ts
Normal file
608
lib/sharing-actions.ts
Normal file
@@ -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<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.')
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user