Files
EpicNext-Cms/src/lib/services/referrals.ts
T
openhands c3ff497050
CI / check (push) Successful in 4m25s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m54s
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
2026-09-20 12:29:01 +02:00

102 lines
2.7 KiB
TypeScript

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,
});
}
}