From 3324a935b48440260afb51402ba8962c2002f3b1 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Sun, 27 Sep 2026 21:04:59 -0400 Subject: [PATCH] [www] auto reward on join --- .env.example | 4 +- apps/econ/README.md | 5 ++ apps/econ/src/discord-role-gift.ts | 91 +++++++++++++++++----- apps/econ/src/test/integration/api.test.ts | 25 ++++++ apps/www/README.md | 12 +++ apps/www/src/client/App.tsx | 29 +++++-- apps/www/src/context.ts | 10 +++ apps/www/src/test/integration/api.test.ts | 62 +++++++++++++++ apps/www/src/www.app.ts | 57 +++++++++++++- 9 files changed, 263 insertions(+), 32 deletions(-) diff --git a/.env.example b/.env.example index fc01e2a..5c239b1 100644 --- a/.env.example +++ b/.env.example @@ -223,5 +223,7 @@ RECFLARE_DOMAIN=rec.example.com # snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits # claim and the daily sweep above keep, so it reaches only players who have claimed on the # website, and is only as fresh as that sweep (with a bot, the morning's; without one, the -# day they claimed). Unset — or parsing to no roles — leaves the cron a logged no-op. +# day they claimed). The website also pays the same box ONCE, on the spot, the first time an +# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or +# parsing to no roles — leaves the cron a logged no-op and the first-link gift off. # RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000 diff --git a/apps/econ/README.md b/apps/econ/README.md index 742601b..c6999f4 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -600,6 +600,11 @@ role that role's RecCenterTokens in a gift box, every time it fires. The map is for the box. An offline player finds the box on their next `GET /api/avatar/v2/gifts`. - A grant that fails partway is logged with the account, role and amount and is not retried — a retry that also failed partway is how a balance gets credited twice. +- **The first link is paid on demand.** `www`'s benefits claim calls `grantDiscordRoleGift` + — one account, the roles Discord just served, the same map and box — the first time an + account links its Discord, so a new supporter gets one box now rather than after the next + run. `www` decides "first" from the link table; this module keeps no ledger either way, and + the cron pays that account again on its next run like everyone else. One D1 read for the links, then a handful of writes and two hub calls per box, sequentially. A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose diff --git a/apps/econ/src/discord-role-gift.ts b/apps/econ/src/discord-role-gift.ts index 1b22428..e5bd3a5 100644 --- a/apps/econ/src/discord-role-gift.ts +++ b/apps/econ/src/discord-role-gift.ts @@ -20,6 +20,12 @@ * never claimed on the website has no Discord link, and so holds no roles here, however * many they hold in the guild. * + * The same box is ALSO handed once, on demand, the first time an account links its Discord + * (`www`'s benefits claim calls `grantDiscordRoleGift` with the roles it just read), so a + * supporter who claims the day after a run isn't waiting a whole schedule for their first + * gift. That is the one grant here with a "first time" to it, and `www` decides it — this + * module keeps no ledger either way. + * * Each grant is the same three moves econ's other server-handed tokens make (the game * reward's tickets, the new-activity bonus): seed the signup grant, credit the balance, * then a box that displays the amount — announced with `StorefrontBalanceUpdate` (the @@ -39,7 +45,6 @@ import { NotificationType } from '../../notify/src/notification-types' import { ALL_PLATFORMS, creditCurrency, CurrencyType, ensureStartingBalances } from './balance-db' import type { GiftContent } from '@repo/domain' -import type { PlatformLink } from '../../auth/src/platform-db' import type { BalanceResponsePayload, GiftPackagePayload, @@ -55,6 +60,13 @@ const HUB_INSTANCE = 'global' /** The message on the box. */ export const DISCORD_ROLE_GIFT_MESSAGE = 'Thanks for supporting the server!' +/** + * What a grant needs off the bindings: the database, the hub, and the role map. Named as a + * Pick so `www` — whose `Env` declares the same three — can hand its own bindings to + * `grantDiscordRoleGift` without pretending to be econ. + */ +export type RoleGiftEnv = Pick + /** One role's gift, as configured. */ export interface RoleTokens { /** The Discord role id — a snowflake, kept as a string (one exceeds 2^53). */ @@ -95,13 +107,32 @@ export function parseRoleTokens(raw: string | undefined): { } /** - * The gift a link earns: of the mapped roles it holds, the one paying the most — or null - * when it holds none. Ties go to whichever is mapped first; they pay the same either way. + * The role map off the bindings, with every rejected entry logged — once per run for the + * cron, once per claim for the on-demand grant, which is seldom enough to be a reminder + * rather than noise. Empty when the gift is off. */ -export function bestRoleGift(link: PlatformLink, roles: readonly RoleTokens[]): RoleTokens | null { +function configuredRoles(env: RoleGiftEnv): RoleTokens[] { + const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS) + for (const entry of rejected) { + console.error( + `discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected =` + ) + } + return roles +} + +/** + * The gift a set of held roles earns: of the mapped roles among them, the one paying the + * most — or null when none is mapped. Ties go to whichever is mapped first; they pay the + * same either way. + */ +export function bestRoleGift( + held: readonly string[], + roles: readonly RoleTokens[] +): RoleTokens | null { let best: RoleTokens | null = null for (const role of roles) { - if (!link.roles.includes(role.roleId)) continue + if (!held.includes(role.roleId)) continue if (best === null || role.tokens > best.tokens) best = role } return best @@ -174,12 +205,7 @@ export async function grantDiscordRoleGifts( failed: 0, } - const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS) - for (const entry of rejected) { - console.error( - `discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected =` - ) - } + const roles = configuredRoles(env) if (roles.length === 0) { console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)') return { ...summary, skipped: true } @@ -190,10 +216,10 @@ export async function grantDiscordRoleGifts( summary.links = links.length for (const link of links) { - const role = bestRoleGift(link, roles) + const role = bestRoleGift(link.roles, roles) if (role === null) continue try { - await grantOne(env, link, role, startingTokens) + await grantOne(env, link.accountId, role, startingTokens) summary.granted++ summary.tokens += role.tokens } catch (err) { @@ -212,38 +238,61 @@ export async function grantDiscordRoleGifts( return summary } +/** + * The on-demand grant: pay ONE account the gift its roles earn right now, exactly as a cron + * run would — same map, same best-role pick, same box. Returns the role paid, or null when + * none of `held` is mapped (which includes the map being off), in which case nothing is + * written. Throws as `grantOne` does, so the caller decides what a half-landed grant means. + * + * `www`'s benefits claim calls this with the roles Discord just served, the first time an + * account links its Discord; whether it IS the first time is the caller's call, since this + * keeps no ledger. It reads the roles it is handed rather than the stored link so the + * caller can pay before, after or without writing the row. + */ +export async function grantDiscordRoleGift( + env: RoleGiftEnv, + accountId: number, + held: readonly string[], + startingTokens: number +): Promise { + const role = bestRoleGift(held, configuredRoles(env)) + if (role === null) return null + await grantOne(env, accountId, role, startingTokens) + return role +} + /** * Pay one account its role's gift: balance, box, then the two frames. The frames are * best-effort — the tokens are credited and the box stored before either is sent, and an * offline player meets both on their next login. */ async function grantOne( - env: Env, - link: PlatformLink, + env: RoleGiftEnv, + accountId: number, role: RoleTokens, startingTokens: number ): Promise { - await ensureStartingBalances(env.DB, link.accountId, startingTokens) + await ensureStartingBalances(env.DB, accountId, startingTokens) const balance = await creditCurrency( env.DB, - link.accountId, + accountId, CurrencyType.RecCenterTokens, role.tokens, startingTokens ) const content = roleGiftContent(role.tokens) - const gift = await createGift(env.DB, link.accountId, content) + const gift = await createGift(env.DB, accountId, content) const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE) try { // The balance first, so the box's announcement lands on a total that includes it. // `Balance` is the RESULTING total, into the one -2 bucket: the frame SETS it. - await hub.notifyPlayer(link.accountId, NotificationType.StorefrontBalanceUpdate, { + await hub.notifyPlayer(accountId, NotificationType.StorefrontBalanceUpdate, { Balance: balance, CurrencyType: CurrencyType.RecCenterTokens, Platform: ALL_PLATFORMS, } satisfies BalanceResponsePayload) - await hub.notifyPlayer(link.accountId, NotificationType.GiftPackageReceivedImmediate, { + await hub.notifyPlayer(accountId, NotificationType.GiftPackageReceivedImmediate, { Id: gift.id, FromPlayerId: COACH_ACCOUNT_ID, ConsumableItemDesc: content.ConsumableItemDesc, @@ -263,7 +312,7 @@ async function grantOne( } satisfies GiftPackagePayload) } catch (err) { console.error( - `discord role gift: could not notify account ${link.accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}` + `discord role gift: could not notify account ${accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}` ) } } diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index d7c4928..4d5e661 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -91,6 +91,7 @@ import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-ro import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { DISCORD_ROLE_GIFT_MESSAGE, + grantDiscordRoleGift, grantDiscordRoleGifts, parseRoleTokens, } from '../../discord-role-gift' @@ -6797,6 +6798,30 @@ describe('discord role gift', () => { expect(frames.map((f) => f.accountId)).toEqual([9311, 9311, 9312, 9312, 9313, 9313]) }) + // The same grant, paid to ONE account on demand: what `www`'s benefits claim calls on an + // account's first Discord link, with the roles Discord just served rather than a stored + // link. Same map, same best-role pick, same box; null — and no writes — when nothing maps. + test('pays one account its role’s gift on demand', async () => { + await onlyDiscordLinks([]) + await drainFrames() + await expect(grantDiscordRoleGift(giftEnv(undefined), 9331, [ROLE_A], 100)).resolves.toBeNull() + await expect(grantDiscordRoleGift(giftEnv(MAP), 9331, [UNMAPPED], 100)).resolves.toBeNull() + expect(await getPendingGifts(env.DB, 9331)).toEqual([]) + expect(await drainFrames()).toEqual([]) + + await expect( + grantDiscordRoleGift(giftEnv(MAP), 9331, [ROLE_A, ROLE_B], DEFAULT_STARTING_TOKENS) + ).resolves.toEqual({ roleId: ROLE_B, tokens: 10000 }) + expect(await tokens(9331)).toBe(DEFAULT_STARTING_TOKENS + 10000) + expect(await getPendingGifts(env.DB, 9331)).toMatchObject([ + { Currency: 10000, AvatarItemType: null, Message: DISCORD_ROLE_GIFT_MESSAGE }, + ]) + expect((await drainFrames()).map((f) => [f.accountId, f.notificationType])).toEqual([ + [9331, NotificationType.StorefrontBalanceUpdate], + [9331, NotificationType.GiftPackageReceivedImmediate], + ]) + }) + test('every run pays again — the schedule is the cadence — and a lapsed role is not paid', async () => { await onlyDiscordLinks([ [9321, '900000000000000321', [ROLE_A]], diff --git a/apps/www/README.md b/apps/www/README.md index eaa0902..c165512 100644 --- a/apps/www/README.md +++ b/apps/www/README.md @@ -134,6 +134,18 @@ IGNORE`, so `linkedAt` keeps the first claim's time — so the page is safe to reload. Nothing revokes Plus: losing the role later leaves the flag set, so it records "held the role once", not "holds it today". +**The first link pays the supporter gift once.** `econ`'s cron hands every account holding +a role mapped in `DISCORD_ROLE_TOKENS` (`RECFLARE_DISCORD_ROLE_TOKENS` in the root `.env`, +see the `econ` README) that role's tokens in a gift box each time it fires. The claim pays +the same box, through the same `grantDiscordRoleGift`, the first time an account links a +Discord — so a new supporter isn't waiting a whole schedule for their first one. "First" +is decided from the link table before the write: a re-claim, and a claim that puts a second +Discord identity on an account that already has one, both pay nothing. The response's +`tokensAwarded` says what was paid (null when nothing was), and the claim page mentions the +box. The grant runs after Plus is granted and the link written, and a failure in it is +logged rather than failing the claim. Unset, the var awards nothing and the claim is as +before. + The link also records the member's **roles** (`platform_account.role`, a JSON array of role ids). The claim writes them from the member record it read; on their own they'd be a snapshot from claim time, because the claim revokes the player's token the moment it's diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index e4f1aae..dc90d52 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -493,9 +493,20 @@ interface BenefitsStatus { const fetchBenefitsStatus = (): Promise => call('/api/benefits/status', { authed: true }) +/** What a successful claim answers. */ +interface ClaimResult { + discordUsername?: string + /** + * The supporter gift paid on this claim — the tokens the server hands a linked role once, + * the FIRST time an account links its Discord. Null on a re-claim, when no role is mapped + * to a gift, or when the server doesn't run one. + */ + tokensAwarded?: number | null +} + /** Redeem the code Discord sent us back with. The access token never reaches this page. */ -const claimBenefits = (code: string): Promise<{ discordUsername?: string }> => - call<{ discordUsername?: string }>('/api/benefits/claim', { json: { code }, authed: true }) +const claimBenefits = (code: string): Promise => + call('/api/benefits/claim', { json: { code }, authed: true }) /** * The per-attempt CSRF nonce for the Discord round-trip, in sessionStorage. @@ -695,11 +706,15 @@ function BenefitsPanel({ account, config }: { account: SelfAccount; config: Site claimBenefits(code) .then((result) => { setStatus({ hasPlus: true, linked: true }) - setDone( - result.discordUsername - ? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.` - : 'Verified — Rec Room Plus is now on your account.' - ) + const verified = result.discordUsername + ? `Verified as ${result.discordUsername} — Rec Room Plus is now on your account.` + : 'Verified — Rec Room Plus is now on your account.' + // The first-link gift, when one was paid: it's sitting in a box in the game, and + // the player would otherwise only find it by opening their gifts. + const gift = result.tokensAwarded + ? ` A welcome gift of ${result.tokensAwarded.toLocaleString()} tokens is waiting in your gift boxes.` + : '' + setDone(verified + gift) setRelogin(true) }) .catch((err: unknown) => setError(err instanceof Error ? err.message : String(err))) diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index 23b0a18..52bed25 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -45,6 +45,16 @@ export type Env = SharedHonoEnv & { * DEFAULT_STARTING_TOKENS, as it does in econ. */ STARTING_TOKENS?: string | number + /** + * The Discord supporter gift's role map — `=` pairs — the same knob + * `econ`'s cron reads, injected into every worker from RECFLARE_DISCORD_ROLE_TOKENS at + * deploy. www reads it for ONE grant: the first time an account links its Discord through + * the benefits claim, it is boxed the amount its role would earn from one cron run, so a + * new supporter isn't waiting a whole schedule for their first gift (see + * `grantDiscordRoleGift` in econ's discord-role-gift.ts, which does the paying). Unset — + * or mapping none of the member's roles — awards nothing and the claim proceeds as before. + */ + DISCORD_ROLE_TOKENS?: string /** * The most RecCenterTokens one staff token gift can carry (see src/staff.ts). A typo guard * rather than a policy, since a credit can't be taken back. Unset means diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index a56e698..1b10c45 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -56,6 +56,10 @@ import { } from '../../../../econ/src/balance-db' import { CATALOG_SCHEMA_DDL } from '../../../../econ/src/catalog-db' import { CONSUMABLE_SCHEMA_DDL, getConsumables } from '../../../../econ/src/consumables-db' +import { + DISCORD_ROLE_GIFT_MESSAGE, + grantDiscordRoleGift, +} from '../../../../econ/src/discord-role-gift' import { EQUIPMENT_SCHEMA_DDL, getEquipment } from '../../../../econ/src/equipment-db' import { grantCustomAvatarItem, @@ -658,6 +662,64 @@ it('stores the discord identity on a platform the login picker will not list', ( expect(CACHED_LOGIN_PLATFORMS).not.toContain(PlatformType.Discord) }) +// The one-time supporter gift the claim pays on an account's FIRST Discord link: econ's +// cron grant, called on demand with the roles Discord just served, against www's own +// bindings. Exercised at the grant, since the route's path to it runs through discord.com; +// the claim decides "first" from the link table before it writes (see www.app.ts). +it('pays the discord role gift once, on demand, from www’s bindings', async () => { + const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') + await hub().fetch('http://do/all', { method: 'DELETE' }) + const supporter = '1077000000000000001' + const booster = '1077000000000000002' + const giftEnv = { ...env, DISCORD_ROLE_TOKENS: `${supporter}=2500,${booster}=10000` } as Env + + // No map, or a member holding no mapped role: nothing is paid and nothing is written. + await expect( + grantDiscordRoleGift({ ...env, DISCORD_ROLE_TOKENS: undefined } as Env, 4010, [supporter], 100) + ).resolves.toBeNull() + await expect( + grantDiscordRoleGift(giftEnv, 4010, ['1077000000000000009'], 100) + ).resolves.toBeNull() + expect(await getPendingGifts(env.DB, 4010)).toEqual([]) + expect(await (await hub().fetch('http://do/all')).json()).toEqual([]) + + // The best of the member's mapped roles pays — one box, on top of the signup grant, which + // is seeded first so a never-touched balance doesn't start from the gift alone. + await expect( + grantDiscordRoleGift(giftEnv, 4010, [supporter, booster], DEFAULT_STARTING_TOKENS) + ).resolves.toEqual({ roleId: booster, tokens: 10000 }) + await expect( + getBalance(env.DB, 4010, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS) + ).resolves.toBe(DEFAULT_STARTING_TOKENS + 10000) + const gifts = await getPendingGifts(env.DB, 4010) + expect(gifts).toHaveLength(1) + expect(gifts[0]).toMatchObject({ + FromPlayerId: 1, + CurrencyType: CurrencyType.RecCenterTokens, + Currency: 10000, + AvatarItemType: null, + Message: DISCORD_ROLE_GIFT_MESSAGE, + }) + + // Announced through www's hub binding exactly as econ's cron announces it: the resulting + // total into the -2 bucket, then the box. + const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{ + playerId: number + notificationType: number + data: Record + }> + expect(frames.map((f) => [f.playerId, f.notificationType])).toEqual([ + [4010, 61], + [4010, 31], + ]) + expect(frames[0].data).toEqual({ + Balance: DEFAULT_STARTING_TOKENS + 10000, + CurrencyType: CurrencyType.RecCenterTokens, + Platform: -2, + }) + expect(frames[1].data).toMatchObject({ Id: gifts[0]!.Id, Currency: 10000, BalanceType: -2 }) +}) + // The privacy policy is what the Meta Horizon Store's VRC.Privacy.1–4 checks are run // against, and a reviewer only sees the rendered page — so the four things they look // for are pinned here. If a section is renamed, re-read the VRC before loosening the diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 17756d6..1dc7217 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -5,7 +5,7 @@ import { getAccount, updateAccount } from '@repo/domain/src/accounts-db' import { PlatformType } from '@repo/domain/src/enums' import { countOnlinePlayers } from '@repo/domain/src/presence-db' import { getStatSeries } from '@repo/domain/src/stats-db' -import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers' +import { intVar, logger, withDefaultCors, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' // The `platform_account` link table, owned (and migrated) by the `auth` worker. A claimed @@ -20,6 +20,11 @@ import { isPlatformIdentityLinked, linkPlatformIdentity, } from '../../auth/src/platform-db' +// The signup grant's default and the Discord supporter gift, both `econ`'s. The gift is +// econ's cron paid on demand: the first time an account links its Discord, the claim hands +// it the box one cron run would, so the amount and the box are econ's and not a copy. +import { DEFAULT_STARTING_TOKENS } from '../../econ/src/balance-db' +import { grantDiscordRoleGift } from '../../econ/src/discord-role-gift' import { authUnreachable } from './auth-messages' import { AUTHORIZE_URL, @@ -363,6 +368,13 @@ const app = new Hono() * That's deliberate for now — a sweep would need a bot token to enumerate the guild, * which this design specifically avoids — but it does mean the flag records "held the * role once", not "holds it today". + * + * The FIRST time an account links a Discord, the claim also pays the supporter gift + * econ's cron would (`DISCORD_ROLE_TOKENS`, the amount for the best of the roles just + * read) — once, so a new supporter isn't waiting a whole schedule for their first box. + * "First" means the account had no Discord link at all before this claim: a re-claim, + * and a claim that swaps in a second Discord identity, both pay nothing. The response's + * `tokensAwarded` says what was paid (null when nothing was). */ .post('/api/benefits/claim', async (c) => { const config = await discordConfig(c.env) @@ -419,6 +431,12 @@ const app = new Hono() PlatformType.Discord, membership.userId ) + // Whether this claim is the account's FIRST Discord link — what the one-time gift below + // keys on. Decided before the write, from the table: an account re-claiming with the + // same Discord is not first (`alreadyMine`), and neither is one that already carries a + // DIFFERENT Discord identity, which the once-only guard doesn't stop. Only asked when + // it could be true, to spare the re-claim a query. + let firstLink = false if (!alreadyMine) { const claimedElsewhere = await countAccountsForPlatformIdentity( c.env.DB, @@ -434,6 +452,8 @@ const app = new Hono() 409 ) } + const links = await getLinksForAccount(c.env.DB, accountId) + firstLink = !links.some((link) => link.platform === PlatformType.Discord) } // Both writes are idempotent: the link is INSERT OR IGNORE (so `linkedAt` keeps the @@ -446,7 +466,7 @@ const app = new Hono() // under a role that has since been retired from the config. Roles are the one part of // the row a re-claim REFRESHES, because they're a snapshot of a membership that moves // and this reading is the fresher one. - await linkPlatformIdentity( + const created = await linkPlatformIdentity( c.env.DB, accountId, PlatformType.Discord, @@ -456,9 +476,40 @@ const app = new Hono() await updateAccount(c.env.DB, accountId, { hasPlus: true }) logger.info('granted plus from a discord benefits claim', { accountId }) + // The one-time supporter gift: the box econ's cron would hand this member, paid now, + // once. Gated on the insert having happened too (`created`), so two claims racing on + // one fresh account can't both pay. Best-effort AFTER Plus is granted and the link is + // written: a grant that fails partway is logged with what to check, as econ's cron + // logs its own, and the claim still answers — the player got what they came for, and + // a retry that also failed partway is how a balance gets credited twice. + let tokensAwarded: number | null = null + if (created && firstLink) { + try { + const role = await grantDiscordRoleGift( + c.env, + accountId, + membership.roles, + intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS) + ) + if (role !== null) { + tokensAwarded = role.tokens + logger.info('awarded the discord role gift on a first link', { + accountId, + roleId: role.roleId, + tokens: role.tokens, + }) + } + } catch (err) { + logger.error('the discord role gift on a first link failed partway', { + accountId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + // The username is echoed for the confirmation line only — it is never stored, and a // Discord member who has since renamed themselves is not a problem to solve here. - return c.json({ hasPlus: true, discordUsername: membership.username }) + return c.json({ hasPlus: true, discordUsername: membership.username, tokensAwarded }) }) // ---- Staff moderation panel ---------------------------------------------