From c18ece510c45d3ed584db1acce1ca52f0973ae20 Mon Sep 17 00:00:00 2001 From: yufoxda Date: Mon, 27 Jul 2026 15:23:17 +0900 Subject: [PATCH 1/2] refactor(community): give the provider port vendor-neutral types The port was generic in name only. Its methods returned DiscordGuildMembership, DiscordMessage and DiscordReactionUser, and every identifier was validated against the Discord snowflake format, so the identifier regex reached callers that have no reason to know what a snowflake is. Replacing the provider would have meant editing the interface and both api_v0 services rather than swapping an adapter. The port now speaks CommunityRole, CommunityMembership, CommunityMessage, CommunityReactionUser and CommunityAccountProfile, treats identifiers as opaque strings, and drops the Discord message length limit. Discord's snowflake format, its 2000-character limit, and the global_name field it returns are refinements applied in discord/schema.ts, which is the only layer that issues those values. The provider-specific field name is mapped to displayName at the adapter boundary, matching the provider_display_name column it is stored in. Behaviour is unchanged: the adapter still rejects a malformed provider response and a guild member response for another user, both of which depend on the snowflake assertion that moved. Co-Authored-By: Claude Opus 4.8 --- .../src/api_v0/message/reactions.test.ts | 12 +-- community/src/api_v0/message/reactions.ts | 8 +- .../api_v0/user/me/identity/service.test.ts | 4 +- .../src/api_v0/user/me/identity/service.ts | 16 ++-- community/src/lib/community/discord/main.ts | 10 +-- .../src/lib/community/discord/message.test.ts | 2 +- .../src/lib/community/discord/message.ts | 31 ++++---- .../src/lib/community/discord/oauth.test.ts | 2 +- community/src/lib/community/discord/oauth.ts | 9 ++- community/src/lib/community/discord/role.ts | 17 ++--- community/src/lib/community/discord/schema.ts | 50 +++++++++++++ community/src/lib/community/interface.ts | 20 ++--- community/src/lib/community/type.ts | 74 +++++++++++-------- 13 files changed, 161 insertions(+), 94 deletions(-) create mode 100644 community/src/lib/community/discord/schema.ts diff --git a/community/src/api_v0/message/reactions.test.ts b/community/src/api_v0/message/reactions.test.ts index 6ca0587..7130d47 100644 --- a/community/src/api_v0/message/reactions.test.ts +++ b/community/src/api_v0/message/reactions.test.ts @@ -7,7 +7,7 @@ import { LinkedReactionUser, ReactionUsersByEmoji, } from './reactions'; -import type { DiscordMessage } from '../../lib/community/type'; +import type { CommunityMessage } from '../../lib/community/type'; const eventMessage: EventMessageRecord = { id: 'event-1', @@ -19,7 +19,7 @@ const eventMessage: EventMessageRecord = { updatedAt: '2026-07-01T00:00:00Z', }; -const discordMessage: DiscordMessage = { +const discordMessage: CommunityMessage = { id: eventMessage.messageId, channelId: eventMessage.channelId, content: eventMessage.content, @@ -27,7 +27,7 @@ const discordMessage: DiscordMessage = { author: { id: '323456789012345678', username: 'bot', - globalName: null, + displayName: null, bot: true, }, reactions: [ @@ -41,15 +41,15 @@ const reactionUsersByEmoji: ReactionUsersByEmoji[] = [ emoji: '✅', count: 2, users: [ - { id: '423456789012345678', username: 'taro', globalName: 'Taro', bot: false }, - { id: '523456789012345678', username: 'hanako', globalName: null, bot: false }, + { id: '423456789012345678', username: 'taro', displayName: 'Taro', bot: false }, + { id: '523456789012345678', username: 'hanako', displayName: null, bot: false }, ], }, { emoji: '🍱', count: 1, users: [ - { id: '423456789012345678', username: 'taro', globalName: 'Taro', bot: false }, + { id: '423456789012345678', username: 'taro', displayName: 'Taro', bot: false }, ], }, ]; diff --git a/community/src/api_v0/message/reactions.ts b/community/src/api_v0/message/reactions.ts index 588f68e..b3f1f37 100644 --- a/community/src/api_v0/message/reactions.ts +++ b/community/src/api_v0/message/reactions.ts @@ -1,4 +1,4 @@ -import type { DiscordMessage, DiscordReactionUser } from "../../lib/community/type"; +import type { CommunityMessage, CommunityReactionUser } from "../../lib/community/type"; import type { MemberStatus } from "../../../../share/drizzle/schema"; export type EventMessageRecord = { @@ -38,7 +38,7 @@ export type LinkedReactionUser = { export type ReactionUsersByEmoji = { emoji: string; count: number; - users: DiscordReactionUser[]; + users: CommunityReactionUser[]; }; export type ReactionMember = { @@ -76,7 +76,7 @@ export const collectDiscordUserIds = (reactionUsersByEmoji: ReactionUsersByEmoji export const buildMessageReactionSummary = ( eventMessage: EventMessageRecord, - discordMessage: DiscordMessage, + discordMessage: CommunityMessage, reactionUsersByEmoji: ReactionUsersByEmoji[], linkedUsers: LinkedReactionUser[], ) => { @@ -91,7 +91,7 @@ export const buildMessageReactionSummary = ( const reactionMember: ReactionMember = { discordUserId: discordUser.id, discordUsername: discordUser.username, - discordGlobalName: discordUser.globalName, + discordGlobalName: discordUser.displayName, userId: linkedUser?.userId ?? null, userName: linkedUser?.userName ?? null, displayName: linkedUser?.displayName ?? null, diff --git a/community/src/api_v0/user/me/identity/service.test.ts b/community/src/api_v0/user/me/identity/service.test.ts index 07b6e14..defc067 100644 --- a/community/src/api_v0/user/me/identity/service.test.ts +++ b/community/src/api_v0/user/me/identity/service.test.ts @@ -17,7 +17,7 @@ const linkedAccount = { const oauthUser = { id: linkedAccount.accountId, username: 'test-user', - globalName: 'Test User', + displayName: 'Test User', avatarUrl: null, }; const guildMembership = { @@ -30,7 +30,7 @@ const persisted = { provider: 'discord' as const, providerAccountId: linkedAccount.accountId, username: oauthUser.username, - providerDisplayName: oauthUser.globalName, + providerDisplayName: oauthUser.displayName, avatarUrl: null, oauthVerifiedAt: fixedNow, membership: { diff --git a/community/src/api_v0/user/me/identity/service.ts b/community/src/api_v0/user/me/identity/service.ts index 57d85d6..6ff57fe 100644 --- a/community/src/api_v0/user/me/identity/service.ts +++ b/community/src/api_v0/user/me/identity/service.ts @@ -11,8 +11,8 @@ import { getAuth } from '../../../../auth/better-auth'; import { CommunityProviderError } from '../../../../lib/community/error'; import { getCurrentDiscordUser } from '../../../../lib/community/discord/oauth'; import type { - DiscordGuildMembership, - DiscordOAuthUser, + CommunityAccountProfile, + CommunityMembership, } from '../../../../lib/community/type'; import { verifyDiscordIdentityRoute, @@ -27,9 +27,9 @@ type LinkedDiscordAccount = { type PersistVerificationInput = { appUserId: string; linkedAccount: LinkedDiscordAccount; - oauthUser: DiscordOAuthUser; + oauthUser: CommunityAccountProfile; guildId: string; - guildMembership: DiscordGuildMembership | null; + guildMembership: CommunityMembership | null; verifiedAt: string; }; @@ -43,7 +43,7 @@ type IdentityAuthApi = { type VerificationDependencies = { getAuthApi(c: Context): IdentityAuthApi; - getCurrentDiscordUser(accessToken: string): Promise; + getCurrentDiscordUser(accessToken: string): Promise; findLinkedDiscordAccounts( c: Context, userId: string, @@ -80,7 +80,7 @@ const persistVerification: VerificationDependencies['persistVerification'] = asy provider: 'discord', providerAccountId: input.oauthUser.id, username: input.oauthUser.username, - providerDisplayName: input.oauthUser.globalName, + providerDisplayName: input.oauthUser.displayName, avatarUrl: input.oauthUser.avatarUrl, oauthVerifiedAt: input.verifiedAt, lastSyncedAt: input.verifiedAt, @@ -92,7 +92,7 @@ const persistVerification: VerificationDependencies['persistVerification'] = asy authAccountId: input.linkedAccount.id, providerAccountId: input.oauthUser.id, username: input.oauthUser.username, - providerDisplayName: input.oauthUser.globalName, + providerDisplayName: input.oauthUser.displayName, avatarUrl: input.oauthUser.avatarUrl, oauthVerifiedAt: input.verifiedAt, lastSyncedAt: input.verifiedAt, @@ -222,7 +222,7 @@ export const createVerifyDiscordIdentityService = ( const [linkedAccount] = linkedAccounts; let accessToken: string; - let oauthUser: DiscordOAuthUser; + let oauthUser: CommunityAccountProfile; try { ({ accessToken } = await authApi.getAccessToken({ diff --git a/community/src/lib/community/discord/main.ts b/community/src/lib/community/discord/main.ts index ba894ad..9d0806e 100644 --- a/community/src/lib/community/discord/main.ts +++ b/community/src/lib/community/discord/main.ts @@ -1,5 +1,5 @@ import type { CommunityProvider } from '../interface'; -import type { DiscordGuildMembership, DiscordMessage, DiscordReactionUser, Role, SendMessageInput, SendMessageResult } from '../type'; +import type { CommunityMembership, CommunityMessage, CommunityReactionUser, CommunityRole, SendMessageInput, SendMessageResult } from '../type'; import { CommunityProviderError } from '../error'; import { getGuildMembershipAPI, listUserRolesAPI } from './role'; import { getMessageAPI, listMessageReactionUsersAPI, sendMessageAPI } from './message'; @@ -31,11 +31,11 @@ export class DiscordProvider implements CommunityProvider { return response.json(); } - async listUserRoles(userId: string): Promise { + async listUserRoles(userId: string): Promise { return listUserRolesAPI(this, userId); } - async getGuildMembership(userId: string): Promise { + async getGuildMembership(userId: string): Promise { return getGuildMembershipAPI(this, userId); } @@ -43,11 +43,11 @@ export class DiscordProvider implements CommunityProvider { return sendMessageAPI(this, input); } - async getMessage(channelId: string, messageId: string): Promise { + async getMessage(channelId: string, messageId: string): Promise { return getMessageAPI(this, channelId, messageId); } - async listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise { + async listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise { return listMessageReactionUsersAPI(this, channelId, messageId, emoji); } } diff --git a/community/src/lib/community/discord/message.test.ts b/community/src/lib/community/discord/message.test.ts index 4e5aa9a..0a8cd30 100644 --- a/community/src/lib/community/discord/message.test.ts +++ b/community/src/lib/community/discord/message.test.ts @@ -119,7 +119,7 @@ test('listMessageReactionUsersAPI fetches and parses users who reacted to a mess assert.deepEqual(users, [{ id: '623456789012345678', username: 'taro', - globalName: 'Taro', + displayName: 'Taro', bot: false, }]); }); diff --git a/community/src/lib/community/discord/message.ts b/community/src/lib/community/discord/message.ts index d1c6f5a..d831c19 100644 --- a/community/src/lib/community/discord/message.ts +++ b/community/src/lib/community/discord/message.ts @@ -1,29 +1,32 @@ import type { DiscordProvider } from './main'; import { CommunityProviderError } from '../error'; +import type { + CommunityMessage, + CommunityReactionUser, + SendMessageInput, + SendMessageResult +} from '../type'; +import { SendMessageResultSchema } from '../type'; import { - DiscordMessage, + DISCORD_MESSAGE_CONTENT_LIMIT, DiscordMessageSchema, - DiscordReactionUser, DiscordReactionUserSchema, - SendMessageInput, - SendMessageInputSchema, - SendMessageResult, - SendMessageResultSchema -} from '../type'; + DiscordSendMessageInputSchema +} from './schema'; // チャンネルへメッセージを送信し、送信したメッセージIDを返す export async function sendMessageAPI( provider: DiscordProvider, input: SendMessageInput ): Promise { - const parsedInput = SendMessageInputSchema.parse(input); + const parsedInput = DiscordSendMessageInputSchema.parse(input); const roleIds = parsedInput.mentionRoleIds ?? []; // メンション対象のロールをメッセージ本文に埋め込む(<@&ロールID>) const mentions = roleIds.map((id) => `<@&${id}>`).join(' '); const content = mentions ? `${mentions} ${parsedInput.content}` : parsedInput.content; - if (content.length > 2000) { + if (content.length > DISCORD_MESSAGE_CONTENT_LIMIT) { throw new CommunityProviderError('Discord message content is too long', 400, 'discord'); } @@ -46,7 +49,7 @@ export async function getMessageAPI( provider: DiscordProvider, channelId: string, messageId: string -): Promise { +): Promise { const message = await provider.request( 'GET', `/channels/${channelId}/messages/${messageId}` @@ -60,7 +63,7 @@ export async function getMessageAPI( author: { id: message.author.id, username: message.author.username, - globalName: message.author.global_name ?? null, + displayName: message.author.global_name ?? null, bot: message.author.bot ?? false, }, reactions: (message.reactions ?? []).map((reaction: any) => ({ @@ -75,8 +78,8 @@ export async function listMessageReactionUsersAPI( channelId: string, messageId: string, emoji: string -): Promise { - const users: DiscordReactionUser[] = []; +): Promise { + const users: CommunityReactionUser[] = []; let after: string | undefined; while (true) { @@ -92,7 +95,7 @@ export async function listMessageReactionUsersAPI( DiscordReactionUserSchema.parse({ id: user.id, username: user.username, - globalName: user.global_name ?? null, + displayName: user.global_name ?? null, bot: user.bot ?? false, }) ); diff --git a/community/src/lib/community/discord/oauth.test.ts b/community/src/lib/community/discord/oauth.test.ts index 28070ab..2a7fe75 100644 --- a/community/src/lib/community/discord/oauth.test.ts +++ b/community/src/lib/community/discord/oauth.test.ts @@ -26,7 +26,7 @@ test('getCurrentDiscordUser verifies the bearer token and maps the Discord profi assert.deepEqual(user, { id: '123456789012345678', username: 'club-member', - globalName: 'Club Member', + displayName: 'Club Member', avatarUrl: 'https://cdn.discordapp.com/avatars/123456789012345678/avatar-hash.png', }); }); diff --git a/community/src/lib/community/discord/oauth.ts b/community/src/lib/community/discord/oauth.ts index b920937..d97f81a 100644 --- a/community/src/lib/community/discord/oauth.ts +++ b/community/src/lib/community/discord/oauth.ts @@ -1,5 +1,6 @@ import { CommunityProviderError } from '../error'; -import { DiscordOAuthUser, DiscordOAuthUserSchema } from '../type'; +import type { CommunityAccountProfile } from '../type'; +import { DiscordAccountProfileSchema } from './schema'; const DISCORD_API_BASE = 'https://discord.com/api/v10'; @@ -22,7 +23,7 @@ const buildDiscordAvatarUrl = (profile: DiscordOAuthProfile): string | null => { export const getCurrentDiscordUser = async ( accessToken: string, fetcher: typeof fetch = fetch, -): Promise => { +): Promise => { const response = await fetcher(`${DISCORD_API_BASE}/users/@me`, { headers: { Authorization: `Bearer ${accessToken}`, @@ -41,10 +42,10 @@ export const getCurrentDiscordUser = async ( } const profile = await response.json(); - const parsed = DiscordOAuthUserSchema.safeParse({ + const parsed = DiscordAccountProfileSchema.safeParse({ id: profile.id, username: profile.username, - globalName: profile.global_name ?? null, + displayName: profile.global_name ?? null, avatarUrl: buildDiscordAvatarUrl(profile), }); diff --git a/community/src/lib/community/discord/role.ts b/community/src/lib/community/discord/role.ts index 27894a5..acfbcde 100644 --- a/community/src/lib/community/discord/role.ts +++ b/community/src/lib/community/discord/role.ts @@ -1,11 +1,10 @@ import type { DiscordProvider } from './main'; +import type { CommunityMembership, CommunityRole } from '../type'; import { - DiscordGuildMembership, - DiscordGuildMembershipSchema, + DiscordMembershipSchema, + DiscordRoleSchema, DiscordSnowflakeSchema, - Role, - RoleSchema, -} from '../type'; +} from './schema'; import { CommunityProviderError } from '../error'; type DiscordGuildMemberResponse = { @@ -22,7 +21,7 @@ type DiscordRoleResponse = { export async function getGuildMembershipAPI( provider: DiscordProvider, userId: string, -): Promise { +): Promise { const expectedUserId = DiscordSnowflakeSchema.parse(userId); const member = await provider.request( 'GET', @@ -45,12 +44,12 @@ export async function getGuildMembershipAPI( ); const roleMap = new Map( allRoles.map((role) => { - const parsed = RoleSchema.parse(role); + const parsed = DiscordRoleSchema.parse(role); return [parsed.id, parsed] as const; }), ); - return DiscordGuildMembershipSchema.parse({ + return DiscordMembershipSchema.parse({ userId: expectedUserId, nickname: member.nick ?? null, roles: memberRoleIds.flatMap((roleId) => { @@ -60,6 +59,6 @@ export async function getGuildMembershipAPI( }); } -export async function listUserRolesAPI(provider: DiscordProvider, userId: string): Promise { +export async function listUserRolesAPI(provider: DiscordProvider, userId: string): Promise { return (await getGuildMembershipAPI(provider, userId)).roles; } diff --git a/community/src/lib/community/discord/schema.ts b/community/src/lib/community/discord/schema.ts new file mode 100644 index 0000000..95da60c --- /dev/null +++ b/community/src/lib/community/discord/schema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { + CommunityAccountProfileSchema, + CommunityMembershipSchema, + CommunityMessageAuthorSchema, + CommunityMessageSchema, + CommunityReactionUserSchema, + CommunityRoleSchema, + SendMessageInputSchema, +} from '../type'; + +// Discord refinements of the neutral port types. Only this adapter knows that +// Discord identifiers are snowflakes, so the format is asserted here rather +// than in the port every other provider would also have to satisfy. +export const DiscordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); + +export const DiscordRoleSchema = CommunityRoleSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +export const DiscordAccountProfileSchema = CommunityAccountProfileSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +export const DiscordMembershipSchema = CommunityMembershipSchema.extend({ + userId: DiscordSnowflakeSchema, + roles: z.array(DiscordRoleSchema), +}); + +export const DiscordSendMessageInputSchema = SendMessageInputSchema.extend({ + channelId: DiscordSnowflakeSchema, + mentionRoleIds: z.array(DiscordSnowflakeSchema).max(100).optional(), +}); + +export const DiscordMessageAuthorSchema = CommunityMessageAuthorSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +export const DiscordMessageSchema = CommunityMessageSchema.extend({ + id: DiscordSnowflakeSchema, + channelId: DiscordSnowflakeSchema, + author: DiscordMessageAuthorSchema, +}); + +export const DiscordReactionUserSchema = CommunityReactionUserSchema.extend({ + id: DiscordSnowflakeSchema, +}); + +// Discord rejects a message body longer than this once mentions are prepended. +export const DISCORD_MESSAGE_CONTENT_LIMIT = 2000; diff --git a/community/src/lib/community/interface.ts b/community/src/lib/community/interface.ts index ab4c7e5..8e1f0b3 100644 --- a/community/src/lib/community/interface.ts +++ b/community/src/lib/community/interface.ts @@ -1,24 +1,26 @@ -import { z } from 'zod'; import { - DiscordGuildMembership, - DiscordMessage, - DiscordReactionUser, - Role, + CommunityMembership, + CommunityMessage, + CommunityReactionUser, + CommunityRole, SendMessageInput, SendMessageResult } from './type'; // --- Interface Definition --- +// The port every community provider implements. It speaks only the neutral +// types from ./type, so replacing the Discord adapter does not reach into the +// callers that depend on this interface. export interface CommunityProvider { // role - listUserRoles(userId: string): Promise; - getGuildMembership(userId: string): Promise; + listUserRoles(userId: string): Promise; + getGuildMembership(userId: string): Promise; // message sendMessage(input: SendMessageInput): Promise; - getMessage(channelId: string, messageId: string): Promise; - listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise; + getMessage(channelId: string, messageId: string): Promise; + listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise; } diff --git a/community/src/lib/community/type.ts b/community/src/lib/community/type.ts index 159f68b..e68e2e2 100644 --- a/community/src/lib/community/type.ts +++ b/community/src/lib/community/type.ts @@ -1,28 +1,38 @@ import { z } from 'zod'; -export const DiscordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); +// This module is the community port's vocabulary. It must stay free of any one +// provider's shapes so a provider can be replaced without touching callers. +// Provider-specific formats and limits belong in that provider's adapter — see +// discord/schema.ts for the Discord refinements of these types. + +// Identifiers are opaque here. Discord issues snowflakes, another provider will +// not, so only the adapter that issues an ID may constrain its format. +export const CommunityIdSchema = z.string().min(1); // --- role schemas --- -export const RoleSchema = z.object({ - id: DiscordSnowflakeSchema, +export const CommunityRoleSchema = z.object({ + id: CommunityIdSchema, name: z.string(), }); -export type Role = z.infer; +export type CommunityRole = z.infer; -export const DiscordOAuthUserSchema = z.object({ - id: DiscordSnowflakeSchema, +// The profile of a linked community account, as returned by the provider's +// account authorization flow. +export const CommunityAccountProfileSchema = z.object({ + id: CommunityIdSchema, username: z.string().min(1), - globalName: z.string().nullable(), + displayName: z.string().nullable(), avatarUrl: z.string().url().nullable(), }); -export type DiscordOAuthUser = z.infer; +export type CommunityAccountProfile = z.infer; -export const DiscordGuildMembershipSchema = z.object({ - userId: DiscordSnowflakeSchema, +// Evidence that an account belongs to a specific community. +export const CommunityMembershipSchema = z.object({ + userId: CommunityIdSchema, nickname: z.string().nullable(), - roles: z.array(RoleSchema), + roles: z.array(CommunityRoleSchema), }); -export type DiscordGuildMembership = z.infer; +export type CommunityMembership = z.infer; export const GetUserRolesInputSchema = z.object({ userId: z.string(), @@ -32,10 +42,12 @@ export type GetUserRolesInput = z.infer; // --- message schemas --- // イベント通知メッセージの送信に使う入出力定義 export const SendMessageInputSchema = z.object({ - channelId: DiscordSnowflakeSchema, - content: z.string().min(1).max(2000), + channelId: CommunityIdSchema, + // Per-provider length limits are enforced by the adapter, which is the only + // layer that knows what its API accepts. + content: z.string().min(1), // メンションするロールID一覧(省略可) - mentionRoleIds: z.array(DiscordSnowflakeSchema).max(100).optional(), + mentionRoleIds: z.array(CommunityIdSchema).max(100).optional(), }); export type SendMessageInput = z.infer; @@ -44,34 +56,34 @@ export const SendMessageResultSchema = z.object({ }); export type SendMessageResult = z.infer; -export const DiscordMessageAuthorSchema = z.object({ - id: DiscordSnowflakeSchema, +export const CommunityMessageAuthorSchema = z.object({ + id: CommunityIdSchema, username: z.string(), - globalName: z.string().nullable(), + displayName: z.string().nullable(), bot: z.boolean(), }); -export type DiscordMessageAuthor = z.infer; +export type CommunityMessageAuthor = z.infer; -export const DiscordMessageReactionSchema = z.object({ +export const CommunityMessageReactionSchema = z.object({ emoji: z.string(), count: z.number(), }); -export type DiscordMessageReaction = z.infer; +export type CommunityMessageReaction = z.infer; -export const DiscordMessageSchema = z.object({ - id: DiscordSnowflakeSchema, - channelId: DiscordSnowflakeSchema, +export const CommunityMessageSchema = z.object({ + id: CommunityIdSchema, + channelId: CommunityIdSchema, content: z.string(), createdAt: z.string(), - author: DiscordMessageAuthorSchema, - reactions: z.array(DiscordMessageReactionSchema), + author: CommunityMessageAuthorSchema, + reactions: z.array(CommunityMessageReactionSchema), }); -export type DiscordMessage = z.infer; +export type CommunityMessage = z.infer; -export const DiscordReactionUserSchema = z.object({ - id: DiscordSnowflakeSchema, +export const CommunityReactionUserSchema = z.object({ + id: CommunityIdSchema, username: z.string(), - globalName: z.string().nullable(), + displayName: z.string().nullable(), bot: z.boolean(), }); -export type DiscordReactionUser = z.infer; +export type CommunityReactionUser = z.infer; From f2650c9bc0b786efd4d1c46ca8faa4aeb4ec1803 Mon Sep 17 00:00:00 2001 From: yufoxda Date: Mon, 27 Jul 2026 15:33:49 +0900 Subject: [PATCH 2/2] fix(message): stop repeating private member fields in reaction badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reaction summary returned the complete member record — student ID, student email, emergency contact, insurance and allergy details — inside every reaction's user list as well as in `members`. A member who reacted with three emoji had those fields serialised four times, so the private data on the wire grew with the number of reactions rather than the number of members. The badges only ever rendered names: the client maps that list through getDisplayName and reads nothing else from it. They now carry a ReactionParticipant with the identity and name fields, while `members` keeps the full record the admin table and its CSV export need, including the emergency contact and allergy details an organiser relies on. Adds a regression test asserting no private field appears in the badge payload. Co-Authored-By: Claude Opus 4.8 --- .../src/api_v0/message/reactions.test.ts | 68 ++++++++----------- community/src/api_v0/message/reactions.ts | 21 +++++- community/src/api_v0/message/schema.ts | 13 +++- .../src/app/event/[id]/detailUtils.test.ts | 43 +----------- frontend/src/app/event/[id]/detailUtils.ts | 12 +++- 5 files changed, 72 insertions(+), 85 deletions(-) diff --git a/community/src/api_v0/message/reactions.test.ts b/community/src/api_v0/message/reactions.test.ts index 7130d47..f32a7bf 100644 --- a/community/src/api_v0/message/reactions.test.ts +++ b/community/src/api_v0/message/reactions.test.ts @@ -98,58 +98,46 @@ test('buildMessageReactionSummary includes reaction users, linked personal infor assert.equal(summary.eventMessage.id, 'event-1'); assert.equal(summary.discordMessage.id, '223456789012345678'); assert.equal(summary.reactions.length, 2); + // A reaction badge only needs names. The private member fields must not be + // repeated here for every emoji the member reacted with. assert.deepEqual(summary.reactions[0].users[0], { discordUserId: '423456789012345678', discordUsername: 'taro', discordGlobalName: 'Taro', - userId: 'user-1', - userName: 'taro-account', - displayName: '太郎', - email: 'taro-account@example.com', - memberId: 'member-1', memberName: '山田 太郎', - memberStatus: 'active', - displayGrade: 'B2', - studentId: 'S001', - studentEmail: 'taro@example.edu', - emergencyContact: '090-0000-0000', - insurance: true, - someAllergy: false, - allergyDetails: null, - skills: ['TypeScript'], - interests: ['Robotics'], - currentActivities: 'Robot controller', - bio: 'Embedded developer', - discordNickname: 'たろう', - discordRoles: ['Member', 'Developer'], - reactions: ['✅'], + displayName: '太郎', }); assert.deepEqual(summary.reactions[0].users[1], { discordUserId: '523456789012345678', discordUsername: 'hanako', discordGlobalName: null, - userId: null, - userName: null, - displayName: null, - email: null, - memberId: null, memberName: null, - memberStatus: null, - displayGrade: null, - studentId: null, - studentEmail: null, - emergencyContact: null, - insurance: null, - someAllergy: null, - allergyDetails: null, - skills: [], - interests: [], - currentActivities: null, - bio: null, - discordNickname: null, - discordRoles: [], - reactions: ['✅'], + displayName: null, }); assert.deepEqual(summary.members.find(member => member.discordUserId === '423456789012345678')?.reactions, ['✅', '🍱']); assert.equal(summary.members.length, 2); + + // The member list stays complete: it is what the admin table and its CSV + // export read, including the private fields the organiser needs. + const taro = summary.members.find(member => member.discordUserId === '423456789012345678'); + assert.equal(taro?.emergencyContact, '090-0000-0000'); + assert.equal(taro?.studentId, 'S001'); +}); + +test('private member fields are never repeated inside the reaction badges', () => { + const summary = buildMessageReactionSummary( + eventMessage, + discordMessage, + reactionUsersByEmoji, + linkedUsers, + ); + + const serialisedBadges = JSON.stringify(summary.reactions); + for (const secret of ['090-0000-0000', 'S001', 'taro@example.edu', 'taro-account@example.com']) { + assert.equal( + serialisedBadges.includes(secret), + false, + `reaction badges must not carry ${secret}`, + ); + } }); diff --git a/community/src/api_v0/message/reactions.ts b/community/src/api_v0/message/reactions.ts index b3f1f37..34f5707 100644 --- a/community/src/api_v0/message/reactions.ts +++ b/community/src/api_v0/message/reactions.ts @@ -41,6 +41,17 @@ export type ReactionUsersByEmoji = { users: CommunityReactionUser[]; }; +// What a reaction badge renders. The full record — including the private +// member fields — is returned once per member in `members`, so it is not +// repeated here for every emoji the same member reacted with. +export type ReactionParticipant = { + discordUserId: string; + discordUsername: string; + discordGlobalName: string | null; + memberName: string | null; + displayName: string | null; +}; + export type ReactionMember = { discordUserId: string; discordUsername: string; @@ -125,10 +136,14 @@ export const buildMessageReactionSummary = ( }); } - return { - ...reactionMember, - reactions: [...reactionMember.reactions], + const participant: ReactionParticipant = { + discordUserId: reactionMember.discordUserId, + discordUsername: reactionMember.discordUsername, + discordGlobalName: reactionMember.discordGlobalName, + memberName: reactionMember.memberName, + displayName: reactionMember.displayName, }; + return participant; }); return { diff --git a/community/src/api_v0/message/schema.ts b/community/src/api_v0/message/schema.ts index f9f9e26..558465a 100644 --- a/community/src/api_v0/message/schema.ts +++ b/community/src/api_v0/message/schema.ts @@ -66,12 +66,23 @@ export const reactionMemberSchema = z.object({ reactions: z.array(z.string()), }).openapi("ReactionMember") +// A reaction badge renders names only. Keeping this separate from +// reactionMemberSchema stops the private member fields from being serialised +// once per member per emoji; they are sent once in `members` instead. +export const reactionParticipantSchema = z.object({ + discordUserId: discordSnowflakeSchema, + discordUsername: z.string(), + discordGlobalName: z.string().nullable(), + memberName: z.string().nullable(), + displayName: z.string().nullable(), +}).openapi("ReactionParticipant") + export const messageReactionSummarySchema = z.object({ eventMessage: eventMessageSchema, reactions: z.array(z.object({ emoji: z.string(), count: z.number(), - users: z.array(reactionMemberSchema), + users: z.array(reactionParticipantSchema), })), members: z.array(reactionMemberSchema), }).openapi("MessageReactionSummary") diff --git a/frontend/src/app/event/[id]/detailUtils.test.ts b/frontend/src/app/event/[id]/detailUtils.test.ts index 4c46d23..8080468 100644 --- a/frontend/src/app/event/[id]/detailUtils.test.ts +++ b/frontend/src/app/event/[id]/detailUtils.test.ts @@ -16,58 +16,21 @@ const summary: EventReactionSummary = { { emoji: '✅', count: 2, + // Badges carry names only; the private fields arrive once in `members`. users: [ { discordUserId: '323456789012345678', discordUsername: 'taro', discordGlobalName: 'Taro', - userId: 'user-1', - userName: 'taro-account', - displayName: '太郎', - email: 'account@example.com', - memberId: 'member-1', memberName: '山田 太郎', - memberStatus: 'active', - displayGrade: 'B2', - studentId: 'S001', - studentEmail: 'taro@example.edu', - emergencyContact: '090-0000-0000', - insurance: true, - someAllergy: false, - allergyDetails: null, - skills: ['TypeScript'], - interests: ['Robotics'], - currentActivities: 'Robot controller', - bio: 'Embedded developer', - discordNickname: 'たろう', - discordRoles: ['Member', 'Developer'], - reactions: ['✅'], + displayName: '太郎', }, { discordUserId: '423456789012345678', discordUsername: 'hanako', discordGlobalName: null, - userId: null, - userName: null, - displayName: null, - email: null, - memberId: null, memberName: null, - memberStatus: null, - displayGrade: null, - studentId: null, - studentEmail: null, - emergencyContact: null, - insurance: null, - someAllergy: null, - allergyDetails: null, - skills: [], - interests: [], - currentActivities: null, - bio: null, - discordNickname: null, - discordRoles: [], - reactions: ['✅'], + displayName: null, }, ], }, diff --git a/frontend/src/app/event/[id]/detailUtils.ts b/frontend/src/app/event/[id]/detailUtils.ts index abe15a4..4007162 100644 --- a/frontend/src/app/event/[id]/detailUtils.ts +++ b/frontend/src/app/event/[id]/detailUtils.ts @@ -13,11 +13,21 @@ export type EventReactionSummary = { reactions: Array<{ emoji: string; count: number; - users: Array; + users: Array; }>; members: Array; }; +// A reaction badge only shows names, so the API sends just these. The private +// member fields arrive once per member in `members`. +export type ReactionParticipant = { + discordUserId: string; + discordUsername: string; + discordGlobalName: string | null; + memberName: string | null; + displayName: string | null; +}; + export type ReactionSummaryMember = { discordUserId: string; discordUsername: string;