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 { 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> 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 }