From 5025948530bbcb01c9edc0282d255d12b3eff49e Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 18:10:22 -0700 Subject: [PATCH 1/5] feat: add community role grant and join prompt for players - Grant Elysian Discord role to players on OAuth login (new and returning) - Add inGuild flag to Player schema, seeded from role grant response - Connect Discord Gateway WebSocket to keep inGuild in sync on join/leave - Return inGuild from load endpoint; expose in game context - Show join community modal once per session when inGuild is false --- apps/api/prisma/schema.prisma | 1 + apps/api/prod.env | 1 + apps/api/src/index.ts | 2 + apps/api/src/routes/auth.ts | 9 + apps/api/src/routes/game.ts | 3 + apps/api/src/services/gateway.ts | 182 ++++++++++++++++++ apps/api/src/services/webhook.ts | 51 ++++- apps/api/test/services/gateway.spec.ts | 128 ++++++++++++ apps/api/test/services/webhook.spec.ts | 89 +++++++++ apps/web/src/components/game/gameLayout.tsx | 2 + .../components/game/joinCommunityModal.tsx | 70 +++++++ apps/web/src/context/gameContext.tsx | 9 + packages/types/src/interfaces/api.ts | 5 + 13 files changed, 549 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/gateway.ts create mode 100644 apps/api/test/services/gateway.spec.ts create mode 100644 apps/web/src/components/game/joinCommunityModal.tsx diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 5ff21c1..0eb67c8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -35,6 +35,7 @@ model Player { lifetimeAchievementsUnlocked Float @default(0) lastLoginDate String? loginStreak Int @default(1) + inGuild Boolean @default(false) } model GameState { diff --git a/apps/api/prod.env b/apps/api/prod.env index d3843dc..a746725 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -10,4 +10,5 @@ DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord mi DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" +DISCORD_ELYSIAN_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord elysian role id" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e579b94..e04bda5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -21,6 +21,7 @@ import { leaderboardRouter } from "./routes/leaderboards.js"; import { prestigeRouter } from "./routes/prestige.js"; import { profileRouter } from "./routes/profile.js"; import { transcendenceRouter } from "./routes/transcendence.js"; +import { connectGateway } from "./services/gateway.js"; import { logger } from "./services/logger.js"; const app = new Hono(); @@ -68,6 +69,7 @@ const port = Number(process.env.PORT ?? 3001); try { serve({ fetch: app.fetch, port: port }, () => { process.stdout.write(`Elysium API running on port ${String(port)}\n`); + connectGateway(); }); } catch (error) { void logger.error( diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index d974509..eeb07cd 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -16,6 +16,7 @@ import { } from "../services/discord.js"; import { signToken } from "../services/jwt.js"; import { logger } from "../services/logger.js"; +import { grantElysianRole } from "../services/webhook.js"; import type { Player } from "@elysium/types"; const authRouter = new Hono(); @@ -92,6 +93,12 @@ authRouter.get("/callback", async(context) => { }, }); + const inGuild = await grantElysianRole(player.discordId); + await prisma.player.update({ + data: { inGuild }, + where: { discordId: player.discordId }, + }); + const jwtToken = signToken(player.discordId); void logger.log("info", `New player registered: ${player.discordId}`); void logger.metric("user_registered", 1, { discordId: player.discordId }); @@ -104,10 +111,12 @@ authRouter.get("/callback", async(context) => { ); } + const inGuild = await grantElysianRole(discordUser.id); const updated = await prisma.player.update({ data: { avatar: discordUser.avatar, discriminator: discordUser.discriminator, + inGuild: inGuild, username: discordUser.username, }, where: { discordId: discordUser.id }, diff --git a/apps/api/src/routes/game.ts b/apps/api/src/routes/game.ts index f6affe1..f74e31e 100644 --- a/apps/api/src/routes/game.ts +++ b/apps/api/src/routes/game.ts @@ -760,6 +760,7 @@ gameRouter.get("/load", async(context) => { : computeHmac(JSON.stringify(freshState), secret); return context.json({ currentSchemaVersion: currentSchemaVersion, + inGuild: playerRecord.inGuild, loginBonus: null, loginStreak: playerRecord.loginStreak, offlineEssence: 0, @@ -898,8 +899,10 @@ gameRouter.get("/load", async(context) => { const signature = secret === undefined ? undefined : computeHmac(JSON.stringify(state), secret); + const inGuild = playerRecord?.inGuild ?? false; return context.json({ currentSchemaVersion, + inGuild, loginBonus, loginStreak, offlineEssence, diff --git a/apps/api/src/services/gateway.ts b/apps/api/src/services/gateway.ts new file mode 100644 index 0000000..c973e49 --- /dev/null +++ b/apps/api/src/services/gateway.ts @@ -0,0 +1,182 @@ +/** + * @file Discord Gateway WebSocket client for listening to guild member events. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +/* eslint-disable max-lines-per-function -- WebSocket gateway requires sequential event handler setup */ +import { prisma } from "../db/client.js"; +import { logger } from "./logger.js"; + +/** + * Discord Gateway opcodes used by this client. + */ +const gatewayOpcodes = { + dispatch: 0, + heartbeat: 1, + heartbeatAck: 11, + hello: 10, + identify: 2, +} as const; + +/** + * GUILD_MEMBERS privileged intent bitmask. + */ +/* eslint-disable-next-line no-bitwise -- Bitwise shift required for Discord intent bitmask */ +const guildMembersIntent = 1 << 1; + +/** + * Updates the inGuild flag for a player when they join the configured guild. + * No-ops silently if the Discord user has no player record. + * @param discordId - The Discord user ID of the member who joined. + * @param guildId - The ID of the guild they joined. + * @returns A promise that resolves when the update attempt completes. + */ +const handleGuildMemberAdd = async( + discordId: string, + guildId: string, +): Promise => { + const configuredGuildId = process.env.DISCORD_GUILD_ID; + if (guildId !== configuredGuildId) { + return; + } + try { + await prisma.player.updateMany({ + data: { inGuild: true }, + where: { discordId }, + }); + } catch (error) { + void logger.error( + "gateway_member_add", + error instanceof Error + ? error + : new Error(String(error)), + ); + } +}; + +/** + * Updates the inGuild flag for a player when they leave the configured guild. + * No-ops silently if the Discord user has no player record. + * @param discordId - The Discord user ID of the member who left. + * @param guildId - The ID of the guild they left. + * @returns A promise that resolves when the update attempt completes. + */ +const handleGuildMemberRemove = async( + discordId: string, + guildId: string, +): Promise => { + const configuredGuildId = process.env.DISCORD_GUILD_ID; + if (guildId !== configuredGuildId) { + return; + } + try { + await prisma.player.updateMany({ + data: { inGuild: false }, + where: { discordId }, + }); + } catch (error) { + void logger.error( + "gateway_member_remove", + error instanceof Error + ? error + : new Error(String(error)), + ); + } +}; + +// eslint-disable-next-line capitalized-comments -- v8 ignore directive must be lowercase +/* v8 ignore next 95 -- @preserve */ +/** + * Connects to the Discord Gateway and listens for guild member events. + * Reconnects automatically on close or error. + * Requires the GUILD_MEMBERS privileged intent to be enabled in the Discord Developer Portal. + */ +const connectGateway = (): void => { + const botToken = process.env.DISCORD_BOT_TOKEN; + if (botToken === undefined || botToken === "") { + void logger.log("info", "Gateway: no bot token configured, skipping"); + return; + } + + const ws = new WebSocket("wss://gateway.discord.gg/?v=10&encoding=json"); + let heartbeatInterval: ReturnType | null = null; + let lastSequence: number | null = null; + + const stopHeartbeat = (): void => { + if (heartbeatInterval !== null) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + }; + + ws.addEventListener("message", (event) => { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Gateway payload is JSON */ + const payload = JSON.parse(event.data as string) as { + op: number; + d: unknown; + s: number | null; + t: string | null; + }; + + if (payload.s !== null) { + lastSequence = payload.s; + } + + if (payload.op === gatewayOpcodes.hello) { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- HELLO d shape; Discord API snake_case */ + const helloData = payload.d as { heartbeat_interval: number }; + const heartbeatMs = helloData.heartbeat_interval; + heartbeatInterval = setInterval(() => { + ws.send(JSON.stringify({ + d: lastSequence, + op: gatewayOpcodes.heartbeat, + })); + }, heartbeatMs); + + ws.send(JSON.stringify({ + d: { + intents: guildMembersIntent, + properties: { browser: "elysium", device: "elysium", os: "linux" }, + token: botToken, + }, + op: gatewayOpcodes.identify, + })); + } + + if (payload.op === gatewayOpcodes.dispatch && payload.t !== null) { + /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions, @typescript-eslint/naming-convention -- dispatch payload shape; Discord API snake_case */ + const data = payload.d as { user?: { id: string }; guild_id?: string }; + const discordId = data.user?.id; + const guildId = data.guild_id; + + if (discordId === undefined || guildId === undefined) { + return; + } + + if (payload.t === "GUILD_MEMBER_ADD") { + void handleGuildMemberAdd(discordId, guildId); + } else if (payload.t === "GUILD_MEMBER_REMOVE") { + void handleGuildMemberRemove(discordId, guildId); + } + } + }); + + ws.addEventListener("close", () => { + stopHeartbeat(); + void logger.log("info", "Gateway: connection closed, reconnecting in 5s"); + setTimeout(connectGateway, 5000); + }); + + ws.addEventListener("error", (event) => { + const message + = event instanceof ErrorEvent + ? event.message + : "WebSocket error"; + void logger.error("gateway_error", new Error(message)); + stopHeartbeat(); + ws.close(); + }); +}; + +export { connectGateway, handleGuildMemberAdd, handleGuildMemberRemove }; diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index b7ef8cd..37649ed 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -15,6 +15,48 @@ const discordApi = "https://discord.com/api/v10"; */ const suppressNotifications = 4096; +/** + * Grants the Elysian Discord role to the given player and returns whether they are in the guild. + * Fails silently so role grant errors do not affect the auth flow. + * @param discordId - The Discord user ID to grant the role to. + * @returns True if the player is in the guild and the role was granted, false otherwise. + */ +const grantElysianRole = async(discordId: string): Promise => { + const botToken = process.env.DISCORD_BOT_TOKEN; + const guildId = process.env.DISCORD_GUILD_ID; + const roleId = process.env.DISCORD_ELYSIAN_ROLE_ID; + + if ( + botToken === undefined || botToken === "" + || guildId === undefined || guildId === "" + || roleId === undefined || roleId === "" + ) { + return false; + } + + try { + const response = await fetch( + `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, + { + headers: { + "Authorization": `Bot ${botToken}`, + "User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)", + }, + method: "PUT", + }, + ); + return response.ok || response.status === 204; + } catch (error) { + void logger.error( + "webhook_elysian_role", + error instanceof Error + ? error + : new Error(String(error)), + ); + return false; + } +}; + /** * Grants the apotheosis Discord role to the given player if configured. * Fails silently so role grant errors do not affect the game action. @@ -38,8 +80,11 @@ const grantApotheosisRole = async(discordId: string): Promise => { await fetch( `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, { - headers: { Authorization: `Bot ${botToken}` }, - method: "PUT", + headers: { + "Authorization": `Bot ${botToken}`, + "User-Agent": "DiscordBot (https://elysium.nhcarrigan.com, 1.0.0)", + }, + method: "PUT", }, ); } catch (error) { @@ -109,4 +154,4 @@ const postMilestoneWebhook = async( } }; -export { grantApotheosisRole, postMilestoneWebhook }; +export { grantApotheosisRole, grantElysianRole, postMilestoneWebhook }; diff --git a/apps/api/test/services/gateway.spec.ts b/apps/api/test/services/gateway.spec.ts new file mode 100644 index 0000000..67387b6 --- /dev/null +++ b/apps/api/test/services/gateway.spec.ts @@ -0,0 +1,128 @@ +/* eslint-disable max-lines-per-function -- Test suites naturally have many cases */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../src/db/client.js", () => ({ + prisma: { + player: { updateMany: vi.fn() }, + }, +})); + +vi.mock("../../src/services/logger.js", () => ({ + logger: { + error: vi.fn().mockResolvedValue(undefined), + log: vi.fn().mockResolvedValue(undefined), + }, +})); + +import { prisma } from "../../src/db/client.js"; + +describe("gateway service", () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + vi.resetAllMocks(); + }); + + afterEach(() => { + process.env = ORIGINAL_ENV; + }); + + describe("handleGuildMemberAdd", () => { + it("sets inGuild to true for the matching guild", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + await handleGuildMemberAdd("user123", "guild123"); + expect(prisma.player.updateMany).toHaveBeenCalledWith({ + data: { inGuild: true }, + where: { discordId: "user123" }, + }); + }); + + it("no-ops when guild id does not match", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + await handleGuildMemberAdd("user123", "other_guild"); + expect(prisma.player.updateMany).not.toHaveBeenCalled(); + }); + + it("no-ops when DISCORD_GUILD_ID env var is missing and guild does not match undefined", async () => { + delete process.env["DISCORD_GUILD_ID"]; + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + await handleGuildMemberAdd("user123", "guild123"); + expect(prisma.player.updateMany).not.toHaveBeenCalled(); + }); + + it("logs error when prisma throws an Error", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + const dbError = new Error("DB failure"); + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberAdd("user123", "guild123"); + expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError); + }); + + it("logs error when prisma throws a non-Error", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); + const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberAdd("user123", "guild123"); + expect(logger.error).toHaveBeenCalledWith( + "gateway_member_add", + new Error("raw error"), + ); + }); + }); + + describe("handleGuildMemberRemove", () => { + it("sets inGuild to false for the matching guild", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + await handleGuildMemberRemove("user123", "guild123"); + expect(prisma.player.updateMany).toHaveBeenCalledWith({ + data: { inGuild: false }, + where: { discordId: "user123" }, + }); + }); + + it("no-ops when guild id does not match", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + await handleGuildMemberRemove("user123", "other_guild"); + expect(prisma.player.updateMany).not.toHaveBeenCalled(); + }); + + it("no-ops when DISCORD_GUILD_ID env var is missing", async () => { + delete process.env["DISCORD_GUILD_ID"]; + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + await handleGuildMemberRemove("user123", "guild123"); + expect(prisma.player.updateMany).not.toHaveBeenCalled(); + }); + + it("logs error when prisma throws an Error", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + const dbError = new Error("DB failure"); + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberRemove("user123", "guild123"); + expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError); + }); + + it("logs error when prisma throws a non-Error", async () => { + process.env["DISCORD_GUILD_ID"] = "guild123"; + vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); + const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); + const { logger } = await import("../../src/services/logger.js"); + await handleGuildMemberRemove("user123", "guild123"); + expect(logger.error).toHaveBeenCalledWith( + "gateway_member_remove", + new Error("raw error"), + ); + }); + }); +}); diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index a68a90a..5c74d8f 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -80,6 +80,95 @@ describe("webhook service", () => { }); }); + describe("grantElysianRole", () => { + it("does nothing when bot token is missing", async () => { + delete process.env["DISCORD_BOT_TOKEN"]; + process.env["DISCORD_GUILD_ID"] = "guild123"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123"; + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user123"); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("does nothing when guild id is missing", async () => { + process.env["DISCORD_BOT_TOKEN"] = "token"; + delete process.env["DISCORD_GUILD_ID"]; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123"; + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user123"); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("does nothing when role id is missing", async () => { + process.env["DISCORD_BOT_TOKEN"] = "token"; + process.env["DISCORD_GUILD_ID"] = "guild123"; + delete process.env["DISCORD_ELYSIAN_ROLE_ID"]; + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user123"); + expect(mockFetch).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("returns true when Discord API responds with ok", async () => { + process.env["DISCORD_BOT_TOKEN"] = "bot_token"; + process.env["DISCORD_GUILD_ID"] = "guild123"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role456"; + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user789"); + expect(mockFetch).toHaveBeenCalledWith( + "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456", + expect.objectContaining({ + method: "PUT", + headers: expect.objectContaining({ Authorization: "Bot bot_token" }), + }), + ); + expect(result).toBe(true); + }); + + it("returns true when Discord API responds with 204", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + mockFetch.mockResolvedValueOnce({ ok: false, status: 204 }); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(true); + }); + + it("returns false when Discord API responds with an error status", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + + it("returns false and swallows fetch errors gracefully", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + mockFetch.mockRejectedValueOnce(new Error("Network error")); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + + it("returns false and swallows non-Error fetch rejections", async () => { + process.env["DISCORD_BOT_TOKEN"] = "tok"; + process.env["DISCORD_GUILD_ID"] = "g"; + process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; + mockFetch.mockRejectedValueOnce("raw string error"); + const { grantElysianRole } = await import("../../src/services/webhook.js"); + const result = await grantElysianRole("user"); + expect(result).toBe(false); + }); + }); + describe("postMilestoneWebhook", () => { const counts = { prestige: 1, transcendence: 0, apotheosis: 0 }; diff --git a/apps/web/src/components/game/gameLayout.tsx b/apps/web/src/components/game/gameLayout.tsx index 37162da..90aaabc 100644 --- a/apps/web/src/components/game/gameLayout.tsx +++ b/apps/web/src/components/game/gameLayout.tsx @@ -27,6 +27,7 @@ import { DebugPanel } from "./debugPanel.js"; import { EditProfileModal } from "./editProfileModal.js"; import { EquipmentPanel } from "./equipmentPanel.js"; import { ExplorationPanel } from "./explorationPanel.js"; +import { JoinCommunityModal } from "./joinCommunityModal.js"; import { LoginBonusModal } from "./loginBonusModal.js"; import { MilestoneToast } from "./milestoneToast.js"; import { OfflineModal } from "./offlineModal.js"; @@ -164,6 +165,7 @@ const GameLayout = (): JSX.Element => { transcendenceCount={state.transcendence?.count ?? 0} /> + {schemaOutdated && !dismissedOutdatedWarning ? : null} diff --git a/apps/web/src/components/game/joinCommunityModal.tsx b/apps/web/src/components/game/joinCommunityModal.tsx new file mode 100644 index 0000000..8bf95cd --- /dev/null +++ b/apps/web/src/components/game/joinCommunityModal.tsx @@ -0,0 +1,70 @@ +/** + * @file Modal prompting players to join the NHCarrigan Discord community. + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ +import { useCallback, useState, type JSX } from "react"; +import { useGame } from "../../context/gameContext.js"; + +const sessionKey = "elysium_join_community_dismissed"; + +/** + * Renders a modal prompting the player to join the NHCarrigan Discord server. + * Shown once per session when the player is not already in the guild. + * @returns The JSX element or null if the player is in the guild or dismissed. + */ +const JoinCommunityModal = (): JSX.Element | null => { + const { inGuild } = useGame(); + const [ dismissed, setDismissed ] = useState( + () => { + return sessionStorage.getItem(sessionKey) === "true"; + }, + ); + + const handleDismiss = useCallback((): void => { + sessionStorage.setItem(sessionKey, "true"); + setDismissed(true); + }, []); + + if (inGuild || dismissed) { + return null; + } + + return ( +
+
+

{"Join Our Community!"}

+

+ {"Did you know Elysium has an active Discord community? "} + {"Join to chat with other players, get updates, and earn "} + {"the exclusive Elysian role!"} +

+

+ {"You already earn the Elysian role just by playing — "} + {"joining lets us show it off in the server!"} +

+
+ + {"Join Discord"} + + +
+
+
+ ); +}; + +export { JoinCommunityModal }; diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index f6c23fb..24f39db 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -243,6 +243,11 @@ interface GameContextValue { isLoading: boolean; error: string | null; + /** + * Whether the player is currently a member of the NHCarrigan Discord server. + */ + inGuild: boolean; + /** * Click the crystal to earn gold. */ @@ -694,6 +699,7 @@ export const GameProvider = ({ const [ schemaOutdated, setSchemaOutdated ] = useState(false); const [ saveSchemaVersion, setSaveSchemaVersion ] = useState(0); const [ currentSchemaVersion, setCurrentSchemaVersion ] = useState(0); + const [ inGuild, setInGuild ] = useState(false); const [ unlockedCodexEntryIds, setUnlockedCodexEntryIds ] = useState< Array >([]); @@ -731,6 +737,7 @@ export const GameProvider = ({ setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); + setInGuild(data.inGuild === true); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`). @@ -2303,6 +2310,7 @@ export const GameProvider = ({ forceUnlocks, formatNumber, handleClick, + inGuild, isLoading, isSyncing, lastSavedAt, @@ -2374,6 +2382,7 @@ export const GameProvider = ({ error, flushBossLoreToasts, forceSync, + inGuild, forceUnlocks, handleClick, isLoading, diff --git a/packages/types/src/interfaces/api.ts b/packages/types/src/interfaces/api.ts index 378cd73..976a301 100644 --- a/packages/types/src/interfaces/api.ts +++ b/packages/types/src/interfaces/api.ts @@ -70,6 +70,11 @@ interface LoginBonusResult { interface LoadResponse { state: GameState; + /** + * Whether the player is currently a member of the NHCarrigan Discord server. + */ + inGuild: boolean; + /** * Offline gold earned since last save (server-calculated). */ -- 2.52.0 From 2c34fe2c813e9da06650efbb56e24c2f14ead515 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 18:11:54 -0700 Subject: [PATCH 2/5] fix: update join community modal with correct Discord invite URL --- apps/web/src/components/game/joinCommunityModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/game/joinCommunityModal.tsx b/apps/web/src/components/game/joinCommunityModal.tsx index 8bf95cd..af03bd9 100644 --- a/apps/web/src/components/game/joinCommunityModal.tsx +++ b/apps/web/src/components/game/joinCommunityModal.tsx @@ -47,7 +47,7 @@ const JoinCommunityModal = (): JSX.Element | null => {
Date: Tue, 24 Mar 2026 18:15:27 -0700 Subject: [PATCH 3/5] refactor: hardcode Elysian role ID instead of env var --- apps/api/prod.env | 1 - apps/api/src/services/webhook.ts | 9 ++++++--- apps/api/test/services/webhook.spec.ts | 17 +---------------- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/apps/api/prod.env b/apps/api/prod.env index a746725..d3843dc 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -10,5 +10,4 @@ DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord mi DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" -DISCORD_ELYSIAN_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord elysian role id" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index 37649ed..6bd6b09 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -15,6 +15,11 @@ const discordApi = "https://discord.com/api/v10"; */ const suppressNotifications = 4096; +/** + * The Discord role ID for the Elysian role granted to all Elysium players. + */ +const elysianRoleId = "1486144823684628490"; + /** * Grants the Elysian Discord role to the given player and returns whether they are in the guild. * Fails silently so role grant errors do not affect the auth flow. @@ -24,19 +29,17 @@ const suppressNotifications = 4096; const grantElysianRole = async(discordId: string): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; const guildId = process.env.DISCORD_GUILD_ID; - const roleId = process.env.DISCORD_ELYSIAN_ROLE_ID; if ( botToken === undefined || botToken === "" || guildId === undefined || guildId === "" - || roleId === undefined || roleId === "" ) { return false; } try { const response = await fetch( - `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, + `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${elysianRoleId}`, { headers: { "Authorization": `Bot ${botToken}`, diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index 5c74d8f..dd1eff5 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -101,25 +101,14 @@ describe("webhook service", () => { expect(result).toBe(false); }); - it("does nothing when role id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - delete process.env["DISCORD_ELYSIAN_ROLE_ID"]; - const { grantElysianRole } = await import("../../src/services/webhook.js"); - const result = await grantElysianRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - expect(result).toBe(false); - }); - it("returns true when Discord API responds with ok", async () => { process.env["DISCORD_BOT_TOKEN"] = "bot_token"; process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role456"; mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user789"); expect(mockFetch).toHaveBeenCalledWith( - "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456", + "https://discord.com/api/v10/guilds/guild123/members/user789/roles/1486144823684628490", expect.objectContaining({ method: "PUT", headers: expect.objectContaining({ Authorization: "Bot bot_token" }), @@ -131,7 +120,6 @@ describe("webhook service", () => { it("returns true when Discord API responds with 204", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; mockFetch.mockResolvedValueOnce({ ok: false, status: 204 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -141,7 +129,6 @@ describe("webhook service", () => { it("returns false when Discord API responds with an error status", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -151,7 +138,6 @@ describe("webhook service", () => { it("returns false and swallows fetch errors gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce(new Error("Network error")); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -161,7 +147,6 @@ describe("webhook service", () => { it("returns false and swallows non-Error fetch rejections", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce("raw string error"); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); -- 2.52.0 From 1a99e83b868cd9401234ad2dc47cb32fa3711df7 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 18:26:38 -0700 Subject: [PATCH 4/5] refactor: hardcode non-secret Discord IDs as constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move guild ID, apotheosis role ID, Discord client ID, and redirect URI from env vars to hardcoded constants — none of these are secrets, and hardcoding removes unnecessary runtime configuration surface. Update tests to use the real IDs and drop now-irrelevant env var scenarios. --- apps/api/prod.env | 4 -- apps/api/src/services/discord.ts | 29 ++++----------- apps/api/src/services/gateway.ts | 8 ++-- apps/api/src/services/webhook.ts | 20 +++------- apps/api/test/services/discord.spec.ts | 28 ++------------ apps/api/test/services/gateway.spec.ts | 45 ++++++----------------- apps/api/test/services/webhook.spec.ts | 51 ++------------------------ apps/web/src/context/gameContext.tsx | 2 +- 8 files changed, 37 insertions(+), 150 deletions(-) diff --git a/apps/api/prod.env b/apps/api/prod.env index d3843dc..8817364 100644 --- a/apps/api/prod.env +++ b/apps/api/prod.env @@ -1,6 +1,4 @@ -DISCORD_CLIENT_ID="op://Environment Variables - Naomi/Elysium/discord client id" DISCORD_CLIENT_SECRET="op://Environment Variables - Naomi/Elysium/discord client secret" -DISCORD_REDIRECT_URI="op://Environment Variables - Naomi/Elysium/discord redirect uri" JWT_SECRET="op://Environment Variables - Naomi/Elysium/jwt secret" DATABASE_URL="op://Environment Variables - Naomi/Elysium/mongo url" ANTI_CHEAT_SECRET="op://Environment Variables - Naomi/Elysium/anti cheat secret" @@ -8,6 +6,4 @@ PORT="op://Environment Variables - Naomi/Elysium/port" CORS_ORIGIN="op://Environment Variables - Naomi/Elysium/origin" DISCORD_MILESTONE_WEBHOOK="op://Environment Variables - Naomi/Elysium/discord milestone webhook" DISCORD_BOT_TOKEN="op://Environment Variables - Naomi/Elysium/discord bot token" -DISCORD_GUILD_ID="op://Environment Variables - Naomi/Elysium/discord guild id" -DISCORD_APOTHEOSIS_ROLE_ID="op://Environment Variables - Naomi/Elysium/discord apotheosis role id" LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth" \ No newline at end of file diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index 8b82ae8..612a355 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -7,6 +7,9 @@ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ import { logger } from "./logger.js"; +const discordClientId = "1465425348375089182"; +const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback"; + interface DiscordTokenResponse { access_token: string; token_type: string; @@ -31,24 +34,18 @@ interface DiscordUser { const exchangeCode = async( code: string, ): Promise => { - const clientId = process.env.DISCORD_CLIENT_ID; const clientSecret = process.env.DISCORD_CLIENT_SECRET; - const redirectUri = process.env.DISCORD_REDIRECT_URI; - if ( - clientId === undefined || clientId === "" - || clientSecret === undefined || clientSecret === "" - || redirectUri === undefined || redirectUri === "" - ) { + if (clientSecret === undefined || clientSecret === "") { throw new Error("Discord OAuth environment variables are required"); } const parameters = new URLSearchParams({ - client_id: clientId, + client_id: discordClientId, client_secret: clientSecret, code: code, grant_type: "authorization_code", - redirect_uri: redirectUri, + redirect_uri: discordRedirectUri, }); try { @@ -146,19 +143,9 @@ const fetchDiscordUserById = async( * @throws {Error} If OAuth environment variables are missing. */ const buildOAuthUrl = (): string => { - const clientId = process.env.DISCORD_CLIENT_ID; - const redirectUri = process.env.DISCORD_REDIRECT_URI; - - if ( - clientId === undefined || clientId === "" - || redirectUri === undefined || redirectUri === "" - ) { - throw new Error("Discord OAuth environment variables are required"); - } - const parameters = new URLSearchParams({ - client_id: clientId, - redirect_uri: redirectUri, + client_id: discordClientId, + redirect_uri: discordRedirectUri, response_type: "code", scope: "identify", }); diff --git a/apps/api/src/services/gateway.ts b/apps/api/src/services/gateway.ts index c973e49..5f0f6bc 100644 --- a/apps/api/src/services/gateway.ts +++ b/apps/api/src/services/gateway.ts @@ -8,6 +8,8 @@ import { prisma } from "../db/client.js"; import { logger } from "./logger.js"; +const discordGuildId = "1354624415861833870"; + /** * Discord Gateway opcodes used by this client. */ @@ -36,8 +38,7 @@ const handleGuildMemberAdd = async( discordId: string, guildId: string, ): Promise => { - const configuredGuildId = process.env.DISCORD_GUILD_ID; - if (guildId !== configuredGuildId) { + if (guildId !== discordGuildId) { return; } try { @@ -66,8 +67,7 @@ const handleGuildMemberRemove = async( discordId: string, guildId: string, ): Promise => { - const configuredGuildId = process.env.DISCORD_GUILD_ID; - if (guildId !== configuredGuildId) { + if (guildId !== discordGuildId) { return; } try { diff --git a/apps/api/src/services/webhook.ts b/apps/api/src/services/webhook.ts index 6bd6b09..5bbdcee 100644 --- a/apps/api/src/services/webhook.ts +++ b/apps/api/src/services/webhook.ts @@ -18,7 +18,9 @@ const suppressNotifications = 4096; /** * The Discord role ID for the Elysian role granted to all Elysium players. */ +const discordGuildId = "1354624415861833870"; const elysianRoleId = "1486144823684628490"; +const apotheosisRoleId = "1479966598210129991"; /** * Grants the Elysian Discord role to the given player and returns whether they are in the guild. @@ -28,18 +30,14 @@ const elysianRoleId = "1486144823684628490"; */ const grantElysianRole = async(discordId: string): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; - const guildId = process.env.DISCORD_GUILD_ID; - if ( - botToken === undefined || botToken === "" - || guildId === undefined || guildId === "" - ) { + if (botToken === undefined || botToken === "") { return false; } try { const response = await fetch( - `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${elysianRoleId}`, + `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${elysianRoleId}`, { headers: { "Authorization": `Bot ${botToken}`, @@ -68,20 +66,14 @@ const grantElysianRole = async(discordId: string): Promise => { */ const grantApotheosisRole = async(discordId: string): Promise => { const botToken = process.env.DISCORD_BOT_TOKEN; - const guildId = process.env.DISCORD_GUILD_ID; - const roleId = process.env.DISCORD_APOTHEOSIS_ROLE_ID; - if ( - botToken === undefined || botToken === "" - || guildId === undefined || guildId === "" - || roleId === undefined || roleId === "" - ) { + if (botToken === undefined || botToken === "") { return; } try { await fetch( - `${discordApi}/guilds/${guildId}/members/${discordId}/roles/${roleId}`, + `${discordApi}/guilds/${discordGuildId}/members/${discordId}/roles/${apotheosisRoleId}`, { headers: { "Authorization": `Bot ${botToken}`, diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index cf924ae..3292f19 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -18,51 +18,31 @@ describe("discord service", () => { }); describe("buildOAuthUrl", () => { - it("throws when DISCORD_CLIENT_ID is missing", async () => { - delete process.env["DISCORD_CLIENT_ID"]; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback"; - const { buildOAuthUrl } = await import("../../src/services/discord.js"); - expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required"); - }); - - it("throws when DISCORD_REDIRECT_URI is missing", async () => { - process.env["DISCORD_CLIENT_ID"] = "client123"; - delete process.env["DISCORD_REDIRECT_URI"]; - const { buildOAuthUrl } = await import("../../src/services/discord.js"); - expect(() => buildOAuthUrl()).toThrow("Discord OAuth environment variables are required"); - }); - it("returns a URL with correct query params", async () => { - process.env["DISCORD_CLIENT_ID"] = "client123"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/callback"; const { buildOAuthUrl } = await import("../../src/services/discord.js"); const url = buildOAuthUrl(); - expect(url).toContain("client_id=client123"); + expect(url).toContain("client_id=1465425348375089182"); expect(url).toContain("response_type=code"); expect(url).toContain("scope=identify"); }); }); describe("exchangeCode", () => { - it("throws when env vars are missing", async () => { - delete process.env["DISCORD_CLIENT_ID"]; + it("throws when DISCORD_CLIENT_SECRET is missing", async () => { + delete process.env["DISCORD_CLIENT_SECRET"]; const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("mycode")).rejects.toThrow("Discord OAuth environment variables are required"); }); it("throws when response is not ok", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; mockFetch.mockResolvedValueOnce({ ok: false, statusText: "Unauthorized" }); const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("bad_code")).rejects.toThrow("Discord token exchange failed"); }); it("returns parsed body on success", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; const tokenData = { access_token: "tok", token_type: "Bearer", expires_in: 3600, refresh_token: "ref", scope: "identify" }; mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(tokenData) }); const { exchangeCode } = await import("../../src/services/discord.js"); @@ -96,9 +76,7 @@ describe("discord service", () => { describe("exchangeCode non-Error throw", () => { it("re-throws when fetch rejects with a non-Error value", async () => { - process.env["DISCORD_CLIENT_ID"] = "cid"; process.env["DISCORD_CLIENT_SECRET"] = "secret"; - process.env["DISCORD_REDIRECT_URI"] = "http://localhost/cb"; mockFetch.mockRejectedValueOnce("raw string error"); const { exchangeCode } = await import("../../src/services/discord.js"); await expect(exchangeCode("some_code")).rejects.toBe("raw string error"); diff --git a/apps/api/test/services/gateway.spec.ts b/apps/api/test/services/gateway.spec.ts index 67387b6..1112bf4 100644 --- a/apps/api/test/services/gateway.spec.ts +++ b/apps/api/test/services/gateway.spec.ts @@ -16,60 +16,48 @@ vi.mock("../../src/services/logger.js", () => ({ import { prisma } from "../../src/db/client.js"; -describe("gateway service", () => { - const ORIGINAL_ENV = process.env; +const discordGuildId = "1354624415861833870"; +describe("gateway service", () => { beforeEach(() => { - process.env = { ...ORIGINAL_ENV }; vi.resetAllMocks(); }); afterEach(() => { - process.env = ORIGINAL_ENV; + vi.clearAllMocks(); }); describe("handleGuildMemberAdd", () => { it("sets inGuild to true for the matching guild", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); - await handleGuildMemberAdd("user123", "guild123"); + await handleGuildMemberAdd("user123", discordGuildId); expect(prisma.player.updateMany).toHaveBeenCalledWith({ data: { inGuild: true }, where: { discordId: "user123" }, }); }); - it("no-ops when guild id does not match", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; + it("no-ops when guild id does not match the configured guild", async () => { const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); await handleGuildMemberAdd("user123", "other_guild"); expect(prisma.player.updateMany).not.toHaveBeenCalled(); }); - it("no-ops when DISCORD_GUILD_ID env var is missing and guild does not match undefined", async () => { - delete process.env["DISCORD_GUILD_ID"]; - const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); - await handleGuildMemberAdd("user123", "guild123"); - expect(prisma.player.updateMany).not.toHaveBeenCalled(); - }); - it("logs error when prisma throws an Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; const dbError = new Error("DB failure"); vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberAdd("user123", "guild123"); + await handleGuildMemberAdd("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith("gateway_member_add", dbError); }); it("logs error when prisma throws a non-Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); const { handleGuildMemberAdd } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberAdd("user123", "guild123"); + await handleGuildMemberAdd("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith( "gateway_member_add", new Error("raw error"), @@ -79,46 +67,35 @@ describe("gateway service", () => { describe("handleGuildMemberRemove", () => { it("sets inGuild to false for the matching guild", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockResolvedValueOnce({ count: 1 }); const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); - await handleGuildMemberRemove("user123", "guild123"); + await handleGuildMemberRemove("user123", discordGuildId); expect(prisma.player.updateMany).toHaveBeenCalledWith({ data: { inGuild: false }, where: { discordId: "user123" }, }); }); - it("no-ops when guild id does not match", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; + it("no-ops when guild id does not match the configured guild", async () => { const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); await handleGuildMemberRemove("user123", "other_guild"); expect(prisma.player.updateMany).not.toHaveBeenCalled(); }); - it("no-ops when DISCORD_GUILD_ID env var is missing", async () => { - delete process.env["DISCORD_GUILD_ID"]; - const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); - await handleGuildMemberRemove("user123", "guild123"); - expect(prisma.player.updateMany).not.toHaveBeenCalled(); - }); - it("logs error when prisma throws an Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; const dbError = new Error("DB failure"); vi.mocked(prisma.player.updateMany).mockRejectedValueOnce(dbError); const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberRemove("user123", "guild123"); + await handleGuildMemberRemove("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith("gateway_member_remove", dbError); }); it("logs error when prisma throws a non-Error", async () => { - process.env["DISCORD_GUILD_ID"] = "guild123"; vi.mocked(prisma.player.updateMany).mockRejectedValueOnce("raw error"); const { handleGuildMemberRemove } = await import("../../src/services/gateway.js"); const { logger } = await import("../../src/services/logger.js"); - await handleGuildMemberRemove("user123", "guild123"); + await handleGuildMemberRemove("user123", discordGuildId); expect(logger.error).toHaveBeenCalledWith( "gateway_member_remove", new Error("raw error"), diff --git a/apps/api/test/services/webhook.spec.ts b/apps/api/test/services/webhook.spec.ts index dd1eff5..4e9f045 100644 --- a/apps/api/test/services/webhook.spec.ts +++ b/apps/api/test/services/webhook.spec.ts @@ -20,42 +20,20 @@ describe("webhook service", () => { describe("grantApotheosisRole", () => { it("does nothing when bot token is missing", async () => { delete process.env["DISCORD_BOT_TOKEN"]; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123"; const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await grantApotheosisRole("user123"); expect(mockFetch).not.toHaveBeenCalled(); }); - it("does nothing when guild id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - delete process.env["DISCORD_GUILD_ID"]; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role123"; - const { grantApotheosisRole } = await import("../../src/services/webhook.js"); - await grantApotheosisRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("does nothing when role id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - delete process.env["DISCORD_APOTHEOSIS_ROLE_ID"]; - const { grantApotheosisRole } = await import("../../src/services/webhook.js"); - await grantApotheosisRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - - it("calls Discord API with correct URL and auth when env vars are set", async () => { + it("calls Discord API with correct URL and auth when bot token is set", async () => { process.env["DISCORD_BOT_TOKEN"] = "bot_token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "role456"; mockFetch.mockResolvedValueOnce({ ok: true }); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await grantApotheosisRole("user789"); expect(mockFetch).toHaveBeenCalledWith( - "https://discord.com/api/v10/guilds/guild123/members/user789/roles/role456", + "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1479966598210129991", expect.objectContaining({ - method: "PUT", + method: "PUT", headers: expect.objectContaining({ Authorization: "Bot bot_token" }), }), ); @@ -63,8 +41,6 @@ describe("webhook service", () => { it("swallows fetch errors gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce(new Error("Network error")); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); @@ -72,8 +48,6 @@ describe("webhook service", () => { it("swallows non-Error fetch rejections gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; - process.env["DISCORD_APOTHEOSIS_ROLE_ID"] = "r"; mockFetch.mockRejectedValueOnce("raw string error"); const { grantApotheosisRole } = await import("../../src/services/webhook.js"); await expect(grantApotheosisRole("user")).resolves.toBeUndefined(); @@ -83,18 +57,6 @@ describe("webhook service", () => { describe("grantElysianRole", () => { it("does nothing when bot token is missing", async () => { delete process.env["DISCORD_BOT_TOKEN"]; - process.env["DISCORD_GUILD_ID"] = "guild123"; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123"; - const { grantElysianRole } = await import("../../src/services/webhook.js"); - const result = await grantElysianRole("user123"); - expect(mockFetch).not.toHaveBeenCalled(); - expect(result).toBe(false); - }); - - it("does nothing when guild id is missing", async () => { - process.env["DISCORD_BOT_TOKEN"] = "token"; - delete process.env["DISCORD_GUILD_ID"]; - process.env["DISCORD_ELYSIAN_ROLE_ID"] = "role123"; const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user123"); expect(mockFetch).not.toHaveBeenCalled(); @@ -103,12 +65,11 @@ describe("webhook service", () => { it("returns true when Discord API responds with ok", async () => { process.env["DISCORD_BOT_TOKEN"] = "bot_token"; - process.env["DISCORD_GUILD_ID"] = "guild123"; mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user789"); expect(mockFetch).toHaveBeenCalledWith( - "https://discord.com/api/v10/guilds/guild123/members/user789/roles/1486144823684628490", + "https://discord.com/api/v10/guilds/1354624415861833870/members/user789/roles/1486144823684628490", expect.objectContaining({ method: "PUT", headers: expect.objectContaining({ Authorization: "Bot bot_token" }), @@ -119,7 +80,6 @@ describe("webhook service", () => { it("returns true when Discord API responds with 204", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockResolvedValueOnce({ ok: false, status: 204 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -128,7 +88,6 @@ describe("webhook service", () => { it("returns false when Discord API responds with an error status", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockResolvedValueOnce({ ok: false, status: 403 }); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -137,7 +96,6 @@ describe("webhook service", () => { it("returns false and swallows fetch errors gracefully", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockRejectedValueOnce(new Error("Network error")); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); @@ -146,7 +104,6 @@ describe("webhook service", () => { it("returns false and swallows non-Error fetch rejections", async () => { process.env["DISCORD_BOT_TOKEN"] = "tok"; - process.env["DISCORD_GUILD_ID"] = "g"; mockFetch.mockRejectedValueOnce("raw string error"); const { grantElysianRole } = await import("../../src/services/webhook.js"); const result = await grantElysianRole("user"); diff --git a/apps/web/src/context/gameContext.tsx b/apps/web/src/context/gameContext.tsx index 24f39db..fd89b84 100644 --- a/apps/web/src/context/gameContext.tsx +++ b/apps/web/src/context/gameContext.tsx @@ -737,7 +737,7 @@ export const GameProvider = ({ setSchemaOutdated(data.schemaOutdated); setSaveSchemaVersion(data.state.schemaVersion ?? 0); setCurrentSchemaVersion(data.currentSchemaVersion); - setInGuild(data.inGuild === true); + setInGuild(data.inGuild); // Fetch number format preference from profile (fire-and-forget, non-blocking) void fetch(`/api/profile/${data.state.player.discordId}`). -- 2.52.0 From e2c07f8b4ccdc74a3637da8cae892f8ec8150f76 Mon Sep 17 00:00:00 2001 From: Hikari Date: Tue, 24 Mar 2026 18:40:19 -0700 Subject: [PATCH 5/5] fix: correct Discord client ID constant --- apps/api/src/services/discord.ts | 2 +- apps/api/test/services/discord.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/discord.ts b/apps/api/src/services/discord.ts index 612a355..dcf02fe 100644 --- a/apps/api/src/services/discord.ts +++ b/apps/api/src/services/discord.ts @@ -7,7 +7,7 @@ /* eslint-disable @typescript-eslint/naming-convention -- Discord API requires snake_case fields and HTTP headers require Pascal-Case */ import { logger } from "./logger.js"; -const discordClientId = "1465425348375089182"; +const discordClientId = "1479551654264049908"; const discordRedirectUri = "https://elysium.nhcarrigan.com/api/auth/callback"; interface DiscordTokenResponse { diff --git a/apps/api/test/services/discord.spec.ts b/apps/api/test/services/discord.spec.ts index 3292f19..e1a09a4 100644 --- a/apps/api/test/services/discord.spec.ts +++ b/apps/api/test/services/discord.spec.ts @@ -21,7 +21,7 @@ describe("discord service", () => { it("returns a URL with correct query params", async () => { const { buildOAuthUrl } = await import("../../src/services/discord.js"); const url = buildOAuthUrl(); - expect(url).toContain("client_id=1465425348375089182"); + expect(url).toContain("client_id=1479551654264049908"); expect(url).toContain("response_type=code"); expect(url).toContain("scope=identify"); }); -- 2.52.0