Skip to content
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
34 changes: 27 additions & 7 deletions community/src/api_v0/message/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
listMessagesRoute
} from "./schema"
import {
appAccounts,
communityIdentities,
communityMemberships,
eventMessages,
Expand Down Expand Up @@ -141,14 +142,15 @@ export const getMessageReactionsService: RouteHandler<typeof getMessageReactions

const discordUserIds = collectDiscordUserIds(reactionUsersByEmoji);

const linkedUsers: LinkedReactionUser[] = discordUserIds.length > 0
// Everything the domain knows about the members who reacted. The
// authentication subject is already recorded on the identity row, so
// this query stays inside the domain schema.
const domainRows = discordUserIds.length > 0
? await db.transaction((tx) => tx
.select({
discordUserId: communityIdentities.providerAccountId,
userId: authUsers.id,
userName: authUsers.name,
email: authUsers.email,
memberId: authUsers.memberId,
userId: communityIdentities.userId,
memberId: appAccounts.memberId,
memberName: members.name,
memberStatus: members.memberStatus,
displayName: memberDirectoryProfiles.displayName,
Expand All @@ -167,8 +169,8 @@ export const getMessageReactionsService: RouteHandler<typeof getMessageReactions
discordRoles: communityMemberships.roleNames,
})
.from(communityIdentities)
.innerJoin(authUsers, eq(communityIdentities.userId, authUsers.id))
.leftJoin(members, eq(authUsers.memberId, members.memberId))
.leftJoin(appAccounts, eq(communityIdentities.userId, appAccounts.userId))
.leftJoin(members, eq(appAccounts.memberId, members.memberId))
.leftJoin(grades, eq(members.grade, grades.id))
.leftJoin(memberDirectoryProfiles, eq(members.memberId, memberDirectoryProfiles.memberId))
.leftJoin(communityMemberships, and(
Expand All @@ -181,6 +183,24 @@ export const getMessageReactionsService: RouteHandler<typeof getMessageReactions
)))
: [];

// The account name and email belong to the authentication store. This is
// a separate query rather than a join, so it becomes a remote call if
// that store moves to its own database.
const subjectIds = domainRows.map((row) => row.userId);
const subjects = subjectIds.length > 0
? await db.transaction((tx) => tx
.select({ id: authUsers.id, name: authUsers.name, email: authUsers.email })
.from(authUsers)
.where(inArray(authUsers.id, subjectIds)))
: [];
const subjectById = new Map(subjects.map((subject) => [subject.id, subject]));

const linkedUsers: LinkedReactionUser[] = domainRows.map((row) => ({
...row,
userName: subjectById.get(row.userId)?.name ?? "",
email: subjectById.get(row.userId)?.email ?? "",
}));

const summary = buildMessageReactionSummary(
eventMessage,
discordMessage,
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
9 changes: 5 additions & 4 deletions community/src/api_v0/user/me/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { getUserMeRoute, updateUserMeRoute } from "./schema";
import { eq } from "drizzle-orm";
import { user } from "../../../../../share/drizzle/schema";

// The authentication store owns these. Membership and role are domain facts and
// come from the request's already-resolved account, so this service never
// reaches across the boundary.
const currentUserSelection = {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
memberId: user.memberId,
role: user.role,
};

const setPrivateNoStore = (c: Context<AppContext>) => {
Expand All @@ -36,7 +37,7 @@ export const getUserMeService: RouteHandler<typeof getUserMeRoute, AppContext> =
return c.json({ error: "User not found in database" }, 404);
}

return c.json(dbUser, 200);
return c.json({ ...dbUser, memberId: appUser.memberId, role: appUser.role }, 200);
};

export const updateUserMeService: RouteHandler<typeof updateUserMeRoute, AppContext> = async (c) => {
Expand All @@ -58,5 +59,5 @@ export const updateUserMeService: RouteHandler<typeof updateUserMeRoute, AppCont
return c.json({ error: "User not found in database" }, 404);
}

return c.json(updatedUser, 200);
return c.json({ ...updatedUser, memberId: appUser.memberId, role: appUser.role }, 200);
};
Loading
Loading