Phase 5
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:
81 files changed
+8024
-113
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import "server-only";
|
||||
import { cookies } from "next/headers";
|
||||
import { bookingDraftSchema, emptyDraft } from "./schema";
|
||||
import type { BookingDraft } from "./schema";
|
||||
import { bookingDraftSchema, emptyDraft, emptyStaffDraft, staffBookingDraftSchema } from "./schema";
|
||||
import type { BookingDraft, StaffBookingDraft } from "./schema";
|
||||
import { sign, verify } from "./sign";
|
||||
|
||||
/**
|
||||
@@ -80,3 +80,44 @@ export async function readLastBookingReference(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff "book for customer" draft ─────────────────────────────────────
|
||||
// A separate cookie from the customer draft above — see the schema
|
||||
// comment on StaffBookingDraft for why.
|
||||
|
||||
const STAFF_COOKIE_NAME = "nc_staff_booking_draft";
|
||||
|
||||
export async function readStaffDraft(businessId: string): Promise<StaffBookingDraft> {
|
||||
const store = await cookies();
|
||||
const raw = store.get(STAFF_COOKIE_NAME)?.value;
|
||||
if (!raw) return emptyStaffDraft(businessId);
|
||||
|
||||
const payload = verify(raw);
|
||||
if (!payload) return emptyStaffDraft(businessId);
|
||||
|
||||
try {
|
||||
const parsed = staffBookingDraftSchema.safeParse(JSON.parse(payload));
|
||||
if (!parsed.success || parsed.data.businessId !== businessId)
|
||||
return emptyStaffDraft(businessId);
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return emptyStaffDraft(businessId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeStaffDraft(draft: StaffBookingDraft): Promise<void> {
|
||||
const store = await cookies();
|
||||
const token = sign(JSON.stringify(draft));
|
||||
store.set(STAFF_COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: MAX_AGE_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearStaffDraft(): Promise<void> {
|
||||
const store = await cookies();
|
||||
store.delete(STAFF_COOKIE_NAME);
|
||||
}
|
||||
@@ -36,3 +36,34 @@ export function emptyDraft(businessId: string): BookingDraft {
|
||||
policyAcknowledged: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The staff-facing "book for customer" draft (src/app/staff/book) — a
|
||||
* deliberately separate shape and cookie from the customer one above
|
||||
* (see cookie.ts), even though the two overlap heavily, so a staff member
|
||||
* booking on a customer's behalf can never collide with their own
|
||||
* in-progress personal booking in another tab. Reuses `guestDetailsSchema`
|
||||
* as-is for "staff is entering a brand-new customer's details" — the same
|
||||
* shape CustomerService.resolveCustomerForBooking already knows how to
|
||||
* resolve/match, so no new matching logic is needed for that path.
|
||||
*/
|
||||
export const staffBookingDraftSchema = z.object({
|
||||
businessId: z.string(),
|
||||
serviceIds: z.array(z.string()).default([]),
|
||||
/** undefined = "any available barber". */
|
||||
staffId: z.string().optional(),
|
||||
date: z.string().optional(),
|
||||
startAt: z.iso.datetime().optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
/** An existing CustomerProfile the staff member picked from search. */
|
||||
customerId: z.string().optional(),
|
||||
/** A brand-new guest customer the staff member is entering inline. Ignored if customerId is set. */
|
||||
guest: guestDetailsSchema.optional(),
|
||||
/** Set by "Book Follow-Up" so the services step can show which appointment this draft originated from — display only, never trusted for pricing. */
|
||||
followUpFromAppointmentId: z.string().optional(),
|
||||
});
|
||||
export type StaffBookingDraft = z.infer<typeof staffBookingDraftSchema>;
|
||||
|
||||
export function emptyStaffDraft(businessId: string): StaffBookingDraft {
|
||||
return { businessId, serviceIds: [] };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { cache } from "react";
|
||||
import { auth } from "@/auth";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
/**
|
||||
* The signed-in staff member's own profile, request-deduped via React's
|
||||
* `cache()` the same way getPrimaryBusiness() is — every staff page needs
|
||||
* this, and several call it more than once per render (page + nested
|
||||
* layout-level checks), so this avoids repeating the query.
|
||||
*
|
||||
* Returns null for a guest or a customer-only account; (staff)/layout.tsx
|
||||
* already redirects those away, but every page re-checks anyway per the
|
||||
* "permission enforcement happens again at each layer" rule established in
|
||||
* Phase 2 (see src/lib/auth/permissions.ts).
|
||||
*/
|
||||
export const getActingStaff = cache(async () => {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) return null;
|
||||
|
||||
return prisma.staffProfile.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true, phone: true, image: true } },
|
||||
business: { select: { id: true, name: true, timezone: true } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export type ActingStaffProfile = NonNullable<Awaited<ReturnType<typeof getActingStaff>>>;
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Generic version of the {valid, issues} shape scheduling/types.ts
|
||||
* pioneered in Phase 3 — new Phase 5 services (Staff, Loyalty) want the
|
||||
* same "typed error code + human message, never throw for an expected
|
||||
* business-rule failure" pattern but with their own closed code unions.
|
||||
* scheduling/types.ts itself is left untouched (its BookingValidationErrorCode
|
||||
* union is already relied on across Phases 3-4) rather than widened to
|
||||
* cover unrelated domains.
|
||||
*/
|
||||
export interface ValidationIssue<Code extends string> {
|
||||
code: Code;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult<Code extends string> {
|
||||
valid: boolean;
|
||||
issues: ValidationIssue<Code>[];
|
||||
}
|
||||
|
||||
export function ok<Code extends string>(): ValidationResult<Code> {
|
||||
return { valid: true, issues: [] };
|
||||
}
|
||||
|
||||
export function fail<Code extends string>(
|
||||
...issues: ValidationIssue<Code>[]
|
||||
): ValidationResult<Code> {
|
||||
return { valid: false, issues };
|
||||
}
|
||||
|
||||
export function mergeValidation<Code extends string>(
|
||||
...results: ValidationResult<Code>[]
|
||||
): ValidationResult<Code> {
|
||||
const issues = results.flatMap((r) => r.issues);
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
Reference in new issue
Block a user