Files
LetsBeBiz-Redesign/letsbe-hub/src/app/api/v1/admin/staff/invite/route.ts

200 lines
7.0 KiB
TypeScript
Raw Normal View History

import { NextRequest, NextResponse } from 'next/server'
import crypto from 'crypto'
import { requireStaffPermission } from '@/lib/auth-helpers'
import { prisma } from '@/lib/prisma'
import { StaffRole } from '@prisma/client'
import { getAssignableRoles } from '@/lib/services/permission-service'
import { emailService } from '@/lib/services/email-service'
interface InviteStaffRequest {
email: string
role: StaffRole
}
/**
* POST /api/v1/admin/staff/invite
* Send a staff invitation
* Requires: staff:invite permission
*/
export async function POST(request: NextRequest) {
try {
const session = await requireStaffPermission('staff:invite')
const body: InviteStaffRequest = await request.json()
// Validate required fields
if (!body.email) {
return NextResponse.json(
{ error: 'Email is required' },
{ status: 400 }
)
}
if (!body.role) {
return NextResponse.json(
{ error: 'Role is required' },
{ status: 400 }
)
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return NextResponse.json(
{ error: 'Invalid email format' },
{ status: 400 }
)
}
// Validate role can be assigned
const assignableRoles = getAssignableRoles(session.user.role)
if (!assignableRoles.includes(body.role)) {
return NextResponse.json(
{ error: 'Cannot invite staff with this role' },
{ status: 403 }
)
}
// Check if email already exists as staff
const existingStaff = await prisma.staff.findUnique({
where: { email: body.email },
})
if (existingStaff) {
return NextResponse.json(
{ error: 'A staff member with this email already exists' },
{ status: 409 }
)
}
// Check if pending invitation exists
const existingInvite = await prisma.staffInvitation.findUnique({
where: { email: body.email },
})
if (existingInvite) {
return NextResponse.json(
{ error: 'A pending invitation for this email already exists' },
{ status: 409 }
)
}
// Generate secure token
const token = crypto.randomBytes(32).toString('hex')
// Create invitation with 7-day expiry
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7)
const invitation = await prisma.staffInvitation.create({
data: {
email: body.email,
role: body.role,
token,
expiresAt,
invitedBy: session.user.id,
},
select: {
id: true,
email: true,
role: true,
expiresAt: true,
createdAt: true,
},
})
// Get the inviter's name for the response
const inviter = await prisma.staff.findUnique({
where: { id: session.user.id },
select: { name: true, email: true },
})
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
const inviteUrl = `${baseUrl}/invite/${token}`
// Send invitation email
let emailSent = false
let emailError: string | undefined
const isEmailConfigured = await emailService.isConfigured()
if (isEmailConfigured) {
const inviterName = inviter?.name || inviter?.email || 'A team member'
const roleDisplay = body.role.charAt(0) + body.role.slice(1).toLowerCase()
const emailResult = await emailService.sendEmail({
to: body.email,
subject: `You've been invited to join LetsBe Hub`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<div style="background-color: #667eea; padding: 30px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px;">LetsBe Hub</h1>
</div>
<div style="padding: 40px 30px; background-color: #f9f9f9;">
<h2 style="color: #333333; margin-top: 0;">You're Invited!</h2>
<p style="color: #666666; line-height: 1.6; font-size: 16px;">
${inviterName} has invited you to join <strong>LetsBe Hub</strong> as a <strong>${roleDisplay}</strong>.
</p>
<p style="color: #666666; line-height: 1.6; font-size: 16px;">
Click the button below to create your account and get started.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin: 30px auto;">
<tr>
<td style="border-radius: 6px; background-color: #667eea;">
<a href="${inviteUrl}" target="_blank" style="background-color: #667eea; border: 15px solid #667eea; font-family: Arial, sans-serif; font-size: 16px; line-height: 1.1; text-align: center; text-decoration: none; display: block; border-radius: 6px; font-weight: bold;">
<span style="color: #ffffff;">Accept Invitation</span>
</a>
</td>
</tr>
</table>
<p style="color: #999999; font-size: 14px; margin-top: 30px;">
Or copy and paste this link into your browser:
</p>
<p style="color: #667eea; font-size: 14px; word-break: break-all;">
<a href="${inviteUrl}" style="color: #667eea;">${inviteUrl}</a>
</p>
<hr style="border: none; border-top: 1px solid #dddddd; margin: 30px 0;" />
<p style="color: #999999; font-size: 12px;">
This invitation expires in 7 days. If you didn't expect this invitation, you can safely ignore this email.
</p>
</div>
<div style="padding: 20px; background-color: #333333; text-align: center;">
<p style="color: #999999; margin: 0; font-size: 12px;">
LetsBe Hub - Infrastructure Management Platform
</p>
</div>
</div>
`,
text: `You've been invited to join LetsBe Hub!\n\n${inviterName} has invited you to join LetsBe Hub as a ${roleDisplay}.\n\nClick here to accept: ${inviteUrl}\n\nThis invitation expires in 7 days.`,
})
emailSent = emailResult.success
emailError = emailResult.error
}
return NextResponse.json(
{
...invitation,
invitedBy: inviter,
inviteUrl, // Always include URL so it can be copied manually if email fails
emailSent,
emailError,
message: emailSent
? 'Invitation sent successfully'
: isEmailConfigured
? 'Invitation created but email failed to send'
: 'Invitation created (email not configured)',
},
{ status: 201 }
)
} catch (error) {
if (typeof error === 'object' && error !== null && 'status' in error) {
const err = error as { status: number; message: string }
return NextResponse.json({ error: err.message }, { status: err.status })
}
console.error('Error creating invitation:', error)
return NextResponse.json(
{ error: 'Failed to create invitation' },
{ status: 500 }
)
}
}