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
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