Files
carmanagement/docs/stubs/teamService.ts
T
2026-05-26 19:28:44 -04:00

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
}