feat(referrals): add referral attribution and daily login rewards
- Track referral attribution at registration via ?ref code with same-IP and duplicate-pair guards - Add daily login rewards with streak tracking, claim flow and sendCurrency payout backed by RCON with DB fallback - Add admin pages for referral settings and the daily reward schedule - Add migration 0033 with tables, seed schedule, settings and ACL grants - Add admin.referrals.* and admin.dailyrewards.* permission slugs - Localize new copy in en, nl and it
This commit is contained in:
1 parent
463bc2cb47
commit
c3ff497050
30 files changed
+2028
-11
No files matched your search
@@ -0,0 +1,133 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { db, WebsiteDailyRewards } from "@/lib/db";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const VALID_CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
|
||||
|
||||
async function normalizeReward(input: {
|
||||
day: string;
|
||||
currency: string;
|
||||
amount: string;
|
||||
}): Promise<
|
||||
| { ok: true; day: number; currency: string; amount: number }
|
||||
| { ok: false; message: string }
|
||||
> {
|
||||
const day = Number.parseInt(input.day, 10);
|
||||
const amount = Number.parseInt(input.amount, 10);
|
||||
const currency = String(input.currency ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!(day >= 1 && day <= 365))
|
||||
return { ok: false, message: "Reward day must be between 1 and 365" };
|
||||
if (!(amount > 0))
|
||||
return { ok: false, message: "Reward amount must be positive" };
|
||||
if (!(VALID_CURRENCIES as readonly string[]).includes(currency))
|
||||
return {
|
||||
ok: false,
|
||||
message: "Reward currency is not one of the emulator wallets",
|
||||
};
|
||||
return { ok: true, day, currency, amount };
|
||||
}
|
||||
|
||||
export async function upsertDailyReward(input: {
|
||||
id?: string;
|
||||
day: string;
|
||||
currency: string;
|
||||
amount: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
|
||||
|
||||
const normalized = await normalizeReward(input);
|
||||
if (!normalized.ok) return actionError(normalized.message);
|
||||
|
||||
const id = input.id ? positiveBigInt(String(input.id).trim()) : null;
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
if (id) {
|
||||
await db
|
||||
.update(WebsiteDailyRewards)
|
||||
.set({
|
||||
day: normalized.day,
|
||||
currency: normalized.currency,
|
||||
amount: normalized.amount,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(WebsiteDailyRewards.id, Number(id)));
|
||||
} else {
|
||||
await db
|
||||
.insert(WebsiteDailyRewards)
|
||||
.values({
|
||||
day: normalized.day,
|
||||
currency: normalized.currency,
|
||||
amount: normalized.amount,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
currency: normalized.currency,
|
||||
amount: normalized.amount,
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
}
|
||||
revalidatePath("/admin/daily-rewards");
|
||||
revalidatePath("/me");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.daily_reward_upsert_failed", error);
|
||||
return actionError("Reward day could not be saved");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDailyReward(input: {
|
||||
id: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
|
||||
|
||||
const id = positiveBigInt(String(input.id ?? "").trim());
|
||||
if (!id) return actionError("Missing reward id");
|
||||
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteDailyRewards)
|
||||
.where(eq(WebsiteDailyRewards.id, Number(id)));
|
||||
revalidatePath("/admin/daily-rewards");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.daily_reward_delete_failed", error, {
|
||||
rewardId: String(id),
|
||||
});
|
||||
return actionError("Reward day could not be deleted");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setDailyRewardEnabled(input: {
|
||||
enabled: boolean;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.DAILY_REWARDS_EDIT);
|
||||
|
||||
try {
|
||||
await siteSettings.update(
|
||||
"daily_reward_enabled",
|
||||
input.enabled ? "1" : "0",
|
||||
);
|
||||
revalidatePath("/admin/daily-rewards");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.daily_reward_toggle_failed", error);
|
||||
return actionError("Reward setting could not be saved");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
/**
|
||||
* Referral system configuration for the /admin/referrals settings form. All
|
||||
* values are validated before the keys leave the CMS settings table, and the
|
||||
* reward currency must be one of the four emulator wallets (bidirectional:
|
||||
* the claim action already validates on read).
|
||||
*/
|
||||
const VALID_CURRENCIES = ["credits", "duckets", "diamonds", "points"] as const;
|
||||
|
||||
export async function updateReferralSettings(input: {
|
||||
referralsNeeded: string;
|
||||
rewardAmount: string;
|
||||
rewardCurrency: string;
|
||||
blockSameIp: string;
|
||||
}): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.REFERRALS_EDIT);
|
||||
|
||||
const needed = Number.parseInt(input.referralsNeeded, 10);
|
||||
const amount = Number.parseInt(input.rewardAmount, 10);
|
||||
const currency = String(input.rewardCurrency ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (!(needed >= 1)) return actionError("Referrals needed must be at least 1");
|
||||
if (!(amount > 0)) return actionError("Reward amount must be positive");
|
||||
if (!(VALID_CURRENCIES as readonly string[]).includes(currency))
|
||||
return actionError("Reward currency is not one of the emulator wallets");
|
||||
const blockSameIp = input.blockSameIp === "1";
|
||||
|
||||
try {
|
||||
await siteSettings.update("referrals_needed", String(needed));
|
||||
await siteSettings.update("referral_reward_amount", String(amount));
|
||||
await siteSettings.update("referral_reward_currency_type", currency);
|
||||
await siteSettings.update(
|
||||
"referrals_block_same_ip",
|
||||
blockSameIp ? "1" : "0",
|
||||
);
|
||||
revalidatePath("/admin/referrals");
|
||||
return actionOk();
|
||||
} catch (error) {
|
||||
logServerError("admin.referral_settings_update_failed", error);
|
||||
return actionError("Referral settings could not be saved");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use server";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, WebsiteDailyRewardClaims } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
isRewardCurrency,
|
||||
loadDailyRewardState,
|
||||
} from "@/lib/services/daily-rewards";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
import {
|
||||
type CurrencyName,
|
||||
currencyDb,
|
||||
sendCurrency,
|
||||
} from "@/lib/services/send-currency";
|
||||
|
||||
/**
|
||||
* Claim the daily login reward for the SIGNED-IN user. The user id is always
|
||||
* re-read from the session — never from the form — so a crafted request cannot
|
||||
* claim on another account.
|
||||
*
|
||||
* Streak rules (see src/lib/services/daily-rewards.ts):
|
||||
* - a claim on the day after the previous claim extends the streak;
|
||||
* - a claim on the same day is a no-op (already claimed);
|
||||
* - a missed day resets the streak to 1.
|
||||
* The reward schedule lives in website_daily_rewards (admin editable) and
|
||||
* repeats after the highest configured day. The unique (user_id, claim_date)
|
||||
* key makes the claim idempotent even under concurrent submits.
|
||||
*
|
||||
* Redirects back to /me with a machine-readable ?daily=<code> outcome; the
|
||||
* redirect() control-flow throw is re-thrown so navigation actually happens.
|
||||
*/
|
||||
const OUTCOME = [
|
||||
"claimed",
|
||||
"already_claimed",
|
||||
"disabled",
|
||||
"bad_config",
|
||||
"ratelimit",
|
||||
"error",
|
||||
] as const;
|
||||
type Outcome = (typeof OUTCOME)[number];
|
||||
|
||||
export async function claimDailyReward(_formData: FormData): Promise<void> {
|
||||
let outcome: Outcome = "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");
|
||||
|
||||
if (!(await rateLimit(`daily-reward-claim:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const state = await loadDailyRewardState(userId);
|
||||
|
||||
if (!state.enabled) {
|
||||
outcome = "disabled";
|
||||
} else if (state.alreadyClaimedToday) {
|
||||
outcome = "already_claimed";
|
||||
} else if (
|
||||
!state.nextReward ||
|
||||
!isRewardCurrency(state.nextReward.currency) ||
|
||||
!(state.nextReward.amount > 0)
|
||||
) {
|
||||
outcome = "bad_config";
|
||||
} else {
|
||||
const { nextReward, nextStreak } = state;
|
||||
const claimDate = new Date(`${state.today}T00:00:00Z`);
|
||||
|
||||
let claimId: number | null = null;
|
||||
try {
|
||||
// Insert first — the unique (user_id, claim_date) key is the
|
||||
// single-claim-per-day guarantee. On success the reward is paid.
|
||||
const [result] = await db.insert(WebsiteDailyRewardClaims).values({
|
||||
userId,
|
||||
claimDate,
|
||||
streak: nextStreak,
|
||||
rewardDay: nextReward.day,
|
||||
currency: nextReward.currency,
|
||||
amount: nextReward.amount,
|
||||
});
|
||||
claimId = Number(result.insertId);
|
||||
} catch (claimError) {
|
||||
const code = (claimError as { cause?: { code?: string } }).cause
|
||||
?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
outcome = "already_claimed";
|
||||
} else {
|
||||
logger.error("Daily reward claim insert failed", {
|
||||
userId,
|
||||
error: claimError,
|
||||
});
|
||||
outcome = "error";
|
||||
}
|
||||
}
|
||||
|
||||
if (claimId !== null) {
|
||||
try {
|
||||
await sendCurrency(
|
||||
{ rcon, db: currencyDb },
|
||||
userId,
|
||||
nextReward.currency as CurrencyName,
|
||||
nextReward.amount,
|
||||
);
|
||||
outcome = "claimed";
|
||||
} catch {
|
||||
// Undeliverable — roll the claim back so the user can retry
|
||||
// instead of losing the reward to a broken RCON/DB write.
|
||||
try {
|
||||
await db
|
||||
.delete(WebsiteDailyRewardClaims)
|
||||
.where(eq(WebsiteDailyRewardClaims.id, BigInt(claimId)));
|
||||
} catch {
|
||||
/* best-effort rollback */
|
||||
}
|
||||
outcome = "error";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath("/me");
|
||||
|
||||
if (outcome === "claimed") {
|
||||
redirect("/me?daily=claimed");
|
||||
}
|
||||
redirect(`/me?daily=${outcome}`);
|
||||
}
|
||||
+19
-1
@@ -11,6 +11,7 @@ import { logger } from "@/lib/logger";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { checkVpn } from "@/lib/services/ip-lookup";
|
||||
import { recordReferral } from "@/lib/services/referrals";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const registerSchema = z.object({
|
||||
@@ -134,8 +135,9 @@ export async function register(
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let inviteeId = 0;
|
||||
try {
|
||||
await db.insert(User).values({
|
||||
const [result] = await db.insert(User).values({
|
||||
username,
|
||||
password: await hashPassword(password),
|
||||
mail: hasEmail ? mail : null,
|
||||
@@ -145,6 +147,7 @@ export async function register(
|
||||
look,
|
||||
termsAccepted: raw.termsAccepted,
|
||||
});
|
||||
inviteeId = Number(result.insertId);
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code === "ER_DUP_ENTRY") {
|
||||
@@ -163,6 +166,21 @@ export async function register(
|
||||
// immediate auto sign-in sees the fresh row.
|
||||
await invalidateKey(`login:user:${username}`);
|
||||
|
||||
// Referral attribution (`/register?ref=<username>`). Best-effort: a broken
|
||||
// referral must never fail the account creation.
|
||||
if (inviteeId > 0) {
|
||||
const invitedBy = String(formData.get("ref") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (invitedBy) {
|
||||
await recordReferral({
|
||||
inviterUsername: invitedBy,
|
||||
inviteeId,
|
||||
inviteeIp: ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Verification email must never block the sign-up response — it is sent
|
||||
// after the response is flushed (no-op when mail is unconfigured).
|
||||
if (hasEmail) {
|
||||
|
||||
Reference in new issue
Block a user