project design
This commit is contained in:
+365
@@ -0,0 +1,365 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user