Fixed an issue where media manager only shows files uploaded in specific sections.

Fixed an issue with icon picker names.

Removed jumbo rounding and a few other style tweaks.
This commit is contained in:
Stefan Lazic committed 2026-08-05 09:25:59 +01:00
1 parent 9e39908ea7
commit d3bbad09c6
74 files changed
+1622 -195

No files matched your search

@@ -1,16 +1,20 @@
import { describe, expect, it } from "vitest";
import { validateProgrammeConfig } from "../loyalty.service";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { resolveRewardThreshold, validateProgrammeConfig } from "../loyalty.service";
import type { ProgrammeConfigInput } from "../loyalty.service";
const base: ProgrammeConfigInput = {
isEnabled: true,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "50",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
};
describe("validateProgrammeConfig", () => {
@@ -63,3 +67,21 @@ describe("validateProgrammeConfig", () => {
expect(result.issues[0]?.code).toBe("FREE_SERVICE_REQUIRED");
});
});
describe("resolveRewardThreshold", () => {
it("AFTER_ALL_STAMPS requires every position filled", () => {
expect(resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS })).toBe(10);
});
it("FINAL_SLOT_IS_REWARD is earned one stamp early — the last position is the reward, not a stamp to collect", () => {
expect(
resolveRewardThreshold({ stampsRequired: 10, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
).toBe(9);
});
it("clamps FINAL_SLOT_IS_REWARD at a minimum threshold of 1, even for a single-stamp card", () => {
expect(
resolveRewardThreshold({ stampsRequired: 1, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD }),
).toBe(1);
});
});
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import {
expireOverdueRewards,
redeemReward,
@@ -32,12 +33,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
input: {
isEnabled: true,
stampsRequired: 8,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "25",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
},
});
expect(first.success).toBe(true);
@@ -50,19 +54,22 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
businessId: business.businessId,
input: {
isEnabled: true,
stampsRequired: 12,
stampsRequired: 10, // max allowed — see validateProgrammeConfig's 1-10 range (Phase 10)
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "PERCENTAGE",
rewardValue: "40",
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
},
});
expect(second.success).toBe(true);
const updated = await prisma.loyaltyProgramme.findUniqueOrThrow({ where: { businessId: business.businessId } });
expect(updated.stampsRequired).toBe(12);
expect(updated.stampsRequired).toBe(10);
expect(Number(updated.rewardValue)).toBe(40);
});
@@ -77,12 +84,15 @@ describe("LoyaltyService — owner configuration and redemption (integration)",
input: {
isEnabled: true,
stampsRequired: 10,
rewardPosition: RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: "FREE_SERVICE",
freeServiceId: inactiveService.id,
autoApplyReward: true,
requiresStaffApproval: false,
earnOnCancelled: false,
earnOnNoShow: false,
cardFont: LoyaltyCardFont.SANS,
cardTopRightTextSize: LoyaltyCardTextSize.MD,
},
});
expect(result.success).toBe(false);
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RewardType } from "@/generated/prisma/enums";
import { RewardPositionMode, RewardType } from "@/generated/prisma/enums";
import {
addStamp,
awardStampForCompletedAppointment,
correctStamp,
getLoyaltyHistory,
getLoyaltySummary,
redeemReward,
} from "../loyalty.service";
import {
cleanupTestBusiness,
@@ -24,12 +25,13 @@ import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures";
describe("LoyaltyService (integration)", () => {
let business: TestBusiness;
async function enableLoyalty(overrides: { stampsRequired?: number } = {}) {
async function enableLoyalty(overrides: { stampsRequired?: number; rewardPosition?: RewardPositionMode } = {}) {
return prisma.loyaltyProgramme.create({
data: {
businessId: business.businessId,
isEnabled: true,
stampsRequired: overrides.stampsRequired ?? 5,
rewardPosition: overrides.rewardPosition ?? RewardPositionMode.AFTER_ALL_STAMPS,
rewardType: RewardType.PERCENTAGE,
rewardValue: 50,
rewardLabel: "50% Off",
@@ -144,6 +146,67 @@ describe("LoyaltyService (integration)", () => {
expect(account.currentStamps).toBe(5);
});
describe("reward position modes", () => {
it("FINAL_SLOT_IS_REWARD earns the reward one stamp early via a manual add — the same threshold AFTER_ALL_STAMPS would need one more stamp to cross", async () => {
const programme = await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const shortOfAfterAllStamps = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 2, staffId });
expect(shortOfAfterAllStamps.rewardCreated).toBe(true); // 2 stamps == threshold (stampsRequired - 1)
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
expect(account.currentStamps).toBe(2);
});
it("FINAL_SLOT_IS_REWARD uses the same threshold for the automatic completed-appointment path as for a manual add", async () => {
await enableLoyalty({ stampsRequired: 3, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
// One manual stamp, then one automatic one from a completed appointment — together they
// cross the threshold (2), and both paths must agree that's enough to earn the reward.
await addStamp(prisma, { customerId, businessId: business.businessId, amount: 1, staffId });
const appointment = await completedAppointment(staffId, customerId);
const result = await awardStampForCompletedAppointment(prisma, { appointmentId: appointment.id, staffId });
expect(result.rewardCreated).toBe(true);
});
it("redemption deducts exactly the earning threshold, not the raw stampsRequired total, in FINAL_SLOT_IS_REWARD mode", async () => {
const programme = await enableLoyalty({ stampsRequired: 4, rewardPosition: RewardPositionMode.FINAL_SLOT_IS_REWARD });
const { staffId } = await createTestStaff(business);
const { customerId } = await createTestCustomer(business);
const appointment = await completedAppointment(staffId, customerId);
// 3 stamps == threshold (stampsRequired - 1) — earns the reward without ever reaching 4.
const added = await addStamp(prisma, { customerId, businessId: business.businessId, amount: 3, staffId });
expect(added.rewardCreated).toBe(true);
const account = await prisma.loyaltyAccount.findUniqueOrThrow({
where: { customerId_loyaltyProgrammeId: { customerId, loyaltyProgrammeId: programme.id } },
});
const reward = await prisma.loyaltyReward.findFirstOrThrow({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
const redemption = await redeemReward(prisma, {
rewardId: reward.id,
appointmentId: appointment.id,
staffId,
businessId: business.businessId,
});
expect(redemption.success).toBe(true);
const afterRedemption = await prisma.loyaltyAccount.findUniqueOrThrow({ where: { id: account.id } });
// Deducting the raw stampsRequired (4) from a balance of 3 would have gone negative —
// clampedDeduction alone would mask the bug. Deducting the threshold (3) leaves exactly 0.
expect(afterRedemption.currentStamps).toBe(0);
});
});
it("rejects a manual stamp when loyalty is disabled for the business", async () => {
// No enableLoyalty() call — business has no loyalty programme at all.
const { staffId } = await createTestStaff(business);
+49 -7
View File
@@ -1,4 +1,5 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { LoyaltyCardFont, LoyaltyCardTextSize, RewardPositionMode } from "@/generated/prisma/enums";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import {
@@ -7,6 +8,9 @@ import {
notifyLoyaltyRewardRedeemed,
notifyLoyaltyStampAdded,
} from "./loyalty-notifications";
import { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
export { MAX_STAMPS_REQUIRED, resolveRewardThreshold } from "./reward-threshold";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
@@ -45,11 +49,19 @@ export interface LoyaltySummary {
accountId: string | null;
currentStamps: number;
stampsRequired: number;
rewardPosition: RewardPositionMode;
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
cardBackgroundImageUrl: string | null;
termsLine: string | null;
cardTopRightText: string | null;
cardFont: LoyaltyCardFont;
cardTopRightTextSize: LoyaltyCardTextSize;
cardTopRightTextColor: string | null;
cardBorderColor: string | null;
cardMemberNumberColor: 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). */
@@ -79,6 +91,7 @@ export async function getLoyaltySummary(
accountId: account?.id ?? null,
currentStamps: account?.currentStamps ?? 0,
stampsRequired: programme.stampsRequired,
rewardPosition: programme.rewardPosition,
availableReward: account?.rewards[0]
? {
id: account.rewards[0].id,
@@ -91,6 +104,13 @@ export async function getLoyaltySummary(
stampIcon: programme.stampIcon,
cardBackgroundColor: programme.cardBackgroundColor,
cardBackgroundImageUrl: programme.cardBackgroundImageUrl,
termsLine: programme.termsLine,
cardTopRightText: programme.cardTopRightText,
cardFont: programme.cardFont,
cardTopRightTextSize: programme.cardTopRightTextSize,
cardTopRightTextColor: programme.cardTopRightTextColor,
cardBorderColor: programme.cardBorderColor,
cardMemberNumberColor: programme.cardMemberNumberColor,
};
}
@@ -124,7 +144,8 @@ export async function getAvailableRewardFlags(
export interface CustomerLoyaltyBrief {
currentStamps: number;
stampsRequired: number;
/** How many stamps actually earn the reward — see resolveRewardThreshold. */
rewardThreshold: number;
hasAvailableReward: boolean;
}
@@ -149,12 +170,13 @@ export async function getLoyaltySummariesForCustomers(
},
});
const rewardThreshold = resolveRewardThreshold(programme);
return new Map(
accounts.map((a) => [
a.customerId,
{
currentStamps: a.currentStamps,
stampsRequired: programme.stampsRequired,
rewardThreshold,
hasAvailableReward: a.rewards.length > 0,
},
]),
@@ -404,7 +426,7 @@ async function addStampTransaction(
let rewardCreated = false;
let rewardId: string | undefined;
if (account.currentStamps >= params.programme.stampsRequired) {
if (account.currentStamps >= resolveRewardThreshold(params.programme)) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
});
@@ -506,6 +528,8 @@ export async function getLoyaltyHistory(
export interface ProgrammeConfigInput {
isEnabled: boolean;
stampsRequired: number;
/** See resolveRewardThreshold's doc comment for what each mode means. */
rewardPosition: RewardPositionMode;
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;
@@ -522,12 +546,19 @@ export interface ProgrammeConfigInput {
cardBackgroundImageUrl?: string;
stampIcon?: string;
termsLine?: string;
/** Free text shown in the card's top-right area — see schema.prisma's cardTopRightText doc comment. */
cardTopRightText?: string;
cardFont: LoyaltyCardFont;
cardTopRightTextSize: LoyaltyCardTextSize;
cardTopRightTextColor?: string;
cardBorderColor?: string;
cardMemberNumberColor?: 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 (!Number.isInteger(input.stampsRequired) || input.stampsRequired < 1 || input.stampsRequired > MAX_STAMPS_REQUIRED) {
return fail({ code: "INVALID_INPUT", message: `Stamps required must be between 1 and ${MAX_STAMPS_REQUIRED}.` });
}
if (!input.isEnabled) return ok();
@@ -580,6 +611,7 @@ export async function updateProgrammeConfig(
const data = {
isEnabled: params.input.isEnabled,
stampsRequired: params.input.stampsRequired,
rewardPosition: params.input.rewardPosition,
rewardType: params.input.rewardType,
rewardValue: params.input.rewardType === "FREE_SERVICE" ? null : (params.input.rewardValue ?? null),
rewardLabel: params.input.rewardLabel?.trim() || null,
@@ -595,6 +627,12 @@ export async function updateProgrammeConfig(
cardBackgroundImageUrl: params.input.cardBackgroundImageUrl || null,
stampIcon: params.input.stampIcon || null,
termsLine: params.input.termsLine?.trim() || null,
cardTopRightText: params.input.cardTopRightText?.trim() || null,
cardFont: params.input.cardFont,
cardTopRightTextSize: params.input.cardTopRightTextSize,
cardTopRightTextColor: params.input.cardTopRightTextColor || null,
cardBorderColor: params.input.cardBorderColor || null,
cardMemberNumberColor: params.input.cardMemberNumberColor || null,
};
await prisma.loyaltyProgramme.upsert({
@@ -745,7 +783,11 @@ export async function redeemReward(
discountAmount = Math.min(Math.max(discountAmount, 0), subtotal);
const discountAmountString = discountAmount.toFixed(2);
const stampsRequired = reward.loyaltyAccount.loyaltyProgramme.stampsRequired;
// Deduct however many stamps actually earned this reward, not the raw
// stampsRequired total — in FINAL_SLOT_IS_REWARD mode those aren't the
// same number (see resolveRewardThreshold), and the ledger must only
// ever remove what was really collected.
const rewardThreshold = resolveRewardThreshold(reward.loyaltyAccount.loyaltyProgramme);
const { alreadyRedeemed } = await prisma.$transaction(async (tx) => {
const updated = await tx.loyaltyReward.updateMany({
@@ -760,7 +802,7 @@ export async function redeemReward(
if (updated.count === 0) return { alreadyRedeemed: true };
const account = await tx.loyaltyAccount.findUniqueOrThrow({ where: { id: reward.loyaltyAccountId } });
const clampedDeduction = Math.min(stampsRequired, account.currentStamps);
const clampedDeduction = Math.min(rewardThreshold, account.currentStamps);
await tx.loyaltyAccount.update({
where: { id: account.id },
data: { currentStamps: { decrement: clampedDeduction } },
+38
View File
@@ -0,0 +1,38 @@
import type { LoyaltyProgramme } from "@/generated/prisma/client";
import { RewardPositionMode } from "@/generated/prisma/enums";
/**
* Stamps required for a reward is capped at 10 (Phase 10 follow-up brief) —
* mirrored by the LoyaltyProgramme_stampsRequired_range CHECK constraint,
* validateProgrammeConfig, and the owner form's own client-side validation.
* Lives here (not loyalty.service.ts) for the same reason
* resolveRewardThreshold does — see that function's doc comment.
*/
export const MAX_STAMPS_REQUIRED = 10;
/**
* The single place "how many stamps actually earn the reward" is decided —
* every caller that needs to know (auto/manual stamp awarding, redemption,
* progress text, the customer card, the owner's live preview) reads through
* this rather than each re-deriving its own answer, so they can't disagree.
* Deliberately its own tiny, side-effect-free module (not defined inline in
* loyalty.service.ts) so client components — the customer card, the owner's
* live preview — can import this one pure function without pulling in the
* rest of that file's Prisma-calling service code.
*
* AFTER_ALL_STAMPS: every one of `stampsRequired` positions is a normal
* stamp; the reward unlocks once all of them are filled — threshold ==
* stampsRequired.
*
* FINAL_SLOT_IS_REWARD: the last of the `stampsRequired` positions on the
* card *is* the reward, not a stamp to collect — so it's earned one stamp
* early, at stampsRequired - 1. (Clamped at 1 so a stampsRequired of 1 can
* never produce a threshold of 0.)
*/
export function resolveRewardThreshold(
programme: Pick<LoyaltyProgramme, "stampsRequired" | "rewardPosition">,
): number {
return programme.rewardPosition === RewardPositionMode.FINAL_SLOT_IS_REWARD
? Math.max(1, programme.stampsRequired - 1)
: programme.stampsRequired;
}
@@ -218,6 +218,7 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
const validation = validateProgrammeConfig({
isEnabled: loyaltyProgramme.isEnabled,
stampsRequired: loyaltyProgramme.stampsRequired,
rewardPosition: loyaltyProgramme.rewardPosition,
rewardType: loyaltyProgramme.rewardType,
rewardValue: loyaltyProgramme.rewardValue?.toString(),
freeServiceId: loyaltyProgramme.freeServiceId ?? undefined,
@@ -225,6 +226,8 @@ export async function getConfigurationWarnings(prisma: PrismaClient, businessId:
requiresStaffApproval: loyaltyProgramme.requiresStaffApproval,
earnOnCancelled: loyaltyProgramme.earnOnCancelled,
earnOnNoShow: loyaltyProgramme.earnOnNoShow,
cardFont: loyaltyProgramme.cardFont,
cardTopRightTextSize: loyaltyProgramme.cardTopRightTextSize,
});
if (!validation.valid) {
warnings.push({