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

@@ -0,0 +1,169 @@
"use server";
import { prisma } from "@/lib/db/prisma";
import { getActingStaff } from "@/lib/staff/get-acting-staff";
import { cancelBooking, rescheduleBooking } from "@/services/scheduling/booking.service";
import {
checkIn,
complete,
confirm,
getAppointmentById,
markNoShow,
start,
} from "@/services/scheduling/appointment.service";
import { awardStampForCompletedAppointment } from "@/services/loyalty/loyalty.service";
import { assertOwnAppointment, StaffPermissionDeniedError } from "../authorization";
/**
* Every status transition here does exactly three things: load the
* appointment, assert it's this staff member's own (in their own
* business), and delegate to AppointmentService/BookingService for the
* actual transition — the transition rules themselves (which statuses can
* move to which) live entirely in AppointmentService's own state machine,
* never re-implemented here. Completing an appointment additionally calls
* LoyaltyService, since "complete this visit" and "award a stamp for it"
* are two different domain responsibilities this layer is allowed to
* orchestrate together — see docs/phase-5-summary.md.
*/
export interface StaffAppointmentActionResult {
success: boolean;
error?: string;
loyaltyStampAwarded?: boolean;
}
async function loadOwnAppointment(appointmentId: string) {
const staff = await getActingStaff();
if (!staff)
return { staff: null, appointment: null, error: "You must be signed in as staff." } as const;
const appointment = await getAppointmentById(prisma, appointmentId);
if (!appointment) return { staff, appointment: null, error: "Appointment not found." } as const;
try {
assertOwnAppointment(appointment, staff);
} catch (error) {
if (error instanceof StaffPermissionDeniedError)
return { staff, appointment: null, error: error.message } as const;
throw error;
}
return { staff, appointment, error: null } as const;
}
export async function confirmAppointmentAction(
appointmentId: string,
): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await confirm(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function checkInAppointmentAction(
appointmentId: string,
): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await checkIn(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function startAppointmentAction(
appointmentId: string,
): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await start(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function completeAppointmentAction(
appointmentId: string,
): Promise<StaffAppointmentActionResult> {
const { staff, appointment, error } = await loadOwnAppointment(appointmentId);
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
const result = await complete(prisma, appointmentId);
if (!result.success) {
return { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
const loyaltyResult = await awardStampForCompletedAppointment(prisma, {
appointmentId,
staffId: staff.id,
});
return {
success: true,
loyaltyStampAwarded: loyaltyResult.success && !loyaltyResult.alreadyAwarded,
};
}
export async function markNoShowAction(
appointmentId: string,
): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnAppointment(appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await markNoShow(prisma, appointmentId);
return result.success
? { success: true }
: { success: false, error: friendlyTransitionMessage(result.validation.issues[0]?.code) };
}
export async function cancelStaffAppointmentAction(params: {
appointmentId: string;
reason?: string;
}): Promise<StaffAppointmentActionResult> {
const { staff, appointment, error } = await loadOwnAppointment(params.appointmentId);
if (!appointment || !staff) return { success: false, error: error ?? "Not found." };
const result = await cancelBooking(prisma, {
appointmentId: params.appointmentId,
cancelledByUserId: staff.user.id,
reason: params.reason,
});
return result.success
? { success: true }
: {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't cancel this appointment.",
};
}
export async function rescheduleStaffAppointmentAction(params: {
appointmentId: string;
staffId?: string;
startAt: string;
}): Promise<StaffAppointmentActionResult> {
const { appointment, error } = await loadOwnAppointment(params.appointmentId);
if (!appointment) return { success: false, error: error ?? "Not found." };
const result = await rescheduleBooking(prisma, {
appointmentId: params.appointmentId,
staffId: params.staffId,
startAt: new Date(params.startAt),
});
return result.success
? { success: true }
: {
success: false,
error: result.validation.issues[0]?.message ?? "Couldn't reschedule this appointment.",
};
}
function friendlyTransitionMessage(code?: string): string {
if (code === "INVALID_STATUS_TRANSITION")
return "This appointment can't move to that status right now — someone may have already updated it.";
if (code === "APPOINTMENT_NOT_FOUND") return "We couldn't find that appointment.";
return "Something went wrong. Please try again.";
}