From e89b3017195f00756dc6bca24c06511ab242e3cb Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Thu, 20 Mar 2025 11:58:44 -0700 Subject: [PATCH 1/5] starter --- src/api/routes/siglead.ts | 466 +++++++++++++++++++ src/ui/pages/siglead/SigScreenComponents.tsx | 6 - 2 files changed, 466 insertions(+), 6 deletions(-) create mode 100644 src/api/routes/siglead.ts diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts new file mode 100644 index 00000000..a898b086 --- /dev/null +++ b/src/api/routes/siglead.ts @@ -0,0 +1,466 @@ +import { FastifyPluginAsync } from "fastify"; +import { allAppRoles, AppRoles } from "../../common/roles.js"; +import { zodToJsonSchema } from "zod-to-json-schema"; +import { + addToTenant, + getEntraIdToken, + listGroupMembers, + modifyGroup, + patchUserProfile, +} from "../functions/entraId.js"; +import { + BaseError, + DatabaseFetchError, + DatabaseInsertError, + EntraGroupError, + EntraInvitationError, + InternalServerError, + NotFoundError, + UnauthorizedError, +} from "../../common/errors/index.js"; +import { PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { genericConfig } from "../../common/config.js"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { + InviteUserPostRequest, + invitePostRequestSchema, + GroupMappingCreatePostRequest, + groupMappingCreatePostSchema, + entraActionResponseSchema, + groupModificationPatchSchema, + GroupModificationPatchRequest, + EntraGroupActions, + entraGroupMembershipListResponse, + ProfilePatchRequest, + entraProfilePatchRequest, +} from "../../common/types/iam.js"; +import { + AUTH_DECISION_CACHE_SECONDS, + getGroupRoles, +} from "../functions/authorization.js"; + +const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { + fastify.get<{ + Querystring: { groupId: string }; + }>( + "/groups/:groupId/roles", + { + schema: { + querystring: { + type: "object", + properties: { + groupId: { + type: "string", + }, + }, + }, + }, + onRequest: async (request, reply) => { + await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + }, + }, + async (request, reply) => { + try { + const groupId = (request.params as Record).groupId; + const roles = await getGroupRoles( + fastify.dynamoClient, + fastify, + groupId, + ); + return reply.send(roles); + } catch (e: unknown) { + if (e instanceof BaseError) { + throw e; + } + + request.log.error(e); + throw new DatabaseFetchError({ + message: "An error occurred finding the group role mapping.", + }); + } + }, + ); + + // fastify.patch<{ Body: ProfilePatchRequest }>( + // "/profile", + // { + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody(request, reply, entraProfilePatchRequest); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, allAppRoles); + // }, + // }, + // async (request, reply) => { + // if (!request.tokenPayload || !request.username) { + // throw new UnauthorizedError({ + // message: "User does not have the privileges for this task.", + // }); + // } + // const userOid = request.tokenPayload["oid"]; + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // await patchUserProfile( + // entraIdToken, + // request.username, + // userOid, + // request.body, + // ); + // reply.send(201); + // }, + // ); + // fastify.get<{ + // Body: undefined; + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId/roles", + // { + // schema: { + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // try { + // const groupId = (request.params as Record).groupId; + // const roles = await getGroupRoles( + // fastify.dynamoClient, + // fastify, + // groupId, + // ); + // return reply.send(roles); + // } catch (e: unknown) { + // if (e instanceof BaseError) { + // throw e; + // } + + // request.log.error(e); + // throw new DatabaseFetchError({ + // message: "An error occurred finding the group role mapping.", + // }); + // } + // }, + // ); + // fastify.post<{ + // Body: GroupMappingCreatePostRequest; + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId/roles", + // { + // schema: { + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody( + // request, + // reply, + // groupMappingCreatePostSchema, + // ); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // const groupId = (request.params as Record).groupId; + // try { + // const timestamp = new Date().toISOString(); + // const command = new PutItemCommand({ + // TableName: `${genericConfig.IAMTablePrefix}-grouproles`, + // Item: marshall({ + // groupUuid: groupId, + // roles: request.body.roles, + // createdAt: timestamp, + // }), + // }); + // await fastify.dynamoClient.send(command); + // fastify.nodeCache.set( + // `grouproles-${groupId}`, + // request.body.roles, + // AUTH_DECISION_CACHE_SECONDS, + // ); + // } catch (e: unknown) { + // fastify.nodeCache.del(`grouproles-${groupId}`); + // if (e instanceof BaseError) { + // throw e; + // } + + // request.log.error(e); + // throw new DatabaseInsertError({ + // message: "Could not create group role mapping.", + // }); + // } + // reply.send({ message: "OK" }); + // request.log.info( + // { type: "audit", actor: request.username, target: groupId }, + // `set target roles to ${request.body.roles.toString()}`, + // ); + // }, + // ); + // fastify.post<{ Body: InviteUserPostRequest }>( + // "/inviteUsers", + // { + // schema: { + // response: { 202: zodToJsonSchema(entraActionResponseSchema) }, + // }, + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody(request, reply, invitePostRequestSchema); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_INVITE_ONLY]); + // }, + // }, + // async (request, reply) => { + // const emails = request.body.emails; + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // if (!entraIdToken) { + // throw new InternalServerError({ + // message: "Could not get Entra ID token to perform task.", + // }); + // } + // const response: Record[]> = { + // success: [], + // failure: [], + // }; + // const results = await Promise.allSettled( + // emails.map((email) => addToTenant(entraIdToken, email)), + // ); + // for (let i = 0; i < results.length; i++) { + // const result = results[i]; + // if (result.status === "fulfilled") { + // request.log.info( + // { type: "audit", actor: request.username, target: emails[i] }, + // "invited user to Entra ID tenant.", + // ); + // response.success.push({ email: emails[i] }); + // } else { + // request.log.info( + // { type: "audit", actor: request.username, target: emails[i] }, + // "failed to invite user to Entra ID tenant.", + // ); + // if (result.reason instanceof EntraInvitationError) { + // response.failure.push({ + // email: emails[i], + // message: result.reason.message, + // }); + // } else { + // response.failure.push({ + // email: emails[i], + // message: "An unknown error occurred.", + // }); + // } + // } + // } + // reply.status(202).send(response); + // }, + // ); + // fastify.patch<{ + // Body: GroupModificationPatchRequest; + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId", + // { + // schema: { + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // preValidation: async (request, reply) => { + // await fastify.zodValidateBody( + // request, + // reply, + // groupModificationPatchSchema, + // ); + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // const groupId = (request.params as Record).groupId; + // if (!groupId || groupId === "") { + // throw new NotFoundError({ + // endpointName: request.url, + // }); + // } + // if (genericConfig.ProtectedEntraIDGroups.includes(groupId)) { + // throw new EntraGroupError({ + // code: 403, + // message: + // "This group is protected and cannot be modified by this service. You must log into Entra ID directly to modify this group.", + // group: groupId, + // }); + // } + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // const addResults = await Promise.allSettled( + // request.body.add.map((email) => + // modifyGroup(entraIdToken, email, groupId, EntraGroupActions.ADD), + // ), + // ); + // const removeResults = await Promise.allSettled( + // request.body.remove.map((email) => + // modifyGroup(entraIdToken, email, groupId, EntraGroupActions.REMOVE), + // ), + // ); + // const response: Record[]> = { + // success: [], + // failure: [], + // }; + // for (let i = 0; i < addResults.length; i++) { + // const result = addResults[i]; + // if (result.status === "fulfilled") { + // response.success.push({ email: request.body.add[i] }); + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.add[i], + // }, + // `added target to group ID ${groupId}`, + // ); + // } else { + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.add[i], + // }, + // `failed to add target to group ID ${groupId}`, + // ); + // if (result.reason instanceof EntraGroupError) { + // response.failure.push({ + // email: request.body.add[i], + // message: result.reason.message, + // }); + // } else { + // response.failure.push({ + // email: request.body.add[i], + // message: "An unknown error occurred.", + // }); + // } + // } + // } + // for (let i = 0; i < removeResults.length; i++) { + // const result = removeResults[i]; + // if (result.status === "fulfilled") { + // response.success.push({ email: request.body.remove[i] }); + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.remove[i], + // }, + // `removed target from group ID ${groupId}`, + // ); + // } else { + // request.log.info( + // { + // type: "audit", + // actor: request.username, + // target: request.body.add[i], + // }, + // `failed to remove target from group ID ${groupId}`, + // ); + // if (result.reason instanceof EntraGroupError) { + // response.failure.push({ + // email: request.body.add[i], + // message: result.reason.message, + // }); + // } else { + // response.failure.push({ + // email: request.body.add[i], + // message: "An unknown error occurred.", + // }); + // } + // } + // } + // reply.status(202).send(response); + // }, + // ); + // fastify.get<{ + // Querystring: { groupId: string }; + // }>( + // "/groups/:groupId", + // { + // schema: { + // response: { 200: zodToJsonSchema(entraGroupMembershipListResponse) }, + // querystring: { + // type: "object", + // properties: { + // groupId: { + // type: "string", + // }, + // }, + // }, + // }, + // onRequest: async (request, reply) => { + // await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); + // }, + // }, + // async (request, reply) => { + // const groupId = (request.params as Record).groupId; + // if (!groupId || groupId === "") { + // throw new NotFoundError({ + // endpointName: request.url, + // }); + // } + // if (genericConfig.ProtectedEntraIDGroups.includes(groupId)) { + // throw new EntraGroupError({ + // code: 403, + // message: + // "This group is protected and cannot be read by this service. You must log into Entra ID directly to read this group.", + // group: groupId, + // }); + // } + // const entraIdToken = await getEntraIdToken( + // { + // smClient: fastify.secretsManagerClient, + // dynamoClient: fastify.dynamoClient, + // }, + // fastify.environmentConfig.AadValidClientId, + // ); + // const response = await listGroupMembers(entraIdToken, groupId); + // reply.status(200).send(response); + // }, + // ); +}; + +export default sigleadRoutes; diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 2e4e9488..4d102357 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -33,12 +33,6 @@ const renderSigLink = (org: string, index: number) => { label={org} variant="filled" active={index % 2 === 0} - // color="blue" - // style={{ - // // color: "lightgray", - // backgroundColor: "DodgerBlue", - // opacity: 0.5 - // }} rightSection={
MemberCount[{index}] From e251c67a3d1887a0a9ff1ce4df44d9dcc1e0cca1 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Fri, 28 Mar 2025 15:48:03 -0500 Subject: [PATCH 2/5] stash pop merge conflicts --- src/api/functions/entraId.ts | 2 +- src/api/index.ts | 2 ++ src/api/routes/siglead.ts | 70 +++++++++++++++++------------------- src/common/roles.ts | 21 ++++++----- 4 files changed, 47 insertions(+), 48 deletions(-) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index 44fbe6bf..81a3be97 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -366,7 +366,7 @@ export async function listGroupMembers( * @throws {EntraUserError} If fetching the user profile fails. * @returns {Promise} The user's profile information. */ -export async function getUserProfile( +export async function getUserProflile( token: string, email: string, ): Promise { diff --git a/src/api/index.ts b/src/api/index.ts index 53678935..774c23be 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,6 +26,7 @@ import mobileWalletRoute from "./routes/mobileWallet.js"; import stripeRoutes from "./routes/stripe.js"; import membershipPlugin from "./routes/membership.js"; import path from "path"; // eslint-disable-line import/no-nodejs-modules +import sigleadRoutes from "./routes/siglead.js"; dotenv.config(); @@ -133,6 +134,7 @@ async function init(prettyPrint: boolean = false) { api.register(ticketsPlugin, { prefix: "/tickets" }); api.register(mobileWalletRoute, { prefix: "/mobileWallet" }); api.register(stripeRoutes, { prefix: "/stripe" }); + api.register(sigleadRoutes, { prefix: "/siglead" }); if (app.runEnvironment === "dev") { api.register(vendingPlugin, { prefix: "/vending" }); } diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index a898b086..0d2b07be 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -1,4 +1,4 @@ -import { FastifyPluginAsync } from "fastify"; +import { FastifyInstance, FastifyPluginAsync } from "fastify"; import { allAppRoles, AppRoles } from "../../common/roles.js"; import { zodToJsonSchema } from "zod-to-json-schema"; import { @@ -38,48 +38,42 @@ import { AUTH_DECISION_CACHE_SECONDS, getGroupRoles, } from "../functions/authorization.js"; +import { OrganizationList } from "common/orgs.js"; +import { z } from "zod"; + +const OrganizationListEnum = z.enum(OrganizationList as [string, ...string[]]); +export type Org = z.infer; + +type Member = { name: string; email: string }; +type OrgMembersResponse = { org: Org; members: Member[] }; + +// const groupMappings = getRunEnvironmentConfig().KnownGroupMappings; +// const groupOptions = Object.entries(groupMappings).map(([key, value]) => ({ +// label: userGroupMappings[key as keyof KnownGroups] || key, +// value: `${key}_${value}`, // to ensure that the same group for multiple roles still renders +// })); const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { fastify.get<{ - Querystring: { groupId: string }; - }>( - "/groups/:groupId/roles", - { - schema: { - querystring: { - type: "object", - properties: { - groupId: { - type: "string", - }, - }, - }, + Reply: OrgMembersResponse[]; + }>("/groups", async (request, reply) => { + const entraIdToken = await getEntraIdToken( + { + smClient: fastify.secretsManagerClient, + dynamoClient: fastify.dynamoClient, }, - onRequest: async (request, reply) => { - await fastify.authorize(request, reply, [AppRoles.IAM_ADMIN]); - }, - }, - async (request, reply) => { - try { - const groupId = (request.params as Record).groupId; - const roles = await getGroupRoles( - fastify.dynamoClient, - fastify, - groupId, - ); - return reply.send(roles); - } catch (e: unknown) { - if (e instanceof BaseError) { - throw e; - } + fastify.environmentConfig.AadValidClientId, + ); + + const data = await Promise.all( + OrganizationList.map(async (org) => { + const members: Member[] = await listGroupMembers(entraIdToken, org); + return { org, members } as OrgMembersResponse; + }), + ); - request.log.error(e); - throw new DatabaseFetchError({ - message: "An error occurred finding the group role mapping.", - }); - } - }, - ); + reply.status(200).send(data); + }); // fastify.patch<{ Body: ProfilePatchRequest }>( // "/profile", diff --git a/src/common/roles.ts b/src/common/roles.ts index a713b930..9b276b9b 100644 --- a/src/common/roles.ts +++ b/src/common/roles.ts @@ -2,15 +2,18 @@ export const runEnvironments = ["dev", "prod"] as const; export type RunEnvironment = (typeof runEnvironments)[number]; export enum AppRoles { - EVENTS_MANAGER = "manage:events", - SIGLEAD_MANAGER = "manage:siglead", - TICKETS_SCANNER = "scan:tickets", - TICKETS_MANAGER = "manage:tickets", - IAM_ADMIN = "admin:iam", - IAM_INVITE_ONLY = "invite:iam", - STRIPE_LINK_CREATOR = "create:stripeLink", - BYPASS_OBJECT_LEVEL_AUTH = "bypass:ola", + EVENTS_MANAGER = "manage:events", + SIGLEAD_MANAGER = "manage:siglead", + TICKETS_SCANNER = "scan:tickets", + TICKETS_MANAGER = "manage:tickets", + IAM_ADMIN = "admin:iam", + IAM_INVITE_ONLY = "invite:iam", + STRIPE_LINK_CREATOR = "create:stripeLink", + BYPASS_OBJECT_LEVEL_AUTH = "bypass:ola", } export const allAppRoles = Object.values(AppRoles).filter( - (value) => typeof value === "string", + (value) => typeof value === "string", ); + + + \ No newline at end of file From b93db840451ed7e7bd5ca96dc78b61d6533823a7 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sat, 24 May 2025 15:59:48 -0700 Subject: [PATCH 3/5] Merged mainscreen-api with siglead-management --- cloudformation/iam.yml | 8 + cloudformation/main.yml | 24 +++ src/api/functions/siglead.ts | 115 ++++++++++++ src/api/routes/siglead.ts | 179 +++++++++++++------ src/common/config.ts | 10 ++ src/common/orgs.ts | 2 +- src/common/types/siglead.ts | 24 +++ src/common/utils.ts | 45 +++++ src/ui/pages/siglead/ManageSigLeads.page.tsx | 60 ++++++- src/ui/pages/siglead/SigScreenComponents.tsx | 20 ++- src/ui/pages/siglead/ViewSigLead.page.tsx | 60 +++---- tests/unit/common/utils.test.ts | 145 ++++++++++++++- 12 files changed, 587 insertions(+), 105 deletions(-) create mode 100644 src/api/functions/siglead.ts create mode 100644 src/common/types/siglead.ts diff --git a/cloudformation/iam.yml b/cloudformation/iam.yml index 11b8e05a..cdc88052 100644 --- a/cloudformation/iam.yml +++ b/cloudformation/iam.yml @@ -99,6 +99,14 @@ Resources: Resource: - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache + - Sid: DynamoDBRateLimitTableAccess + Effect: Allow + Action: + - dynamodb:DescribeTable + - dynamodb:UpdateItem + Resource: + - Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter + - Sid: DynamoDBAuditLogTableAccess Effect: Allow Action: diff --git a/cloudformation/main.yml b/cloudformation/main.yml index 88326e17..69a15a79 100644 --- a/cloudformation/main.yml +++ b/cloudformation/main.yml @@ -408,6 +408,30 @@ Resources: - AttributeName: userEmail KeyType: HASH + RateLimiterTable: + Type: "AWS::DynamoDB::Table" + DeletionPolicy: "Delete" + UpdateReplacePolicy: "Delete" + Properties: + BillingMode: "PAY_PER_REQUEST" + TableName: infra-core-api-rate-limiter + DeletionProtectionEnabled: true + PointInTimeRecoverySpecification: + PointInTimeRecoveryEnabled: false + AttributeDefinitions: + - AttributeName: PK + AttributeType: S + - AttributeName: SK + AttributeType: S + KeySchema: + - AttributeName: PK + KeyType: HASH + - AttributeName: SK + KeyType: RANGE + TimeToLiveSpecification: + AttributeName: ttl + Enabled: true + EventRecordsTable: Type: "AWS::DynamoDB::Table" DeletionPolicy: "Retain" diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts new file mode 100644 index 00000000..413ca4c1 --- /dev/null +++ b/src/api/functions/siglead.ts @@ -0,0 +1,115 @@ +import { + DynamoDBClient, + QueryCommand, + ScanCommand, +} from "@aws-sdk/client-dynamodb"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { OrganizationList } from "common/orgs.js"; +import { + SigDetailRecord, + SigMemberCount, + SigMemberRecord, +} from "common/types/siglead.js"; +import { transformSigLeadToURI } from "common/utils.js"; +import { string } from "zod"; + +export async function fetchMemberRecords( + sigid: string, + tableName: string, + dynamoClient: DynamoDBClient, +) { + const fetchSigMemberRecords = new QueryCommand({ + TableName: tableName, + KeyConditionExpression: "#sigid = :accessVal", + ExpressionAttributeNames: { + "#sigid": "sigGroupId", + }, + ExpressionAttributeValues: { + ":accessVal": { S: sigid }, + }, + ScanIndexForward: false, + }); + + const result = await dynamoClient.send(fetchSigMemberRecords); + + // Process the results + return (result.Items || []).map((item) => { + const unmarshalledItem = unmarshall(item); + return unmarshalledItem as SigMemberRecord; + }); +} + +export async function fetchSigDetail( + sigid: string, + tableName: string, + dynamoClient: DynamoDBClient, +) { + const fetchSigDetail = new QueryCommand({ + TableName: tableName, + KeyConditionExpression: "#sigid = :accessVal", + ExpressionAttributeNames: { + "#sigid": "sigid", + }, + ExpressionAttributeValues: { + ":accessVal": { S: sigid }, + }, + ScanIndexForward: false, + }); + + const result = await dynamoClient.send(fetchSigDetail); + + // Process the results + return (result.Items || [{}]).map((item) => { + const unmarshalledItem = unmarshall(item); + + // Strip '#' from access field + delete unmarshalledItem.leadGroupId; + delete unmarshalledItem.memberGroupId; + + return unmarshalledItem as SigDetailRecord; + })[0]; +} + +// select count(sigid) +// from table +// groupby sigid +export async function fetchSigCounts( + sigMemberTableName: string, + dynamoClient: DynamoDBClient, +) { + const scan = new ScanCommand({ + TableName: sigMemberTableName, + ProjectionExpression: "sigGroupId", + }); + + const result = await dynamoClient.send(scan); + + const ids2Name: Record = {}; + OrganizationList.forEach((org) => { + const sigid = transformSigLeadToURI(org); + ids2Name[sigid] = org; + }); + + const counts: Record = {}; + (result.Items || []).forEach((item) => { + const sigGroupId = item.sigGroupId?.S; + if (sigGroupId) { + counts[sigGroupId] = (counts[sigGroupId] || 0) + 1; + } + }); + + const joined: Record = {}; + Object.keys(counts).forEach((sigid) => { + joined[sigid] = [ids2Name[sigid], counts[sigid]]; + }); + + const countsArray: SigMemberCount[] = Object.entries(joined).map( + ([sigid, [signame, count]]) => ({ + sigid, + signame, + count, + }), + ); + console.log(countsArray); + return countsArray; +} diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index f588398c..43353825 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -1,72 +1,137 @@ -import { FastifyInstance, FastifyPluginAsync } from "fastify"; -import { allAppRoles, AppRoles } from "../../common/roles.js"; -import { - addToTenant, - getEntraIdToken, - listGroupMembers, - modifyGroup, - patchUserProfile, -} from "../functions/entraId.js"; -import { - BaseError, - DatabaseFetchError, - DatabaseInsertError, - EntraGroupError, - EntraInvitationError, - InternalServerError, - NotFoundError, - UnauthorizedError, -} from "../../common/errors/index.js"; -import { PutItemCommand } from "@aws-sdk/client-dynamodb"; +import { FastifyPluginAsync } from "fastify"; +import { DatabaseFetchError } from "../../common/errors/index.js"; + import { genericConfig } from "../../common/config.js"; -import { marshall } from "@aws-sdk/util-dynamodb"; + import { - InviteUserPostRequest, - invitePostRequestSchema, - GroupMappingCreatePostRequest, - groupMappingCreatePostSchema, - entraActionResponseSchema, - groupModificationPatchSchema, - GroupModificationPatchRequest, - EntraGroupActions, - entraGroupMembershipListResponse, - ProfilePatchRequest, - entraProfilePatchRequest, -} from "../../common/types/iam.js"; + SigDetailRecord, + SigleadGetRequest, + SigMemberCount, + SigMemberRecord, +} from "common/types/siglead.js"; import { - AUTH_DECISION_CACHE_SECONDS, - getGroupRoles, -} from "../functions/authorization.js"; -import { OrganizationList } from "common/orgs.js"; -import { z } from "zod"; + fetchMemberRecords, + fetchSigCounts, + fetchSigDetail, +} from "api/functions/siglead.js"; +import { intersection } from "api/plugins/auth.js"; + +const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { + const limitedRoutes: FastifyPluginAsync = async (fastify) => { + /*fastify.register(rateLimiter, { + limit: 30, + duration: 60, + rateLimitIdentifier: "linkry", + });*/ -const OrganizationListEnum = z.enum(OrganizationList as [string, ...string[]]); -export type Org = z.infer; + fastify.get( + "/sigmembers/:sigid", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + const { sigid } = request.params; + const tableName = genericConfig.SigleadDynamoSigMemberTableName; -type Member = { name: string; email: string }; -type OrgMembersResponse = { org: Org; members: Member[] }; + // First try-catch: Fetch owner records + let memberRecords: SigMemberRecord[]; + try { + memberRecords = await fetchMemberRecords( + sigid, + tableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch member records: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to fetch member records from Dynamo table.", + }); + } -const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { - fastify.get<{ - Reply: OrgMembersResponse[]; - }>("/groups", async (request, reply) => { - const entraIdToken = await getEntraIdToken( + // Send the response + reply.code(200).send(memberRecords); + }, + ); + + fastify.get( + "/sigdetail/:sigid", { - smClient: fastify.secretsManagerClient, - dynamoClient: fastify.dynamoClient, + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + const { sigid } = request.params; + const tableName = genericConfig.SigleadDynamoSigDetailTableName; + + // First try-catch: Fetch owner records + let sigDetail: SigDetailRecord; + try { + sigDetail = await fetchSigDetail( + sigid, + tableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig detail record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: "Failed to fetch sig detail record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigDetail); }, - fastify.environmentConfig.AadValidClientId, ); - const data = await Promise.all( - OrganizationList.map(async (org) => { - const members: Member[] = await listGroupMembers(entraIdToken, org); - return { org, members } as OrgMembersResponse; - }), + // fetch sig count + fastify.get( + "/sigcount", + { + onRequest: async (request, reply) => { + /*await fastify.authorize(request, reply, [ + AppRoles.LINKS_MANAGER, + AppRoles.LINKS_ADMIN, + ]);*/ + }, + }, + async (request, reply) => { + // First try-catch: Fetch owner records + let sigMemCounts: SigMemberCount[]; + try { + sigMemCounts = await fetchSigCounts( + genericConfig.SigleadDynamoSigMemberTableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: + "Failed to fetch sig member counts record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigMemCounts); + }, ); + }; - reply.status(200).send(data); - }); + fastify.register(limitedRoutes); }; export default sigleadRoutes; diff --git a/src/common/config.ts b/src/common/config.ts index 14937cf5..70447d88 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -48,6 +48,10 @@ export type GenericConfigType = { EntraReadOnlySecretName: string; AuditLogTable: string; ApiKeyTable: string; + + RateLimiterDynamoTableName: string; + SigleadDynamoSigDetailTableName: string; + SigleadDynamoSigMemberTableName: string; }; type EnvironmentConfigType = { @@ -63,6 +67,8 @@ export const commChairsTestingGroupId = "d714adb7-07bb-4d4d-a40a-b035bc2a35a3"; export const commChairsGroupId = "105e7d32-7289-435e-a67a-552c7f215507"; export const miscTestingGroupId = "ff25ec56-6a33-420d-bdb0-51d8a3920e46"; +export const orgsGroupId = "0b3be7c2-748e-46ce-97e7-cf86f9ca7337"; + const genericConfig: GenericConfigType = { EventsDynamoTableName: "infra-core-api-events", StripeLinksDynamoTableName: "infra-core-api-stripe-links", @@ -86,6 +92,10 @@ const genericConfig: GenericConfigType = { RoomRequestsStatusTableName: "infra-core-api-room-requests-status", AuditLogTable: "infra-core-api-audit-log", ApiKeyTable: "infra-core-api-keys", + + RateLimiterDynamoTableName: "infra-core-api-rate-limiter", + SigleadDynamoSigDetailTableName: "infra-core-api-sig-details", + SigleadDynamoSigMemberTableName: "infra-core-api-sig-member-details", } as const; const environmentConfig: EnvironmentConfigType = { diff --git a/src/common/orgs.ts b/src/common/orgs.ts index 61d570d2..ee84d00e 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -4,7 +4,7 @@ export const SIGList = [ "GameBuilders", "SIGAIDA", "SIGGRAPH", - "ICPC", + "SIGICPC", "SIGMobile", "SIGMusic", "GLUG", diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts new file mode 100644 index 00000000..da642313 --- /dev/null +++ b/src/common/types/siglead.ts @@ -0,0 +1,24 @@ +export type SigDetailRecord = { + sigid: string; + signame: string; + description: string; +}; + +export type SigMemberRecord = { + sigGroupId: string; + email: string; + designation: string; + memberName: string; +}; + +export type SigleadGetRequest = { + Params: { sigid: string }; + Querystring: undefined; + Body: undefined; +}; + +export type SigMemberCount = { + sigid: string; + signame: string; + count: number; +}; \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 786c998f..94372580 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -12,3 +12,48 @@ export function transformCommaSeperatedName(name: string) { } return name; } + +const notUnreservedCharsRegex = /[^a-zA-Z0-9\-._~]/g; +const reservedCharsRegex = /[:\/?#\[\]@!$&'()*+,;=]/g; +/** + * Transforms an organization name (sig lead) into a URI-friendly format. + * The function performs the following transformations: + * - Removes characters that are reserved or not unreserved. + * - Adds spaces between camel case words. + * - Converts reserved characters to spaces. + * - Converts all characters to lowercase and replaces all types of whitespace with hyphens. + * - Replaces any sequence of repeated hyphens with a single hyphen. + * - Refer to RFC 3986 https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + * + * @param {string} org - The organization (sig lead) name to be transformed. + * @returns {string} - The transformed organization name, ready for use as a URL. + */ +export function transformSigLeadToURI(org: string) { + console.log(`org\t${org}`) + org = org + // change not reserved chars to spaces + .trim() + .replace(notUnreservedCharsRegex, " ") + .trim() + .replace(/\s/g, "-") + + // remove all that is reserved or not unreserved + .replace(reservedCharsRegex, "") + + // convert SIG -> sig for camel case + .replace(/SIG/g, "sig") + + // add hyphen for camel case + .replace(/([a-z])([A-Z])/g, "$1-$2") + + // lower + .toLowerCase() + + // add spaces between chars and numbers (seq2seq -> seq-2-seq) + .replace(/(?<=[a-z])([0-9]+)(?=[a-z])/g, "-$1-") + + // remove duplicate hyphens + .replace(/-{2,}/g, "-"); + + return org === "-" ? "" : org; +} \ No newline at end of file diff --git a/src/ui/pages/siglead/ManageSigLeads.page.tsx b/src/ui/pages/siglead/ManageSigLeads.page.tsx index 3555e727..87bc78f4 100644 --- a/src/ui/pages/siglead/ManageSigLeads.page.tsx +++ b/src/ui/pages/siglead/ManageSigLeads.page.tsx @@ -22,6 +22,10 @@ import { useApi } from "@ui/util/api"; import { OrganizationList as orgList } from "@common/orgs"; import { AppRoles } from "@common/roles"; import { ScreenComponent } from "./SigScreenComponents"; +import { GroupMemberGetResponse } from "@common/types/iam"; +import { transformCommaSeperatedName } from "@common/utils"; +import { orgsGroupId } from "@common/config"; +import { SigMemberCount } from "@common/types/siglead"; export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); @@ -65,6 +69,7 @@ type EventPostRequest = z.infer; export const ManageSigLeadsPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); + const [SigMemberCounts, setSigMemberCounts] = useState([]); const navigate = useNavigate(); const api = useApi("core"); @@ -107,6 +112,27 @@ export const ManageSigLeadsPage: React.FC = () => { getEvent(); }, [eventId, isEditing]); + useEffect(() => { + const getMemberCounts = async () => { + try { + console.warn("fetching counts"); + /*const formValues = { + }; + form.setValues(formValues);*/ + const sigMemberCountsRequest = await api.get( + `/api/v1/siglead/sigcount`, + ); + setSigMemberCounts(sigMemberCountsRequest.data); + } catch (error) { + console.error("Error fetching sig member counts:", error); + notifications.show({ + message: "Failed to fetch sig member counts, please try again.", + }); + } + }; + getMemberCounts(); + }, []); // empty dependency array to only run once + const form = useForm({ validate: zodResolver(requestBodySchema), initialValues: { @@ -177,13 +203,45 @@ export const ManageSigLeadsPage: React.FC = () => { } }; + const getGroupMembers = async (selectedGroup: string) => { + try { + const response = await api.get(`/api/v1/iam/groups/${selectedGroup}`); + const data = response.data as GroupMemberGetResponse; + const responseMapped = data + .map((x) => ({ + ...x, + name: transformCommaSeperatedName(x.name), + })) + .sort((a, b) => (a.name > b.name ? 1 : a.name < b.name ? -1 : 0)); + // console.log(responseMapped); + return responseMapped; + } catch (error) { + console.error("Failed to get users:", error); + return []; + } + }; + + // const TestButton: React.FC = () => { + // return ( + // + // ); + // }; + return ( SigLead Management System - + {/* */} diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 53d1c7df..0799a788 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -3,16 +3,20 @@ import { OrganizationList } from "@common/orgs"; import { NavLink, Paper } from "@mantine/core"; import { IconUsersGroup } from "@tabler/icons-react"; import { useLocation } from "react-router-dom"; +import { SigMemberCount } from "@common/types/siglead"; -const renderSigLink = (org: string, index: number) => { +const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { const color = "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; const size = "18px"; + const name = sigMemCount.signame; + const id = sigMemCount.sigid; + const count = sigMemCount.count; return ( { fontSize: `${size}`, }} > - MemberCount[{index}] + {count}
} @@ -39,7 +43,10 @@ const renderSigLink = (org: string, index: number) => { ); }; -export const ScreenComponent: React.FC = () => { +type props = { + SigMemberCounts: SigMemberCount[]; +}; +export const ScreenComponent: React.FC = ({ SigMemberCounts }) => { return ( <> { Organization Member Count - {OrganizationList.map(renderSigLink)} + {/* {OrganizationList.map(renderSigLink)} */} + {SigMemberCounts.map(renderSigLink)} ); }; diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index bc3b2c2b..d3310bd2 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -25,23 +25,7 @@ import { AuthGuard } from "@ui/components/AuthGuard"; import { getRunEnvironmentConfig } from "@ui/config"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; - -const baseSigSchema = z.object({ - sigid: z.string().min(1), - signame: z.string().min(1), - description: z.string().optional(), -}); - -const baseSigMemberSchema = z.object({ - sigGroupId: z.string().min(1), - email: z.string().email("Invalid email"), - designation: z.enum(["L", "M"]), - id: z.string().optional(), - memberName: z.string(), -}); - -type sigDetails = z.infer; -type sigMemberDetails = z.infer; +import { SigDetailRecord, SigMemberRecord } from "@common/types/siglead.js"; export const ViewSigLeadPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); @@ -49,21 +33,8 @@ export const ViewSigLeadPage: React.FC = () => { const api = useApi("core"); const { colorScheme } = useMantineColorScheme(); const { sigId } = useParams(); - const [sigMembers, setSigMembers] = useState([ - { - sigGroupId: sigId || "", - email: "alice1@illinois.edu", - designation: "L", - memberName: "Alice", - }, - { - sigGroupId: sigId || "", - email: "bob2@illinois.edu", - designation: "M", - memberName: "Bob", - }, - ]); - const [sigDetails, setSigDetails] = useState({ + const [sigMembers, setSigMembers] = useState([]); + const [sigDetails, setSigDetails] = useState({ sigid: sigId || "", signame: "Default Sig", description: @@ -71,12 +42,21 @@ export const ViewSigLeadPage: React.FC = () => { }); useEffect(() => { - // Fetch sig data and populate form / for now dummy data... + // Fetch sig data and populate form const getSig = async () => { try { /*const formValues = { }; form.setValues(formValues);*/ + const sigMemberRequest = await api.get( + `/api/v1/siglead/sigmembers/${sigId}`, + ); + setSigMembers(sigMemberRequest.data); + + const sigDetailRequest = await api.get( + `/api/v1/siglead/sigdetail/${sigId}`, + ); + setSigDetails(sigDetailRequest.data); } catch (error) { console.error("Error fetching sig data:", error); notifications.show({ @@ -87,7 +67,7 @@ export const ViewSigLeadPage: React.FC = () => { getSig(); }, [sigId]); - const renderSigMember = (members: sigMemberDetails, index: number) => { + const renderSigMember = (member: SigMemberRecord, index: number) => { const shouldShow = true; return ( { : "#ffffff", }} > - {members.memberName} - {members.email} - {members.designation} + {member.memberName} + {member.email} + {member.designation} )} @@ -175,7 +155,7 @@ export const ViewSigLeadPage: React.FC = () => { - {sigDetails.sigid} + {sigDetails.signame} {sigDetails.description || ""} @@ -202,7 +182,9 @@ export const ViewSigLeadPage: React.FC = () => { Roles - {sigMembers.map(renderSigMember)} + + {sigMembers.length > 0 ? sigMembers.map(renderSigMember) : <>} + diff --git a/tests/unit/common/utils.test.ts b/tests/unit/common/utils.test.ts index 15177175..e22d642c 100644 --- a/tests/unit/common/utils.test.ts +++ b/tests/unit/common/utils.test.ts @@ -1,5 +1,5 @@ import { expect, test, describe } from "vitest"; -import { transformCommaSeperatedName } from "../../../src/common/utils.js"; +import { transformCommaSeperatedName, transformSigLeadToURI } from "../../../src/common/utils.js"; describe("Comma-seperated name transformer tests", () => { test("Already-transformed names are returned as-is", () => { @@ -27,3 +27,146 @@ describe("Comma-seperated name transformer tests", () => { expect(output).toEqual(", Test"); }); }); + +describe("transformSigLeadToURI tests", () => { + + // Basic Functionality Tests + test("should convert simple names with spaces to lowercase hyphenated", () => { + const output = transformSigLeadToURI("SIG Network"); + expect(output).toEqual("sig-network"); + }); + + test("should convert simple names to lowercase", () => { + const output = transformSigLeadToURI("Testing"); + expect(output).toEqual("testing"); + }); + + test("should handle names already in the desired format", () => { + const output = transformSigLeadToURI("already-transformed-name"); + expect(output).toEqual("already-transformed-name"); + }); + + // Camel Case Tests + test("should add hyphens between camelCase words", () => { + const output = transformSigLeadToURI("SIGAuth"); + expect(output).toEqual("sig-auth"); + }); + + test("should handle multiple camelCase words", () => { + const output = transformSigLeadToURI("SuperCamelCaseProject"); + expect(output).toEqual("super-camel-case-project"); + }); + + test("should handle mixed camelCase and spaces", () => { + const output = transformSigLeadToURI("SIG ContribEx"); // SIG Contributor Experience + expect(output).toEqual("sig-contrib-ex"); + }); + + test("should handle camelCase starting with lowercase", () => { + const output = transformSigLeadToURI("myCamelCaseName"); + expect(output).toEqual("my-camel-case-name"); + }); + + // Reserved Character Tests (RFC 3986 gen-delims and sub-delims) + test("should convert reserved characters like & to hyphens", () => { + const output = transformSigLeadToURI("SIG Storage & Backup"); + expect(output).toEqual("sig-storage-backup"); // & -> space -> hyphen + }); + + test("should convert reserved characters like / and : to hyphens", () => { + const output = transformSigLeadToURI("Project:Alpha/Beta"); + expect(output).toEqual("project-alpha-beta"); // : -> space, / -> space, space+space -> hyphen + }); + + test("should convert reserved characters like () and + to hyphens", () => { + const output = transformSigLeadToURI("My Project (Test+Alpha)"); + expect(output).toEqual("my-project-test-alpha"); + }); + + test("should convert various reserved characters #[]@?$, to hyphens", () => { + const output = transformSigLeadToURI("Special#Chars[Test]?@Value,$"); + expect(output).toEqual("special-chars-test-value"); + }); + + // Non-Allowed Character Removal Tests + test("should remove characters not unreserved or reserved (e.g., ™, ©)", () => { + const output = transformSigLeadToURI("MyOrg™ With © Symbols"); + expect(output).toEqual("my-org-with-symbols"); + }); + + test("should remove emoji", () => { + const output = transformSigLeadToURI("Project ✨ Fun"); + expect(output).toEqual("project-fun"); + }); + + + // Whitespace and Hyphen Collapsing Tests + test("should handle multiple spaces between words", () => { + const output = transformSigLeadToURI("SIG UI Project"); + expect(output).toEqual("sig-ui-project"); + }); + + test("should handle leading/trailing whitespace", () => { + const output = transformSigLeadToURI(" Leading and Trailing "); + expect(output).toEqual("leading-and-trailing"); + }); + + test("should handle mixed whitespace (tabs, newlines)", () => { + const output = transformSigLeadToURI("Mix\tOf\nWhite Space"); + expect(output).toEqual("mix-of-white-space"); + }); + + test("should collapse multiple hyphens resulting from transformations", () => { + const output = transformSigLeadToURI("Test--Multiple / Spaces"); + expect(output).toEqual("test-multiple-spaces"); + }); + + test("should collapse hyphens from start/end after transformations", () => { + const output = transformSigLeadToURI("&Another Test!"); + expect(output).toEqual("another-test"); + }); + + // Unreserved Character Tests (RFC 3986) + test("should keep unreserved characters: hyphen, period, underscore, tilde", () => { + const output = transformSigLeadToURI("Keep.These-Chars_Okay~123"); + expect(output).toEqual("keep.these-chars_okay~123"); + }); + + test("should handle unreserved chars next to reserved chars", () => { + const output = transformSigLeadToURI("Test._~&Stuff"); + expect(output).toEqual("test._~-stuff"); + }); + + + // Edge Case Tests + test("should return an empty string for an empty input", () => { + const output = transformSigLeadToURI(""); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only spaces", () => { + const output = transformSigLeadToURI(" "); + expect(output).toEqual(""); + }); + + test("should return an empty string for input with only reserved/non-allowed chars and spaces", () => { + const output = transformSigLeadToURI(" & / # ™ © "); + expect(output).toEqual(""); + }); + + test("should handle numbers correctly", () => { + const output = transformSigLeadToURI("ProjectApollo11"); + expect(output).toEqual("project-apollo11"); // Number doesn't trigger camel case break after letter + }); + + test("should handle numbers triggering camel case break", () => { + const output = transformSigLeadToURI("Project11Apollo"); + expect(output).toEqual("project-11-apollo"); // Letter after number triggers camel case break + }); + + test("should handle names starting with lowercase", () => { + const output = transformSigLeadToURI("myOrg"); + expect(output).toEqual("my-org"); + }); + +}); From bced8dcb1e43d8a3333fa39ece5f3d8371602818 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Thu, 29 May 2025 18:07:00 -0700 Subject: [PATCH 4/5] api for dynamodb addMember done * siglead management screen fix choppy nav * moved id -> name record into commons * created add member post route * implemented backend add member to dynamodb * still need to check if member already exists and add to azure --- src/api/functions/siglead.ts | 50 +++++++++----- src/api/routes/siglead.ts | 52 +++++++++------ src/common/orgs.ts | 9 +++ src/common/types/siglead.ts | 26 +++++++- src/common/utils.ts | 12 ++++ src/ui/Router.tsx | 9 ++- src/ui/pages/siglead/SigScreenComponents.tsx | 6 +- src/ui/pages/siglead/ViewSigLead.page.tsx | 70 +++++++++++++++++++- 8 files changed, 192 insertions(+), 42 deletions(-) diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts index 413ca4c1..1e986d2c 100644 --- a/src/api/functions/siglead.ts +++ b/src/api/functions/siglead.ts @@ -1,16 +1,22 @@ import { + AttributeValue, DynamoDBClient, + PutItemCommand, + PutItemCommandInput, QueryCommand, ScanCommand, } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; -import { OrganizationList } from "common/orgs.js"; +import { OrganizationList, orgIds2Name } from "common/orgs.js"; import { + DynamoDBItem, SigDetailRecord, SigMemberCount, SigMemberRecord, + SigMemberUpdateRecord, } from "common/types/siglead.js"; import { transformSigLeadToURI } from "common/utils.js"; +import { KeyObject } from "crypto"; import { string } from "zod"; export async function fetchMemberRecords( @@ -84,13 +90,11 @@ export async function fetchSigCounts( const result = await dynamoClient.send(scan); - const ids2Name: Record = {}; - OrganizationList.forEach((org) => { - const sigid = transformSigLeadToURI(org); - ids2Name[sigid] = org; - }); - const counts: Record = {}; + // Object.entries(orgIds2Name).forEach(([id, _]) => { + // counts[id] = 0; + // }); + (result.Items || []).forEach((item) => { const sigGroupId = item.sigGroupId?.S; if (sigGroupId) { @@ -98,18 +102,32 @@ export async function fetchSigCounts( } }); - const joined: Record = {}; - Object.keys(counts).forEach((sigid) => { - joined[sigid] = [ids2Name[sigid], counts[sigid]]; - }); - - const countsArray: SigMemberCount[] = Object.entries(joined).map( - ([sigid, [signame, count]]) => ({ - sigid, - signame, + const countsArray: SigMemberCount[] = Object.entries(counts).map( + ([id, count]) => ({ + sigid: id, + signame: orgIds2Name[id], count, }), ); console.log(countsArray); return countsArray; } + +export async function addMemberToSig( + sigMemberTableName: string, + sigMemberUpdateRequest: SigMemberUpdateRecord, + dynamoClient: DynamoDBClient, +) { + const item: Record = {}; + Object.entries(sigMemberUpdateRequest).forEach(([k, v]) => { + item[k] = { S: v }; + }); + const input: PutItemCommandInput = { + Item: item, + ReturnConsumedCapacity: "TOTAL", + TableName: sigMemberTableName, + }; + // console.log(input); + const put = new PutItemCommand(input); + const response = await dynamoClient.send(put); +} diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index 43353825..865b6df0 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -8,13 +8,16 @@ import { SigleadGetRequest, SigMemberCount, SigMemberRecord, + SigMemberUpdateRecord, } from "common/types/siglead.js"; import { + addMemberToSig, fetchMemberRecords, fetchSigCounts, fetchSigDetail, } from "api/functions/siglead.js"; import { intersection } from "api/plugins/auth.js"; +import { request } from "http"; const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { const limitedRoutes: FastifyPluginAsync = async (fastify) => { @@ -97,36 +100,47 @@ const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { ); // fetch sig count - fastify.get( - "/sigcount", - { - onRequest: async (request, reply) => { - /*await fastify.authorize(request, reply, [ - AppRoles.LINKS_MANAGER, - AppRoles.LINKS_ADMIN, - ]);*/ - }, - }, + fastify.get("/sigcount", async (request, reply) => { + // First try-catch: Fetch owner records + let sigMemCounts: SigMemberCount[]; + try { + sigMemCounts = await fetchSigCounts( + genericConfig.SigleadDynamoSigMemberTableName, + fastify.dynamoClient, + ); + } catch (error) { + request.log.error( + `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + ); + throw new DatabaseFetchError({ + message: + "Failed to fetch sig member counts record from Dynamo table.", + }); + } + + // Send the response + reply.code(200).send(sigMemCounts); + }); + + // add member + fastify.post<{ Body: SigMemberUpdateRecord }>( + "/addMember", async (request, reply) => { - // First try-catch: Fetch owner records - let sigMemCounts: SigMemberCount[]; try { - sigMemCounts = await fetchSigCounts( + await addMemberToSig( genericConfig.SigleadDynamoSigMemberTableName, + request.body, fastify.dynamoClient, ); } catch (error) { request.log.error( - `Failed to fetch sig member counts record: ${error instanceof Error ? error.toString() : "Unknown error"}`, + `Failed to add member: ${error instanceof Error ? error.toString() : "Unknown error"}`, ); throw new DatabaseFetchError({ - message: - "Failed to fetch sig member counts record from Dynamo table.", + message: "Failed to add sig member record to Dynamo table.", }); } - - // Send the response - reply.code(200).send(sigMemCounts); + reply.code(200); }, ); }; diff --git a/src/common/orgs.ts b/src/common/orgs.ts index ee84d00e..becff6fc 100644 --- a/src/common/orgs.ts +++ b/src/common/orgs.ts @@ -1,3 +1,5 @@ +import { transformSigLeadToURI } from "./utils.js"; + export const SIGList = [ "SIGPwny", "SIGCHI", @@ -28,3 +30,10 @@ export const CommitteeList = [ "Marketing Committee", ] as [string, ...string[]]; export const OrganizationList = ["ACM", ...SIGList, ...CommitteeList] as [string, ...string[]]; + +const orgIds2Name: Record = {}; +OrganizationList.forEach((org) => { + const sigid = transformSigLeadToURI(org); + orgIds2Name[sigid] = org; +}); +export { orgIds2Name }; \ No newline at end of file diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts index da642313..a1fff569 100644 --- a/src/common/types/siglead.ts +++ b/src/common/types/siglead.ts @@ -21,4 +21,28 @@ export type SigMemberCount = { sigid: string; signame: string; count: number; -}; \ No newline at end of file +}; + +export type SigMemberUpdateRecord = { + sigGroupId: string; + email: string; + id: string; + memberName: string; + designation: string; + createdAt: string; + updatedAt: string; +} + +export type SigMemberUpdateRequest = { + +} + +export type DynamoDBItem = { + Item: { + [key: string]: { + [key: string]: string; + }; + }; + ReturnConsumedCapacity: string; + TableName: string; +} \ No newline at end of file diff --git a/src/common/utils.ts b/src/common/utils.ts index 94372580..c8ca5318 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -56,4 +56,16 @@ export function transformSigLeadToURI(org: string) { .replace(/-{2,}/g, "-"); return org === "-" ? "" : org; +} + +export function getTimeInFormat() { + const date = new Date(); + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + const hours = String(date.getUTCHours()).padStart(2, '0'); + const minutes = String(date.getUTCMinutes()).padStart(2, '0'); + const seconds = String(date.getUTCSeconds()).padStart(2, '0'); + + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`; } \ No newline at end of file diff --git a/src/ui/Router.tsx b/src/ui/Router.tsx index d7864969..a2c39dd5 100644 --- a/src/ui/Router.tsx +++ b/src/ui/Router.tsx @@ -25,7 +25,10 @@ import { ManageProfilePage } from "./pages/profile/ManageProfile.page"; import { ManageStripeLinksPage } from "./pages/stripe/ViewLinks.page"; import { ManageRoomRequestsPage } from "./pages/roomRequest/RoomRequestLanding.page"; import { ManageSigLeadsPage } from "./pages/siglead/ManageSigLeads.page"; -import { ViewSigLeadPage } from "./pages/siglead/ViewSigLead.page"; +import { + AddMemberToSigPage, + ViewSigLeadPage, +} from "./pages/siglead/ViewSigLead.page"; import { ViewRoomRequest } from "./pages/roomRequest/ViewRoomRequest.page"; import { ViewLogsPage } from "./pages/logs/ViewLogs.page"; import { TermsOfService } from "./pages/tos/TermsOfService.page"; @@ -197,6 +200,10 @@ const authenticatedRouter = createBrowserRouter([ path: "/siglead-management/:sigId", element: , }, + { + path: "/siglead-management/:sigId/addMember", + element: , + }, { path: "/roomRequests", element: , diff --git a/src/ui/pages/siglead/SigScreenComponents.tsx b/src/ui/pages/siglead/SigScreenComponents.tsx index 0799a788..a3f04f56 100644 --- a/src/ui/pages/siglead/SigScreenComponents.tsx +++ b/src/ui/pages/siglead/SigScreenComponents.tsx @@ -2,10 +2,12 @@ import React, { useEffect, useMemo, useState } from "react"; import { OrganizationList } from "@common/orgs"; import { NavLink, Paper } from "@mantine/core"; import { IconUsersGroup } from "@tabler/icons-react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { SigMemberCount } from "@common/types/siglead"; const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { + const navigate = useNavigate(); + const color = "light-dark(var(--mantine-color-black), var(--mantine-color-white))"; const size = "18px"; @@ -14,7 +16,7 @@ const renderSigLink = (sigMemCount: SigMemberCount, index: number) => { const count = sigMemCount.count; return ( navigate(`./${id}`)} active={index % 2 === 0} label={name} color="var(--mantine-color-blue-light)" diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index d3310bd2..90beb649 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -18,14 +18,20 @@ import { DateTimePicker } from "@mantine/dates"; import { useForm, zodResolver } from "@mantine/form"; import { notifications } from "@mantine/notifications"; import dayjs from "dayjs"; -import React, { useEffect, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { z } from "zod"; import { AuthGuard } from "@ui/components/AuthGuard"; import { getRunEnvironmentConfig } from "@ui/config"; import { useApi } from "@ui/util/api"; import { AppRoles } from "@common/roles"; -import { SigDetailRecord, SigMemberRecord } from "@common/types/siglead.js"; +import { + SigDetailRecord, + SigMemberRecord, + SigMemberUpdateRecord, +} from "@common/types/siglead.js"; +import { getTimeInFormat } from "@common/utils"; +import { orgIds2Name } from "@common/orgs"; export const ViewSigLeadPage: React.FC = () => { const [isSubmitting, setIsSubmitting] = useState(false); @@ -162,7 +168,9 @@ export const ViewSigLeadPage: React.FC = () => { - + */} + + + + ); +}; From 70140f6b123304b6c572e75a5b1928c3ad877da8 Mon Sep 17 00:00:00 2001 From: Ethan Chang Date: Sat, 7 Jun 2025 18:41:14 -0700 Subject: [PATCH 5/5] test button for azure api --- src/api/functions/entraId.ts | 2 +- src/api/functions/siglead.ts | 52 ++++++++++++++++++++--- src/api/routes/siglead.ts | 4 +- src/common/types/siglead.ts | 4 -- src/common/utils.ts | 2 +- src/ui/pages/siglead/ViewSigLead.page.tsx | 10 +++++ 6 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/api/functions/entraId.ts b/src/api/functions/entraId.ts index c29044a1..35b81130 100644 --- a/src/api/functions/entraId.ts +++ b/src/api/functions/entraId.ts @@ -29,7 +29,7 @@ import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { checkPaidMembershipFromTable } from "./membership.js"; -function validateGroupId(groupId: string): boolean { +export function validateGroupId(groupId: string): boolean { const groupIdPattern = /^[a-zA-Z0-9-]+$/; // Adjust the pattern as needed return groupIdPattern.test(groupId); } diff --git a/src/api/functions/siglead.ts b/src/api/functions/siglead.ts index 1e986d2c..ba603a83 100644 --- a/src/api/functions/siglead.ts +++ b/src/api/functions/siglead.ts @@ -1,12 +1,14 @@ import { AttributeValue, DynamoDBClient, + GetItemCommand, PutItemCommand, PutItemCommandInput, QueryCommand, ScanCommand, } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { DatabaseInsertError } from "common/errors/index.js"; import { OrganizationList, orgIds2Name } from "common/orgs.js"; import { DynamoDBItem, @@ -113,7 +115,7 @@ export async function fetchSigCounts( return countsArray; } -export async function addMemberToSig( +export async function addMemberToSigDynamo( sigMemberTableName: string, sigMemberUpdateRequest: SigMemberUpdateRecord, dynamoClient: DynamoDBClient, @@ -122,12 +124,50 @@ export async function addMemberToSig( Object.entries(sigMemberUpdateRequest).forEach(([k, v]) => { item[k] = { S: v }; }); - const input: PutItemCommandInput = { + + // put into table + const put = new PutItemCommand({ Item: item, ReturnConsumedCapacity: "TOTAL", TableName: sigMemberTableName, - }; - // console.log(input); - const put = new PutItemCommand(input); - const response = await dynamoClient.send(put); + }); + try { + const response = await dynamoClient.send(put); + console.log(response); + } catch (e) { + console.error("Put to dynamo db went wrong."); + throw e; + } + + // fetch from db and check if fetched item update time = input item update time + const validatePutQuery = new GetItemCommand({ + TableName: sigMemberTableName, + Key: { + sigGroupId: { S: sigMemberUpdateRequest.sigGroupId }, + email: { S: sigMemberUpdateRequest.email }, + }, + ProjectionExpression: "updatedAt", + }); + + try { + const response = await dynamoClient.send(validatePutQuery); + const item = response.Item; + + if (!item || !item.updatedAt?.S) { + throw new Error("Item not found or missing 'updatedAt'"); + } + + if (item.updatedAt.S !== sigMemberUpdateRequest.updatedAt) { + throw new DatabaseInsertError({ + message: "The member exists, but was updated by someone else!", + }); + } + } catch (e) { + console.error("Validate DynamoDB get went wrong.", e); + throw e; + } +} + +export async function addMemberToSigEntra() { + // uuid validation not implemented yet } diff --git a/src/api/routes/siglead.ts b/src/api/routes/siglead.ts index 865b6df0..1b27a3e8 100644 --- a/src/api/routes/siglead.ts +++ b/src/api/routes/siglead.ts @@ -11,7 +11,7 @@ import { SigMemberUpdateRecord, } from "common/types/siglead.js"; import { - addMemberToSig, + addMemberToSigDynamo, fetchMemberRecords, fetchSigCounts, fetchSigDetail, @@ -127,7 +127,7 @@ const sigleadRoutes: FastifyPluginAsync = async (fastify, _options) => { "/addMember", async (request, reply) => { try { - await addMemberToSig( + await addMemberToSigDynamo( genericConfig.SigleadDynamoSigMemberTableName, request.body, fastify.dynamoClient, diff --git a/src/common/types/siglead.ts b/src/common/types/siglead.ts index a1fff569..e0b46c00 100644 --- a/src/common/types/siglead.ts +++ b/src/common/types/siglead.ts @@ -33,10 +33,6 @@ export type SigMemberUpdateRecord = { updatedAt: string; } -export type SigMemberUpdateRequest = { - -} - export type DynamoDBItem = { Item: { [key: string]: { diff --git a/src/common/utils.ts b/src/common/utils.ts index c8ca5318..c825392c 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -29,7 +29,7 @@ const reservedCharsRegex = /[:\/?#\[\]@!$&'()*+,;=]/g; * @returns {string} - The transformed organization name, ready for use as a URL. */ export function transformSigLeadToURI(org: string) { - console.log(`org\t${org}`) + // console.log(`org\t${org}`) org = org // change not reserved chars to spaces .trim() diff --git a/src/ui/pages/siglead/ViewSigLead.page.tsx b/src/ui/pages/siglead/ViewSigLead.page.tsx index 90beb649..779c9d23 100644 --- a/src/ui/pages/siglead/ViewSigLead.page.tsx +++ b/src/ui/pages/siglead/ViewSigLead.page.tsx @@ -219,6 +219,13 @@ export const AddMemberToSigPage: FC = () => { await api.post(`/api/v1/siglead/addMember`, data); } + async function testAddGroup() { + await api.patch( + `/api/v1/iam/groups/:e37a2420-1030-48da-9d17-f7e201b446e1`, + { add: ["d115c8cb-2520-4ba4-bc36-dd55af69c590"], remove: [] }, + ); + } + return ( { {/* */} + ); };