From 87686a310fdd24e078d768860081493b910db03c Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 31 Mar 2026 13:20:01 -0700 Subject: [PATCH] feat: add opt-out toggle for prestige bot announcements Adds enablePrestigeAnnouncements to ProfileSettings (defaults to true). The prestige route now checks this setting before posting the Discord webhook, and the edit profile modal exposes a toggle in the Sounds & Notifications section so players can opt out. Closes #169 --- apps/api/src/routes/prestige.ts | 31 +++++++++++++------ apps/api/src/routes/profile.ts | 2 ++ apps/api/test/routes/prestige.spec.ts | 16 ++++++++-- .../src/components/game/editProfileModal.tsx | 21 +++++++++++++ .../types/src/interfaces/profileSettings.ts | 6 ++++ 5 files changed, 65 insertions(+), 11 deletions(-) diff --git a/apps/api/src/routes/prestige.ts b/apps/api/src/routes/prestige.ts index 2c651a8..f205411 100644 --- a/apps/api/src/routes/prestige.ts +++ b/apps/api/src/routes/prestige.ts @@ -147,17 +147,30 @@ prestigeRouter.post("/", async(context) => { const prestigeCount = prestigeData.count; void logger.metric("prestige", 1, { discordId, prestigeCount }); - void postMilestoneWebhook(discordId, "prestige", { - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next -- @preserve */ - apotheosis: prestigeState.apotheosis?.count ?? 0, - prestige: prestigeData.count, - - // eslint-disable-next-line capitalized-comments -- v8 ignore - /* v8 ignore next 2 -- @preserve */ - transcendence: prestigeState.transcendence?.count ?? 0, + const playerRecord = await prisma.player.findUnique({ + select: { profileSettings: true }, + where: { discordId }, }); + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Runtime shape check for JSON field */ + const playerSettings = playerRecord?.profileSettings as + Record | null | undefined; + const announcementsEnabled + = playerSettings?.enablePrestigeAnnouncements !== false; + + if (announcementsEnabled) { + void postMilestoneWebhook(discordId, "prestige", { + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next -- @preserve */ + apotheosis: prestigeState.apotheosis?.count ?? 0, + + prestige: prestigeData.count, + + // eslint-disable-next-line capitalized-comments -- v8 ignore + /* v8 ignore next 2 -- @preserve */ + transcendence: prestigeState.transcendence?.count ?? 0, + }); + } return context.json({ milestoneRunestones: milestoneRunestones, diff --git a/apps/api/src/routes/profile.ts b/apps/api/src/routes/profile.ts index e1e6414..8bb1428 100644 --- a/apps/api/src/routes/profile.ts +++ b/apps/api/src/routes/profile.ts @@ -47,6 +47,7 @@ const parseProfileSettings = (raw: unknown): ProfileSettings => { : "suffix"; return { enableNotifications: rawObject.enableNotifications === true, + enablePrestigeAnnouncements: rawObject.enablePrestigeAnnouncements !== false, enableSounds: rawObject.enableSounds === true, numberFormat: numberFormat, showAchievementsUnlocked: rawObject.showAchievementsUnlocked !== false, @@ -222,6 +223,7 @@ profileRouter.put("/", authMiddleware, async(context) => { : "suffix"; const profileSettings: ProfileSettings = { enableNotifications: body.profileSettings.enableNotifications ?? false, + enablePrestigeAnnouncements: body.profileSettings.enablePrestigeAnnouncements ?? true, enableSounds: body.profileSettings.enableSounds ?? false, numberFormat: numberFormat, showAchievementsUnlocked: body.profileSettings.showAchievementsUnlocked ?? true, diff --git a/apps/api/test/routes/prestige.spec.ts b/apps/api/test/routes/prestige.spec.ts index a6dd7b4..5c6546a 100644 --- a/apps/api/test/routes/prestige.spec.ts +++ b/apps/api/test/routes/prestige.spec.ts @@ -7,7 +7,7 @@ import type { GameState } from "@elysium/types"; vi.mock("../../src/db/client.js", () => ({ prisma: { - player: { update: vi.fn() }, + player: { findUnique: vi.fn(), update: vi.fn() }, gameState: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })); @@ -47,7 +47,7 @@ const makeState = (overrides: Partial = {}): GameState => ({ describe("prestige route", () => { let app: Hono; let prisma: { - player: { update: ReturnType }; + player: { findUnique: ReturnType; update: ReturnType }; gameState: { findUnique: ReturnType; update: ReturnType; updateMany: ReturnType }; }; @@ -128,6 +128,18 @@ describe("prestige route", () => { const body = await res.json() as { runestones: number; newPrestigeCount: number }; expect(body.newPrestigeCount).toBe(1); }); + + it("skips webhook when enablePrestigeAnnouncements is false", async () => { + const { postMilestoneWebhook } = await import("../../src/services/webhook.js"); + const state = makeState(); + vi.mocked(prisma.gameState.findUnique).mockResolvedValueOnce({ state, updatedAt: 0 } as never); + vi.mocked(prisma.gameState.updateMany).mockResolvedValueOnce({ count: 1 } as never); + vi.mocked(prisma.player.update).mockResolvedValueOnce({} as never); + vi.mocked(prisma.player.findUnique).mockResolvedValueOnce({ profileSettings: { enablePrestigeAnnouncements: false } } as never); + const res = await post(""); + expect(res.status).toBe(200); + expect(postMilestoneWebhook).not.toHaveBeenCalledWith(expect.anything(), "prestige", expect.anything()); + }); }); describe("POST /buy-upgrade", () => { diff --git a/apps/web/src/components/game/editProfileModal.tsx b/apps/web/src/components/game/editProfileModal.tsx index 3be4e74..1b96c78 100644 --- a/apps/web/src/components/game/editProfileModal.tsx +++ b/apps/web/src/components/game/editProfileModal.tsx @@ -225,6 +225,10 @@ const EditProfileModal = ({ void handleNotificationsEnable(); } + function handlePrestigeAnnouncementsToggle(): void { + toggleSetting("enablePrestigeAnnouncements"); + } + const isSaveDisabled = saving || characterName.trim() === ""; let saveLabel = "Save Profile"; @@ -417,6 +421,23 @@ const EditProfileModal = ({ } +
diff --git a/packages/types/src/interfaces/profileSettings.ts b/packages/types/src/interfaces/profileSettings.ts index 9b4dbc0..b5cf8f2 100644 --- a/packages/types/src/interfaces/profileSettings.ts +++ b/packages/types/src/interfaces/profileSettings.ts @@ -48,11 +48,17 @@ interface ProfileSettings { * Whether browser system notifications are enabled. */ enableNotifications: boolean; + + /** + * Whether prestige milestones are announced in the Discord server. + */ + enablePrestigeAnnouncements: boolean; } // eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name const DEFAULT_PROFILE_SETTINGS: ProfileSettings = { enableNotifications: false, + enablePrestigeAnnouncements: true, enableSounds: false, numberFormat: "suffix", showAchievementsUnlocked: true,