Phase 5 is complete. All 53 tasks are done, verified on a fresh database in a single clean run: **121 unit tests**, **55 integration tests**, and **15 E2E journeys** (6 Phase 4 + 9 new Phase 5) all passing, plus a clean `tsc`/`eslint`/`build`.

Summary of this final stretch:

- **Fixed a real bug**: `StaffService`'s break/time-off update/delete functions threw uncaught instead of failing gracefully on a permission mismatch — fixed with proper `PERMISSION_DENIED` handling, covered by new integration tests.
- **Built the remaining Phase 5 UI**: Breaks editor, Time Off editor, Barber Profile, and Notification Preferences — all wired to real services, no placeholders.
- **Extended seed data**: more customers, loyalty accounts/transactions/rewards, and appointments spanning every status today (anchored to real time so the "Current Appointment" priority logic is reliably exercised).
- **Found and fixed a second real bug live in the browser**: the Break/Time-Off edit sheets showed stale default values instead of the item being edited (a classic `useState`-doesn't-reinitialize-on-prop-change bug) — fixed with a `key` prop to force remount.
- **Wrote the full Phase 5 test suite**: unit tests for `selectCurrentAppointment` and all `StaffService` validators, integration tests for `LoyaltyService` and `StaffService` (including the permission-fix regression tests), and 9 new Playwright E2E journeys covering the brief's exact required list.
- **Did the desktop-responsive pass**: a persistent sidebar nav replacing the bottom bar at `lg:`, a genuine calendar+"Right Now" side-by-side split, and 2-column layouts for the clients list and customer details — addressing the brief's explicit "more than simple centering" requirement.
- **Wrote [docs/phase-5-summary.md](docs/phase-5-summary.md)** documenting everything above in full.

Nothing is committed — the whole session (Phases 2–5) sits uncommitted on top of the initial template commit, same as you left it. Let me know if you'd like me to commit, or if you want to move on to Phase 6.
This commit is contained in:
Stefan Lazic committed 2026-08-02 23:44:18 +01:00
1 parent 6ae145d187
commit ffee9d6754
81 files changed
+8024 -113

No files matched your search

+627
View File
@@ -0,0 +1,627 @@
import type { PrismaClient } from "@/generated/prisma/client";
import { NotificationCategory, NotificationChannel, 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";
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" } });
}
export async function setWorkingHours(
prisma: PrismaClient,
params: { staffId: string; days: WorkingHoursDayInput[] },
): Promise<{ success: boolean; validation: ValidationResult<StaffServiceErrorCode> }> {
const validation = validateWorkingHoursInput(params.days);
if (!validation.valid) return { success: false, validation };
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, 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 };
}
// ── Notification preferences ───────────────────────────────────────────
// UI only this phase — no real delivery. Every (category, channel) pair
// defaults to enabled (matches the schema's `isEnabled @default(true)`),
// so a user with no rows yet still sees a fully-populated, all-on grid.
const NOTIFICATION_CATEGORIES = Object.values(NotificationCategory);
const NOTIFICATION_CHANNELS = Object.values(NotificationChannel);
export interface NotificationPreferenceCell {
category: NotificationCategory;
channel: NotificationChannel;
isEnabled: boolean;
}
export async function getNotificationPreferences(
prisma: PrismaClient,
userId: string,
): Promise<NotificationPreferenceCell[]> {
const rows = await prisma.notificationPreference.findMany({ where: { userId } });
const byKey = new Map(rows.map((r) => [`${r.category}:${r.channel}`, r.isEnabled]));
const cells: NotificationPreferenceCell[] = [];
for (const category of NOTIFICATION_CATEGORIES) {
for (const channel of NOTIFICATION_CHANNELS) {
cells.push({ category, channel, isEnabled: byKey.get(`${category}:${channel}`) ?? true });
}
}
return cells;
}
export async function setNotificationPreferences(
prisma: PrismaClient,
userId: string,
updates: NotificationPreferenceCell[],
): Promise<{ success: true }> {
await prisma.$transaction(
updates.map((u) =>
prisma.notificationPreference.upsert({
where: { userId_category_channel: { userId, category: u.category, channel: u.channel } },
create: { userId, category: u.category, channel: u.channel, isEnabled: u.isEnabled },
update: { isEnabled: u.isEnabled },
}),
),
);
return { success: true };
}