366 lines
10 KiB
TypeScript
366 lines
10 KiB
TypeScript
import { PrismaClient, EmployeeRole } from '@prisma/client'
|
|
import { clerkClient } from '@clerk/clerk-sdk-node'
|
|
|
|
const prisma = new PrismaClient()
|
|
|
|
// ─── Types ────────────────────────────────────────────────────
|
|
|
|
export interface InvitePayload {
|
|
firstName: string
|
|
lastName: string
|
|
email: string
|
|
role: EmployeeRole
|
|
}
|
|
|
|
export interface UpdateRolePayload {
|
|
role: EmployeeRole
|
|
}
|
|
|
|
export interface TeamMember {
|
|
id: string
|
|
clerkUserId: string
|
|
firstName: string
|
|
lastName: string
|
|
email: string
|
|
phone: string | null
|
|
role: EmployeeRole
|
|
isActive: boolean
|
|
createdAt: Date
|
|
updatedAt: Date
|
|
// hydrated from Clerk
|
|
lastActiveAt?: Date | null
|
|
invitationStatus?: 'accepted' | 'pending' | 'revoked'
|
|
}
|
|
|
|
// ─── List employees ───────────────────────────────────────────
|
|
|
|
export async function listEmployees(companyId: string): Promise<TeamMember[]> {
|
|
const employees = await prisma.employee.findMany({
|
|
where: { companyId },
|
|
orderBy: [{ role: 'asc' }, { createdAt: 'asc' }],
|
|
})
|
|
|
|
// Hydrate last-active timestamps from Clerk in parallel
|
|
const hydrated = await Promise.allSettled(
|
|
employees.map(async (emp) => {
|
|
try {
|
|
const clerkUser = await clerkClient.users.getUser(emp.clerkUserId)
|
|
return {
|
|
...emp,
|
|
lastActiveAt: clerkUser.lastActiveAt
|
|
? new Date(clerkUser.lastActiveAt)
|
|
: null,
|
|
invitationStatus: 'accepted' as const,
|
|
}
|
|
} catch {
|
|
// Clerk user not yet accepted invitation
|
|
return {
|
|
...emp,
|
|
lastActiveAt: null,
|
|
invitationStatus: 'pending' as const,
|
|
}
|
|
}
|
|
})
|
|
)
|
|
|
|
return hydrated.map((r) =>
|
|
r.status === 'fulfilled' ? r.value : (r as any).value
|
|
)
|
|
}
|
|
|
|
// ─── Get single employee (scoped to company) ──────────────────
|
|
|
|
export async function getEmployee(companyId: string, employeeId: string) {
|
|
const employee = await prisma.employee.findFirst({
|
|
where: { id: employeeId, companyId },
|
|
})
|
|
if (!employee) {
|
|
throw Object.assign(new Error('Employee not found'), { statusCode: 404, code: 'employee_not_found' })
|
|
}
|
|
return employee
|
|
}
|
|
|
|
// ─── Invite ───────────────────────────────────────────────────
|
|
// 1. Send a Clerk invitation (creates the Clerk user + sends email)
|
|
// 2. Create the Employee row immediately so the company sees it in the list
|
|
// with isActive=false until they accept and log in (Clerk webhook sets isActive=true)
|
|
|
|
export async function inviteEmployee(
|
|
companyId: string,
|
|
inviterId: string,
|
|
payload: InvitePayload
|
|
) {
|
|
const { firstName, lastName, email, role } = payload
|
|
|
|
// Owners cannot be invited — must be the account creator
|
|
if (role === 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Cannot invite a member with the OWNER role'),
|
|
{ statusCode: 400, code: 'invalid_role' }
|
|
)
|
|
}
|
|
|
|
// Check for existing active employee with that email in this company
|
|
const existing = await prisma.employee.findFirst({
|
|
where: { companyId, email },
|
|
})
|
|
if (existing) {
|
|
throw Object.assign(
|
|
new Error('An employee with this email already exists in your team'),
|
|
{ statusCode: 409, code: 'employee_already_exists' }
|
|
)
|
|
}
|
|
|
|
// Get the company's subdomain/name for the invitation email redirect URL
|
|
const company = await prisma.company.findUniqueOrThrow({
|
|
where: { id: companyId },
|
|
include: { brand: { select: { subdomain: true, displayName: true } } },
|
|
})
|
|
|
|
// Send Clerk invitation
|
|
// Clerk will create the user and email them a magic link
|
|
let clerkInvitation: Awaited<ReturnType<typeof clerkClient.invitations.createInvitation>>
|
|
|
|
try {
|
|
clerkInvitation = await clerkClient.invitations.createInvitation({
|
|
emailAddress: email,
|
|
redirectUrl: `${process.env.DASHBOARD_URL}/onboarding/accept-invite`,
|
|
publicMetadata: {
|
|
companyId,
|
|
companyName: company.brand?.displayName ?? company.name,
|
|
role,
|
|
invitedBy: inviterId,
|
|
},
|
|
})
|
|
} catch (err: any) {
|
|
// Clerk throws if email already has a Clerk account with a pending invite
|
|
if (err?.errors?.[0]?.code === 'duplicate_record') {
|
|
throw Object.assign(
|
|
new Error('This email already has a pending invitation'),
|
|
{ statusCode: 409, code: 'duplicate_invitation' }
|
|
)
|
|
}
|
|
throw err
|
|
}
|
|
|
|
// Create the Employee record with a placeholder clerkUserId
|
|
// It will be updated when the user accepts the invite (via Clerk webhook)
|
|
const employee = await prisma.employee.create({
|
|
data: {
|
|
companyId,
|
|
clerkUserId: `pending_${clerkInvitation.id}`, // replaced by webhook
|
|
firstName,
|
|
lastName,
|
|
email,
|
|
role,
|
|
isActive: false, // activated on invite acceptance
|
|
},
|
|
})
|
|
|
|
return {
|
|
employee,
|
|
invitationId: clerkInvitation.id,
|
|
}
|
|
}
|
|
|
|
// ─── Update role ──────────────────────────────────────────────
|
|
|
|
export async function updateEmployeeRole(
|
|
companyId: string,
|
|
requesterId: string,
|
|
requesterRole: EmployeeRole,
|
|
employeeId: string,
|
|
payload: UpdateRolePayload
|
|
) {
|
|
const target = await getEmployee(companyId, employeeId)
|
|
|
|
// Only OWNER can change roles
|
|
if (requesterRole !== 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Only the account owner can change team member roles'),
|
|
{ statusCode: 403, code: 'forbidden' }
|
|
)
|
|
}
|
|
|
|
// Cannot change an OWNER's role (there must always be one owner)
|
|
if (target.role === 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Cannot change the role of the account owner'),
|
|
{ statusCode: 400, code: 'cannot_change_owner_role' }
|
|
)
|
|
}
|
|
|
|
// Cannot assign OWNER role via this endpoint
|
|
if (payload.role === 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Cannot assign the OWNER role via this endpoint'),
|
|
{ statusCode: 400, code: 'invalid_role' }
|
|
)
|
|
}
|
|
|
|
// Prevent self-role-change (edge case)
|
|
if (target.id === requesterId) {
|
|
throw Object.assign(
|
|
new Error('You cannot change your own role'),
|
|
{ statusCode: 400, code: 'cannot_change_own_role' }
|
|
)
|
|
}
|
|
|
|
const updated = await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { role: payload.role },
|
|
})
|
|
|
|
return updated
|
|
}
|
|
|
|
// ─── Deactivate ───────────────────────────────────────────────
|
|
|
|
export async function deactivateEmployee(
|
|
companyId: string,
|
|
requesterRole: EmployeeRole,
|
|
employeeId: string
|
|
) {
|
|
const target = await getEmployee(companyId, employeeId)
|
|
|
|
if (requesterRole !== 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Only the account owner can deactivate team members'),
|
|
{ statusCode: 403, code: 'forbidden' }
|
|
)
|
|
}
|
|
|
|
if (target.role === 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Cannot deactivate the account owner'),
|
|
{ statusCode: 400, code: 'cannot_deactivate_owner' }
|
|
)
|
|
}
|
|
|
|
// Revoke Clerk session so they are immediately signed out
|
|
if (!target.clerkUserId.startsWith('pending_')) {
|
|
try {
|
|
await clerkClient.users.banUser(target.clerkUserId)
|
|
} catch {
|
|
// Non-fatal — still deactivate in DB
|
|
}
|
|
}
|
|
|
|
const updated = await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { isActive: false },
|
|
})
|
|
|
|
return updated
|
|
}
|
|
|
|
// ─── Reactivate ───────────────────────────────────────────────
|
|
|
|
export async function reactivateEmployee(
|
|
companyId: string,
|
|
requesterRole: EmployeeRole,
|
|
employeeId: string
|
|
) {
|
|
if (requesterRole !== 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Only the account owner can reactivate team members'),
|
|
{ statusCode: 403, code: 'forbidden' }
|
|
)
|
|
}
|
|
|
|
const target = await getEmployee(companyId, employeeId)
|
|
|
|
if (!target.clerkUserId.startsWith('pending_')) {
|
|
try {
|
|
await clerkClient.users.unbanUser(target.clerkUserId)
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
}
|
|
|
|
const updated = await prisma.employee.update({
|
|
where: { id: employeeId },
|
|
data: { isActive: true },
|
|
})
|
|
|
|
return updated
|
|
}
|
|
|
|
// ─── Remove (hard delete) ─────────────────────────────────────
|
|
// Only OWNER can remove. Removes employee row + revokes Clerk invite if pending.
|
|
|
|
export async function removeEmployee(
|
|
companyId: string,
|
|
requesterRole: EmployeeRole,
|
|
employeeId: string
|
|
) {
|
|
if (requesterRole !== 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Only the account owner can remove team members'),
|
|
{ statusCode: 403, code: 'forbidden' }
|
|
)
|
|
}
|
|
|
|
const target = await getEmployee(companyId, employeeId)
|
|
|
|
if (target.role === 'OWNER') {
|
|
throw Object.assign(
|
|
new Error('Cannot remove the account owner'),
|
|
{ statusCode: 400, code: 'cannot_remove_owner' }
|
|
)
|
|
}
|
|
|
|
// Revoke pending Clerk invitation
|
|
if (target.clerkUserId.startsWith('pending_')) {
|
|
const inviteId = target.clerkUserId.replace('pending_', '')
|
|
try {
|
|
await clerkClient.invitations.revokeInvitation(inviteId)
|
|
} catch {
|
|
// Already accepted or expired — safe to ignore
|
|
}
|
|
} else {
|
|
// Ban the Clerk user so they can't log in
|
|
try {
|
|
await clerkClient.users.banUser(target.clerkUserId)
|
|
} catch {
|
|
// Non-fatal
|
|
}
|
|
}
|
|
|
|
await prisma.employee.delete({ where: { id: employeeId } })
|
|
|
|
return { success: true }
|
|
}
|
|
|
|
// ─── Clerk webhook handler ────────────────────────────────────
|
|
// Called when a user accepts their invitation. Updates the Employee row
|
|
// with the real Clerk user ID and activates the account.
|
|
|
|
export async function handleInvitationAccepted(
|
|
clerkUserId: string,
|
|
email: string,
|
|
companyId: string,
|
|
role: EmployeeRole
|
|
) {
|
|
const pendingEmployee = await prisma.employee.findFirst({
|
|
where: {
|
|
companyId,
|
|
email,
|
|
clerkUserId: { startsWith: 'pending_' },
|
|
},
|
|
})
|
|
|
|
if (!pendingEmployee) return null
|
|
|
|
const activated = await prisma.employee.update({
|
|
where: { id: pendingEmployee.id },
|
|
data: {
|
|
clerkUserId,
|
|
isActive: true,
|
|
role, // in case it was updated between invite and acceptance
|
|
},
|
|
})
|
|
|
|
return activated
|
|
}
|