CI / ci (push) Canceled after 0s
1. Summary of changes
The installer-created account is no longer special. "Owner" is now literally renamed Administrator and behaves exactly like the existing role/permission system already allowed — the codebase turned out to already define "administrator" structurally (a StaffProfile whose role holds MANAGE_STAFF), not as a name or a business.ownerId-style field, and last-administrator protection already existed. The real work was: closing the gap between that and a full admin-management UI, fixing two OTP-migration bugs the new spec's requirements exposed, and making the existing protection transactional.
Businesses can now have any number of Administrators, added via Invite Administrator (name + email → OTP-verified before access), promoted/demoted via the existing staff role dropdown (now intercepted with a confirmation dialog when it crosses the Administrator boundary), or removed via the existing deactivate flow.
Two real bugs found and fixed, both required for the spec's "unverified email must not get access":
Invitation acceptance (/invite/accept) never actually verified an OTP — it silently relied on a sign-in path that would have failed for every real invitee. It now sends and verifies a real code, reusing the shared OtpCodeStep UI.
A newly-invited (not-yet-accepted) account could sign in via plain email OTP before ever touching the invite link, because sign-in only checked "does a User row exist," not whether it was verified. Fixed in the shared OTP service, benefiting every invitation, not just administrators'.
Last-administrator protection (already existed) is now transactional — a SELECT ... FOR UPDATE lock on the business row closes the race where two concurrent removals of two different administrators could both succeed.
Administrators (any staff member, in fact) can now change their own email — new address must be OTP-verified before it replaces the login email.
No hidden installer privilege: confirmed via code audit and a live install → the account is a plain Administrator-role staff member, provable identically via the Team UI as any later-added one.
NextChair Manager and platform Super Administrator are untouched.
2. Prisma/database changes
No schema changes. One data-only migration: prisma/migrations/20260905214944_administrator_role_rename — UPDATE Role SET name = 'Administrator' WHERE name = 'Owner' AND isSystem = true. Runs through the normal migration-deploy process; not something to run by hand.
3. How Administrator roles are represented
A per-business Role row named "Administrator" (was "Owner"), isSystem: true, holding every Permission — created identically at install and in dev seed data. This is what "Invite"/"Make Administrator" assign.
Whether a StaffProfile currently counts as an Administrator is permission-based (holds MANAGE_STAFF), not a name check — the same definition the codebase already used for last-owner protection (isStaffAdministrator in staff.service.ts). This is deliberately reused rather than introduced, and stays correct if a business ever gets a custom role that also grants MANAGE_STAFF.
No Business.adminId-style field anywhere — confirmed none existed before this work either.
4. How administrator invitations work
Reuses the existing StaffInvitation model and createStaffProfile/createInvitation machinery verbatim — a new inviteAdministratorAction just pre-selects the Administrator role and uses a minimal Name+Email form (invite-administrator-form.tsx). Acceptance (accept-invitation.ts) now requires a real OTP code sent to the invited email before the account activates — expiry (7 days), resend, and revoke are all the pre-existing invitation mechanics (revoke = the existing deactivate action, relabeled "Revoke Invitation" while pending).
5. How last-administrator protection is enforced
Server-side only, inside staff.service.ts's updateStaffAsOwner (role change) and deactivateStaff (remove), each wrapped in prisma.$transaction that first locks the business row (SELECT ... FOR UPDATE), then counts other active Administrators before allowing the change. Verified with a genuine concurrency test (two simultaneous removals against a 2-admin business) and manually in-browser (see below).
6. Installation flow changes
Only the role name and one line of wizard copy changed ("This becomes the first Administrator..."). No structural UX change — still collects name/email, verifies via OTP, creates/activates the account, assigns the role, exactly as before.
7. Files changed
New: the migration; email-change.service.ts + change-email.ts action + change-email-section.tsx; invite-administrator-form.tsx + add-administrator/page.tsx; 3 new integration test files.
Modified — core: staff.service.ts (transactional protection, isStaffAdministrator), staff-invitation.service.ts, otp.service.ts (onlyIfUserExists fix, email-change purpose), default-permissions.ts, permissions.ts, auth.ts (JWT email-refresh, simplified finalize path).
Modified — actions/UI: owner/actions/staff.ts, staff-detail-panel.tsx, team/page.tsx, add-staff-form.tsx, accept-invitation.ts/form/page, otp-code-step.tsx, profile-form.tsx, installer files, seed.ts, plus a terminology sweep ("owner" → "administrator" copy in breaks.ts/time-off.ts/staff-emails.ts/profile page).
8. Manual testing steps
Sign in as the seeded owner → /owner/team shows an Administrator badge and an "Invite Administrator" icon next to "+". Invite someone, accept via /invite/accept?token=... printed to the terminal (code also prints there) — they land signed in with full Manage access. Promote/demote any staff member via the role dropdown on their detail page — crossing the Administrator boundary shows a confirmation dialog. With only one Administrator left, attempting to demote or deactivate them shows "You can't remove the only administrator. Add another administrator first." Change your own email from Profile settings — the new address must be OTP-verified before it replaces the old one.
9. Deployment steps on the VPS
None beyond the normal deploy process — the migration is a standard Prisma migration that prisma migrate deploy (whatever already runs it there) will pick up automatically. No new environment variables. No changes to NextChair Manager or its authentication.
Verification performed: tsc/lint/unit — all clean; full test:integration — 219 passed (up from 207, all new); full test:e2e — same 36/37 as before this work (the 1 failure is a pre-existing, auth-unrelated flake unrelated to any of this); a full live manual pass covering every scenario above, including the last-administrator block and the email-change JWT-refresh behavior.
1051 lines
36 KiB
TypeScript
1051 lines
36 KiB
TypeScript
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<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 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<boolean> {
|
|
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<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;
|
|
}
|
|
|
|
/**
|
|
* 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() };
|
|
}
|