Owner Administration is complete. Summary of this final stretch of work:

Seed data extended with an inactive staff member, a pending invitation, a redeemed-reward fixture, a service with no eligible staff, and a future-appointment fixture for conflict testing — all idempotent, verified against the dev DB.

Testing brought to 179 unit / 86 integration / 27 E2E tests, all passing together on a freshly seeded, disposable Postgres:

Unit tests for every new pure validator (business hours, categories/services, booking rules, loyalty config, staff creation).
Integration tests covering opening-hours conflicts, last-owner protection, staff deactivation/eligibility conflicts, loyalty redemption's atomic race-guard, and owner-vs-barber/business-isolation authorization — all against a real Postgres.
12 new Playwright owner journeys, which surfaced and fixed two genuine issues along the way: a cookie-clearing race condition in the sign-out-mid-test flow (a real, reproducible timing bug, not just a test artifact), and confirmation that seed fixtures upserted by deterministic ID go stale once "today" has moved on — now handled defensively.
Desktop responsiveness: converted the single-column Business Details, Booking Rules, and Loyalty Configuration forms to a genuine two-column layout at the lg: breakpoint (verified via measured element positions, not just screenshots), confirmed mobile stacking is unaffected.

Final verification: clean tsc, eslint, and next build, plus the full three-suite run against one fresh database as the authoritative pass.
This commit is contained in:
Stefan Lazic committed 2026-08-03 12:56:55 +01:00
1 parent ffee9d6754
commit ec5583f972
73 files changed
+8400 -53

No files matched your search

