"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([ "credits", "duckets", "diamonds", "points", ]); export async function claimReferral(_formData: FormData): Promise { 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}`); }