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: 80 additions & 0 deletions src/api/functions/uin.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
40 changes: 39 additions & 1 deletion src/api/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -58,6 +64,38 @@ const userRoute: FastifyPluginAsync = async (fastify, _options) => {
);
},
);
fastify.withTypeProvider<FastifyZodOpenApiTypeProvider>().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;
15 changes: 15 additions & 0 deletions src/common/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof batchResolveUserInfoResponse>;
5 changes: 4 additions & 1 deletion src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Notifications } from "@mantine/notifications";

import ColorSchemeContext from "./ColorSchemeContext";
import { Router } from "./Router";
import { UserResolverProvider } from "./components/NameOptionalCard";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix the ESLint import/extensions violation.

The import statement is missing a file extension. While this may work with your current build configuration, it violates the ESLint rule and could cause issues in some environments.

Based on coding guidelines.

Apply this diff to add the file extension:

-import { UserResolverProvider } from "./components/NameOptionalCard";
+import { UserResolverProvider } from "./components/NameOptionalCard/index";

Alternatively, if there's an index.ts or index.tsx file that re-exports from the module, you could be more explicit:

-import { UserResolverProvider } from "./components/NameOptionalCard";
+import { UserResolverProvider } from "./components/NameOptionalCard/index.tsx";

Committable suggestion skipped: line range outside the PR's diff.

🧰 Tools
🪛 ESLint

[error] 11-11: Missing file extension for "./components/NameOptionalCard"

(import/extensions)

🤖 Prompt for AI Agents
In src/ui/App.tsx around line 11, the import for UserResolverProvider lacks a
file extension which violates the ESLint import/extensions rule; update the
import to include the explicit file extension (e.g. import from
"./components/NameOptionalCard.tsx") or, if you intend to import from a barrel
index, point to that file explicitly (e.g.
"./components/NameOptionalCard/index.ts" or ".tsx") so the resolver and linter
both accept it.


export default function App() {
const preferredColorScheme = useColorScheme();
Expand All @@ -25,7 +26,9 @@ export default function App() {
forceColorScheme={colorScheme}
>
<Notifications position="top-right" />
<Router />
<UserResolverProvider>
<Router />
</UserResolverProvider>
</MantineProvider>
</ColorSchemeContext.Provider>
);
Expand Down
Loading
Loading