200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|