Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 34 additions & 46 deletions community/src/api_v0/message/reactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -19,15 +19,15 @@ const eventMessage: EventMessageRecord = {
updatedAt: '2026-07-01T00:00:00Z',
};

const discordMessage: DiscordMessage = {
const discordMessage: CommunityMessage = {
id: eventMessage.messageId,
channelId: eventMessage.channelId,
content: eventMessage.content,
createdAt: eventMessage.createdAt,
author: {
id: '323456789012345678',
username: 'bot',
globalName: null,
displayName: null,
bot: true,
},
reactions: [
Expand All @@ -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 },
],
},
];
Expand Down Expand Up @@ -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}`,
);
}
});
29 changes: 22 additions & 7 deletions community/src/api_v0/message/reactions.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -38,7 +38,18 @@ export type LinkedReactionUser = {
export type ReactionUsersByEmoji = {
emoji: string;
count: number;
users: DiscordReactionUser[];
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 = {
Expand Down Expand Up @@ -76,7 +87,7 @@ export const collectDiscordUserIds = (reactionUsersByEmoji: ReactionUsersByEmoji

export const buildMessageReactionSummary = (
eventMessage: EventMessageRecord,
discordMessage: DiscordMessage,
discordMessage: CommunityMessage,
reactionUsersByEmoji: ReactionUsersByEmoji[],
linkedUsers: LinkedReactionUser[],
) => {
Expand All @@ -91,7 +102,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,
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 12 additions & 1 deletion community/src/api_v0/message/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions community/src/api_v0/user/me/identity/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const linkedAccount = {
const oauthUser = {
id: linkedAccount.accountId,
username: 'test-user',
globalName: 'Test User',
displayName: 'Test User',
avatarUrl: null,
};
const guildMembership = {
Expand All @@ -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: {
Expand Down
16 changes: 8 additions & 8 deletions community/src/api_v0/user/me/identity/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
};

Expand All @@ -43,7 +43,7 @@ type IdentityAuthApi = {

type VerificationDependencies = {
getAuthApi(c: Context<AppContext>): IdentityAuthApi;
getCurrentDiscordUser(accessToken: string): Promise<DiscordOAuthUser>;
getCurrentDiscordUser(accessToken: string): Promise<CommunityAccountProfile>;
findLinkedDiscordAccounts(
c: Context<AppContext>,
userId: string,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -222,7 +222,7 @@ export const createVerifyDiscordIdentityService = (

const [linkedAccount] = linkedAccounts;
let accessToken: string;
let oauthUser: DiscordOAuthUser;
let oauthUser: CommunityAccountProfile;

try {
({ accessToken } = await authApi.getAccessToken({
Expand Down
10 changes: 5 additions & 5 deletions community/src/lib/community/discord/main.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -31,23 +31,23 @@ export class DiscordProvider implements CommunityProvider {
return response.json();
}

async listUserRoles(userId: string): Promise<Role[]> {
async listUserRoles(userId: string): Promise<CommunityRole[]> {
return listUserRolesAPI(this, userId);
}

async getGuildMembership(userId: string): Promise<DiscordGuildMembership> {
async getGuildMembership(userId: string): Promise<CommunityMembership> {
return getGuildMembershipAPI(this, userId);
}

async sendMessage(input: SendMessageInput): Promise<SendMessageResult> {
return sendMessageAPI(this, input);
}

async getMessage(channelId: string, messageId: string): Promise<DiscordMessage> {
async getMessage(channelId: string, messageId: string): Promise<CommunityMessage> {
return getMessageAPI(this, channelId, messageId);
}

async listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise<DiscordReactionUser[]> {
async listMessageReactionUsers(channelId: string, messageId: string, emoji: string): Promise<CommunityReactionUser[]> {
return listMessageReactionUsersAPI(this, channelId, messageId, emoji);
}
}
2 changes: 1 addition & 1 deletion community/src/lib/community/discord/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}]);
});
Expand Down
Loading
Loading