Skip to content

Commit a30528e

Browse files
Implement batch name resolution for emails (#418)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Batch email resolution with server-side batching, retries, and graceful partial results; app-wide provider enables caching and fallbacks for display names. * New user-resolution card used across tables, logs, membership lists, room requests, and links for consistent avatar/name display. * Click-to-copy ticket ID via purchaser email cell with user notifications; minor UI spacing and timeline sizing tweaks. * **Tests** * Added live and e2e test updates covering batch user-resolution and adjusted render expectations. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent f076739 commit a30528e

File tree

21 files changed

+572
-271
lines changed

21 files changed

+572
-271
lines changed

src/api/functions/uin.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import {
2+
BatchGetItemCommand,
23
DynamoDBClient,
34
PutItemCommand,
45
QueryCommand,
56
UpdateItemCommand,
67
} from "@aws-sdk/client-dynamodb";
78
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
9+
import { ValidLoggers } from "api/types.js";
10+
import { retryDynamoTransactionWithBackoff } from "api/utils.js";
811
import { argon2id, hash } from "argon2";
912
import { genericConfig } from "common/config.js";
1013
import {
@@ -235,3 +238,80 @@ export async function getUserIdByUin({
235238
const data = unmarshall(response.Items[0]) as { id: string };
236239
return data;
237240
}
241+
242+
export async function batchGetUserInfo({
243+
emails,
244+
dynamoClient,
245+
logger,
246+
}: {
247+
emails: string[];
248+
dynamoClient: DynamoDBClient;
249+
logger: ValidLoggers;
250+
}) {
251+
const results: Record<
252+
string,
253+
{
254+
firstName?: string;
255+
lastName?: string;
256+
}
257+
> = {};
258+
259+
// DynamoDB BatchGetItem has a limit of 100 items per request
260+
const BATCH_SIZE = 100;
261+
262+
for (let i = 0; i < emails.length; i += BATCH_SIZE) {
263+
const batch = emails.slice(i, i + BATCH_SIZE);
264+
265+
try {
266+
await retryDynamoTransactionWithBackoff(
267+
async () => {
268+
const response = await dynamoClient.send(
269+
new BatchGetItemCommand({
270+
RequestItems: {
271+
[genericConfig.UserInfoTable]: {
272+
Keys: batch.map((email) => ({
273+
id: { S: email },
274+
})),
275+
ProjectionExpression: "id, firstName, lastName",
276+
},
277+
},
278+
}),
279+
);
280+
281+
// Process responses
282+
const items = response.Responses?.[genericConfig.UserInfoTable] || [];
283+
for (const item of items) {
284+
const email = item.id?.S;
285+
if (email) {
286+
results[email] = {
287+
firstName: item.firstName?.S,
288+
lastName: item.lastName?.S,
289+
};
290+
}
291+
}
292+
293+
// If there are unprocessed keys, throw to trigger retry
294+
if (
295+
response.UnprocessedKeys &&
296+
Object.keys(response.UnprocessedKeys).length > 0
297+
) {
298+
const error = new Error(
299+
"UnprocessedKeys present - triggering retry",
300+
);
301+
error.name = "TransactionCanceledException";
302+
throw error;
303+
}
304+
},
305+
logger,
306+
`batchGetUserInfo (batch ${i / BATCH_SIZE + 1})`,
307+
);
308+
} catch (error) {
309+
logger.warn(
310+
`Failed to fetch batch ${i / BATCH_SIZE + 1} after retries, returning partial results`,
311+
{ error },
312+
);
313+
}
314+
}
315+
316+
return results;
317+
}

src/api/routes/user.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ import {
99
} from "common/errors/index.js";
1010
import * as z from "zod/v4";
1111
import {
12+
batchResolveUserInfoRequest,
13+
batchResolveUserInfoResponse,
1214
searchUserByUinRequest,
1315
searchUserByUinResponse,
1416
} from "common/types/user.js";
15-
import { getUinHash, getUserIdByUin } from "api/functions/uin.js";
17+
import {
18+
batchGetUserInfo,
19+
getUinHash,
20+
getUserIdByUin,
21+
} from "api/functions/uin.js";
1622
import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi";
1723
import { QueryCommand } from "@aws-sdk/client-dynamodb";
1824
import { genericConfig } from "common/config.js";
@@ -58,6 +64,38 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => {
5864
);
5965
},
6066
);
67+
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().post(
68+
"/batchResolveInfo",
69+
{
70+
schema: withRoles(
71+
[],
72+
withTags(["Generic"], {
73+
summary: "Resolve user emails to user info.",
74+
body: batchResolveUserInfoRequest,
75+
response: {
76+
200: {
77+
description: "The search was performed.",
78+
content: {
79+
"application/json": {
80+
schema: batchResolveUserInfoResponse,
81+
},
82+
},
83+
},
84+
},
85+
}),
86+
),
87+
onRequest: fastify.authorizeFromSchema,
88+
},
89+
async (request, reply) => {
90+
return reply.send(
91+
await batchGetUserInfo({
92+
dynamoClient: fastify.dynamoClient,
93+
emails: request.body.emails,
94+
logger: request.log,
95+
}),
96+
);
97+
},
98+
);
6199
};
62100

63101
export default userRoute;

src/common/types/user.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,18 @@ export const searchUserByUinRequest = z.object({
88
export const searchUserByUinResponse = z.object({
99
email: z.email(),
1010
});
11+
12+
export const batchResolveUserInfoRequest = z.object({
13+
emails: z.array(z.email()).min(1)
14+
})
15+
16+
17+
export const batchResolveUserInfoResponse = z.object({
18+
}).catchall(
19+
z.object({
20+
firstName: z.string().optional(),
21+
lastName: z.string().optional()
22+
})
23+
);
24+
25+
export type BatchResolveUserInfoResponse = z.infer<typeof batchResolveUserInfoResponse>;

src/ui/App.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Notifications } from "@mantine/notifications";
88

99
import ColorSchemeContext from "./ColorSchemeContext";
1010
import { Router } from "./Router";
11+
import { UserResolverProvider } from "./components/NameOptionalCard";
1112

1213
export default function App() {
1314
const preferredColorScheme = useColorScheme();
@@ -25,7 +26,9 @@ export default function App() {
2526
forceColorScheme={colorScheme}
2627
>
2728
<Notifications position="top-right" />
28-
<Router />
29+
<UserResolverProvider>
30+
<Router />
31+
</UserResolverProvider>
2932
</MantineProvider>
3033
</ColorSchemeContext.Provider>
3134
);

0 commit comments

Comments
 (0)