Revert "PHP Rebuild"

This reverts commit 2ef3654841.
This commit is contained in:
Stefan Lazic committed 2026-09-05 09:32:05 +01:00
1 parent a59f998f2b
commit 2aa8405006
690 files changed
+12045 -33943

No files matched your search

+1000
View File
@@ -0,0 +1,1000 @@
import type { 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_OWNER";
export class StaffPermissionDeniedError extends Error {
constructor(message = "You don't have permission to manage this staff member's schedule.") {
super(message);
this.name = "StaffPermissionDeniedError";
}
}
/** 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<StaffServiceErrorCode> {
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<StaffServiceErrorCode> {
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<StaffServiceErrorCode> {
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 owner" — see docs/phase-6-summary.md, "Roles and permissions". */
async function countOtherActiveManagers(
prisma: PrismaClient,
params: { businessId: string; excludeStaffId: string },
) {
return prisma.staffProfile.count({
where: {
businessId: params.businessId,
status: "ACTIVE",
id: { not: params.excludeStaffId },
role: { permissions: { some: { permission: "MANAGE_STAFF" } } },
},
});
}
async function staffCurrentlyManagesStaff(prisma: PrismaClient, staffId: string): Promise<boolean> {
const staff = await prisma.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<StaffServiceErrorCode> {
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;
}
export async function updateStaffAsOwner(
prisma: PrismaClient,
params: { staffId: string; businessId: string; input: UpdateStaffAsOwnerInput },
) {
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." }) };
}
if (params.input.roleId && params.input.roleId !== existing.roleId) {
const role = await prisma.role.findFirst({ where: { id: params.input.roleId, businessId: params.businessId } });
if (!role) {
return { success: false as const, validation: fail({ code: "ROLE_NOT_FOUND", message: "Role not found." }) };
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
const stillManagesAfter = !!(await prisma.rolePermission.findFirst({
where: { roleId: role.id, permission: "MANAGE_STAFF" },
}));
if (wasManager && !stillManagesAfter) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
};
}
}
}
await prisma.$transaction([
prisma.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 } : {}),
},
}),
prisma.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 } : {}),
},
}),
]);
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: [],
};
}
const wasManager = await staffCurrentlyManagesStaff(prisma, params.staffId);
if (wasManager) {
const otherManagers = await countOtherActiveManagers(prisma, {
businessId: params.businessId,
excludeStaffId: params.staffId,
});
if (otherManagers === 0) {
return {
success: false as const,
validation: fail({
code: "LAST_OWNER",
message: "This is the only active owner — assign another owner first.",
}),
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.",
}),
};
}
}
await prisma.staffProfile.update({ where: { id: params.staffId }, data: { status: "INACTIVE" } });
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() };
}