Phase 7
This commit is contained in:
189 files changed
+9873
-655
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user