This commit is contained in:
1 parent
8efd032cc6
commit
df38dccbf1
735 files changed
+128321
-120870
No files matched your search
+125
-103
@@ -27,121 +27,143 @@ import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
|
||||
* redirects with ?claimed=1. redirect() is called OUTSIDE the try/catch so its
|
||||
* internal control-flow throw is never swallowed.
|
||||
*/
|
||||
const VALID_CURRENCIES = new Set<CurrencyName>(["credits", "duckets", "diamonds", "points"]);
|
||||
const VALID_CURRENCIES = new Set<CurrencyName>([
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
]);
|
||||
|
||||
export async function claimReferral(_formData: FormData): Promise<void> {
|
||||
let outcome: "claimed" | "not_enough" | "no_referrals" | "bad_config" | "error" = "error";
|
||||
let outcome:
|
||||
| "claimed"
|
||||
| "not_enough"
|
||||
| "no_referrals"
|
||||
| "bad_config"
|
||||
| "error" = "error";
|
||||
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
const userId = Number(session.user.id);
|
||||
if (!Number.isFinite(userId) || userId <= 0) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referrals_needed" }, select: { value: true } })
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({ where: { key: "referral_reward_amount" }, select: { value: true } })
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: { key: { in: ["referral_reward_currency_type", "referral_reward_currency"] } },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
// Reward configuration (CMS-owned website_settings). AtomCMS defaults:
|
||||
// 5 referrals needed, 30 diamonds reward.
|
||||
const [neededRaw, amountRaw, currencyRaw] = await Promise.all([
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referrals_needed" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
prisma.websiteSetting
|
||||
.findUnique({
|
||||
where: { key: "referral_reward_amount" },
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
// The seeded key is referral_reward_currency_type; fall back to the
|
||||
// shorter referral_reward_currency name if that is what is configured.
|
||||
prisma.websiteSetting
|
||||
.findFirst({
|
||||
where: {
|
||||
key: {
|
||||
in: ["referral_reward_currency_type", "referral_reward_currency"],
|
||||
},
|
||||
},
|
||||
select: { value: true },
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds").trim().toLowerCase() as CurrencyName;
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const amount = Number.parseInt(amountRaw?.value ?? "30", 10);
|
||||
const currency = (currencyRaw?.value ?? "diamonds")
|
||||
.trim()
|
||||
.toLowerCase() as CurrencyName;
|
||||
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
// The user's referral tally lives in user_referrals (one row per user).
|
||||
const referrals = await prisma.userReferrals
|
||||
.findFirst({
|
||||
where: { userId },
|
||||
select: { id: true, referralsTotal: true },
|
||||
orderBy: { id: "desc" },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
const total = referrals ? Number(referrals.referralsTotal) : 0;
|
||||
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
if (!referrals || total <= 0) {
|
||||
outcome = "no_referrals";
|
||||
} else if (total < needed) {
|
||||
outcome = "not_enough";
|
||||
} else if (!VALID_CURRENCIES.has(currency) || !(amount > 0)) {
|
||||
// Misconfigured reward — keep it conservative and grant nothing.
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
// Spend the threshold first so a concurrent double-submit can't claim
|
||||
// twice off the same balance, then deliver the reward and log it.
|
||||
await prisma.userReferrals.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { decrement: needed } },
|
||||
});
|
||||
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
try {
|
||||
await sendCurrency({ rcon, db: prisma }, userId, currency, amount);
|
||||
} catch {
|
||||
// sendCurrency already falls back to a direct DB write; if it still
|
||||
// throws the spend stands. Roll the threshold back so the user isn't
|
||||
// charged for an undelivered reward.
|
||||
await prisma.userReferrals
|
||||
.update({
|
||||
where: { id: referrals.id },
|
||||
data: { referralsTotal: { increment: needed } },
|
||||
})
|
||||
.catch(() => {});
|
||||
outcome = "error";
|
||||
throw new Error("currency-delivery-failed");
|
||||
}
|
||||
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
});
|
||||
await prisma.claimedReferralLogs
|
||||
.create({
|
||||
data: {
|
||||
userId,
|
||||
ipAddress: await clientIp(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort audit log; the reward already landed.
|
||||
});
|
||||
|
||||
outcome = "claimed";
|
||||
}
|
||||
} catch (err) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||
// navigation actually happens instead of being treated as a failure.
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"digest" in err &&
|
||||
typeof (err as { digest?: unknown }).digest === "string" &&
|
||||
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if (outcome === "claimed") outcome = "error";
|
||||
}
|
||||
outcome = "claimed";
|
||||
}
|
||||
} catch (err) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it so the
|
||||
// navigation actually happens instead of being treated as a failure.
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"digest" in err &&
|
||||
typeof (err as { digest?: unknown }).digest === "string" &&
|
||||
(err as { digest: string }).digest.startsWith("NEXT_REDIRECT")
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
if (outcome === "claimed") outcome = "error";
|
||||
}
|
||||
|
||||
revalidatePath("/me");
|
||||
revalidatePath("/me");
|
||||
|
||||
if (outcome === "claimed") {
|
||||
redirect("/me?claimed=1");
|
||||
}
|
||||
redirect(`/me?error=${outcome}`);
|
||||
if (outcome === "claimed") {
|
||||
redirect("/me?claimed=1");
|
||||
}
|
||||
redirect(`/me?error=${outcome}`);
|
||||
}
|
||||
Reference in new issue
Block a user