This commit is contained in:
Stefan Lazic committed 2026-08-03 21:15:24 +01:00
1 parent ec5583f972
commit 546613e677
189 files changed
+9873 -655

No files matched your search

+103 -7
View File
@@ -1,6 +1,12 @@
import type { LoyaltyProgramme, PrismaClient } from "@/generated/prisma/client";
import { fail, ok } from "@/lib/validation-result";
import type { ValidationResult } from "@/lib/validation-result";
import {
notifyLoyaltyRewardEarned,
notifyLoyaltyRewardExpiring,
notifyLoyaltyRewardRedeemed,
notifyLoyaltyStampAdded,
} from "./loyalty-notifications";
/**
* Ledger-based loyalty, per the brief: the cached `LoyaltyAccount.currentStamps`
@@ -39,7 +45,7 @@ export interface LoyaltySummary {
accountId: string | null;
currentStamps: number;
stampsRequired: number;
availableReward: { id: string; label: string; createdAt: Date } | null;
availableReward: { id: string; label: string; createdAt: Date; expiresAt: Date | null } | null;
rewardLabel: string | null;
stampIcon: string | null;
cardBackgroundColor: string | null;
@@ -77,6 +83,7 @@ export async function getLoyaltySummary(
id: account.rewards[0].id,
label: account.rewards[0].label,
createdAt: account.rewards[0].createdAt,
expiresAt: account.rewards[0].expiresAt,
}
: null,
rewardLabel: programme.rewardLabel,
@@ -275,7 +282,7 @@ export async function awardStampForCompletedAppointment(
});
if (alreadyAwarded) return { success: true, alreadyAwarded: true, validation: ok() };
const { rewardCreated } = await addStampTransaction(prisma, {
const { rewardCreated, rewardId, transactionId } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: 1, // one stamp per completed visit — see file header; not a per-service count
appointmentId: appointment.id,
@@ -284,6 +291,21 @@ export async function awardStampForCompletedAppointment(
programme,
});
await notifyLoyaltyStampAdded(prisma, {
businessId: appointment.businessId,
customerId: appointment.customerId,
transactionId,
appointmentId: appointment.id,
});
if (rewardCreated && rewardId) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: appointment.businessId,
customerId: appointment.customerId,
rewardId,
rewardLabel: programme.rewardLabel ?? "Reward",
});
}
return { success: true, rewardCreated, validation: ok() };
}
@@ -324,7 +346,7 @@ export async function addStamp(
loyaltyProgrammeId: programme.id,
});
const { rewardCreated } = await addStampTransaction(prisma, {
const { rewardCreated, rewardId, transactionId } = await addStampTransaction(prisma, {
loyaltyAccountId: account.id,
amount: params.amount,
appointmentId: params.appointmentId,
@@ -333,6 +355,21 @@ export async function addStamp(
programme,
});
await notifyLoyaltyStampAdded(prisma, {
businessId: params.businessId,
customerId: params.customerId,
transactionId,
appointmentId: params.appointmentId,
});
if (rewardCreated && rewardId) {
await notifyLoyaltyRewardEarned(prisma, {
businessId: params.businessId,
customerId: params.customerId,
rewardId,
rewardLabel: programme.rewardLabel ?? "Reward",
});
}
return { success: true, rewardCreated, validation: ok() };
}
@@ -346,13 +383,13 @@ async function addStampTransaction(
reason: string | null;
programme: LoyaltyProgramme;
},
): Promise<{ rewardCreated: boolean }> {
): Promise<{ rewardCreated: boolean; rewardId?: string; transactionId: string }> {
return prisma.$transaction(async (tx) => {
const account = await tx.loyaltyAccount.update({
where: { id: params.loyaltyAccountId },
data: { currentStamps: { increment: params.amount } },
});
await tx.loyaltyTransaction.create({
const transaction = await tx.loyaltyTransaction.create({
data: {
loyaltyAccountId: params.loyaltyAccountId,
type: "STAMP_ADDED",
@@ -364,6 +401,7 @@ async function addStampTransaction(
});
let rewardCreated = false;
let rewardId: string | undefined;
if (account.currentStamps >= params.programme.stampsRequired) {
const existingAvailable = await tx.loyaltyReward.findFirst({
where: { loyaltyAccountId: account.id, status: "AVAILABLE" },
@@ -371,7 +409,7 @@ async function addStampTransaction(
// 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({
const reward = await tx.loyaltyReward.create({
data: {
loyaltyAccountId: account.id,
rewardType: params.programme.rewardType,
@@ -384,10 +422,11 @@ async function addStampTransaction(
},
});
rewardCreated = true;
rewardId = reward.id;
}
}
return { rewardCreated };
return { rewardCreated, rewardId, transactionId: transaction.id };
});
}
@@ -645,6 +684,16 @@ export async function redeemReward(
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has already been redeemed or has expired." }),
};
}
// Belt-and-braces against the periodic expiry sweep (see
// expireOverdueRewards, run from the notification cron) not having run
// yet — a reward past its own expiresAt is never redeemable even if its
// stored status still says AVAILABLE.
if (reward.expiresAt && reward.expiresAt < new Date()) {
return {
success: false,
validation: fail({ code: "REWARD_NOT_AVAILABLE", message: "This reward has expired." }),
};
}
const appointment = await prisma.appointment.findFirst({
where: { id: params.appointmentId, businessId: params.businessId },
@@ -738,5 +787,52 @@ export async function redeemReward(
};
}
await notifyLoyaltyRewardRedeemed(prisma, {
businessId: params.businessId,
customerId: reward.loyaltyAccount.customerId,
rewardId: reward.id,
rewardLabel: reward.label,
appointmentId: params.appointmentId,
});
return { success: true, validation: ok(), discountAmount: discountAmountString };
}
const REWARD_EXPIRING_SOON_WINDOW_DAYS = 7;
/**
* Run from the notification cron (see /api/cron/process-notifications)
* alongside processDueNotificationDeliveries — one scheduled trigger covers
* both. Two independent jobs in one pass: flip any AVAILABLE reward whose
* expiresAt has passed to EXPIRED (redeemReward also defends against this
* directly, but the sweep is what makes "Expired" actually show up in
* history/UI instead of staying stuck at AVAILABLE forever), then notify
* once per day for rewards still AVAILABLE and expiring within the next
* week.
*/
export async function expireOverdueRewards(prisma: PrismaClient, now: Date = new Date()): Promise<void> {
await prisma.loyaltyReward.updateMany({
where: { status: "AVAILABLE", expiresAt: { lt: now } },
data: { status: "EXPIRED" },
});
const expiringSoon = await prisma.loyaltyReward.findMany({
where: {
status: "AVAILABLE",
expiresAt: { gte: now, lte: new Date(now.getTime() + REWARD_EXPIRING_SOON_WINDOW_DAYS * 86_400_000) },
},
include: { loyaltyAccount: { include: { loyaltyProgramme: true } } },
});
for (const reward of expiringSoon) {
if (!reward.expiresAt) continue;
await notifyLoyaltyRewardExpiring(prisma, {
businessId: reward.loyaltyAccount.loyaltyProgramme.businessId,
customerId: reward.loyaltyAccount.customerId,
rewardId: reward.id,
rewardLabel: reward.label,
expiresAt: reward.expiresAt,
now,
});
}
}