Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,1000 +0,0 @@
|
||||
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() };
|
||||
}
|
||||
Reference in new issue
Block a user