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,