Files
Epicnabbo-Catalogus-Updated…/src/actions/referral.ts
T
SimoandCursor 968ca15c27 feat: jwt cache, redis health, help-ticket admin, and write rate limits
Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere.

Co-authored-by: Cursor <[email protected]>
2026-07-21 21:58:48 +02:00

179 lines
5.4 KiB
TypeScript

"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { clientIp, rateLimit } from "@/lib/rate-limit";
import { rcon } from "@/lib/services/rcon";
import { type CurrencyName, sendCurrency } from "@/lib/services/send-currency";
/**
* Claim the referral reward for the SIGNED-IN user. Faithful to AtomCMS's
* ReferralController::__invoke:
* - the user id is re-read from the session (auth()), NEVER from FormData,
* so a crafted form cannot claim on another account;
* - the user must have referred at least `referrals_needed` people
* (user_referrals.referrals_total >= needed), otherwise it's rejected;
* - on success `referrals_total` is decremented by the threshold, the
* configured reward is granted, and the claim is logged.
*
* The reward amount/currency are CMS-configurable via website_settings
* (referral_reward_amount + referral_reward_currency_type). If the currency
* setting is not one of the four known wallets we keep the claim conservative
* and fail rather than guessing — no currency is moved.
*
* Errors redirect back to /me with a machine-readable ?error= code; success
* 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",
]);
export async function claimReferral(_formData: FormData): Promise<void> {
let outcome:
| "claimed"
| "not_enough"
| "no_referrals"
| "bad_config"
| "ratelimit"
| "error" = "error";
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");
}
await clientIp();
if (!(await rateLimit(`referral-claim:${userId}`, 5, 60_000)).ok) {
outcome = "ratelimit";
} else {
// 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;
// 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;
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");
}
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";
}
revalidatePath("/me");
if (outcome === "claimed") {
redirect("/me?claimed=1");
}
redirect(`/me?error=${outcome}`);
}