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
@@ -4,6 +4,7 @@ import {
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Calendar,
|
||||
CalendarCheck,
|
||||
CalendarDays,
|
||||
ClipboardList,
|
||||
Cog,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
FileText,
|
||||
Filter,
|
||||
Gavel,
|
||||
Gift,
|
||||
HelpCircle,
|
||||
Image,
|
||||
KeyRound,
|
||||
@@ -526,6 +528,20 @@ export const ADMIN_NAV_GROUPS: AdminNavGroup[] = [
|
||||
permission: PERMS.SHOP_VIEW,
|
||||
matchPrefixes: ["/admin/calendar"],
|
||||
},
|
||||
{
|
||||
href: "/admin/referrals",
|
||||
labelKey: "referrals",
|
||||
icon: Gift,
|
||||
permission: PERMS.REFERRALS_VIEW,
|
||||
matchPrefixes: ["/admin/referrals"],
|
||||
},
|
||||
{
|
||||
href: "/admin/daily-rewards",
|
||||
labelKey: "dailyRewards",
|
||||
icon: CalendarCheck,
|
||||
permission: PERMS.DAILY_REWARDS_VIEW,
|
||||
matchPrefixes: ["/admin/daily-rewards"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -48,6 +48,12 @@ export const PERMS = {
|
||||
// ── Polls Module ──
|
||||
POLLS_VIEW: "admin.polls.view",
|
||||
POLLS_EDIT: "admin.polls.edit",
|
||||
// ── Referrals Module ──
|
||||
REFERRALS_VIEW: "admin.referrals.view",
|
||||
REFERRALS_EDIT: "admin.referrals.edit",
|
||||
// ── Daily Login Rewards Module ──
|
||||
DAILY_REWARDS_VIEW: "admin.dailyrewards.view",
|
||||
DAILY_REWARDS_EDIT: "admin.dailyrewards.edit",
|
||||
// ── Notifications Module ──
|
||||
NOTIFICATIONS_VIEW: "admin.notifications.view",
|
||||
NOTIFICATIONS_EDIT: "admin.notifications.edit",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/db", () => ({ db: {} }));
|
||||
vi.mock("./site-settings", () => ({
|
||||
siteSettings: { getBool: async () => true },
|
||||
}));
|
||||
|
||||
import {
|
||||
type DailyRewardRow,
|
||||
rewardForStreak,
|
||||
shiftDayKey,
|
||||
} from "./daily-rewards";
|
||||
|
||||
describe("daily reward date math", () => {
|
||||
it("shifts day keys across month and year boundaries", () => {
|
||||
expect(shiftDayKey("2026-03-01", -1)).toBe("2026-02-28");
|
||||
expect(shiftDayKey("2026-01-01", 31)).toBe("2026-02-01");
|
||||
expect(shiftDayKey("2026-12-31", 1)).toBe("2027-01-01");
|
||||
expect(shiftDayKey("2026-02-28", 1)).toBe("2026-03-01");
|
||||
});
|
||||
});
|
||||
|
||||
describe("daily reward streak resolution", () => {
|
||||
const rewards: DailyRewardRow[] = [
|
||||
{ id: "1", day: 1, currency: "duckets", amount: 50 },
|
||||
{ id: "2", day: 2, currency: "duckets", amount: 60 },
|
||||
{ id: "5", day: 5, currency: "diamonds", amount: 2 },
|
||||
];
|
||||
|
||||
it("resolves configured days and repeats the cycle", () => {
|
||||
expect(rewardForStreak(rewards, 1)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 2)?.day).toBe(2);
|
||||
expect(rewardForStreak(rewards, 5)?.day).toBe(5);
|
||||
// The cycle is the highest configured day: 5 → back to day 1.
|
||||
expect(rewardForStreak(rewards, 6)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 7)?.day).toBe(2);
|
||||
expect(rewardForStreak(rewards, 10)?.day).toBe(5);
|
||||
});
|
||||
|
||||
it("falls back to the day-1 reward for unconfigured days", () => {
|
||||
// Days 3 and 4 are not configured in the schedule → day-1 reward.
|
||||
expect(rewardForStreak(rewards, 3)?.day).toBe(1);
|
||||
expect(rewardForStreak(rewards, 4)?.day).toBe(1);
|
||||
});
|
||||
|
||||
it("returns null for empty schedules or non-positive streaks", () => {
|
||||
expect(rewardForStreak([], 3)).toBeNull();
|
||||
expect(
|
||||
rewardForStreak(
|
||||
[{ id: "1", day: 3, currency: "credits", amount: 10 }],
|
||||
0,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import "server-only";
|
||||
|
||||
import { asc, desc, eq } from "drizzle-orm";
|
||||
import { db, WebsiteDailyRewardClaims, WebsiteDailyRewards } from "@/lib/db";
|
||||
import { siteSettings } from "./site-settings";
|
||||
|
||||
export const VALID_CURRENCIES = [
|
||||
"credits",
|
||||
"duckets",
|
||||
"diamonds",
|
||||
"points",
|
||||
] as const;
|
||||
export type RewardCurrency = (typeof VALID_CURRENCIES)[number];
|
||||
|
||||
export function isRewardCurrency(value: string): value is RewardCurrency {
|
||||
return (VALID_CURRENCIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export interface DailyRewardRow {
|
||||
id: string;
|
||||
day: number;
|
||||
currency: RewardCurrency;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A "day" is identified by its UTC date key (YYYY-MM-DD) — a single
|
||||
* cross-timezone convention so past claims, today and "yesterday" are always
|
||||
* computed the same way. The date is stored as a DATE column via
|
||||
* `dateAsDate`, which round-trips ISO keys exactly.
|
||||
*/
|
||||
export function dateKey(value: Date = new Date()): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Shift a YYYY-MM-DD key by a signed number of days. */
|
||||
export function shiftDayKey(key: string, days: number): string {
|
||||
const [y, m, d] = key.split("-").map(Number);
|
||||
const shifted = new Date(Date.UTC(y, m - 1, d + days));
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function yesterdayKey(from: Date = new Date()): string {
|
||||
return shiftDayKey(dateKey(from), -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which reward a user's streak entitlement maps to. The cycle repeats
|
||||
* with the highest configured day number; any unconfigured day within the
|
||||
* cycle falls back to the day-1 reward.
|
||||
*/
|
||||
export function rewardForStreak(
|
||||
rewards: DailyRewardRow[],
|
||||
streak: number,
|
||||
): DailyRewardRow | null {
|
||||
if (rewards.length === 0 || !(streak >= 1)) return null;
|
||||
const sorted = [...rewards].sort((a, b) => a.day - b.day);
|
||||
const cycle = Math.max(...sorted.map((r) => r.day));
|
||||
const dayOfCycle = ((streak - 1) % cycle) + 1;
|
||||
return sorted.find((r) => r.day === dayOfCycle) ?? sorted[0];
|
||||
}
|
||||
|
||||
export interface DailyRewardState {
|
||||
enabled: boolean;
|
||||
today: string;
|
||||
rewards: DailyRewardRow[];
|
||||
/** null when the user has never claimed; otherwise the most recent claim. */
|
||||
lastClaim: { date: string; streak: number } | null;
|
||||
alreadyClaimedToday: boolean;
|
||||
/** Streak the next claim would land on. */
|
||||
nextStreak: number;
|
||||
/** Reward day number the next claim would grant. */
|
||||
nextRewardDay: number;
|
||||
/** Resolved reward for the next claim (null when rewards are unconfigured). */
|
||||
nextReward: DailyRewardRow | null;
|
||||
}
|
||||
|
||||
export async function loadDailyRewardState(
|
||||
userId: number,
|
||||
now: Date = new Date(),
|
||||
): Promise<DailyRewardState> {
|
||||
const today = dateKey(now);
|
||||
|
||||
let enabled = true;
|
||||
let rewards: DailyRewardRow[] = [];
|
||||
let lastClaim: { date: string; streak: number } | null = null;
|
||||
try {
|
||||
enabled = await siteSettings.getBool("daily_reward_enabled", true);
|
||||
const rewardRows = await db
|
||||
.select({
|
||||
id: WebsiteDailyRewards.id,
|
||||
day: WebsiteDailyRewards.day,
|
||||
currency: WebsiteDailyRewards.currency,
|
||||
amount: WebsiteDailyRewards.amount,
|
||||
})
|
||||
.from(WebsiteDailyRewards)
|
||||
.orderBy(asc(WebsiteDailyRewards.day));
|
||||
rewards = rewardRows
|
||||
.filter((row) => isRewardCurrency(row.currency))
|
||||
.map((row) => ({
|
||||
id: String(row.id),
|
||||
day: Number(row.day),
|
||||
currency: row.currency as RewardCurrency,
|
||||
amount: Number(row.amount),
|
||||
}));
|
||||
|
||||
const claimRows = await db
|
||||
.select({
|
||||
id: WebsiteDailyRewardClaims.id,
|
||||
claimDate: WebsiteDailyRewardClaims.claimDate,
|
||||
streak: WebsiteDailyRewardClaims.streak,
|
||||
})
|
||||
.from(WebsiteDailyRewardClaims)
|
||||
.where(eq(WebsiteDailyRewardClaims.userId, userId))
|
||||
.orderBy(
|
||||
desc(WebsiteDailyRewardClaims.claimDate),
|
||||
desc(WebsiteDailyRewardClaims.id),
|
||||
)
|
||||
.limit(1);
|
||||
if (claimRows[0]) {
|
||||
lastClaim = {
|
||||
date: dateKey(claimRows[0].claimDate),
|
||||
streak: Number(claimRows[0].streak),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
enabled = false;
|
||||
rewards = [];
|
||||
lastClaim = null;
|
||||
}
|
||||
|
||||
const alreadyClaimedToday = lastClaim?.date === today;
|
||||
const nextStreak = !lastClaim
|
||||
? 1
|
||||
: lastClaim.date === yesterdayKey(now)
|
||||
? lastClaim.streak + 1
|
||||
: lastClaim.date === today
|
||||
? lastClaim.streak
|
||||
: 1;
|
||||
// Same day claims keep yesterday's streak (already claimed), otherwise the
|
||||
// streak for the next claim is what matters.
|
||||
const effectiveStreak =
|
||||
lastClaim?.date === today ? lastClaim.streak : nextStreak;
|
||||
const nextReward = rewardForStreak(rewards, effectiveStreak);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
today,
|
||||
rewards,
|
||||
lastClaim,
|
||||
alreadyClaimedToday,
|
||||
nextStreak,
|
||||
nextRewardDay: nextReward?.day ?? 1,
|
||||
nextReward,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db, Referrals, User, UserReferrals } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { siteSettings } from "./site-settings";
|
||||
|
||||
/**
|
||||
* Attribute a registration to an inviter. Called from the register server
|
||||
* action after the account is created — it must never fail the sign-up, so
|
||||
* every step is best-effort and logged on error.
|
||||
*
|
||||
* Rules:
|
||||
* - the `ref` username must resolve to an existing user;
|
||||
* - self-referral is rejected;
|
||||
* - when `referrals_block_same_ip` is enabled (default), an invitee sharing
|
||||
* the inviter's current IP is rejected (multi-account abuse);
|
||||
* - a duplicate inviter → invitee pair is silently ignored;
|
||||
* - on success the pair is recorded and the inviter's tally is bumped by one.
|
||||
*/
|
||||
export async function recordReferral(input: {
|
||||
inviterUsername: string | null;
|
||||
inviteeId: number;
|
||||
inviteeIp: string;
|
||||
}): Promise<void> {
|
||||
const username = String(input.inviterUsername ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
if (!username) return;
|
||||
|
||||
if (!Number.isSafeInteger(input.inviteeId) || input.inviteeId <= 0) return;
|
||||
|
||||
try {
|
||||
const [inviter] = await db
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
ipCurrent: User.ipCurrent,
|
||||
})
|
||||
.from(User)
|
||||
.where(eq(User.username, username))
|
||||
.limit(1);
|
||||
if (!inviter) return;
|
||||
if (Number(inviter.id) === input.inviteeId) return;
|
||||
|
||||
const blockSameIp = await siteSettings.getBool(
|
||||
"referrals_block_same_ip",
|
||||
true,
|
||||
);
|
||||
if (blockSameIp && inviter.ipCurrent === input.inviteeIp) return;
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: Referrals.id })
|
||||
.from(Referrals)
|
||||
.where(
|
||||
and(
|
||||
eq(Referrals.userId, inviter.id),
|
||||
eq(Referrals.referredUserId, BigInt(input.inviteeId)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existing) return;
|
||||
|
||||
const now = new Date();
|
||||
await db.insert(Referrals).values({
|
||||
userId: inviter.id,
|
||||
referredUserId: BigInt(input.inviteeId),
|
||||
referredUserIp: input.inviteeIp,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const [tally] = await db
|
||||
.select({
|
||||
id: UserReferrals.id,
|
||||
referralsTotal: UserReferrals.referralsTotal,
|
||||
})
|
||||
.from(UserReferrals)
|
||||
.where(eq(UserReferrals.userId, inviter.id))
|
||||
.limit(1);
|
||||
if (tally) {
|
||||
await db
|
||||
.update(UserReferrals)
|
||||
.set({ referralsTotal: tally.referralsTotal + 1n, updatedAt: now })
|
||||
.where(eq(UserReferrals.id, tally.id));
|
||||
} else {
|
||||
await db.insert(UserReferrals).values({
|
||||
userId: inviter.id,
|
||||
referralsTotal: 1n,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Failed to attribute referral during registration", {
|
||||
error,
|
||||
inviter: username,
|
||||
inviteeId: input.inviteeId,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user