+295 -6
View File
@@ -9,11 +9,11 @@ import type { ValidationResult } from "@/lib/validation-result";
* 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.
* Reward redemption (Phase 6) follows the same discipline: redeeming a
* reward, deducting the consumed stamps from the cached total, and
* recording the ledger entry all happen inside one `$transaction` — see
* `redeemReward`. This was a documented, deliberate Phase 5 limitation,
* not an oversight; it's implemented now.
*/
export type LoyaltyErrorCode =
@@ -24,7 +24,15 @@ export type LoyaltyErrorCode =
| "REASON_REQUIRED"
| "INVALID_AMOUNT"
| "APPOINTMENT_NOT_FOUND"
| "APPOINTMENT_NOT_COMPLETED";
| "APPOINTMENT_NOT_COMPLETED"
| "INVALID_INPUT"
| "INVALID_PERCENTAGE"
| "INVALID_VALUE"
| "FREE_SERVICE_REQUIRED"
| "REWARD_NOT_FOUND"
| "REWARD_NOT_AVAILABLE"
| "REWARD_CUSTOMER_MISMATCH"
| "APPOINTMENT_NOT_ELIGIBLE";
export interface LoyaltySummary {
isEnabled: boolean;
@@ -451,3 +459,284 @@ export async function getLoyaltyHistory(
},
});
}
// ── Owner: programme configuration ─────────────────────────────────────
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
rewardType: "PERCENTAGE" | "FIXED_AMOUNT" | "FREE_SERVICE";
/** Decimal string — see ServiceCatalogueService's file header for why money is never a JS number. Required for PERCENTAGE/FIXED_AMOUNT, ignored for FREE_SERVICE. */
rewardValue?: string;
rewardLabel?: string;
maxRewardValue?: string;
freeServiceId?: string;
autoApplyReward: boolean;
requiresStaffApproval: boolean;
stampsExpireAfterDays?: number;
rewardExpiresAfterDays?: number;
earnOnCancelled: boolean;
earnOnNoShow: boolean;
cardBackgroundColor?: string;
cardBackgroundImageUrl?: string;
stampIcon?: string;
termsLine?: string;
}
/** Only enforced when isEnabled — a half-configured, disabled draft is fine (the owner is still working on it); a half-configured, *enabled* programme would start awarding rewards nobody can redeem correctly. */
export function validateProgrammeConfig(input: ProgrammeConfigInput): ValidationResult<LoyaltyErrorCode> {
if (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1) {
return fail({ code: "INVALID_INPUT", message: "Stamps required must be at least 1." });
}
if (!input.isEnabled) return ok();
if (input.rewardType === "PERCENTAGE") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value <= 0 || value > 100) {
return fail({ code: "INVALID_PERCENTAGE", message: "Percentage must be between 1 and 100." });
}
} else if (input.rewardType === "FIXED_AMOUNT") {
const value = input.rewardValue !== undefined ? Number(input.rewardValue) : NaN;
if (!Number.isFinite(value) || value < 0) {
return fail({ code: "INVALID_VALUE", message: "Enter a fixed reward amount of zero or more." });
}
} else if (input.rewardType === "FREE_SERVICE" && !input.freeServiceId) {
return fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose which service is free." });
}
return ok();
}
export async function getProgrammeConfig(prisma: PrismaClient, businessId: string) {
return prisma.loyaltyProgramme.findUnique({
where: { businessId },
include: {
eligibleServices: { include: { service: true } },
eligibleStaff: true,
freeService: true,
},
});
}
export async function updateProgrammeConfig(
prisma: PrismaClient,
params: { businessId: string; input: ProgrammeConfigInput },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode> }> {
const validation = validateProgrammeConfig(params.input);
if (!validation.valid) return { success: false, validation };
if (params.input.isEnabled && params.input.rewardType === "FREE_SERVICE" && params.input.freeServiceId) {
const service = await prisma.service.findFirst({
where: { id: params.input.freeServiceId, businessId: params.businessId, isActive: true },
});
if (!service) {
return {
success: false,
validation: fail({ code: "FREE_SERVICE_REQUIRED", message: "Choose an active service." }),
};
}
}
const data = {
isEnabled: params.input.isEnabled,
stampsRequired: params.input.stampsRequired,
rewardType: params.input.rewardType,
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
rewardLabel: params.input.rewardLabel?.trim() || null,
maxRewardValue: params.input.maxRewardValue || null,
freeServiceId: params.input.rewardType === "FREE_SERVICE" ? (params.input.freeServiceId ?? null) : null,
autoApplyReward: params.input.autoApplyReward,
requiresStaffApproval: params.input.requiresStaffApproval,
stampsExpireAfterDays: params.input.stampsExpireAfterDays ?? null,
rewardExpiresAfterDays: params.input.rewardExpiresAfterDays ?? null,
earnOnCancelled: params.input.earnOnCancelled,
earnOnNoShow: params.input.earnOnNoShow,
cardBackgroundColor: params.input.cardBackgroundColor || null,
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
stampIcon: params.input.stampIcon || null,
termsLine: params.input.termsLine?.trim() || null,
};
await prisma.loyaltyProgramme.upsert({
where: { businessId: params.businessId },
create: { businessId: params.businessId, ...data },
update: data,
});
return { success: true, validation: ok() };
}
export async function setEligibleServices(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; serviceIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleService.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleService.createMany({
data: params.serviceIds.map((serviceId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, serviceId })),
}),
]);
return { success: true };
}
/** LoyaltyEligibleStaff.staffId has no FK relation in the schema (see schema.prisma) — callers that need staff names for display fetch StaffProfile rows separately and join by id in memory. */
export async function setEligibleStaff(
prisma: PrismaClient,
params: { loyaltyProgrammeId: string; staffIds: string[] },
): Promise<{ success: true }> {
await prisma.$transaction([
prisma.loyaltyEligibleStaff.deleteMany({ where: { loyaltyProgrammeId: params.loyaltyProgrammeId } }),
prisma.loyaltyEligibleStaff.createMany({
data: params.staffIds.map((staffId) => ({ loyaltyProgrammeId: params.loyaltyProgrammeId, staffId })),
}),
]);
return { success: true };
}
// ── Reward redemption ───────────────────────────────────────────────────
/** Appointments this reward could be applied to right now — same customer, not cancelled, no reward already applied. Shown so staff pick a real, current appointment rather than typing an id. */
export async function listEligibleAppointmentsForReward(
prisma: PrismaClient,
params: { customerId: string; businessId: string },
) {
return prisma.appointment.findMany({
where: {
customerId: params.customerId,
businessId: params.businessId,
status: { notIn: ["CANCELLED"] },
discountAmount: null,
},
include: { services: { include: { service: true } }, staff: { include: { user: { select: { name: true } } } } },
orderBy: { startAt: "desc" },
take: 10,
});
}
/**
* Redeems an AVAILABLE reward against a specific appointment — the one
* place `LoyaltyReward.status`, `LoyaltyAccount.currentStamps`, and
* `Appointment.discountAmount` ever change together. The status flip uses
* a conditional `updateMany` (`WHERE status = 'AVAILABLE'`) inside the
* transaction rather than a read-then-write, so two simultaneous
* redemption attempts can't both succeed — the loser sees
* REWARD_NOT_AVAILABLE, not a corrupted double-deduction.
*
* Neither the reward nor the stamps-required count is snapshotted at
* *earn* time — redemption always uses the loyalty programme's current
* configuration (current stampsRequired, current freeServiceId for a
* FREE_SERVICE reward). A documented simplification: if the owner
* reconfigures the programme between a reward being earned and redeemed,
* redemption reflects the new configuration. See docs/phase-6-summary.md.
*/
export async function redeemReward(
prisma: PrismaClient,
params: { rewardId: string; appointmentId: string; staffId: string; businessId: string },
): Promise<{ success: boolean; validation: ValidationResult<LoyaltyErrorCode>; discountAmount?: string }> {
const reward = await prisma.loyaltyReward.findUnique({
where: { id: params.rewardId },
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
if (!reward || reward.loyaltyAccount.loyaltyProgramme.businessId !== params.businessId) {
return { success: false, validation: fail({ code: "REWARD_NOT_FOUND", message: "Reward not found." }) };
}
if (reward.status !== "AVAILABLE") {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
};
}
const appointment = await prisma.appointment.findFirst({
where: { id: params.appointmentId, businessId: params.businessId },
include: { services: true },
});
if (!appointment) {
return { success: false, validation: fail({ code: "APPOINTMENT_NOT_FOUND", message: "Appointment not found." }) };
}
if (appointment.customerId !== reward.loyaltyAccount.customerId) {
return {
success: false,
validation: fail({ code: "REWARD_CUSTOMER_MISMATCH", message: "This reward belongs to a different customer." }),
};
}
if (appointment.status === "CANCELLED") {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "Can't apply a reward to a cancelled appointment." }),
};
}
if (appointment.discountAmount !== null) {
return {
success: false,
validation: fail({ code: "APPOINTMENT_NOT_ELIGIBLE", message: "A reward has already been applied to this appointment." }),
};
}
const subtotal = appointment.services.reduce((sum, s) => sum + Number(s.priceAtBooking), 0);
let discountAmount: number;
if (reward.rewardType === "PERCENTAGE") {
discountAmount = Math.round(subtotal * (Number(reward.value ?? 0) / 100) * 100) / 100;
} else if (reward.rewardType === "FIXED_AMOUNT") {
discountAmount = Number(reward.value ?? 0);
} else {
const freeLine = appointment.services.find((s) => s.serviceId === reward.loyaltyAccount.loyaltyProgramme.freeServiceId);
if (!freeLine) {
return {
success: false,
validation: fail({
code: "APPOINTMENT_NOT_ELIGIBLE",
message: "This appointment doesn't include the service this reward covers.",
}),
};
}
discountAmount = Number(freeLine.priceAtBooking);
}
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
const discountAmountString = discountAmount.toFixed(2);
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
const updated = await tx.loyaltyReward.updateMany({
where: { id: params.rewardId, status: "AVAILABLE" },
data: {
status: "REDEEMED",
redeemedAt: new Date(),
redeemedByStaffId: params.staffId,
appliedToAppointmentId: params.appointmentId,
},
});
if (updated.count === 0) return { alreadyRedeemed: true };
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: account.id },
data: { currentStamps: { decrement: clampedDeduction } },
});
await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: account.id,
type: "REWARD_REDEEMED",
amount: -clampedDeduction,
appointmentId: params.appointmentId,
authoredByStaffId: params.staffId,
},
});
await tx.appointment.update({
where: { id: params.appointmentId },
data: { discountAmount: discountAmountString },
});
return { alreadyRedeemed: false };
});
if (alreadyRedeemed) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed." }),
};
}
return { success: true, validation: ok(), discountAmount: discountAmountString };
}