diff --git a/apps/www/README.md b/apps/www/README.md
index 46f4c39..197940f 100644
--- a/apps/www/README.md
+++ b/apps/www/README.md
@@ -270,6 +270,14 @@ skip that one link and leave its snapshot for the next run, and the sweep honors
Discord's rate-limit headers between calls. `wrangler tail www` shows a summary line per
run: `discord role sweep: refreshed N of M links, …`.
+A developer can run the same sweep on demand with `POST /api/staff/discord-roles/sync`
+(developer role required, empty body). It answers with the run's summary —
+`{ skipped, refreshed, changed, gone, failed, halted }` — so a run that stops on its first
+call reports WHY (`halted: "the bot is not in the guild (Unknown Guild)"`) instead of
+leaving a silent log line on a worker with observability off. Use it after changing the
+bot token or inviting the bot, or when the role drop needs this morning's roles. The run
+is written to `audit_log` as `sync_discord_roles` against the developer who pressed it.
+
## Development
### Run in dev mode
diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx
index 460c169..2fe7c6e 100644
--- a/apps/www/src/client/App.tsx
+++ b/apps/www/src/client/App.tsx
@@ -3363,6 +3363,7 @@ function Dashboard({
<>
+
>
),
},
@@ -3833,6 +3834,75 @@ function RoleTokenDropForm() {
)
}
+/** What one run of the Discord role sweep did — www's `SweepSummary`, as the sync answers. */
+interface SweepSummary {
+ skipped: boolean
+ refreshed: number
+ changed: number
+ gone: number
+ failed: number
+ halted: string | null
+}
+
+/**
+ * Developer-only: run the Discord role sweep now, instead of waiting for its nightly cron.
+ * Sits under the role drop because the drop reads the snapshot this refreshes — press this
+ * first and the drop sees this morning's roles rather than yesterday's.
+ *
+ * The answer is the point. The cron reports only to a log line, so a sweep that halts on
+ * its first call (a token Discord refuses, a bot not yet in the guild) halts silently every
+ * night; here the same run comes back with its summary, and a `halted` or `skipped` run is
+ * shown as the error it is for the operator, with the reason the server gave. A run that
+ * went through is summed up in a sentence.
+ */
+function DiscordRoleSyncCard() {
+ const { pending, error, done, run } = useAction()
+
+ return (
+
+
Refresh Discord roles
+
+ Re-read every linked Discord member's roles from the server now. This runs on its own
+ once a day; use it after changing the bot or inviting it to the server, or when the role
+ drop above needs today's roles. If the sweep can't run, the reason is shown here.
+
+ {error &&
{error}
}
+ {done &&
{done}
}
+
+
+ )
+}
+
/**
* Change the account's username — the name used to sign in, here and in the game.
*
diff --git a/apps/www/src/staff.ts b/apps/www/src/staff.ts
index d2f0b96..152b407 100644
--- a/apps/www/src/staff.ts
+++ b/apps/www/src/staff.ts
@@ -60,6 +60,8 @@ import { grantCustomAvatarItem, ownedCustomAvatarItemIds } from '../../econ/src/
// The notification ids and the kick frame's recovered shape, owned by `notify`. Both are
// imported as values/types with no runtime dependencies.
import { NotificationType } from '../../notify/src/notification-types'
+// The sweep itself, so a developer can run it on demand instead of waiting for the cron.
+import { refreshDiscordRoles } from './discord-roles'
import type { Context, MiddlewareHandler } from 'hono'
import type { GiftContent } from '@repo/domain'
@@ -1095,6 +1097,31 @@ export async function giftRoleTokensHandler(c: Context) {
return c.json({ roleId, amount, message, paid })
}
+/**
+ * Run the Discord role sweep NOW — the daily cron (discord-roles.ts) on a button, for a
+ * developer who has just changed the bot, invited it to the guild, or needs the role drop
+ * above to see this morning's roles. Same code, same credentials, same writes: every Discord
+ * link is re-read through the bot token and `platform_account.role` rewritten.
+ *
+ * The point of having it as a request is the ANSWER. The cron's summary goes to a log line
+ * on a worker with observability off, so a sweep that halts on its first call (a token
+ * Discord refuses, a bot not in the guild) halts silently every night, and the table looks
+ * exactly as it did — which is how it was found never to have completed a run. Here the
+ * same summary comes back as the response, `halted` reason and all.
+ *
+ * Always a 200 with the summary, whatever it says: a `skipped` or `halted` run is not a
+ * malformed request, it is the sweep reporting on its configuration, and the operator
+ * reads the reason off the body. Two developers pressing it at once run two sweeps that
+ * interleave harmlessly — each write is the absolute role list Discord served — at twice the
+ * Discord calls; nothing locks it. It is on the audit log like any other developer action.
+ */
+export async function syncDiscordRolesHandler(c: Context) {
+ const summary = await refreshDiscordRoles(c.env)
+ await recordPlayerAudit(c, 'sync_discord_roles', { ...summary })
+ logger.info('staff ran the discord role sweep', { moderatorId: staffId(c), ...summary })
+ return c.json(summary)
+}
+
/**
* Give a player XP, in a gift box — the way a game reward pays its XP. The XP is banked
* here (the box, like every box, grants nothing when opened) and spent on any levels it now
diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts
index 6f3a7c3..f01d410 100644
--- a/apps/www/src/test/integration/api.test.ts
+++ b/apps/www/src/test/integration/api.test.ts
@@ -876,7 +876,8 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
writes.push(
'/api/staff/rooms/1/gift-tokens',
'/api/staff/online/gift-tokens',
- '/api/staff/discord-roles/1/gift-tokens'
+ '/api/staff/discord-roles/1/gift-tokens',
+ '/api/staff/discord-roles/sync'
)
for (const path of writes) {
expect((await SELF.fetch(`https://example.com${path}`, { method: 'POST' })).status).toBe(401)
@@ -887,8 +888,11 @@ it('refuses every staff endpoint without a token, and without a staff role', asy
expect(res.status).toBe(403)
}
- // The gifts and the Plus grant are narrower: a moderator is staff, but not a developer.
- for (const path of writes.filter((p) => p.includes('/gift-') || p.includes('/grant-plus'))) {
+ // The gifts, the Plus grant and the role sweep are narrower: a moderator is staff, but
+ // not a developer.
+ for (const path of writes.filter(
+ (p) => p.includes('/gift-') || p.includes('/grant-plus') || p.endsWith('/sync')
+ )) {
expect((await staffPost(path, 8101, { amount: 1 })).status).toBe(403)
}
@@ -2629,6 +2633,34 @@ it('runs the sweep from the scheduled handler and stays off without a guild', as
expect(await discordLink(9100)).toEqual(before)
})
+// The same sweep on a developer's request, answered with its summary. The deployed test
+// config has no guild, so this pins the shape of the answer and the audit row, not a
+// Discord round trip: the sweep's own behaviour is covered below through its seams.
+it('runs the sweep on demand for a developer and answers with the summary', async () => {
+ await onlyDiscordLinks([[9100, '900000000000000100', [ROLE_A]]])
+ const before = await discordLink(9100)
+
+ const res = await devPost('/api/staff/discord-roles/sync', 8110, {})
+ expect(res.status).toBe(200)
+ const summary = (await res.json()) as Record
+ expect(summary).toEqual({
+ skipped: true,
+ refreshed: 0,
+ changed: 0,
+ gone: 0,
+ failed: 0,
+ halted: null,
+ })
+ expect(await discordLink(9100)).toEqual(before)
+
+ const row = await env.DB.prepare(
+ `SELECT player_id, data FROM audit_log WHERE action = 'sync_discord_roles'
+ ORDER BY audit_log_id DESC LIMIT 1`
+ ).first<{ player_id: number; data: string }>()
+ expect(row?.player_id).toBe(8110)
+ expect(JSON.parse(row?.data ?? '{}')).toMatchObject({ skipped: true })
+})
+
it('re-reads every discord link and writes the roles back', async () => {
await onlyDiscordLinks([
[9111, '900000000000000111', [ROLE_A]],
diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts
index 05e5ed5..6ec0816 100644
--- a/apps/www/src/www.app.ts
+++ b/apps/www/src/www.app.ts
@@ -58,6 +58,7 @@ import {
requireDeveloper,
requireStaff,
searchReportsHandler,
+ syncDiscordRolesHandler,
topReportedHandler,
} from './staff'
import {
@@ -572,6 +573,9 @@ const app = new Hono()
// Its sibling: the same gift to every account whose Discord link holds a role, offline
// included — the supporter cron's audience, with an operator's amount and message.
.post('/api/staff/discord-roles/:roleId/gift-tokens', requireDeveloper, giftRoleTokensHandler)
+ // The daily role sweep, run now and answered with its summary (halt reason included) —
+ // the cron reports only to a log line, and this is the way to see why it wrote nothing.
+ .post('/api/staff/discord-roles/sync', requireDeveloper, syncDiscordRolesHandler)
// Rec Room Plus is worth tokens and a discount, so granting it is developer-only too.
.post('/api/staff/players/:id/grant-plus', requireDeveloper, grantPlusHandler)
.post('/api/staff/players/:id/username-changes', addUsernameChangeHandler)