feat(referrals): add referral attribution and daily login rewards
CI / check (push) Successful in 4m25s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m54s

- 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:
openhands committed 2026-09-20 12:29:01 +02:00
1 parent 463bc2cb47
commit c3ff497050
30 files changed
+2028 -11

No files matched your search

+16
View File
@@ -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"],
},
],
},
{
+6
View File
@@ -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",
+56
View File
@@ -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();
});
});
+156
View File
@@ -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,
};
}
+101
View File
@@ -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,
});
}
}