From 30a148dfa36f90c1ee736a91822d7d1211657d24 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 15 Nov 2025 14:34:50 -0600 Subject: [PATCH 1/6] Implement batch name resolution for emails --- src/api/functions/uin.ts | 80 ++++++ src/api/routes/user.ts | 40 ++- src/common/types/user.ts | 15 ++ src/ui/App.tsx | 5 +- src/ui/components/NameOptionalCard/index.tsx | 241 ++++++++++++++++++ src/ui/pages/apiKeys/ManageKeys.page.tsx | 2 +- src/ui/pages/apiKeys/ManageKeysTable.tsx | 7 +- src/ui/pages/iam/GroupMemberManagement.tsx | 17 +- src/ui/pages/logs/LogRenderer.tsx | 11 +- .../ExternalMemberListManagement.tsx | 10 +- .../roomRequest/ViewRoomRequest.page.tsx | 14 +- src/ui/pages/stripe/CurrentLinks.tsx | 5 +- src/ui/pages/tickets/ViewTickets.page.tsx | 37 ++- 13 files changed, 439 insertions(+), 45 deletions(-) create mode 100644 src/ui/components/NameOptionalCard/index.tsx diff --git a/src/api/functions/uin.ts b/src/api/functions/uin.ts index 251fd22d..b3130bcc 100644 --- a/src/api/functions/uin.ts +++ b/src/api/functions/uin.ts @@ -1,10 +1,13 @@ import { + BatchGetItemCommand, DynamoDBClient, PutItemCommand, QueryCommand, UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; import { marshall, unmarshall } from "@aws-sdk/util-dynamodb"; +import { ValidLoggers } from "api/types.js"; +import { retryDynamoTransactionWithBackoff } from "api/utils.js"; import { argon2id, hash } from "argon2"; import { genericConfig } from "common/config.js"; import { @@ -235,3 +238,80 @@ export async function getUserIdByUin({ const data = unmarshall(response.Items[0]) as { id: string }; return data; } + +export async function batchGetUserInfo({ + emails, + dynamoClient, + logger, +}: { + emails: string[]; + dynamoClient: DynamoDBClient; + logger: ValidLoggers; +}) { + const results: Record< + string, + { + firstName?: string; + lastName?: string; + } + > = {}; + + // DynamoDB BatchGetItem has a limit of 100 items per request + const BATCH_SIZE = 100; + + for (let i = 0; i < emails.length; i += BATCH_SIZE) { + const batch = emails.slice(i, i + BATCH_SIZE); + + try { + await retryDynamoTransactionWithBackoff( + async () => { + const response = await dynamoClient.send( + new BatchGetItemCommand({ + RequestItems: { + [genericConfig.UserInfoTable]: { + Keys: batch.map((email) => ({ + id: { S: email }, + })), + ProjectionExpression: "id, firstName, lastName", + }, + }, + }), + ); + + // Process responses + const items = response.Responses?.[genericConfig.UserInfoTable] || []; + for (const item of items) { + const email = item.id?.S; + if (email) { + results[email] = { + firstName: item.firstName?.S, + lastName: item.lastName?.S, + }; + } + } + + // If there are unprocessed keys, throw to trigger retry + if ( + response.UnprocessedKeys && + Object.keys(response.UnprocessedKeys).length > 0 + ) { + const error = new Error( + "UnprocessedKeys present - triggering retry", + ); + error.name = "TransactionCanceledException"; + throw error; + } + }, + logger, + `batchGetUserInfo (batch ${i / BATCH_SIZE + 1})`, + ); + } catch (error) { + logger.warn( + `Failed to fetch batch ${i / BATCH_SIZE + 1} after retries, returning partial results`, + { error }, + ); + } + } + + return results; +} diff --git a/src/api/routes/user.ts b/src/api/routes/user.ts index b14a3b29..e15abd23 100644 --- a/src/api/routes/user.ts +++ b/src/api/routes/user.ts @@ -9,10 +9,16 @@ import { } from "common/errors/index.js"; import * as z from "zod/v4"; import { + batchResolveUserInfoRequest, + batchResolveUserInfoResponse, searchUserByUinRequest, searchUserByUinResponse, } from "common/types/user.js"; -import { getUinHash, getUserIdByUin } from "api/functions/uin.js"; +import { + batchGetUserInfo, + getUinHash, + getUserIdByUin, +} from "api/functions/uin.js"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { QueryCommand } from "@aws-sdk/client-dynamodb"; import { genericConfig } from "common/config.js"; @@ -58,6 +64,38 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => { ); }, ); + fastify.withTypeProvider().post( + "/batchResolveInfo", + { + schema: withRoles( + [], + withTags(["Generic"], { + summary: "Resolve user emails to user info.", + body: batchResolveUserInfoRequest, + response: { + 200: { + description: "The search was performed.", + content: { + "application/json": { + schema: batchResolveUserInfoResponse, + }, + }, + }, + }, + }), + ), + onRequest: fastify.authorizeFromSchema, + }, + async (request, reply) => { + return reply.send( + await batchGetUserInfo({ + dynamoClient: fastify.dynamoClient, + emails: request.body.emails, + logger: request.log, + }), + ); + }, + ); }; export default userRoute; diff --git a/src/common/types/user.ts b/src/common/types/user.ts index baee5293..f66b240a 100644 --- a/src/common/types/user.ts +++ b/src/common/types/user.ts @@ -8,3 +8,18 @@ export const searchUserByUinRequest = z.object({ export const searchUserByUinResponse = z.object({ email: z.email(), }); + +export const batchResolveUserInfoRequest = z.object({ + emails: z.array(z.email()).min(1) +}) + + +export const batchResolveUserInfoResponse = z.object({ +}).catchall( + z.object({ + firstName: z.string().optional(), + lastName: z.string().optional() + }) +); + +export type BatchResolveUserInfoResponse = z.infer; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5f2253e7..d04e6395 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import { Notifications } from "@mantine/notifications"; import ColorSchemeContext from "./ColorSchemeContext"; import { Router } from "./Router"; +import { UserResolverProvider } from "./components/NameOptionalCard"; export default function App() { const preferredColorScheme = useColorScheme(); @@ -25,7 +26,9 @@ export default function App() { forceColorScheme={colorScheme} > - + + + ); diff --git a/src/ui/components/NameOptionalCard/index.tsx b/src/ui/components/NameOptionalCard/index.tsx new file mode 100644 index 00000000..63443449 --- /dev/null +++ b/src/ui/components/NameOptionalCard/index.tsx @@ -0,0 +1,241 @@ +import { + createContext, + useContext, + useEffect, + useState, + useRef, + ReactNode, +} from "react"; +import { Avatar, Group, Text, Skeleton, Badge } from "@mantine/core"; +import { useApi } from "@ui/util/api"; +import { BatchResolveUserInfoResponse } from "@common/types/user"; +import { useAuth } from "../AuthContext"; + +// Types +interface UserData { + email: string; + name?: string; +} + +const AVATAR_SIZES = { + xs: 16, + sm: 26, + md: 38, + lg: 56, + xl: 84, +} as const; + +// Basic email validation regex +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +interface UserResolverContextType { + resolveUser: (email: string) => UserData | undefined; + requestUser: (email: string) => void; + isResolving: (email: string) => boolean; +} + +// Context +const UserResolverContext = createContext(null); + +// Provider Props +interface UserResolverProviderProps { + children: ReactNode; + batchDelay?: number; +} + +interface UserDataResponse { + name?: string; +} + +// Sentinel value to indicate we've checked and there's no name +const NO_NAME_FOUND = Symbol("NO_NAME_FOUND"); + +export function UserResolverProvider({ + children, + batchDelay = 50, +}: UserResolverProviderProps) { + const api = useApi("core"); + const [userCache, setUserCache] = useState< + Record + >({}); + const pendingRequests = useRef>(new Set()); + const batchTimeout = useRef(null); + + const fetchUsers = async (emailsToFetch: string[]) => { + const response = await api.post( + "/api/v1/users/batchResolveInfo", + { + emails: emailsToFetch, + }, + ); + + const emailToName: Record = {}; + for (const email of emailsToFetch) { + const userData = response.data[email]; + if (userData?.firstName || userData?.lastName) { + const nameParts = [userData.firstName, userData.lastName].filter( + Boolean, + ); + emailToName[email] = nameParts.join(" "); + } else { + emailToName[email] = NO_NAME_FOUND; + } + } + + return emailToName; + }; + + const executeBatch = async () => { + if (pendingRequests.current.size === 0) { + return; + } + + const emailsToFetch = Array.from(pendingRequests.current); + pendingRequests.current.clear(); + + try { + const results = await fetchUsers(emailsToFetch); + setUserCache((prev) => ({ ...prev, ...results })); + } catch (error) { + console.error("Failed to fetch users:", error); + const failedCache: Record = {}; + emailsToFetch.forEach((email) => { + failedCache[email] = NO_NAME_FOUND; + }); + setUserCache((prev) => ({ ...prev, ...failedCache })); + } + }; + + const requestUser = (email: string) => { + // Skip if already cached (including NO_NAME_FOUND sentinel) + if (email in userCache) { + return; + } + + // Validate email format - if invalid, mark as NO_NAME_FOUND immediately + if (!EMAIL_REGEX.test(email)) { + setUserCache((prev) => ({ ...prev, [email]: NO_NAME_FOUND })); + return; + } + + pendingRequests.current.add(email); + + // Clear existing timeout and set new one + if (batchTimeout.current) { + clearTimeout(batchTimeout.current); + } + + batchTimeout.current = setTimeout(() => { + executeBatch(); + }, batchDelay); + }; + + const isResolving = (email: string): boolean => { + return !(email in userCache); + }; + + const resolveUser = (email: string): UserData | undefined => { + const cached = userCache[email]; + if (!cached || cached === NO_NAME_FOUND) { + return undefined; + } + return { email, name: cached }; + }; + + return ( + + {children} + + ); +} + +// Hook +function useUserResolver() { + const context = useContext(UserResolverContext); + if (!context) { + throw new Error("useUserResolver must be used within UserResolverProvider"); + } + return context; +} + +// Component Props +interface NameOptionalUserCardProps { + email: string; + name?: string; + size?: "xs" | "sm" | "md" | "lg" | "xl"; + fallback?: (email: string) => ReactNode; +} + +// Component +export function NameOptionalUserCard({ + name: providedName, + email, + size = "sm", + fallback, +}: NameOptionalUserCardProps) { + const { resolveUser, requestUser, isResolving } = useUserResolver(); + const [resolvedUser, setResolvedUser] = useState(); + const { userData } = useAuth(); + + // Check if this is actually an email + const isValidEmail = EMAIL_REGEX.test(email); + + useEffect(() => { + // If name is already provided or not a valid email, don't resolve + if (providedName || !isValidEmail) { + return; + } + + // Request the user (will be batched) + requestUser(email); + + // Set up polling to check if user has been resolved + const interval = setInterval(() => { + const user = resolveUser(email); + if (user) { + setResolvedUser(user); + clearInterval(interval); + } + }, 10); + + return () => clearInterval(interval); + }, [email, providedName, isValidEmail, resolveUser, requestUser]); + + // If not a valid email, render fallback or default text + if (!isValidEmail) { + return fallback ? <>{fallback(email)} : {email}; + } + + const displayName = providedName || resolvedUser?.name || email; + const isLoading = !providedName && isResolving(email); + + const isCurrentUser = !!userData && userData.email === email; + + return ( + + {isLoading ? ( + + ) : ( + + )} +
+ + + {isLoading ? : displayName} + + + {isCurrentUser && !isLoading && ( + + You + + )} + + + {isLoading ? : email} + +
+
+ ); +} diff --git a/src/ui/pages/apiKeys/ManageKeys.page.tsx b/src/ui/pages/apiKeys/ManageKeys.page.tsx index 80b62a51..60def845 100644 --- a/src/ui/pages/apiKeys/ManageKeys.page.tsx +++ b/src/ui/pages/apiKeys/ManageKeys.page.tsx @@ -16,7 +16,7 @@ export const ManageApiKeysPage: React.FC = () => { }} showSidebar > - + API Keys Manage organization API keys. diff --git a/src/ui/pages/apiKeys/ManageKeysTable.tsx b/src/ui/pages/apiKeys/ManageKeysTable.tsx index f8762aef..3c7e0e49 100644 --- a/src/ui/pages/apiKeys/ManageKeysTable.tsx +++ b/src/ui/pages/apiKeys/ManageKeysTable.tsx @@ -38,6 +38,7 @@ import { AppRoles } from "@common/roles"; import { BlurredTextDisplay } from "../../components/BlurredTextDisplay"; import * as z from "zod/v4"; import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; const HumanFriendlyDate = ({ date }: { date: number }) => { return ( @@ -208,11 +209,7 @@ export const OrgApiKeyTable: React.FC = ({ { key: "owner", label: "Owner", - render: (key) => ( - - {key.owner === userData?.email ? "You" : key.owner} - - ), + render: (key) => , }, { key: "created", diff --git a/src/ui/pages/iam/GroupMemberManagement.tsx b/src/ui/pages/iam/GroupMemberManagement.tsx index 005c223e..8ad04e8b 100644 --- a/src/ui/pages/iam/GroupMemberManagement.tsx +++ b/src/ui/pages/iam/GroupMemberManagement.tsx @@ -22,6 +22,7 @@ import { import { notifications } from "@mantine/notifications"; import { GroupMemberGetResponse, EntraActionResponse } from "@common/types/iam"; import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; interface GroupMemberManagementProps { fetchMembers: () => Promise; @@ -188,21 +189,7 @@ const GroupMemberManagement: React.FC = ({ label: "Member", isPrimaryColumn: true, render: (member) => ( - - -
- - {member.name} - - - {member.email} - -
-
+ ), }, { diff --git a/src/ui/pages/logs/LogRenderer.tsx b/src/ui/pages/logs/LogRenderer.tsx index 0753f105..abf1a3fd 100644 --- a/src/ui/pages/logs/LogRenderer.tsx +++ b/src/ui/pages/logs/LogRenderer.tsx @@ -24,6 +24,7 @@ import { import { Modules, ModulesToHumanName } from "@common/modules"; import { notifications } from "@mantine/notifications"; import { ResponsiveTable, Column } from "@ui/components/ResponsiveTable"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; interface LogEntry { actor: string; @@ -211,7 +212,7 @@ export const LogRenderer: React.FC = ({ getLogs }) => { { key: "actor", label: "Actor", - render: (log) => {log.actor}, + render: (log) => , }, { key: "action", @@ -234,9 +235,11 @@ export const LogRenderer: React.FC = ({ getLogs }) => { render: (log) => ( {selectedModule === Modules.AUDIT_LOG && - Object.values(Modules).includes(log.target as Modules) - ? ModulesToHumanName[log.target as Modules] - : log.target} + Object.values(Modules).includes(log.target as Modules) ? ( + ModulesToHumanName[log.target as Modules] + ) : ( + + )} ), }, diff --git a/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx b/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx index 2054e308..4a1046a8 100644 --- a/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx +++ b/src/ui/pages/membershipLists/ExternalMemberListManagement.tsx @@ -23,6 +23,7 @@ import { illinoisNetId } from "@common/types/generic"; import { AuthGuard } from "@ui/components/AuthGuard"; import { AppRoles } from "@common/roles"; import pluralize from "pluralize"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; interface ExternalMemberListManagementProps { fetchMembers: (listId: string) => Promise; @@ -327,12 +328,7 @@ const ExternalMemberListManagement: React.FC< return ( - - - - {member} - - + {statusBadge} {actionButton} @@ -379,7 +375,7 @@ const ExternalMemberListManagement: React.FC< - Member NetID + Member Status Actions diff --git a/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx b/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx index d0f20917..9ebec954 100644 --- a/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx +++ b/src/ui/pages/roomRequest/ViewRoomRequest.page.tsx @@ -49,6 +49,7 @@ import { downloadFromS3PresignedUrl, uploadToS3PresignedUrl, } from "@ui/util/s3"; +import { NameOptionalUserCard } from "@ui/components/NameOptionalCard"; export const ViewRoomRequest: React.FC = () => { const { semesterId, requestId } = useParams(); @@ -446,17 +447,19 @@ export const ViewRoomRequest: React.FC = () => { <> {data.updates.map((x) => ( {formatStatus(x.status)}} > - {x.createdBy && {x.createdBy}} + {x.createdBy && ( + + )} {x.notes && ( - + {x.notes} )} @@ -464,6 +467,7 @@ export const ViewRoomRequest: React.FC = () => {