"use server"; import { desc, eq, inArray, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { auth } from "@/lib/auth"; import { ClaimedReferralLogs, db, UserReferrals, WebsiteSetting, } from "@/lib/db"; import { clientIp, rateLimit } from "@/lib/rate-limit"; import { rcon } from "@/lib/services/rcon"; import { type CurrencyName, currencyDb, 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 settingKeys = [ "referrals_needed", "referral_reward_amount", "referral_reward_currency_type", "referral_reward_currency", ] as const; let settings: Array<{ key: string; value: string }> = []; try { settings = await db .select({ key: WebsiteSetting.key, value: WebsiteSetting.value }) .from(WebsiteSetting) .where(inArray(WebsiteSetting.key, [...settingKeys])); } catch { settings = []; } const byKey = new Map(settings.map((s) => [s.key, s.value])); const neededRaw = byKey.get("referrals_needed"); const amountRaw = byKey.get("referral_reward_amount"); // The seeded key is referral_reward_currency_type; fall back to the // shorter referral_reward_currency name if that is what is configured. const currencyRaw = byKey.get("referral_reward_currency_type") ?? byKey.get("referral_reward_currency"); const needed = Number.parseInt(neededRaw ?? "5", 10) || 5; const amount = Number.parseInt(amountRaw ?? "30", 10); const currency = (currencyRaw ?? "diamonds") .trim() .toLowerCase() as CurrencyName; // The user's referral tally lives in user_referrals (one row per user). let referrals: { id: bigint; referralsTotal: bigint } | undefined; try { const rows = await db .select({ id: UserReferrals.id, referralsTotal: UserReferrals.referralsTotal, }) .from(UserReferrals) .where(eq(UserReferrals.userId, userId)) .orderBy(desc(UserReferrals.id)) .limit(1); referrals = rows[0]; } catch { referrals = undefined; } 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 db .update(UserReferrals) .set({ referralsTotal: sql`${UserReferrals.referralsTotal} - ${needed}`, }) .where(eq(UserReferrals.id, referrals.id)); try { await sendCurrency( { rcon, db: currencyDb }, 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. try { await db .update(UserReferrals) .set({ referralsTotal: sql`${UserReferrals.referralsTotal} + ${needed}`, }) .where(eq(UserReferrals.id, referrals.id)); } catch { /* best-effort rollback */ } outcome = "error"; throw new Error("currency-delivery-failed"); } try { await db.insert(ClaimedReferralLogs).values({ 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}`); }