import type { Prisma, PrismaClient } from "@/generated/prisma/client"; import { Weekday } from "@/generated/prisma/enums"; import { calendarDateOf, calendarDateToUtcMidnight, endOfBusinessDay, minutesFromMidnight, startOfBusinessDay, weekdayOf, } from "@/lib/time/business-time"; import type { CalendarDate } from "@/lib/time/business-time"; import { fail, ok } from "@/lib/validation-result"; import type { ValidationResult } from "@/lib/validation-result"; /** * Everything about a staff member's own operational setup: profile fields * they're allowed to edit themselves, working hours, breaks, and time off * (including the one-off "Block Time" case — a TimeOff row with minutes * set, see schema.prisma). Deliberately does NOT touch availability * calculation or appointment status — those stay in AvailabilityService/ * StaffAvailabilityService and AppointmentService respectively. This * service only manages the *inputs* those read; it's the write side of * the same data StaffAvailabilityService reads. */ export type StaffServiceErrorCode = | "INVALID_INPUT" | "INVALID_TIME_RANGE" | "NO_DAYS_SELECTED" | "STAFF_NOT_FOUND" | "BREAK_NOT_FOUND" | "TIME_OFF_NOT_FOUND" | "APPOINTMENT_CONFLICTS" | "PERMISSION_DENIED" | "EMAIL_ALREADY_IN_USE" | "ROLE_NOT_FOUND" | "LAST_ADMINISTRATOR"; export class StaffPermissionDeniedError extends Error { constructor(message = "You don't have permission to manage this staff member's schedule.") { super(message); this.name = "StaffPermissionDeniedError"; } } /** A Prisma client or an in-flight transaction client — same convention as src/lib/auth/otp.service.ts's `Db` alias, so the last-administrator count/update below can run atomically inside one `$transaction`. */ type Db = PrismaClient | Prisma.TransactionClient; /** Thrown inside a `$transaction` callback below to carry a specific StaffServiceErrorCode back out through the surrounding try/catch — mirrors installer.service.ts's own InstallStepError pattern. */ class StaffOperationError extends Error { constructor( readonly code: StaffServiceErrorCode, message: string, ) { super(message); } } /** Every mutation here takes the acting staff member's own id and asserts the resource belongs to them — a staff member manages only their own schedule this phase (see docs/phase-5-summary.md, "Permission model"). */ export function assertOwnsResource(resourceStaffId: string, actingStaffId: string): void { if (resourceStaffId !== actingStaffId) { throw new StaffPermissionDeniedError(); } } // ── Profile ───────────────────────────────────────────────────────────── export async function getStaffProfileByUserId(prisma: PrismaClient, userId: string) { return prisma.staffProfile.findUnique({ where: { userId }, include: { user: { select: { id: true, name: true, email: true, phone: true, image: true } }, services: { include: { service: true } }, business: { select: { id: true, name: true, timezone: true } }, role: { select: { name: true } }, }, }); } export interface UpdateStaffProfileInput { staffId: string; name?: string; phone?: string; image?: string; bio?: string; /** "Public visibility" in the brief — whether customers can find/book this barber online. Reuses the existing field rather than adding a duplicate flag. */ isBookableOnline?: boolean; } /** * Only the fields the brief explicitly allows a barber to change themselves * — service eligibility, business settings, and other staff accounts are * deliberately not accepted here (owner-only, out of scope this phase). */ export async function updateStaffProfile( prisma: PrismaClient, input: UpdateStaffProfileInput, ): Promise<{ success: boolean }> { const staff = await prisma.staffProfile.findUnique({ where: { id: input.staffId }, select: { userId: true }, }); if (!staff) return { success: false }; await prisma.$transaction([ prisma.user.update({ where: { id: staff.userId }, data: { ...(input.name !== undefined ? { name: input.name } : {}), ...(input.phone !== undefined ? { phone: input.phone || null } : {}), ...(input.image !== undefined ? { image: input.image || null } : {}), }, }), prisma.staffProfile.update({ where: { id: input.staffId }, data: { ...(input.bio !== undefined ? { bio: input.bio || null } : {}), ...(input.isBookableOnline !== undefined ? { isBookableOnline: input.isBookableOnline } : {}), }, }), ]); return { success: true }; } // ── Working hours ─────────────────────────────────────────────────────── export interface WorkingHoursDayInput { dayOfWeek: Weekday; isOff: boolean; startMinutes?: number; endMinutes?: number; } export function validateWorkingHoursInput( days: WorkingHoursDayInput[], ): ValidationResult { for (const day of days) { if (day.isOff) continue; if (day.startMinutes === undefined || day.endMinutes === undefined) { return fail({ code: "INVALID_INPUT", message: `${day.dayOfWeek}: start and end time are required for a working day.`, }); } if (day.startMinutes < 0 || day.endMinutes > 24 * 60) { return fail({ code: "INVALID_TIME_RANGE", message: `${day.dayOfWeek}: times must fall within a single day.`, }); } if (day.startMinutes >= day.endMinutes) { return fail({ code: "INVALID_TIME_RANGE", message: `${day.dayOfWeek}: start time must be before end time.`, }); } } return ok(); } export async function getWorkingHours(prisma: PrismaClient, staffId: string) { return prisma.workingHours.findMany({ where: { staffId }, orderBy: { dayOfWeek: "asc" } }); } /** * Future, non-cancelled appointments that would fall outside the proposed * working hours — either on a day newly marked off, or before/after the * new start/end window on a day that stays working. Same pattern as * findBreakConflicts/findTimeOffConflicts/BusinessService's * findOpeningHoursConflicts (see docs/phase-7-summary.md, "Working-hours * conflict review"): this never cancels anything itself, only reports what * the caller should show before asking for acknowledgement. */ export async function findWorkingHoursConflicts( prisma: PrismaClient, params: { staffId: string; businessTimezone: string; days: WorkingHoursDayInput[] }, ) { const appointments = await prisma.appointment.findMany({ where: { staffId: params.staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] }, startAt: { gte: new Date() }, }, include: APPOINTMENT_CONFLICT_INCLUDE, orderBy: { startAt: "asc" }, }); const byDay = new Map(params.days.map((d) => [d.dayOfWeek, d])); return appointments.filter((a) => { const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone)); const proposed = byDay.get(weekday); if (!proposed) return false; if (proposed.isOff) return true; const aStart = minutesFromMidnight(a.startAt, params.businessTimezone); const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone); return aStart < (proposed.startMinutes ?? 0) || aEnd > (proposed.endMinutes ?? 24 * 60); }); } export async function setWorkingHours( prisma: PrismaClient, params: { staffId: string; businessTimezone: string; days: WorkingHoursDayInput[]; acknowledgeConflicts?: boolean; }, ) { const validation = validateWorkingHoursInput(params.days); if (!validation.valid) return { success: false as const, validation, conflicts: [] }; if (!params.acknowledgeConflicts) { const conflicts = await findWorkingHoursConflicts(prisma, { staffId: params.staffId, businessTimezone: params.businessTimezone, days: params.days, }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "These hours would leave existing appointments outside your working hours.", }), }; } } await prisma.$transaction( params.days.map((day) => prisma.workingHours.upsert({ where: { staffId_dayOfWeek: { staffId: params.staffId, dayOfWeek: day.dayOfWeek } }, create: { staffId: params.staffId, dayOfWeek: day.dayOfWeek, isOff: day.isOff, startMinutes: day.isOff ? null : (day.startMinutes ?? null), endMinutes: day.isOff ? null : (day.endMinutes ?? null), }, update: { isOff: day.isOff, startMinutes: day.isOff ? null : (day.startMinutes ?? null), endMinutes: day.isOff ? null : (day.endMinutes ?? null), }, }), ), ); return { success: true as const, conflicts: [], validation: ok() }; } // ── Breaks ────────────────────────────────────────────────────────────── const APPOINTMENT_CONFLICT_INCLUDE = { services: { include: { service: true } }, customer: true, } as const; export interface BreakInput { label: string; startMinutes: number; endMinutes: number; days: Weekday[]; } export function validateBreakInput(input: BreakInput): ValidationResult { if (!input.label.trim()) return fail({ code: "INVALID_INPUT", message: "Give this break a label." }); if (input.days.length === 0) return fail({ code: "NO_DAYS_SELECTED", message: "Select at least one day for this break." }); if ( input.startMinutes < 0 || input.endMinutes > 24 * 60 || input.startMinutes >= input.endMinutes ) { return fail({ code: "INVALID_TIME_RANGE", message: "Break start time must be before its end time.", }); } return ok(); } export async function listBreaks(prisma: PrismaClient, staffId: string) { return prisma.break.findMany({ where: { staffId }, include: { days: true }, orderBy: { startMinutes: "asc" }, }); } /** Future, non-cancelled appointments that would fall inside a recurring break on any of `days`. Breaks recur indefinitely, so only future appointments are ever relevant — one from last month isn't a live conflict. */ export async function findBreakConflicts( prisma: PrismaClient, params: { staffId: string; businessTimezone: string; days: Weekday[]; startMinutes: number; endMinutes: number; excludeBreakId?: string; }, ) { const appointments = await prisma.appointment.findMany({ where: { staffId: params.staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] }, startAt: { gte: new Date() }, }, include: APPOINTMENT_CONFLICT_INCLUDE, orderBy: { startAt: "asc" }, }); return appointments.filter((a) => { const weekday = weekdayOf(calendarDateOf(a.startAt, params.businessTimezone)); if (!params.days.includes(weekday)) return false; const aStart = minutesFromMidnight(a.startAt, params.businessTimezone); const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone); return aStart < params.endMinutes && aEnd > params.startMinutes; }); } export async function createBreak( prisma: PrismaClient, params: { staffId: string; businessTimezone: string; input: BreakInput; acknowledgeConflicts?: boolean; }, ) { const validation = validateBreakInput(params.input); if (!validation.valid) return { success: false as const, validation, conflicts: [] }; if (!params.acknowledgeConflicts) { const conflicts = await findBreakConflicts(prisma, { staffId: params.staffId, businessTimezone: params.businessTimezone, ...params.input, }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "This break overlaps existing appointments.", }), }; } } const created = await prisma.break.create({ data: { staffId: params.staffId, label: params.input.label, startMinutes: params.input.startMinutes, endMinutes: params.input.endMinutes, days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) }, }, }); return { success: true as const, break: created, conflicts: [], validation: ok() }; } export async function updateBreak( prisma: PrismaClient, params: { breakId: string; staffId: string; businessTimezone: string; input: BreakInput; acknowledgeConflicts?: boolean; }, ) { const existing = await prisma.break.findUnique({ where: { id: params.breakId } }); if (!existing) return { success: false as const, validation: fail({ code: "BREAK_NOT_FOUND" as const, message: "Break not found." }), conflicts: [], }; if (existing.staffId !== params.staffId) { return { success: false as const, validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own breaks." }), conflicts: [], }; } const validation = validateBreakInput(params.input); if (!validation.valid) return { success: false as const, validation, conflicts: [] }; if (!params.acknowledgeConflicts) { const conflicts = await findBreakConflicts(prisma, { staffId: params.staffId, businessTimezone: params.businessTimezone, ...params.input, excludeBreakId: params.breakId, }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "This break overlaps existing appointments.", }), }; } } await prisma.$transaction([ prisma.breakDay.deleteMany({ where: { breakId: params.breakId } }), prisma.break.update({ where: { id: params.breakId }, data: { label: params.input.label, startMinutes: params.input.startMinutes, endMinutes: params.input.endMinutes, days: { create: params.input.days.map((dayOfWeek) => ({ dayOfWeek })) }, }, }), ]); return { success: true as const, conflicts: [], validation: ok() }; } export async function deleteBreak( prisma: PrismaClient, params: { breakId: string; staffId: string }, ): Promise<{ success: boolean }> { const existing = await prisma.break.findUnique({ where: { id: params.breakId } }); if (!existing) return { success: false }; if (existing.staffId !== params.staffId) return { success: false }; await prisma.break.delete({ where: { id: params.breakId } }); return { success: true }; } // ── Time off (also represents one-off "Block Time" — see schema.prisma) ── export interface TimeOffInput { startDate: CalendarDate; endDate: CalendarDate; reason?: string; /** Both set = a partial-day block ("Block Time"); both omitted = whole-day time off. */ startMinutes?: number; endMinutes?: number; } export function validateTimeOffInput(input: TimeOffInput): ValidationResult { if (input.startDate > input.endDate) { return fail({ code: "INVALID_INPUT", message: "Start date must be on or before the end date.", }); } const hasStart = input.startMinutes !== undefined; const hasEnd = input.endMinutes !== undefined; if (hasStart !== hasEnd) { return fail({ code: "INVALID_INPUT", message: "Provide both a start and end time for a partial-day block, or neither for a full day.", }); } if (hasStart && hasEnd) { if (input.startDate !== input.endDate) { return fail({ code: "INVALID_INPUT", message: "A specific-time block can only cover a single day.", }); } if ( input.startMinutes! < 0 || input.endMinutes! > 24 * 60 || input.startMinutes! >= input.endMinutes! ) { return fail({ code: "INVALID_TIME_RANGE", message: "Start time must be before end time." }); } } return ok(); } /** Split by calendar date, not business time zone — @db.Date columns are compared as UTC midnight regardless of zone, see business-time.ts. */ export async function listTimeOff(prisma: PrismaClient, staffId: string) { const rows = await prisma.timeOff.findMany({ where: { staffId }, orderBy: { startDate: "desc" }, }); const today = calendarDateToUtcMidnight(new Date().toISOString().slice(0, 10)); return { upcoming: rows .filter((r) => r.endDate >= today) .sort((a, b) => a.startDate.getTime() - b.startDate.getTime()), past: rows.filter((r) => r.endDate < today), }; } /** Time off is only ever checked against appointments, never against other time off — there is no "excludeTimeOffId" case because two time-off rows can't conflict with each other. */ export async function findTimeOffConflicts( prisma: PrismaClient, params: { staffId: string; businessTimezone: string; startDate: CalendarDate; endDate: CalendarDate; startMinutes?: number; endMinutes?: number; }, ) { const rangeStart = startOfBusinessDay(params.startDate, params.businessTimezone); const rangeEnd = endOfBusinessDay(params.endDate, params.businessTimezone); const appointments = await prisma.appointment.findMany({ where: { staffId: params.staffId, status: { notIn: ["CANCELLED", "NO_SHOW"] }, startAt: { lt: rangeEnd }, endAt: { gt: rangeStart }, }, include: APPOINTMENT_CONFLICT_INCLUDE, orderBy: { startAt: "asc" }, }); if (params.startMinutes === undefined || params.endMinutes === undefined) return appointments; return appointments.filter((a) => { const aStart = minutesFromMidnight(a.startAt, params.businessTimezone); const aEnd = minutesFromMidnight(a.endAt, params.businessTimezone); return aStart < params.endMinutes! && aEnd > params.startMinutes!; }); } export async function createTimeOff( prisma: PrismaClient, params: { staffId: string; businessTimezone: string; input: TimeOffInput; acknowledgeConflicts?: boolean; }, ) { const validation = validateTimeOffInput(params.input); if (!validation.valid) return { success: false as const, validation, conflicts: [] }; if (!params.acknowledgeConflicts) { const conflicts = await findTimeOffConflicts(prisma, { staffId: params.staffId, businessTimezone: params.businessTimezone, ...params.input, }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "This time off overlaps existing appointments.", }), }; } } const created = await prisma.timeOff.create({ data: { staffId: params.staffId, startDate: calendarDateToUtcMidnight(params.input.startDate), endDate: calendarDateToUtcMidnight(params.input.endDate), reason: params.input.reason, startMinutes: params.input.startMinutes ?? null, endMinutes: params.input.endMinutes ?? null, status: "APPROVED", }, }); return { success: true as const, timeOff: created, conflicts: [], validation: ok() }; } export async function updateTimeOff( prisma: PrismaClient, params: { timeOffId: string; staffId: string; businessTimezone: string; input: TimeOffInput; acknowledgeConflicts?: boolean; }, ) { const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } }); if (!existing) return { success: false as const, validation: fail({ code: "TIME_OFF_NOT_FOUND" as const, message: "Time off not found." }), conflicts: [], }; if (existing.staffId !== params.staffId) { return { success: false as const, validation: fail({ code: "PERMISSION_DENIED" as const, message: "You can only manage your own time off." }), conflicts: [], }; } const validation = validateTimeOffInput(params.input); if (!validation.valid) return { success: false as const, validation, conflicts: [] }; if (!params.acknowledgeConflicts) { const conflicts = await findTimeOffConflicts(prisma, { staffId: params.staffId, businessTimezone: params.businessTimezone, ...params.input, }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "This time off overlaps existing appointments.", }), }; } } await prisma.timeOff.update({ where: { id: params.timeOffId }, data: { startDate: calendarDateToUtcMidnight(params.input.startDate), endDate: calendarDateToUtcMidnight(params.input.endDate), reason: params.input.reason, startMinutes: params.input.startMinutes ?? null, endMinutes: params.input.endMinutes ?? null, }, }); return { success: true as const, conflicts: [], validation: ok() }; } export async function deleteTimeOff( prisma: PrismaClient, params: { timeOffId: string; staffId: string }, ): Promise<{ success: boolean }> { const existing = await prisma.timeOff.findUnique({ where: { id: params.timeOffId } }); if (!existing) return { success: false }; if (existing.staffId !== params.staffId) return { success: false }; // Only future time off is editable/removable — see the brief's "Edit or remove future time off". await prisma.timeOff.delete({ where: { id: params.timeOffId } }); return { success: true }; } // ── Owner: team management ───────────────────────────────────────────── // Everything below acts on staff *other than* the caller — the owner's own // business-membership must already be verified by the caller (see // src/features/owner/authorization.ts); every function here additionally // re-scopes by businessId itself rather than trusting the caller alone. export async function listStaffForBusiness( prisma: PrismaClient, params: { businessId: string; q?: string; includeInactive?: boolean }, ) { return prisma.staffProfile.findMany({ where: { businessId: params.businessId, ...(params.includeInactive ? {} : { status: { not: "INACTIVE" } }), // No `mode: "insensitive"` — that's a Postgres-only Prisma option (removed in the // Phase 7 MySQL migration). MySQL's default collation (utf8mb4_0900_ai_ci — "ai" = // accent-insensitive, "ci" = case-insensitive) already makes `contains` case-insensitive. ...(params.q ? { user: { OR: [{ name: { contains: params.q } }, { email: { contains: params.q } }], }, } : {}), }, include: { user: { select: { id: true, name: true, email: true, phone: true, image: true } }, role: { select: { id: true, name: true } }, invitation: true, }, orderBy: { createdAt: "asc" }, }); } export async function getStaffDetailById(prisma: PrismaClient, params: { staffId: string; businessId: string }) { return prisma.staffProfile.findFirst({ where: { id: params.staffId, businessId: params.businessId }, include: { user: { select: { id: true, name: true, email: true, phone: true, image: true } }, role: { include: { permissions: true } }, services: { include: { service: true } }, invitation: true, }, }); } export async function listRolesForBusiness(prisma: PrismaClient, businessId: string) { return prisma.role.findMany({ where: { businessId }, include: { permissions: true }, orderBy: { name: "asc" }, }); } /** A role holding MANAGE_STAFF is this app's definition of "an Administrator" — see docs/phase-6-summary.md, "Roles and permissions". Deliberately permission-based, not a check against the role's name — stays correct even if a business ever gets a custom role that also grants MANAGE_STAFF. */ async function countOtherActiveAdministrators( db: Db, params: { businessId: string; excludeStaffId: string }, ) { return db.staffProfile.count({ where: { businessId: params.businessId, status: "ACTIVE", id: { not: params.excludeStaffId }, role: { permissions: { some: { permission: "MANAGE_STAFF" } } }, }, }); } /** Exported so the owner-actions layer can decide when a role change / deactivation crosses the Administrator boundary, for audit-log naming (ADMINISTRATOR_PROMOTED/DEMOTED/REMOVED vs. the generic STAFF_* actions). */ export async function isStaffAdministrator(db: Db, staffId: string): Promise { const staff = await db.staffProfile.findUnique({ where: { id: staffId }, select: { status: true, role: { select: { permissions: { select: { permission: true } } } } }, }); return ( !!staff && staff.status === "ACTIVE" && staff.role.permissions.some((p) => p.permission === "MANAGE_STAFF") ); } export interface CreateStaffInput { name: string; email: string; phone?: string; image?: string; bio?: string; roleId: string; serviceIds: string[]; isBookableOnline: boolean; } export function validateCreateStaffInput(input: CreateStaffInput): ValidationResult { if (!input.name.trim()) return fail({ code: "INVALID_INPUT", message: "Name is required." }); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.email)) { return fail({ code: "INVALID_INPUT", message: "Enter a valid email address." }); } if (!input.roleId) return fail({ code: "INVALID_INPUT", message: "Choose a role." }); return ok(); } /** Creates the User + StaffProfile + initial service eligibility immediately — the profile is fully usable right away. Working hours default to "off every day" until the owner (or the staff member, once they've accepted) sets them; StaffInvitationService is what tracks acceptance, deliberately kept separate — see that file. */ export async function createStaffProfile( prisma: PrismaClient, params: { businessId: string; input: CreateStaffInput }, ) { const validation = validateCreateStaffInput(params.input); if (!validation.valid) return { success: false as const, validation }; const [role, existingUser] = await Promise.all([ prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }), prisma.user.findUnique({ where: { email: params.input.email.toLowerCase() } }), ]); if (!role) { return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) }; } if (existingUser) { return { success: false as const, validation: fail({ code: "EMAIL_ALREADY_IN_USE", message: "That email is already in use." }), }; } const user = await prisma.user.create({ data: { name: params.input.name.trim(), email: params.input.email.toLowerCase(), phone: params.input.phone || null, image: params.input.image || null, staffProfile: { create: { businessId: params.businessId, roleId: params.input.roleId, bio: params.input.bio?.trim() || null, isBookableOnline: params.input.isBookableOnline, services: { create: params.input.serviceIds.map((serviceId) => ({ serviceId })) }, }, }, }, include: { staffProfile: true }, }); return { success: true as const, staff: user.staffProfile!, validation: ok() }; } export interface UpdateStaffAsOwnerInput { name?: string; phone?: string; image?: string; bio?: string; isBookableOnline?: boolean; roleId?: string; } /** * A role change is the only path here that can ever reduce the business's * administrator count, so the last-administrator check and the actual * write must happen atomically — otherwise two concurrent requests each * demoting a *different* administrator could both read "someone else is * still an administrator" before either commits, and both succeed, * leaving zero. When a role change is requested, this acquires a * `SELECT ... FOR UPDATE` lock on the business's own row first (same * `$queryRaw` tagged-template pattern already used in * src/app/api/health/route.ts) so a second concurrent call for the same * business blocks until the first transaction commits — at which point its * own count reflects the first call's already-applied change. Calls that * don't touch `roleId` skip the lock entirely so ordinary concurrent * profile edits for different staff members are never needlessly * serialized against each other. */ export async function updateStaffAsOwner( prisma: PrismaClient, params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput }, ) { try { await prisma.$transaction(async (tx) => { if (params.input.roleId !== undefined) { await tx.$queryRaw`SELECT id FROM Business WHERE id = ${params.businessId} FOR UPDATE`; } const existing = await tx.staffProfile.findFirst({ where: { id: params.staffId, businessId: params.businessId }, }); if (!existing) { throw new StaffOperationError("STAFF_NOT_FOUND", "Staff member not found."); } if (params.input.roleId && params.input.roleId !== existing.roleId) { const role = await tx.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } }); if (!role) { throw new StaffOperationError("ROLE_NOT_FOUND", "Role not found."); } const wasAdministrator = await isStaffAdministrator(tx, params.staffId); const stillAdministratorAfter = !!(await tx.rolePermission.findFirst({ where: { roleId: role.id, permission: "MANAGE_STAFF" }, })); if (wasAdministrator && !stillAdministratorAfter) { const otherAdministrators = await countOtherActiveAdministrators(tx, { businessId: params.businessId, excludeStaffId: params.staffId, }); if (otherAdministrators === 0) { throw new StaffOperationError( "LAST_ADMINISTRATOR", "You can't remove the only administrator. Add another administrator first.", ); } } } await tx.user.update({ where: { id: existing.userId }, data: { ...(params.input.name !== undefined ? { name: params.input.name } : {}), ...(params.input.phone !== undefined ? { phone: params.input.phone || null } : {}), ...(params.input.image !== undefined ? { image: params.input.image || null } : {}), }, }); await tx.staffProfile.update({ where: { id: params.staffId }, data: { ...(params.input.bio !== undefined ? { bio: params.input.bio || null } : {}), ...(params.input.isBookableOnline !== undefined ? { isBookableOnline: params.input.isBookableOnline } : {}), ...(params.input.roleId !== undefined ? { roleId: params.input.roleId } : {}), }, }); }); } catch (error) { if (error instanceof StaffOperationError) { return { success: false as const, validation: fail({ code: error.code, message: error.message }) }; } throw error; } return { success: true as const, validation: ok() }; } /** Future, non-cancelled appointments still assigned to this staff member — shown for acknowledgement before deactivating, never auto-resolved. Deactivating does not touch these rows; it only removes the staff member from future booking/availability. */ export async function findStaffDeactivationConflicts( prisma: PrismaClient, params: { staffId: string }, ) { return prisma.appointment.findMany({ where: { staffId: params.staffId, status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] }, startAt: { gte: new Date() }, }, include: { customer: true, services: { include: { service: true } } }, orderBy: { startAt: "asc" }, }); } export async function deactivateStaff( prisma: PrismaClient, params: { staffId: string; businessId: string; acknowledgeConflicts?: boolean }, ) { const existing = await prisma.staffProfile.findFirst({ where: { id: params.staffId, businessId: params.businessId }, }); if (!existing) { return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }), conflicts: [], }; } if (!params.acknowledgeConflicts) { const conflicts = await findStaffDeactivationConflicts(prisma, { staffId: params.staffId }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "This staff member still has upcoming appointments.", }), }; } } // Last-administrator protection is checked fresh, inside the same // transaction as the actual write, under a lock on the business row — // see updateStaffAsOwner's own comment for why this needs to be atomic // rather than the read-then-write this used to be. try { await prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT id FROM Business WHERE id = ${params.businessId} FOR UPDATE`; const wasAdministrator = await isStaffAdministrator(tx, params.staffId); if (wasAdministrator) { const otherAdministrators = await countOtherActiveAdministrators(tx, { businessId: params.businessId, excludeStaffId: params.staffId, }); if (otherAdministrators === 0) { throw new StaffOperationError( "LAST_ADMINISTRATOR", "You can't remove the only administrator. Add another administrator first.", ); } } await tx.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } }); }); } catch (error) { if (error instanceof StaffOperationError) { return { success: false as const, validation: fail({ code: error.code, message: error.message }), conflicts: [] }; } throw error; } return { success: true as const, conflicts: [], validation: ok() }; } export async function reactivateStaff( prisma: PrismaClient, params: { staffId: string; businessId: string }, ): Promise<{ success: boolean }> { const result = await prisma.staffProfile.updateMany({ where: { id: params.staffId, businessId: params.businessId }, data: { status: "ACTIVE" }, }); return { success: result.count > 0 }; } /** Future, non-cancelled appointments this staff member has that book a service being *removed* from their eligible list — the only ones that matter, since keeping or adding eligibility can never orphan an existing booking. */ export async function findServiceEligibilityConflicts( prisma: PrismaClient, params: { staffId: string; removedServiceIds: string[] }, ) { if (params.removedServiceIds.length === 0) return []; return prisma.appointment.findMany({ where: { staffId: params.staffId, status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] }, startAt: { gte: new Date() }, services: { some: { serviceId: { in: params.removedServiceIds } } }, }, include: { customer: true, services: { include: { service: true } } }, orderBy: { startAt: "asc" }, }); } export async function setStaffServiceEligibility( prisma: PrismaClient, params: { staffId: string; businessId: string; serviceIds: string[]; acknowledgeConflicts?: boolean }, ) { const existing = await prisma.staffProfile.findFirst({ where: { id: params.staffId, businessId: params.businessId }, include: { services: true }, }); if (!existing) { return { success: false as const, validation: fail({ code: "STAFF_NOT_FOUND", message: "Staff member not found." }), conflicts: [], }; } const currentServiceIds = existing.services.map((s) => s.serviceId); const removedServiceIds = currentServiceIds.filter((id) => !params.serviceIds.includes(id)); if (!params.acknowledgeConflicts) { const conflicts = await findServiceEligibilityConflicts(prisma, { staffId: params.staffId, removedServiceIds, }); if (conflicts.length > 0) { return { success: false as const, conflicts, validation: fail({ code: "APPOINTMENT_CONFLICTS", message: "This staff member has upcoming appointments for a service you're removing.", }), }; } } await prisma.$transaction([ prisma.staffService.deleteMany({ where: { staffId: params.staffId } }), prisma.staffService.createMany({ data: params.serviceIds.map((serviceId) => ({ staffId: params.staffId, serviceId })), }), ]); return { success: true as const, conflicts: [], validation: ok() }; }