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

+453
View File
@@ -0,0 +1,453 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
* is always derived from — and only ever changed alongside — a
* `LoyaltyTransaction` row. Nothing in this file writes `currentStamps`
* without creating the matching transaction in the same call, and nothing
* outside this file should touch either table directly.
*
* Reward reset-on-redemption (see LoyaltyReward's own schema comment) is
* deliberately not implemented here — redeeming a reward isn't one of the
* Phase 5 barber capabilities, so `currentStamps` is allowed to sit at or
* above `stampsRequired` once a reward becomes available. Redemption is a
* documented Phase 5 limitation, not an oversight.
*/
export type LoyaltyErrorCode =
| "LOYALTY_DISABLED"
| "PROGRAMME_NOT_FOUND"
| "NOT_ELIGIBLE_SERVICE"
| "NOT_ELIGIBLE_STAFF"
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED";
export interface LoyaltySummary {
isEnabled: boolean;
accountId: string | null;
currentStamps: number;
stampsRequired: number;
availableReward: { id: string; label: string; createdAt: Date } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
}
/** The customer's loyalty standing for this business — null only if no loyalty programme has ever been configured (distinct from "configured but disabled", which still returns a summary with isEnabled: false). */
export async function getLoyaltySummary(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
): Promise<LoyaltySummary | null> {
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme) return null;
const account = await prisma.loyaltyAccount.findUnique({
where: {
customerId_loyaltyProgrammeId: {
customerId: params.customerId,
loyaltyProgrammeId: programme.id,
},
},
include: {
rewards: { where: { status: "AVAILABLE" }, orderBy: { createdAt: "desc" }, take: 1 },
},
});
return {
isEnabled: programme.isEnabled,
accountId: account?.id ?? null,
currentStamps: account?.currentStamps ?? 0,
stampsRequired: programme.stampsRequired,
availableReward: account?.rewards[0]
? {
id: account.rewards[0].id,
label: account.rewards[0].label,
createdAt: account.rewards[0].createdAt,
}
: null,
rewardLabel: programme.rewardLabel,
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
};
}
/**
* Batched version of getLoyaltySummary's "does this customer have a reward
* waiting" check, for rendering a loyalty indicator across a whole day's
* appointments (the calendar) without one query per customer. Returns an
* empty map if loyalty isn't configured/enabled at all.
*/
export async function getAvailableRewardFlags(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, boolean>> {
if (params.customerIds.length === 0) return new Map();
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) return new Map();
const accounts = await prisma.loyaltyAccount.findMany({
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
select: {
customerId: true,
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
},
});
return new Map(accounts.map((a) => [a.customerId, a.rewards.length > 0]));
}
export interface CustomerLoyaltyBrief {
currentStamps: number;
stampsRequired: number;
hasAvailableReward: boolean;
}
/** Fuller batched summary — current stamps + threshold + reward flag — for the staff customer directory, one query for the whole list rather than one per row. */
export async function getLoyaltySummariesForCustomers(
prisma: PrismaClient,
params: { businessId: string; customerIds: string[] },
): Promise<Map<string, CustomerLoyaltyBrief>> {
if (params.customerIds.length === 0) return new Map();
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) return new Map();
const accounts = await prisma.loyaltyAccount.findMany({
where: { loyaltyProgrammeId: programme.id, customerId: { in: params.customerIds } },
select: {
customerId: true,
currentStamps: true,
rewards: { where: { status: "AVAILABLE" }, select: { id: true }, take: 1 },
},
});
return new Map(
accounts.map((a) => [
a.customerId,
{
currentStamps: a.currentStamps,
stampsRequired: programme.stampsRequired,
hasAvailableReward: a.rewards.length > 0,
},
]),
);
}
async function getOrCreateAccount(
prisma: PrismaClient,
params: { customerId: string; loyaltyProgrammeId: string },
) {
return prisma.loyaltyAccount.upsert({
where: {
customerId_loyaltyProgrammeId: {
customerId: params.customerId,
loyaltyProgrammeId: params.loyaltyProgrammeId,
},
},
create: { customerId: params.customerId, loyaltyProgrammeId: params.loyaltyProgrammeId },
update: {},
});
}
/**
* An empty eligible-services/eligible-staff list means "everyone/everything
* is eligible" (see schema.prisma) — an explicit row only exists to
* *restrict* eligibility. A visit counts if at least one of its services is
* eligible (the common "any qualifying service in this visit earns a
* stamp" rule), not only if every service is.
*/
async function checkEligibility(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[]; staffId: string },
): Promise<ValidationResult<LoyaltyErrorCode>> {
const [eligibleServices, eligibleStaff] = await Promise.all([
prisma.loyaltyEligibleService.findMany({
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
}),
prisma.loyaltyEligibleStaff.findMany({
where: { loyaltyProgrammeId: params.loyaltyProgrammeId },
}),
]);
if (eligibleStaff.length > 0 && !eligibleStaff.some((r) => r.staffId === params.staffId)) {
return fail({
code: "NOT_ELIGIBLE_STAFF",
message: "This staff member's visits don't earn loyalty stamps.",
});
}
if (
eligibleServices.length > 0 &&
!params.serviceIds.some((id) => eligibleServices.some((r) => r.serviceId === id))
) {
return fail({
code: "NOT_ELIGIBLE_SERVICE",
message: "None of the services on this visit earn a loyalty stamp.",
});
}
return ok();
}
interface AddStampResult {
success: boolean;
alreadyAwarded?: boolean;
rewardCreated?: boolean;
validation: ValidationResult<LoyaltyErrorCode>;
}
/**
* The auto-award path, called once by the "complete appointment" Server
* Action after AppointmentService has already transitioned the status —
* this function never changes appointment status itself. Idempotent: if a
* STAMP_ADDED transaction already references this appointment (e.g. the
* action is somehow triggered twice), it returns success without creating
* a second one — the brief's explicit "should not receive the same
* automatic stamp twice".
*/
export async function awardStampForCompletedAppointment(
prisma: PrismaClient,
params: { appointmentId: string; staffId: string },
): Promise<AddStampResult> {
const appointment = await prisma.appointment.findUnique({
where: { id: params.appointmentId },
include: { services: true },
});
if (!appointment)
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }),
};
if (appointment.status !== "COMPLETED") {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_COMPLETED",
message: "Only a completed appointment can earn a stamp.",
}),
};
}
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: appointment.businessId },
});
if (!programme || !programme.isEnabled) {
return {
success: false,
validation: fail({
code: "LOYALTY_DISABLED",
message: "Loyalty isn't enabled for this business.",
}),
};
}
const eligibility = await checkEligibility(prisma, {
loyaltyProgrammeId: programme.id,
serviceIds: appointment.services.map((s) => s.serviceId),
staffId: appointment.staffId,
});
if (!eligibility.valid) return { success: false, validation: eligibility };
const account = await getOrCreateAccount(prisma, {
customerId: appointment.customerId,
loyaltyProgrammeId: programme.id,
});
const alreadyAwarded = await prisma.loyaltyTransaction.findFirst({
where: { loyaltyAccountId: account.id, appointmentId: appointment.id, type: "STAMP_ADDED" },
});
if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() };
const { rewardCreated } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: 1, // one stamp per completed visit — see file header; not a per-service count
appointmentId: appointment.id,
authoredByStaffId: params.staffId,
reason: null,
programme,
});
return { success: true, rewardCreated, validation: ok() };
}
/** The manual "Add Stamp" action a staff member triggers from Customer Details / Current Appointment — not tied to a completion event, so no duplicate-award check applies (every manual add is its own deliberate transaction). */
export async function addStamp(
prisma: PrismaClient,
params: {
customerId: string;
businessId: string;
amount: number;
staffId: string;
appointmentId?: string;
reason?: string;
},
): Promise<AddStampResult> {
if (!Number.isInteger(params.amount) || params.amount <= 0) {
return {
success: false,
validation: fail({ code: "INVALID_AMOUNT", message: "Enter a positive number of stamps." }),
};
}
const programme = await prisma.loyaltyProgramme.findUnique({
where: { businessId: params.businessId },
});
if (!programme || !programme.isEnabled) {
return {
success: false,
validation: fail({
code: "LOYALTY_DISABLED",
message: "Loyalty isn't enabled for this business.",
}),
};
}
const account = await getOrCreateAccount(prisma, {
customerId: params.customerId,
loyaltyProgrammeId: programme.id,
});
const { rewardCreated } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: params.amount,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
reason: params.reason ?? null,
programme,
});
return { success: true, rewardCreated, validation: ok() };
}
async function addStampTransaction(
prisma: PrismaClient,
params: {
loyaltyAccountId: string;
amount: number;
appointmentId?: string;
authoredByStaffId: string;
reason: string | null;
programme: LoyaltyProgramme;
},
): Promise<{ rewardCreated: boolean }> {
return prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { increment: params.amount } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_ADDED",
amount: params.amount,
reason: params.reason,
appointmentId: params.appointmentId,
authoredByStaffId: params.authoredByStaffId,
},
});
let rewardCreated = false;
if (account.currentStamps >= params.programme.stampsRequired) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
// Crossing the threshold again while a reward is already waiting to be
// redeemed doesn't stack a second one.
if (!existingAvailable) {
await tx.loyaltyReward.create({
data: {
loyaltyAccountId: account.id,
rewardType: params.programme.rewardType,
value: params.programme.rewardValue,
label: params.programme.rewardLabel ?? "Reward",
status: "AVAILABLE",
expiresAt: params.programme.rewardExpiresAfterDays
? new Date(Date.now() + params.programme.rewardExpiresAfterDays * 86_400_000)
: null,
},
});
rewardCreated = true;
}
}
return { rewardCreated };
});
}
/**
* Corrects an accidental stamp — always removes (never adds; use `addStamp`
* for that), always requires a reason, and always creates a compensating
* STAMP_REMOVED transaction rather than editing or deleting the original
* entry. The cached total is clamped at zero so a correction can never make
* it negative.
*/
export async function correctStamp(
prisma: PrismaClient,
params: { loyaltyAccountId: string; amount: number; staffId: string; reason: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
if (!params.reason.trim()) {
return {
success: false,
validation: fail({
code: "REASON_REQUIRED",
message: "A reason is required to correct a stamp.",
}),
};
}
if (!Number.isInteger(params.amount) || params.amount <= 0) {
return {
success: false,
validation: fail({
code: "INVALID_AMOUNT",
message: "Enter a positive number of stamps to remove.",
}),
};
}
await prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.findUniqueOrThrow({
where: { id: params.loyaltyAccountId },
});
const clampedRemoval = Math.min(params.amount, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { decrement: clampedRemoval } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_REMOVED",
amount: -clampedRemoval,
reason: params.reason,
authoredByStaffId: params.staffId,
},
});
});
return { success: true, validation: ok() };
}
export async function getLoyaltyHistory(
prisma: PrismaClient,
params: { loyaltyAccountId: string; limit?: number },
) {
return prisma.loyaltyTransaction.findMany({
where: { loyaltyAccountId: params.loyaltyAccountId },
orderBy: { createdAt: "desc" },
take: params.limit ?? 20,
include: {
authoredByStaff: { include: { user: { select: { name: true } } } },
appointment: { select: { id: true, startAt: true } },
},
});
}