diff --git a/.github/workflows/pr_build.yml b/.github/workflows/pr_build.yml deleted file mode 100644 index ad56f60..0000000 --- a/.github/workflows/pr_build.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: PR Build Check - -on: - pull_request: - branches: ["master"] - paths: - - "peopleportal/**" - - "landing-v3/**" - - ".nx/**" - - "nx.json" - - "nx" - - ".github/workflows/pr_build.yml" - - workflow_dispatch: - -concurrency: - group: "pr-build-${{ github.ref }}" - cancel-in-progress: true - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: "20" # Keep in sync with deploy_peopleportal.yml and deploy_landingv3.yml - - - name: Build with Nx - # Same build the deploy workflows run, so a PR that cannot build fails before it merges - run: ./nx run-many -t build -p pplserver pplui landing-v3 diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml new file mode 100644 index 0000000..16b5d55 --- /dev/null +++ b/.github/workflows/pr_checks.yml @@ -0,0 +1,78 @@ +name: People Portal PR Checks + +on: + pull_request: + branches: ["master"] + paths: + - "peopleportal/**" + - ".nx/**" + - "nx.json" + - "nx" + - ".prettierrc" + - ".prettierignore" + - ".github/workflows/pr_checks.yml" + + workflow_dispatch: + +concurrency: + group: "pr-checks-${{ github.ref }}" + cancel-in-progress: true + +permissions: + contents: read + +# The three jobs run in parallel so the PR gets every result as fast as possible. +jobs: + format: + name: Format (Prettier) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" # Keep in sync with the other jobs and deploy_peopleportal.yml + + - name: Check formatting + # Prettier is pinned so a new release cannot change the output underneath us. + # Run `npx prettier@3.9.10 --write peopleportal/webui/src peopleportal/server/src` to fix failures. + run: npx --yes prettier@3.9.10 --check peopleportal/webui/src peopleportal/server/src + + build: + name: Build (Nx) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "20" # Keep in sync with the other jobs and deploy_peopleportal.yml + + - name: Build with Nx + # Same build the deploy workflow runs, so a PR that cannot build fails before it merges + run: ./nx run-many -t build -p pplserver pplui + + test: + name: Test (Nx) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + # Node 22, not 20: the server test script passes a glob to `node --test`, which Node 20 + # does not expand, and express-handlebars requires Node >=22.15. + node-version: "22" + + - name: Run tests + # The test targets depend on the build, so Nx builds first inside this job. + # Integration tests (pplserver:test-integration) are left out: they need Mongo/Redis. + run: | + ./nx run pplserver:test-unit + ./nx run pplui:test diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..6614a1e --- /dev/null +++ b/.prettierignore @@ -0,0 +1,12 @@ +# Handlebars email templates use partials ({{> header}}), which Prettier's +# glimmer parser cannot handle ("Handlebars partials are not supported"). +# The partials themselves (header.hbs, footer.hbs) parse fine, so keep them checked. +peopleportal/server/src/clients/EmailClient/templates/*.hbs +!peopleportal/server/src/clients/EmailClient/templates/header.hbs +!peopleportal/server/src/clients/EmailClient/templates/footer.hbs + +# Not formatted with Prettier +landing-v3/ + +# Generated by `tsoa spec-and-routes` on every build +peopleportal/server/src/routes.ts diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5fcd8a7 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/peopleportal/server/src/app.ts b/peopleportal/server/src/app.ts index 8e5cf06..9873d5d 100644 --- a/peopleportal/server/src/app.ts +++ b/peopleportal/server/src/app.ts @@ -17,17 +17,23 @@ */ /* Configure ENV Variables before other imports */ -import { ENVIRONMENT, isProduction, loadEnvironmentFiles, assertRequiredEnvironment, envInt } from './config/environment' -const loadedEnvFiles = loadEnvironmentFiles() - -import express, { Router, Request, Response, NextFunction } from "express" -import cors from "cors" +import { + ENVIRONMENT, + isProduction, + loadEnvironmentFiles, + assertRequiredEnvironment, + envInt, +} from "./config/environment"; +const loadedEnvFiles = loadEnvironmentFiles(); + +import express, { Router, Request, Response, NextFunction } from "express"; +import cors from "cors"; import { RegisterRoutes } from "./routes"; -import { apiReference } from '@scalar/express-api-reference' +import { apiReference } from "@scalar/express-api-reference"; import { ValidateError } from "tsoa"; import mongoose from "mongoose"; import { OpenIdClient } from "./clients/OpenIdClient"; -import expressSession from 'express-session' +import expressSession from "express-session"; import { generateSecureRandomString } from "./utils/strings"; import path from "path"; import { NativeExpressOIDCAuthPort } from "./auth"; @@ -35,73 +41,81 @@ import { AuthentikClient } from "./clients/AuthentikClient"; import { RedisClient } from "./clients/RedisClient"; import { CustomValidationError, ResourceAccessError } from "./utils/errors"; import { ENABLED_SHARED_RESOURCES } from "./config"; -import log from 'loglevel'; -import { agendaClient } from './clients/AgendaClient'; -import { authenticateGiteaWebhook } from './utils/gitea-webhook-auth'; -import { describeUnknownError } from './utils/errors'; +import log from "loglevel"; +import { agendaClient } from "./clients/AgendaClient"; +import { authenticateGiteaWebhook } from "./utils/gitea-webhook-auth"; +import { describeUnknownError } from "./utils/errors"; -log.setLevel("info") +log.setLevel("info"); -assertRequiredEnvironment() -log.info(`Environment: ${ENVIRONMENT}${loadedEnvFiles.length ? ` (loaded ${loadedEnvFiles.join(', ')})` : ''}`) +assertRequiredEnvironment(); +log.info( + `Environment: ${ENVIRONMENT}${loadedEnvFiles.length ? ` (loaded ${loadedEnvFiles.join(", ")})` : ""}`, +); if (!process.env.PEOPLEPORTAL_TOKEN_SECRET) { - /* A generated secret changes on every boot, silently invalidating every + /* A generated secret changes on every boot, silently invalidating every session. Fine locally, never acceptable in production. */ - if (isProduction) - throw new Error("PEOPLEPORTAL_TOKEN_SECRET must be set in production") + if (isProduction) + throw new Error("PEOPLEPORTAL_TOKEN_SECRET must be set in production"); - process.env.PEOPLEPORTAL_TOKEN_SECRET = generateSecureRandomString(16) - log.warn("PEOPLEPORTAL_TOKEN_SECRET is unset; generated an ephemeral one. Sessions will not survive a restart.") + process.env.PEOPLEPORTAL_TOKEN_SECRET = generateSecureRandomString(16); + log.warn( + "PEOPLEPORTAL_TOKEN_SECRET is unset; generated an ephemeral one. Sessions will not survive a restart.", + ); } const app = express(); -const PORT = envInt('PORT', 3000); +const PORT = envInt("PORT", 3000); -app.use(express.urlencoded({ extended: true })) -app.use(express.json()) +app.use(express.urlencoded({ extended: true })); +app.use(express.json()); -app.use(cors({ - origin: 'http://localhost:5173', - credentials: true -})); +app.use( + cors({ + origin: "http://localhost:5173", + credentials: true, + }), +); -app.set('trust proxy', true); +app.set("trust proxy", true); app.use( - expressSession({ - name: 'peopleportal_sid', - secret: process.env.PEOPLEPORTAL_TOKEN_SECRET, - resave: false, - saveUninitialized: true, - store: new expressSession.MemoryStore(), /* Use Redis for Horizontal Scaling */ - proxy: true, - }) + expressSession({ + name: "peopleportal_sid", + secret: process.env.PEOPLEPORTAL_TOKEN_SECRET, + resave: false, + saveUninitialized: true, + store: new expressSession.MemoryStore() /* Use Redis for Horizontal Scaling */, + proxy: true, + }), ); /* Register TSOA Routes */ -const ApiRouter = Router() -ApiRouter.use("/api/webhook/git/repoevent", authenticateGiteaWebhook) -ApiRouter.use("/api/webhook/git/commitevent", authenticateGiteaWebhook) +const ApiRouter = Router(); +ApiRouter.use("/api/webhook/git/repoevent", authenticateGiteaWebhook); +ApiRouter.use("/api/webhook/git/commitevent", authenticateGiteaWebhook); ApiRouter.get("/api/docs/swagger.json", async (req, res) => { - const doc = await import("../dist/swagger.json"); - res.json(doc.default || doc); + const doc = await import("../dist/swagger.json"); + res.json(doc.default || doc); }); /* Enable Documentation A */ -ApiRouter.use("/api/docs", apiReference({ - spec: { - url: "/api/docs/swagger.json", - }, - - metaData: { - title: "People Portal Server API Reference", - }, - - favicon: '/logo.svg', - showDeveloperTools: "never", - theme: "kepler", - hideClientButton: true, - customCss: ` +ApiRouter.use( + "/api/docs", + apiReference({ + spec: { + url: "/api/docs/swagger.json", + }, + + metaData: { + title: "People Portal Server API Reference", + }, + + favicon: "/logo.svg", + showDeveloperTools: "never", + theme: "kepler", + hideClientButton: true, + customCss: ` a[href="https://www.scalar.com"] { display: none; } @@ -113,62 +127,66 @@ ApiRouter.use("/api/docs", apiReference({ } `, - authentication: { - /* Must Match Generated OpenAPI Spec from tsoa.json */ - preferredSecurityScheme: 'OIDC Bindle Shim', - }, -})); + authentication: { + /* Must Match Generated OpenAPI Spec from tsoa.json */ + preferredSecurityScheme: "OIDC Bindle Shim", + }, + }), +); /* Register & Setup Catch All Route for Public Dir */ RegisterRoutes(ApiRouter); app.use(ApiRouter); app.get(["/onboard", "/onboard/*splat"], (req, res) => { - res.sendFile(path.join(__dirname, "ui", "index.html")) -}) + res.sendFile(path.join(__dirname, "ui", "index.html")); +}); app.get(["/apply", "/apply/*splat"], (req, res) => { - res.sendFile(path.join(__dirname, "ui", "index.html")) -}) + res.sendFile(path.join(__dirname, "ui", "index.html")); +}); -app.use(express.static(path.join(__dirname, "ui"), { index: false })) +app.use(express.static(path.join(__dirname, "ui"), { index: false })); app.get("*splat", NativeExpressOIDCAuthPort, (req, res) => { - res.sendFile(path.join(__dirname, "ui", "index.html")) -}) + res.sendFile(path.join(__dirname, "ui", "index.html")); +}); app.use(function errorHandler( - err: unknown, - req: Request, - res: Response, - next: NextFunction + err: unknown, + req: Request, + res: Response, + next: NextFunction, ): Response | void { - if (res.headersSent) { - return next(err); - } - - if (err instanceof ValidateError) { - console.warn(`Caught Validation Error for ${req.path}:`, err.fields); - return res.status(422).json({ - message: "Validation Failed", - details: err?.fields - }); - } + if (res.headersSent) { + return next(err); + } - if (err instanceof ResourceAccessError || err instanceof CustomValidationError) { - return res.status(err.status).json({ - message: err.message, - }); - } + if (err instanceof ValidateError) { + console.warn(`Caught Validation Error for ${req.path}:`, err.fields); + return res.status(422).json({ + message: "Validation Failed", + details: err?.fields, + }); + } - if (err instanceof Error) { - console.error(err) - return res.status(500).json({ - message: err.message ?? "Unknown Internal Server Error", - }); - } + if ( + err instanceof ResourceAccessError || + err instanceof CustomValidationError + ) { + return res.status(err.status).json({ + message: err.message, + }); + } + + if (err instanceof Error) { + console.error(err); + return res.status(500).json({ + message: err.message ?? "Unknown Internal Server Error", + }); + } - next(); + next(); }); /** @@ -181,12 +199,12 @@ app.use(function errorHandler( * down. Here the promise has an explicit owner and an explicit failure path. */ async function startup(): Promise { - /* Validate Connections */ - await OpenIdClient.init() - await RedisClient.init() - //await AuthentikClient.validateAuthentikConnection() + /* Validate Connections */ + await OpenIdClient.init(); + await RedisClient.init(); + //await AuthentikClient.validateAuthentikConnection() - /* Validate Service Team Creation. + /* Validate Service Team Creation. The app cannot function without its service teams: ExecutiveBoardMembers backs the org chart and the executive layer, EventsTeamMembers backs event management. Without them a fresh instance shows "Executive Board Not @@ -198,41 +216,47 @@ async function startup(): Promise { treating a duplicate group as success, so running it every boot is safe. Isolated because a directory hiccup should degrade the org chart, not stop the server from serving. */ - const authentikClient = new AuthentikClient() - try { - await authentikClient.validateServiceExistance() - } catch (e: unknown) { - log.error("Service team validation failed; executive and events features may be unavailable:", describeUnknownError(e)) - } - - /* Initialize Shared Resource Clients. One integration failing to start must - not stop the server from serving, so each is isolated. */ - for (const client in ENABLED_SHARED_RESOURCES) { - log.info(`Initializing Shared Resource Client: ${client}`) - const clientInstance = ENABLED_SHARED_RESOURCES[client]!; + const authentikClient = new AuthentikClient(); try { - await clientInstance.init() + await authentikClient.validateServiceExistance(); } catch (e: unknown) { - log.error(`Failed to initialize shared resource client "${client}", continuing without it:`, describeUnknownError(e)) + log.error( + "Service team validation failed; executive and events features may be unavailable:", + describeUnknownError(e), + ); } - } - /* Validate Database Connection */ - await mongoose.connect(process.env.PEOPLEPORTAL_MONGO_URL!) - log.info(`Server running at http://localhost:${PORT}`); + /* Initialize Shared Resource Clients. One integration failing to start must + not stop the server from serving, so each is isolated. */ + for (const client in ENABLED_SHARED_RESOURCES) { + log.info(`Initializing Shared Resource Client: ${client}`); + const clientInstance = ENABLED_SHARED_RESOURCES[client]!; + try { + await clientInstance.init(); + } catch (e: unknown) { + log.error( + `Failed to initialize shared resource client "${client}", continuing without it:`, + describeUnknownError(e), + ); + } + } - // Agenda Client Singleton - await agendaClient.initialize(); + /* Validate Database Connection */ + await mongoose.connect(process.env.PEOPLEPORTAL_MONGO_URL!); + log.info(`Server running at http://localhost:${PORT}`); + + // Agenda Client Singleton + await agendaClient.initialize(); } app.listen(PORT, () => { - startup().catch((e: unknown) => { - /* Reaching here means a dependency the server cannot serve without (OIDC + startup().catch((e: unknown) => { + /* Reaching here means a dependency the server cannot serve without (OIDC discovery, Redis, Mongo, the job scheduler) did not come up. Exit deliberately with a readable reason rather than dying on an unhandled rejection halfway through boot. */ - log.error("Fatal error during startup:", describeUnknownError(e)) - if (e instanceof Error && e.stack) log.error(e.stack) - process.exit(1) - }) + log.error("Fatal error during startup:", describeUnknownError(e)); + if (e instanceof Error && e.stack) log.error(e.stack); + process.exit(1); + }); }); diff --git a/peopleportal/server/src/auth.ts b/peopleportal/server/src/auth.ts index b75b2c8..e139d39 100644 --- a/peopleportal/server/src/auth.ts +++ b/peopleportal/server/src/auth.ts @@ -18,98 +18,116 @@ import * as express from "express"; import { AuthorizedUser, OpenIdClient } from "./clients/OpenIdClient"; -import jwt from "jsonwebtoken" +import jwt from "jsonwebtoken"; import { BindleController } from "./controllers/BindleController"; import { AuthentikClient } from "./clients/AuthentikClient"; import { ENABLED_SERVICE_TEAM_NAMES } from "./utils/services"; import { ResourceAccessError } from "./utils/errors"; import { formatBindleAccessError } from "./utils/strings"; import { ConstraintViolationException } from "@aws-sdk/client-organizations"; -import { hasAdminAuthority } from './config'; +import { hasAdminAuthority } from "./config"; export async function NativeExpressOIDCAuthPort( req: express.Request, res: express.Response, - next: express.NextFunction + next: express.NextFunction, ) { /* Obtain Auth Status */ try { - const res = await expressAuthentication(req, "oidc") - next() + const res = await expressAuthentication(req, "oidc"); + next(); } catch (e: any) { /* Auth Exception */ // Calculate return_to (Protocol + Host + Original URL) - const returnTo = req.protocol + "://" + req.get("host") + req.originalUrl; - res.redirect(302, "/api/auth/login?return_to=" + encodeURIComponent(returnTo)) + const returnTo = + req.protocol + "://" + req.get("host") + req.originalUrl; + res.redirect( + 302, + "/api/auth/login?return_to=" + encodeURIComponent(returnTo), + ); } } export async function expressAuthentication( request: express.Request, securityName: string, - scopes?: string[] + scopes?: string[], ): Promise { try { if (securityName == "oidc") return await oidcAuthVerify(request, scopes); - else if (securityName == "bindles") return await bindlesAuthVerify(request, scopes); - else if (securityName == "events") return await eventsAuthVerify(request, scopes); - else if (securityName == "executive") return await executiveAuthVerify(request, scopes); - else if (securityName == "ats_otp") { - if (!request.session.tempsession?.jwt || !request.session.tempsession?.user) { - return Promise.reject(new ResourceAccessError(401, "Session is Invalid")); + if ( + !request.session.tempsession?.jwt || + !request.session.tempsession?.user + ) { + return Promise.reject( + new ResourceAccessError(401, "Session is Invalid"), + ); } try { - jwt.verify(request.session.tempsession.jwt, process.env.PEOPLEPORTAL_TOKEN_SECRET!); - return Promise.resolve(true) + jwt.verify( + request.session.tempsession.jwt, + process.env.PEOPLEPORTAL_TOKEN_SECRET!, + ); + return Promise.resolve(true); } catch (error) { delete request.session.tempsession; - return Promise.reject(new ResourceAccessError(401, "Invalid or expired token")); + return Promise.reject( + new ResourceAccessError(401, "Invalid or expired token"), + ); } - } - - else - throw new Error("Invalid Security Name!") + } else throw new Error("Invalid Security Name!"); } catch (e) { - return Promise.reject(e) + return Promise.reject(e); } } /** * Verifies if user has valid OIDC Token. Verification happens as defined in the OpenID * standard via the OpenIdClient. - * + * * @param request Express Request Object * @param scopes Array of Scopes * @returns User Authorization Status (Boolean) */ -async function oidcAuthVerify(request: express.Request, scopes?: string[]): Promise { +async function oidcAuthVerify( + request: express.Request, + scopes?: string[], +): Promise { try { - const authToken = request.session.accessToken + const authToken = request.session.accessToken; if (!authToken) - return Promise.reject(new ResourceAccessError(401, "No Token Provided")); + return Promise.reject( + new ResourceAccessError(401, "No Token Provided"), + ); - const userData = await OpenIdClient.verifyAccessToken(authToken) + const userData = await OpenIdClient.verifyAccessToken(authToken); if (!request.session.accessToken || !request.session.authorizedUser) { - request.session.accessToken = authToken - request.session.authorizedUser = userData + request.session.accessToken = authToken; + request.session.authorizedUser = userData; } - return Promise.resolve(true) + return Promise.resolve(true); } catch (e: any) { /* Check for Token Expiration & Refresh Logic */ - if ((e.name === 'TokenExpiredError' || e.message?.includes('expired')) && request.session.refreshToken) { + if ( + (e.name === "TokenExpiredError" || + e.message?.includes("expired")) && + request.session.refreshToken + ) { try { console.log("Access Token Expired. Attempting Refresh..."); - const newTokens = await OpenIdClient.refreshAccessToken(request.session.refreshToken); + const newTokens = await OpenIdClient.refreshAccessToken( + request.session.refreshToken, + ); /* Update Session */ request.session.accessToken = newTokens.accessToken; @@ -126,13 +144,15 @@ async function oidcAuthVerify(request: express.Request, scopes?: string[]): Prom } /* OIDC Authorization Failed! */ - return Promise.reject(new ResourceAccessError(401, "Invalid or expired token")); + return Promise.reject( + new ResourceAccessError(401, "Invalid or expired token"), + ); } } /** * TODO!! - * + * * @param request Express Request Object * @param scopes Array of Scopes * @param skipOidcCheck Skip OIDC Check, Used when called from another OIDC functions. @@ -141,54 +161,73 @@ async function oidcAuthVerify(request: express.Request, scopes?: string[]): Prom export async function executiveAuthVerify( request: express.Request, scopes?: string[], - skipOidcCheck?: boolean + skipOidcCheck?: boolean, ): Promise { if (!skipOidcCheck) { const isAuthenticated = await oidcAuthVerify(request, scopes); if (!isAuthenticated) - return Promise.reject(new ResourceAccessError(401, "OIDC Authentication Failed!")); + return Promise.reject( + new ResourceAccessError(401, "OIDC Authentication Failed!"), + ); } if (!request.session.authorizedUser) - return Promise.reject(new ResourceAccessError(401, "Failed to Fetch OIDC User Information!")); + return Promise.reject( + new ResourceAccessError( + 401, + "Failed to Fetch OIDC User Information!", + ), + ); /* Fetch User Data */ const authorizedUser: AuthorizedUser = request.session.authorizedUser; /* Superusers are implicitly Executives, Check Scope */ - if (authorizedUser.is_superuser) - return Promise.resolve(true); + if (authorizedUser.is_superuser) return Promise.resolve(true); /* 2. Superuser Exclusive Scope Check */ if (scopes && scopes.includes("su:exclusive")) { /* We already know they are NOT a superuser here */ - return Promise.reject(new ResourceAccessError(403, "This action is restricted to Superusers only!")); + return Promise.reject( + new ResourceAccessError( + 403, + "This action is restricted to Superusers only!", + ), + ); } /* 3. Check for Executive Board Membership */ /* We fetch the Root Teams for the user */ const authentikClient = new AuthentikClient(); try { - const userTeams = await authentikClient.getRootTeamsForUsername(authorizedUser.username); + const userTeams = await authentikClient.getRootTeamsForUsername( + authorizedUser.username, + ); /* Any team carrying admin authority, not flagged for deletion */ - const isExecutive = hasAdminAuthority(userTeams.teams, authorizedUser.groups ?? []); - - if (isExecutive) - return Promise.resolve(true); + const isExecutive = hasAdminAuthority( + userTeams.teams, + authorizedUser.groups ?? [], + ); + if (isExecutive) return Promise.resolve(true); } catch (e) { /* Failed to Fetch Root Teams */ return Promise.reject(e); } /* Neither Conditions Work! */ - return Promise.reject(new ResourceAccessError(403, "You must be an Executive Board Member or a Superuser to perform this action!")); + return Promise.reject( + new ResourceAccessError( + 403, + "You must be an Executive Board Member or a Superuser to perform this action!", + ), + ); } /** - * - * + * + * * @param request Express Request Object * @param scopes Array of Scopes * @returns User Authorization Status (Boolean) @@ -196,7 +235,7 @@ export async function executiveAuthVerify( export async function eventsAuthVerify( request: express.Request, scopes?: string[], - skipOidcCheck?: boolean + skipOidcCheck?: boolean, ): Promise { /* tsoa hands every request the SAME scopes array instance for a given route, so mutating it here (the previous scopes.shift()) permanently stripped the @@ -212,11 +251,18 @@ export async function eventsAuthVerify( if (!skipOidcCheck) { const isAuthenticated = await oidcAuthVerify(request, effectiveScopes); if (!isAuthenticated) - return Promise.reject(new ResourceAccessError(401, "OIDC Authentication Failed!")); + return Promise.reject( + new ResourceAccessError(401, "OIDC Authentication Failed!"), + ); } if (!request.session.authorizedUser) - return Promise.reject(new ResourceAccessError(401, "Failed to Fetch OIDC User Information!")); + return Promise.reject( + new ResourceAccessError( + 401, + "Failed to Fetch OIDC User Information!", + ), + ); /* Fetch User Data */ const authorizedUser: AuthorizedUser = request.session.authorizedUser; @@ -226,71 +272,95 @@ export async function eventsAuthVerify( return Promise.resolve(true); /* 2. Superuser Exclusive Scope Check */ - if (allowExecOverride && effectiveScopes && effectiveScopes.includes("su:exclusive")) { + if ( + allowExecOverride && + effectiveScopes && + effectiveScopes.includes("su:exclusive") + ) { /* We already know they are NOT a superuser here */ - return Promise.reject(new ResourceAccessError(403, "This action is restricted to Superusers only!")); + return Promise.reject( + new ResourceAccessError( + 403, + "This action is restricted to Superusers only!", + ), + ); } /* 3. Check for Executive Board Membership */ /* We fetch the Root Teams for the user */ const authentikClient = new AuthentikClient(); try { - const userTeams = await authentikClient.getRootTeamsForUsername(authorizedUser.username); + const userTeams = await authentikClient.getRootTeamsForUsername( + authorizedUser.username, + ); /* Check if any of the teams are EVENTS and NOT Flagged for Deletion */ - const inEventsTeam = userTeams.teams.some(team => - team.name === "Events" && - !team.flaggedForDeletion + const inEventsTeam = userTeams.teams.some( + (team) => team.name === "Events" && !team.flaggedForDeletion, ); - if (inEventsTeam) - return Promise.resolve(true); + if (inEventsTeam) return Promise.resolve(true); /* If enabled, a user in exec can bypass permission. */ if (allowExecOverride) { /* Any team carrying admin authority, not flagged for deletion */ - const isExecutive = hasAdminAuthority(userTeams.teams, authorizedUser.groups ?? []); + const isExecutive = hasAdminAuthority( + userTeams.teams, + authorizedUser.groups ?? [], + ); - if (isExecutive) - return Promise.resolve(true); + if (isExecutive) return Promise.resolve(true); } - - } catch (e) { /* Failed to Fetch Root Teams */ return Promise.reject(e); } /* Neither Conditions Work! */ - return Promise.reject(new ResourceAccessError(403, "You must be an Executive Board Member or a Superuser to perform this action!")); + return Promise.reject( + new ResourceAccessError( + 403, + "You must be an Executive Board Member or a Superuser to perform this action!", + ), + ); } /** * Verifies if user has the required bindles. When req.params.teamId exists in the request * we automatically process Bindle Authorization for that team. Otherwise, a Dynamic Locator * is needed to be present as the first element in scopes to resolve the teamId. - * + * * Additionally, since all team actions **must be protected by bindles**, we ensure that the * team is not **flagged for deletion** and we also populate a special request bindle field * for AuthentikClient call optimizations. - * + * * **Authorization Override** * The Executive Authorization Layer overrides and automatically approves all Bindle Authorization * checks thereby, providing superusers and executive administrators full access. However, for * Service Team Bindle Manipulation, we explicitly require the "su:exclusive" scope. - * + * * @param request Express Request Object * @param scopes Array of Bindles or Dynamic Locator + Bindles * @returns User Authorization Status (Boolean) */ -async function bindlesAuthVerify(request: express.Request, scopes?: string[]): Promise { +async function bindlesAuthVerify( + request: express.Request, + scopes?: string[], +): Promise { /* 1. We Need OIDC Verification by Default (The Superset) */ const isAuthenticated = await oidcAuthVerify(request, scopes); if (!isAuthenticated || !request.session.authorizedUser) - return Promise.reject(new ResourceAccessError(401, "Invalid or expired token")); + return Promise.reject( + new ResourceAccessError(401, "Invalid or expired token"), + ); if (!scopes || scopes.length === 0) { - return Promise.reject(new ResourceAccessError(403, "Bindle Security Check failed: No Scopes Defined")); + return Promise.reject( + new ResourceAccessError( + 403, + "Bindle Security Check failed: No Scopes Defined", + ), + ); } /* 2. Resolve Team ID & Required Bindles */ @@ -307,14 +377,20 @@ async function bindlesAuthVerify(request: express.Request, scopes?: string[]): P requiredBindles = scopes.slice(1); if (locatorPath) { - const resolvedId = locatorPath.split(".").reduce((o: any, i) => o?.[i], request); - if (typeof resolvedId === "string") - teamId = resolvedId; + const resolvedId = locatorPath + .split(".") + .reduce((o: any, i) => o?.[i], request); + if (typeof resolvedId === "string") teamId = resolvedId; } } if (!teamId || requiredBindles.length === 0) { - return Promise.reject(new ResourceAccessError(403, "Bindle Security Check failed: Could not resolve Team ID or missing Required Bindles")); + return Promise.reject( + new ResourceAccessError( + 403, + "Bindle Security Check failed: Could not resolve Team ID or missing Required Bindles", + ), + ); } const authorizedUser: AuthorizedUser = request.session.authorizedUser; @@ -325,26 +401,33 @@ async function bindlesAuthVerify(request: express.Request, scopes?: string[]): P const teamInfo = await authentikClient.getGroupInfo(teamId); request.bindle = { teamInfo, - requestedPermissions: requiredBindles - } + requestedPermissions: requiredBindles, + }; /* 2.5. Check if Team is Flagged for Deletion */ if (teamInfo.attributes.flaggedForDeletion) { - return Promise.reject(new ResourceAccessError( - 403, - "This team is flagged for deletion and therefore, is read-only." - )); + return Promise.reject( + new ResourceAccessError( + 403, + "This team is flagged for deletion and therefore, is read-only.", + ), + ); } /* 2.75: Executive Authorization Override */ /* Since team info is populated, we can now check for override */ try { /* Service Teams are Restricted to Superusers! */ - const teamScopes = ENABLED_SERVICE_TEAM_NAMES.has(teamInfo.name) ? ["su:exclusive"] : []; - const isExecutive = await executiveAuthVerify(request, teamScopes, true); - - if (isExecutive) - return Promise.resolve(true); + const teamScopes = ENABLED_SERVICE_TEAM_NAMES.has(teamInfo.name) + ? ["su:exclusive"] + : []; + const isExecutive = await executiveAuthVerify( + request, + teamScopes, + true, + ); + + if (isExecutive) return Promise.resolve(true); } catch (e) { /* Failed Executive Override, Continue */ } @@ -356,7 +439,9 @@ async function bindlesAuthVerify(request: express.Request, scopes?: string[]): P /* If Subteam, Elevate to Parent for Ownership Check */ if (teamInfo.parentPk) { - authoritativeTeam = await authentikClient.getGroupInfo(teamInfo.parentPk); + authoritativeTeam = await authentikClient.getGroupInfo( + teamInfo.parentPk, + ); } /* Verify PeoplePortal Validity & Check Membership */ @@ -368,20 +453,30 @@ async function bindlesAuthVerify(request: express.Request, scopes?: string[]): P } /* 4. Granular Permission Check */ - const effectivePermissions = BindleController.getEffectivePermissionSet(teamInfo, authorizedUser.groups); + const effectivePermissions = BindleController.getEffectivePermissionSet( + teamInfo, + authorizedUser.groups, + ); /* Ensure User has ALL required bindles */ - const missingBindles = requiredBindles.filter(bindle => !effectivePermissions.has(bindle)); - if (missingBindles.length === 0) - return Promise.resolve(true); + const missingBindles = requiredBindles.filter( + (bindle) => !effectivePermissions.has(bindle), + ); + if (missingBindles.length === 0) return Promise.resolve(true); /* Access Denied */ - const owners = authoritativeTeam.users.map(u => u.name); - return Promise.reject(new ResourceAccessError(403, formatBindleAccessError(owners, missingBindles))); - + const owners = authoritativeTeam.users.map((u) => u.name); + return Promise.reject( + new ResourceAccessError( + 403, + formatBindleAccessError(owners, missingBindles), + ), + ); } catch (e) { console.error("Bindle Permission Check Failed", e); if (e instanceof ResourceAccessError) return Promise.reject(e); - return Promise.reject(new ResourceAccessError(403, "Bindle Permission Check Failed")); + return Promise.reject( + new ResourceAccessError(403, "Bindle Permission Check Failed"), + ); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/AWSClient/S3Client.ts b/peopleportal/server/src/clients/AWSClient/S3Client.ts index 9a05f1a..578a05c 100644 --- a/peopleportal/server/src/clients/AWSClient/S3Client.ts +++ b/peopleportal/server/src/clients/AWSClient/S3Client.ts @@ -20,11 +20,11 @@ import { S3Client } from "@aws-sdk/client-s3"; export const s3Client = new S3Client({ - region: process.env.AWS_REGION || 'us-east-1', - credentials: { - accessKeyId: process.env.AWS_ACCESS_KEY_ID || '', - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '', - }, + region: process.env.AWS_REGION || "us-east-1", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", + }, }); -export const BUCKET_NAME = process.env.S3_BUCKET_NAME || ''; +export const BUCKET_NAME = process.env.S3_BUCKET_NAME || ""; diff --git a/peopleportal/server/src/clients/AWSClient/index.ts b/peopleportal/server/src/clients/AWSClient/index.ts index 89ec611..bbfb774 100644 --- a/peopleportal/server/src/clients/AWSClient/index.ts +++ b/peopleportal/server/src/clients/AWSClient/index.ts @@ -26,7 +26,7 @@ import { DescribeCreateAccountStatusCommand, MoveAccountCommand, ListParentsCommand, - paginateListAccountsForParent + paginateListAccountsForParent, } from "@aws-sdk/client-organizations"; import { BudgetsClient, @@ -38,13 +38,13 @@ import { ThresholdType, NotificationType, ComparisonOperator, - ActionType + ActionType, } from "@aws-sdk/client-budgets"; import { STSClient, AssumeRoleCommand } from "@aws-sdk/client-sts"; import axios from "axios"; export class AWSClient implements RootTeamSettingClient { - private static readonly TAG = "AWSClient" + private static readonly TAG = "AWSClient"; private orgClient: OrganizationsClient; private budgetsClient: BudgetsClient; @@ -54,25 +54,36 @@ export class AWSClient implements RootTeamSettingClient { private readonly ROOT_ID = process.env.AWS_ORG_ROOT_ID; private readonly NONPROD_OU_ID = process.env.AWS_NONPROD_OU_ID; private readonly SUSPENDED_OU_ID = process.env.AWS_SUSPENDED_OU_ID; - private readonly MANAGEMENT_ACCOUNT_ID = process.env.AWS_MANAGEMENT_ACCOUNT_ID; - private readonly ADMIN_ROLE_NAME = process.env.AWS_ADMIN_ROLE_NAME ?? "AppDevNonProductionRole"; - private readonly BUDGET_LIMIT = process.env.AWS_DEFAULT_BUDGET_AMOUNT ?? "50"; - private readonly BILLING_ALERT_EMAIL = process.env.AWS_BILLING_ALERT_EMAIL ?? "awsclient+financealerts@appdevclub.com"; + private readonly MANAGEMENT_ACCOUNT_ID = + process.env.AWS_MANAGEMENT_ACCOUNT_ID; + private readonly ADMIN_ROLE_NAME = + process.env.AWS_ADMIN_ROLE_NAME ?? "AppDevNonProductionRole"; + private readonly BUDGET_LIMIT = + process.env.AWS_DEFAULT_BUDGET_AMOUNT ?? "50"; + private readonly BILLING_ALERT_EMAIL = + process.env.AWS_BILLING_ALERT_EMAIL ?? + "awsclient+financealerts@appdevclub.com"; private readonly DENY_ALL_SCP_ID = process.env.AWS_DENY_ALL_SCP_ID; - private readonly BUDGET_ACTION_ROLE_ARN = process.env.AWS_BUDGET_ACTION_ROLE_ARN; + private readonly BUDGET_ACTION_ROLE_ARN = + process.env.AWS_BUDGET_ACTION_ROLE_ARN; private readonly SESSION_EXPIRY = 3600; - private readonly SUPPORTED_ROOTSETTINGS: RootTeamSettingMap = { "awsclient:provision": { friendlyName: "Provision AWS Account", - description: "Creates a new AWS Account just for your team" - } - } + description: "Creates a new AWS Account just for your team", + }, + }; constructor() { - if (!this.ROOT_ID || !this.NONPROD_OU_ID || !this.MANAGEMENT_ACCOUNT_ID) { - throw new Error(`${AWSClient.TAG}: Missing critical AWS environment variables. AWS features will fail.`); + if ( + !this.ROOT_ID || + !this.NONPROD_OU_ID || + !this.MANAGEMENT_ACCOUNT_ID + ) { + throw new Error( + `${AWSClient.TAG}: Missing critical AWS environment variables. AWS features will fail.`, + ); } this.orgClient = new OrganizationsClient({ region: this.REGION }); @@ -89,11 +100,15 @@ export class AWSClient implements RootTeamSettingClient { } async syncSettingUpdate(org: GetGroupInfoResponse): Promise { - const settings = org.attributes.rootTeamSettings?.[this.getResourceName()]; - const shouldProvision = settings && settings["awsclient:provision"] === true; + const settings = + org.attributes.rootTeamSettings?.[this.getResourceName()]; + const shouldProvision = + settings && settings["awsclient:provision"] === true; if (!shouldProvision) { - console.debug("[AWS_CLIENT] 100%: AWS Provisioning is disabled for this team."); + console.debug( + "[AWS_CLIENT] 100%: AWS Provisioning is disabled for this team.", + ); return true; } @@ -101,23 +116,31 @@ export class AWSClient implements RootTeamSettingClient { try { // Check for existing account - console.debug(`[AWS_CLIENT] 10%: Checking for existing account: ${name}...`); + console.debug( + `[AWS_CLIENT] 10%: Checking for existing account: ${name}...`, + ); const existingAccountId = await this.findAccountIdByName(name); if (existingAccountId) { - console.debug(`[AWS_CLIENT] 100%: Account already exists (${existingAccountId}). No action taken.`); + console.debug( + `[AWS_CLIENT] 100%: Account already exists (${existingAccountId}). No action taken.`, + ); return true; } // Create Account const adminEmail = `awsclient+${name.toLowerCase()}@appdevclub.com`; - console.debug(`[AWS_CLIENT] 20%: Creating AWS Account (${adminEmail})...`); + console.debug( + `[AWS_CLIENT] 20%: Creating AWS Account (${adminEmail})...`, + ); const accountId = await this.createAccount(name, adminEmail); if (!accountId) throw new Error("Account creation returned no ID."); // Move to OU - console.debug("[AWS_CLIENT] 60%: Moving account to Non Production OU..."); + console.debug( + "[AWS_CLIENT] 60%: Moving account to Non Production OU...", + ); await this.moveAccount(accountId); // Create Budget @@ -126,13 +149,22 @@ export class AWSClient implements RootTeamSettingClient { // Use the first user's email for alerts, or fallback to the finance email const alertEmail = this.BILLING_ALERT_EMAIL; console.log(`Using ${alertEmail} for billing alerts for ${name}`); - await this.createBudget(accountId, alertEmail, name, this.BUDGET_LIMIT); - - console.debug(`[AWS_CLIENT] 100%: Successfully provisioned AWS Account: ${accountId}`); + await this.createBudget( + accountId, + alertEmail, + name, + this.BUDGET_LIMIT, + ); + + console.debug( + `[AWS_CLIENT] 100%: Successfully provisioned AWS Account: ${accountId}`, + ); return true; } catch (e: any) { console.error(e); - console.debug(`[AWS_CLIENT] 100%: AWS Provisioning Failed: ${e.message}`); + console.debug( + `[AWS_CLIENT] 100%: AWS Provisioning Failed: ${e.message}`, + ); return false; } } @@ -145,68 +177,99 @@ export class AWSClient implements RootTeamSettingClient { * @param org Team Information */ async archiveTeam(org: GetGroupInfoResponse): Promise { - const settings = org.attributes.rootTeamSettings?.[this.getResourceName()]; - const isProvisioned = settings && settings["awsclient:provision"] === true; + const settings = + org.attributes.rootTeamSettings?.[this.getResourceName()]; + const isProvisioned = + settings && settings["awsclient:provision"] === true; if (!isProvisioned) { - console.debug("[AWS_CLIENT] Archive: AWS not provisioned for this team, skipping."); + console.debug( + "[AWS_CLIENT] Archive: AWS not provisioned for this team, skipping.", + ); return true; } if (!this.SUSPENDED_OU_ID) { - throw new Error("AWS_SUSPENDED_OU_ID is not configured; cannot archive team account."); + throw new Error( + "AWS_SUSPENDED_OU_ID is not configured; cannot archive team account.", + ); } const accountId = await this.findAccountIdByName(org.name); if (!accountId) { - console.debug(`[AWS_CLIENT] Archive: No AWS account found for team '${org.name}', skipping.`); + console.debug( + `[AWS_CLIENT] Archive: No AWS account found for team '${org.name}', skipping.`, + ); return true; } /* Move account to Suspended OU, resolving current parent dynamically */ - const parentsRes = await this.orgClient.send(new ListParentsCommand({ ChildId: accountId })); + const parentsRes = await this.orgClient.send( + new ListParentsCommand({ ChildId: accountId }), + ); const currentParentId = parentsRes.Parents?.[0]?.Id; if (!currentParentId) { - throw new Error(`[AWS_CLIENT] Archive: Could not determine parent OU for account ${accountId}.`); + throw new Error( + `[AWS_CLIENT] Archive: Could not determine parent OU for account ${accountId}.`, + ); } if (currentParentId !== this.SUSPENDED_OU_ID) { - await this.orgClient.send(new MoveAccountCommand({ - AccountId: accountId, - SourceParentId: currentParentId, - DestinationParentId: this.SUSPENDED_OU_ID - })); - console.debug(`[AWS_CLIENT] Archive: Moved account ${accountId} to Suspended OU.`); + await this.orgClient.send( + new MoveAccountCommand({ + AccountId: accountId, + SourceParentId: currentParentId, + DestinationParentId: this.SUSPENDED_OU_ID, + }), + ); + console.debug( + `[AWS_CLIENT] Archive: Moved account ${accountId} to Suspended OU.`, + ); } else { - console.debug(`[AWS_CLIENT] Archive: Account ${accountId} already in Suspended OU.`); + console.debug( + `[AWS_CLIENT] Archive: Account ${accountId} already in Suspended OU.`, + ); } /* Delete the monthly budget — best-effort, not fatal if missing */ try { - await this.budgetsClient.send(new DeleteBudgetCommand({ - AccountId: this.MANAGEMENT_ACCOUNT_ID, - BudgetName: `Project-Budget-${org.name}` - })); - console.debug(`[AWS_CLIENT] Archive: Deleted budget for ${org.name}.`); + await this.budgetsClient.send( + new DeleteBudgetCommand({ + AccountId: this.MANAGEMENT_ACCOUNT_ID, + BudgetName: `Project-Budget-${org.name}`, + }), + ); + console.debug( + `[AWS_CLIENT] Archive: Deleted budget for ${org.name}.`, + ); } catch (e: any) { - if (e.name !== 'NotFoundException') { - console.warn(`[AWS_CLIENT] Archive: Could not delete budget: ${e.message}`); + if (e.name !== "NotFoundException") { + console.warn( + `[AWS_CLIENT] Archive: Could not delete budget: ${e.message}`, + ); } } return true; } - public async generateConsoleLink(accountId: string, sessionName: string): Promise { + public async generateConsoleLink( + accountId: string, + sessionName: string, + ): Promise { // Assume Admin Role const roleArn = `arn:aws:iam::${accountId}:role/${this.ADMIN_ROLE_NAME}`; - const cleanSessionName = sessionName.replace(/[^a-zA-Z0-9_+=,.@-]/g, '-').substring(0, 64); - - const assumeRes = await this.stsClient.send(new AssumeRoleCommand({ - RoleArn: roleArn, - RoleSessionName: cleanSessionName.substring(0, 64), - DurationSeconds: this.SESSION_EXPIRY - })); + const cleanSessionName = sessionName + .replace(/[^a-zA-Z0-9_+=,.@-]/g, "-") + .substring(0, 64); + + const assumeRes = await this.stsClient.send( + new AssumeRoleCommand({ + RoleArn: roleArn, + RoleSessionName: cleanSessionName.substring(0, 64), + DurationSeconds: this.SESSION_EXPIRY, + }), + ); if (!assumeRes.Credentials) { throw new Error("Failed to obtain temporary credentials."); @@ -216,7 +279,7 @@ export class AWSClient implements RootTeamSettingClient { const sessionJson = JSON.stringify({ sessionId: assumeRes.Credentials.AccessKeyId, sessionKey: assumeRes.Credentials.SecretAccessKey, - sessionToken: assumeRes.Credentials.SessionToken + sessionToken: assumeRes.Credentials.SessionToken, }); // Get Signin Token @@ -235,7 +298,7 @@ export class AWSClient implements RootTeamSettingClient { Email: email, AccountName: name, RoleName: this.ADMIN_ROLE_NAME, - IamUserAccessToBilling: "DENY" + IamUserAccessToBilling: "DENY", }); const createRes = await this.orgClient.send(createCmd); @@ -248,59 +311,78 @@ export class AWSClient implements RootTeamSettingClient { while (true) { if (Date.now() - startTime > timeoutMs) { - throw new Error(`Account creation timed out. Request ID: ${requestId}. Check AWS Console for status.`); + throw new Error( + `Account creation timed out. Request ID: ${requestId}. Check AWS Console for status.`, + ); } - await new Promise(r => setTimeout(r, 3000)); + await new Promise((r) => setTimeout(r, 3000)); - const statusCmd = new DescribeCreateAccountStatusCommand({ CreateAccountRequestId: requestId }); + const statusCmd = new DescribeCreateAccountStatusCommand({ + CreateAccountRequestId: requestId, + }); const statusRes = await this.orgClient.send(statusCmd); const state = statusRes.CreateAccountStatus?.State; if (state === "SUCCEEDED") { return statusRes.CreateAccountStatus!.AccountId!; } else if (state === "FAILED") { - throw new Error(`AWS Creation Failed: ${statusRes.CreateAccountStatus?.FailureReason}`); + throw new Error( + `AWS Creation Failed: ${statusRes.CreateAccountStatus?.FailureReason}`, + ); } - console.debug("[AWS_CLIENT] 30%: Waiting for AWS to finalize account creation..."); + console.debug( + "[AWS_CLIENT] 30%: Waiting for AWS to finalize account creation...", + ); } } private async moveAccount(accountId: string) { - await this.orgClient.send(new MoveAccountCommand({ - AccountId: accountId, - SourceParentId: this.ROOT_ID, - DestinationParentId: this.NONPROD_OU_ID - })); + await this.orgClient.send( + new MoveAccountCommand({ + AccountId: accountId, + SourceParentId: this.ROOT_ID, + DestinationParentId: this.NONPROD_OU_ID, + }), + ); } - private async createBudget(accountId: string, email: string, projectName: string, budgetLimit: string) { - await new Promise(r => setTimeout(r, 5000)); + private async createBudget( + accountId: string, + email: string, + projectName: string, + budgetLimit: string, + ) { + await new Promise((r) => setTimeout(r, 5000)); const budgetName = `Project-Budget-${projectName}`; - await this.budgetsClient.send(new CreateBudgetCommand({ - AccountId: this.MANAGEMENT_ACCOUNT_ID, - Budget: { - BudgetName: budgetName, - BudgetType: BudgetType.Cost, - TimeUnit: TimeUnit.MONTHLY, - BudgetLimit: { Amount: budgetLimit, Unit: "USD" }, - CostFilters: { "LinkedAccount": [accountId] } - }, - NotificationsWithSubscribers: [ - { - Notification: { - NotificationType: NotificationType.ACTUAL, - ComparisonOperator: ComparisonOperator.GREATER_THAN, - Threshold: 80, - ThresholdType: ThresholdType.PERCENTAGE + await this.budgetsClient.send( + new CreateBudgetCommand({ + AccountId: this.MANAGEMENT_ACCOUNT_ID, + Budget: { + BudgetName: budgetName, + BudgetType: BudgetType.Cost, + TimeUnit: TimeUnit.MONTHLY, + BudgetLimit: { Amount: budgetLimit, Unit: "USD" }, + CostFilters: { LinkedAccount: [accountId] }, + }, + NotificationsWithSubscribers: [ + { + Notification: { + NotificationType: NotificationType.ACTUAL, + ComparisonOperator: ComparisonOperator.GREATER_THAN, + Threshold: 80, + ThresholdType: ThresholdType.PERCENTAGE, + }, + Subscribers: [ + { SubscriptionType: "EMAIL", Address: email }, + ], }, - Subscribers: [{ SubscriptionType: "EMAIL", Address: email }] - } - ] - })); + ], + }), + ); await this.createBudgetEnforcementAction(accountId, budgetName); } @@ -313,36 +395,52 @@ export class AWSClient implements RootTeamSettingClient { * Requires AWS_DENY_ALL_SCP_ID and AWS_BUDGET_ACTION_ROLE_ARN to be set. * If either is missing the action is skipped with a warning. */ - private async createBudgetEnforcementAction(accountId: string, budgetName: string) { + private async createBudgetEnforcementAction( + accountId: string, + budgetName: string, + ) { if (!this.DENY_ALL_SCP_ID || !this.BUDGET_ACTION_ROLE_ARN) { - console.warn("[AWS_CLIENT] AWS_DENY_ALL_SCP_ID or AWS_BUDGET_ACTION_ROLE_ARN not set; budget enforcement action skipped."); + console.warn( + "[AWS_CLIENT] AWS_DENY_ALL_SCP_ID or AWS_BUDGET_ACTION_ROLE_ARN not set; budget enforcement action skipped.", + ); return; } - await this.budgetsClient.send(new CreateBudgetActionCommand({ - AccountId: this.MANAGEMENT_ACCOUNT_ID, - BudgetName: budgetName, - NotificationType: NotificationType.ACTUAL, - ActionType: ActionType.SCP, - ActionThreshold: { - ActionThresholdValue: 100, - ActionThresholdType: "PERCENTAGE" - }, - Definition: { - ScpActionDefinition: { - PolicyId: this.DENY_ALL_SCP_ID, - TargetIds: [accountId] - } - }, - ExecutionRoleArn: this.BUDGET_ACTION_ROLE_ARN, - ApprovalModel: "AUTOMATIC", - Subscribers: [{ SubscriptionType: "EMAIL", Address: this.BILLING_ALERT_EMAIL }] - })); + await this.budgetsClient.send( + new CreateBudgetActionCommand({ + AccountId: this.MANAGEMENT_ACCOUNT_ID, + BudgetName: budgetName, + NotificationType: NotificationType.ACTUAL, + ActionType: ActionType.SCP, + ActionThreshold: { + ActionThresholdValue: 100, + ActionThresholdType: "PERCENTAGE", + }, + Definition: { + ScpActionDefinition: { + PolicyId: this.DENY_ALL_SCP_ID, + TargetIds: [accountId], + }, + }, + ExecutionRoleArn: this.BUDGET_ACTION_ROLE_ARN, + ApprovalModel: "AUTOMATIC", + Subscribers: [ + { + SubscriptionType: "EMAIL", + Address: this.BILLING_ALERT_EMAIL, + }, + ], + }), + ); - console.debug(`[AWS_CLIENT] Budget enforcement action created for account ${accountId}.`); + console.debug( + `[AWS_CLIENT] Budget enforcement action created for account ${accountId}.`, + ); } - public async findAccountIdByName(name: string): Promise { + public async findAccountIdByName( + name: string, + ): Promise { const TIMEOUT_MS = 30000; // 30 second timeout const findAccount = async (): Promise => { @@ -362,10 +460,15 @@ export class AWSClient implements RootTeamSettingClient { } // just in case the account was created but not moved - const rootPaginator = paginateListAccountsForParent({ client: this.orgClient }, { ParentId: this.ROOT_ID }); + const rootPaginator = paginateListAccountsForParent( + { client: this.orgClient }, + { ParentId: this.ROOT_ID }, + ); for await (const page of rootPaginator) { - const match = (page.Accounts || []).find((a) => a.Name === name); + const match = (page.Accounts || []).find( + (a) => a.Name === name, + ); if (match) { await this.moveAccount(match.Id!); return match.Id; @@ -374,10 +477,15 @@ export class AWSClient implements RootTeamSettingClient { // check suspended OU so archived accounts aren't re-provisioned if (this.SUSPENDED_OU_ID) { - const suspendedPaginator = paginateListAccountsForParent({ client: this.orgClient }, { ParentId: this.SUSPENDED_OU_ID }); + const suspendedPaginator = paginateListAccountsForParent( + { client: this.orgClient }, + { ParentId: this.SUSPENDED_OU_ID }, + ); for await (const page of suspendedPaginator) { - const match = (page.Accounts || []).find((a) => a.Name === name); + const match = (page.Accounts || []).find( + (a) => a.Name === name, + ); if (match) return match.Id; } } @@ -387,12 +495,17 @@ export class AWSClient implements RootTeamSettingClient { // Wrap in timeout to prevent indefinite hangs const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`AWS account lookup timed out after ${TIMEOUT_MS / 1000}s`)), TIMEOUT_MS); + setTimeout( + () => + reject( + new Error( + `AWS account lookup timed out after ${TIMEOUT_MS / 1000}s`, + ), + ), + TIMEOUT_MS, + ); }); return Promise.race([findAccount(), timeoutPromise]); } - - - -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/AWSClient/models.ts b/peopleportal/server/src/clients/AWSClient/models.ts index 9e7f8d9..77ad136 100644 --- a/peopleportal/server/src/clients/AWSClient/models.ts +++ b/peopleportal/server/src/clients/AWSClient/models.ts @@ -20,13 +20,11 @@ import { RootTeamSettingInfo } from "../../controllers/OrgController"; export interface AWSAccountTeamSetting extends RootTeamSettingInfo { - accountId: string + accountId: string; } -export interface AdditionalRootSettingParams { - -} +export interface AdditionalRootSettingParams {} export interface AWSAdditionalParams extends AdditionalRootSettingParams { - budgetLimit: string -} \ No newline at end of file + budgetLimit: string; +} diff --git a/peopleportal/server/src/clients/AgendaClient/Jobs/index.ts b/peopleportal/server/src/clients/AgendaClient/Jobs/index.ts index 1257806..0a5a0c8 100644 --- a/peopleportal/server/src/clients/AgendaClient/Jobs/index.ts +++ b/peopleportal/server/src/clients/AgendaClient/Jobs/index.ts @@ -26,18 +26,21 @@ interface _JobData { export interface JobSchedulingData extends _JobData { runAt: Date | string; // runAt also accepts times as strings in English. - // See Agenda docs for schedule function. + // See Agenda docs for schedule function. } export interface RecurringJobSchedulingData extends _JobData { schedule: string; } - // Helper functions class JobHelperFunctions { - public static async sendEventReminderEmail(event: HydratedDocument | DocumentJSON, emailClient: EmailClient, relativeTime: string) { - const emails = await EventRsvp.distinct('email', { + public static async sendEventReminderEmail( + event: HydratedDocument | DocumentJSON, + emailClient: EmailClient, + relativeTime: string, + ) { + const emails = await EventRsvp.distinct("email", { eventId: event._id, status: RsvpStatus.ACCEPT, }).exec(); @@ -58,18 +61,27 @@ class JobHelperFunctions { relativeTime: relativeTime, eventName: event.eventName, eventDescription: event.eventDescription, - startTime: event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" }), - endTime: event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" }), + startTime: event.startTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), + endTime: event.endTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), location: event.location, inviteLink: inviteLink, googleCalendarLink: gCalendarLink, - } + }, }); } - public static async sendEventReminderSlack(event: HydratedDocument | DocumentJSON, relativeTime: string) { + public static async sendEventReminderSlack( + event: HydratedDocument | DocumentJSON, + relativeTime: string, + ) { const slackClient = ENABLED_SHARED_RESOURCES.slackClient as SlackClient; - const announcementsChannel = await slackClient.getChannelFromName(EventController.SLACK_EVENT_ANNOUNCEMENT_CHANNEL); + const announcementsChannel = await slackClient.getChannelFromName( + EventController.SLACK_EVENT_ANNOUNCEMENT_CHANNEL, + ); if (announcementsChannel === null) { return false; } @@ -78,42 +90,54 @@ class JobHelperFunctions { relativeTime += "s"; } const inviteLink = EventController.generateEventInviteLink(event); - const message = `*Reminder*\n` + - `${event.eventName} starts in ${relativeTime}.\n\n` + - `RSVP here: ${inviteLink}`; - - return await slackClient.sendMessageInChannel(announcementsChannel.id!, message); + const message = + `*Reminder*\n` + + `${event.eventName} starts in ${relativeTime}.\n\n` + + `RSVP here: ${inviteLink}`; + + return await slackClient.sendMessageInChannel( + announcementsChannel.id!, + message, + ); } - public static async sendEventReminderDiscord(event: HydratedDocument | DocumentJSON, relativeTime: string) { - const discordClient = ENABLED_SHARED_RESOURCES.discordClient as DiscordClient; + public static async sendEventReminderDiscord( + event: HydratedDocument | DocumentJSON, + relativeTime: string, + ) { + const discordClient = + ENABLED_SHARED_RESOURCES.discordClient as DiscordClient; const inviteLink = EventController.generateEventInviteLink(event); if (relativeTime === "2 Hour") { relativeTime += "s"; } - const message = `## Reminder\n` + - `**${event.eventName}** starts in ${relativeTime}.\n\n` + - `RSVP here: ${inviteLink}`; - + const message = + `## Reminder\n` + + `**${event.eventName}** starts in ${relativeTime}.\n\n` + + `RSVP here: ${inviteLink}`; + try { - const announcementsChannel = await discordClient.getChannelFromName(EventController.DISCORD_EVENT_ANNOUNCEMENT_CHANNEL); + const announcementsChannel = await discordClient.getChannelFromName( + EventController.DISCORD_EVENT_ANNOUNCEMENT_CHANNEL, + ); if (announcementsChannel === undefined) { return false; } - await discordClient.sendMessageInChannel(announcementsChannel, message); + await discordClient.sendMessageInChannel( + announcementsChannel, + message, + ); return true; } catch (error) { return false; } - } } - // Define job payloads // Empty payload parent interface (for typing) -export interface JobPayload { } +export interface JobPayload {} export interface SendEmailJobPayload extends JobPayload { request: EmailSendRequest; @@ -123,20 +147,34 @@ export interface SendEventRemindersPayload extends JobPayload { eventId: string; } - // Define Agenda jobs here -export const DefinedJobs: Record) => Promise, - options?: Partial>; -}> = { +export const DefinedJobs: Record< + string, + { + workerFunction: (argument: Job) => Promise; + options?: Partial< + Pick< + JobDefinition, + | "lockLimit" + | "lockLifetime" + | "concurrency" + | "backoff" + | "removeOnComplete" + | "logging" + > + >; + } +> = { sendEventRemindersDaily: { workerFunction: async (job: Job) => { const emailClient = new EmailClient(); - const ny8AM = DateTime.now().setZone("America/New_York").set({ - hour: 8, minute: 0, second: 0, millisecond: 0 - }) + hour: 8, + minute: 0, + second: 0, + millisecond: 0, + }); const today8AM = ny8AM.toUTC().toJSDate(); const oneDay = 1000 * 60 * 60 * 24; // Day in Milliseconds @@ -148,77 +186,126 @@ export const DefinedJobs: Record) => { - const event = await Event.findOne({ _id: job.attrs.data.eventId }).lean().exec(); + const event = await Event.findOne({ _id: job.attrs.data.eventId }) + .lean() + .exec(); if (event == null) { - console.error(`[sendEventReminders Job] - Event ${job.attrs.data.eventId} not found.`); + console.error( + `[sendEventReminders Job] - Event ${job.attrs.data.eventId} not found.`, + ); return; } try { if (event.marketingChannels?.includes("email")) { const emailClient = new EmailClient(); - await JobHelperFunctions.sendEventReminderEmail(event, emailClient, "2 Hour"); + await JobHelperFunctions.sendEventReminderEmail( + event, + emailClient, + "2 Hour", + ); } - + if (event.marketingChannels?.includes("slack")) { - const success = await JobHelperFunctions.sendEventReminderSlack(event, "2 Hour"); + const success = + await JobHelperFunctions.sendEventReminderSlack( + event, + "2 Hour", + ); if (!success) { - console.error("Failed to send Slack reminders for event:", event._id); + console.error( + "Failed to send Slack reminders for event:", + event._id, + ); } } if (event.marketingChannels?.includes("discord")) { - const success = await JobHelperFunctions.sendEventReminderDiscord(event, "2 Hour"); + const success = + await JobHelperFunctions.sendEventReminderDiscord( + event, + "2 Hour", + ); if (!success) { - console.error("Failed to send Discord reminders for event:", event._id); + console.error( + "Failed to send Discord reminders for event:", + event._id, + ); } } - } catch (error) { - console.error(`Failed to send reminders for event ${event._id} due to error:`, error); + console.error( + `Failed to send reminders for event ${event._id} due to error:`, + error, + ); job.fail(error?.toString() ?? event._id.toString()); - } + } }, options: { removeOnComplete: true, // Frees space by removing job from MongoDB on completion @@ -226,16 +313,13 @@ export const DefinedJobs: Record { - if (dbQueryInterval === undefined) { dbQueryInterval = "10 minutes"; } @@ -47,7 +43,6 @@ export class AgendaClient { processEvery: dbQueryInterval, }); - // Define all jobs Object.entries(DefinedJobs).map((entry) => { const key = entry[0]; @@ -55,7 +50,7 @@ export class AgendaClient { this.agenda!.define(key, job.workerFunction, job.options); }); - for(const entry of RecurringJobs) { + for (const entry of RecurringJobs) { // Agenda automatically checks for duplicates. /* Awaited: an unawaited every() swallowed scheduling failures, so a recurring job could silently never be registered. */ @@ -63,7 +58,7 @@ export class AgendaClient { entry.schedule, entry.jobName, entry.jobPayload, - entry.options + entry.options, ); } @@ -73,10 +68,10 @@ export class AgendaClient { // Schedule a job to be run one time. public async scheduleJobOnce(jobData: JobSchedulingData) { await this.agenda!.schedule( - jobData.runAt, - jobData.jobName, - jobData.jobPayload, - jobData.options + jobData.runAt, + jobData.jobName, + jobData.jobPayload, + jobData.options, ); } @@ -85,4 +80,4 @@ export class AgendaClient { } } -export const agendaClient = new AgendaClient(); \ No newline at end of file +export const agendaClient = new AgendaClient(); diff --git a/peopleportal/server/src/clients/AppleAccountClient/index.ts b/peopleportal/server/src/clients/AppleAccountClient/index.ts index 752e006..4321671 100644 --- a/peopleportal/server/src/clients/AppleAccountClient/index.ts +++ b/peopleportal/server/src/clients/AppleAccountClient/index.ts @@ -16,21 +16,22 @@ along with this program. If not, see . */ -import { SharedResourceClient } from ".." -import { BindlePermissionMap } from "../../controllers/BindleController" -import { GetGroupInfoResponse } from "../AuthentikClient/models" +import { SharedResourceClient } from ".."; +import { BindlePermissionMap } from "../../controllers/BindleController"; +import { GetGroupInfoResponse } from "../AuthentikClient/models"; export class AppleAccountClient implements SharedResourceClient { - private static readonly TAG = "AppleAccountClient" + private static readonly TAG = "AppleAccountClient"; private readonly supportedBindles: BindlePermissionMap = { "apple:platformaccess": { friendlyName: "App Store Connect Access", - description: "Enabling this allows TestFlight and Publishing to the App Store", + description: + "Enabling this allows TestFlight and Publishing to the App Store", }, - } + }; async init(): Promise { - return Promise.resolve() + return Promise.resolve(); } getResourceName(): string { @@ -41,13 +42,19 @@ export class AppleAccountClient implements SharedResourceClient { return this.supportedBindles; } - handleOrgBindleSync(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + handleOrgBindleSync( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { // TODO: Implement Apple Account Client return Promise.resolve(true); } - archiveTeam(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + archiveTeam( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { // TODO: Implement Apple Account Client archival return Promise.resolve(true); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/AuthentikClient/index.ts b/peopleportal/server/src/clients/AuthentikClient/index.ts index f314b49..7b68ca0 100644 --- a/peopleportal/server/src/clients/AuthentikClient/index.ts +++ b/peopleportal/server/src/clients/AuthentikClient/index.ts @@ -16,15 +16,46 @@ along with this program. If not, see . */ -import axios from "axios" -import log from "loglevel" -import { AddGroupMemberRequest, AuthentikClientError, AuthentikServerVersion, CreateTeamRequest, CreateUserRequest, GetGroupInfoRequestOptions, GetGroupInfoResponse as GetGroupInfoResponse, GetTeamsListOptions as GetGroupsListOptions, GetTeamsListResponse as GetGroupsListResponse, GetTeamsForUsernameResponse, GetUserListOptions, GetUserListResponse, RemoveGroupMemberRequest, TeamAttributeDefinition, TeamInformationBrief, UserAttributeDefinition, UserInformationBrief, AuthentikFilterCursor, ServiceSeasonType, AuthentikClientErrorType, TeamType, UserInformationDetail, UserInformationPartial, TeamInformationDetail, GetTeamsListDetailResponse, TeamMembershipBrief, GetTeamMembershipsResponse } from "./models" -import { sanitizeGroupName } from "../../utils/strings" -import { EnabledRootSettings } from "../../controllers/OrgController" -import { BindleController, EnabledBindlePermissions } from "../../controllers/BindleController" -import { ENABLED_SERVICE_TEAMS } from "../../config" -import { ENABLED_SERVICE_TEAM_NAMES } from "../../utils/services" -import { mapWithConcurrency } from "../../utils/concurrency" +import axios from "axios"; +import log from "loglevel"; +import { + AddGroupMemberRequest, + AuthentikClientError, + AuthentikServerVersion, + CreateTeamRequest, + CreateUserRequest, + GetGroupInfoRequestOptions, + GetGroupInfoResponse as GetGroupInfoResponse, + GetTeamsListOptions as GetGroupsListOptions, + GetTeamsListResponse as GetGroupsListResponse, + GetTeamsForUsernameResponse, + GetUserListOptions, + GetUserListResponse, + RemoveGroupMemberRequest, + TeamAttributeDefinition, + TeamInformationBrief, + UserAttributeDefinition, + UserInformationBrief, + AuthentikFilterCursor, + ServiceSeasonType, + AuthentikClientErrorType, + TeamType, + UserInformationDetail, + UserInformationPartial, + TeamInformationDetail, + GetTeamsListDetailResponse, + TeamMembershipBrief, + GetTeamMembershipsResponse, +} from "./models"; +import { sanitizeGroupName } from "../../utils/strings"; +import { EnabledRootSettings } from "../../controllers/OrgController"; +import { + BindleController, + EnabledBindlePermissions, +} from "../../controllers/BindleController"; +import { ENABLED_SERVICE_TEAMS } from "../../config"; +import { ENABLED_SERVICE_TEAM_NAMES } from "../../utils/services"; +import { mapWithConcurrency } from "../../utils/concurrency"; export class AuthentikClient { /* Authentik is a small self-hosted instance; a handful of parallel lookups @@ -32,53 +63,68 @@ export class AuthentikClient { queue. */ private static readonly GROUP_FETCH_CONCURRENCY = 5; - private static readonly TAG = "AuthentikClient" - private AUTHENTIK_TOKEN = process.env.PEOPLEPORTAL_AUTHENTIK_TOKEN + private static readonly TAG = "AuthentikClient"; + private AUTHENTIK_TOKEN = process.env.PEOPLEPORTAL_AUTHENTIK_TOKEN; private readonly AxiosBaseConfig = { baseURL: process.env.PEOPLEPORTAL_AUTHENTIK_ENDPOINT ?? "", maxBodyLength: Infinity, headers: { - 'Accept': "application/json", - 'Authorization': `Bearer ${this.AUTHENTIK_TOKEN}` - } - } + Accept: "application/json", + Authorization: `Bearer ${this.AUTHENTIK_TOKEN}`, + }, + }; /* Server Version Compatibility */ - private static readonly MIN_SERVER_VERSION = "2025.12.0" /* Breaking Changes from 2025.8+ */ - private static readonly MAX_SERVER_VERSION = "2026.5.2" + private static readonly MIN_SERVER_VERSION = + "2025.12.0"; /* Breaking Changes from 2025.8+ */ + private static readonly MAX_SERVER_VERSION = "2026.5.2"; constructor() { if (!this.AxiosBaseConfig.baseURL) - throw new Error("Authentik Backend URL is Invalid!") + throw new Error("Authentik Backend URL is Invalid!"); if (!this.AUTHENTIK_TOKEN) - throw new Error("Authentik Token is Invalid!") + throw new Error("Authentik Token is Invalid!"); } public static validateAuthentikConnection = async () => { const client = new AuthentikClient(); var RequestConfig: any = { ...client.AxiosBaseConfig, - method: 'get', + method: "get", url: `/api/v3/admin/version/`, - } + }; try { - const res = await axios.request(RequestConfig) - const version = new AuthentikServerVersion(res.data.version_current); - if (!version.isAtLeast(AuthentikClient.MIN_SERVER_VERSION) || !version.isAtMost(AuthentikClient.MAX_SERVER_VERSION)) + const res = await axios.request(RequestConfig); + const version = new AuthentikServerVersion( + res.data.version_current, + ); + if ( + !version.isAtLeast(AuthentikClient.MIN_SERVER_VERSION) || + !version.isAtMost(AuthentikClient.MAX_SERVER_VERSION) + ) throw new AuthentikClientError( AuthentikClientErrorType.SERVER_VERSION_MISTMATCH, - `Authentik Server Version ${version} is not compatible with People Portal. Please ensure the server is greater than ${AuthentikClient.MIN_SERVER_VERSION} and less than ${AuthentikClient.MAX_SERVER_VERSION}` + `Authentik Server Version ${version} is not compatible with People Portal. Please ensure the server is greater than ${AuthentikClient.MIN_SERVER_VERSION} and less than ${AuthentikClient.MAX_SERVER_VERSION}`, ); /* Log Authentik Connection */ - log.info(AuthentikClient.TAG, `Connection Validated to Authentik v${version}`); + log.info( + AuthentikClient.TAG, + `Connection Validated to Authentik v${version}`, + ); } catch (e) { - log.error(AuthentikClient.TAG, "Authentik Connection Validation Failed: ", e); - throw new AuthentikClientError(AuthentikClientErrorType.SERVER_CONNECTION_ERROR); + log.error( + AuthentikClient.TAG, + "Authentik Connection Validation Failed: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.SERVER_CONNECTION_ERROR, + ); } - } + }; public validateServiceExistance = async () => { log.info(AuthentikClient.TAG, "Validating Service Teams"); @@ -98,22 +144,32 @@ export class AuthentikClient { description: rootTeamConfig.description, teamStartDate: new Date().toISOString().slice(0, 10), teamEndDate: new Date().toISOString().slice(0, 10), - } - }) + }, + }); /* Set Root Team PK */ rootTeamPk = rootServiceTeam.pk; } catch (e) { /* If Duplicate Group Exists, Fetch the Existing Group */ - if (e instanceof AuthentikClientError && e.code == AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS) { + if ( + e instanceof AuthentikClientError && + e.code == AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS + ) { try { - rootTeamPk = await this.getGroupPkFromName(rootTeamName); + rootTeamPk = + await this.getGroupPkFromName(rootTeamName); } catch (fetchError) { - console.error(`Failed to fetch existing service team ${rootTeamName}`, fetchError); + console.error( + `Failed to fetch existing service team ${rootTeamName}`, + fetchError, + ); continue; } } else { - console.error(`Failed to create service team ${rootTeamName}`, e); + console.error( + `Failed to create service team ${rootTeamName}`, + e, + ); continue; } } @@ -133,21 +189,35 @@ export class AuthentikClient { seasonType: ServiceSeasonType.ROLLING, seasonYear: new Date().getFullYear(), description: subTeam.description, - teamStartDate: new Date().toISOString().slice(0, 10), + teamStartDate: new Date() + .toISOString() + .slice(0, 10), teamEndDate: new Date().toISOString().slice(0, 10), - } - }) + }, + }); subTeamPk = createdSubTeam.pk; } catch (e) { - if (e instanceof AuthentikClientError && e.code == AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS) { + if ( + e instanceof AuthentikClientError && + e.code == + AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS + ) { try { - subTeamPk = await this.getGroupPkFromName(subTeam.uniqueName); + subTeamPk = await this.getGroupPkFromName( + subTeam.uniqueName, + ); } catch (fetchError) { - console.error(`Failed to fetch existing subteam ${subTeam.uniqueName}`, fetchError); + console.error( + `Failed to fetch existing subteam ${subTeam.uniqueName}`, + fetchError, + ); continue; } } else { - console.error(`Failed to create subteam ${subTeam.uniqueName}`, e); + console.error( + `Failed to create subteam ${subTeam.uniqueName}`, + e, + ); continue; } } @@ -156,25 +226,29 @@ export class AuthentikClient { if (subTeam.bindles && subTeamPk) { await this.updateBindlePermissions( subTeamPk, - BindleController.sanitizeBindlePermissions(subTeam.bindles) + BindleController.sanitizeBindlePermissions( + subTeam.bindles, + ), ); } } } - } + }; - public getUserInfo = async (userId: number): Promise => { + public getUserInfo = async ( + userId: number, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', + method: "get", url: `/api/v3/core/users/${userId}/`, params: { - type: 'internal', - } - } + type: "internal", + }, + }; try { - const res = await axios.request(RequestConfig) + const res = await axios.request(RequestConfig); return { pk: res.data.pk, username: res.data.username, @@ -191,12 +265,21 @@ export class AuthentikClient { avatar: res.data.avatar, }; } catch (e) { - log.error(AuthentikClient.TAG, "User Info Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.USERINFO_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "User Info Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.USERINFO_REQUEST_FAILED, + ); } - } + }; - public getFullUserList = async (): Promise<{ users: UserInformationBrief[], failedPages: number[] }> => { + public getFullUserList = async (): Promise<{ + users: UserInformationBrief[]; + failedPages: number[]; + }> => { const allUsers: UserInformationBrief[] = []; const failedPages: number[] = []; @@ -208,11 +291,13 @@ export class AuthentikClient { allUsers.push(...firstPage.users); const totalPages = firstPage.pagination.total_pages; - const remainingPages = Array.from({ length: totalPages - 1 }, (_, i) => i + 2); - + const remainingPages = Array.from( + { length: totalPages - 1 }, + (_, i) => i + 2, + ); const results = await Promise.allSettled( - remainingPages.map(p => this.getUserList({ page: p })) + remainingPages.map((p) => this.getUserList({ page: p })), ); for (const [idx, result] of results.entries()) { @@ -220,79 +305,93 @@ export class AuthentikClient { if (result.status === "fulfilled") { allUsers.push(...result.value.users); } else { - log.error(AuthentikClient.TAG, `Failed to fetch page ${page}:`, result.reason); + log.error( + AuthentikClient.TAG, + `Failed to fetch page ${page}:`, + result.reason, + ); failedPages.push(page); } } return { users: allUsers, failedPages }; - } + }; - public getUserList = async (options: GetUserListOptions): Promise => { + public getUserList = async ( + options: GetUserListOptions, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', - url: '/api/v3/core/users/', + method: "get", + url: "/api/v3/core/users/", params: { /* Pick Only People Portal Created Users */ - path: 'peopleportal.atheesh.org/onboardeduser' - } - } + path: "peopleportal.atheesh.org/onboardeduser", + }, + }; console.log(RequestConfig); /* Append Optional Parameters */ - if (options?.page) - RequestConfig.params.page = options.page + if (options?.page) RequestConfig.params.page = options.page; - if (options?.search) - RequestConfig.params.search = options.search + if (options?.search) RequestConfig.params.search = options.search; try { - const res = await axios.request(RequestConfig) - const userListArray: UserInformationBrief[] = res.data.results.map((user: any) => ({ - pk: user.pk, - username: user.username, - name: user.name, - email: user.email, - memberSince: user.date_joined, - active: user.is_active, - attributes: user.attributes, - })) + const res = await axios.request(RequestConfig); + const userListArray: UserInformationBrief[] = res.data.results.map( + (user: any) => ({ + pk: user.pk, + username: user.username, + name: user.name, + email: user.email, + memberSince: user.date_joined, + active: user.is_active, + attributes: user.attributes, + }), + ); /* Return Mapped List */ return { pagination: res.data.pagination, - users: userListArray + users: userListArray, }; } catch (e) { - log.error(AuthentikClient.TAG, "Get User List Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.USERLIST_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "Get User List Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.USERLIST_REQUEST_FAILED, + ); } - } + }; - public getRootTeamsForUsername = async (username: string): Promise => { + public getRootTeamsForUsername = async ( + username: string, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', - url: '/api/v3/core/groups/', + method: "get", + url: "/api/v3/core/groups/", params: { include_users: false, is_superuser: false, - include_parents: true, /* For Subteams, We return the Parent Team */ - include_children: false, /* We don't need subteams */ - members_by_username: username, /* Fetch Groups with Username as Member */ + include_parents: true /* For Subteams, We return the Parent Team */, + include_children: false /* We don't need subteams */, + members_by_username: + username /* Fetch Groups with Username as Member */, page_size: 1000, - } - } + }, + }; try { - const res = await axios.request(RequestConfig) + const res = await axios.request(RequestConfig); const teamMap = new Map(); for (const group of res.data.results) { /* Filter by People Portal Creation */ - if (!group.attributes?.peoplePortalCreation) - continue; + if (!group.attributes?.peoplePortalCreation) continue; const parentPk = group.parents?.[0] ?? null; @@ -310,8 +409,8 @@ export class AuthentikClient { teamMap.set(parentTeam.pk, { name: parentTeam.name, pk: parentTeam.pk, - parent: null, /* Parent of a Parent is null (Root Team) */ - ...parentTeam.attributes + parent: null /* Parent of a Parent is null (Root Team) */, + ...parentTeam.attributes, }); } else { /* Skip Archived Root Teams */ @@ -322,50 +421,59 @@ export class AuthentikClient { name: group.name, pk: group.pk, parent: null, - ...group.attributes + ...group.attributes, }); } } return { - teams: Array.from(teamMap.values()) + teams: Array.from(teamMap.values()), }; } catch (e) { - log.error(AuthentikClient.TAG, "Get Group List for User Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "Get Group List for User Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED, + ); } - } + }; - public getTeamMembershipsForUsername = async (username: string): Promise => { + public getTeamMembershipsForUsername = async ( + username: string, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', - url: '/api/v3/core/groups/', + method: "get", + url: "/api/v3/core/groups/", params: { include_users: false, is_superuser: false, - include_parents: true, /* For Subteams, We return the Parent Team */ - include_children: false, /* We don't need subteams */ - members_by_username: username, /* Fetch Groups with Username as Member */ + include_parents: true /* For Subteams, We return the Parent Team */, + include_children: false /* We don't need subteams */, + members_by_username: + username /* Fetch Groups with Username as Member */, page_size: 1000, - } - } + }, + }; try { - const res = await axios.request(RequestConfig) + const res = await axios.request(RequestConfig); const map = new Map(); for (const group of res.data.results) { /* Filter by People Portal Creation */ - if (!group.attributes?.peoplePortalCreation) - continue; + if (!group.attributes?.peoplePortalCreation) continue; const parent = group.parents_obj?.[0] ?? null; - map.set(group.pk, { // key = the membership group pk (matches roles keys) + map.set(group.pk, { + // key = the membership group pk (matches roles keys) name: group.name, pk: group.pk, parent: parent?.pk ?? null, - ...group.attributes, // friendlyName, teamType, etc. of the SUBTEAM + ...group.attributes, // friendlyName, teamType, etc. of the SUBTEAM parentName: parent?.name, parentFriendlyName: parent?.attributes?.friendlyName, }); @@ -373,20 +481,28 @@ export class AuthentikClient { return { memberships: Array.from(map.values()) }; } catch (e) { - log.error(AuthentikClient.TAG, "Get Group List for User Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "Get Group List for User Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED, + ); } - } + }; /** * Fetches list of groups from Authentik and filters for People Portal Creations, * filters out subteams and returns only root level groups. Method provides infinite * scrolling support via Base64 encoded cursor pagination. - * + * * @param options Get Groups Options * @returns List of Groups */ - public getGroupsList = async (options: GetGroupsListOptions): Promise => { + public getGroupsList = async ( + options: GetGroupsListOptions, + ): Promise => { /* Default Limits */ const limit = options.limit ?? 20; @@ -394,21 +510,28 @@ export class AuthentikClient { let currentCursor: AuthentikFilterCursor = { authentikPage: 1, authentikIndex: 0, - searchHash: Buffer.from(options.search ?? "").toString('base64') + searchHash: Buffer.from(options.search ?? "").toString("base64"), }; /* Decode Cursor if provided */ if (options.cursor) { try { - const decoded = JSON.parse(Buffer.from(options.cursor, 'base64').toString('ascii')); + const decoded = JSON.parse( + Buffer.from(options.cursor, "base64").toString("ascii"), + ); /* Check Search Hash Consistency */ - const currentSearchHash = Buffer.from(options.search ?? "").toString('base64'); + const currentSearchHash = Buffer.from( + options.search ?? "", + ).toString("base64"); if (decoded.searchHash === currentSearchHash) { currentCursor = decoded; } } catch (e) { - log.warn(AuthentikClient.TAG, "Invalid Cursor Provided, resetting to start."); + log.warn( + AuthentikClient.TAG, + "Invalid Cursor Provided, resetting to start.", + ); } } @@ -419,42 +542,53 @@ export class AuthentikClient { while (collectedTeams.length < limit && hasMorePages) { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', - url: '/api/v3/core/groups/', + method: "get", + url: "/api/v3/core/groups/", params: { include_users: false, is_superuser: false, page: currentCursor.authentikPage, - page_size: 100, /* Fetch larger chunks internally */ - ordering: 'name', /* Standard Order for Streaming */ - } - } + page_size: 100 /* Fetch larger chunks internally */, + ordering: "name" /* Standard Order for Streaming */, + }, + }; if (options.includeUsers) - RequestConfig.params.include_users = options.includeUsers + RequestConfig.params.include_users = options.includeUsers; if (options.search) - RequestConfig.params.search = options.search + RequestConfig.params.search = options.search; const res = await axios.request(RequestConfig); const results = res.data.results; const totalPages = Math.ceil(res.data.pagination.count / 100); /* Iterate current page starting from cursor index */ - for (let i = currentCursor.authentikIndex; i < results.length; i++) { + for ( + let i = currentCursor.authentikIndex; + i < results.length; + i++ + ) { const entry = results[i]; /* Filter Logic */ - const parentPk = entry.parent ?? entry.parents?.[0] ?? null; /* 01-19-2026 (@atheesh): Filtering Patches to support Authentik v2025.12+ */ - const notArchived = options.includeArchived || !entry.attributes.archivedAt; - const isMatch = entry.attributes.peoplePortalCreation && notArchived && ((options.subgroupsOnly) ? parentPk : !parentPk); + const parentPk = + entry.parent ?? + entry.parents?.[0] ?? + null; /* 01-19-2026 (@atheesh): Filtering Patches to support Authentik v2025.12+ */ + const notArchived = + options.includeArchived || !entry.attributes.archivedAt; + const isMatch = + entry.attributes.peoplePortalCreation && + notArchived && + (options.subgroupsOnly ? parentPk : !parentPk); if (isMatch) { collectedTeams.push({ name: entry.name, pk: entry.pk, parent: parentPk, - ...entry.attributes + ...entry.attributes, }); } @@ -481,19 +615,26 @@ export class AuthentikClient { /* Construct Next Cursor */ let nextCursor: string | undefined = undefined; if (hasMorePages) { - nextCursor = Buffer.from(JSON.stringify(currentCursor)).toString('base64'); + nextCursor = Buffer.from( + JSON.stringify(currentCursor), + ).toString("base64"); } return { teams: collectedTeams, - ...(nextCursor && { nextCursor }) + ...(nextCursor && { nextCursor }), }; - } catch (e) { - log.error(AuthentikClient.TAG, "Get Teams List Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "Get Teams List Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED, + ); } - } + }; /** * Fetches list of groups from Authentik and filters for People Portal Creations, @@ -501,11 +642,13 @@ export class AuthentikClient { * scrolling support via Base64 encoded cursor pagination. Returns detailed team * information rather than brief team information, allowing for avoidance of redundant * API calls. - * + * * @param options Get Groups Options * @returns List of Groups */ - public getGroupsListDetail = async (options: GetGroupsListOptions): Promise => { + public getGroupsListDetail = async ( + options: GetGroupsListOptions, + ): Promise => { /* Default Limits */ const limit = options.limit ?? 20; @@ -513,21 +656,28 @@ export class AuthentikClient { let currentCursor: AuthentikFilterCursor = { authentikPage: 1, authentikIndex: 0, - searchHash: Buffer.from(options.search ?? "").toString('base64') + searchHash: Buffer.from(options.search ?? "").toString("base64"), }; /* Decode Cursor if provided */ if (options.cursor) { try { - const decoded = JSON.parse(Buffer.from(options.cursor, 'base64').toString('ascii')); + const decoded = JSON.parse( + Buffer.from(options.cursor, "base64").toString("ascii"), + ); /* Check Search Hash Consistency */ - const currentSearchHash = Buffer.from(options.search ?? "").toString('base64'); + const currentSearchHash = Buffer.from( + options.search ?? "", + ).toString("base64"); if (decoded.searchHash === currentSearchHash) { currentCursor = decoded; } } catch (e) { - log.warn(AuthentikClient.TAG, "Invalid Cursor Provided, resetting to start."); + log.warn( + AuthentikClient.TAG, + "Invalid Cursor Provided, resetting to start.", + ); } } @@ -538,44 +688,56 @@ export class AuthentikClient { while (collectedTeams.length < limit && hasMorePages) { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', - url: '/api/v3/core/groups/', + method: "get", + url: "/api/v3/core/groups/", params: { include_users: true, include_children: true, is_superuser: false, page: currentCursor.authentikPage, - page_size: 100, /* Fetch larger chunks internally */ - ordering: 'name', /* Standard Order for Streaming */ - } - } + page_size: 100 /* Fetch larger chunks internally */, + ordering: "name" /* Standard Order for Streaming */, + }, + }; if (options.includeUsers) - RequestConfig.params.include_users = options.includeUsers + RequestConfig.params.include_users = options.includeUsers; if (options.search) - RequestConfig.params.search = options.search + RequestConfig.params.search = options.search; const res = await axios.request(RequestConfig); const results = res.data.results; const totalPages = Math.ceil(res.data.pagination.count / 100); /* Iterate current page starting from cursor index */ - for (let i = currentCursor.authentikIndex; i < results.length; i++) { + for ( + let i = currentCursor.authentikIndex; + i < results.length; + i++ + ) { const entry = results[i]; /* Filter Logic */ - const parentPk = entry.parent ?? entry.parents?.[0] ?? null; /* 01-19-2026 (@atheesh): Filtering Patches to support Authentik v2025.12+ */ + const parentPk = + entry.parent ?? + entry.parents?.[0] ?? + null; /* 01-19-2026 (@atheesh): Filtering Patches to support Authentik v2025.12+ */ /* Archived teams are excluded unless asked for, matching getGroupsList. Without this the org chart, the only caller, kept drawing teams that had been archived: archiveTeam stamps attributes.archivedAt and tears down the team's Slack, Gitea and AWS resources, but nothing removed the group, so it still arrived here. */ - const notArchived = options.includeArchived || !entry.attributes.archivedAt; - const isMatch = notArchived && entry.attributes.peoplePortalCreation && ((options.subgroupsOnly) ? parentPk : !parentPk); - - let parent_obj: TeamInformationBrief | undefined = undefined; + const notArchived = + options.includeArchived || !entry.attributes.archivedAt; + const isMatch = + notArchived && + entry.attributes.peoplePortalCreation && + (options.subgroupsOnly ? parentPk : !parentPk); + + let parent_obj: TeamInformationBrief | undefined = + undefined; if (parentPk !== null && entry.parent_obj != null) { for (const parent of entry.parents_obj) { if (parent.pk === parentPk) { @@ -584,36 +746,50 @@ export class AuthentikClient { } } } - + let users: UserInformationPartial[] = []; if (entry.users_obj != null) { - users = entry.users_obj.map((user_obj: { - pk: number, username: string, name: string, is_active: boolean, - last_login: Date, email: string, attributes: UserAttributeDefinition, uid: string - }) => { - return { - pk: user_obj.pk, - username: user_obj.username, - name: user_obj.name, - email: user_obj.email, - active: user_obj.is_active, - attributes: user_obj.attributes, - }; - }); + users = entry.users_obj.map( + (user_obj: { + pk: number; + username: string; + name: string; + is_active: boolean; + last_login: Date; + email: string; + attributes: UserAttributeDefinition; + uid: string; + }) => { + return { + pk: user_obj.pk, + username: user_obj.username, + name: user_obj.name, + email: user_obj.email, + active: user_obj.is_active, + attributes: user_obj.attributes, + }; + }, + ); } - + let subteams: TeamInformationBrief[] = []; if (entry.children_obj != null) { - subteams = entry.children_obj.map((subteam: { - pk: string, name: string, is_superuser: boolean, attributes: TeamAttributeDefinition, group_uuid: string, - }) => { - return { - pk: subteam.pk, - name: subteam.name, - parent: entry.pk, - ...subteam.attributes - }; - }); + subteams = entry.children_obj.map( + (subteam: { + pk: string; + name: string; + is_superuser: boolean; + attributes: TeamAttributeDefinition; + group_uuid: string; + }) => { + return { + pk: subteam.pk, + name: subteam.name, + parent: entry.pk, + ...subteam.attributes, + }; + }, + ); } if (isMatch) { @@ -625,7 +801,7 @@ export class AuthentikClient { parentInfo: parent_obj, subteamPkList: entry.children, subteams: subteams, - ...entry.attributes + ...entry.attributes, }); } @@ -652,51 +828,62 @@ export class AuthentikClient { /* Construct Next Cursor */ let nextCursor: string | undefined = undefined; if (hasMorePages) { - nextCursor = Buffer.from(JSON.stringify(currentCursor)).toString('base64'); + nextCursor = Buffer.from( + JSON.stringify(currentCursor), + ).toString("base64"); } return { teams: collectedTeams, - ...(nextCursor && { nextCursor }) + ...(nextCursor && { nextCursor }), }; - } catch (e) { - log.error(AuthentikClient.TAG, "Get Teams List Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "Get Teams List Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUPLIST_REQUEST_FAILED, + ); } - } + }; public getGroupPkFromName = async (teamName: string): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', + method: "get", url: `/api/v3/core/groups/`, params: { include_users: false, include_children: false, - name: teamName - } - } + name: teamName, + }, + }; try { - const res = await axios.request(RequestConfig) - const teams = res.data.results + const res = await axios.request(RequestConfig); + const teams = res.data.results; /* Filter for Exact Match */ - const exactMatches = teams.filter((t: any) => t.name === teamName) + const exactMatches = teams.filter((t: any) => t.name === teamName); if (exactMatches.length < 1) - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_NOT_FOUND) + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_NOT_FOUND, + ); if (exactMatches.length > 1) - throw new AuthentikClientError(AuthentikClientErrorType.MULTIPLE_RESULTS_RETURNED) + throw new AuthentikClientError( + AuthentikClientErrorType.MULTIPLE_RESULTS_RETURNED, + ); - const team = exactMatches[0] - return team.pk + const team = exactMatches[0]; + return team.pk; } catch (e) { - throw e + throw e; } - } + }; /** * Resolves many groups in one request instead of one request per group. @@ -715,7 +902,7 @@ export class AuthentikClient { */ public getGroupsInfoByPks = async ( pks: readonly string[], - options?: GetGroupInfoRequestOptions + options?: GetGroupInfoRequestOptions, ): Promise> => { const resolved = new Map(); const wanted = new Set(pks); @@ -735,8 +922,8 @@ export class AuthentikClient { username: user.username, name: user.name, email: user.email, - attributes: user.attributes - })) + attributes: user.attributes, + })), }); /* One sweep of the directory. Authentik has no pk__in filter, so the @@ -748,19 +935,20 @@ export class AuthentikClient { while (page <= totalPages && resolved.size < wanted.size) { const res = await axios.request({ ...this.AxiosBaseConfig, - method: 'get', - url: '/api/v3/core/groups/', + method: "get", + url: "/api/v3/core/groups/", params: { include_users: includeUsers, include_children: options?.includeChildren ?? true, include_parents: options?.includeParentInfo ?? false, page, - page_size: 1000 - } + page_size: 1000, + }, }); for (const entry of res.data.results ?? []) { - if (wanted.has(entry.pk)) resolved.set(entry.pk, toGroupInfo(entry)); + if (wanted.has(entry.pk)) + resolved.set(entry.pk, toGroupInfo(entry)); } totalPages = res.data.pagination?.total_pages ?? 1; @@ -769,7 +957,11 @@ export class AuthentikClient { } catch (e) { /* A failed sweep is not fatal; every pk simply falls through to the per-group path below, which is the old behaviour. */ - log.warn(AuthentikClient.TAG, "Bulk group fetch failed, falling back to individual lookups: ", e) + log.warn( + AuthentikClient.TAG, + "Bulk group fetch failed, falling back to individual lookups: ", + e, + ); } const missing = [...wanted].filter((pk) => !resolved.has(pk)); @@ -778,7 +970,7 @@ export class AuthentikClient { const settled = await mapWithConcurrency( missing, AuthentikClient.GROUP_FETCH_CONCURRENCY, - (pk) => this.getGroupInfo(pk, options) + (pk) => this.getGroupInfo(pk, options), ); for (const [i, result] of settled.entries()) { @@ -790,38 +982,43 @@ export class AuthentikClient { /* GROUP_NOT_FOUND is meaningful to callers and must not be flattened into "missing from the map" alongside a network failure, so it is rethrown. */ - if (result.reason instanceof AuthentikClientError && - result.reason.code === AuthentikClientErrorType.GROUP_NOT_FOUND) + if ( + result.reason instanceof AuthentikClientError && + result.reason.code === AuthentikClientErrorType.GROUP_NOT_FOUND + ) continue; throw result.reason; } return resolved; - } + }; - public getGroupInfo = async (teamId: string, options?: GetGroupInfoRequestOptions): Promise => { + public getGroupInfo = async ( + teamId: string, + options?: GetGroupInfoRequestOptions, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', + method: "get", url: `/api/v3/core/groups/${teamId}/`, params: { /* Avoid Breaking Changes of Including Request Options, Default to True */ include_users: options?.includeUsers ?? true, include_children: options?.includeChildren ?? true, - include_parents: options?.includeParentInfo ?? false - } - } + include_parents: options?.includeParentInfo ?? false, + }, + }; try { - const res = await axios.request(RequestConfig) - const subteams = res.data.children_obj + const res = await axios.request(RequestConfig); + const subteams = res.data.children_obj; /* Populate Users Within Subteams, Optional to Avoid Breaking Changes */ - if (!(options?.disableSubteamMemberPopulate)) { + if (!options?.disableSubteamMemberPopulate) { for (let subteam of subteams) { - const subteamInfo = await this.getGroupInfo(subteam.pk) - subteam.users = subteamInfo.users + const subteamInfo = await this.getGroupInfo(subteam.pk); + subteam.users = subteamInfo.users; } } @@ -830,105 +1027,132 @@ export class AuthentikClient { name: res.data.name, subteamPkList: res.data.children, subteams: res.data.children_obj, - parentPk: res.data.parent ?? res.data.parents?.[0] ?? null, /* People Portal Legacy Single Parent Patch */ - parentInfo: res.data.parents_obj ? res.data.parents_obj[0] : null, /* Authentik 2025.12+ Only */ + parentPk: + res.data.parent ?? + res.data.parents?.[0] ?? + null /* People Portal Legacy Single Parent Patch */, + parentInfo: res.data.parents_obj + ? res.data.parents_obj[0] + : null /* Authentik 2025.12+ Only */, attributes: res.data.attributes, users: (res.data.users_obj ?? []).map((user: any) => ({ pk: user.pk, username: user.username, name: user.name, email: user.email, - attributes: user.attributes - })) - } + attributes: user.attributes, + })), + }; } catch (e) { /* Log The Error */ - log.error(AuthentikClient.TAG, "Get Teams List Request Failed with Error: ", e) + log.error( + AuthentikClient.TAG, + "Get Teams List Request Failed with Error: ", + e, + ); /* 02-06-2026 (@atheesh): Data Integrity Compromise, See COE at https://wiki.appdevclub.com/people-portal-dev-guide/coe/ats-open-roles-failure */ - if (axios.isAxiosError(e) && e.response?.status === 404 && e.response?.headers['x-powered-by'] === 'authentik') - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_NOT_FOUND) + if ( + axios.isAxiosError(e) && + e.response?.status === 404 && + e.response?.headers["x-powered-by"] === "authentik" + ) + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_NOT_FOUND, + ); /* We Just Know that the Request Failed! */ - throw new AuthentikClientError(AuthentikClientErrorType.GROUPINFO_REQUEST_FAILED) + throw new AuthentikClientError( + AuthentikClientErrorType.GROUPINFO_REQUEST_FAILED, + ); } - } + }; /** - * Private method to update any group attributes. + * Private method to update any group attributes. * Warning: Shouldn't be made public and inputs need to be sanitized as it * executes directly on the Authentik Backend! - * + * * @param teamId Target Team ID * @param updatePayload Raw, Sanitized Update Payload * @returns True if the update was successful */ - private updateGroupAttributes = async (teamId: string, updatePayload: Partial): Promise => { + private updateGroupAttributes = async ( + teamId: string, + updatePayload: Partial, + ): Promise => { let groupInfo = await this.getGroupInfo(teamId); var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'patch', + method: "patch", url: `/api/v3/core/groups/${teamId}/`, data: { attributes: { ...groupInfo.attributes, - ...updatePayload - } - } - } + ...updatePayload, + }, + }, + }; try { - await axios.request(RequestConfig) - return true + await axios.request(RequestConfig); + return true; } catch (e) { - log.error(AuthentikClient.TAG, "Update Group Attributes Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUPATTR_UPDATE_FAILED) + log.error( + AuthentikClient.TAG, + "Update Group Attributes Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUPATTR_UPDATE_FAILED, + ); } - } + }; /** * Method to sanitize and update the Root Team Settings. * Internally calls updateGroupAttributes. - * + * * @param teamId Target Team ID * @param teamSettings Unsanitized Root Team Settings * @returns True if update was successful */ - public updateRootTeamSettings = async (teamId: string, teamSettings: { [key: string]: EnabledRootSettings }): Promise => { - return await this.updateGroupAttributes( - teamId, - { rootTeamSettings: teamSettings } - ) - } + public updateRootTeamSettings = async ( + teamId: string, + teamSettings: { [key: string]: EnabledRootSettings }, + ): Promise => { + return await this.updateGroupAttributes(teamId, { + rootTeamSettings: teamSettings, + }); + }; /** * Method to Update Team Bindle Permissions. * Internally calls updateGroupAttributes. - * + * * @param teamId Target Team ID * @param bindlePermissions Unsanitized Bindle Perissions Map * @returns True if update was successful */ - public updateBindlePermissions = async (teamId: string, bindlePermissions: { [key: string]: EnabledBindlePermissions }): Promise => { - return await this.updateGroupAttributes( - teamId, - { bindlePermissions } - ) - } + public updateBindlePermissions = async ( + teamId: string, + bindlePermissions: { [key: string]: EnabledBindlePermissions }, + ): Promise => { + return await this.updateGroupAttributes(teamId, { bindlePermissions }); + }; /** * Flags a group for deletion. * Internally calls updateGroupAttributes. - * + * * @param teamId Target Team ID * @returns True if update was successful */ public flagGroupForDeletion = async (teamId: string): Promise => { - return await this.updateGroupAttributes( - teamId, - { flaggedForDeletion: true } - ) - } + return await this.updateGroupAttributes(teamId, { + flaggedForDeletion: true, + }); + }; /** * Marks a group as archived by stamping an `archivedAt` timestamp (and @@ -938,25 +1162,33 @@ export class AuthentikClient { * @param archivedBy Optional User PK of the executive performing the archive * @returns True if update was successful */ - public archiveGroup = async (teamId: string, archivedBy?: string): Promise => { - return await this.updateGroupAttributes( - teamId, - { - archivedAt: new Date().toISOString(), - ...(archivedBy !== undefined && { archivedBy }) - } - ) - } + public archiveGroup = async ( + teamId: string, + archivedBy?: string, + ): Promise => { + return await this.updateGroupAttributes(teamId, { + archivedAt: new Date().toISOString(), + ...(archivedBy !== undefined && { archivedBy }), + }); + }; /** * Validates and updates group information (Friendly Name, Description, Team Dates). - * + * * @param teamId Target Team ID * @param conf Configuration object containing potential updates */ - public updateGroupInformation = async (teamId: string, conf: { [key: string]: string | undefined }) => { + public updateGroupInformation = async ( + teamId: string, + conf: { [key: string]: string | undefined }, + ) => { /* Strictly restrict updates to allowed fields to prevent attribute pollution */ - const allowedFields = ["friendlyName", "description", "teamStartDate", "teamEndDate"]; + const allowedFields = [ + "friendlyName", + "description", + "teamStartDate", + "teamEndDate", + ]; const filteredConf: any = {}; for (const key of allowedFields) { @@ -973,115 +1205,152 @@ export class AuthentikClient { } await this.updateGroupAttributes(teamId, filteredConf); - } + }; /** * Method to update User Attibutes in the Authentik Backend. * Requires verified update payload of type Partial - * + * * @param userId Target User ID * @param updatePayload Verified Update Payload * @returns True if update was successful */ - public updateUserAttributes = async (userId: number, updatePayload: Partial): Promise => { + public updateUserAttributes = async ( + userId: number, + updatePayload: Partial, + ): Promise => { let userInfo = await this.getUserInfo(userId); var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'patch', + method: "patch", url: `/api/v3/core/users/${userId}/`, data: { attributes: { ...userInfo.attributes, - ...updatePayload - } - } - } + ...updatePayload, + }, + }, + }; try { - await axios.request(RequestConfig) - return true + await axios.request(RequestConfig); + return true; } catch (e) { - log.error(AuthentikClient.TAG, "Update User Attributes Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.USERATTR_UPDATE_FAILED) + log.error( + AuthentikClient.TAG, + "Update User Attributes Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.USERATTR_UPDATE_FAILED, + ); } - } + }; - public addGroupMember = async (request: AddGroupMemberRequest): Promise => { + public addGroupMember = async ( + request: AddGroupMemberRequest, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'post', + method: "post", url: `/api/v3/core/groups/${request.groupId}/add_user/`, - data: { pk: request.userPk } - } + data: { pk: request.userPk }, + }; try { - await axios.request(RequestConfig) - return true + await axios.request(RequestConfig); + return true; } catch (e) { - log.error(AuthentikClient.TAG, "Add Team Member Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_ADD_MEMBER_FAILED) + log.error( + AuthentikClient.TAG, + "Add Team Member Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_ADD_MEMBER_FAILED, + ); } - } + }; - public removeGroupMember = async (request: RemoveGroupMemberRequest): Promise => { + public removeGroupMember = async ( + request: RemoveGroupMemberRequest, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'post', + method: "post", url: `/api/v3/core/groups/${request.groupId}/remove_user/`, - data: { pk: request.userPk } - } + data: { pk: request.userPk }, + }; try { - await axios.request(RequestConfig) - return true + await axios.request(RequestConfig); + return true; } catch (e) { - log.error(AuthentikClient.TAG, "Remove Team Member Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_DEL_MEMBER_FAILED) + log.error( + AuthentikClient.TAG, + "Remove Team Member Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_DEL_MEMBER_FAILED, + ); } - } + }; public removeAllTeamMembers = async (groupId: string): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'patch', + method: "patch", url: `/api/v3/core/groups/${groupId}/`, - data: { users: [] } - } + data: { users: [] }, + }; try { - await axios.request(RequestConfig) - return true + await axios.request(RequestConfig); + return true; } catch (e) { - log.error(AuthentikClient.TAG, "Remove All Team Members Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_DEL_MEMBER_FAILED) + log.error( + AuthentikClient.TAG, + "Remove All Team Members Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_DEL_MEMBER_FAILED, + ); } - } + }; - public getUserInfoFromEmail = async (email: string): Promise => { + public getUserInfoFromEmail = async ( + email: string, + ): Promise => { var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'get', + method: "get", url: `/api/v3/core/users/`, params: { email, - type: 'internal', - } - } + type: "internal", + }, + }; try { - const res = await axios.request(RequestConfig) - const users = res.data.results + const res = await axios.request(RequestConfig); + const users = res.data.results; /* Filter for Exact Match */ - const exactMatches = users.filter((u: any) => u.email === email) + const exactMatches = users.filter((u: any) => u.email === email); if (exactMatches.length < 1) - throw new AuthentikClientError(AuthentikClientErrorType.USER_NOT_FOUND) + throw new AuthentikClientError( + AuthentikClientErrorType.USER_NOT_FOUND, + ); if (exactMatches.length > 1) - throw new AuthentikClientError(AuthentikClientErrorType.MULTIPLE_RESULTS_RETURNED) + throw new AuthentikClientError( + AuthentikClientErrorType.MULTIPLE_RESULTS_RETURNED, + ); - const user = exactMatches[0] + const user = exactMatches[0]; return { pk: user.pk, username: user.username, @@ -1092,22 +1361,32 @@ export class AuthentikClient { attributes: user.attributes, is_superuser: user.is_superuser, avatar: user.avatar, - } + }; } catch (e) { - log.error(AuthentikClient.TAG, "Get User PK Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.USERINFO_REQUEST_FAILED) + log.error( + AuthentikClient.TAG, + "Get User PK Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.USERINFO_REQUEST_FAILED, + ); } - } + }; - public createNewUser = async (request: CreateUserRequest): Promise => { + public createNewUser = async ( + request: CreateUserRequest, + ): Promise => { if (!request.email.endsWith("@terpmail.umd.edu")) - throw new Error("People Portal Currently Supports Terpmail Addresses Only!") + throw new Error( + "People Portal Currently Supports Terpmail Addresses Only!", + ); - let username = request.email.replace("@terpmail.umd.edu", "") + let username = request.email.replace("@terpmail.umd.edu", ""); var RequestConfigAddUser: any = { ...this.AxiosBaseConfig, - method: 'post', - url: '/api/v3/core/users/', + method: "post", + url: "/api/v3/core/users/", data: { username, name: request.name, @@ -1117,64 +1396,85 @@ export class AuthentikClient { attributes: { ...request.attributes, peoplePortalCreation: true, - } - } - } + }, + }, + }; try { - const res = await axios.request(RequestConfigAddUser) - const userPk = res.data.pk + const res = await axios.request(RequestConfigAddUser); + const userPk = res.data.pk; /* Set User Password */ var RequestConfigSetPassword: any = { ...this.AxiosBaseConfig, - method: 'post', + method: "post", url: `/api/v3/core/users/${userPk}/set_password/`, data: { - password: request.password - } - } + password: request.password, + }, + }; - await axios.request(RequestConfigSetPassword) - return true + await axios.request(RequestConfigSetPassword); + return true; } catch (e) { - log.error(AuthentikClient.TAG, "Create New User Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.USER_CREATE_FAILED) + log.error( + AuthentikClient.TAG, + "Create New User Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.USER_CREATE_FAILED, + ); } - } + }; /** * Creates a New Team in the Authentik database and sets default People Portal * creation attributes. - * + * * This method supports providing a parent to assist in the creation of subteams * within an existing team. - * + * * Additionally, this method supports creating service teams using the Service Team Name * parameter to create a service team with the specified name. - * + * * @param request Create Team Request * @returns Newly Created Team */ - public createNewTeam = async (request: CreateTeamRequest): Promise => { + public createNewTeam = async ( + request: CreateTeamRequest, + ): Promise => { if (request.parent && !request.parentName) - throw new Error("Creating a SubTeam needs a Parent Name!") + throw new Error("Creating a SubTeam needs a Parent Name!"); /* Validation: Service Season Types are restricted to Service Teams */ - if (!request.serviceTeamName && (Object.values(ServiceSeasonType) as string[]).includes(request.attributes.seasonType)) { - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_SEASON_NOTPERMITTED); + if ( + !request.serviceTeamName && + (Object.values(ServiceSeasonType) as string[]).includes( + request.attributes.seasonType, + ) + ) { + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_SEASON_NOTPERMITTED, + ); } - const attr = request.attributes - const teamName = request.serviceTeamName ? - request.serviceTeamName : - sanitizeGroupName(`${attr.friendlyName.replaceAll(" ", "")}${attr.seasonType}${attr.seasonYear}`) + const attr = request.attributes; + const teamName = request.serviceTeamName + ? request.serviceTeamName + : sanitizeGroupName( + `${attr.friendlyName.replaceAll(" ", "")}${attr.seasonType}${attr.seasonYear}`, + ); /* Validation: Ensure Regular Teams do not use Service Team Names */ if (!request.serviceTeamName) { for (const reservedName of ENABLED_SERVICE_TEAM_NAMES) { - if (teamName.toLowerCase().includes(reservedName.toLowerCase())) { - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS); + if ( + teamName.toLowerCase().includes(reservedName.toLowerCase()) + ) { + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS, + ); } } } @@ -1182,58 +1482,75 @@ export class AuthentikClient { /* Define Default Attributes */ const teamAttributes: TeamAttributeDefinition = { ...request.attributes, - peoplePortalCreation: true, /* Helps Identify People Portal Managed Entires! */ + peoplePortalCreation: true /* Helps Identify People Portal Managed Entires! */, rootTeamSettings: {}, bindlePermissions: {}, - } + }; /* Check if a team with the Same Name Exists! */ - let teamExists = false + let teamExists = false; try { - const teamPk = await this.getGroupPkFromName(teamName) - teamExists = true + const teamPk = await this.getGroupPkFromName(teamName); + teamExists = true; } catch (e) { /* If its some other error than count, throw it! */ - if (!(e instanceof AuthentikClientError)) - throw e + if (!(e instanceof AuthentikClientError)) throw e; } if (teamExists) - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS) + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_DUPLICATE_EXISTS, + ); var RequestConfig: any = { ...this.AxiosBaseConfig, - method: 'post', - url: '/api/v3/core/groups/', + method: "post", + url: "/api/v3/core/groups/", data: { is_superuser: false, - name: (request.parent && !request.serviceTeamName) ? `${sanitizeGroupName(request.parentName!)}${teamName}` : teamName, - parents: request.parent ? [request.parent] : [], /* Patched for Authentik v2025.12+ */ - attributes: teamAttributes - } - } + name: + request.parent && !request.serviceTeamName + ? `${sanitizeGroupName(request.parentName!)}${teamName}` + : teamName, + parents: request.parent + ? [request.parent] + : [] /* Patched for Authentik v2025.12+ */, + attributes: teamAttributes, + }, + }; try { - const res = await axios.request(RequestConfig) + const res = await axios.request(RequestConfig); return { pk: res.data.pk, name: res.data.name, subteamPkList: res.data.children, subteams: res.data.children_obj, - parentPk: res.data.parent ?? res.data.parents?.[0] ?? null, /* People Portal Legacy Single Parent Patch */ - parentInfo: res.data.parents_obj ? res.data.parents_obj[0] : null, /* Authentik 2025.12+ Only */ + parentPk: + res.data.parent ?? + res.data.parents?.[0] ?? + null /* People Portal Legacy Single Parent Patch */, + parentInfo: res.data.parents_obj + ? res.data.parents_obj[0] + : null /* Authentik 2025.12+ Only */, attributes: res.data.attributes, users: (res.data.users_obj ?? []).map((user: any) => ({ pk: user.pk, username: user.username, name: user.name, email: user.email, - attributes: user.attributes - })) - } + attributes: user.attributes, + })), + }; } catch (e) { - log.error(AuthentikClient.TAG, "Create Team Request Failed with Error: ", e) - throw new AuthentikClientError(AuthentikClientErrorType.GROUP_CREATE_FAILED) + log.error( + AuthentikClient.TAG, + "Create Team Request Failed with Error: ", + e, + ); + throw new AuthentikClientError( + AuthentikClientErrorType.GROUP_CREATE_FAILED, + ); } - } -} \ No newline at end of file + }; +} diff --git a/peopleportal/server/src/clients/AuthentikClient/models.ts b/peopleportal/server/src/clients/AuthentikClient/models.ts index 0a087c2..137df45 100644 --- a/peopleportal/server/src/clients/AuthentikClient/models.ts +++ b/peopleportal/server/src/clients/AuthentikClient/models.ts @@ -16,62 +16,65 @@ along with this program. If not, see . */ -import { EnabledBindlePermissions } from "../../controllers/BindleController" -import { EnabledRootSettings, RootTeamSettingInfo } from "../../controllers/OrgController" +import { EnabledBindlePermissions } from "../../controllers/BindleController"; +import { + EnabledRootSettings, + RootTeamSettingInfo, +} from "../../controllers/OrgController"; export enum TeamType { PROJECT = "PROJECT", CORPORATE = "CORPORATE", BOOTCAMP = "BOOTCAMP", - SERVICE = "SERVICE" + SERVICE = "SERVICE", } export enum SeasonType { FALL = "FALL", - SPRING = "SPRING" + SPRING = "SPRING", } export enum ServiceSeasonType { - ROLLING = "ROLLING" + ROLLING = "ROLLING", } export interface TeamAttributeDefinition { - friendlyName: string, - teamType: TeamType, - seasonType: SeasonType | ServiceSeasonType, - seasonYear: number, - teamStartDate?: string, - teamEndDate?: string, - peoplePortalCreation?: boolean, - flaggedForDeletion?: boolean, + friendlyName: string; + teamType: TeamType; + seasonType: SeasonType | ServiceSeasonType; + seasonYear: number; + teamStartDate?: string; + teamEndDate?: string; + peoplePortalCreation?: boolean; + flaggedForDeletion?: boolean; /* ISO timestamp set when the team is archived; absent means active */ - archivedAt?: string, + archivedAt?: string; /* User PK of the executive who archived the team */ - archivedBy?: string, - description: string, + archivedBy?: string; + description: string; rootTeamSettings: { /* The key is the setting name (Ex. AwsAccount, AppleDevAccount, etc.) */ - [key: string]: EnabledRootSettings - }, + [key: string]: EnabledRootSettings; + }; bindlePermissions: { /* The key is the Client name (Ex. GiteaClient, OpenIdClient, etc.) */ - [key: string]: EnabledBindlePermissions - } + [key: string]: EnabledBindlePermissions; + }; } export interface UserAttributeDefinition { - peoplePortalCreation?: boolean, - alumniAccount: boolean, - major: string, - expectedGrad: Date, - phoneNumber: string, - linkedinUrl?: string, + peoplePortalCreation?: boolean; + alumniAccount: boolean; + major: string; + expectedGrad: Date; + phoneNumber: string; + linkedinUrl?: string; roles: { /* Org ID Mapped to Role Title */ - [key: string]: string - }, - avatar?: string + [key: string]: string; + }; + avatar?: string; } export interface AuthentikFilterCursor { @@ -80,7 +83,6 @@ export interface AuthentikFilterCursor { searchHash: string; } - export interface PaginationDefinition { next: number; previous: number; @@ -93,86 +95,86 @@ export interface PaginationDefinition { /* Teams API Models */ export interface GetUserListOptions { - page?: number, - search?: string + page?: number; + search?: string; } export interface GetTeamsListOptions { - subgroupsOnly?: boolean, - includeUsers?: boolean, + subgroupsOnly?: boolean; + includeUsers?: boolean; /* When false (default), teams flagged with `archivedAt` are excluded */ - includeArchived?: boolean, - search?: string, - limit?: number, - cursor?: string + includeArchived?: boolean; + search?: string; + limit?: number; + cursor?: string; } export interface GetTeamsForUsernameResponse { - teams: TeamInformationBrief[] + teams: TeamInformationBrief[]; } /* Messy but, changing would cause breaking changes */ export interface TeamInformationBrief extends TeamAttributeDefinition { - parent: string | null, - name: string, - pk: string, + parent: string | null; + name: string; + pk: string; } /* Given in groups-list Authentik API endpoint */ export interface TeamInformationDetail extends TeamInformationBrief { - users: UserInformationPartial[], - parent_obj?: TeamInformationBrief, - subteamPkList: string[], - subteams: TeamInformationBrief[] + users: UserInformationPartial[]; + parent_obj?: TeamInformationBrief; + subteamPkList: string[]; + subteams: TeamInformationBrief[]; } export interface TeamMembershipBrief extends TeamInformationBrief { - parentName?: string, - parentFriendlyName?: string, + parentName?: string; + parentFriendlyName?: string; } export interface GetTeamMembershipsResponse { - memberships: TeamMembershipBrief[] + memberships: TeamMembershipBrief[]; } export interface GetTeamsListResponse { - teams: TeamInformationBrief[], - nextCursor?: string + teams: TeamInformationBrief[]; + nextCursor?: string; } export interface GetTeamsListDetailResponse { - teams: TeamInformationDetail[], - nextCursor?: string + teams: TeamInformationDetail[]; + nextCursor?: string; } export interface GetGroupInfoRequestOptions { - includeParentInfo?: boolean, - includeChildren?: boolean, - includeUsers?: boolean, - disableSubteamMemberPopulate?: boolean + includeParentInfo?: boolean; + includeChildren?: boolean; + includeUsers?: boolean; + disableSubteamMemberPopulate?: boolean; } export interface GetGroupInfoResponse { - pk: string, - name: string, - users: UserInformationBrief[], - parentPk: string, - parentInfo?: GetGroupInfoResponse, /* Optional to Avoid Breaking Changes */ - subteamPkList: string[], - subteams: GetGroupInfoResponse[], - attributes: TeamAttributeDefinition + pk: string; + name: string; + users: UserInformationBrief[]; + parentPk: string; + parentInfo?: GetGroupInfoResponse; /* Optional to Avoid Breaking Changes */ + subteamPkList: string[]; + subteams: GetGroupInfoResponse[]; + attributes: TeamAttributeDefinition; } export interface AddGroupMemberRequest { - groupId: string, - userPk: number, - roleTitle: string + groupId: string; + userPk: number; + roleTitle: string; } export interface RemoveGroupMemberRequest { - groupInfo?: GetGroupInfoResponse, /* We Can Optimize Calls if using Bindle Auth */ - groupId: string, - userPk: number + groupInfo?: GetGroupInfoResponse; /* We Can Optimize Calls if using Bindle Auth */ + groupId: string; + userPk: number; } export interface CreateUserRequest { @@ -185,64 +187,64 @@ export interface CreateUserRequest { expectedGrad: Date; phoneNumber: string; linkedinUrl?: string; - roles: { [key: string]: string } - } + roles: { [key: string]: string }; + }; } export interface CreateTeamRequest { - parent?: string, - parentName?: string, - isSuperuser?: boolean, - serviceTeamName?: string, + parent?: string; + parentName?: string; + isSuperuser?: boolean; + serviceTeamName?: string; attributes: { - friendlyName: string, - teamType: TeamType, - seasonType: SeasonType | ServiceSeasonType, - seasonYear: number, - description: string, + friendlyName: string; + teamType: TeamType; + seasonType: SeasonType | ServiceSeasonType; + seasonYear: number; + description: string; /* Optional: only top-level teams carry meeting dates; sub-teams omit them */ - teamStartDate?: string, - teamEndDate?: string, - } + teamStartDate?: string; + teamEndDate?: string; + }; } /* Returned in authentik groups-list API endpoint */ export interface UserInformationPartial { - pk: string, - username: string, - name: string, - email: string, - active: boolean, - attributes: UserAttributeDefinition, + pk: string; + username: string; + name: string; + email: string; + active: boolean; + attributes: UserAttributeDefinition; } /* User Information API Models */ export interface UserInformationBrief { - pk: string, - username: string, - name: string, - email: string, - memberSince: Date, - active: boolean, - attributes: UserAttributeDefinition, - is_superuser: boolean, - avatar: string, + pk: string; + username: string; + name: string; + email: string; + memberSince: Date; + active: boolean; + attributes: UserAttributeDefinition; + is_superuser: boolean; + avatar: string; } export interface UserInformationDetail extends UserInformationBrief { - groups: string[], - last_login: Date, - type: string, + groups: string[]; + last_login: Date; + type: string; groupsInfo: { - name: string, - pk: string, - attributes: TeamAttributeDefinition - }[], + name: string; + pk: string; + attributes: TeamAttributeDefinition; + }[]; } export interface GetUserListResponse { - pagination: PaginationDefinition, - users: UserInformationBrief[] + pagination: PaginationDefinition; + users: UserInformationBrief[]; } export enum AuthentikClientErrorType { @@ -268,9 +270,12 @@ export enum AuthentikClientErrorType { } export class AuthentikClientError extends Error { - constructor(public readonly code: AuthentikClientErrorType, messageOverride?: string) { - super(messageOverride ?? AuthentikClientError.getMessage(code)) - this.name = "AuthentikClientError" + constructor( + public readonly code: AuthentikClientErrorType, + messageOverride?: string, + ) { + super(messageOverride ?? AuthentikClientError.getMessage(code)); + this.name = "AuthentikClientError"; } private static getMessage(code: AuthentikClientErrorType): string { @@ -326,14 +331,14 @@ export class AuthentikServerVersion { /** * Compares two Authentik Version Strings - * + * * @param v1 Authentik Version String * @param v2 Authentik Version String * @returns -1, 0 or 1 for numeric comparison */ private static compare(v1: string, v2: string): number { - const p1 = v1.split('.').map(Number); - const p2 = v2.split('.').map(Number); + const p1 = v1.split(".").map(Number); + const p2 = v2.split(".").map(Number); const len = Math.max(p1.length, p2.length); for (let i = 0; i < len; i++) { diff --git a/peopleportal/server/src/clients/DiscordClient/index.ts b/peopleportal/server/src/clients/DiscordClient/index.ts index 9573426..7e207f1 100644 --- a/peopleportal/server/src/clients/DiscordClient/index.ts +++ b/peopleportal/server/src/clients/DiscordClient/index.ts @@ -16,11 +16,15 @@ along with this program. If not, see . */ -import { Client, Events, GatewayIntentBits, GuildBasedChannel } from 'discord.js' -import { SharedResourceClient } from '..'; -import { BindlePermissionMap } from '../../controllers/BindleController'; -import { GetGroupInfoResponse } from '../AuthentikClient/models'; - +import { + Client, + Events, + GatewayIntentBits, + GuildBasedChannel, +} from "discord.js"; +import { SharedResourceClient } from ".."; +import { BindlePermissionMap } from "../../controllers/BindleController"; +import { GetGroupInfoResponse } from "../AuthentikClient/models"; export class DiscordClient implements SharedResourceClient { private static readonly TAG = "DiscordClient"; @@ -62,21 +66,25 @@ export class DiscordClient implements SharedResourceClient { this.enabled = false; this.discordClient = null; console.warn( - `${DiscordClient.TAG}: ${missing.join(" and ")} not set; Discord integration disabled.` + `${DiscordClient.TAG}: ${missing.join(" and ")} not set; Discord integration disabled.`, ); return; } this.enabled = true; this.discordClient = new Client({ intents: DiscordClient.INTENTS }); - this.discordClient.on(Events.ClientReady, () => this.isReady = true); + this.discordClient.on(Events.ClientReady, () => (this.isReady = true)); /* login() is async and rejects on a bad/expired token. Unhandled, that rejection killed the process at import time, before Express ever listened, turning a Discord credential problem into a full outage. Degrade to a disabled Discord integration instead. */ - this.discordClient.login(process.env.PEOPLEPORTAL_DISCORD_BOT_TOKEN) + this.discordClient + .login(process.env.PEOPLEPORTAL_DISCORD_BOT_TOKEN) .catch((e) => { - console.error("DiscordClient: login failed, Discord integration disabled:", e?.message ?? e); + console.error( + "DiscordClient: login failed, Discord integration disabled:", + e?.message ?? e, + ); }); } @@ -90,7 +98,10 @@ export class DiscordClient implements SharedResourceClient { try { await this.waitForReady(); } catch (e: any) { - console.error(`${DiscordClient.TAG}: not ready, integration disabled:`, e?.message ?? e); + console.error( + `${DiscordClient.TAG}: not ready, integration disabled:`, + e?.message ?? e, + ); } } @@ -108,7 +119,7 @@ export class DiscordClient implements SharedResourceClient { const onReady = () => { clearTimeout(timeout); resolve(); - } + }; timeout = setTimeout(() => { client.off(Events.ClientReady, onReady); @@ -127,11 +138,17 @@ export class DiscordClient implements SharedResourceClient { } // Implement later when needed. - async handleOrgBindleSync(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + async handleOrgBindleSync( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { return Promise.resolve(true); } - async archiveTeam(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + async archiveTeam( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { // TODO: Implement Discord channel archival return Promise.resolve(true); } @@ -142,8 +159,12 @@ export class DiscordClient implements SharedResourceClient { if (!this.enabled || !this.discordClient) return undefined; // Uses guild id in .env in case bot is in multiple servers. - const guild = await this.discordClient.guilds.fetch(process.env.PEOPLEPORTAL_DISCORD_SERVER_ID!); - return guild.channels.cache.find(ch => ch.name === channelName && ch.isTextBased()); + const guild = await this.discordClient.guilds.fetch( + process.env.PEOPLEPORTAL_DISCORD_SERVER_ID!, + ); + return guild.channels.cache.find( + (ch) => ch.name === channelName && ch.isTextBased(), + ); } async sendMessageInChannel(channel: GuildBasedChannel, message: string) { @@ -152,4 +173,4 @@ export class DiscordClient implements SharedResourceClient { } throw new Error(`Channel ${channel.name} is not sendable.`); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/EmailClient/index.ts b/peopleportal/server/src/clients/EmailClient/index.ts index 3fabacf..b94bba4 100644 --- a/peopleportal/server/src/clients/EmailClient/index.ts +++ b/peopleportal/server/src/clients/EmailClient/index.ts @@ -24,7 +24,7 @@ import Handlebars from "handlebars"; import { EmailSendRequest } from "./models"; import { Options as NodemailerOptions } from "nodemailer/lib/mailer"; -type MailTemplateOptions = NodemailerOptions & TemplateOptions +type MailTemplateOptions = NodemailerOptions & TemplateOptions; export class EmailClient { private transporter: Transporter; constructor() { @@ -42,14 +42,14 @@ export class EmailClient { this.transporter.use( "compile", hbs({ - extName: '.hbs', + extName: ".hbs", viewPath: path.resolve(__dirname, "templates"), viewEngine: { extname: ".hbs", partialsDir: path.resolve(__dirname, "templates"), defaultLayout: false, }, - }) + }), ); } @@ -69,8 +69,8 @@ export class EmailClient { replyTo: request.replyTo, } as MailTemplateOptions); } catch (e) { - console.error(e) - throw e + console.error(e); + throw e; } } @@ -84,15 +84,22 @@ export class EmailClient { /* Register Partials for Dev Mode */ const templatesDir = path.resolve(__dirname, "templates"); const files = fs.readdirSync(templatesDir); - files.forEach(file => { + files.forEach((file) => { if (file.endsWith(".hbs")) { const partialName = path.basename(file, ".hbs"); - const partialContent = fs.readFileSync(path.join(templatesDir, file), "utf-8"); + const partialContent = fs.readFileSync( + path.join(templatesDir, file), + "utf-8", + ); Handlebars.registerPartial(partialName, partialContent); } }); - const templatePath = path.resolve(__dirname, "templates", request.templateName + ".hbs"); + const templatePath = path.resolve( + __dirname, + "templates", + request.templateName + ".hbs", + ); if (fs.existsSync(templatePath)) { const source = fs.readFileSync(templatePath, "utf-8"); const template = Handlebars.compile(source); @@ -106,11 +113,13 @@ export class EmailClient { } console.log(`Intercepted Email Send:`); - console.log(`From: ${request.from ?? process.env.PEOPLEPORTAL_SMTP_DEFAULTFROM}`); + console.log( + `From: ${request.from ?? process.env.PEOPLEPORTAL_SMTP_DEFAULTFROM}`, + ); console.log(`To: ${request.to}`); - console.log(`cc: ${request.cc ?? 'None'}`); + console.log(`cc: ${request.cc ?? "None"}`); console.log(`bcc: ${request.bcc ?? `None`}`); console.log(`Subject: ${request.subject}`); console.log(`Body Preview:\n${resolvedBody}`); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/EmailClient/models/index.ts b/peopleportal/server/src/clients/EmailClient/models/index.ts index fa6419c..6ae47bb 100644 --- a/peopleportal/server/src/clients/EmailClient/models/index.ts +++ b/peopleportal/server/src/clients/EmailClient/models/index.ts @@ -25,4 +25,4 @@ export interface EmailSendRequest { templateName: string; templateVars: object; from?: string; -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/EmailClient/templates/footer.hbs b/peopleportal/server/src/clients/EmailClient/templates/footer.hbs index b431170..255a566 100644 --- a/peopleportal/server/src/clients/EmailClient/templates/footer.hbs +++ b/peopleportal/server/src/clients/EmailClient/templates/footer.hbs @@ -1,5 +1,5 @@ \ No newline at end of file diff --git a/peopleportal/server/src/clients/EmailClient/templates/header.hbs b/peopleportal/server/src/clients/EmailClient/templates/header.hbs index 519cd68..106e375 100644 --- a/peopleportal/server/src/clients/EmailClient/templates/header.hbs +++ b/peopleportal/server/src/clients/EmailClient/templates/header.hbs @@ -1,12 +1,18 @@ - - - - + + + +
- App Dev Club Logo - - App Dev Club
- People Experience & Technology -
+ App Dev Club Logo + + App Dev Club
+ People Experience & Technology +
\ No newline at end of file diff --git a/peopleportal/server/src/clients/GiteaClient/config.ts b/peopleportal/server/src/clients/GiteaClient/config.ts index b77380e..ce05877 100644 --- a/peopleportal/server/src/clients/GiteaClient/config.ts +++ b/peopleportal/server/src/clients/GiteaClient/config.ts @@ -1,7 +1,7 @@ import { GiteaBranchProtection } from "./models"; -export const GITEA_DEFAULT_ORG_NAME = "PeoplePortalTeamOwners" -export const GITEA_DEFAULT_BRANCH_NAME = "main" +export const GITEA_DEFAULT_ORG_NAME = "PeoplePortalTeamOwners"; +export const GITEA_DEFAULT_BRANCH_NAME = "main"; export const GITEA_DEFAULT_BRANCH_PROTECTION: GiteaBranchProtection = { rule_name: GITEA_DEFAULT_BRANCH_NAME, priority: 1, @@ -18,4 +18,4 @@ export const GITEA_DEFAULT_BRANCH_PROTECTION: GiteaBranchProtection = { enable_merge_whitelist: true, enable_push: true, enable_push_whitelist: true, -} \ No newline at end of file +}; diff --git a/peopleportal/server/src/clients/GiteaClient/hooksetup.ts b/peopleportal/server/src/clients/GiteaClient/hooksetup.ts index 9709d8a..ae6ce22 100644 --- a/peopleportal/server/src/clients/GiteaClient/hooksetup.ts +++ b/peopleportal/server/src/clients/GiteaClient/hooksetup.ts @@ -3,25 +3,27 @@ import log from "loglevel"; import { getGiteaWebhookAuthorizationHeader } from "../../utils/gitea-webhook-auth"; export interface GiteaHookConfiguration { - url: string, - content_type: string, - is_system_webhook: string + url: string; + content_type: string; + is_system_webhook: string; } export interface GiteaHookDefinition { - id?: number, - type?: string, - active?: boolean, - events: string[], - branch_filter?: string, - authorization_header?: string, - config: GiteaHookConfiguration + id?: number; + type?: string; + active?: boolean; + events: string[]; + branch_filter?: string; + authorization_header?: string; + config: GiteaHookConfiguration; } export class GiteaHookSetup { - private static readonly TAG = "GiteaHookSetup" + private static readonly TAG = "GiteaHookSetup"; - private static getAdminHooksConfig(): { [key: string]: GiteaHookDefinition } { + private static getAdminHooksConfig(): { + [key: string]: GiteaHookDefinition; + } { const authorizationHeader = getGiteaWebhookAuthorizationHeader(); return { "people-portal-repohook": { @@ -29,11 +31,11 @@ export class GiteaHookSetup { authorization_header: authorizationHeader, config: { url: `${process.env.PEOPLEPORTAL_WEBHOOK_URL}/api/webhook/git/repoevent`, - content_type: 'json', + content_type: "json", /* Fails if true instead of 'true'. Undocumented API: https://github.com/go-gitea/gitea/pull/33180 */ is_system_webhook: "true", - } + }, }, "people-portal-commithook": { @@ -42,10 +44,10 @@ export class GiteaHookSetup { authorization_header: authorizationHeader, config: { url: `${process.env.PEOPLEPORTAL_WEBHOOK_URL}/api/webhook/git/commitevent`, - content_type: 'json', + content_type: "json", is_system_webhook: "true", - } - } + }, + }, }; } @@ -53,7 +55,7 @@ export class GiteaHookSetup { * Fetches the list of currently configured hooks and adds new People Portal * hooks if it doesn't exist, and repairs the authorization header on * existing People Portal hooks. - * + * * @param giteaBaseConfig Gitea Base Request Configuration */ public static async setupHooks(giteaBaseConfig: any) { @@ -61,34 +63,35 @@ export class GiteaHookSetup { /* Get List of Hooks */ var getHooksRequestConfig = { ...giteaBaseConfig, - method: 'get', + method: "get", url: `/api/v1/admin/hooks`, params: { type: "all", - limit: 1000 - } - } + limit: 1000, + }, + }; - const hooksResponse = await axios.request(getHooksRequestConfig) + const hooksResponse = await axios.request(getHooksRequestConfig); const existingHooks = new Set(); for (const hook of hooksResponse.data as GiteaHookDefinition[]) { - existingHooks.add(hook.config.url) + existingHooks.add(hook.config.url); - const expectedHook = Object.values(adminHooksConfig) - .find((hookInfo) => hookInfo.config.url === hook.config.url); + const expectedHook = Object.values(adminHooksConfig).find( + (hookInfo) => hookInfo.config.url === hook.config.url, + ); if (expectedHook && hook.id !== undefined) { /* Gitea does not return the configured secret. Reapply the * definition so existing hooks receive secret rotations too. */ await axios.request({ ...giteaBaseConfig, - method: 'patch', + method: "patch", url: `/api/v1/admin/hooks/${hook.id}`, data: { type: "gitea", active: true, - ...expectedHook - } + ...expectedHook, + }, }); } } @@ -101,20 +104,23 @@ export class GiteaHookSetup { /* Org Doesn't Exist */ var RequestConfig: any = { ...giteaBaseConfig, - method: 'post', + method: "post", url: `/api/v1/admin/hooks`, data: { type: "gitea", active: true, - ...hookInfo - } - } + ...hookInfo, + }, + }; /* Excecute Request */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); } /* Log */ - log.info(this.TAG, `Hooks Setup Complete: ${Object.keys(adminHooksConfig).join(", ")}`); + log.info( + this.TAG, + `Hooks Setup Complete: ${Object.keys(adminHooksConfig).join(", ")}`, + ); } } diff --git a/peopleportal/server/src/clients/GiteaClient/index.ts b/peopleportal/server/src/clients/GiteaClient/index.ts index 41ee9e8..f788ea0 100644 --- a/peopleportal/server/src/clients/GiteaClient/index.ts +++ b/peopleportal/server/src/clients/GiteaClient/index.ts @@ -16,14 +16,27 @@ along with this program. If not, see . */ -import axios, { AxiosError } from "axios" -import { SharedResourceClient } from ".." -import { GetGroupInfoResponse } from "../AuthentikClient/models" -import { GiteaAPITeamDefinition, GiteaAPIUserDefinition, GiteaBranchProtection, GiteaHookRepositoryTrigger, GiteaRepository, GiteaRepositoryBrief } from "./models"; +import axios, { AxiosError } from "axios"; +import { SharedResourceClient } from ".."; +import { GetGroupInfoResponse } from "../AuthentikClient/models"; +import { + GiteaAPITeamDefinition, + GiteaAPIUserDefinition, + GiteaBranchProtection, + GiteaHookRepositoryTrigger, + GiteaRepository, + GiteaRepositoryBrief, +} from "./models"; import { computeStringArrStateDiff } from "../../utils/operations"; -import { BindlePermission, BindlePermissionMap } from "../../controllers/BindleController"; +import { + BindlePermission, + BindlePermissionMap, +} from "../../controllers/BindleController"; import { GiteaHookSetup } from "./hooksetup"; -import { GITEA_DEFAULT_BRANCH_PROTECTION, GITEA_DEFAULT_ORG_NAME } from "./config"; +import { + GITEA_DEFAULT_BRANCH_PROTECTION, + GITEA_DEFAULT_ORG_NAME, +} from "./config"; class GiteaClientResourceNotExists extends Error { constructor(message: string) { @@ -33,94 +46,101 @@ class GiteaClientResourceNotExists extends Error { } interface GiteaOrgCreateRequest { - grpSharedResourceId: string, - displayName: string, - orgWebsite: string + grpSharedResourceId: string; + displayName: string; + orgWebsite: string; } interface GiteaTeamCreateRequest { - grpSharedResourceId: string, - teamSharedResourceId: string, - canCreateRespositories: boolean, - description: string, - writeAccess: boolean + grpSharedResourceId: string; + teamSharedResourceId: string; + canCreateRespositories: boolean; + description: string; + writeAccess: boolean; } export class GiteaClient implements SharedResourceClient { - private static INITIALIZED = false - private static readonly TAG = "GiteaClient" - private GITEA_TOKEN = process.env.PEOPLEPORTAL_GITEA_TOKEN + private static INITIALIZED = false; + private static readonly TAG = "GiteaClient"; + private GITEA_TOKEN = process.env.PEOPLEPORTAL_GITEA_TOKEN; - public static readonly GIT_NULL_COMMITID = "0000000000000000000000000000000000000000" - public static readonly GIT_REF_MAINBRANCH = "refs/heads/main" - public static readonly GIT_NAME_MAINBRANCH = "main" + public static readonly GIT_NULL_COMMITID = + "0000000000000000000000000000000000000000"; + public static readonly GIT_REF_MAINBRANCH = "refs/heads/main"; + public static readonly GIT_NAME_MAINBRANCH = "main"; public readonly GiteaBaseConfig = { baseURL: process.env.PEOPLEPORTAL_GITEA_ENDPOINT ?? "", maxBodyLength: Infinity, headers: { - 'Accept': "application/json", - 'Authorization': `Bearer ${this.GITEA_TOKEN}` - } - } + Accept: "application/json", + Authorization: `Bearer ${this.GITEA_TOKEN}`, + }, + }; private readonly supportedBindles: BindlePermissionMap = { "repo:allowcreate": { friendlyName: "Allow Repository Creation", - description: "Enabling this allows members in this subteam to create repositories", + description: + "Enabling this allows members in this subteam to create repositories", }, "repo:brprotect-approvals": { friendlyName: "Allow Pull Request Approvals", - description: "Enabling this allows members in this subteam to approve and review PRs" + description: + "Enabling this allows members in this subteam to approve and review PRs", }, "repo:brprotect-merge": { friendlyName: "Allow Git Merge", - description: "Enabling this allows members in this subteam to merge Pull Requests" + description: + "Enabling this allows members in this subteam to merge Pull Requests", }, "repo:brprotect-override": { friendlyName: "Override Branch Protections", - description: "Dangerous: Allows members to bypass all Branch Protections thereby, significantly reducing code quality", + description: + "Dangerous: Allows members to bypass all Branch Protections thereby, significantly reducing code quality", }, - } + }; constructor() { if (!this.GiteaBaseConfig.baseURL) - throw new Error("Gitea Backend URL is Invalid!") + throw new Error("Gitea Backend URL is Invalid!"); - if (!this.GITEA_TOKEN) - throw new Error("Gitea Token is Invalid!") + if (!this.GITEA_TOKEN) throw new Error("Gitea Token is Invalid!"); } async init(): Promise { if (!GiteaClient.INITIALIZED) { - await GiteaHookSetup.setupHooks(this.GiteaBaseConfig) - GiteaClient.INITIALIZED = true + await GiteaHookSetup.setupHooks(this.GiteaBaseConfig); + GiteaClient.INITIALIZED = true; } } getResourceName(): string { - return GiteaClient.TAG + return GiteaClient.TAG; } getSupportedBindles(): BindlePermissionMap { - return this.supportedBindles + return this.supportedBindles; } - public async handleOrgBindleSync(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + public async handleOrgBindleSync( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { /* If the Org's Not Created, Create it! */ await this.ensureOrganizationExists({ grpSharedResourceId: org.name, displayName: `${org.attributes.friendlyName} ${org.attributes.seasonType} ${org.attributes.seasonYear}`, - orgWebsite: `${process.env.PEOPLEPORTAL_BASE_URL}/org/teams/${org.pk}` - }) + orgWebsite: `${process.env.PEOPLEPORTAL_BASE_URL}/org/teams/${org.pk}`, + }); /* Perform Team Bindle Sync & Apply Branch Protections */ - await this.handleTeamBindleSync(org, org.name, callback) + await this.handleTeamBindleSync(org, org.name, callback); await this.handleBranchProtectionSync(org); - return true + return true; } /** @@ -131,60 +151,76 @@ export class GiteaClient implements SharedResourceClient { * @param org Team Information * @param callback Progress Reporting Callback */ - public async archiveTeam(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { - let repositories: GiteaRepository[] + public async archiveTeam( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { + let repositories: GiteaRepository[]; try { - repositories = await this.getOrgRepositories(org.name) + repositories = await this.getOrgRepositories(org.name); } catch (error: any) { /* No organization (and therefore no repos) to archive */ - if (error instanceof GiteaClientResourceNotExists) - return true + if (error instanceof GiteaClientResourceNotExists) return true; - throw error + throw error; } for (const repository of repositories) { try { var ArchiveRepoRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'patch', + method: "patch", url: `/api/v1/repos/${org.name}/${repository.name}`, - data: { archived: true } + data: { archived: true }, }; await axios.request(ArchiveRepoRequestConfig); callback(1, "Git Repository Archived: " + repository.name); } catch (error: any) { if (!(error instanceof GiteaClientResourceNotExists)) { - callback(0, "Git Repository Archival Failed for " + repository.name); - console.error(`[GiteaClient] Failed to archive repository ${repository.name}:`, error.message); + callback( + 0, + "Git Repository Archival Failed for " + repository.name, + ); + console.error( + `[GiteaClient] Failed to archive repository ${repository.name}:`, + error.message, + ); } } } - return true + return true; } /** * Creates a team if doesn't exist and syncs the team members. Translates Bindle Permissions * to the Organization's Rules. - * - * @param team + * + * @param team * @param orgRoot If Org Root, we create a fake resource to map the root team members - * @param callback - * @returns + * @param callback + * @returns */ - private async handleTeamBindleSync(team: GetGroupInfoResponse, orgId: string, callback: (updatedResourceCount: number, status: string) => void): Promise { + private async handleTeamBindleSync( + team: GetGroupInfoResponse, + orgId: string, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { /* We'll put team owners in the Shared Owners Team so, People Portal has Supreme Access */ - const teamSharedResourceId = (orgId == team.name) ? GITEA_DEFAULT_ORG_NAME : team.name + const teamSharedResourceId = + orgId == team.name ? GITEA_DEFAULT_ORG_NAME : team.name; /* Team Deletion Synchronization */ if (team.attributes?.flaggedForDeletion === true) { try { - const teamInfoBrief = await this.getTeamInfo(orgId, teamSharedResourceId) + const teamInfoBrief = await this.getTeamInfo( + orgId, + teamSharedResourceId, + ); var DeleteTeamRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'delete', + method: "delete", url: `/api/v1/teams/${teamInfoBrief.id}`, }; @@ -192,8 +228,14 @@ export class GiteaClient implements SharedResourceClient { callback(1, "Git Team Deleted for " + teamSharedResourceId); } catch (error: any) { if (!(error instanceof GiteaClientResourceNotExists)) { - callback(0, "Git Team Deletion Failed for " + teamSharedResourceId); - console.error(`[GiteaClient] Failed to delete team ${teamSharedResourceId}:`, error.message); + callback( + 0, + "Git Team Deletion Failed for " + teamSharedResourceId, + ); + console.error( + `[GiteaClient] Failed to delete team ${teamSharedResourceId}:`, + error.message, + ); } } @@ -202,81 +244,101 @@ export class GiteaClient implements SharedResourceClient { } /* Dynamically Calculate Capabilities */ - const bindles = team.attributes?.bindlePermissions?.[this.getResourceName()] || {}; + const bindles = + team.attributes?.bindlePermissions?.[this.getResourceName()] || {}; const canCreateRespositories = bindles["repo:allowcreate"] === true; - const writeAccess = canCreateRespositories || + const writeAccess = + canCreateRespositories || bindles["repo:brprotect-approvals"] === true || bindles["repo:brprotect-override"] === true || bindles["repo:brprotect-merge"] === true; - await this.ensureTeamExists({ teamSharedResourceId, canCreateRespositories, description: `Permissions Group Replication for ${teamSharedResourceId}`, grpSharedResourceId: orgId, - writeAccess - }) + writeAccess, + }); /* Obtain Current Team Members */ - const teamInfoBrief = await this.getTeamInfo(orgId, teamSharedResourceId) - const teamMemberList = await this.getTeamMembers(teamInfoBrief.id) + const teamInfoBrief = await this.getTeamInfo( + orgId, + teamSharedResourceId, + ); + const teamMemberList = await this.getTeamMembers(teamInfoBrief.id); /* Map Objects to Obtain Usernames */ - const finalStateUsernames = team.users.map((user) => user.username) - const currentStateUsernames = teamMemberList.map((member) => member.login) + const finalStateUsernames = team.users.map((user) => user.username); + const currentStateUsernames = teamMemberList.map( + (member) => member.login, + ); /* Perform User Delta Operations */ - const { additions, deletions } = computeStringArrStateDiff(finalStateUsernames, currentStateUsernames) + const { additions, deletions } = computeStringArrStateDiff( + finalStateUsernames, + currentStateUsernames, + ); for (const username of additions) { this.addTeamMember(teamInfoBrief.id, username) - .then(() => callback(1, "Git Permissions Updated for " + username)) - .catch(() => callback(1, "Git Permission Update Failed for " + username)) + .then(() => + callback(1, "Git Permissions Updated for " + username), + ) + .catch(() => + callback(1, "Git Permission Update Failed for " + username), + ); } for (const username of deletions) { this.removeTeamMember(teamInfoBrief.id, username) - .then(() => callback(1, "Git Permissions Updated for " + username)) - .catch(() => callback(1, "Git Permission Update Failed for " + username)) + .then(() => + callback(1, "Git Permissions Updated for " + username), + ) + .catch(() => + callback(1, "Git Permission Update Failed for " + username), + ); } /* Increased Processed Count for Existing Elements */ - const existingCount = finalStateUsernames.length - additions.length - callback(existingCount, "Git Permissions Updated for " + teamInfoBrief.name) + const existingCount = finalStateUsernames.length - additions.length; + callback( + existingCount, + "Git Permissions Updated for " + teamInfoBrief.name, + ); /* Recurse Sub Teams */ for (const subteam of team.subteams ?? []) { - await this.handleTeamBindleSync(subteam, orgId, callback) + await this.handleTeamBindleSync(subteam, orgId, callback); } /* We're Good Now! */ - return true + return true; } /** * Provisions a newly created repository to comply with People Portal standards. * If requests fail, we throw the errors as-is without catching. The callee is * expected to delete the newly created repository considering standards mismatch. - * + * * @param repoEvent Gitea Repository Event */ public async handleRepoProvisioning(repoEvent: GiteaHookRepositoryTrigger) { /* 1. Remove the creator from the collaborators list so team permissions take over */ var DeleteCollabRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'delete', + method: "delete", url: `/api/v1/repos/${repoEvent.repository.owner.username}/${repoEvent.repository.name}/collaborators/${repoEvent.sender.username}`, }; /* 2. Apply standard repository settings: Public Visibility and Main default branch */ var PatchRepoRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'patch', + method: "patch", url: `/api/v1/repos/${repoEvent.repository.owner.username}/${repoEvent.repository.name}`, data: { private: false, default_branch: GiteaClient.GIT_NAME_MAINBRANCH, - } + }, }; /* Execute Both Requests */ @@ -289,27 +351,27 @@ export class GiteaClient implements SharedResourceClient { repositories?: GiteaRepositoryBrief[], ) { if (!repositories) - repositories = await this.getOrgRepositories(team.name) + repositories = await this.getOrgRepositories(team.name); /* Define Base List */ - const approvals_whitelist_teams = [GITEA_DEFAULT_ORG_NAME] - const merge_whitelist_teams = [GITEA_DEFAULT_ORG_NAME] - const push_whitelist_teams = [GITEA_DEFAULT_ORG_NAME] + const approvals_whitelist_teams = [GITEA_DEFAULT_ORG_NAME]; + const merge_whitelist_teams = [GITEA_DEFAULT_ORG_NAME]; + const push_whitelist_teams = [GITEA_DEFAULT_ORG_NAME]; /* Enumerate Permissions from Team Information */ for (const subteam of team.subteams ?? []) { - const bindlePermissions = subteam.attributes?.bindlePermissions?.[this.getResourceName()] - if (!bindlePermissions) - continue; + const bindlePermissions = + subteam.attributes?.bindlePermissions?.[this.getResourceName()]; + if (!bindlePermissions) continue; if (bindlePermissions["repo:brprotect-approvals"] === true) - approvals_whitelist_teams.push(subteam.name) + approvals_whitelist_teams.push(subteam.name); if (bindlePermissions["repo:brprotect-merge"] === true) - merge_whitelist_teams.push(subteam.name) + merge_whitelist_teams.push(subteam.name); if (bindlePermissions["repo:brprotect-override"] === true) - push_whitelist_teams.push(subteam.name) + push_whitelist_teams.push(subteam.name); } /* Define Branch Protection Rules, Apply Default Override */ @@ -317,58 +379,56 @@ export class GiteaClient implements SharedResourceClient { approvals_whitelist_teams, merge_whitelist_teams, push_whitelist_teams, - ...GITEA_DEFAULT_BRANCH_PROTECTION - } + ...GITEA_DEFAULT_BRANCH_PROTECTION, + }; /* Apply Branch Protection Rules */ - await this.applyBranchProtections(repositories, branchProt) + await this.applyBranchProtections(repositories, branchProt); } /** * Applies Branch Protection Rules to a List of Repositories. If the repository * is empty we skip branch protections thereby allowing the first "main" merge. - * + * * On commit, if a main branch already exists, we apply the branch protections * as requested in `branchProt`. - * + * * @param repositories List of Repositories * @param branchProt Branch Protection Rules */ private async applyBranchProtections( repositories: GiteaRepositoryBrief[], - branchProt: GiteaBranchProtection + branchProt: GiteaBranchProtection, ) { for (const repositorynt of repositories) { const repository = repositorynt as GiteaRepository; /* Bypass Branch Protection for Empty Repositories, They're Applied using Push Hooks */ - if (repository.empty === true) - continue; + if (repository.empty === true) continue; var updateRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'patch', + method: "patch", url: `/api/v1/repos/${repository.owner.username}/${repository.name}/branch_protections/${branchProt.rule_name}`, data: branchProt, - } + }; try { /* Apply Branch Protection Update */ - await axios.request(updateRequestConfig) + await axios.request(updateRequestConfig); } catch (e: any) { - if (!e.response || e.response.status != 404) - throw e; + if (!e.response || e.response.status != 404) throw e; /* Branch Protection Doesn't Exist, Create it! */ var createRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'post', + method: "post", url: `/api/v1/repos/${repository.owner.username}/${repository.name}/branch_protections`, data: branchProt, - } + }; /* Excecute Request, Throws on Error! */ - await axios.request(createRequestConfig) + await axios.request(createRequestConfig); } } } @@ -376,204 +436,245 @@ export class GiteaClient implements SharedResourceClient { /** * Fetches all repositories owned by an organization. * Uses the Gitea API internally. - * + * * @param orgName Shared Resource ID * @returns Repository List */ - private async getOrgRepositories(orgName: string): Promise { + private async getOrgRepositories( + orgName: string, + ): Promise { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'get', + method: "get", url: `/api/v1/orgs/${orgName}/repos`, - } + }; /* Excecute Request */ - const response = await axios.request(RequestConfig) - return response.data as GiteaRepository[] + const response = await axios.request(RequestConfig); + return response.data as GiteaRepository[]; } private async addTeamMember(teamId: number, username: string) { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'put', + method: "put", url: `/api/v1/teams/${teamId}/members/${username}`, - } + }; /* Excecute Request */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); } private async removeTeamMember(teamId: number, username: string) { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'delete', + method: "delete", url: `/api/v1/teams/${teamId}/members/${username}`, - } + }; /* Excecute Request */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); } - private async ensureTeamExists(req: GiteaTeamCreateRequest): Promise { + private async ensureTeamExists( + req: GiteaTeamCreateRequest, + ): Promise { try { - const teamInfo = await this.getTeamInfo(req.grpSharedResourceId, req.teamSharedResourceId); + const teamInfo = await this.getTeamInfo( + req.grpSharedResourceId, + req.teamSharedResourceId, + ); const expectedPermission = req.writeAccess ? "write" : "read"; /* Check for drift and apply updates if necessary */ - if (teamInfo.description !== req.description || + if ( + teamInfo.description !== req.description || teamInfo.permission !== expectedPermission || - teamInfo.can_create_org_repo !== req.canCreateRespositories) { - + teamInfo.can_create_org_repo !== req.canCreateRespositories + ) { var UpdateRequestConfig: any = { ...this.GiteaBaseConfig, - method: 'patch', + method: "patch", url: `/api/v1/teams/${teamInfo.id}`, data: { name: req.teamSharedResourceId, description: req.description, permission: expectedPermission, can_create_org_repo: req.canCreateRespositories, - units: ["repo.actions", "repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.projects"], - includes_all_repositories: true - } - } + units: [ + "repo.actions", + "repo.code", + "repo.issues", + "repo.pulls", + "repo.releases", + "repo.projects", + ], + includes_all_repositories: true, + }, + }; await axios.request(UpdateRequestConfig); } return true; } catch (error: any) { - if (!(error instanceof GiteaClientResourceNotExists)) - throw error + if (!(error instanceof GiteaClientResourceNotExists)) throw error; } /* Team Doesn't Exist, Create it! */ var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'post', + method: "post", url: `/api/v1/orgs/${req.grpSharedResourceId}/teams`, data: { name: req.teamSharedResourceId, description: req.description, permission: req.writeAccess ? "write" : "read", can_create_org_repo: req.canCreateRespositories, - units: ["repo.actions", "repo.code", "repo.issues", "repo.pulls", "repo.releases", "repo.projects"], + units: [ + "repo.actions", + "repo.code", + "repo.issues", + "repo.pulls", + "repo.releases", + "repo.projects", + ], includes_all_repositories: true, - } - } + }, + }; /* Throws an Exception! */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); return true; } - private async ensureOrganizationExists(req: GiteaOrgCreateRequest): Promise { + private async ensureOrganizationExists( + req: GiteaOrgCreateRequest, + ): Promise { try { /* Try to Obtain Org Info */ - const orgInfo = await this.getOrganizationInfo(req.grpSharedResourceId) + const orgInfo = await this.getOrganizationInfo( + req.grpSharedResourceId, + ); /* Check if we need to update the organization */ - const description = `Code Repository for the ${req.displayName} team. \nManaged by [**People Portal**](https://github.com/candiedoperation/AppDev-PeoplePortalServer).` - - if (orgInfo.full_name != req.displayName || orgInfo.website != req.orgWebsite || orgInfo.description != description) { - await this.updateOrganization(req) + const description = `Code Repository for the ${req.displayName} team. \nManaged by [**People Portal**](https://github.com/candiedoperation/AppDev-PeoplePortalServer).`; + + if ( + orgInfo.full_name != req.displayName || + orgInfo.website != req.orgWebsite || + orgInfo.description != description + ) { + await this.updateOrganization(req); } return true; } catch (error: any) { if (!(error instanceof GiteaClientResourceNotExists)) { - throw error + throw error; } } /* Org Doesn't Exist */ var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'post', + method: "post", url: `/api/v1/orgs`, data: { username: req.grpSharedResourceId, full_name: req.displayName, description: `Code Repository for the ${req.displayName} team. \nManaged by [**People Portal**](https://github.com/candiedoperation/AppDev-PeoplePortalServer).`, - website: req.orgWebsite - } - } + website: req.orgWebsite, + }, + }; /* Throws an Exception! */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); return true; } - private async updateOrganization(req: GiteaOrgCreateRequest): Promise { + private async updateOrganization( + req: GiteaOrgCreateRequest, + ): Promise { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'patch', + method: "patch", url: `/api/v1/orgs/${req.grpSharedResourceId}`, data: { full_name: req.displayName, description: `Code Repository for the ${req.displayName} team. \nManaged by [**People Portal**](https://github.com/candiedoperation/AppDev-PeoplePortalServer).`, - website: req.orgWebsite - } - } + website: req.orgWebsite, + }, + }; /* Throws an Exception! */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); return true; } - private async getTeamMembers(giteaTeamId: number): Promise { + private async getTeamMembers( + giteaTeamId: number, + ): Promise { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'get', - url: `/api/v1/teams/${giteaTeamId}/members` - } + method: "get", + url: `/api/v1/teams/${giteaTeamId}/members`, + }; - const teamMembers = await axios.request(RequestConfig) - return teamMembers.data + const teamMembers = await axios.request(RequestConfig); + return teamMembers.data; } - private async getTeamInfo(orgName: string, teamName: string): Promise { + private async getTeamInfo( + orgName: string, + teamName: string, + ): Promise { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'get', - url: `/api/v1/orgs/${orgName}/teams/search?q=${teamName}` - } + method: "get", + url: `/api/v1/orgs/${orgName}/teams/search?q=${teamName}`, + }; - const teamInfo = await axios.request(RequestConfig) - const matchedTeams = teamInfo.data.data + const teamInfo = await axios.request(RequestConfig); + const matchedTeams = teamInfo.data.data; if (matchedTeams.length < 1) - throw new GiteaClientResourceNotExists("Team Doesn't Exist") + throw new GiteaClientResourceNotExists("Team Doesn't Exist"); - return matchedTeams[0] + return matchedTeams[0]; } private async getOrganizationInfo(orgName: string) { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'get', - url: `/api/v1/orgs/${orgName}` - } + method: "get", + url: `/api/v1/orgs/${orgName}`, + }; try { - const orgInfo = await axios.request(RequestConfig) - return orgInfo.data + const orgInfo = await axios.request(RequestConfig); + return orgInfo.data; } catch (e) { const error = e as AxiosError; if (error.response?.status == 404) - throw new GiteaClientResourceNotExists("Organization Doesn't Exist") + throw new GiteaClientResourceNotExists( + "Organization Doesn't Exist", + ); /* Throw the error, otherwise */ - throw e + throw e; } } /* Repository Management */ - public async getRepository(owner: string, repo: string): Promise { + public async getRepository( + owner: string, + repo: string, + ): Promise { const response = await axios.request({ ...this.GiteaBaseConfig, - method: 'get', - url: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}` + method: "get", + url: `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, }); return response.data as GiteaRepository; @@ -582,20 +683,22 @@ export class GiteaClient implements SharedResourceClient { public async deleteRepository(owner: string, repo: string) { var RequestConfig: any = { ...this.GiteaBaseConfig, - method: 'delete', - url: `/api/v1/repos/${owner}/${repo}` - } + method: "delete", + url: `/api/v1/repos/${owner}/${repo}`, + }; try { /* Call the DELETE API */ - await axios.request(RequestConfig) + await axios.request(RequestConfig); } catch (e) { const error = e as AxiosError; if (error.response?.status == 404) - throw new GiteaClientResourceNotExists("Repository Doesn't Exist") + throw new GiteaClientResourceNotExists( + "Repository Doesn't Exist", + ); /* Throw the error, otherwise */ - throw e + throw e; } } } diff --git a/peopleportal/server/src/clients/GiteaClient/models.ts b/peopleportal/server/src/clients/GiteaClient/models.ts index d6ae9a4..8476256 100644 --- a/peopleportal/server/src/clients/GiteaClient/models.ts +++ b/peopleportal/server/src/clients/GiteaClient/models.ts @@ -17,218 +17,218 @@ */ export interface GiteaAPITeamDefinition { - id: number, - name: string, - description: string, - organization: string | null, - includes_all_repositories: boolean, - permission: "none" | "read" | "write" | "admin" | "owner", - can_create_org_repo: boolean, - units: string[], - units_map: object, + id: number; + name: string; + description: string; + organization: string | null; + includes_all_repositories: boolean; + permission: "none" | "read" | "write" | "admin" | "owner"; + can_create_org_repo: boolean; + units: string[]; + units_map: object; } export interface GiteaAPIUserDefinition { - active: boolean; - avatar_url: string; - created: string; // ISO date string - description: string; - email: string; - followers_count: number; - following_count: number; - full_name: string; - html_url: string; - id: number; - is_admin: boolean; - language: string; - last_login: string; // ISO date string - location: string; - login: string; - login_name: string; - prohibit_login: boolean; - restricted: boolean; - source_id: number; - starred_repos_count: number; - visibility: string; - website: string; + active: boolean; + avatar_url: string; + created: string; // ISO date string + description: string; + email: string; + followers_count: number; + following_count: number; + full_name: string; + html_url: string; + id: number; + is_admin: boolean; + language: string; + last_login: string; // ISO date string + location: string; + login: string; + login_name: string; + prohibit_login: boolean; + restricted: boolean; + source_id: number; + starred_repos_count: number; + visibility: string; + website: string; } export interface GiteaUser { - id: number; - login: string; - login_name: string; - source_id: number; - full_name: string; - email: string; - avatar_url: string; - html_url: string; - language: string; - is_admin: boolean; - last_login: string; - created: string; - restricted: boolean; - active: boolean; - prohibit_login: boolean; - location: string; - website: string; - description: string; - visibility: string; - followers_count: number; - following_count: number; - starred_repos_count: number; - username: string; + id: number; + login: string; + login_name: string; + source_id: number; + full_name: string; + email: string; + avatar_url: string; + html_url: string; + language: string; + is_admin: boolean; + last_login: string; + created: string; + restricted: boolean; + active: boolean; + prohibit_login: boolean; + location: string; + website: string; + description: string; + visibility: string; + followers_count: number; + following_count: number; + starred_repos_count: number; + username: string; } export interface GiteaRepositoryPermissions { - admin: boolean; - push: boolean; - pull: boolean; + admin: boolean; + push: boolean; + pull: boolean; } export interface GiteaRepositoryInternalTracker { - enable_time_tracker: boolean; - allow_only_contributors_to_track_time: boolean; - enable_issue_dependencies: boolean; + enable_time_tracker: boolean; + allow_only_contributors_to_track_time: boolean; + enable_issue_dependencies: boolean; } export interface GiteaRepositoryBrief { - owner: GiteaUser; - name: string; + owner: GiteaUser; + name: string; } export interface GiteaRepository extends GiteaRepositoryBrief { - id: number; - full_name: string; - description: string; - empty: boolean; - private: boolean; - fork: boolean; - template: boolean; - mirror: boolean; - size: number; - language: string; - languages_url: string; - html_url: string; - url: string; - link: string; - ssh_url: string; - clone_url: string; - original_url: string; - website: string; - stars_count: number; - forks_count: number; - watchers_count: number; - open_issues_count: number; - open_pr_counter: number; - release_counter: number; - default_branch: string; - archived: boolean; - created_at: string; - updated_at: string; - archived_at: string; - permissions: GiteaRepositoryPermissions; - has_issues: boolean; - internal_tracker: GiteaRepositoryInternalTracker; - has_wiki: boolean; - has_pull_requests: boolean; - has_projects: boolean; - projects_mode: string; - has_releases: boolean; - has_packages: boolean; - has_actions: boolean; - ignore_whitespace_conflicts: boolean; - allow_merge_commits: boolean; - allow_rebase: boolean; - allow_rebase_explicit: boolean; - allow_squash_merge: boolean; - allow_fast_forward_only_merge: boolean; - allow_rebase_update: boolean; - default_delete_branch_after_merge: boolean; - default_merge_style: string; - default_allow_maintainer_edit: boolean; - avatar_url: string; - internal: boolean; - mirror_interval: string; - object_format_name: string; - mirror_updated: string; - topics: string[]; - licenses: string[]; + id: number; + full_name: string; + description: string; + empty: boolean; + private: boolean; + fork: boolean; + template: boolean; + mirror: boolean; + size: number; + language: string; + languages_url: string; + html_url: string; + url: string; + link: string; + ssh_url: string; + clone_url: string; + original_url: string; + website: string; + stars_count: number; + forks_count: number; + watchers_count: number; + open_issues_count: number; + open_pr_counter: number; + release_counter: number; + default_branch: string; + archived: boolean; + created_at: string; + updated_at: string; + archived_at: string; + permissions: GiteaRepositoryPermissions; + has_issues: boolean; + internal_tracker: GiteaRepositoryInternalTracker; + has_wiki: boolean; + has_pull_requests: boolean; + has_projects: boolean; + projects_mode: string; + has_releases: boolean; + has_packages: boolean; + has_actions: boolean; + ignore_whitespace_conflicts: boolean; + allow_merge_commits: boolean; + allow_rebase: boolean; + allow_rebase_explicit: boolean; + allow_squash_merge: boolean; + allow_fast_forward_only_merge: boolean; + allow_rebase_update: boolean; + default_delete_branch_after_merge: boolean; + default_merge_style: string; + default_allow_maintainer_edit: boolean; + avatar_url: string; + internal: boolean; + mirror_interval: string; + object_format_name: string; + mirror_updated: string; + topics: string[]; + licenses: string[]; } export interface GiteaHookRepositoryTrigger { - action: string, - repository: GiteaRepository, - organization: GiteaUser, - sender: GiteaUser, + action: string; + repository: GiteaRepository; + organization: GiteaUser; + sender: GiteaUser; } export interface GiteaCommitUser { - name: string; - email: string; - username: string; + name: string; + email: string; + username: string; } export interface GiteaCommit { - id: string; - message: string; - url: string; - author: GiteaCommitUser; - committer: GiteaCommitUser; - verification: any; - timestamp: string; - added: string[]; - removed: string[]; - modified: string[]; + id: string; + message: string; + url: string; + author: GiteaCommitUser; + committer: GiteaCommitUser; + verification: any; + timestamp: string; + added: string[]; + removed: string[]; + modified: string[]; } export interface GiteaHookCommitTrigger { - ref: string; - before: string; - after: string; - compare_url: string; - commits: GiteaCommit[]; - total_commits: number; - head_commit: GiteaCommit; - repository: GiteaRepository; - pusher: GiteaUser; - sender: GiteaUser; + ref: string; + before: string; + after: string; + compare_url: string; + commits: GiteaCommit[]; + total_commits: number; + head_commit: GiteaCommit; + repository: GiteaRepository; + pusher: GiteaUser; + sender: GiteaUser; } export interface GiteaBranchProtection { - rule_name: string; - priority: number; - - approvals_whitelist_teams?: string[]; - approvals_whitelist_username?: string[]; - block_on_official_review_requests?: boolean; - block_on_rejected_reviews?: boolean; - dismiss_stale_approvals?: boolean; - enable_approvals_whitelist?: boolean; - ignore_stale_approvals?: boolean; - required_approvals?: number; - - block_admin_merge_override?: boolean; - block_on_outdated_branch?: boolean; - enable_force_push?: boolean; - enable_force_push_allowlist?: boolean; - enable_merge_whitelist?: boolean; - enable_push?: boolean; - enable_push_whitelist?: boolean; - - merge_whitelist_teams?: string[]; - merge_whitelist_usernames?: string[]; - - push_whitelist_deploy_keys?: boolean; - push_whitelist_teams?: string[]; - push_whitelist_usernames?: string[]; - - force_push_allowlist_deploy_keys?: boolean; - force_push_allowlist_teams?: string[]; - force_push_allowlist_usernames?: string[]; - - enable_status_check?: boolean; - status_check_contexts?: string[]; - protected_file_patterns?: string; - unprotected_file_patterns?: string; - require_signed_commits?: boolean; -} \ No newline at end of file + rule_name: string; + priority: number; + + approvals_whitelist_teams?: string[]; + approvals_whitelist_username?: string[]; + block_on_official_review_requests?: boolean; + block_on_rejected_reviews?: boolean; + dismiss_stale_approvals?: boolean; + enable_approvals_whitelist?: boolean; + ignore_stale_approvals?: boolean; + required_approvals?: number; + + block_admin_merge_override?: boolean; + block_on_outdated_branch?: boolean; + enable_force_push?: boolean; + enable_force_push_allowlist?: boolean; + enable_merge_whitelist?: boolean; + enable_push?: boolean; + enable_push_whitelist?: boolean; + + merge_whitelist_teams?: string[]; + merge_whitelist_usernames?: string[]; + + push_whitelist_deploy_keys?: boolean; + push_whitelist_teams?: string[]; + push_whitelist_usernames?: string[]; + + force_push_allowlist_deploy_keys?: boolean; + force_push_allowlist_teams?: string[]; + force_push_allowlist_usernames?: string[]; + + enable_status_check?: boolean; + status_check_contexts?: string[]; + protected_file_patterns?: string; + unprotected_file_patterns?: string; + require_signed_commits?: boolean; +} diff --git a/peopleportal/server/src/clients/OpenIdClient/index.ts b/peopleportal/server/src/clients/OpenIdClient/index.ts index 2e22057..efb833b 100644 --- a/peopleportal/server/src/clients/OpenIdClient/index.ts +++ b/peopleportal/server/src/clients/OpenIdClient/index.ts @@ -16,202 +16,218 @@ along with this program. If not, see . */ -import jwt from "jsonwebtoken" -import jwksClient from 'jwks-rsa'; -import * as client from 'openid-client' +import jwt from "jsonwebtoken"; +import jwksClient from "jwks-rsa"; +import * as client from "openid-client"; import { UserAttributeDefinition } from "../AuthentikClient/models"; export interface AuthorizedUser { - sub: string, - email: string, - name: string, - pk: number, - attributes: UserAttributeDefinition, - is_superuser: boolean, - username: string, - groups: string[] + sub: string; + email: string; + name: string; + pk: number; + attributes: UserAttributeDefinition; + is_superuser: boolean; + username: string; + groups: string[]; } export interface AuthorizationStamp { - accessToken: string, - expiry: Date, - refreshToken?: string, - idToken?: string | undefined, - user: AuthorizedUser + accessToken: string; + expiry: Date; + refreshToken?: string; + idToken?: string | undefined; + user: AuthorizedUser; } export interface RedirectionRequest { - redirectUrl: URL, - expectedState: string + redirectUrl: URL; + expectedState: string; } export class OpenIdClient { - private static config: client.Configuration | undefined; - private static code_challenge_method = 'S256' - private static code_verifier = client.randomPKCECodeVerifier() - private static code_challenge: string | undefined - private static redirect_uri: string | undefined - private static jwksClient: jwksClient.JwksClient | undefined; - - public static async init() { - this.config = await client.discovery( - new URL(process.env.PEOPLEPORTAL_OIDC_DSCVURL!), - process.env.PEOPLEPORTAL_OIDC_CLIENTID!, - process.env.PEOPLEPORTAL_OIDC_CLIENTSECRET!, - undefined, - { execute: [client.allowInsecureRequests] } - ) - - const jwksUri = this.config.serverMetadata().jwks_uri - if (!jwksUri) { - throw new Error("OIDC Backend doesn't support JWKS!") + private static config: client.Configuration | undefined; + private static code_challenge_method = "S256"; + private static code_verifier = client.randomPKCECodeVerifier(); + private static code_challenge: string | undefined; + private static redirect_uri: string | undefined; + private static jwksClient: jwksClient.JwksClient | undefined; + + public static async init() { + this.config = await client.discovery( + new URL(process.env.PEOPLEPORTAL_OIDC_DSCVURL!), + process.env.PEOPLEPORTAL_OIDC_CLIENTID!, + process.env.PEOPLEPORTAL_OIDC_CLIENTSECRET!, + undefined, + { execute: [client.allowInsecureRequests] }, + ); + + const jwksUri = this.config.serverMetadata().jwks_uri; + if (!jwksUri) { + throw new Error("OIDC Backend doesn't support JWKS!"); + } + + this.jwksClient = jwksClient({ jwksUri }); + this.code_challenge = await client.calculatePKCECodeChallenge( + this.code_verifier, + ); + this.redirect_uri = `${process.env.PEOPLEPORTAL_BASE_URL}/api/auth/redirect`; } - this.jwksClient = jwksClient({ jwksUri }) - this.code_challenge = await client.calculatePKCECodeChallenge(this.code_verifier) - this.redirect_uri = `${process.env.PEOPLEPORTAL_BASE_URL}/api/auth/redirect` - } - - public static startAuthFlow(): RedirectionRequest { - /* We Init the Module before Accepting Conns */ - if (!this.config || !this.code_challenge) - throw new Error("OpenID Client is Not Initialized!") - - const expectedState = client.randomState() - let parameters: Record = { - redirect_uri: this.redirect_uri!, - scope: 'openid profile email people_portal offline_access', - code_challenge: this.code_challenge, - code_challenge_method: this.code_challenge_method, + public static startAuthFlow(): RedirectionRequest { + /* We Init the Module before Accepting Conns */ + if (!this.config || !this.code_challenge) + throw new Error("OpenID Client is Not Initialized!"); + + const expectedState = client.randomState(); + let parameters: Record = { + redirect_uri: this.redirect_uri!, + scope: "openid profile email people_portal offline_access", + code_challenge: this.code_challenge, + code_challenge_method: this.code_challenge_method, + }; + + parameters.state = expectedState; + let redirectUrl = client.buildAuthorizationUrl(this.config, parameters); + return { redirectUrl, expectedState }; } - parameters.state = expectedState - let redirectUrl = client.buildAuthorizationUrl(this.config, parameters) - return { redirectUrl, expectedState } - } - - public static async issueAuthorizationStamps(currentUrl: URL, expectedState: string): Promise { - let tokens = await client.authorizationCodeGrant(this.config!, currentUrl, { - pkceCodeVerifier: this.code_verifier, - idTokenExpected: true, - expectedState - }) - - const claims = tokens.claims() - if (!claims) - throw new Error("Failed to Obtain OIDC Claims!") - - return { - accessToken: tokens.access_token, - ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}), - idToken: tokens.id_token, - expiry: new Date(claims.exp * 1000), - user: { - sub: claims.sub, - email: claims.email as string, - name: claims.name as string, - username: claims.preferred_username as string, - groups: claims.groups as string[], - pk: (claims.pk as number), - attributes: (claims.attributes as unknown as UserAttributeDefinition), - is_superuser: claims.is_superuser as boolean - } + public static async issueAuthorizationStamps( + currentUrl: URL, + expectedState: string, + ): Promise { + let tokens = await client.authorizationCodeGrant( + this.config!, + currentUrl, + { + pkceCodeVerifier: this.code_verifier, + idTokenExpected: true, + expectedState, + }, + ); + + const claims = tokens.claims(); + if (!claims) throw new Error("Failed to Obtain OIDC Claims!"); + + return { + accessToken: tokens.access_token, + ...(tokens.refresh_token + ? { refreshToken: tokens.refresh_token } + : {}), + idToken: tokens.id_token, + expiry: new Date(claims.exp * 1000), + user: { + sub: claims.sub, + email: claims.email as string, + name: claims.name as string, + username: claims.preferred_username as string, + groups: claims.groups as string[], + pk: claims.pk as number, + attributes: + claims.attributes as unknown as UserAttributeDefinition, + is_superuser: claims.is_superuser as boolean, + }, + }; } - } - - public static verifyAccessToken(accessToken: string): Promise { - return new Promise((resolve, reject) => { - jwt.verify( - accessToken, - (header, callback) => { - this.jwksClient?.getSigningKey(header.kid, (err, key) => { - const signingKey = key?.getPublicKey() - callback(null, signingKey) - }) - }, - - { - algorithms: ["RS256"], - ignoreExpiration: false, - audience: process.env.PEOPLEPORTAL_OIDC_CLIENTID - }, - - (err, decoded) => { - if (err) { - console.log(err) - reject(err) - } - - /* Success!! */ - resolve(decoded) - } - ) - }) - } - - public static async refreshAccessToken(refreshToken: string): Promise { - if (!this.config) - throw new Error("OpenID Client is Uninitialized!") - - const tokens = await client.refreshTokenGrant(this.config, refreshToken) - const claims = tokens.claims() - if (!claims) - throw new Error("Failed to Obtain OIDC Claims from Refresh!") - - return { - accessToken: tokens.access_token, - ...(tokens.refresh_token ? { refreshToken: tokens.refresh_token } : {}), // Rotation might give a new one - idToken: tokens.id_token, - expiry: new Date(claims.exp * 1000), - user: { - sub: claims.sub, - email: claims.email as string, - name: claims.name as string, - username: claims.preferred_username as string, - groups: claims.groups as string[], - pk: (claims.pk as number), - attributes: (claims.attributes as unknown as UserAttributeDefinition), - is_superuser: claims.is_superuser as boolean - } + + public static verifyAccessToken(accessToken: string): Promise { + return new Promise((resolve, reject) => { + jwt.verify( + accessToken, + (header, callback) => { + this.jwksClient?.getSigningKey(header.kid, (err, key) => { + const signingKey = key?.getPublicKey(); + callback(null, signingKey); + }); + }, + + { + algorithms: ["RS256"], + ignoreExpiration: false, + audience: process.env.PEOPLEPORTAL_OIDC_CLIENTID, + }, + + (err, decoded) => { + if (err) { + console.log(err); + reject(err); + } + + /* Success!! */ + resolve(decoded); + }, + ); + }); + } + + public static async refreshAccessToken( + refreshToken: string, + ): Promise { + if (!this.config) throw new Error("OpenID Client is Uninitialized!"); + + const tokens = await client.refreshTokenGrant( + this.config, + refreshToken, + ); + const claims = tokens.claims(); + if (!claims) + throw new Error("Failed to Obtain OIDC Claims from Refresh!"); + + return { + accessToken: tokens.access_token, + ...(tokens.refresh_token + ? { refreshToken: tokens.refresh_token } + : {}), // Rotation might give a new one + idToken: tokens.id_token, + expiry: new Date(claims.exp * 1000), + user: { + sub: claims.sub, + email: claims.email as string, + name: claims.name as string, + username: claims.preferred_username as string, + groups: claims.groups as string[], + pk: claims.pk as number, + attributes: + claims.attributes as unknown as UserAttributeDefinition, + is_superuser: claims.is_superuser as boolean, + }, + }; } - } - - public static async getUserInfo(accessToken: string, sub: string) { - if (!this.config) - throw new Error("OpenID Client is Uninitialized!") - - return await client.fetchUserInfo(this.config, accessToken, sub) - } - - /** - * Builds the RP-initiated logout URL for the provider, so signing out of - * People Portal also ends the session at the identity provider. Without - * this, destroying the local session leaves the IdP session alive and the - * next login completes silently, which reads as "logout did nothing". - * - * Returns undefined when the provider advertises no end_session_endpoint, - * which is the caller's cue to fall back to a local-only logout rather - * than fail the request. - * - * @param idToken The id_token from the session, sent as id_token_hint so - * the provider knows which session to end without prompting - * @returns The provider's logout URL, or undefined if unsupported - */ - public static buildLogoutUrl(idToken?: string): URL | undefined { - if (!this.config) - throw new Error("OpenID Client is Uninitialized!") - - if (!this.config.serverMetadata().end_session_endpoint) - return undefined - - /* Must be registered on the provider as a redirect uri; see + + public static async getUserInfo(accessToken: string, sub: string) { + if (!this.config) throw new Error("OpenID Client is Uninitialized!"); + + return await client.fetchUserInfo(this.config, accessToken, sub); + } + + /** + * Builds the RP-initiated logout URL for the provider, so signing out of + * People Portal also ends the session at the identity provider. Without + * this, destroying the local session leaves the IdP session alive and the + * next login completes silently, which reads as "logout did nothing". + * + * Returns undefined when the provider advertises no end_session_endpoint, + * which is the caller's cue to fall back to a local-only logout rather + * than fail the request. + * + * @param idToken The id_token from the session, sent as id_token_hint so + * the provider knows which session to end without prompting + * @returns The provider's logout URL, or undefined if unsupported + */ + public static buildLogoutUrl(idToken?: string): URL | undefined { + if (!this.config) throw new Error("OpenID Client is Uninitialized!"); + + if (!this.config.serverMetadata().end_session_endpoint) + return undefined; + + /* Must be registered on the provider as a redirect uri; see scripts/bootstrap-authentik.sh, which registers both this and the login callback. */ - const postLogoutRedirectUri = `${process.env.PEOPLEPORTAL_BASE_URL}/` + const postLogoutRedirectUri = `${process.env.PEOPLEPORTAL_BASE_URL}/`; - return client.buildEndSessionUrl(this.config, { - post_logout_redirect_uri: postLogoutRedirectUri, - ...(idToken ? { id_token_hint: idToken } : {}), - }) - } + return client.buildEndSessionUrl(this.config, { + post_logout_redirect_uri: postLogoutRedirectUri, + ...(idToken ? { id_token_hint: idToken } : {}), + }); + } } diff --git a/peopleportal/server/src/clients/PeoplePortalClient/index.ts b/peopleportal/server/src/clients/PeoplePortalClient/index.ts index f2da743..b27153f 100644 --- a/peopleportal/server/src/clients/PeoplePortalClient/index.ts +++ b/peopleportal/server/src/clients/PeoplePortalClient/index.ts @@ -16,70 +16,80 @@ along with this program. If not, see . */ -import { SharedResourceClient } from ".." -import { BindlePermissionMap } from "../../controllers/BindleController" -import { GetGroupInfoResponse } from "../AuthentikClient/models" +import { SharedResourceClient } from ".."; +import { BindlePermissionMap } from "../../controllers/BindleController"; +import { GetGroupInfoResponse } from "../AuthentikClient/models"; export class PeoplePortalClient implements SharedResourceClient { - public static readonly TAG = "PeoplePortalClient" + public static readonly TAG = "PeoplePortalClient"; private readonly supportedBindles: BindlePermissionMap = { "corp:awsaccess": { friendlyName: "AWS Console Access", - description: "Enabling this allows members in this subteam to access the AWS Console", + description: + "Enabling this allows members in this subteam to access the AWS Console", }, "corp:hiringaccess": { friendlyName: "Permit Recruitment", - description: "Enabling this allows members in this subteam to recruit new members to your team", + description: + "Enabling this allows members in this subteam to recruit new members to your team", }, "corp:bindlesync": { friendlyName: "Enable Permissions Sync", - description: "Enabling this allows members to sync shared permissions", + description: + "Enabling this allows members to sync shared permissions", }, "corp:rootsettings": { friendlyName: "Allow Team Settings Management", - description: "Enabling this allows members in this subteam to modify team settings", + description: + "Enabling this allows members in this subteam to modify team settings", }, "corp:subteamaccess": { friendlyName: "Allow Subteam Management", - description: "Enabling this allows members in this subteam to create, modify and delete subteams", + description: + "Enabling this allows members in this subteam to create, modify and delete subteams", }, "corp:permissionsmgmt": { friendlyName: "Allow Permission Management", - description: "Enabling this allows members to modify all permissions for all subteams", + description: + "Enabling this allows members to modify all permissions for all subteams", }, "corp:membermgmt": { friendlyName: "Allow Member Management", - description: "Enabling this allows people in this subteam to add & remove members to your team", + description: + "Enabling this allows people in this subteam to add & remove members to your team", }, "corp:reviewaccess": { friendlyName: "Allow Internal Review Access", - description: "Enabling this allows people in this subteam to write internal reviews on other members of this team.", + description: + "Enabling this allows people in this subteam to write internal reviews on other members of this team.", }, "corp:meetingsmgmt": { friendlyName: "Allow Meeting Management", - description: "Enabling this allows members in this subteam to create, modify and delete team meetings", + description: + "Enabling this allows members in this subteam to create, modify and delete team meetings", }, "corp:eventmgmt": { friendlyName: "Allow Event Management", - description: "Enabling this allows members in this subteam to create, modify, and delete events" + description: + "Enabling this allows members in this subteam to create, modify, and delete events", }, - } + }; async init(): Promise { - return Promise.resolve() + return Promise.resolve(); } getResourceName(): string { - return PeoplePortalClient.TAG + return PeoplePortalClient.TAG; } getSupportedBindles(): BindlePermissionMap { @@ -90,12 +100,15 @@ export class PeoplePortalClient implements SharedResourceClient { * PeoplePortalClient provides the ability to set granular permissions. Bindle Sync * is redundant as permission settings happen in subteam configurations and evaluations * happen in BindleController.ts and the Bindle Authentication Middleware. - * + * * @param org Team Information from Authentik * @param callback Notify Client of Sync Status (Not Needed) * @returns True (Always) */ - handleOrgBindleSync(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + handleOrgBindleSync( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { return Promise.resolve(true); } @@ -103,7 +116,10 @@ export class PeoplePortalClient implements SharedResourceClient { * PeoplePortalClient only defines permission metadata and owns no external * resource state, so there is nothing to archive. */ - archiveTeam(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + archiveTeam( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { return Promise.resolve(true); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/PhotoCheckClient/PhotoCheckClient.ts b/peopleportal/server/src/clients/PhotoCheckClient/PhotoCheckClient.ts index 5b449e2..96edb43 100644 --- a/peopleportal/server/src/clients/PhotoCheckClient/PhotoCheckClient.ts +++ b/peopleportal/server/src/clients/PhotoCheckClient/PhotoCheckClient.ts @@ -19,7 +19,7 @@ const FAIL_CLOSED = process.env.PHOTO_CHECK_FAIL_CLOSED === "true"; function undecided(reason: string): PhotoCheckResult { console.warn( - `[photo-check] no decision (${reason}); ${FAIL_CLOSED ? "rejecting" : "ALLOWING"} upload` + `[photo-check] no decision (${reason}); ${FAIL_CLOSED ? "rejecting" : "ALLOWING"} upload`, ); return { passed: !FAIL_CLOSED, reason }; } @@ -30,7 +30,9 @@ export interface PhotoCheckResult { count?: number; } -export async function checkPhotoHasFace(imageBytes: Uint8Array): Promise { +export async function checkPhotoHasFace( + imageBytes: Uint8Array, +): Promise { if (!ENABLED) { /* Deliberately silent: this is a configured-off state, not a failure, and warning on every upload would train people to ignore the log. */ diff --git a/peopleportal/server/src/clients/RedisClient/index.ts b/peopleportal/server/src/clients/RedisClient/index.ts index dff672c..331087d 100644 --- a/peopleportal/server/src/clients/RedisClient/index.ts +++ b/peopleportal/server/src/clients/RedisClient/index.ts @@ -39,7 +39,10 @@ export class RedisClient { if (!redisUrl) { /* Unconfigured is a deployment choice, not an error. Say so once and stop trying, rather than throwing on every cache read. */ - log.warn(this.TAG, "PEOPLEPORTAL_REDIS_URL is unset; running without a cache."); + log.warn( + this.TAG, + "PEOPLEPORTAL_REDIS_URL is unset; running without a cache.", + ); this.disabled = true; return null; } @@ -60,7 +63,10 @@ export class RedisClient { reconnects in a tight loop, which is what made a downed Redis hang startup and flood the log. */ reconnectStrategy: (retries: number) => - Math.min(100 * 2 ** retries, this.MAX_RECONNECT_DELAY_MS), + Math.min( + 100 * 2 ** retries, + this.MAX_RECONNECT_DELAY_MS, + ), }, }); @@ -68,10 +74,14 @@ export class RedisClient { takes down the process, so this listener is mandatory, not cosmetic: a mid-flight Redis restart would otherwise kill us. */ this.client.on("error", (e: unknown) => { - this.logErrorThrottled(`Redis unavailable, serving uncached: ${e instanceof Error ? e.message : String(e)}`); + this.logErrorThrottled( + `Redis unavailable, serving uncached: ${e instanceof Error ? e.message : String(e)}`, + ); }); - this.client.on("ready", () => log.info(this.TAG, "Cache connected.")); + this.client.on("ready", () => + log.info(this.TAG, "Cache connected."), + ); } return this.client; @@ -83,7 +93,11 @@ export class RedisClient { } /** Runs a cache operation, degrading any failure or stall to `fallback`. */ - private static async attempt(label: string, operation: () => Promise, fallback: T): Promise { + private static async attempt( + label: string, + operation: () => Promise, + fallback: T, + ): Promise { if (!this.ready()) return fallback; try { @@ -93,11 +107,21 @@ export class RedisClient { return await Promise.race([ operation(), new Promise((_, reject) => - setTimeout(() => reject(new Error(`${label} timed out after ${this.COMMAND_TIMEOUT_MS}ms`)), this.COMMAND_TIMEOUT_MS) + setTimeout( + () => + reject( + new Error( + `${label} timed out after ${this.COMMAND_TIMEOUT_MS}ms`, + ), + ), + this.COMMAND_TIMEOUT_MS, + ), ), ]); } catch (e: unknown) { - this.logErrorThrottled(`${label} failed, treating as a miss: ${e instanceof Error ? e.message : String(e)}`); + this.logErrorThrottled( + `${label} failed, treating as a miss: ${e instanceof Error ? e.message : String(e)}`, + ); return fallback; } } @@ -115,35 +139,56 @@ export class RedisClient { await Promise.race([ client.connect(), new Promise((_, reject) => - setTimeout(() => reject(new Error("connect timed out")), this.CONNECT_TIMEOUT_MS) + setTimeout( + () => reject(new Error("connect timed out")), + this.CONNECT_TIMEOUT_MS, + ), ), ]); } catch (e: unknown) { /* Deliberately not rethrown: startup() exits the process on a thrown error, and the server serves fine without a cache. */ - log.warn(this.TAG, `Cache unavailable at boot, continuing without it and retrying in the background: ${e instanceof Error ? e.message : String(e)}`); + log.warn( + this.TAG, + `Cache unavailable at boot, continuing without it and retrying in the background: ${e instanceof Error ? e.message : String(e)}`, + ); } } public static async get(key: string): Promise { - return this.attempt(`get(${key})`, async () => { - const value = await this.client!.get(key); - return value ? (JSON.parse(value) as T) : null; - }, null); + return this.attempt( + `get(${key})`, + async () => { + const value = await this.client!.get(key); + return value ? (JSON.parse(value) as T) : null; + }, + null, + ); } public static async set(key: string, value: unknown, ttlSeconds?: number) { - await this.attempt(`set(${key})`, async () => { - const serialized = JSON.stringify(value); - if (ttlSeconds) await this.client!.set(key, serialized, { EX: ttlSeconds }); - else await this.client!.set(key, serialized); - }, undefined); + await this.attempt( + `set(${key})`, + async () => { + const serialized = JSON.stringify(value); + if (ttlSeconds) + await this.client!.set(key, serialized, { EX: ttlSeconds }); + else await this.client!.set(key, serialized); + }, + undefined, + ); } public static async delete(key: string) { /* A failed invalidation serves stale data until the TTL expires, so every cached key must carry one. */ - await this.attempt(`delete(${key})`, async () => { await this.client!.del(key); }, undefined); + await this.attempt( + `delete(${key})`, + async () => { + await this.client!.del(key); + }, + undefined, + ); } /** Lets tests and shutdown paths release the connection and start clean. */ diff --git a/peopleportal/server/src/clients/SlackClient/index.ts b/peopleportal/server/src/clients/SlackClient/index.ts index 405950a..4f0dde6 100644 --- a/peopleportal/server/src/clients/SlackClient/index.ts +++ b/peopleportal/server/src/clients/SlackClient/index.ts @@ -16,26 +16,36 @@ along with this program. If not, see . */ -import { WebClient } from '@slack/web-api'; -import { BindlePermissionMap } from '../../controllers/BindleController'; -import { SharedResourceClient } from '..'; -import { GetGroupInfoResponse, UserInformationBrief } from '../AuthentikClient/models'; -import { computeStringArrStateDiff } from '../../utils/operations'; -import log from 'loglevel'; +import { WebClient } from "@slack/web-api"; +import { BindlePermissionMap } from "../../controllers/BindleController"; +import { SharedResourceClient } from ".."; +import { + GetGroupInfoResponse, + UserInformationBrief, +} from "../AuthentikClient/models"; +import { computeStringArrStateDiff } from "../../utils/operations"; +import log from "loglevel"; export class SlackClient implements SharedResourceClient { - private static readonly TAG = "SlackClient" + private static readonly TAG = "SlackClient"; private readonly slackClient: WebClient; private botUserId: string | undefined; private readonly slackInviteUrl: string; - private async executeWithRateLimitRetry(operation: () => Promise): Promise { + private async executeWithRateLimitRetry( + operation: () => Promise, + ): Promise { try { return await operation(); } catch (error: any) { - if (error?.code === 'slack_web_api_rate_limited_error' || error?.data?.error === 'ratelimited') { - console.warn(`Slack rate limit hit. Waiting 3 seconds before retrying...`); - await new Promise(resolve => setTimeout(resolve, 3000)); + if ( + error?.code === "slack_web_api_rate_limited_error" || + error?.data?.error === "ratelimited" + ) { + console.warn( + `Slack rate limit hit. Waiting 3 seconds before retrying...`, + ); + await new Promise((resolve) => setTimeout(resolve, 3000)); return await operation(); } throw error; @@ -45,9 +55,10 @@ export class SlackClient implements SharedResourceClient { private readonly supportedBindles: BindlePermissionMap = { "slack:universalaccess": { friendlyName: "Enable Universal Access", - description: "Enabling this adds the members of this subteam to all of the project's slack channels", - } - } + description: + "Enabling this adds the members of this subteam to all of the project's slack channels", + }, + }; constructor() { if (!process.env.PEOPLEPORTAL_SLACK_INVITE_URL) @@ -56,13 +67,17 @@ export class SlackClient implements SharedResourceClient { if (!process.env.PEOPLEPORTAL_SLACK_BOT_TOKEN) throw new Error("PEOPLEPORTAL_SLACK_BOT_TOKEN is not defined"); - this.slackClient = new WebClient(process.env.PEOPLEPORTAL_SLACK_BOT_TOKEN); + this.slackClient = new WebClient( + process.env.PEOPLEPORTAL_SLACK_BOT_TOKEN, + ); this.slackInviteUrl = process.env.PEOPLEPORTAL_SLACK_INVITE_URL; } private async getBotUserId(): Promise { if (this.botUserId) return this.botUserId; - const result = await this.executeWithRateLimitRetry(() => this.slackClient.auth.test()); + const result = await this.executeWithRateLimitRetry(() => + this.slackClient.auth.test(), + ); if (result.user_id) { this.botUserId = result.user_id; return result.user_id; @@ -75,11 +90,11 @@ export class SlackClient implements SharedResourceClient { } getResourceName(): string { - return SlackClient.TAG + return SlackClient.TAG; } getSupportedBindles(): BindlePermissionMap { - return this.supportedBindles + return this.supportedBindles; } public getSlackInviteLink(): string { @@ -92,18 +107,25 @@ export class SlackClient implements SharedResourceClient { */ async sendPrivateMessage(email: string, message: string): Promise { try { - const slackUser = await this.executeWithRateLimitRetry(() => this.slackClient.users.lookupByEmail({ email })); + const slackUser = await this.executeWithRateLimitRetry(() => + this.slackClient.users.lookupByEmail({ email }), + ); const userId = slackUser.user?.id; if (userId) { - const result = await this.executeWithRateLimitRetry(() => this.slackClient.chat.postMessage({ - channel: userId, - text: message - })); + const result = await this.executeWithRateLimitRetry(() => + this.slackClient.chat.postMessage({ + channel: userId, + text: message, + }), + ); console.log("Message sent: ", result.ts); return true; - } + } } catch (error) { - console.error(`Could not send slack message to user with email ${email}`, error); + console.error( + `Could not send slack message to user with email ${email}`, + error, + ); } return false; @@ -111,15 +133,19 @@ export class SlackClient implements SharedResourceClient { async sendMessageInChannel(channelId: string, message: string) { try { - const result = await this.executeWithRateLimitRetry(() => this.slackClient.chat.postMessage({ - channel: channelId, - text: message - })); + const result = await this.executeWithRateLimitRetry(() => + this.slackClient.chat.postMessage({ + channel: channelId, + text: message, + }), + ); console.log("Message sent: ", result.ts); return true; - } catch (error) { - console.error(`Could not send slack message to channel id ${channelId}`, error); + console.error( + `Could not send slack message to channel id ${channelId}`, + error, + ); } return false; @@ -129,14 +155,16 @@ export class SlackClient implements SharedResourceClient { // Returns null if could not get channel. async getChannelFromName(channelName: string) { try { - const response = await this.executeWithRateLimitRetry(() => this.slackClient.conversations.list({ - types: "public_channel,private_channel", - })); + const response = await this.executeWithRateLimitRetry(() => + this.slackClient.conversations.list({ + types: "public_channel,private_channel", + }), + ); if (!response.channels) { return null; } - + for (const channel of response.channels) { if (channel.name === channelName) { return channel; @@ -149,25 +177,34 @@ export class SlackClient implements SharedResourceClient { return null; } - async handleOrgBindleSync(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { - log.info(`[SlackClient] handleOrgBindleSync started for org: ${org.name}`); + async handleOrgBindleSync( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { + log.info( + `[SlackClient] handleOrgBindleSync started for org: ${org.name}`, + ); log.info(`[SlackClient] Found ${org.subteams?.length ?? 0} subteams`); // Cache for email -> userId mapping to avoid redundant API calls across channels const userIdCache = new Map(); const botId = await this.getBotUserId(); - const resolveUserId = async (email: string): Promise => { + const resolveUserId = async ( + email: string, + ): Promise => { if (userIdCache.has(email)) return userIdCache.get(email); try { - const userLookup = await this.executeWithRateLimitRetry(() => this.slackClient.users.lookupByEmail({ email })); + const userLookup = await this.executeWithRateLimitRetry(() => + this.slackClient.users.lookupByEmail({ email }), + ); const userId = userLookup.user?.id; if (userId) { userIdCache.set(email, userId); return userId; } } catch (error: any) { - if (error?.data?.error !== 'users_not_found') { + if (error?.data?.error !== "users_not_found") { console.error(`Failed to resolve user ${email}`, error); } } @@ -179,8 +216,13 @@ export class SlackClient implements SharedResourceClient { const universalAccessEmailSet = new Set(); for (const subteam of org.subteams) { const bindlePermissions = subteam.attributes.bindlePermissions; - if (bindlePermissions && bindlePermissions[SlackClient.TAG]?.["slack:universalaccess"]) { - log.info(`[SlackClient] Subteam ${subteam.name} has universal access enabled`); + if ( + bindlePermissions && + bindlePermissions[SlackClient.TAG]?.["slack:universalaccess"] + ) { + log.info( + `[SlackClient] Subteam ${subteam.name} has universal access enabled`, + ); for (const user of subteam.users) { if (!universalAccessEmailSet.has(user.email)) { universalAccessUsers.push(user); @@ -190,7 +232,9 @@ export class SlackClient implements SharedResourceClient { } } - log.info(`[SlackClient] Identified ${universalAccessUsers.length} users for universal access`); + log.info( + `[SlackClient] Identified ${universalAccessUsers.length} users for universal access`, + ); // Pre-resolve common users (Org Owners + Universal Access) const commonUserIds = new Set(); @@ -202,15 +246,16 @@ export class SlackClient implements SharedResourceClient { for (const subteam of org.subteams) { const channelName = subteam.name - .replace(/(?(commonUserIds); @@ -221,18 +266,26 @@ export class SlackClient implements SharedResourceClient { } // 3. Calculate Diff - const { additions, deletions } = computeStringArrStateDiff(Array.from(targetUserIds), Array.from(currentMemberIds)); + const { additions, deletions } = computeStringArrStateDiff( + Array.from(targetUserIds), + Array.from(currentMemberIds), + ); // 4. Handle Additions if (additions.length > 0) { // Slack allows up to 1000 users per invite call, joined by commas. - const usersToInvite = additions.join(','); - await this.executeWithRateLimitRetry(() => this.slackClient.conversations.invite({ - channel: channelId, - users: usersToInvite - })).catch((e: any) => { - if (e?.data?.error !== 'already_in_channel') { - console.error(`Failed to add users to channel ${channelName}`, e); + const usersToInvite = additions.join(","); + await this.executeWithRateLimitRetry(() => + this.slackClient.conversations.invite({ + channel: channelId, + users: usersToInvite, + }), + ).catch((e: any) => { + if (e?.data?.error !== "already_in_channel") { + console.error( + `Failed to add users to channel ${channelName}`, + e, + ); } }); } @@ -243,10 +296,14 @@ export class SlackClient implements SharedResourceClient { await this.removeUserFromChannel(channelId, uid); } } else { - console.error(`Could not create or find channel: ${channelName}`); + console.error( + `Could not create or find channel: ${channelName}`, + ); } } catch (e: any) { - console.error(`Error processing slack channel ${channelName}: ${e.message}`); + console.error( + `Error processing slack channel ${channelName}: ${e.message}`, + ); } } @@ -261,11 +318,14 @@ export class SlackClient implements SharedResourceClient { * @param org Team Information * @param callback Progress Reporting Callback */ - public async archiveTeam(org: GetGroupInfoResponse, callback: (updatedResourceCount: number, status: string) => void): Promise { + public async archiveTeam( + org: GetGroupInfoResponse, + callback: (updatedResourceCount: number, status: string) => void, + ): Promise { for (const subteam of org.subteams ?? []) { const channelName = subteam.name - .replace(/(? this.slackClient.conversations.archive({ - channel: channelId - })); + await this.executeWithRateLimitRetry(() => + this.slackClient.conversations.archive({ + channel: channelId, + }), + ); callback(1, "Slack Channel Archived: " + channelName); } catch (error: any) { - if (error?.data?.error !== 'already_archived') { - callback(0, "Slack Channel Archival Failed for " + channelName); - console.error(`[SlackClient] Failed to archive channel ${channelName}:`, error.message); + if (error?.data?.error !== "already_archived") { + callback( + 0, + "Slack Channel Archival Failed for " + channelName, + ); + console.error( + `[SlackClient] Failed to archive channel ${channelName}:`, + error.message, + ); } } } @@ -290,49 +358,69 @@ export class SlackClient implements SharedResourceClient { return true; } - private async findChannelByName(channelName: string): Promise { + private async findChannelByName( + channelName: string, + ): Promise { try { let cursor: string | undefined; do { - const options: any = { types: 'private_channel' }; + const options: any = { types: "private_channel" }; if (cursor) options.cursor = cursor; - const listResult: any = await this.executeWithRateLimitRetry(() => this.slackClient.conversations.list(options)); - const found = listResult.channels?.find((c: any) => c.name === channelName); + const listResult: any = await this.executeWithRateLimitRetry( + () => this.slackClient.conversations.list(options), + ); + const found = listResult.channels?.find( + (c: any) => c.name === channelName, + ); if (found) return found.id; cursor = listResult.response_metadata?.next_cursor; } while (cursor); } catch (listError) { - console.error("Error listing channels to find existing one", listError); + console.error( + "Error listing channels to find existing one", + listError, + ); } return undefined; } - private async createOrGetChannel(channelName: string): Promise { + private async createOrGetChannel( + channelName: string, + ): Promise { try { - const result = await this.executeWithRateLimitRetry(() => this.slackClient.conversations.create({ - name: channelName, - is_private: true - })); + const result = await this.executeWithRateLimitRetry(() => + this.slackClient.conversations.create({ + name: channelName, + is_private: true, + }), + ); return result.channel?.id; } catch (error: any) { - if (error?.data?.error === 'name_taken') { + if (error?.data?.error === "name_taken") { try { let cursor: string | undefined; do { const options: any = { - types: 'private_channel', + types: "private_channel", }; if (cursor) options.cursor = cursor; - const listResult: any = await this.executeWithRateLimitRetry(() => this.slackClient.conversations.list(options)); + const listResult: any = + await this.executeWithRateLimitRetry(() => + this.slackClient.conversations.list(options), + ); - const found = listResult.channels?.find((c: any) => c.name === channelName); + const found = listResult.channels?.find( + (c: any) => c.name === channelName, + ); if (found) { // Only return the ID if the existing channel is private if (!found.is_private) { - console.warn(`Channel ${channelName} exists but is PUBLIC. Skipping to enforce privacy.`); + console.warn( + `Channel ${channelName} exists but is PUBLIC. Skipping to enforce privacy.`, + ); return undefined; } return found.id; @@ -341,7 +429,10 @@ export class SlackClient implements SharedResourceClient { cursor = listResult.response_metadata?.next_cursor; } while (cursor); } catch (listError) { - console.error("Error listing channels to find existing one", listError); + console.error( + "Error listing channels to find existing one", + listError, + ); } } console.error("Error creating channel", channelName, error); @@ -356,7 +447,9 @@ export class SlackClient implements SharedResourceClient { const options: any = { channel: channelId }; if (cursor) options.cursor = cursor; - const membersResult: any = await this.executeWithRateLimitRetry(() => this.slackClient.conversations.members(options)); + const membersResult: any = await this.executeWithRateLimitRetry( + () => this.slackClient.conversations.members(options), + ); if (membersResult.members) { for (const m of membersResult.members) currentMemberIds.add(m); } @@ -367,28 +460,35 @@ export class SlackClient implements SharedResourceClient { private async removeUserFromChannel(channelId: string, userId: string) { try { - await this.executeWithRateLimitRetry(() => this.slackClient.conversations.kick({ - channel: channelId, - user: userId - })); + await this.executeWithRateLimitRetry(() => + this.slackClient.conversations.kick({ + channel: channelId, + user: userId, + }), + ); } catch (error: any) { const code = error?.data?.error; - if (code !== 'not_in_channel' && code !== 'cant_kick_self') { - console.error(`Failed to remove user ${userId} from channel ${channelId}`, error); + if (code !== "not_in_channel" && code !== "cant_kick_self") { + console.error( + `Failed to remove user ${userId} from channel ${channelId}`, + error, + ); } } } public async validateUserPresence(email: string): Promise { try { - const result = await this.executeWithRateLimitRetry(() => this.slackClient.users.lookupByEmail({ email })); + const result = await this.executeWithRateLimitRetry(() => + this.slackClient.users.lookupByEmail({ email }), + ); return !!result.user; } catch (error: any) { - if (error?.data?.error === 'users_not_found') { + if (error?.data?.error === "users_not_found") { return false; } throw new Error(`Slack verification failed: ${error.message}`); } } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/clients/index.ts b/peopleportal/server/src/clients/index.ts index 024d434..a48555b 100644 --- a/peopleportal/server/src/clients/index.ts +++ b/peopleportal/server/src/clients/index.ts @@ -25,14 +25,14 @@ import { GetGroupInfoResponse } from "./AuthentikClient/models"; * Gitea, Slack, etc. */ export interface SharedResourceClient { - init(): Promise - getResourceName(): string - getSupportedBindles(): BindlePermissionMap + init(): Promise; + getResourceName(): string; + getSupportedBindles(): BindlePermissionMap; handleOrgBindleSync( org: GetGroupInfoResponse, - callback: (updatedResourceCount: number, status: string) => void - ): Promise + callback: (updatedResourceCount: number, status: string) => void, + ): Promise; /** * Archives the team's resources, making them read-only while preserving @@ -41,8 +41,8 @@ export interface SharedResourceClient { */ archiveTeam( org: GetGroupInfoResponse, - callback: (updatedResourceCount: number, status: string) => void - ): Promise + callback: (updatedResourceCount: number, status: string) => void, + ): Promise; } /** @@ -50,13 +50,13 @@ export interface SharedResourceClient { * changes like, AWS accounts, etc. */ export interface RootTeamSettingClient { - getResourceName(): string - getSupportedSettings(): RootTeamSettingMap - syncSettingUpdate(org: GetGroupInfoResponse): Promise + getResourceName(): string; + getSupportedSettings(): RootTeamSettingMap; + syncSettingUpdate(org: GetGroupInfoResponse): Promise; /** * Archives the team's root setting resources, preserving their data * (Ex. an extension point for suspending a provisioned AWS account). */ - archiveTeam(org: GetGroupInfoResponse): Promise -} \ No newline at end of file + archiveTeam(org: GetGroupInfoResponse): Promise; +} diff --git a/peopleportal/server/src/config.ts b/peopleportal/server/src/config.ts index 2e7e9f9..6e9ebb2 100644 --- a/peopleportal/server/src/config.ts +++ b/peopleportal/server/src/config.ts @@ -32,189 +32,196 @@ import { DiscordClient } from "./clients/DiscordClient"; * of default subteams and the default bindle permissions to apply to each subteam. */ export interface TeamTypeConfig { - defaultSubteams: { - friendlyName: string; - description: string; - bindles?: { [key: string]: EnabledBindlePermissions }; /* Optional Bindle Permissions */ - }[]; + defaultSubteams: { + friendlyName: string; + description: string; + bindles?: { + [key: string]: EnabledBindlePermissions; + }; /* Optional Bindle Permissions */ + }[]; } /** * This interface helps define the configuration for service teams. * Service teams are teams that are unique to the organization and are created * automatically by People Portal if they don't exist. - * + * * Some service teams enable hardcoded internal functionality. For example, the * ExecutiveBoard team integrates with the Executive Authorization Layer to override * all bindles just as how superusers would do. - * + * * Adding to and removing members from these service teams have predefined rulesets * that are defined in the code. */ export interface ServiceTeamConfig { - friendlyName: string; - description: string; - subteams: { - uniqueName: string; friendlyName: string; description: string; - bindles?: { [key: string]: EnabledBindlePermissions }; - }[]; + subteams: { + uniqueName: string; + friendlyName: string; + description: string; + bindles?: { [key: string]: EnabledBindlePermissions }; + }[]; } /* Define Enabled Shared Resources Here */ -export const ENABLED_SHARED_RESOURCES: { [key: string]: SharedResourceClient } = { - // appleAccountClient: new AppleAccountClient(), - giteaClient: new GiteaClient(), - peoplePortalClient: new PeoplePortalClient(), - slackClient: new SlackClient(), - discordClient: new DiscordClient(), -} +export const ENABLED_SHARED_RESOURCES: { [key: string]: SharedResourceClient } = + { + // appleAccountClient: new AppleAccountClient(), + giteaClient: new GiteaClient(), + peoplePortalClient: new PeoplePortalClient(), + slackClient: new SlackClient(), + discordClient: new DiscordClient(), + }; /* Define Enabled Root Team Setting Resources Here */ -export const ENABLED_TEAMSETTING_RESOURCES: { [key: string]: RootTeamSettingClient } = { - awsClient: new AWSClient() -} +export const ENABLED_TEAMSETTING_RESOURCES: { + [key: string]: RootTeamSettingClient; +} = { + awsClient: new AWSClient(), +}; /* Define Enabled Service Teams Here */ export const ENABLED_SERVICE_TEAMS: Record = { - ExecutiveBoard: { - friendlyName: "Executive Board", - description: "The President and Other Club Executives", - subteams: [ - { - uniqueName: "ExecutiveBoardMembers", - friendlyName: "Current Executives", - description: "The Current President and Club Executives" - }, - { - uniqueName: "ExecutiveBoardAlumni", - friendlyName: "Previous Executives", - description: "The Previous Presidents and Club Executives" - }, - ] - }, - TechOps: { - friendlyName: "Technical Operations", - description: "The Team Responsible for People Portal and Club Infrastructure", - subteams: [ - { - uniqueName: "TechOpsMembers", - friendlyName: "Current Tech Ops", - description: "The Current Technical Operations Team" - }, - { - uniqueName: "TechOpsAlumni", - friendlyName: "Previous Tech Ops", - description: "The Previous Technical Operations Team" - }, - ] - }, - Events: { - friendlyName: "Events", - description: "Team of Members able to Create, Manage, and Delete Events", - subteams: [ - { - uniqueName: "EventsTeamMembers", - friendlyName: "Events Team Members", - description: "Current members of the events team.", - bindles: { - "PeoplePortalClient": { - "corp:eventmgmt": true, - }, - } - }, - ] - }, -} + ExecutiveBoard: { + friendlyName: "Executive Board", + description: "The President and Other Club Executives", + subteams: [ + { + uniqueName: "ExecutiveBoardMembers", + friendlyName: "Current Executives", + description: "The Current President and Club Executives", + }, + { + uniqueName: "ExecutiveBoardAlumni", + friendlyName: "Previous Executives", + description: "The Previous Presidents and Club Executives", + }, + ], + }, + TechOps: { + friendlyName: "Technical Operations", + description: + "The Team Responsible for People Portal and Club Infrastructure", + subteams: [ + { + uniqueName: "TechOpsMembers", + friendlyName: "Current Tech Ops", + description: "The Current Technical Operations Team", + }, + { + uniqueName: "TechOpsAlumni", + friendlyName: "Previous Tech Ops", + description: "The Previous Technical Operations Team", + }, + ], + }, + Events: { + friendlyName: "Events", + description: + "Team of Members able to Create, Manage, and Delete Events", + subteams: [ + { + uniqueName: "EventsTeamMembers", + friendlyName: "Events Team Members", + description: "Current members of the events team.", + bindles: { + PeoplePortalClient: { + "corp:eventmgmt": true, + }, + }, + }, + ], + }, +}; /* Define Team Type Templates Here */ export const TEAM_TYPE_CONFIGS: Partial> = { - [TeamType.PROJECT]: { - defaultSubteams: [ - { - friendlyName: "Leadership", - description: "Project and Tech Leads", - bindles: { - "PeoplePortalClient": { - "corp:awsaccess": true, - "corp:hiringaccess": true - }, + [TeamType.PROJECT]: { + defaultSubteams: [ + { + friendlyName: "Leadership", + description: "Project and Tech Leads", + bindles: { + PeoplePortalClient: { + "corp:awsaccess": true, + "corp:hiringaccess": true, + }, - "SlackClient": { - "slack:universalaccess": true, - }, + SlackClient: { + "slack:universalaccess": true, + }, - "GiteaClient": { - "repo:allowcreate": true, - "repo:brprotect-approvals": true, - "repo:brprotect-override": true, - "repo:brprotect-merge": true - }, - } - }, - { - friendlyName: "Engineering", - description: "UI/UX, PMs, SWEs, etc.", - bindles: { - "GiteaClient": { - "repo:brprotect-override": true, - "repo:brprotect-merge": true - }, - } - } - ] - }, + GiteaClient: { + "repo:allowcreate": true, + "repo:brprotect-approvals": true, + "repo:brprotect-override": true, + "repo:brprotect-merge": true, + }, + }, + }, + { + friendlyName: "Engineering", + description: "UI/UX, PMs, SWEs, etc.", + bindles: { + GiteaClient: { + "repo:brprotect-override": true, + "repo:brprotect-merge": true, + }, + }, + }, + ], + }, - [TeamType.BOOTCAMP]: { - defaultSubteams: [ - { - friendlyName: "Learners", - description: "Bootcamp Students", - bindles: { - "GiteaClient": { - "repo:allowcreate": true, - "repo:brprotect-override": true, - "repo:brprotect-merge": true, - "repo:brprotect-approvals": true - }, - } - }, - { - friendlyName: "Educators", - description: "Bootcamp Teachers", - bindles: { - "PeoplePortalClient": { - "corp:hiringaccess": true, - "corp:bindlesync": true, - "corp:subteamaccess": true, - "corp:membermgmt": true - }, + [TeamType.BOOTCAMP]: { + defaultSubteams: [ + { + friendlyName: "Learners", + description: "Bootcamp Students", + bindles: { + GiteaClient: { + "repo:allowcreate": true, + "repo:brprotect-override": true, + "repo:brprotect-merge": true, + "repo:brprotect-approvals": true, + }, + }, + }, + { + friendlyName: "Educators", + description: "Bootcamp Teachers", + bindles: { + PeoplePortalClient: { + "corp:hiringaccess": true, + "corp:bindlesync": true, + "corp:subteamaccess": true, + "corp:membermgmt": true, + }, - "SlackClient": { - "slack:universalaccess": true, - }, + SlackClient: { + "slack:universalaccess": true, + }, - "GiteaClient": { - "repo:allowcreate": true, - "repo:brprotect-override": true, - "repo:brprotect-merge": true, - "repo:brprotect-approvals": true - }, - } - }, - { - friendlyName: "Interviewers", - description: "Interviewers for Bootcamp", - bindles: { - "PeoplePortalClient": { - "corp:hiringaccess": true - } - } - } - ] - } -} + GiteaClient: { + "repo:allowcreate": true, + "repo:brprotect-override": true, + "repo:brprotect-merge": true, + "repo:brprotect-approvals": true, + }, + }, + }, + { + friendlyName: "Interviewers", + description: "Interviewers for Bootcamp", + bindles: { + PeoplePortalClient: { + "corp:hiringaccess": true, + }, + }, + }, + ], + }, +}; /** * Root teams under which administrative authority can be held. @@ -224,7 +231,7 @@ export const TEAM_TYPE_CONFIGS: Partial> = { * to miss. */ export const ADMIN_AUTHORITY_TEAMS: ReadonlySet = new Set([ - "ExecutiveBoard", + "ExecutiveBoard", ]); /** @@ -245,7 +252,7 @@ export const ADMIN_AUTHORITY_TEAMS: ReadonlySet = new Set([ * need it are granted superuser in Authentik individually. */ export const ADMIN_AUTHORITY_SUBTEAMS: ReadonlySet = new Set([ - "ExecutiveBoardMembers", + "ExecutiveBoardMembers", ]); /** @@ -258,15 +265,18 @@ export const ADMIN_AUTHORITY_SUBTEAMS: ReadonlySet = new Set([ * @param userGroups the user's own group names, from the OIDC session */ export const hasAdminAuthority = ( - rootTeams: { name: string; flaggedForDeletion?: boolean }[], - userGroups: string[] + rootTeams: { name: string; flaggedForDeletion?: boolean }[], + userGroups: string[], ): boolean => { - const liveAdminRoot = rootTeams.some( - team => ADMIN_AUTHORITY_TEAMS.has(team.name) && !team.flaggedForDeletion - ); - if (!liveAdminRoot) return false; + const liveAdminRoot = rootTeams.some( + (team) => + ADMIN_AUTHORITY_TEAMS.has(team.name) && !team.flaggedForDeletion, + ); + if (!liveAdminRoot) return false; - return userGroups.some( - group => ADMIN_AUTHORITY_SUBTEAMS.has(group) || ADMIN_AUTHORITY_TEAMS.has(group) - ); + return userGroups.some( + (group) => + ADMIN_AUTHORITY_SUBTEAMS.has(group) || + ADMIN_AUTHORITY_TEAMS.has(group), + ); }; diff --git a/peopleportal/server/src/config/environment.ts b/peopleportal/server/src/config/environment.ts index 7954795..0f6edd7 100644 --- a/peopleportal/server/src/config/environment.ts +++ b/peopleportal/server/src/config/environment.ts @@ -36,13 +36,17 @@ import dotenv from "dotenv"; export type NodeEnvironment = "development" | "test" | "production"; -const VALID_ENVIRONMENTS: NodeEnvironment[] = ["development", "test", "production"]; +const VALID_ENVIRONMENTS: NodeEnvironment[] = [ + "development", + "test", + "production", +]; function resolveEnvironment(): NodeEnvironment { const raw = (process.env.NODE_ENV ?? "development").toLowerCase(); if (!VALID_ENVIRONMENTS.includes(raw as NodeEnvironment)) { throw new Error( - `NODE_ENV must be one of ${VALID_ENVIRONMENTS.join(", ")} (received "${raw}")` + `NODE_ENV must be one of ${VALID_ENVIRONMENTS.join(", ")} (received "${raw}")`, ); } return raw as NodeEnvironment; @@ -97,16 +101,18 @@ function isBlank(value: string | undefined): boolean { export function assertRequiredEnvironment(): void { if (!isProduction) return; - const missing = REQUIRED_IN_PRODUCTION.filter((key) => isBlank(process.env[key])); + const missing = REQUIRED_IN_PRODUCTION.filter((key) => + isBlank(process.env[key]), + ); if (missing.length > 0) { throw new Error( - `Missing required environment variables in production: ${missing.join(", ")}` + `Missing required environment variables in production: ${missing.join(", ")}`, ); } if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === "0") { throw new Error( - "NODE_TLS_REJECT_UNAUTHORIZED=0 disables all TLS verification and must not be set in production" + "NODE_TLS_REJECT_UNAUTHORIZED=0 disables all TLS verification and must not be set in production", ); } } diff --git a/peopleportal/server/src/controllers/ATSController.ts b/peopleportal/server/src/controllers/ATSController.ts index 905933c..babbb46 100644 --- a/peopleportal/server/src/controllers/ATSController.ts +++ b/peopleportal/server/src/controllers/ATSController.ts @@ -16,17 +16,46 @@ along with this program. If not, see . */ -import { Body, Request, Controller, Delete, Get, Path, Post, Put, Route, SuccessResponse, Tags } from "tsoa"; +import { + Body, + Request, + Controller, + Delete, + Get, + Path, + Post, + Put, + Route, + SuccessResponse, + Tags, +} from "tsoa"; import { SubteamConfig, ISubteamConfig } from "../models/SubteamConfig"; -import { TeamRecruitingStatus, ITeamRecruitingStatus } from "../models/TeamRecruitingStatus"; +import { + TeamRecruitingStatus, + ITeamRecruitingStatus, +} from "../models/TeamRecruitingStatus"; import { AuthentikClient } from "../clients/AuthentikClient"; -import { GetGroupInfoResponse, UserInformationBrief, AuthentikClientError, AuthentikClientErrorType } from "../clients/AuthentikClient/models"; -import { Applicant, IApplicant, ApplicantProfile } from '../models/Applicant'; -import { Application, IApplication, ApplicationStage } from "../models/Application"; +import { + GetGroupInfoResponse, + UserInformationBrief, + AuthentikClientError, + AuthentikClientErrorType, +} from "../clients/AuthentikClient/models"; +import { Applicant, IApplicant, ApplicantProfile } from "../models/Applicant"; +import { + Application, + IApplication, + ApplicationStage, +} from "../models/Application"; import { Security } from "tsoa"; -import * as express from 'express' -import path from 'path'; -import { PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import * as express from "express"; +import path from "path"; +import { + PutObjectCommand, + GetObjectCommand, + CopyObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { s3Client, BUCKET_NAME } from "../clients/AWSClient/S3Client"; @@ -35,11 +64,19 @@ import { EmailClient } from "../clients/EmailClient"; import { OrgController } from "./OrgController"; import { BindleController } from "./BindleController"; import { AuthorizedUser } from "../clients/OpenIdClient"; -import { validateS3FileSignature, FILE_SIGNATURES } from '../utils/s3-validation'; +import { + validateS3FileSignature, + FILE_SIGNATURES, +} from "../utils/s3-validation"; import MarkdownIt from "markdown-it"; import { RedisClient } from "../clients/RedisClient"; import { mapWithConcurrency } from "../utils/concurrency"; -import { CacheEnvelope, isCacheEnvelope, isCacheStale, wrapCacheValue } from "../utils/cache-envelope"; +import { + CacheEnvelope, + isCacheEnvelope, + isCacheStale, + wrapCacheValue, +} from "../utils/cache-envelope"; // ==================== // Type Definitions @@ -54,28 +91,28 @@ interface ATSSubteamConfigRequest { // Team-level application request with ordered role preferences interface ATSTeamApplicationRequest { teamPk: string; - rolePreferences: { role: string, subteamPk: string }[]; // Ordered array + rolePreferences: { role: string; subteamPk: string }[]; // Ordered array profile: ApplicantProfile; responses: Record; } interface ATSUpdateApplicationStageRequest { - stage: ApplicationStage, - interviewLink?: string, - interviewGuidelines?: string, - hiredRole?: string, - hiredSubteamPk?: string + stage: ApplicationStage; + interviewLink?: string; + interviewGuidelines?: string; + hiredRole?: string; + hiredSubteamPk?: string; } interface ATSKanbanApplicationCard { id: string; applicantId: string; name: string; - rolePreferences: { role: string, subteamPk: string }[]; + rolePreferences: { role: string; subteamPk: string }[]; appliedAt: Date; stage: ApplicationStage; - /** + /** * Rating for an Applicant * @minimum 0 Minimum Stars is 0 * @maximum 5 Maximum Stars is 5 @@ -98,8 +135,8 @@ interface ATSApplicationDetails extends ATSKanbanApplicationCard { } // Constants -const ALLOWED_RESUME_EXTENSIONS = ['pdf'] as const; -const ALLOWED_CONTENT_TYPES = ['application/pdf'] as const; +const ALLOWED_RESUME_EXTENSIONS = ["pdf"] as const; +const ALLOWED_CONTENT_TYPES = ["application/pdf"] as const; const MAX_RESPONSE_LENGTH = 2000; const MAX_WHY_APPDEV_WORDS = 200; const MIN_RESPONSE_WORDS = 10; @@ -119,17 +156,17 @@ export class ATSController extends Controller { private readonly orgController: OrgController; constructor() { - super() - this.authentikClient = new AuthentikClient() - this.emailClient = new EmailClient() - this.orgController = new OrgController() + super(); + this.authentikClient = new AuthentikClient(); + this.emailClient = new EmailClient(); + this.orgController = new OrgController(); } /** * Generates a presigned URL for uploading a resume to the AWS S3 bucket. * Checks for PDF File Type, Prevents Path Traversals and requires valid * Guest Authentication Token. - * + * * @param request Express Request Object * @param fileName Resume File Name * @param contentType Resume File Content Type @@ -139,30 +176,47 @@ export class ATSController extends Controller { @Tags("Applicant Portal") @Security("ats_otp") @SuccessResponse(200) - async getResumeUploadUrl(@Request() request: express.Request, @Query() fileName: string, @Query() contentType: string) { + async getResumeUploadUrl( + @Request() request: express.Request, + @Query() fileName: string, + @Query() contentType: string, + ) { const userEmail = request.session.tempsession?.user?.email; if (!userEmail) { this.setStatus(401); - return { error: "Unauthorized", message: "User session not found." }; + return { + error: "Unauthorized", + message: "User session not found.", + }; } // Validate PDF only - const fileExtension = fileName.split('.').pop()?.toLowerCase(); - if (!fileExtension || !ALLOWED_RESUME_EXTENSIONS.includes(fileExtension as any)) { + const fileExtension = fileName.split(".").pop()?.toLowerCase(); + if ( + !fileExtension || + !ALLOWED_RESUME_EXTENSIONS.includes(fileExtension as any) + ) { this.setStatus(400); return { error: "InvalidFileType", - message: `Only PDF resumes are accepted. Allowed types: ${ALLOWED_RESUME_EXTENSIONS.join(', ')}` + message: `Only PDF resumes are accepted. Allowed types: ${ALLOWED_RESUME_EXTENSIONS.join(", ")}`, }; } if (!ALLOWED_CONTENT_TYPES.includes(contentType as any)) { this.setStatus(400); - return { error: "InvalidContentType", message: "Invalid content type. Only PDF is accepted." }; + return { + error: "InvalidContentType", + message: "Invalid content type. Only PDF is accepted.", + }; } // Prevent path traversal - if (fileName.includes('..') || fileName.includes('/') || fileName.includes('\\')) { + if ( + fileName.includes("..") || + fileName.includes("/") || + fileName.includes("\\") + ) { this.setStatus(400); return { error: "InvalidFileName", message: "Invalid file name" }; } @@ -171,7 +225,10 @@ export class ATSController extends Controller { const applicant = await Applicant.findOne({ email: userEmail }).exec(); if (!applicant) { this.setStatus(404); - return { error: "ApplicantNotFound", message: "Applicant not found" }; + return { + error: "ApplicantNotFound", + message: "Applicant not found", + }; } const key = `resumes/${applicant._id}.pdf`; @@ -182,24 +239,24 @@ export class ATSController extends Controller { Key: key, Conditions: [ ["content-length-range", 0, 10 * 1024 * 1024], // 10MB limit - { "content-type": "application/pdf" } + { "content-type": "application/pdf" }, ], Fields: { - "content-type": "application/pdf" + "content-type": "application/pdf", }, - Expires: 600 // 10 minutes + Expires: 600, // 10 minutes }); // Construct the proxy URL using the request headers const protocol = request.protocol; - const host = request.get('host'); + const host = request.get("host"); const publicUrl = `${protocol}://${host}/api/ats/resume/download?key=${encodeURIComponent(key)}`; return { uploadUrl: url, fields, publicUrl, - key + key, }; } @@ -207,7 +264,7 @@ export class ATSController extends Controller { * Generates a presigned URL for downloading a resume from the AWS S3 bucket. * Requires valid Guest Authentication Token and intended for use by applicants * to view their resume and profile. - * + * * @param request Express Request Object * @param key Resume Key * @returns AWS S3 Download URL @@ -217,21 +274,31 @@ export class ATSController extends Controller { @Security("ats_otp") @SuccessResponse(307, "Temporary Redirect") async getResumeDownloadUrl(@Request() request: any, @Query() key: string) { - const userEmail = request.session?.tempsession?.user?.email + const userEmail = request.session?.tempsession?.user?.email; // 1. Verify Authentication if (!userEmail) { this.setStatus(401); - return { error: "Unauthorized", message: "User session not found." }; + return { + error: "Unauthorized", + message: "User session not found.", + }; } const applicant = await Applicant.findOne({ email: userEmail }); const normalizedKey = path.posix.normalize(key); const expectedKey = `resumes/${applicant?._id}.pdf`; - if (!applicant || normalizedKey !== expectedKey || normalizedKey.includes('..')) { + if ( + !applicant || + normalizedKey !== expectedKey || + normalizedKey.includes("..") + ) { this.setStatus(403); - return { error: "Forbidden", message: "You do not have permission to access this file." }; + return { + error: "Forbidden", + message: "You do not have permission to access this file.", + }; } // 3. Generate Presigned GET URL @@ -244,17 +311,21 @@ export class ATSController extends Controller { Key: normalizedKey, }); - const downloadUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 }); // 15 minutes + const downloadUrl = await getSignedUrl(s3Client, command, { + expiresIn: 900, + }); // 15 minutes // 4. Redirect this.setStatus(307); - this.setHeader('Location', downloadUrl); + this.setHeader("Location", downloadUrl); return; - } catch (error) { console.error("Error generating download URL:", error); this.setStatus(404); // Or 500 - return { error: "NotFound", message: "File not found or access denied." }; + return { + error: "NotFound", + message: "File not found or access denied.", + }; } } @@ -263,7 +334,7 @@ export class ATSController extends Controller { * subteam and parent information via Authentik API calls. To perform this action, the * user must either be a Team Owner (on the parent team) or, hold the `corp:hiringaccess` * bindle. - * + * * @param teamId Subteam ID * @returns Subteam Recruitment Configuration */ @@ -272,12 +343,14 @@ export class ATSController extends Controller { @SuccessResponse(200) @Security("bindles", ["corp:hiringaccess"]) async getSubTeamATSConfig(@Path() teamId: string) { - const config = await SubteamConfig.findOne({ subteamPk: teamId }).lean().exec() as any; + const config = (await SubteamConfig.findOne({ subteamPk: teamId }) + .lean() + .exec()) as any; if (!config) { this.setStatus(404); return { error: "Not Found", - message: `Subteam config with ID ${teamId} not found` + message: `Subteam config with ID ${teamId} not found`, }; } @@ -288,13 +361,15 @@ export class ATSController extends Controller { /* 01/21/2026 (@atheesh): 50% API Call Reduction on 2025.12+ via new Client Option */ const authentikSubteamInfo = await this.authentikClient.getGroupInfo( config.subteamPk, - { includeParentInfo: true } + { includeParentInfo: true }, ); let authentikParentInfo = authentikSubteamInfo.parentInfo; if (!authentikParentInfo) { /* Fallback for Legacy Implementation (Authentik <2025.12) */ - authentikParentInfo = await this.authentikClient.getGroupInfo(authentikSubteamInfo.parentPk); + authentikParentInfo = await this.authentikClient.getGroupInfo( + authentikSubteamInfo.parentPk, + ); } return { @@ -304,7 +379,7 @@ export class ATSController extends Controller { friendlyName: authentikSubteamInfo.attributes.friendlyName, description: authentikSubteamInfo.attributes.description, seasonText: `${authentikSubteamInfo.attributes.seasonType} ${authentikSubteamInfo.attributes.seasonYear}`, - pk: authentikSubteamInfo.pk + pk: authentikSubteamInfo.pk, }, parentInfo: { @@ -312,17 +387,17 @@ export class ATSController extends Controller { friendlyName: authentikParentInfo.attributes.friendlyName, description: authentikParentInfo.attributes.description, seasonText: `${authentikParentInfo.attributes.seasonType} ${authentikParentInfo.attributes.seasonYear}`, - pk: authentikParentInfo.pk - } + pk: authentikParentInfo.pk, + }, }; } /** * Configure a subteam to recruit for specific roles with addtional role specific - * questions. This endpoint automatically sets the `isRecruiting` status for a + * questions. This endpoint automatically sets the `isRecruiting` status for a * subteam. To perform this action, the user must either be a Team Owner or hold * the `corp:hiringaccess` bindle. - * + * * @param teamId People Portal Team ID * @param body Subteam Configuration Request * @returns Updated Subteam Recruitment Configuration @@ -331,14 +406,17 @@ export class ATSController extends Controller { @Tags("Recruitment Configuration") @SuccessResponse(200, "Created or Updated") @Security("bindles", ["corp:hiringaccess"]) - async createOrUpdateSubteamConfig(@Path() teamId: string, @Body() body: ATSSubteamConfigRequest) { + async createOrUpdateSubteamConfig( + @Path() teamId: string, + @Body() body: ATSSubteamConfigRequest, + ) { const { isRecruiting, roles, roleSpecificQuestions } = body; if (roles.length === 0) { this.setStatus(400); return { error: "Bad Request", - message: "Roles are required" + message: "Roles are required", }; } @@ -349,21 +427,31 @@ export class ATSController extends Controller { subteamPk: teamId, isRecruiting, roles, - roleSpecificQuestions: roleSpecificQuestions || {} + roleSpecificQuestions: roleSpecificQuestions || {}, }, // update data { - upsert: true, // Create if doesn't exist - new: true, // Return updated document - setDefaultsOnInsert: true // Apply schema defaults on insert - } - ).lean().exec(); + upsert: true, // Create if doesn't exist + new: true, // Return updated document + setDefaultsOnInsert: true, // Apply schema defaults on insert + }, + ) + .lean() + .exec(); /* Update Team Recruiting Status */ - const subteamInfo = await this.authentikClient.getGroupInfo(updatedConfig.subteamPk) + const subteamInfo = await this.authentikClient.getGroupInfo( + updatedConfig.subteamPk, + ); if (updatedConfig.isRecruiting) { - await this.addSubteamToRecruiting(subteamInfo.parentPk, updatedConfig.subteamPk) + await this.addSubteamToRecruiting( + subteamInfo.parentPk, + updatedConfig.subteamPk, + ); } else { - await this.removeSubteamFromRecruiting(subteamInfo.parentPk, updatedConfig.subteamPk) + await this.removeSubteamFromRecruiting( + subteamInfo.parentPk, + updatedConfig.subteamPk, + ); } // Convert Map back to object for JSON response @@ -374,14 +462,15 @@ export class ATSController extends Controller { * Public API that fetches the list of teams that are currently recruiting * new candidates. Provides a list of roles and other Recruitment Info in * addition to the team and subteam info. - * + * * @returns List of Teams Recruiting */ @Get("openteams") @Tags("Applicant Portal") @SuccessResponse(200) async getAllRecruitingTeams() { - const cached = await RedisClient.get>(OPEN_TEAMS_CACHE_KEY); + const cached = + await RedisClient.get>(OPEN_TEAMS_CACHE_KEY); if (isCacheEnvelope(cached)) { if (isCacheStale(cached, OPEN_TEAMS_CACHE_SOFT_TTL_SECONDS)) @@ -412,8 +501,14 @@ export class ATSController extends Controller { /* Deliberately not awaited. Nothing may escape: an unhandled rejection here would take the process down for a stale cache entry. */ void this.buildAndCacheOpenTeams() - .catch((e: any) => console.error(`Background refresh of the open-teams cache failed: ${e?.message ?? e}`)) - .finally(() => { ATSController.openTeamsRefreshInFlight = false; }); + .catch((e: any) => + console.error( + `Background refresh of the open-teams cache failed: ${e?.message ?? e}`, + ), + ) + .finally(() => { + ATSController.openTeamsRefreshInFlight = false; + }); } private async buildAndCacheOpenTeams() { @@ -421,7 +516,7 @@ export class ATSController extends Controller { await RedisClient.set( OPEN_TEAMS_CACHE_KEY, wrapCacheValue(validRecruitingTeams), - OPEN_TEAMS_CACHE_HARD_TTL_SECONDS + OPEN_TEAMS_CACHE_HARD_TTL_SECONDS, ); return validRecruitingTeams; @@ -430,19 +525,29 @@ export class ATSController extends Controller { /** The uncached build. Every Mongo and Authentik read for this page lives here. */ private async buildOpenTeams() { /* Fetch All teams that are recruiting currently */ - const recruitingTeams: any[] = await TeamRecruitingStatus.find({ isRecruiting: true }).lean().exec(); + const recruitingTeams: any[] = await TeamRecruitingStatus.find({ + isRecruiting: true, + }) + .lean() + .exec(); - /* + /* * 01/21/2026 (@atheesh): Optimization from O(N*M) to O(N) * Previously, this loop made a DB call and an API call for every subteam (M) of every team (N). - * - * Now, we batch fetch all SubteamConfigs in one DB query. Furthermore, we utilize the already - * fetched `authentikTeamInfo` (Parent) and its `subteams` array (Children) to populate the - * recruitment info, eliminating the need for the redundant Authentik API calls that were + * + * Now, we batch fetch all SubteamConfigs in one DB query. Furthermore, we utilize the already + * fetched `authentikTeamInfo` (Parent) and its `subteams` array (Children) to populate the + * recruitment info, eliminating the need for the redundant Authentik API calls that were * happening in `getSubTeamATSConfig`. This aligns with the "Authentik 2025.12+" update. */ - const allSubteamPks = recruitingTeams.flatMap(t => t.recruitingSubteamPks); - const allConfigs = await SubteamConfig.find({ subteamPk: { $in: allSubteamPks } }).lean().exec(); + const allSubteamPks = recruitingTeams.flatMap( + (t) => t.recruitingSubteamPks, + ); + const allConfigs = await SubteamConfig.find({ + subteamPk: { $in: allSubteamPks }, + }) + .lean() + .exec(); const configMap = new Map(allConfigs.map((c: any) => [c.subteamPk, c])); /* @@ -453,8 +558,12 @@ export class ATSController extends Controller { * and the teams resolve in a single bulk lookup. */ const teamInfoByPk = await this.authentikClient.getGroupsInfoByPks( - recruitingTeams.map(t => t.teamPk), - { includeUsers: false, includeChildren: true, disableSubteamMemberPopulate: true } + recruitingTeams.map((t) => t.teamPk), + { + includeUsers: false, + includeChildren: true, + disableSubteamMemberPopulate: true, + }, ); /* Populate Team and Subteam Info */ @@ -470,13 +579,13 @@ export class ATSController extends Controller { continue; } - const recruitingSubteams = new Set(team.recruitingSubteamPks) + const recruitingSubteams = new Set(team.recruitingSubteamPks); const parentInfoObj = { name: authentikTeamInfo.name, friendlyName: authentikTeamInfo.attributes.friendlyName, description: authentikTeamInfo.attributes.description, seasonText: `${authentikTeamInfo.attributes.seasonType} ${authentikTeamInfo.attributes.seasonYear}`, - pk: authentikTeamInfo.pk + pk: authentikTeamInfo.pk, }; team.teamInfo = parentInfoObj; @@ -490,16 +599,18 @@ export class ATSController extends Controller { friendlyName: sub.attributes.friendlyName, description: sub.attributes.description, seasonText: `${sub.attributes.seasonType} ${sub.attributes.seasonYear}`, - pk: sub.pk + pk: sub.pk, }; team.subteamInfo[sub.pk] = { ...subteamInfoObj, - recruitmentInfo: config ? { - ...config, - subteamInfo: subteamInfoObj, - parentInfo: parentInfoObj - } : null, + recruitmentInfo: config + ? { + ...config, + subteamInfo: subteamInfoObj, + parentInfo: parentInfoObj, + } + : null, }; } } @@ -514,8 +625,12 @@ export class ATSController extends Controller { * one query rather than one delete per team inside the render loop. */ if (missingTeamPks.length > 0) { - console.warn(`Recruiting Teams with PKs ${missingTeamPks.join(", ")} not found in Authentik. Cleaning up.`); - await TeamRecruitingStatus.deleteMany({ teamPk: { $in: missingTeamPks } }).exec(); + console.warn( + `Recruiting Teams with PKs ${missingTeamPks.join(", ")} not found in Authentik. Cleaning up.`, + ); + await TeamRecruitingStatus.deleteMany({ + teamPk: { $in: missingTeamPks }, + }).exec(); } return validRecruitingTeams; @@ -524,7 +639,7 @@ export class ATSController extends Controller { /** * Fetches the list of available application stages an applicant can * be in. To access this list, the user must be logged in via OIDC. - * + * * @returns List of Application Stages */ @Get("stages") @@ -535,7 +650,7 @@ export class ATSController extends Controller { // Return the ApplicationStage enum as an array of {id, name} objects const stages = Object.entries(ApplicationStage).map(([key, value]) => ({ id: value, - name: value + name: value, })); return stages; @@ -546,7 +661,7 @@ export class ATSController extends Controller { * the roles they're recruiting for. Ideally, this endpoint is used so that the * applicant can view all available roles, the role specific questions to aid in * selecting and ranking roles. - * + * * @param teamId Team ID for a currently recruiting team. * @returns Team Recuritment Info */ @@ -559,30 +674,38 @@ export class ATSController extends Controller { const teamInfo = await this.authentikClient.getGroupInfo(teamId); // Get team recruiting status - const teamStatus = await TeamRecruitingStatus.findOne({ teamPk: teamId }).exec(); + const teamStatus = await TeamRecruitingStatus.findOne({ + teamPk: teamId, + }).exec(); if (!teamStatus || !teamStatus.isRecruiting) { this.setStatus(403); return { error: "NotRecruiting", - message: "This team is not currently recruiting" + message: "This team is not currently recruiting", }; } // Fetch configs for all recruiting subteams const recruitingSubteams = await Promise.all( teamStatus.recruitingSubteamPks.map(async (subteamPk) => { - const config = await SubteamConfig.findOne({ subteamPk }).exec(); - const subteamInfo = await this.authentikClient.getGroupInfo(subteamPk); + const config = await SubteamConfig.findOne({ + subteamPk, + }).exec(); + const subteamInfo = + await this.authentikClient.getGroupInfo(subteamPk); return { subteamPk: subteamPk, - friendlyName: subteamInfo.attributes.friendlyName || subteamInfo.name, + friendlyName: + subteamInfo.attributes.friendlyName || + subteamInfo.name, description: subteamInfo.attributes.description, roles: config?.roles || [], - roleSpecificQuestions: config?.roleSpecificQuestions || {} + roleSpecificQuestions: + config?.roleSpecificQuestions || {}, }; - }) + }), ); return { @@ -592,33 +715,35 @@ export class ATSController extends Controller { friendlyName: teamInfo.attributes.friendlyName, description: teamInfo.attributes.description, seasonText: `${teamInfo.attributes.seasonType} ${teamInfo.attributes.seasonYear}`, - pk: teamInfo.pk + pk: teamInfo.pk, }, - recruitingSubteams: recruitingSubteams + recruitingSubteams: recruitingSubteams, }; - } catch (err) { - if (err instanceof AuthentikClientError && err.code === AuthentikClientErrorType.GROUP_NOT_FOUND) { + if ( + err instanceof AuthentikClientError && + err.code === AuthentikClientErrorType.GROUP_NOT_FOUND + ) { this.setStatus(404); return { error: "TeamNotFound", - message: "The requested team could not be found." + message: "The requested team could not be found.", }; } this.setStatus(500); return { error: "ServerError", - message: "Failed to fetch team details" + message: "Failed to fetch team details", }; } } /** * Fetches all applications for a team in a lightweight format suitable for - * the Kanban board. To perform this action, the user must either be a Team Owner + * the Kanban board. To perform this action, the user must either be a Team Owner * or hold the `corp:hiringaccess` bindle. - * + * * @param teamId People Portal Team ID * @returns Candidate Applications List */ @@ -626,28 +751,35 @@ export class ATSController extends Controller { @Tags("Recruitment Actions") @Security("bindles", ["corp:hiringaccess"]) @SuccessResponse(200) - async getTeamApplications(@Path() teamId: string): Promise { + async getTeamApplications( + @Path() teamId: string, + ): Promise { try { // Fetch all applications for this team directly - ONE per applicant! // Project only necessary fields for the summary view const applications = await Application.find({ - teamPk: teamId + teamPk: teamId, }) - .select('applicantId stage rolePreferences appliedAt stars') - .populate<{ applicantId: IApplicant }>('applicantId', 'fullName') + .select("applicantId stage rolePreferences appliedAt stars") + .populate<{ applicantId: IApplicant }>( + "applicantId", + "fullName", + ) .lean() .exec(); // Map to Kanban-friendly format - const kanbanData: ATSKanbanApplicationCard[] = applications.map((app) => ({ - id: app._id.toString(), - applicantId: app.applicantId._id.toString(), - name: app.applicantId.fullName || 'Unknown Applicant', - stage: app.stage, - rolePreferences: app.rolePreferences, - appliedAt: app.appliedAt, - stars: app.stars || 0 - })); + const kanbanData: ATSKanbanApplicationCard[] = applications.map( + (app) => ({ + id: app._id.toString(), + applicantId: app.applicantId._id.toString(), + name: app.applicantId.fullName || "Unknown Applicant", + stage: app.stage, + rolePreferences: app.rolePreferences, + appliedAt: app.appliedAt, + stars: app.stars || 0, + }), + ); return kanbanData; } catch (err) { @@ -660,9 +792,9 @@ export class ATSController extends Controller { /** * Fetches all information about an application including, preferred roles, * responses to role specific questions and the application stage history. - * To perform this action, the user must either be a Team Owner or hold + * To perform this action, the user must either be a Team Owner or hold * the `corp:hiringaccess` bindle. - * + * * @param teamId People Portal Team ID * @param applicationId Application ID * @returns Full Application Details @@ -671,10 +803,16 @@ export class ATSController extends Controller { @Tags("Recruitment Actions") @Security("bindles", ["corp:hiringaccess"]) @SuccessResponse(200) - async getApplicationDetails(@Path() teamId: string, @Path() applicationId: string): Promise { + async getApplicationDetails( + @Path() teamId: string, + @Path() applicationId: string, + ): Promise { try { - const app = await Application.findOne({ _id: applicationId, teamPk: teamId }) - .populate<{ applicantId: IApplicant }>('applicantId') + const app = await Application.findOne({ + _id: applicationId, + teamPk: teamId, + }) + .populate<{ applicantId: IApplicant }>("applicantId") .lean() .exec(); @@ -686,8 +824,8 @@ export class ATSController extends Controller { return { id: app._id.toString(), applicantId: app.applicantId._id.toString(), - name: app.applicantId.fullName || 'Unknown Applicant', - email: app.applicantId.email || '', + name: app.applicantId.fullName || "Unknown Applicant", + email: app.applicantId.email || "", stage: app.stage, rolePreferences: app.rolePreferences, appliedAt: app.appliedAt, @@ -699,21 +837,25 @@ export class ATSController extends Controller { stageHistory: app.stageHistory, ...(app.notes ? { notes: app.notes } : {}), ...(app.hiredRole ? { hiredRole: app.hiredRole } : {}), - ...(app.appDevInternalPk ? { appDevInternalPk: app.appDevInternalPk } : {}) + ...(app.appDevInternalPk + ? { appDevInternalPk: app.appDevInternalPk } + : {}), }; - } catch (err) { console.error("Error fetching application details:", err); this.setStatus(500); - return { error: "ServerError", message: "Failed to fetch application details" }; + return { + error: "ServerError", + message: "Failed to fetch application details", + }; } } /** * Updates a candidate's star rating and any notes that are added for - * feedback. To perform this action, the user must either be a Team Owner + * feedback. To perform this action, the user must either be a Team Owner * or hold the `corp:hiringaccess` bindle. - * + * * @param teamId People Portal Team ID * @param applicationId Application ID * @param stars New star rating (1-5) @@ -728,23 +870,35 @@ export class ATSController extends Controller { @Request() req: express.Request, @Path() teamId: string, @Path() applicationId: string, - @Body() body: { stars?: number, notes?: string } + @Body() body: { stars?: number; notes?: string }, ) { try { const { stars, notes } = body; if (stars === undefined && !notes) { this.setStatus(400); - return { error: "BadRequest", message: "Stars or notes must be provided" }; + return { + error: "BadRequest", + message: "Stars or notes must be provided", + }; } - if (stars !== undefined && (typeof stars !== 'number' || stars < 1 || stars > 5)) { + if ( + stars !== undefined && + (typeof stars !== "number" || stars < 1 || stars > 5) + ) { this.setStatus(400); - return { error: "BadRequest", message: "Stars must be a number between 1 and 5" }; + return { + error: "BadRequest", + message: "Stars must be a number between 1 and 5", + }; } // 1. Fetch Existing Application - const app = await Application.findOne({ _id: applicationId, teamPk: teamId }); + const app = await Application.findOne({ + _id: applicationId, + teamPk: teamId, + }); if (!app) { this.setStatus(404); return { error: "NotFound", message: "Application not found" }; @@ -760,7 +914,10 @@ export class ATSController extends Controller { // Validate Markdown (Empty Check) if (!notes.trim()) { this.setStatus(400); - return { error: "BadRequest", message: "Notes cannot be empty" }; + return { + error: "BadRequest", + message: "Notes cannot be empty", + }; } // Validate Markdown Structure (Unclosed Fences) @@ -769,28 +926,34 @@ export class ATSController extends Controller { const tokens = md.parse(notes + "\n\n" + sentinel, {}); // If sentinel is trapped inside a code/fence block, the previous block was unclosed - const isTrapped = tokens.some(token => - (token.type === 'fence' || token.type === 'code_block') && - token.content.includes(sentinel) + const isTrapped = tokens.some( + (token) => + (token.type === "fence" || + token.type === "code_block") && + token.content.includes(sentinel), ); if (isTrapped) { this.setStatus(400); - return { error: "BadRequest", message: "Invalid Markdown Structure" }; + return { + error: "BadRequest", + message: "Invalid Markdown Structure", + }; } - const authorizedUser: AuthorizedUser = req.session.authorizedUser!; + const authorizedUser: AuthorizedUser = + req.session.authorizedUser!; const authorName = authorizedUser.name; // Format Date: "Jan 22, 2026 at 3:45pm" const now = new Date(); - const formattedDate = new Intl.DateTimeFormat('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: 'numeric', - hour12: true + const formattedDate = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "numeric", + hour12: true, }).format(now); const noteHeader = `###### ${authorName} \n*${formattedDate}*`; @@ -811,12 +974,12 @@ export class ATSController extends Controller { /** * Provides a signed AWS S3 URL to access the applicant's resume * obtained from their profile. To obtain the resume, the user - * must either be a team owner or hold the `corp:hiringaccess` + * must either be a team owner or hold the `corp:hiringaccess` * bindle. - * + * * Information about the applicant will only be available if they applied * to the team provided in the request. - * + * * @param teamId People Portal Team ID * @param applicantId Applicant's ID * @returns Signed S3 Download URL @@ -825,9 +988,15 @@ export class ATSController extends Controller { @Tags("Recruitment Actions") @SuccessResponse(200) @Security("bindles", ["corp:hiringaccess"]) - async getApplicantUrls(@Path() teamId: string, @Path() applicantId: string) { + async getApplicantUrls( + @Path() teamId: string, + @Path() applicantId: string, + ) { try { - const teamApplication = await Application.exists({ applicantId, teamPk: teamId }); + const teamApplication = await Application.exists({ + applicantId, + teamPk: teamId, + }); if (!teamApplication) { this.setStatus(404); return { error: "NotFound", message: "Applicant not found" }; @@ -842,18 +1011,24 @@ export class ATSController extends Controller { const profile = applicant.profile || new Map(); let resumeDownloadUrl: string | undefined; - const resumeUrl = profile.get('resumeUrl'); + const resumeUrl = profile.get("resumeUrl"); if (resumeUrl) { try { const expectedKey = `resumes/${applicant._id}.pdf`; - if (path.posix.normalize(resumeUrl) === expectedKey && !resumeUrl.includes("..")) { + if ( + path.posix.normalize(resumeUrl) === expectedKey && + !resumeUrl.includes("..") + ) { const command = new GetObjectCommand({ Bucket: BUCKET_NAME, Key: expectedKey, }); - resumeDownloadUrl = await getSignedUrl(s3Client, command, { expiresIn: 900 }); // 15 mins + resumeDownloadUrl = await getSignedUrl( + s3Client, + command, + { expiresIn: 900 }, + ); // 15 mins } - } catch (s3Error) { console.error("Error signing S3 URL:", s3Error); // Fallback or just ignore, maybe client can deal with the raw key or empty string? @@ -864,11 +1039,13 @@ export class ATSController extends Controller { return { resumeUrl: resumeDownloadUrl, }; - } catch (err) { console.error("Error fetching applicant URLs:", err); this.setStatus(500); - return { error: "ServerError", message: "Failed to fetch applicant URLs" }; + return { + error: "ServerError", + message: "Failed to fetch applicant URLs", + }; } } @@ -876,30 +1053,30 @@ export class ATSController extends Controller { * Updates the stage for an Application to a team. Stages are determined * by the ApplicationStage enum. Additionally, stage state changes are * permitted based on the following ruleset: - * + * * - Applications in the **Applied** stage can only be moved to **Interview** or **Rejected** * - Applications in the **Interview** stage can only be moved to **Potential Hire**, **Hired**, or **Rejected** * - Applications in the **Potential Hire** stage can only be moved to **Hired** or **Rejected** * - Applications in the **Hired** stage are not movable * - Applications in the **Rejected** stage are not movable - * + * * On stage changes, the applicants are notified immediately through emails and therefore, stage * changes require additional request information based on the following: - * + * * - Transitions to **Interview** need Interview Guidelines and Link for Scheduling * - Transitions to **Rejected** directly send an email without additional information * - Transitions to **Potential Hire** directly sends an email by auto populating contact info * - Transtions to **Hired** happen based on a ruleset: * - If the user is an existing App Dev member, they are automatically added to the team * and an email is sent notifying them about the change. - * + * * - Otherwise, we generate a unique invite link, valid for 48 hours, and send them an email * with onboarding instructions. - * + * * To perform this action, the user must either be a team owner or hold the `corp:hiringaccess` * bindle. Additionally, requests will only be allowed if the Applicant has applied to the * team provided in the request. - * + * * @param req Express Request Object * @param teamId People Portal Team ID * @param applicationId Candidate Application ID @@ -914,27 +1091,45 @@ export class ATSController extends Controller { @Request() req: express.Request, @Path() teamId: string, @Path() applicationId: string, - @Body() body: ATSUpdateApplicationStageRequest + @Body() body: ATSUpdateApplicationStageRequest, ) { try { - const { stage, interviewLink, interviewGuidelines, hiredRole, hiredSubteamPk } = body; + const { + stage, + interviewLink, + interviewGuidelines, + hiredRole, + hiredSubteamPk, + } = body; if (!req.session?.authorizedUser) { this.setStatus(401); - return { error: "Unauthorized", message: "Authenticated user not found" }; + return { + error: "Unauthorized", + message: "Authenticated user not found", + }; } /* Always scope the lookup to the team in the route. */ - const application = await Application.findOne({ _id: applicationId, teamPk: teamId }); + const application = await Application.findOne({ + _id: applicationId, + teamPk: teamId, + }); if (!application) { this.setStatus(404); return { error: "NotFound", message: "Application not found" }; } - const teamInfo = await this.getAuthorizedHiringTeam(req.session.authorizedUser, application.teamPk); + const teamInfo = await this.getAuthorizedHiringTeam( + req.session.authorizedUser, + application.teamPk, + ); if (!teamInfo) { this.setStatus(403); - return { error: "Forbidden", message: "You do not have hiring access to this team" }; + return { + error: "Forbidden", + message: "You do not have hiring access to this team", + }; } const applicant = await Applicant.findById(application.applicantId); @@ -947,46 +1142,91 @@ export class ATSController extends Controller { // Idempotency check if (currentStage === stage) { - return { message: "Stage already set", application: { id: application._id, stage: application.stage } }; + return { + message: "Stage already set", + application: { + id: application._id, + stage: application.stage, + }, + }; } // Transition Validation const VALID_TRANSITIONS: Record = { - [ApplicationStage.APPLIED]: [ApplicationStage.INTERVIEW, ApplicationStage.REJECTED], - [ApplicationStage.INTERVIEW]: [ApplicationStage.POTENTIAL_HIRE, ApplicationStage.HIRED, ApplicationStage.REJECTED], - [ApplicationStage.POTENTIAL_HIRE]: [ApplicationStage.HIRED, ApplicationStage.REJECTED], + [ApplicationStage.APPLIED]: [ + ApplicationStage.INTERVIEW, + ApplicationStage.REJECTED, + ], + [ApplicationStage.INTERVIEW]: [ + ApplicationStage.POTENTIAL_HIRE, + ApplicationStage.HIRED, + ApplicationStage.REJECTED, + ], + [ApplicationStage.POTENTIAL_HIRE]: [ + ApplicationStage.HIRED, + ApplicationStage.REJECTED, + ], [ApplicationStage.HIRED]: [], - [ApplicationStage.REJECTED]: [] + [ApplicationStage.REJECTED]: [], }; const allowed = VALID_TRANSITIONS[currentStage] || []; if (!allowed.includes(stage)) { this.setStatus(400); - return { error: "BadRequest", message: `Invalid transition from ${currentStage} to ${stage}` }; + return { + error: "BadRequest", + message: `Invalid transition from ${currentStage} to ${stage}`, + }; } // Mandatory Field Checks if (stage === ApplicationStage.INTERVIEW) { if (!interviewLink) { this.setStatus(400); - return { error: "BadRequest", message: "Interview Link is required for Interview stage" }; + return { + error: "BadRequest", + message: + "Interview Link is required for Interview stage", + }; } - if (!interviewGuidelines || interviewGuidelines.length < 50 || interviewGuidelines.length > 500) { + if ( + !interviewGuidelines || + interviewGuidelines.length < 50 || + interviewGuidelines.length > 500 + ) { this.setStatus(400); - return { error: "BadRequest", message: "Interview Guidelines are required (50-500 characters)" }; + return { + error: "BadRequest", + message: + "Interview Guidelines are required (50-500 characters)", + }; } } if (stage === ApplicationStage.HIRED) { if (!hiredRole || !hiredSubteamPk) { this.setStatus(400); - return { error: "BadRequest", message: "Hired Role and Subteam are required for Hired stage" }; + return { + error: "BadRequest", + message: + "Hired Role and Subteam are required for Hired stage", + }; } - if (!(await this.isValidHireDestination(teamInfo, hiredSubteamPk, hiredRole))) { + if ( + !(await this.isValidHireDestination( + teamInfo, + hiredSubteamPk, + hiredRole, + )) + ) { this.setStatus(400); - return { error: "BadRequest", message: "Hired role and subteam must belong to an active team destination" }; + return { + error: "BadRequest", + message: + "Hired role and subteam must belong to an active team destination", + }; } /* Add Hired Role and Hired Subteam PK to the Application */ @@ -999,7 +1239,7 @@ export class ATSController extends Controller { application.stageHistory.push({ stage: stage, changedAt: new Date(), - changedBy: `${req.session.authorizedUser?.name} (${req.session.authorizedUser?.username})` + changedBy: `${req.session.authorizedUser?.name} (${req.session.authorizedUser?.username})`, }); /* Save the Application and Send Emails */ @@ -1018,9 +1258,9 @@ export class ATSController extends Controller { interviewerName: req.session.authorizedUser!.name, teamName: teamInfo.attributes.friendlyName, interviewLink: interviewLink, - interviewGuidelines: interviewGuidelines - } - }) + interviewGuidelines: interviewGuidelines, + }, + }); /* Potential Hire deliberately notifies nobody. It is an internal grouping the recruiting team uses while deciding, and the old "Waitlisted for ..." mail told applicants they had been passed @@ -1029,8 +1269,10 @@ export class ATSController extends Controller { a future opt-in send. */ } else if (stage === ApplicationStage.HIRED) { await this.processHiredStageTransition( - req.session.authorizedUser!, teamInfo, - application, applicant + req.session.authorizedUser!, + teamInfo, + application, + applicant, ); } else if (stage === ApplicationStage.REJECTED) { await this.emailClient.send({ @@ -1042,8 +1284,8 @@ export class ATSController extends Controller { templateVars: { applicantName: applicant.fullName, teamName: teamInfo.attributes.friendlyName, - } - }) + }, + }); } return { @@ -1051,24 +1293,27 @@ export class ATSController extends Controller { application: { id: application._id, stage: application.stage, - hiredRole: application.hiredRole - } + hiredRole: application.hiredRole, + }, }; } catch (err) { console.error("Error updating application stage:", err); this.setStatus(500); - return { error: "ServerError", message: "Failed to update application stage" }; + return { + error: "ServerError", + message: "Failed to update application stage", + }; } } /** * Fetches all the other teams the applicant has applied to and the - * stage they're currently in for those teams. To access this, the + * stage they're currently in for those teams. To access this, the * user needs to be a Team Owner or have the `corp:hiringaccess` bindle. - * + * * Information about the applicant will only be available if they applied * to the team provided in the request. - * + * * @param teamId People Portal Team ID * @param applicantId Candidate's Applicant ID * @returns List of Other Applications @@ -1077,76 +1322,106 @@ export class ATSController extends Controller { @Tags("Recruitment Actions") @SuccessResponse(200) @Security("bindles", ["corp:hiringaccess"]) - async getApplicantApplications(@Path() teamId: string, @Path() applicantId: string) { + async getApplicantApplications( + @Path() teamId: string, + @Path() applicantId: string, + ) { try { - const teamApplication = await Application.exists({ applicantId, teamPk: teamId }); + const teamApplication = await Application.exists({ + applicantId, + teamPk: teamId, + }); if (!teamApplication) { this.setStatus(404); return { error: "NotFound", message: "Applicant not found" }; } - const applications = await Application.find({ applicantId: applicantId }) + const applications = await Application.find({ + applicantId: applicantId, + }) .lean() .sort({ appliedAt: -1 }) .exec(); - const results = await Promise.all(applications.map(async (app: any) => { - let teamName = app.teamPk; + const results = await Promise.all( + applications.map(async (app: any) => { + let teamName = app.teamPk; - try { - const teamInfo = await this.authentikClient.getGroupInfo(app.teamPk); - if (teamInfo && teamInfo.attributes && teamInfo.attributes.friendlyName) { - teamName = teamInfo.attributes.friendlyName; - } else if (teamInfo && teamInfo.name) { - teamName = teamInfo.name; - } - } catch (e) { - console.error(`Failed to fetch team info for ${app.teamPk}`, e); - } - - // Enrich role preferences with subteam names - const rolePreferencesWithNames = await Promise.all((app.rolePreferences || []).map(async (pref: any) => { - let subteamName = pref.subteamPk; try { - const subteamInfo = await this.authentikClient.getGroupInfo(pref.subteamPk); - subteamName = subteamInfo.attributes?.friendlyName || subteamInfo.name; + const teamInfo = + await this.authentikClient.getGroupInfo(app.teamPk); + if ( + teamInfo && + teamInfo.attributes && + teamInfo.attributes.friendlyName + ) { + teamName = teamInfo.attributes.friendlyName; + } else if (teamInfo && teamInfo.name) { + teamName = teamInfo.name; + } } catch (e) { - console.error(`Failed to fetch subteam info for ${pref.subteamPk}`, e); + console.error( + `Failed to fetch team info for ${app.teamPk}`, + e, + ); } - return { ...pref, subteamName }; - })); - return { - id: app._id, - teamPk: app.teamPk, - teamName: teamName, - rolePreferences: rolePreferencesWithNames, - stage: app.stage, - appliedAt: app.appliedAt, - hiredRole: app.hiredRole - }; - })); + // Enrich role preferences with subteam names + const rolePreferencesWithNames = await Promise.all( + (app.rolePreferences || []).map(async (pref: any) => { + let subteamName = pref.subteamPk; + try { + const subteamInfo = + await this.authentikClient.getGroupInfo( + pref.subteamPk, + ); + subteamName = + subteamInfo.attributes?.friendlyName || + subteamInfo.name; + } catch (e) { + console.error( + `Failed to fetch subteam info for ${pref.subteamPk}`, + e, + ); + } + return { ...pref, subteamName }; + }), + ); - return results; + return { + id: app._id, + teamPk: app.teamPk, + teamName: teamName, + rolePreferences: rolePreferencesWithNames, + stage: app.stage, + appliedAt: app.appliedAt, + hiredRole: app.hiredRole, + }; + }), + ); + return results; } catch (err) { console.error("Error fetching applicant applications:", err); this.setStatus(500); - return { error: "ServerError", message: "Failed to fetch applicant history" }; + return { + error: "ServerError", + message: "Failed to fetch applicant history", + }; } } /** * Primary Endpoint for either an internal or external user to submit their application - * to a team that's currently recruiting. For access to this API, the user must be + * to a team that's currently recruiting. For access to this API, the user must be * authenticated via the Guest Authorization Layer. - * - * This handler performs basic request and session validation, verifies that the team exists - * and is actively recruiting, validates the user’s selected roles and profile information, - * checks required application questions and responses, prevents duplicate applications, - * updates or creates the applicant record, sets attributes for existing internal members, + * + * This handler performs basic request and session validation, verifies that the team exists + * and is actively recruiting, validates the user’s selected roles and profile information, + * checks required application questions and responses, prevents duplicate applications, + * updates or creates the applicant record, sets attributes for existing internal members, * and finally creates a new application. - * + * * @param body Team Application Request * @param request Express Request Object * @returns Application Submission Status @@ -1157,35 +1432,49 @@ export class ATSController extends Controller { @Security("ats_otp") async applyToTeam( @Body() body: ATSTeamApplicationRequest, - @Request() request: express.Request + @Request() request: express.Request, ) { try { const { teamPk, rolePreferences, profile, responses } = body; // 1. Basic Validation - if (!teamPk || !rolePreferences || rolePreferences.length === 0 || !profile) { + if ( + !teamPk || + !rolePreferences || + rolePreferences.length === 0 || + !profile + ) { this.setStatus(400); return { error: "InvalidRequest", - message: "TeamPk, role preferences, and profile are required." + message: + "TeamPk, role preferences, and profile are required.", }; } const userEmail = request.session.tempsession?.user?.email; if (!userEmail) { this.setStatus(401); - return { error: "Unauthorized", message: "User session not found. Please verify your email." }; + return { + error: "Unauthorized", + message: + "User session not found. Please verify your email.", + }; } // 2. Verify team exists and is recruiting - const teamInfo: GetGroupInfoResponse = await this.authentikClient.getGroupInfo(teamPk); - const teamStatus = await TeamRecruitingStatus.findOne({ teamPk }).exec(); + const teamInfo: GetGroupInfoResponse = + await this.authentikClient.getGroupInfo(teamPk); + const teamStatus = await TeamRecruitingStatus.findOne({ + teamPk, + }).exec(); if (!teamStatus || !teamStatus.isRecruiting) { this.setStatus(403); return { error: "RecruitmentClosed", - message: "This team is not currently accepting applications." + message: + "This team is not currently accepting applications.", }; } @@ -1194,35 +1483,51 @@ export class ATSController extends Controller { const allQuestions = new Set(); for (const subteamPk of teamStatus.recruitingSubteamPks) { - const config = await SubteamConfig.findOne({ subteamPk }).exec(); + const config = await SubteamConfig.findOne({ + subteamPk, + }).exec(); if (config && config.isRecruiting) { - config.roles.forEach(role => allAvailableRoles.add(role)); + config.roles.forEach((role) => allAvailableRoles.add(role)); } } // 4. Validate all selected roles are available for (const pref of rolePreferences) { // Check if the subteam actually offers this role - const subteamConfig = await SubteamConfig.findOne({ subteamPk: pref.subteamPk }).exec(); - if (!subteamConfig || !subteamConfig.isRecruiting || !subteamConfig.roles.includes(pref.role)) { + const subteamConfig = await SubteamConfig.findOne({ + subteamPk: pref.subteamPk, + }).exec(); + if ( + !subteamConfig || + !subteamConfig.isRecruiting || + !subteamConfig.roles.includes(pref.role) + ) { this.setStatus(400); return { error: "InvalidRole", - message: `Role '${pref.role}' is not available in the specified subteam` + message: `Role '${pref.role}' is not available in the specified subteam`, }; } } // 5. Collect role-specific questions for selected roles for (const subteamPk of teamStatus.recruitingSubteamPks) { - const config = await SubteamConfig.findOne({ subteamPk }).exec(); + const config = await SubteamConfig.findOne({ + subteamPk, + }).exec(); if (config && config.isRecruiting) { if (config && config.isRecruiting) { for (const pref of rolePreferences) { - if (pref.subteamPk === subteamPk) { // Only check if this preference belongs to this subteam + if (pref.subteamPk === subteamPk) { + // Only check if this preference belongs to this subteam if (config.roles.includes(pref.role)) { - const roleQuestions = config.roleSpecificQuestions[pref.role] || []; - roleQuestions.forEach((q: string) => allQuestions.add(q)); + const roleQuestions = + config.roleSpecificQuestions[ + pref.role + ] || []; + roleQuestions.forEach((q: string) => + allQuestions.add(q), + ); } } } @@ -1231,46 +1536,74 @@ export class ATSController extends Controller { } // 6. Validate personal profile - if (!profile.resumeUrl || profile.resumeUrl.trim() === '') { + if (!profile.resumeUrl || profile.resumeUrl.trim() === "") { this.setStatus(400); - return { error: "MissingRequiredField", message: "Resume URL is required" }; + return { + error: "MissingRequiredField", + message: "Resume URL is required", + }; } - if (!profile.resumeUrl.toLowerCase().endsWith('.pdf')) { + if (!profile.resumeUrl.toLowerCase().endsWith(".pdf")) { this.setStatus(400); - return { error: "InvalidFileType", message: "Only PDF resumes are accepted" }; + return { + error: "InvalidFileType", + message: "Only PDF resumes are accepted", + }; } - if (!profile.whyAppDev || profile.whyAppDev.trim() === '') { + if (!profile.whyAppDev || profile.whyAppDev.trim() === "") { this.setStatus(400); - return { error: "MissingRequiredField", message: "Please provide 'Why App Dev'" }; + return { + error: "MissingRequiredField", + message: "Please provide 'Why App Dev'", + }; } - if (!profile.instagramFollow || profile.instagramFollow.trim() === '') { + if ( + !profile.instagramFollow || + profile.instagramFollow.trim() === "" + ) { this.setStatus(400); - return { error: "MissingRequiredField", message: "Please confirm Instagram follow" }; + return { + error: "MissingRequiredField", + message: "Please confirm Instagram follow", + }; } if (profile.linkedinUrl && profile.linkedinUrl.trim() !== "") { - try { new URL(profile.linkedinUrl) } catch { + try { + new URL(profile.linkedinUrl); + } catch { this.setStatus(400); - return { error: "InvalidURL", message: "Please provide a valid LinkedIn URL" }; + return { + error: "InvalidURL", + message: "Please provide a valid LinkedIn URL", + }; } } if (profile.githubUrl && profile.githubUrl.trim() !== "") { - try { new URL(profile.githubUrl) } catch { + try { + new URL(profile.githubUrl); + } catch { this.setStatus(400); - return { error: "InvalidURL", message: "Please provide a valid GitHub URL" }; + return { + error: "InvalidURL", + message: "Please provide a valid GitHub URL", + }; } } - const whyAppDevWords = profile.whyAppDev.trim().split(/\s+/).filter(Boolean).length; + const whyAppDevWords = profile.whyAppDev + .trim() + .split(/\s+/) + .filter(Boolean).length; if (whyAppDevWords > MAX_WHY_APPDEV_WORDS) { this.setStatus(400); return { error: "WordCountExceeded", - message: `'Why App Dev' must be ${MAX_WHY_APPDEV_WORDS} words or less` + message: `'Why App Dev' must be ${MAX_WHY_APPDEV_WORDS} words or less`, }; } @@ -1280,11 +1613,11 @@ export class ATSController extends Controller { allQuestions.add(teamQuestionKey); for (const question of Array.from(allQuestions)) { - if (!responses[question] || responses[question].trim() === '') { + if (!responses[question] || responses[question].trim() === "") { this.setStatus(400); return { error: "MissingResponse", - message: `Please answer: ${question}` + message: `Please answer: ${question}`, }; } @@ -1292,58 +1625,82 @@ export class ATSController extends Controller { this.setStatus(400); return { error: "ResponseTooLong", - message: `Response too long for: ${question}. Maximum ${MAX_RESPONSE_LENGTH} characters.` + message: `Response too long for: ${question}. Maximum ${MAX_RESPONSE_LENGTH} characters.`, }; } - const wordCount = responses[question].trim().split(/\s+/).filter(Boolean).length; + const wordCount = responses[question] + .trim() + .split(/\s+/) + .filter(Boolean).length; if (wordCount < MIN_RESPONSE_WORDS) { this.setStatus(400); return { error: "ResponseTooShort", - message: `Response too short for: ${question}. Please provide at least ${MIN_RESPONSE_WORDS} words.` + message: `Response too short for: ${question}. Please provide at least ${MIN_RESPONSE_WORDS} words.`, }; } } // 8. Check for duplicate application - const applicant = await Applicant.findOne({ email: userEmail }).exec(); + const applicant = await Applicant.findOne({ + email: userEmail, + }).exec(); if (!applicant) { this.setStatus(404); - return { error: "ApplicantNotFound", message: "Applicant profile not found." }; + return { + error: "ApplicantNotFound", + message: "Applicant profile not found.", + }; } const existingApp = await Application.findOne({ applicantId: applicant._id, - teamPk: teamPk + teamPk: teamPk, }).exec(); if (existingApp) { this.setStatus(409); return { error: "DuplicateApplication", - message: "You have already applied to this team." + message: "You have already applied to this team.", }; } // 9. Validate the server-issued resume key before persisting profile changes. try { const expectedResumeKey = `resumes/${applicant._id}.pdf`; - if (path.posix.normalize(profile.resumeUrl) !== expectedResumeKey || profile.resumeUrl.includes("..")) { + if ( + path.posix.normalize(profile.resumeUrl) !== + expectedResumeKey || + profile.resumeUrl.includes("..") + ) { this.setStatus(400); - return { error: "InvalidResumeKey", message: "Invalid resume upload." }; + return { + error: "InvalidResumeKey", + message: "Invalid resume upload.", + }; } - const isValidSignature = await validateS3FileSignature(profile.resumeUrl, [FILE_SIGNATURES.PDF]); + const isValidSignature = await validateS3FileSignature( + profile.resumeUrl, + [FILE_SIGNATURES.PDF], + ); if (!isValidSignature) { this.setStatus(400); - return { error: "InvalidFileSignature", message: "The uploaded file is not a valid PDF." }; + return { + error: "InvalidFileSignature", + message: "The uploaded file is not a valid PDF.", + }; } } catch (e) { console.error("Failed to validate resume", e); this.setStatus(500); - return { error: "FileProcessingError", message: "Failed to process resume file." }; + return { + error: "FileProcessingError", + message: "Failed to process resume file.", + }; } // 9.1 Update the already authenticated applicant. @@ -1351,23 +1708,29 @@ export class ATSController extends Controller { applicant._id, { $set: { - fullName: request.session.tempsession?.user?.name || userEmail.split('@')[0], + fullName: + request.session.tempsession?.user?.name || + userEmail.split("@")[0], profile: new Map(Object.entries(profile)), - updatedAt: new Date() - } + updatedAt: new Date(), + }, }, - { new: true } + { new: true }, ).exec(); if (!updatedApplicant) { this.setStatus(404); - return { error: "ApplicantNotFound", message: "Applicant profile not found." }; + return { + error: "ApplicantNotFound", + message: "Applicant profile not found.", + }; } // 9.5 Check Previous App Dev History, if exists! let appDevInternalPk = null; try { - const internalUser = await this.authentikClient.getUserInfoFromEmail(userEmail) - appDevInternalPk = internalUser.pk + const internalUser = + await this.authentikClient.getUserInfoFromEmail(userEmail); + appDevInternalPk = internalUser.pk; } catch (_e) { /* Not previously in App Dev */ } @@ -1376,33 +1739,33 @@ export class ATSController extends Controller { const application = await Application.create({ applicantId: updatedApplicant._id, teamPk: teamPk, - rolePreferences: rolePreferences, // Ordered array of roles + rolePreferences: rolePreferences, // Ordered array of roles stage: ApplicationStage.APPLIED, responses: new Map(Object.entries(responses)), appliedAt: new Date(), appDevInternalPk, - stageHistory: [{ - stage: ApplicationStage.APPLIED, - changedAt: new Date() - }] + stageHistory: [ + { + stage: ApplicationStage.APPLIED, + changedAt: new Date(), + }, + ], }); - await Applicant.findByIdAndUpdate( - updatedApplicant._id, - { $push: { applicationIds: application._id } } - ).exec(); + await Applicant.findByIdAndUpdate(updatedApplicant._id, { + $push: { applicationIds: application._id }, + }).exec(); return { message: "Application submitted successfully", - id: application._id + id: application._id, }; - } catch (err) { console.error("Application submission error:", err); this.setStatus(500); return { error: "ServerError", - message: "Failed to submit application" + message: "Failed to submit application", }; } } @@ -1411,7 +1774,7 @@ export class ATSController extends Controller { * Updates the applicant's recruitment profile with common questions and * other updates like their Resume. To access this API, a token from the * Guest Authentication Layer is needed. - * + * * @param body Applicant Profile Payload * @param request Express Request Object * @returns Profile Update Status @@ -1420,12 +1783,18 @@ export class ATSController extends Controller { @Tags("Applicant Portal") @SuccessResponse(200, "Profile Updated") @Security("ats_otp") - async updateProfile(@Body() body: ApplicantProfile, @Request() request: express.Request) { + async updateProfile( + @Body() body: ApplicantProfile, + @Request() request: express.Request, + ) { try { - const userEmail = request.session?.tempsession?.user?.email + const userEmail = request.session?.tempsession?.user?.email; if (!userEmail) { this.setStatus(401); - return { error: "Unauthorized", message: "User session not found." }; + return { + error: "Unauthorized", + message: "User session not found.", + }; } const applicant = await Applicant.findOne({ email: userEmail }); @@ -1435,20 +1804,33 @@ export class ATSController extends Controller { } // Merge existing profile with new updates - const currentProfile = applicant.profile ? Object.fromEntries(applicant.profile) : {}; + const currentProfile = applicant.profile + ? Object.fromEntries(applicant.profile) + : {}; // Immediate Signature Validation for new Resume Uploads if (body.resumeUrl && body.resumeUrl !== currentProfile.resumeUrl) { const expectedKey = `resumes/${applicant._id}.pdf`; - if (path.posix.normalize(body.resumeUrl) !== expectedKey || body.resumeUrl.includes("..")) { + if ( + path.posix.normalize(body.resumeUrl) !== expectedKey || + body.resumeUrl.includes("..") + ) { this.setStatus(400); - return { error: "InvalidResumeKey", message: "Invalid resume upload." }; + return { + error: "InvalidResumeKey", + message: "Invalid resume upload.", + }; } - const isValid = await validateS3FileSignature(body.resumeUrl, [FILE_SIGNATURES.PDF]); + const isValid = await validateS3FileSignature(body.resumeUrl, [ + FILE_SIGNATURES.PDF, + ]); if (!isValid) { this.setStatus(400); - return { error: "InvalidFileSignature", message: "The uploaded file is not a valid PDF." }; + return { + error: "InvalidFileSignature", + message: "The uploaded file is not a valid PDF.", + }; } } @@ -1459,28 +1841,46 @@ export class ATSController extends Controller { { $set: { profile: updatedProfile, - updatedAt: new Date() - } - } + updatedAt: new Date(), + }, + }, ); return { message: "Profile updated successfully" }; - } catch (err) { console.error("Error updating profile:", err); this.setStatus(500); - return { error: "ServerError", message: "Failed to update profile" }; + return { + error: "ServerError", + message: "Failed to update profile", + }; } } /* ==== APPLICATION STAGE CHANGE EMAIL HELPERS ==== */ - async processHiredStageTransition(authorizedUser: AuthorizedUser, teamInfo: GetGroupInfoResponse, application: IApplication, applicant: IApplicant): Promise { + async processHiredStageTransition( + authorizedUser: AuthorizedUser, + teamInfo: GetGroupInfoResponse, + application: IApplication, + applicant: IApplicant, + ): Promise { /* This helper is callable independently of tsoa middleware. Re-check the * authorization and destination immediately before changing membership or * creating an invite so direct controller calls cannot bypass ATS policy. */ - const authorizedTeam = await this.getAuthorizedHiringTeam(authorizedUser, application.teamPk); - if (!authorizedTeam || !application.hiredRole || !application.hiredSubteamPk || - !(await this.isValidHireDestination(authorizedTeam, application.hiredSubteamPk, application.hiredRole))) { + const authorizedTeam = await this.getAuthorizedHiringTeam( + authorizedUser, + application.teamPk, + ); + if ( + !authorizedTeam || + !application.hiredRole || + !application.hiredSubteamPk || + !(await this.isValidHireDestination( + authorizedTeam, + application.hiredSubteamPk, + application.hiredRole, + )) + ) { throw new Error("Unauthorized hired-stage transition"); } @@ -1489,7 +1889,8 @@ export class ATSController extends Controller { const applicantEmail = applicant.email; let user: UserInformationBrief; try { - user = await this.authentikClient.getUserInfoFromEmail(applicantEmail); + user = + await this.authentikClient.getUserInfoFromEmail(applicantEmail); } catch (_) { /* Onboard the User */ await this.orgController.createInvite( @@ -1497,21 +1898,24 @@ export class ATSController extends Controller { session: { authorizedUser }, bindle: { teamInfo, - requestedPermissions: [] - } + requestedPermissions: [], + }, }, { inviteeName: applicant.fullName, inviteeEmail: applicantEmail, roleTitle: application.hiredRole!, - subteamPk: application.hiredSubteamPk! - } + subteamPk: application.hiredSubteamPk!, + }, ); return; } /* Do not convert downstream membership/email failures into external invites. */ - await this.orgController.addTeamMember(application.hiredSubteamPk!, { userPk: +user.pk, roleTitle: application.hiredRole! }); + await this.orgController.addTeamMember(application.hiredSubteamPk!, { + userPk: +user.pk, + roleTitle: application.hiredRole!, + }); await this.emailClient.send({ to: applicantEmail, cc: [authorizedUser.email], @@ -1523,51 +1927,84 @@ export class ATSController extends Controller { invitorName: authorizedUser.name, teamName: teamInfo.attributes.friendlyName, roleTitle: application.hiredRole!, - } + }, }); } /** Team-scoped ATS authorization: root owner or effective hiring bindle. */ - private async getAuthorizedHiringTeam(authorizedUser: AuthorizedUser, teamPk: string): Promise { + private async getAuthorizedHiringTeam( + authorizedUser: AuthorizedUser, + teamPk: string, + ): Promise { const teamInfo = await this.authentikClient.getGroupInfo(teamPk); - if (!teamInfo.attributes.peoplePortalCreation || teamInfo.attributes.flaggedForDeletion) return null; + if ( + !teamInfo.attributes.peoplePortalCreation || + teamInfo.attributes.flaggedForDeletion + ) + return null; if (authorizedUser.is_superuser) return teamInfo; const isOwner = authorizedUser.groups.includes(teamInfo.name); - const permissions = BindleController.getEffectivePermissionSet(teamInfo, authorizedUser.groups); - return isOwner || permissions.has("corp:hiringaccess") ? teamInfo : null; + const permissions = BindleController.getEffectivePermissionSet( + teamInfo, + authorizedUser.groups, + ); + return isOwner || permissions.has("corp:hiringaccess") + ? teamInfo + : null; } /** A hire may only target a live subteam under the application's root team, * and the role must be configured for that exact destination. */ - private async isValidHireDestination(teamInfo: GetGroupInfoResponse, subteamPk: string, role: string): Promise { - if (teamInfo.attributes.flaggedForDeletion || !teamInfo.subteamPkList.includes(subteamPk)) return false; - - const destination = await this.authentikClient.getGroupInfo(subteamPk, { includeParentInfo: true }); - if (!destination.attributes.peoplePortalCreation || + private async isValidHireDestination( + teamInfo: GetGroupInfoResponse, + subteamPk: string, + role: string, + ): Promise { + if ( + teamInfo.attributes.flaggedForDeletion || + !teamInfo.subteamPkList.includes(subteamPk) + ) + return false; + + const destination = await this.authentikClient.getGroupInfo(subteamPk, { + includeParentInfo: true, + }); + if ( + !destination.attributes.peoplePortalCreation || destination.parentPk !== teamInfo.pk || - destination.attributes.flaggedForDeletion) return false; + destination.attributes.flaggedForDeletion + ) + return false; const config = await SubteamConfig.findOne({ subteamPk }).lean().exec(); return !!config && config.roles.includes(role); } /* === HELPER METHODS === */ - async addSubteamToRecruiting(@Path() teamId: string, @Path() subteamId: string) { + async addSubteamToRecruiting( + @Path() teamId: string, + @Path() subteamId: string, + ) { const updatedTeam = await TeamRecruitingStatus.findOneAndUpdate( { teamPk: teamId }, { $addToSet: { recruitingSubteamPks: subteamId }, - teamPk: teamId + teamPk: teamId, }, - { upsert: true, new: true } - ).lean().exec(); + { upsert: true, new: true }, + ) + .lean() + .exec(); // Set isRecruiting based on array length - if (updatedTeam.recruitingSubteamPks.length > 0 && !updatedTeam.isRecruiting) { + if ( + updatedTeam.recruitingSubteamPks.length > 0 && + !updatedTeam.isRecruiting + ) { await TeamRecruitingStatus.updateOne( { teamPk: teamId }, - { isRecruiting: true } + { isRecruiting: true }, ); updatedTeam.isRecruiting = true; } @@ -1578,12 +2015,17 @@ export class ATSController extends Controller { return updatedTeam; } - async removeSubteamFromRecruiting(@Path() teamId: string, @Path() subteamId: string) { + async removeSubteamFromRecruiting( + @Path() teamId: string, + @Path() subteamId: string, + ) { const updatedTeam = await TeamRecruitingStatus.findOneAndUpdate( { teamPk: teamId }, { $pull: { recruitingSubteamPks: subteamId } }, - { new: true } - ).lean().exec(); + { new: true }, + ) + .lean() + .exec(); if (!updatedTeam) { this.setStatus(404); @@ -1591,10 +2033,13 @@ export class ATSController extends Controller { } // Set isRecruiting to false if no subteams left - if (updatedTeam.recruitingSubteamPks.length === 0 && updatedTeam.isRecruiting) { + if ( + updatedTeam.recruitingSubteamPks.length === 0 && + updatedTeam.isRecruiting + ) { await TeamRecruitingStatus.updateOne( { teamPk: teamId }, - { isRecruiting: false } + { isRecruiting: false }, ); updatedTeam.isRecruiting = false; } @@ -1626,13 +2071,21 @@ export class ATSController extends Controller { const [teamsRes, grouped] = await Promise.all([ this.authentikClient.getGroupsList({ limit: 500 }), Application.aggregate([ - { $group: { _id: { teamPk: "$teamPk", stage: "$stage" }, count: { $sum: 1 } } } + { + $group: { + _id: { teamPk: "$teamPk", stage: "$stage" }, + count: { $sum: 1 }, + }, + }, ]).exec(), ]); /* teamPk -> stage -> count */ const byTeam = new Map>(); - for (const row of grouped as { _id: { teamPk: string, stage: string }, count: number }[]) { + for (const row of grouped as { + _id: { teamPk: string; stage: string }; + count: number; + }[]) { const stages = byTeam.get(row._id.teamPk) ?? {}; stages[row._id.stage] = row.count; byTeam.set(row._id.teamPk, stages); @@ -1665,5 +2118,4 @@ export class ATSController extends Controller { return { stages: stageKeys, teams }; } - } diff --git a/peopleportal/server/src/controllers/AuthController.ts b/peopleportal/server/src/controllers/AuthController.ts index 13ffa0a..395c67c 100644 --- a/peopleportal/server/src/controllers/AuthController.ts +++ b/peopleportal/server/src/controllers/AuthController.ts @@ -16,19 +16,32 @@ along with this program. If not, see . */ -import * as express from 'express' -import { Request, Controller, Get, Route, SuccessResponse, Security, Post, Body, Tags, Queries, Hidden, Query } from "tsoa"; -import { OpenIdClient } from '../clients/OpenIdClient'; -import { UserInfoResponse } from 'openid-client'; -import { Applicant } from '../models/Applicant'; -import { Application } from '../models/Application'; -import jwt from "jsonwebtoken" -import { generateSecureRandomString, capitalizeString } from '../utils/strings'; -import { AuthentikClient } from '../clients/AuthentikClient'; -import { EmailClient } from '../clients/EmailClient'; -import { signAvatarUrl } from '../utils/avatars'; -import { executiveAuthVerify } from '../auth'; -import { describeUnknownError } from '../utils/errors'; +import * as express from "express"; +import { + Request, + Controller, + Get, + Route, + SuccessResponse, + Security, + Post, + Body, + Tags, + Queries, + Hidden, + Query, +} from "tsoa"; +import { OpenIdClient } from "../clients/OpenIdClient"; +import { UserInfoResponse } from "openid-client"; +import { Applicant } from "../models/Applicant"; +import { Application } from "../models/Application"; +import jwt from "jsonwebtoken"; +import { generateSecureRandomString, capitalizeString } from "../utils/strings"; +import { AuthentikClient } from "../clients/AuthentikClient"; +import { EmailClient } from "../clients/EmailClient"; +import { signAvatarUrl } from "../utils/avatars"; +import { executiveAuthVerify } from "../auth"; +import { describeUnknownError } from "../utils/errors"; export interface CorpUserInfoResponse extends UserInfoResponse { avatar: string; @@ -45,20 +58,19 @@ interface OtpVerifyRequest { otp: string; } - @Route("/api/auth") export class AuthController extends Controller { - private emailClient: EmailClient + private emailClient: EmailClient; public constructor() { - super() - this.emailClient = new EmailClient() + super(); + this.emailClient = new EmailClient(); } /** * Returns the user info of the currently authenticated user. Uses the * Express Session Cookie and User Scopes provided by the OpenID Connect * Authorization Server. - * + * * @param req Express Request Object * @returns User Info Response */ @@ -66,40 +78,48 @@ export class AuthController extends Controller { @Tags("Core Authentication") @Security("oidc") @SuccessResponse(200) - async getUserInfo(@Request() req: express.Request): Promise { + async getUserInfo( + @Request() req: express.Request, + ): Promise { if (!req.session.accessToken || !req.session.authorizedUser) - throw new Error("Unauthorized") + throw new Error("Unauthorized"); - const userInfo = await OpenIdClient.getUserInfo(req.session.accessToken, req.session.authorizedUser.sub) + const userInfo = await OpenIdClient.getUserInfo( + req.session.accessToken, + req.session.authorizedUser.sub, + ); // Remap to CorpUserInfo and sign avatar URL - const avatarKey = (userInfo as any).attributes?.avatar - const avatarUrl = await signAvatarUrl(req.session.authorizedUser.pk ?? req.session.authorizedUser.sub, avatarKey) + const avatarKey = (userInfo as any).attributes?.avatar; + const avatarUrl = await signAvatarUrl( + req.session.authorizedUser.pk ?? req.session.authorizedUser.sub, + avatarKey, + ); - let isExecutive = false + let isExecutive = false; try { - isExecutive = await executiveAuthVerify(req, [], true) + isExecutive = await executiveAuthVerify(req, [], true); } catch { - isExecutive = false + isExecutive = false; } return { ...userInfo, avatar: avatarUrl, - isExecutive - } + isExecutive, + }; } /** * Primary endpoint to initiate the People Portal OIDC Authentication Flow. This routine * accepts redirection parameters, generates the required Authentication Flow parameters * from the OpenID Connect Authentication server and redirects to it to continue with authentication. - * + * * Once this process is completed, the OpenID Connect Authentication Server usually redirects * to the /api/auth/redirect endpoint which handles the final steps as defined in the next method. - * + * * @param req Express Request Object - * @param redirect_uri Post-Login Redirect URL + * @param redirect_uri Post-Login Redirect URL * @returns Redirects to the OpenID Connect Authorization Server */ @Get("login") @@ -121,8 +141,7 @@ export class AuthController extends Controller { req.session.tempsession = req.session.tempsession || {}; if (redirect_uri) { req.session.tempsession.redirect_uri = redirect_uri; - if (state) - req.session.tempsession.state = state; + if (state) req.session.tempsession.state = state; } if (return_to) { @@ -130,31 +149,35 @@ export class AuthController extends Controller { verbatim after login, so anything else is an open redirect. */ let safe = return_to.startsWith("/") && !return_to.startsWith("//"); if (!safe) { - try { safe = new URL(return_to).host === req.get("host"); } catch { safe = false; } + try { + safe = new URL(return_to).host === req.get("host"); + } catch { + safe = false; + } } if (safe) req.session.tempsession.return_to = return_to; } - let authFlowResponse = OpenIdClient.startAuthFlow() + let authFlowResponse = OpenIdClient.startAuthFlow(); if (!authFlowResponse) - throw new Error("Failed to Compute OIDC Redirect URL") + throw new Error("Failed to Compute OIDC Redirect URL"); /* Perform Redirection */ - const res = (req as any).res as express.Response - req.session.oidcState = authFlowResponse.expectedState - return res.redirect(302, authFlowResponse.redirectUrl.toString()) + const res = (req as any).res as express.Response; + req.session.oidcState = authFlowResponse.expectedState; + return res.redirect(302, authFlowResponse.redirectUrl.toString()); } /** - * Handles the OpenID Connect callback, exchanges the code for a session, + * Handles the OpenID Connect callback, exchanges the code for a session, * and redirects the user to the requested endpoint or root. - * + * * **Non-standard Behavior:** * If the user is logging in via the API Docs, we inject a "Shim Token" (SessionCookieShimToken) * into the URL fragment. This is to trick the API Docs into unlocking the authorized state. - * However, since all authentication and authorization works over the Session Cookie, + * However, since all authentication and authorization works over the Session Cookie, * the shim doesn't impact security or the actual requests. - * + * * @param req Express Request Object * @returns Express Redirection */ @@ -163,18 +186,23 @@ export class AuthController extends Controller { @SuccessResponse(302, "Redirect") async handleRedirect(@Request() req: express.Request) { try { - const fullURL = req.protocol + '://' + req.get('host') + req.originalUrl; - let authorizationStamp = await OpenIdClient.issueAuthorizationStamps(new URL(fullURL), req.session.oidcState!) - req.session.accessToken = authorizationStamp.accessToken - req.session.authorizedUser = authorizationStamp.user - req.session.tokenExpiry = authorizationStamp.expiry.getTime() + const fullURL = + req.protocol + "://" + req.get("host") + req.originalUrl; + let authorizationStamp = + await OpenIdClient.issueAuthorizationStamps( + new URL(fullURL), + req.session.oidcState!, + ); + req.session.accessToken = authorizationStamp.accessToken; + req.session.authorizedUser = authorizationStamp.user; + req.session.tokenExpiry = authorizationStamp.expiry.getTime(); if (authorizationStamp.refreshToken) - req.session.refreshToken = authorizationStamp.refreshToken + req.session.refreshToken = authorizationStamp.refreshToken; if (authorizationStamp.idToken) - req.session.idToken = authorizationStamp.idToken + req.session.idToken = authorizationStamp.idToken; /* Redirect Logic */ - const res = (req as any).res as express.Response + const res = (req as any).res as express.Response; const tempsession = req.session.tempsession; /* Case 0: Simple Return To (App Navigation) */ @@ -187,11 +215,14 @@ export class AuthController extends Controller { /* Case 1: OAuth2 Passthrough (API Docs Portal, Postman, etc.) */ if (tempsession?.redirect_uri) { const redirectUri = new URL(tempsession.redirect_uri); - const currentOrigin = req.protocol + '://' + req.get('host'); + const currentOrigin = req.protocol + "://" + req.get("host"); /* Implicit Flow Injection for OAuth2 Docs */ /* If the redirect is for our own docs, we inject the dummy token directly using hash fragment */ - if (redirectUri.origin === currentOrigin && redirectUri.pathname.startsWith('/api/docs')) { + if ( + redirectUri.origin === currentOrigin && + redirectUri.pathname.startsWith("/api/docs") + ) { const hashParams = new URLSearchParams(); hashParams.append("access_token", "SessionCookieShimToken"); hashParams.append("token_type", "Bearer"); @@ -204,9 +235,15 @@ export class AuthController extends Controller { } else { /* Standard Code Flow for others (if needed later) or External Redirects */ /* Note: We essentially only support our own docs for now */ - redirectUri.searchParams.append("code", generateSecureRandomString(16)); + redirectUri.searchParams.append( + "code", + generateSecureRandomString(16), + ); if (tempsession.state) - redirectUri.searchParams.append("state", tempsession.state); + redirectUri.searchParams.append( + "state", + tempsession.state, + ); } /* Cleanup */ @@ -216,10 +253,10 @@ export class AuthController extends Controller { } /* Case 3: Default */ - return res.redirect(302, "/") + return res.redirect(302, "/"); } catch (e) { - console.log(e) - throw e + console.log(e); + throw e; } } @@ -227,7 +264,7 @@ export class AuthController extends Controller { * Initiates Guest Authentication into People Portal. This routine generates * a 6-digit Verification Code and sends it to the user's email. Primarily * used for authentication during Recruitment Applications. - * + * * @param body Guest Authentication Initation Request * @param req Express Request Object * @returns Status Message @@ -235,13 +272,19 @@ export class AuthController extends Controller { @Post("otpinit") @Tags("Guest Authentication") @SuccessResponse(200) - async otpInitRequest(@Body() body: OtpInitRequest, @Request() req: express.Request) { + async otpInitRequest( + @Body() body: OtpInitRequest, + @Request() req: express.Request, + ) { const { email } = body; const name = capitalizeString(body.name); if (!email || !name) { this.setStatus(400); - return { error: "Bad Request", message: "Email and name are required" }; + return { + error: "Bad Request", + message: "Email and name are required", + }; } // Generate 6-digit OTP @@ -259,13 +302,13 @@ export class AuthController extends Controller { /* Send OTP Via Email to User */ await this.emailClient.send({ to: email, - subject: 'App Dev Verification Code', - templateName: 'AuthOtpSendCode', + subject: "App Dev Verification Code", + templateName: "AuthOtpSendCode", templateVars: { name, - otpCode: otp - } - }) + otpCode: otp, + }, + }); return { message: "Verification Code sent successfully" }; } @@ -274,10 +317,10 @@ export class AuthController extends Controller { * Verifies the 6-digit Verification Code sent to the user's email, signs a * JSON Web Token (JWT) to establish an authenticated session and creates the * user's guest profile in the database, if it doesn't exist. - * + * * The profile generation transforms the user from an unknown guest into a * verified applicant in the database. - * + * * @param body Guest Authentication Verification Request * @param req Express Request Object * @returns Status Message @@ -285,17 +328,24 @@ export class AuthController extends Controller { @Post("otpverify") @Tags("Guest Authentication") @SuccessResponse(200) - async otpVerifyRequest(@Body() body: OtpVerifyRequest, @Request() req: express.Request) { + async otpVerifyRequest( + @Body() body: OtpVerifyRequest, + @Request() req: express.Request, + ) { const { email, otp } = body; // Get tempsession data const tempsession = req.session.tempsession || {}; // Verify OTP from tempsession - if (!tempsession.otp || !tempsession.otpEmail || !tempsession.otpExpiry || + if ( + !tempsession.otp || + !tempsession.otpEmail || + !tempsession.otpExpiry || tempsession.otpEmail !== email || tempsession.otp !== otp || - Date.now() > tempsession.otpExpiry) { + Date.now() > tempsession.otpExpiry + ) { this.setStatus(401); return { error: "Unauthorized", message: "Invalid or expired OTP" }; } @@ -306,7 +356,7 @@ export class AuthController extends Controller { if (!applicant) { applicant = new Applicant({ email, - fullName: tempsession.otpName + fullName: tempsession.otpName, }); await applicant.save(); } @@ -315,7 +365,7 @@ export class AuthController extends Controller { const token = jwt.sign( { email, name: applicant.fullName, id: applicant._id }, process.env.PEOPLEPORTAL_TOKEN_SECRET!, - { expiresIn: "24h" } + { expiresIn: "24h" }, ); // Store JWT and user info in tempsession @@ -324,21 +374,23 @@ export class AuthController extends Controller { user: { email: applicant.email, name: applicant.fullName, - id: applicant._id.toString() - } - } + id: applicant._id.toString(), + }, + }; return { name: applicant.fullName, email: applicant.email, - profile: applicant.profile ? Object.fromEntries(applicant.profile) : {} + profile: applicant.profile + ? Object.fromEntries(applicant.profile) + : {}, }; } /** * Verifies the guest authentication session and returns the user's profile. * Additionally, we enumerate the user's ATS applications and return them. - * + * * @param req Express Request Object * @returns User Profile and ATS Applications */ @@ -355,7 +407,10 @@ export class AuthController extends Controller { try { // Verify the JWT - const decoded = jwt.verify(tempsession.jwt, process.env.PEOPLEPORTAL_TOKEN_SECRET!) as { + const decoded = jwt.verify( + tempsession.jwt, + process.env.PEOPLEPORTAL_TOKEN_SECRET!, + ) as { email: string; name: string; id: string; @@ -365,53 +420,81 @@ export class AuthController extends Controller { const applicant = await Applicant.findById(decoded.id).exec(); if (!applicant) { - return { error: "Unauthorized", message: "Applicant not found" }; + return { + error: "Unauthorized", + message: "Applicant not found", + }; } - const applications = await Application.find({ applicantId: applicant._id }).lean() + const applications = await Application.find({ + applicantId: applicant._id, + }).lean(); // Fetch subteam and parent names from Authentik const authentikClient = new AuthentikClient(); - const applicationsWithNames = await Promise.all(applications.map(async (app: any) => { - let teamName = app.teamPk; - try { - // 1. Fetch Parent Team Name - const teamInfo = await authentikClient.getGroupInfo(app.teamPk); - teamName = teamInfo.attributes?.friendlyName || teamInfo.name; - } catch (e) { - console.error(`Failed to fetch team info for ${app.teamPk}`, e); - } - - // 2. Fetch Subteam Names for Preferences - const rolePreferencesWithNames = await Promise.all((app.rolePreferences || []).map(async (pref: any) => { - let subteamName = pref.subteamPk; + const applicationsWithNames = await Promise.all( + applications.map(async (app: any) => { + let teamName = app.teamPk; try { - const subteamInfo = await authentikClient.getGroupInfo(pref.subteamPk); - subteamName = subteamInfo.attributes?.friendlyName || subteamInfo.name; + // 1. Fetch Parent Team Name + const teamInfo = await authentikClient.getGroupInfo( + app.teamPk, + ); + teamName = + teamInfo.attributes?.friendlyName || teamInfo.name; } catch (e) { - console.error(`Failed to fetch subteam info for ${pref.subteamPk}`, e); + console.error( + `Failed to fetch team info for ${app.teamPk}`, + e, + ); } - return { ...pref, subteamName }; - })); - // 3. Map subteam preferences (legacy support or if frontend needs it) - // For the new UI, we mostly care about rolePreferencesWithNames - return { - ...app, - teamName: teamName, - rolePreferences: rolePreferencesWithNames - }; - })); + // 2. Fetch Subteam Names for Preferences + const rolePreferencesWithNames = await Promise.all( + (app.rolePreferences || []).map(async (pref: any) => { + let subteamName = pref.subteamPk; + try { + const subteamInfo = + await authentikClient.getGroupInfo( + pref.subteamPk, + ); + subteamName = + subteamInfo.attributes?.friendlyName || + subteamInfo.name; + } catch (e) { + console.error( + `Failed to fetch subteam info for ${pref.subteamPk}`, + e, + ); + } + return { ...pref, subteamName }; + }), + ); + + // 3. Map subteam preferences (legacy support or if frontend needs it) + // For the new UI, we mostly care about rolePreferencesWithNames + return { + ...app, + teamName: teamName, + rolePreferences: rolePreferencesWithNames, + }; + }), + ); return { name: applicant.fullName, email: applicant.email, - profile: applicant.profile ? Object.fromEntries(applicant.profile) : {}, - applications: applicationsWithNames + profile: applicant.profile + ? Object.fromEntries(applicant.profile) + : {}, + applications: applicationsWithNames, }; } catch (error) { // JWT is invalid or expired - return { error: "Unauthorized", message: "Session expired or invalid" }; + return { + error: "Unauthorized", + message: "Session expired or invalid", + }; } } @@ -446,7 +529,10 @@ export class AuthController extends Controller { } catch (e: unknown) { /* A provider problem must not strand the user in a logged-in session: fall through to the local logout below. */ - console.error("Failed building provider logout URL:", describeUnknownError(e)); + console.error( + "Failed building provider logout URL:", + describeUnknownError(e), + ); } return new Promise((resolve) => { @@ -455,4 +541,4 @@ export class AuthController extends Controller { }); }); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/controllers/BindleController.ts b/peopleportal/server/src/controllers/BindleController.ts index da92dfa..e8f3f35 100644 --- a/peopleportal/server/src/controllers/BindleController.ts +++ b/peopleportal/server/src/controllers/BindleController.ts @@ -25,35 +25,39 @@ import { PeoplePortalClient } from "../clients/PeoplePortalClient"; export interface EnabledBindlePermissions { /* Name of the Perm (ex. repo:allowcreate) */ - [key: string]: boolean + [key: string]: boolean; } export interface BindlePermissionMap { - [key: string]: BindlePermission + [key: string]: BindlePermission; } export interface BindlePermission { - friendlyName: string, - description: string, + friendlyName: string; + description: string; } @Route("/api/bindles") export class BindleController extends Controller { - private static bindleDefinition: { [key: string]: BindlePermissionMap } = (() => { - const definitions: { [key: string]: BindlePermissionMap } = {}; - for (const sharedResource of Object.values(ENABLED_SHARED_RESOURCES)) { - const resourceName = sharedResource.getResourceName(); - definitions[resourceName] = sharedResource.getSupportedBindles(); - } + private static bindleDefinition: { [key: string]: BindlePermissionMap } = + (() => { + const definitions: { [key: string]: BindlePermissionMap } = {}; + for (const sharedResource of Object.values( + ENABLED_SHARED_RESOURCES, + )) { + const resourceName = sharedResource.getResourceName(); + definitions[resourceName] = + sharedResource.getSupportedBindles(); + } - /* Perform Return for Static Store */ - return definitions; - })(); + /* Perform Return for Static Store */ + return definitions; + })(); /** * Fetches the list of globally available Bindle Settings constructed * from enabled shared resources. - * + * * @returns List of available Bindle Permissions */ @Get("definitions") @@ -67,25 +71,29 @@ export class BindleController extends Controller { * Given a list of bindle permissions, this function will sanitize the input * by removing any bindle permissions that are not supported by the enabled * shared resources. - * + * * @param bindlePermissions Input Bindle Permission Map * @returns Sanitized Bindle Permission Map */ - public static sanitizeBindlePermissions = (bindlePermissions: { [key: string]: EnabledBindlePermissions }): { [key: string]: EnabledBindlePermissions } => { - const sanitizedBindlePermissions: { [key: string]: EnabledBindlePermissions } = {}; + public static sanitizeBindlePermissions = (bindlePermissions: { + [key: string]: EnabledBindlePermissions; + }): { [key: string]: EnabledBindlePermissions } => { + const sanitizedBindlePermissions: { + [key: string]: EnabledBindlePermissions; + } = {}; for (const clientName in bindlePermissions) { - const supportedBindles = BindleController.bindleDefinition[clientName]; - if (!supportedBindles) - continue; + const supportedBindles = + BindleController.bindleDefinition[clientName]; + if (!supportedBindles) continue; const filteredBindles: EnabledBindlePermissions = {}; for (const bindleKey in bindlePermissions[clientName]) { // Check if this bindle key is supported by the client - if (!supportedBindles[bindleKey]) - continue; + if (!supportedBindles[bindleKey]) continue; /* Update Filtered Bindle Setting */ - filteredBindles[bindleKey] = bindlePermissions[clientName][bindleKey] ?? false; + filteredBindles[bindleKey] = + bindlePermissions[clientName][bindleKey] ?? false; } /* Apply the Filtered Bindles to the Final List */ @@ -93,18 +101,21 @@ export class BindleController extends Controller { } return sanitizedBindlePermissions; - } + }; /** * Calculates granular permissions from subteam memberships interactively. * Team Owners bypass this check in the middleware. Scoped to People Portal * bindles only. - * + * * @param targetTeam Team Information from Authentik Client * @param userGroups User's group membership from OIDC Session * @returns Set of Bindle Permissions (Ex. corp:membermgmt) */ - public static getEffectivePermissionSet(targetTeam: GetGroupInfoResponse, userGroups: string[]): Set { + public static getEffectivePermissionSet( + targetTeam: GetGroupInfoResponse, + userGroups: string[], + ): Set { /* Add Group Structures to a Set for O(1) lookup, takes O(n) time */ const activePermissions = new Set(); const userGroupSet = new Set(userGroups); @@ -115,11 +126,15 @@ export class BindleController extends Controller { /* 2. Check if user is in this subteam */ if (userGroupSet.has(subteam.name)) { /* 3. Merge Enabled Bindles (PeoplePortal Only) */ - const bindlePermissions = subteam.attributes.bindlePermissions; + const bindlePermissions = + subteam.attributes.bindlePermissions; if (bindlePermissions) { - const clientBindles = bindlePermissions[PeoplePortalClient.TAG]; + const clientBindles = + bindlePermissions[PeoplePortalClient.TAG]; if (clientBindles) { - for (const [bindle, enabled] of Object.entries(clientBindles)) { + for (const [bindle, enabled] of Object.entries( + clientBindles, + )) { if (enabled) activePermissions.add(bindle); } } @@ -130,4 +145,4 @@ export class BindleController extends Controller { return activePermissions; } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/controllers/EventController.ts b/peopleportal/server/src/controllers/EventController.ts index 45db1d6..59ed209 100644 --- a/peopleportal/server/src/controllers/EventController.ts +++ b/peopleportal/server/src/controllers/EventController.ts @@ -16,12 +16,26 @@ along with this program. If not, see . */ -import { Route, Request, Controller, Get, SuccessResponse, Post, Body, Tags, Security, Path, Queries, Delete, Patch } from "tsoa"; +import { + Route, + Request, + Controller, + Get, + SuccessResponse, + Post, + Body, + Tags, + Security, + Path, + Queries, + Delete, + Patch, +} from "tsoa"; import { EmailClient } from "../clients/EmailClient"; import { SlackClient } from "../clients/SlackClient"; import { AuthentikClient } from "../clients/AuthentikClient"; -import { ENABLED_SHARED_RESOURCES, hasAdminAuthority } from '../config'; -import * as express from 'express'; +import { ENABLED_SHARED_RESOURCES, hasAdminAuthority } from "../config"; +import * as express from "express"; import { CustomValidationError, ResourceAccessError } from "../utils/errors"; import { Event, IEvent } from "../models/Event"; import { EventRsvp, IRsvp, RsvpStatus } from "../models/EventRsvp"; @@ -29,7 +43,6 @@ import { FlattenMaps, HydratedDocument, Types } from "mongoose"; import { agendaClient } from "../clients/AgendaClient"; import { DiscordClient } from "../clients/DiscordClient"; - import type { DocumentJSON } from "../utils/types"; export interface GetEventListOptions { @@ -38,13 +51,13 @@ export interface GetEventListOptions { scopes?: string[]; } export interface GetEventListResponse { - data: string[], - count: number, + data: string[]; + count: number; } export interface GetEventResponse { status: string; - data: DocumentJSON + data: DocumentJSON; } export interface CreateEventRequest { @@ -57,9 +70,9 @@ export interface CreateEventRequest { marketingChannels: string[]; } export interface CreateEventResponse { - eventId: string, - status: string, - issues: string[], + eventId: string; + status: string; + issues: string[]; } export interface BasicEventResponse { @@ -98,7 +111,6 @@ export interface GetEventRsvpResponse { rsvp: DocumentJSON | null; } - @Route("/api/events/") export class EventController extends Controller { public static readonly SLACK_EVENT_ANNOUNCEMENT_CHANNEL = "announcements"; @@ -108,16 +120,16 @@ export class EventController extends Controller { private readonly emailClient; private readonly slackClient; private readonly discordClient; - + constructor() { - super() + super(); this.authentikClient = new AuthentikClient(); this.emailClient = new EmailClient(); this.slackClient = ENABLED_SHARED_RESOURCES.slackClient as SlackClient; - this.discordClient = ENABLED_SHARED_RESOURCES.discordClient as DiscordClient; + this.discordClient = + ENABLED_SHARED_RESOURCES.discordClient as DiscordClient; } - /** * Get list of event Ids. */ @@ -128,7 +140,7 @@ export class EventController extends Controller { @Tags("Event Management") async getListOfEvents( @Request() req: express.Request, - @Queries() options?: GetEventListOptions + @Queries() options?: GetEventListOptions, ): Promise { let filters: { [key: string]: any } = {}; @@ -151,18 +163,31 @@ export class EventController extends Controller { scopes = ["public"]; } else if (!req.session.authorizedUser?.is_superuser) { try { - const userTeams = await this.authentikClient.getRootTeamsForUsername(req.session.authorizedUser!.username); - if (!hasAdminAuthority(userTeams.teams, req.session.authorizedUser!.groups ?? [])) { + const userTeams = + await this.authentikClient.getRootTeamsForUsername( + req.session.authorizedUser!.username, + ); + if ( + !hasAdminAuthority( + userTeams.teams, + req.session.authorizedUser!.groups ?? [], + ) + ) { scopes = ["public", "internal"]; } } catch (error) { - throw new ResourceAccessError(500, "Failed to get team membership."); + throw new ResourceAccessError( + 500, + "Failed to get team membership.", + ); } } if (options?.scopes !== undefined) { if (scopes.length > 0) { - scopes = scopes.filter(scope => options.scopes?.includes(scope)); + scopes = scopes.filter((scope) => + options.scopes?.includes(scope), + ); } else { scopes = options?.scopes; } @@ -172,20 +197,21 @@ export class EventController extends Controller { filters.scope = { $in: scopes }; } - const query = Event.distinct("_id", filters).sort({ startTime: 'asc' }).lean(); + const query = Event.distinct("_id", filters) + .sort({ startTime: "asc" }) + .lean(); const results = await query.exec(); const response = { data: results.map((objId) => objId.toString()), count: results.length, - } + }; return response; } - /** * Return a single event's information. - * + * * @param req express Request object * @param eventId Id of the event * @returns Event data as json. @@ -208,30 +234,46 @@ export class EventController extends Controller { query = query.where("scope").equals("public"); } else if (!req.session.authorizedUser?.is_superuser) { try { - const userTeams = await this.authentikClient.getRootTeamsForUsername(req.session.authorizedUser!.username); - if (!hasAdminAuthority(userTeams.teams, req.session.authorizedUser!.groups ?? [])) { + const userTeams = + await this.authentikClient.getRootTeamsForUsername( + req.session.authorizedUser!.username, + ); + if ( + !hasAdminAuthority( + userTeams.teams, + req.session.authorizedUser!.groups ?? [], + ) + ) { query = query.where("scope").ne("exec"); } } catch (error) { - throw new ResourceAccessError(500, "Failed to get team membership."); + throw new ResourceAccessError( + 500, + "Failed to get team membership.", + ); } } - try { const event = await query.lean().exec(); if (!event) { - throw new CustomValidationError(404, `No event with id ${eventId}`); + throw new CustomValidationError( + 404, + `No event with id ${eventId}`, + ); } - return { status: "Success", data: event } + return { status: "Success", data: event }; } catch (error) { - console.error(`Failed to get event data for event ${eventId}:`, error); + console.error( + `Failed to get event data for event ${eventId}:`, + error, + ); throw new ResourceAccessError(500, "Failed to fetch event data"); } } /** - * + * * @param req Express Request Object * @param body CreateEventRequest * @returns Object with eventId and status. @@ -246,7 +288,10 @@ export class EventController extends Controller { ): Promise { // Validations if (body.startTime > body.endTime) { - throw new CustomValidationError(400, "End time can't be before start time."); + throw new CustomValidationError( + 400, + "End time can't be before start time.", + ); } if (body.title.length == 0) { throw new CustomValidationError(400, "No title given."); @@ -272,13 +317,15 @@ export class EventController extends Controller { // EMAIL INVITEES if (body.marketingChannels.includes("email")) { try { - const userInfo = await this.resolveUserInfoFromScope(body.scope); + const userInfo = await this.resolveUserInfoFromScope( + body.scope, + ); if (!userInfo) { throw new Error("Failed to fetch recipients."); } // Send out emails to invitees with bcc. - const emails = userInfo.map(uInfo => uInfo.email); + const emails = userInfo.map((uInfo) => uInfo.email); await this.emailClient.send({ to: process.env.PEOPLEPORTAL_SMTP_USER!, cc: [authorizedUser.email], @@ -288,8 +335,12 @@ export class EventController extends Controller { templateVars: { eventName: body.title, eventDescription: body.description, - startTime: body.startTime.toLocaleString("en-US", { timeZone: "America/New_York" }), - endTime: body.endTime.toLocaleString("en-US", { timeZone: "America/New_York" }), + startTime: body.startTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), + endTime: body.endTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), eventLocation: body.location, inviteLink: inviteLink, googleCalendarLink: gCalendarLink, @@ -297,20 +348,21 @@ export class EventController extends Controller { }); } catch (error) { console.error("Failed to send email to event invitees:", error); - issues.push("Failed to send email.") + issues.push("Failed to send email."); } } // SLACK MESSAGE if (body.marketingChannels.includes("slack")) { - const message = `*Event Announcement*\n` + - `>${event.eventName}\n` + - `Description: ${event.eventDescription}\n` + - `Start Time: ${event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + - `End Time: ${event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + - `Location: ${event.location}\n\n` + - `RSVP here: ${inviteLink}\n` + - `Add to google calendar: ${gCalendarLink}`; + const message = + `*Event Announcement*\n` + + `>${event.eventName}\n` + + `Description: ${event.eventDescription}\n` + + `Start Time: ${event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + + `End Time: ${event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + + `Location: ${event.location}\n\n` + + `RSVP here: ${inviteLink}\n` + + `Add to google calendar: ${gCalendarLink}`; const success = await this.sendSlackMessage(message); if (!success) { @@ -320,34 +372,37 @@ export class EventController extends Controller { // DISCORD MESSAGE if (body.marketingChannels.includes("discord")) { - const message = `# Event Announcement\n` + - `## ${event.eventName}\n` + - `- Description: ${event.eventDescription}\n` + - `- Start Time: ${event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + - `- End Time: ${event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + - `- Location: ${event.location}\n\n` + - `RSVP here: ${inviteLink}\n` + - `Add to google calendar: ${gCalendarLink}`; + const message = + `# Event Announcement\n` + + `## ${event.eventName}\n` + + `- Description: ${event.eventDescription}\n` + + `- Start Time: ${event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + + `- End Time: ${event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" })}\n` + + `- Location: ${event.location}\n\n` + + `RSVP here: ${inviteLink}\n` + + `Add to google calendar: ${gCalendarLink}`; const success = await this.sendDiscordMessage(message); if (!success) { issues.push("Failed to send Discord announcement."); } } - + // Schedule 2 Hour Reminder try { await agendaClient.scheduleJobOnce({ jobName: "sendEventReminders", runAt: new Date(event.startTime.getTime() - 1000 * 60 * 60 * 2), jobPayload: { eventId: event._id.toString() }, - options: { timezone: "America/New_York" } + options: { timezone: "America/New_York" }, }); } catch (error) { issues.push("Failed to schedule 2 hour reminder."); - console.error(`Failed to schedule 2 hour reminder for event ${event._id}:`, error); + console.error( + `Failed to schedule 2 hour reminder for event ${event._id}:`, + error, + ); } - return { eventId: event._id.toString(), @@ -356,10 +411,9 @@ export class EventController extends Controller { }; } - /** * Cancels event. Deletes document from Events and all RSVPs. - * + * * @param req Express Request Object * @param eventId Id of the event to cancel * @param body { notify?: boolean }. If notify is true (default), will send notify invitees of event cancellation. @@ -373,24 +427,34 @@ export class EventController extends Controller { async cancelEvent( @Request() req: express.Request, @Path() eventId: string, - @Body() body?: { notify?: boolean } + @Body() body?: { notify?: boolean }, ): Promise { // Find event let event: HydratedDocument | null; try { event = await Event.findOne({ _id: eventId }).exec(); } catch (error) { - console.error(`Failed to get event data for event ${eventId}: `, error); - throw new ResourceAccessError(500, `Failed to fetch event ${eventId}`); + console.error( + `Failed to get event data for event ${eventId}: `, + error, + ); + throw new ResourceAccessError( + 500, + `Failed to fetch event ${eventId}`, + ); } - + if (!event) { throw new CustomValidationError(404, `No event with id ${eventId}`); } - await agendaClient.cancelJob("sendEventReminders", { eventId: eventId }); + await agendaClient.cancelJob("sendEventReminders", { + eventId: eventId, + }); // Delete all RSVPs and Event - const rsvp_deletion = await EventRsvp.deleteMany({ eventId: eventId }).exec(); + const rsvp_deletion = await EventRsvp.deleteMany({ + eventId: eventId, + }).exec(); await event.deleteOne(); const issues: string[] = []; @@ -403,10 +467,11 @@ export class EventController extends Controller { }; } - if (event.marketingChannels.includes("email")) { try { - const invitees = await this.resolveUserInfoFromScope(event.scope); + const invitees = await this.resolveUserInfoFromScope( + event.scope, + ); if (invitees === undefined) { throw new Error("Failed to fetch invitees."); } @@ -414,26 +479,31 @@ export class EventController extends Controller { await this.emailClient.send({ to: process.env.PEOPLEPORTAL_SMTP_USER!, cc: [req.session.authorizedUser!.email], - bcc: invitees.map(invitee => invitee.email), + bcc: invitees.map((invitee) => invitee.email), subject: `App Dev Event Cancelled - ${event.eventName}`, templateName: "EventCancellation", templateVars: { eventName: event.eventName, eventDescription: event.eventDescription, - startTime: event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" }), - endTime: event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" }), + startTime: event.startTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), + endTime: event.endTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), }, }); } catch (error) { console.error(`Failed to get email recipients:`, error); issues.push("Failed to send emails."); - } + } } // Send Slack Message if (event.marketingChannels.includes("slack")) { - const message = `*Event Announcement*\n` + - `${event.eventName} has been *cancelled.*`; + const message = + `*Event Announcement*\n` + + `${event.eventName} has been *cancelled.*`; const success = await this.sendSlackMessage(message); if (!success) { @@ -443,9 +513,10 @@ export class EventController extends Controller { // Send Discord Message if (event.marketingChannels.includes("discord")) { - const message = `# Event Announcement\n` + - `**${event.eventName}** has been **cancelled**`; - + const message = + `# Event Announcement\n` + + `**${event.eventName}** has been **cancelled**`; + const success = await this.sendDiscordMessage(message); if (!success) { issues.push("Failed to send Discord announcement."); @@ -459,11 +530,10 @@ export class EventController extends Controller { }; } - /** * Updates the information of an event. Sends update announcement if * notify is set. - * + * * @param eventId id of the event * @param body UpdateEventRequest * @returns BasicEventResponse @@ -474,13 +544,16 @@ export class EventController extends Controller { @Tags("Event Management") async updateEvent( @Path() eventId: string, - @Body() body: UpdateEventRequest + @Body() body: UpdateEventRequest, ): Promise { let event: HydratedDocument | null; try { event = await Event.findOne({ _id: eventId }).exec(); } catch (error) { - throw new ResourceAccessError(500, `Failed to fetch event ${eventId}`); + throw new ResourceAccessError( + 500, + `Failed to fetch event ${eventId}`, + ); } if (!event) { @@ -494,30 +567,42 @@ export class EventController extends Controller { const k = key as keyof typeof docUpdates; let isEqual = event[k] === docUpdates[k]; if (k.includes("Time")) { - isEqual = (event[k] as Date).getTime() === (docUpdates[k] as Date).getTime(); + isEqual = + (event[k] as Date).getTime() === + (docUpdates[k] as Date).getTime(); } if (docUpdates[k] === undefined || isEqual) { delete docUpdates[k]; } }); - if ((docUpdates.startTime ?? event.startTime) > (docUpdates.endTime ?? event.endTime)) { - throw new CustomValidationError(400, "startTime must be before endTime"); + if ( + (docUpdates.startTime ?? event.startTime) > + (docUpdates.endTime ?? event.endTime) + ) { + throw new CustomValidationError( + 400, + "startTime must be before endTime", + ); } const issues: string[] = []; // Nothing to change. if (Object.keys(docUpdates).length == 0) { - return { status: "Success", message: "No data changed.", issues: issues }; + return { + status: "Success", + message: "No data changed.", + issues: issues, + }; } const friendlyNames = { - "eventName": "Name", - "eventDescription": "Description", - "startTime": "Start Time", - "endTime": "End Time", - "location": "Location" + eventName: "Name", + eventDescription: "Description", + startTime: "Start Time", + endTime: "End Time", + location: "Location", }; // Calculate diff @@ -526,14 +611,18 @@ export class EventController extends Controller { if (k.includes("Time")) { return { name: friendlyNames[k], - oldData: event[k].toLocaleString("en-US", { timeZone: "America/New_York" }), - newData: docUpdates[k]!.toLocaleString("en-US", { timeZone: "America/New_York" }) - } + oldData: event[k].toLocaleString("en-US", { + timeZone: "America/New_York", + }), + newData: docUpdates[k]!.toLocaleString("en-US", { + timeZone: "America/New_York", + }), + }; } return { name: friendlyNames[k], oldData: event[k], - newData: docUpdates[k] + newData: docUpdates[k], }; }); @@ -543,18 +632,22 @@ export class EventController extends Controller { event.set(docUpdates); await event.save(); } catch (error) { - throw new ResourceAccessError(500, "Failed to update event.") + throw new ResourceAccessError(500, "Failed to update event."); } if (docUpdates.startTime !== undefined) { // Re-schedule 2hr notification job. try { - await agendaClient.cancelJob("sendEventReminders", { eventId: eventId }); + await agendaClient.cancelJob("sendEventReminders", { + eventId: eventId, + }); await agendaClient.scheduleJobOnce({ jobName: "sendEventReminders", - runAt: new Date(docUpdates.startTime.getTime() - 1000 * 60 * 60 * 2), + runAt: new Date( + docUpdates.startTime.getTime() - 1000 * 60 * 60 * 2, + ), jobPayload: { eventId: event._id.toString() }, - options: { timezone: "America/New_York" } + options: { timezone: "America/New_York" }, }); } catch (error) { issues.push("Failed to replace notification job."); @@ -566,7 +659,7 @@ export class EventController extends Controller { return { status: "Success", message: "Successfully updated event.", - issues: issues + issues: issues, }; } @@ -574,10 +667,11 @@ export class EventController extends Controller { const inviteLink = EventController.generateEventInviteLink(event); if (event.marketingChannels.includes("slack")) { - const message = `*Important Update*\n` + - `Changes have been made to ${event.eventName}.\n` + - `Please review the new information here: ${inviteLink}`; - + const message = + `*Important Update*\n` + + `Changes have been made to ${event.eventName}.\n` + + `Please review the new information here: ${inviteLink}`; + const success = await this.sendSlackMessage(message); if (!success) { issues.push("Failed to send Slack announcement."); @@ -585,10 +679,11 @@ export class EventController extends Controller { } if (event.marketingChannels.includes("discord")) { - const message = `## Update - ${event.eventName}\n` + - `Changes have been made to ${event.eventName}.\n` + - `Please review the new information here: ${inviteLink}`; - + const message = + `## Update - ${event.eventName}\n` + + `Changes have been made to ${event.eventName}.\n` + + `Please review the new information here: ${inviteLink}`; + const success = await this.sendDiscordMessage(message); if (!success) { issues.push("Failed to send Discord announcement."); @@ -603,7 +698,13 @@ export class EventController extends Controller { }; } - const order = ["Name", "Description", "Start Time", "End Time", "Location"]; + const order = [ + "Name", + "Description", + "Start Time", + "End Time", + "Location", + ]; // Sort for cleaner diff (prevents stuff like End Time showing before Start Time) diff.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name)); @@ -614,8 +715,7 @@ export class EventController extends Controller { throw new Error("Failed to get recipients."); } - const emails = users.map(user => user.email); - + const emails = users.map((user) => user.email); await this.emailClient.send({ to: process.env.PEOPLEPORTAL_SMTP_USER!, @@ -626,8 +726,8 @@ export class EventController extends Controller { eventName: oldName, diff: diff, googleCalendarLink: gCalendarLink, - inviteLink: inviteLink - } + inviteLink: inviteLink, + }, }); } catch (error) { console.error("Failed to send email:", error); @@ -641,10 +741,9 @@ export class EventController extends Controller { }; } - /** * Returns rsvp data for a specific event. - * @param eventId + * @param eventId * @returns { status: string, data: HydratedDocument[] } */ @Get("{eventId}/rsvps") @@ -653,9 +752,8 @@ export class EventController extends Controller { @Tags("Event RSVPs") async getRsvps( @Path() eventId: string, - @Queries() queries: GetRsvpsRequest + @Queries() queries: GetRsvpsRequest, ): Promise { - if (queries.page < 1) { throw new CustomValidationError(400, "page must be positive."); } @@ -675,28 +773,28 @@ export class EventController extends Controller { const [results, totalDocs] = await Promise.all([ query.exec(), - EventRsvp.countDocuments({ eventId: eventId }).lean() + EventRsvp.countDocuments({ eventId: eventId }).lean(), ]); return { status: "success", data: results, pagination: { - totalPages: Math.ceil(totalDocs / pageSize) - } + totalPages: Math.ceil(totalDocs / pageSize), + }, }; } catch (error) { console.error(`Failed to get RSVPs for event ${eventId}:`, error); throw new ResourceAccessError(500, "Failed to fetch RSVPs"); } } - + /** - * + * * Endpoint to RSVP user to an event given its id. * Will get user email through either the session, * or if the user is not yet an App Dev member, gets * email through a tempsession. - * + * * @param req Will be used to get session or tempsession * @param eventId object id of the event to rsvp to (string) * @param body Rsvp Request @@ -718,7 +816,9 @@ export class EventController extends Controller { } // Ensure email is available through session or tempsession - const email = req.session.authorizedUser?.email ?? req.session.tempsession?.user?.email; + const email = + req.session.authorizedUser?.email ?? + req.session.tempsession?.user?.email; if (email === undefined) { throw new CustomValidationError(401, "User session not found."); } @@ -736,23 +836,33 @@ export class EventController extends Controller { // Check if user is in invited users if (event.scope === "internal") { authorized = true; - } - else if (event.scope === "exec") { + } else if (event.scope === "exec") { if (req.session.authorizedUser?.is_superuser) { authorized = true; } else { try { - const userTeams = await this.authentikClient.getRootTeamsForUsername(req.session.authorizedUser!.username); - authorized = hasAdminAuthority(userTeams.teams, req.session.authorizedUser!.groups ?? []); + const userTeams = + await this.authentikClient.getRootTeamsForUsername( + req.session.authorizedUser!.username, + ); + authorized = hasAdminAuthority( + userTeams.teams, + req.session.authorizedUser!.groups ?? [], + ); } catch (error) { - throw new ResourceAccessError(500, "Failed to get team membership."); + throw new ResourceAccessError( + 500, + "Failed to get team membership.", + ); } } } - if (!authorized) { - throw new CustomValidationError(403, "Unauthorized to RSVP to event."); + throw new CustomValidationError( + 403, + "Unauthorized to RSVP to event.", + ); } } @@ -794,31 +904,32 @@ export class EventController extends Controller { action: status, eventName: event.eventName, eventDescription: event.eventDescription, - startTime: event.startTime.toLocaleString("en-US", { timeZone: "America/New_York" }), - endTime: event.endTime.toLocaleString("en-US", { timeZone: "America/New_York" }), + startTime: event.startTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), + endTime: event.endTime.toLocaleString("en-US", { + timeZone: "America/New_York", + }), eventLocation: event.location, googleCalendarLink: gCalendarLink, - } + }, }); } catch (error) { console.error("Failed to send confirmation email for RSVP."); issues.push("Failed to send confirmation email."); } - - return { + return { status: "Success", message: "Successfully RSVP'd to event.", - issues: issues + issues: issues, }; - } - /** * Checks if current user has RSVP'd to the given event. * Returns rsvp data if user has RSVP'd, and null otherwise. - * + * * @param eventId id of the event. * @param req express Request object * @returns { status: string, rsvpData: DocumentJSON | null } @@ -837,31 +948,40 @@ export class EventController extends Controller { if (!event) { throw new CustomValidationError(404, `No event with id ${eventId}`); } - + // Ensure email is available through session or tempsession - const email = req.session.authorizedUser?.email ?? req.session.tempsession?.user?.email; + const email = + req.session.authorizedUser?.email ?? + req.session.tempsession?.user?.email; if (email === undefined) { throw new CustomValidationError(401, "User session not found."); } - const rsvp = await EventRsvp.findOne({ eventId: eventId, email: email }).lean().exec(); + const rsvp = await EventRsvp.findOne({ eventId: eventId, email: email }) + .lean() + .exec(); if (!rsvp) { return { status: "Success", rsvp: null, }; } - + return { status: "Success", rsvp: rsvp, }; } - private async sendSlackMessage(message: string) : Promise { - const slackChannel = await this.slackClient.getChannelFromName(EventController.SLACK_EVENT_ANNOUNCEMENT_CHANNEL); + private async sendSlackMessage(message: string): Promise { + const slackChannel = await this.slackClient.getChannelFromName( + EventController.SLACK_EVENT_ANNOUNCEMENT_CHANNEL, + ); if (slackChannel?.id !== undefined) { - const success = await this.slackClient.sendMessageInChannel(slackChannel.id!, message); + const success = await this.slackClient.sendMessageInChannel( + slackChannel.id!, + message, + ); if (!success) { console.error("Failed to send slack message."); return false; @@ -873,13 +993,18 @@ export class EventController extends Controller { } } - private async sendDiscordMessage(message: string) : Promise { + private async sendDiscordMessage(message: string): Promise { try { - const discordChannel = await this.discordClient.getChannelFromName(EventController.DISCORD_EVENT_ANNOUNCEMENT_CHANNEL); + const discordChannel = await this.discordClient.getChannelFromName( + EventController.DISCORD_EVENT_ANNOUNCEMENT_CHANNEL, + ); if (discordChannel !== undefined) { - await this.discordClient.sendMessageInChannel(discordChannel, message); + await this.discordClient.sendMessageInChannel( + discordChannel, + message, + ); } else { - throw new Error("Could not find announcements channel.") + throw new Error("Could not find announcements channel."); } return true; } catch (error) { @@ -891,19 +1016,20 @@ export class EventController extends Controller { /** * Generates a link accessible on the frontend allowing users * to RSVP to events. - * + * * @param event Mongoose Event Document * @returns Generated link : string */ - public static generateEventInviteLink(event: HydratedDocument | DocumentJSON) { + public static generateEventInviteLink( + event: HydratedDocument | DocumentJSON, + ) { return `${process.env.PEOPLEPORTAL_BASE_URL}/community/events/${event._id}/attendance`; } - /** * Private helper method to get deduplicated list of user info from * event scope. - * + * * @param scope scope of event. * @returns Deduplicated array of user info for all invitees. */ @@ -912,28 +1038,33 @@ export class EventController extends Controller { // Return all internal members const allUsers = await this.authentikClient.getFullUserList(); return allUsers.users; - } - else if (scope === "exec") { + } else if (scope === "exec") { /* ExecutiveBoardMembers only: an exec event is exec board business. Mirrors the hasAdminAuthority gate on visibility. */ - const execGroupId = await this.authentikClient.getGroupPkFromName("ExecutiveBoardMembers"); - const groupInfo = await this.authentikClient.getGroupInfo(execGroupId); - + const execGroupId = await this.authentikClient.getGroupPkFromName( + "ExecutiveBoardMembers", + ); + const groupInfo = + await this.authentikClient.getGroupInfo(execGroupId); + return groupInfo.users; } - } - + } - static generateCalendarLink(event: HydratedDocument | DocumentJSON): string { + static generateCalendarLink( + event: HydratedDocument | DocumentJSON, + ): string { // Helper to format dates to Google/ICS standard: YYYYMMDDTHHMMSSZ const formatToUniversalTime = (date: Date) => { - return date.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z"; }; const timeChunk = `${formatToUniversalTime(event.startTime)}/${formatToUniversalTime(event.endTime)}`; // Link for google calendar. - const googleLink = new URL("https://calendar.google.com/calendar/render"); + const googleLink = new URL( + "https://calendar.google.com/calendar/render", + ); googleLink.searchParams.append("action", "TEMPLATE"); googleLink.searchParams.append("text", event.eventName); googleLink.searchParams.append("dates", timeChunk); @@ -943,4 +1074,4 @@ export class EventController extends Controller { return googleLink.toString(); } -} \ No newline at end of file +} diff --git a/peopleportal/server/src/controllers/HooksController.ts b/peopleportal/server/src/controllers/HooksController.ts index ba67008..0f1a2d1 100644 --- a/peopleportal/server/src/controllers/HooksController.ts +++ b/peopleportal/server/src/controllers/HooksController.ts @@ -16,91 +16,125 @@ along with this program. If not, see . */ -import { Route, Controller, Get, SuccessResponse, Post, Body, Tags } from "tsoa"; -import { GiteaHookCommitTrigger, GiteaHookRepositoryTrigger } from "../clients/GiteaClient/models"; +import { + Route, + Controller, + Get, + SuccessResponse, + Post, + Body, + Tags, +} from "tsoa"; +import { + GiteaHookCommitTrigger, + GiteaHookRepositoryTrigger, +} from "../clients/GiteaClient/models"; import { GiteaClient } from "../clients/GiteaClient"; import { AuthentikClient } from "../clients/AuthentikClient"; @Route("/api/webhook") export class HooksController extends Controller { - private readonly giteaClient: GiteaClient - private readonly authentikClient: AuthentikClient - - constructor() { - super() - this.giteaClient = new GiteaClient() - this.authentikClient = new AuthentikClient() - } - - @Post("git/repoevent") - @Tags("Git Web Hooks") - @SuccessResponse(200) - async processGitRepoEventHook(@Body() repoEvent: GiteaHookRepositoryTrigger) { - switch (repoEvent.action) { - case "created": - return this.handleRepoCreation(repoEvent) - - default: - return "OK" + private readonly giteaClient: GiteaClient; + private readonly authentikClient: AuthentikClient; + + constructor() { + super(); + this.giteaClient = new GiteaClient(); + this.authentikClient = new AuthentikClient(); } - } - - @Post("git/commitevent") - @Tags("Git Web Hooks") - @SuccessResponse(200) - async processGitMainCommitEventHook(@Body() commitEvent: GiteaHookCommitTrigger) { - if ( - commitEvent.ref !== GiteaClient.GIT_REF_MAINBRANCH || - commitEvent.before !== GiteaClient.GIT_NULL_COMMITID + + @Post("git/repoevent") + @Tags("Git Web Hooks") + @SuccessResponse(200) + async processGitRepoEventHook( + @Body() repoEvent: GiteaHookRepositoryTrigger, ) { - /* Not Main Branch or First Commit */ - return "OK"; + switch (repoEvent.action) { + case "created": + return this.handleRepoCreation(repoEvent); + + default: + return "OK"; + } } - try { - const ownerName = commitEvent.repository.owner.username; - const repository = await this.giteaClient.getRepository(ownerName, commitEvent.repository.name); - if (repository.id !== commitEvent.repository.id || - repository.owner.username !== ownerName) { - throw new Error("Repository does not match webhook event"); - } - - const teamPk = await this.authentikClient.getGroupPkFromName(ownerName) - const teamInfo = await this.authentikClient.getGroupInfo(teamPk) - - /* Apply Branch Protections now that main exists */ - await this.giteaClient.handleBranchProtectionSync(teamInfo, [repository]) - } catch (e: any) { - /* Potential Group Not Found Error! We're good, not mission critical. */ - console.error(`Failed applying branch protection for ${commitEvent.repository.full_name}: ${e.message}`); + @Post("git/commitevent") + @Tags("Git Web Hooks") + @SuccessResponse(200) + async processGitMainCommitEventHook( + @Body() commitEvent: GiteaHookCommitTrigger, + ) { + if ( + commitEvent.ref !== GiteaClient.GIT_REF_MAINBRANCH || + commitEvent.before !== GiteaClient.GIT_NULL_COMMITID + ) { + /* Not Main Branch or First Commit */ + return "OK"; + } + + try { + const ownerName = commitEvent.repository.owner.username; + const repository = await this.giteaClient.getRepository( + ownerName, + commitEvent.repository.name, + ); + if ( + repository.id !== commitEvent.repository.id || + repository.owner.username !== ownerName + ) { + throw new Error("Repository does not match webhook event"); + } + + const teamPk = + await this.authentikClient.getGroupPkFromName(ownerName); + const teamInfo = await this.authentikClient.getGroupInfo(teamPk); + + /* Apply Branch Protections now that main exists */ + await this.giteaClient.handleBranchProtectionSync(teamInfo, [ + repository, + ]); + } catch (e: any) { + /* Potential Group Not Found Error! We're good, not mission critical. */ + console.error( + `Failed applying branch protection for ${commitEvent.repository.full_name}: ${e.message}`, + ); + } + + return "OK"; } - return "OK"; - } - - private async handleRepoCreation(repoEvent: GiteaHookRepositoryTrigger) { - try { - const organizationName = repoEvent.organization.username; - if (repoEvent.repository.owner.username !== organizationName) { - throw new Error("Repository owner does not match webhook organization"); - } - - /* Resolve both resources authoritatively before applying privileged changes. */ - await this.authentikClient.getGroupPkFromName(organizationName); - const repository = await this.giteaClient.getRepository( - organizationName, - repoEvent.repository.name - ); - if (repository.id !== repoEvent.repository.id || - repository.owner.username !== organizationName) { - throw new Error("Repository does not match webhook event"); - } - - await this.giteaClient.handleRepoProvisioning({ ...repoEvent, repository }); - } catch (e: any) { - /* Processing failed; leave the repository untouched for an authorized operator to review. */ - console.error(`Failed provisioning repository ${repoEvent.repository.full_name}: ${e.message}`); - return "OK (Actions Failed)"; + private async handleRepoCreation(repoEvent: GiteaHookRepositoryTrigger) { + try { + const organizationName = repoEvent.organization.username; + if (repoEvent.repository.owner.username !== organizationName) { + throw new Error( + "Repository owner does not match webhook organization", + ); + } + + /* Resolve both resources authoritatively before applying privileged changes. */ + await this.authentikClient.getGroupPkFromName(organizationName); + const repository = await this.giteaClient.getRepository( + organizationName, + repoEvent.repository.name, + ); + if ( + repository.id !== repoEvent.repository.id || + repository.owner.username !== organizationName + ) { + throw new Error("Repository does not match webhook event"); + } + + await this.giteaClient.handleRepoProvisioning({ + ...repoEvent, + repository, + }); + } catch (e: any) { + /* Processing failed; leave the repository untouched for an authorized operator to review. */ + console.error( + `Failed provisioning repository ${repoEvent.repository.full_name}: ${e.message}`, + ); + return "OK (Actions Failed)"; + } } - } } diff --git a/peopleportal/server/src/controllers/MeetingsController.ts b/peopleportal/server/src/controllers/MeetingsController.ts index efcc064..f36b405 100644 --- a/peopleportal/server/src/controllers/MeetingsController.ts +++ b/peopleportal/server/src/controllers/MeetingsController.ts @@ -154,7 +154,11 @@ export interface APITeamMeetingResponse { } /** Generates weekly occurrence start/end pairs from `start`/`end` through `until` (inclusive). */ -function generateWeeklyOccurrences(start: Date, end: Date, until: Date): Array<{ start: Date; end: Date }> { +function generateWeeklyOccurrences( + start: Date, + end: Date, + until: Date, +): Array<{ start: Date; end: Date }> { const occurrences: Array<{ start: Date; end: Date }> = []; const cursorStart = new Date(start); const cursorEnd = new Date(end); @@ -164,7 +168,10 @@ function generateWeeklyOccurrences(start: Date, end: Date, until: Date): Array<{ untilEnd.setHours(23, 59, 59, 999); while (cursorStart.getTime() <= untilEnd.getTime()) { - occurrences.push({ start: new Date(cursorStart), end: new Date(cursorEnd) }); + occurrences.push({ + start: new Date(cursorStart), + end: new Date(cursorEnd), + }); /* setDate preserves wall-clock time across DST boundaries */ cursorStart.setDate(cursorStart.getDate() + 7); cursorEnd.setDate(cursorEnd.getDate() + 7); @@ -173,7 +180,6 @@ function generateWeeklyOccurrences(start: Date, end: Date, until: Date): Array<{ return occurrences; } - @Route("/api/org/teams") export class MeetingsController extends Controller { private readonly authentikClient = new AuthentikClient(); @@ -184,7 +190,10 @@ export class MeetingsController extends Controller { * all need the same group tree; without this each would re-fan-out 1+N * sequential Authentik calls. */ - private readonly groupInfoCache = new Map>(); + private readonly groupInfoCache = new Map< + string, + ReturnType + >(); private getGroupInfoCached(groupPk: string) { let pending = this.groupInfoCache.get(groupPk); @@ -199,20 +208,38 @@ export class MeetingsController extends Controller { * Flattens a team into its full member roster: the team's own users (owners) * plus everyone in its subteams, de-duplicated by Authentik user PK. */ - private async getTeamRoster(teamId: string): Promise> { + private async getTeamRoster( + teamId: string, + ): Promise> { const teamInfo = await this.getGroupInfoCached(teamId); const members = new Map(); /* Authentik user PKs arrive as strings here but are numeric everywhere attendance is stored (matching authorizedUser.pk); normalize to number. */ - const add = (u: { pk: string | number; name: string; username: string; email: string } | undefined) => { + const add = ( + u: + | { + pk: string | number; + name: string; + username: string; + email: string; + } + | undefined, + ) => { if (!u) return; const pk = Number(u.pk); if (!members.has(pk)) { - members.set(pk, { pk, name: u.name, username: u.username, email: u.email }); + members.set(pk, { + pk, + name: u.name, + username: u.username, + email: u.email, + }); } }; teamInfo.users.forEach(add); - teamInfo.subteams?.forEach((subteam: any) => (subteam.users ?? []).forEach(add)); + teamInfo.subteams?.forEach((subteam: any) => + (subteam.users ?? []).forEach(add), + ); return members; } @@ -221,15 +248,24 @@ export class MeetingsController extends Controller { * Subteam invites store only this PK, so membership resolved here always * reflects the subteam's present composition. */ - private async getSubteamDirectory(teamId: string): Promise }>> { + private async getSubteamDirectory( + teamId: string, + ): Promise< + Map }> + > { const teamInfo = await this.getGroupInfoCached(teamId); - const directory = new Map }>(); + const directory = new Map< + string, + { pk: string; name: string; memberPks: Set } + >(); for (const subteam of (teamInfo.subteams ?? []) as any[]) { if (subteam.attributes?.flaggedForDeletion) continue; directory.set(String(subteam.pk), { - pk: String(subteam.pk), - name: subteam.attributes?.friendlyName ?? subteam.name, - memberPks: new Set((subteam.users ?? []).map((u: any) => Number(u.pk))), + pk: String(subteam.pk), + name: subteam.attributes?.friendlyName ?? subteam.name, + memberPks: new Set( + (subteam.users ?? []).map((u: any) => Number(u.pk)), + ), }); } return directory; @@ -251,14 +287,22 @@ export class MeetingsController extends Controller { const viewerPk = req.session.authorizedUser!.pk; const [inviteCount, ownRow, subteamInvites] = await Promise.all([ MeetingAttendance.countDocuments({ meetingId }), - MeetingAttendance.findOne({ meetingId, userPk: viewerPk }).select("_id").lean(), - MeetingSubteamAttendance.find({ meetingId }).select("subteamPk").lean(), + MeetingAttendance.findOne({ meetingId, userPk: viewerPk }) + .select("_id") + .lean(), + MeetingSubteamAttendance.find({ meetingId }) + .select("subteamPk") + .lean(), ]); if (ownRow) return true; if (inviteCount === 0 && subteamInvites.length === 0) return true; if (subteamInvites.length > 0) { const directory = await this.getSubteamDirectory(teamId); - if (subteamInvites.some((invite) => directory.get(invite.subteamPk)?.memberPks.has(viewerPk))) { + if ( + subteamInvites.some((invite) => + directory.get(invite.subteamPk)?.memberPks.has(viewerPk), + ) + ) { return true; } } @@ -275,9 +319,13 @@ export class MeetingsController extends Controller { directory: Map }>, userPk: number, ): AttendanceRole | null { - const covering = invites.filter((invite) => directory.get(invite.subteamPk)?.memberPks.has(userPk)); + const covering = invites.filter((invite) => + directory.get(invite.subteamPk)?.memberPks.has(userPk), + ); if (covering.length === 0) return null; - return covering.some((invite) => invite.role === "required") ? "required" : "optional"; + return covering.some((invite) => invite.role === "required") + ? "required" + : "optional"; } /** @@ -285,7 +333,10 @@ export class MeetingsController extends Controller { * (Team Owner, holder of the `corp:meetingsmgmt` bindle, or a superuser). * Used by endpoints that allow either a manager or the attendee themselves. */ - private async canManageMeetings(req: express.Request, teamId: string): Promise { + private async canManageMeetings( + req: express.Request, + teamId: string, + ): Promise { const user = req.session.authorizedUser!; if (user.is_superuser) return true; @@ -294,20 +345,30 @@ export class MeetingsController extends Controller { verified by @Security("oidc"), so skip the redundant check. */ try { if (await executiveAuthVerify(req, [], true)) return true; - } catch { /* not an executive — fall through to owner/bindle checks */ } + } catch { + /* not an executive — fall through to owner/bindle checks */ + } const teamInfo = await this.getGroupInfoCached(teamId); /* Owner check mirrors the bindle auth layer: ownership lives on the root team. */ let authoritativeTeam = teamInfo; if (teamInfo.parentPk) { - authoritativeTeam = await this.getGroupInfoCached(teamInfo.parentPk); + authoritativeTeam = await this.getGroupInfoCached( + teamInfo.parentPk, + ); } - if (authoritativeTeam.attributes.peoplePortalCreation && new Set(user.groups).has(authoritativeTeam.name)) { + if ( + authoritativeTeam.attributes.peoplePortalCreation && + new Set(user.groups).has(authoritativeTeam.name) + ) { return true; } - const effective = BindleController.getEffectivePermissionSet(teamInfo, user.groups); + const effective = BindleController.getEffectivePermissionSet( + teamInfo, + user.groups, + ); return effective.has("corp:meetingsmgmt"); } @@ -342,40 +403,61 @@ export class MeetingsController extends Controller { filter.start = range; } - let meetings = await TeamMeeting - .find(filter) + let meetings = await TeamMeeting.find(filter) .sort({ start: 1 }) .lean() .exec(); - if (meetings.length > 0 && !(await this.canManageMeetings(req, teamId))) { + if ( + meetings.length > 0 && + !(await this.canManageMeetings(req, teamId)) + ) { const viewerPk = req.session.authorizedUser!.pk; const ids = meetings.map((m) => String(m._id)); const [invitedIds, viewerRows, subteamInvites] = await Promise.all([ - MeetingAttendance.distinct("meetingId", { meetingId: { $in: ids } }), - MeetingAttendance.find({ meetingId: { $in: ids }, userPk: viewerPk }).select("meetingId").lean(), - MeetingSubteamAttendance.find({ meetingId: { $in: ids } }).select("meetingId subteamPk").lean(), + MeetingAttendance.distinct("meetingId", { + meetingId: { $in: ids }, + }), + MeetingAttendance.find({ + meetingId: { $in: ids }, + userPk: viewerPk, + }) + .select("meetingId") + .lean(), + MeetingSubteamAttendance.find({ meetingId: { $in: ids } }) + .select("meetingId subteamPk") + .lean(), ]); /* A meeting with any invite (person or subteam) is targeted, not general. */ const hasInvites = new Set(invitedIds as string[]); - subteamInvites.forEach((invite) => hasInvites.add(invite.meetingId)); + subteamInvites.forEach((invite) => + hasInvites.add(invite.meetingId), + ); - const invitedDirectly = new Set(viewerRows.map((row) => row.meetingId)); + const invitedDirectly = new Set( + viewerRows.map((row) => row.meetingId), + ); const directory = await this.getSubteamDirectory(teamId); const mySubteams = new Set( - [...directory.values()].filter((s) => s.memberPks.has(viewerPk)).map((s) => s.pk), + [...directory.values()] + .filter((s) => s.memberPks.has(viewerPk)) + .map((s) => s.pk), ); const invitedViaSubteam = new Set( - subteamInvites.filter((invite) => mySubteams.has(invite.subteamPk)).map((invite) => invite.meetingId), + subteamInvites + .filter((invite) => mySubteams.has(invite.subteamPk)) + .map((invite) => invite.meetingId), ); meetings = meetings.filter((m) => { const id = String(m._id); - return m.visibleToAll - || !hasInvites.has(id) - || invitedDirectly.has(id) - || invitedViaSubteam.has(id); + return ( + m.visibleToAll || + !hasInvites.has(id) || + invitedDirectly.has(id) || + invitedViaSubteam.has(id) + ); }); } @@ -383,7 +465,10 @@ export class MeetingsController extends Controller { than one occurrence shares its seriesId. The visible week may hold only one occurrence of a series, so the count is resolved across all of them. */ const seriesIds = [...new Set(meetings.map((m) => m.seriesId))]; - const counts = await TeamMeeting.aggregate<{ _id: string; count: number }>([ + const counts = await TeamMeeting.aggregate<{ + _id: string; + count: number; + }>([ { $match: { seriesId: { $in: seriesIds } } }, { $group: { _id: "$seriesId", count: { $sum: 1 } } }, ]); @@ -415,7 +500,10 @@ export class MeetingsController extends Controller { const start = new Date(body.start); const end = new Date(body.end); if (start.getTime() >= end.getTime()) { - throw new CustomValidationError(400, "Meeting start time must be before end time"); + throw new CustomValidationError( + 400, + "Meeting start time must be before end time", + ); } let occurrences: Array<{ start: Date; end: Date }>; @@ -424,11 +512,17 @@ export class MeetingsController extends Controller { const teamInfo = await this.getGroupInfoCached(teamId); const teamEndDate = teamInfo.attributes.teamEndDate; if (!teamEndDate) { - throw new CustomValidationError(400, "Team has no end date set; cannot create a recurring meeting"); + throw new CustomValidationError( + 400, + "Team has no end date set; cannot create a recurring meeting", + ); } const until = new Date(`${teamEndDate}T23:59:59`); if (until.getTime() < start.getTime()) { - throw new CustomValidationError(400, "Team end date is before the meeting start"); + throw new CustomValidationError( + 400, + "Team end date is before the meeting start", + ); } occurrences = generateWeeklyOccurrences(start, end, until); } else { @@ -437,13 +531,13 @@ export class MeetingsController extends Controller { const seriesId = randomUUID(); const docs = occurrences.map((occurrence) => ({ - teamPk: teamId, + teamPk: teamId, seriesId, - name: body.name, - description: body.description ?? "", - start: occurrence.start, - end: occurrence.end, - createdBy: req.session.authorizedUser!.pk, + name: body.name, + description: body.description ?? "", + start: occurrence.start, + end: occurrence.end, + createdBy: req.session.authorizedUser!.pk, visibleToAll: body.visibleToAll ?? false, })); @@ -453,21 +547,29 @@ export class MeetingsController extends Controller { copy of the required/optional roster. A person listed as both required and optional is kept as required. */ const required = new Set(body.requiredAttendees ?? []); - const optional = new Set((body.optionalAttendees ?? []).filter((pk) => !required.has(pk))); + const optional = new Set( + (body.optionalAttendees ?? []).filter((pk) => !required.has(pk)), + ); const roster: Array<{ userPk: number; role: AttendanceRole }> = [ - ...[...required].map((userPk) => ({ userPk, role: "required" as const })), - ...[...optional].map((userPk) => ({ userPk, role: "optional" as const })), + ...[...required].map((userPk) => ({ + userPk, + role: "required" as const, + })), + ...[...optional].map((userPk) => ({ + userPk, + role: "optional" as const, + })), ]; if (roster.length > 0) { const attendanceDocs = created.flatMap((meeting) => roster.map((entry) => ({ meetingId: String(meeting._id), - teamPk: teamId, + teamPk: teamId, seriesId, - userPk: entry.userPk, - role: entry.role, - present: false, + userPk: entry.userPk, + role: entry.role, + present: false, })), ); await MeetingAttendance.insertMany(attendanceDocs); @@ -476,22 +578,40 @@ export class MeetingsController extends Controller { /* Subteam invites are stored by group PK only — membership is resolved at read time so the invite follows the subteam as it grows/shrinks. Unknown PKs are dropped; a subteam listed as both is kept required. */ - if ((body.requiredSubteams?.length ?? 0) > 0 || (body.optionalSubteams?.length ?? 0) > 0) { + if ( + (body.requiredSubteams?.length ?? 0) > 0 || + (body.optionalSubteams?.length ?? 0) > 0 + ) { const directory = await this.getSubteamDirectory(teamId); - const requiredSubs = new Set((body.requiredSubteams ?? []).filter((pk) => directory.has(pk))); - const optionalSubs = new Set((body.optionalSubteams ?? []).filter((pk) => directory.has(pk) && !requiredSubs.has(pk))); - const subteamRoster: Array<{ subteamPk: string; role: AttendanceRole }> = [ - ...[...requiredSubs].map((subteamPk) => ({ subteamPk, role: "required" as const })), - ...[...optionalSubs].map((subteamPk) => ({ subteamPk, role: "optional" as const })), + const requiredSubs = new Set( + (body.requiredSubteams ?? []).filter((pk) => directory.has(pk)), + ); + const optionalSubs = new Set( + (body.optionalSubteams ?? []).filter( + (pk) => directory.has(pk) && !requiredSubs.has(pk), + ), + ); + const subteamRoster: Array<{ + subteamPk: string; + role: AttendanceRole; + }> = [ + ...[...requiredSubs].map((subteamPk) => ({ + subteamPk, + role: "required" as const, + })), + ...[...optionalSubs].map((subteamPk) => ({ + subteamPk, + role: "optional" as const, + })), ]; if (subteamRoster.length > 0) { const subteamDocs = created.flatMap((meeting) => subteamRoster.map((entry) => ({ meetingId: String(meeting._id), - teamPk: teamId, + teamPk: teamId, seriesId, subteamPk: entry.subteamPk, - role: entry.role, + role: entry.role, })), ); await MeetingSubteamAttendance.insertMany(subteamDocs); @@ -502,7 +622,14 @@ export class MeetingsController extends Controller { const recurring = created.length > 1; this.setStatus(201); - return created.map((doc) => ({ ...doc.toObject(), _id: doc._id.toString(), recurring }) as APITeamMeetingResponse); + return created.map( + (doc) => + ({ + ...doc.toObject(), + _id: doc._id.toString(), + recurring, + }) as APITeamMeetingResponse, + ); } /** @@ -524,16 +651,23 @@ export class MeetingsController extends Controller { @Path() meetingId: string, @Body() body: APITeamMeetingUpdateRequest, ): Promise { - const target = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }); + const target = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }); if (!target) throw new CustomValidationError(404, "Meeting not found"); - const newStart = body.start !== undefined ? new Date(body.start) : undefined; - const newEnd = body.end !== undefined ? new Date(body.end) : undefined; + const newStart = + body.start !== undefined ? new Date(body.start) : undefined; + const newEnd = body.end !== undefined ? new Date(body.end) : undefined; const effectiveStart = newStart ?? target.start; - const effectiveEnd = newEnd ?? target.end; + const effectiveEnd = newEnd ?? target.end; if (effectiveStart.getTime() >= effectiveEnd.getTime()) { - throw new CustomValidationError(400, "Meeting start time must be before end time"); + throw new CustomValidationError( + 400, + "Meeting start time must be before end time", + ); } /* A time edit moves/resizes the clicked occurrence; for "following"/"all" @@ -541,16 +675,19 @@ export class MeetingsController extends Controller { they keep their own dates. Omitted fields fall back to the target's current values, so a start-only or end-only edit still resizes correctly. */ const targetNewStart = newStart ?? target.start; - const targetNewEnd = newEnd ?? target.end; + const targetNewEnd = newEnd ?? target.end; const startDelta = targetNewStart.getTime() - target.start.getTime(); - const duration = targetNewEnd.getTime() - targetNewStart.getTime(); + const duration = targetNewEnd.getTime() - targetNewStart.getTime(); const timeChanged = newStart !== undefined || newEnd !== undefined; /* Editing never reassigns seriesId. A "this"-scope edit therefore keeps the occurrence linked to its series (standard calendar behavior: it can still be swept by a later "following"/"all" edit) rather than detaching it. */ const scope: MeetingScope = body.scope ?? "this"; - const filter: Record = { seriesId: target.seriesId, teamPk: teamId }; + const filter: Record = { + seriesId: target.seriesId, + teamPk: teamId, + }; if (scope === "this") { filter._id = target._id; } else if (scope === "following") { @@ -559,19 +696,34 @@ export class MeetingsController extends Controller { const occurrences = await TeamMeeting.find(filter); for (const occurrence of occurrences) { - if (body.name !== undefined) occurrence.name = body.name; - if (body.description !== undefined) occurrence.description = body.description; - if (body.visibleToAll !== undefined) occurrence.visibleToAll = body.visibleToAll; + if (body.name !== undefined) occurrence.name = body.name; + if (body.description !== undefined) + occurrence.description = body.description; + if (body.visibleToAll !== undefined) + occurrence.visibleToAll = body.visibleToAll; if (timeChanged) { - occurrence.start = new Date(occurrence.start.getTime() + startDelta); - occurrence.end = new Date(occurrence.start.getTime() + duration); + occurrence.start = new Date( + occurrence.start.getTime() + startDelta, + ); + occurrence.end = new Date( + occurrence.start.getTime() + duration, + ); } await occurrence.save(); } /* Series membership is unchanged by an edit, so a single count suffices. */ - const recurring = await TeamMeeting.countDocuments({ seriesId: target.seriesId }) > 1; - return occurrences.map((occurrence) => ({ ...occurrence.toObject(), _id: occurrence._id.toString(), recurring }) as APITeamMeetingResponse); + const recurring = + (await TeamMeeting.countDocuments({ seriesId: target.seriesId })) > + 1; + return occurrences.map( + (occurrence) => + ({ + ...occurrence.toObject(), + _id: occurrence._id.toString(), + recurring, + }) as APITeamMeetingResponse, + ); } /** @@ -592,11 +744,17 @@ export class MeetingsController extends Controller { @Path() meetingId: string, @Query() scope?: MeetingScope, ): Promise { - const target = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }); + const target = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }); if (!target) throw new CustomValidationError(404, "Meeting not found"); const effectiveScope: MeetingScope = scope ?? "this"; - const filter: Record = { seriesId: target.seriesId, teamPk: teamId }; + const filter: Record = { + seriesId: target.seriesId, + teamPk: teamId, + }; if (effectiveScope === "this") { filter._id = target._id; } else if (effectiveScope === "following") { @@ -608,8 +766,12 @@ export class MeetingsController extends Controller { const doomedIds = doomed.map((m) => m._id.toString()); await TeamMeeting.deleteMany(filter); if (doomedIds.length > 0) { - await MeetingAttendance.deleteMany({ meetingId: { $in: doomedIds } }); - await MeetingSubteamAttendance.deleteMany({ meetingId: { $in: doomedIds } }); + await MeetingAttendance.deleteMany({ + meetingId: { $in: doomedIds }, + }); + await MeetingSubteamAttendance.deleteMany({ + meetingId: { $in: doomedIds }, + }); } } @@ -633,11 +795,19 @@ export class MeetingsController extends Controller { member of any team by walking teamId, so the roster is restricted to the team's own members and to meeting managers. */ const viewerPk = req.session.authorizedUser!.pk; - if (!roster.has(viewerPk) && !(await this.canManageMeetings(req, teamId))) { - throw new ResourceAccessError(403, "You must be a member of this team to view its roster."); + if ( + !roster.has(viewerPk) && + !(await this.canManageMeetings(req, teamId)) + ) { + throw new ResourceAccessError( + 403, + "You must be a member of this team to view its roster.", + ); } - return [...roster.values()].sort((a, b) => a.name.localeCompare(b.name)); + return [...roster.values()].sort((a, b) => + a.name.localeCompare(b.name), + ); } /** @@ -678,28 +848,42 @@ export class MeetingsController extends Controller { @Query() to?: Date, ): Promise { const viewerPk = req.session.authorizedUser!.pk; - const records = await MeetingAttendance.find({ teamPk: teamId, userPk: viewerPk }).lean(); - const byMeeting = new Map(records.map((r) => [r.meetingId, r] as const)); + const records = await MeetingAttendance.find({ + teamPk: teamId, + userPk: viewerPk, + }).lean(); + const byMeeting = new Map( + records.map((r) => [r.meetingId, r] as const), + ); /* Meetings the viewer attends through a subteam invite, without an own row yet: resolved live against current subteam membership. */ const directory = await this.getSubteamDirectory(teamId); - const mySubteamPks = [...directory.values()].filter((s) => s.memberPks.has(viewerPk)).map((s) => s.pk); + const mySubteamPks = [...directory.values()] + .filter((s) => s.memberPks.has(viewerPk)) + .map((s) => s.pk); const subteamRole = new Map(); if (mySubteamPks.length > 0) { - const invites = await MeetingSubteamAttendance - .find({ teamPk: teamId, subteamPk: { $in: mySubteamPks } }) - .lean(); + const invites = await MeetingSubteamAttendance.find({ + teamPk: teamId, + subteamPk: { $in: mySubteamPks }, + }).lean(); for (const invite of invites) { const current = subteamRole.get(invite.meetingId); - if (current !== "required") subteamRole.set(invite.meetingId, invite.role); + if (current !== "required") + subteamRole.set(invite.meetingId, invite.role); } } - const meetingIds = [...new Set([...byMeeting.keys(), ...subteamRole.keys()])]; + const meetingIds = [ + ...new Set([...byMeeting.keys(), ...subteamRole.keys()]), + ]; if (meetingIds.length === 0) return []; - const filter: Record = { _id: { $in: meetingIds }, teamPk: teamId }; + const filter: Record = { + _id: { $in: meetingIds }, + teamPk: teamId, + }; if (from || to) { const range: Record = {}; if (from) range.$gte = from; @@ -707,9 +891,14 @@ export class MeetingsController extends Controller { filter.start = range; } - const meetings = await TeamMeeting.find(filter).sort({ start: 1 }).lean(); + const meetings = await TeamMeeting.find(filter) + .sort({ start: 1 }) + .lean(); const seriesIds = [...new Set(meetings.map((m) => m.seriesId))]; - const counts = await TeamMeeting.aggregate<{ _id: string; count: number }>([ + const counts = await TeamMeeting.aggregate<{ + _id: string; + count: number; + }>([ { $match: { seriesId: { $in: seriesIds } } }, { $group: { _id: "$seriesId", count: { $sum: 1 } } }, ]); @@ -720,7 +909,10 @@ export class MeetingsController extends Controller { return { ...m, recurring: (seriesSize.get(m.seriesId) ?? 1) > 1, - role: record?.role ?? subteamRole.get(String(m._id)) ?? "optional", + role: + record?.role ?? + subteamRole.get(String(m._id)) ?? + "optional", present: record?.present ?? false, }; }) as any as APIMyAttendanceItem[]; @@ -742,14 +934,19 @@ export class MeetingsController extends Controller { @Path() teamId: string, @Path() meetingId: string, ): Promise { - const meeting = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }).lean(); + const meeting = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }).lean(); if (!meeting) throw new CustomValidationError(404, "Meeting not found"); /* Same predicate as the calendar list; 404 (not 403) so an uninvited member can't even confirm a hidden meeting exists. */ if (!(await this.canViewMeeting(req, teamId, meeting))) { throw new CustomValidationError(404, "Meeting not found"); } - const recurring = await TeamMeeting.countDocuments({ seriesId: meeting.seriesId }) > 1; + const recurring = + (await TeamMeeting.countDocuments({ seriesId: meeting.seriesId })) > + 1; return { ...meeting, recurring } as any as APITeamMeetingResponse; } @@ -772,34 +969,59 @@ export class MeetingsController extends Controller { @Path() teamId: string, @Path() meetingId: string, ): Promise { - const meeting = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }).lean(); + const meeting = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }).lean(); if (!meeting) throw new CustomValidationError(404, "Meeting not found"); /* Check-in is open for the whole calendar day of the meeting, so a code can't be scanned on a different day but timing within the day is lenient. */ const now = Date.now(); - const opensAt = new Date(meeting.start); opensAt.setHours(0, 0, 0, 0); - const closesAt = new Date(meeting.end); closesAt.setHours(23, 59, 59, 999); - if (now < opensAt.getTime()) throw new CustomValidationError(400, "Check-in for this meeting is not open yet"); - if (now > closesAt.getTime()) throw new CustomValidationError(400, "Check-in for this meeting has closed"); + const opensAt = new Date(meeting.start); + opensAt.setHours(0, 0, 0, 0); + const closesAt = new Date(meeting.end); + closesAt.setHours(23, 59, 59, 999); + if (now < opensAt.getTime()) + throw new CustomValidationError( + 400, + "Check-in for this meeting is not open yet", + ); + if (now > closesAt.getTime()) + throw new CustomValidationError( + 400, + "Check-in for this meeting has closed", + ); const viewerPk = req.session.authorizedUser!.pk; const roster = await this.getTeamRoster(teamId); /* Outsiders may not self-add. Pre-listed attendees are always allowed. */ - const existing = await MeetingAttendance.findOne({ meetingId, teamPk: teamId, userPk: viewerPk }); + const existing = await MeetingAttendance.findOne({ + meetingId, + teamPk: teamId, + userPk: viewerPk, + }); if (!existing && !roster.has(viewerPk)) { - throw new CustomValidationError(403, "You are not a member of this team"); + throw new CustomValidationError( + 403, + "You are not a member of this team", + ); } /* First check-in of a subteam invitee materializes their row with the subteam's role; anyone else lands as "optional". */ let insertRole: AttendanceRole = "optional"; if (!existing) { - const invites = await MeetingSubteamAttendance.find({ meetingId, teamPk: teamId }).lean(); + const invites = await MeetingSubteamAttendance.find({ + meetingId, + teamPk: teamId, + }).lean(); if (invites.length > 0) { const directory = await this.getSubteamDirectory(teamId); - insertRole = this.resolveSubteamRole(invites, directory, viewerPk) ?? "optional"; + insertRole = + this.resolveSubteamRole(invites, directory, viewerPk) ?? + "optional"; } } @@ -810,20 +1032,25 @@ export class MeetingsController extends Controller { { meetingId, teamPk: teamId, userPk: viewerPk }, { $setOnInsert: { seriesId: meeting.seriesId, role: insertRole }, - $set: { present: true, markedBy: viewerPk, markedAt: new Date() }, + $set: { + present: true, + markedBy: viewerPk, + markedAt: new Date(), + }, }, { new: true, upsert: true, setDefaultsOnInsert: true }, ); - if (!record) throw new CustomValidationError(500, "Failed to record attendance"); + if (!record) + throw new CustomValidationError(500, "Failed to record attendance"); return { - userPk: record.userPk, - name: roster.get(record.userPk)?.name ?? `User ${record.userPk}`, - role: record.role, - present: record.present, - markedBy: record.markedBy ?? null, - markedAt: record.markedAt ?? null, - explicit: true, + userPk: record.userPk, + name: roster.get(record.userPk)?.name ?? `User ${record.userPk}`, + role: record.role, + present: record.present, + markedBy: record.markedBy ?? null, + markedAt: record.markedAt ?? null, + explicit: true, viaSubteam: null, }; } @@ -846,7 +1073,10 @@ export class MeetingsController extends Controller { @Path() teamId: string, @Path() meetingId: string, ): Promise { - const meeting = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }).lean(); + const meeting = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }).lean(); if (!meeting) throw new CustomValidationError(404, "Meeting not found"); const [records, invites] = await Promise.all([ @@ -859,21 +1089,25 @@ export class MeetingsController extends Controller { /* Attribute each covered person to one invited subteam for display; required invites win so the label matches the effective role. */ const viaSubteam = new Map(); - const sortedInvites = [...invites].sort((a, b) => (a.role === b.role ? 0 : a.role === "required" ? -1 : 1)); + const sortedInvites = [...invites].sort((a, b) => + a.role === b.role ? 0 : a.role === "required" ? -1 : 1, + ); for (const invite of sortedInvites) { const subteam = directory.get(invite.subteamPk); if (!subteam) continue; - subteam.memberPks.forEach((pk) => { if (!viaSubteam.has(pk)) viaSubteam.set(pk, subteam.name); }); + subteam.memberPks.forEach((pk) => { + if (!viaSubteam.has(pk)) viaSubteam.set(pk, subteam.name); + }); } const attendees: APIMeetingAttendee[] = records.map((r) => ({ - userPk: r.userPk, - name: roster.get(r.userPk)?.name ?? `User ${r.userPk}`, - role: r.role, - present: r.present, - markedBy: r.markedBy ?? null, - markedAt: r.markedAt ?? null, - explicit: true, + userPk: r.userPk, + name: roster.get(r.userPk)?.name ?? `User ${r.userPk}`, + role: r.role, + present: r.present, + markedBy: r.markedBy ?? null, + markedAt: r.markedAt ?? null, + explicit: true, viaSubteam: viaSubteam.get(r.userPk) ?? null, })); @@ -883,28 +1117,34 @@ export class MeetingsController extends Controller { for (const [pk, subteamName] of viaSubteam) { if (explicitPks.has(pk)) continue; attendees.push({ - userPk: pk, - name: roster.get(pk)?.name ?? `User ${pk}`, - role: this.resolveSubteamRole(invites, directory, pk)!, - present: false, - markedBy: null, - markedAt: null, - explicit: false, + userPk: pk, + name: roster.get(pk)?.name ?? `User ${pk}`, + role: this.resolveSubteamRole(invites, directory, pk)!, + present: false, + markedBy: null, + markedAt: null, + explicit: false, viaSubteam: subteamName, }); } - attendees.sort((a, b) => (a.role === b.role ? a.name.localeCompare(b.name) : a.role === "required" ? -1 : 1)); + attendees.sort((a, b) => + a.role === b.role + ? a.name.localeCompare(b.name) + : a.role === "required" + ? -1 + : 1, + ); /* Reaching here means the bindle gate passed, so the viewer always manages. */ return { - viewerPk: req.session.authorizedUser!.pk, + viewerPk: req.session.authorizedUser!.pk, canManage: true, attendees, - subteams: invites.map((invite) => ({ + subteams: invites.map((invite) => ({ subteamPk: invite.subteamPk, - name: directory.get(invite.subteamPk)?.name ?? invite.subteamPk, - role: invite.role, + name: directory.get(invite.subteamPk)?.name ?? invite.subteamPk, + role: invite.role, })), }; } @@ -926,36 +1166,44 @@ export class MeetingsController extends Controller { @Path() meetingId: string, @Body() body: APIAddAttendeeRequest, ): Promise { - const meeting = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }).lean(); + const meeting = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }).lean(); if (!meeting) throw new CustomValidationError(404, "Meeting not found"); - const role: AttendanceRole = body.role === "optional" ? "optional" : "required"; + const role: AttendanceRole = + body.role === "optional" ? "optional" : "required"; try { await MeetingAttendance.create({ meetingId, - teamPk: teamId, + teamPk: teamId, seriesId: meeting.seriesId, - userPk: body.userPk, + userPk: body.userPk, role, - present: false, + present: false, }); } catch (e: any) { /* The unique (meetingId, userPk) index is the real duplicate guard — a pre-check would race with concurrent adds/check-ins. */ - if (e?.code === 11000) throw new CustomValidationError(409, "Person is already an attendee"); + if (e?.code === 11000) + throw new CustomValidationError( + 409, + "Person is already an attendee", + ); throw e; } const roster = await this.getTeamRoster(teamId); this.setStatus(201); return { - userPk: body.userPk, - name: roster.get(body.userPk)?.name ?? `User ${body.userPk}`, + userPk: body.userPk, + name: roster.get(body.userPk)?.name ?? `User ${body.userPk}`, role, - present: false, - markedBy: null, - markedAt: null, - explicit: true, + present: false, + markedBy: null, + markedAt: null, + explicit: true, viaSubteam: null, }; } @@ -979,25 +1227,34 @@ export class MeetingsController extends Controller { @Path() meetingId: string, @Body() body: APIAddSubteamRequest, ): Promise { - const meeting = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }).lean(); + const meeting = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }).lean(); if (!meeting) throw new CustomValidationError(404, "Meeting not found"); const directory = await this.getSubteamDirectory(teamId); const subteam = directory.get(body.subteamPk); - if (!subteam) throw new CustomValidationError(400, "Not a subteam of this team"); + if (!subteam) + throw new CustomValidationError(400, "Not a subteam of this team"); - const role: AttendanceRole = body.role === "optional" ? "optional" : "required"; + const role: AttendanceRole = + body.role === "optional" ? "optional" : "required"; try { await MeetingSubteamAttendance.create({ meetingId, - teamPk: teamId, - seriesId: meeting.seriesId, + teamPk: teamId, + seriesId: meeting.seriesId, subteamPk: body.subteamPk, role, }); } catch (e: any) { /* The unique (meetingId, subteamPk) index is the real duplicate guard. */ - if (e?.code === 11000) throw new CustomValidationError(409, "Subteam is already invited"); + if (e?.code === 11000) + throw new CustomValidationError( + 409, + "Subteam is already invited", + ); throw e; } @@ -1023,8 +1280,16 @@ export class MeetingsController extends Controller { @Path() meetingId: string, @Path() subteamPk: string, ): Promise { - const result = await MeetingSubteamAttendance.deleteOne({ meetingId, teamPk: teamId, subteamPk }); - if (result.deletedCount === 0) throw new CustomValidationError(404, "Subteam is not invited to this meeting"); + const result = await MeetingSubteamAttendance.deleteOne({ + meetingId, + teamPk: teamId, + subteamPk, + }); + if (result.deletedCount === 0) + throw new CustomValidationError( + 404, + "Subteam is not invited to this meeting", + ); } /** @@ -1053,7 +1318,11 @@ export class MeetingsController extends Controller { } const viewerPk = req.session.authorizedUser!.pk; - const markUpdate = { present: body.present, markedBy: viewerPk, markedAt: new Date() }; + const markUpdate = { + present: body.present, + markedBy: viewerPk, + markedAt: new Date(), + }; let record = await MeetingAttendance.findOneAndUpdate( { meetingId, teamPk: teamId, userPk }, { $set: markUpdate }, @@ -1064,12 +1333,26 @@ export class MeetingsController extends Controller { with the role their subteam invite grants. Atomic upsert: the person may check themselves in concurrently, and a plain save() would trip the unique (meetingId, userPk) index. */ - const meeting = await TeamMeeting.findOne({ _id: meetingId, teamPk: teamId }).lean(); - if (!meeting) throw new CustomValidationError(404, "Meeting not found"); - const invites = await MeetingSubteamAttendance.find({ meetingId, teamPk: teamId }).lean(); - const directory = invites.length > 0 ? await this.getSubteamDirectory(teamId) : new Map(); + const meeting = await TeamMeeting.findOne({ + _id: meetingId, + teamPk: teamId, + }).lean(); + if (!meeting) + throw new CustomValidationError(404, "Meeting not found"); + const invites = await MeetingSubteamAttendance.find({ + meetingId, + teamPk: teamId, + }).lean(); + const directory = + invites.length > 0 + ? await this.getSubteamDirectory(teamId) + : new Map(); const role = this.resolveSubteamRole(invites, directory, userPk); - if (!role) throw new CustomValidationError(404, "This person is not an attendee of the meeting"); + if (!role) + throw new CustomValidationError( + 404, + "This person is not an attendee of the meeting", + ); record = await MeetingAttendance.findOneAndUpdate( { meetingId, teamPk: teamId, userPk }, { @@ -1079,17 +1362,18 @@ export class MeetingsController extends Controller { { new: true, upsert: true, setDefaultsOnInsert: true }, ); } - if (!record) throw new CustomValidationError(500, "Failed to record attendance"); + if (!record) + throw new CustomValidationError(500, "Failed to record attendance"); const roster = await this.getTeamRoster(teamId); return { - userPk: record.userPk, - name: roster.get(record.userPk)?.name ?? `User ${record.userPk}`, - role: record.role, - present: record.present, - markedBy: record.markedBy ?? null, - markedAt: record.markedAt ?? null, - explicit: true, + userPk: record.userPk, + name: roster.get(record.userPk)?.name ?? `User ${record.userPk}`, + role: record.role, + present: record.present, + markedBy: record.markedBy ?? null, + markedAt: record.markedAt ?? null, + explicit: true, viaSubteam: null, }; } @@ -1111,7 +1395,12 @@ export class MeetingsController extends Controller { @Path() meetingId: string, @Path() userPk: number, ): Promise { - const result = await MeetingAttendance.deleteOne({ meetingId, teamPk: teamId, userPk }); - if (result.deletedCount === 0) throw new CustomValidationError(404, "Attendee not found"); + const result = await MeetingAttendance.deleteOne({ + meetingId, + teamPk: teamId, + userPk, + }); + if (result.deletedCount === 0) + throw new CustomValidationError(404, "Attendee not found"); } } diff --git a/peopleportal/server/src/controllers/OrgController.ts b/peopleportal/server/src/controllers/OrgController.ts index ed3ddc2..f622565 100644 --- a/peopleportal/server/src/controllers/OrgController.ts +++ b/peopleportal/server/src/controllers/OrgController.ts @@ -16,50 +16,119 @@ along with this program. If not, see . */ -import * as express from 'express'; -import path from 'path'; -import { Error, FlattenMaps, HydratedDocument, Types } from 'mongoose'; -import { Request, Body, Controller, Get, Patch, Path, Post, Queries, Route, SuccessResponse, Put, Security, Delete, Tags, Query } from "tsoa"; -import { AddGroupMemberRequest, GetGroupInfoResponse, GetTeamsListResponse, GetUserListOptions, GetUserListResponse, RemoveGroupMemberRequest, SeasonType, TeamType, UserInformationBrief, GetTeamsForUsernameResponse, AuthentikClientError, CreateUserRequest, ServiceSeasonType, AuthentikClientErrorType, TeamInformationDetail, GetTeamsListDetailResponse, UserInformationPartial, TeamAttributeDefinition, TeamInformationBrief, GetTeamMembershipsResponse } from "../clients/AuthentikClient/models"; +import * as express from "express"; +import path from "path"; +import { Error, FlattenMaps, HydratedDocument, Types } from "mongoose"; +import { + Request, + Body, + Controller, + Get, + Patch, + Path, + Post, + Queries, + Route, + SuccessResponse, + Put, + Security, + Delete, + Tags, + Query, +} from "tsoa"; +import { + AddGroupMemberRequest, + GetGroupInfoResponse, + GetTeamsListResponse, + GetUserListOptions, + GetUserListResponse, + RemoveGroupMemberRequest, + SeasonType, + TeamType, + UserInformationBrief, + GetTeamsForUsernameResponse, + AuthentikClientError, + CreateUserRequest, + ServiceSeasonType, + AuthentikClientErrorType, + TeamInformationDetail, + GetTeamsListDetailResponse, + UserInformationPartial, + TeamAttributeDefinition, + TeamInformationBrief, + GetTeamMembershipsResponse, +} from "../clients/AuthentikClient/models"; import { AuthentikClient } from "../clients/AuthentikClient"; import { Invite } from "../models/Invites"; import { EmailClient } from "../clients/EmailClient"; -import { SharedResourceClient } from '../clients'; -import { ENABLED_SHARED_RESOURCES, ENABLED_TEAMSETTING_RESOURCES, ENABLED_SERVICE_TEAMS, TEAM_TYPE_CONFIGS } from '../config'; -import { SlackClient } from '../clients/SlackClient'; -import { AWSClient } from '../clients/AWSClient'; -import { checkPhotoHasFace } from '../clients/PhotoCheckClient/PhotoCheckClient'; -import { s3Client, BUCKET_NAME } from '../clients/AWSClient/S3Client'; -import { GetObjectCommand, PutObjectCommand, CopyObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { SharedResourceClient } from "../clients"; +import { + ENABLED_SHARED_RESOURCES, + ENABLED_TEAMSETTING_RESOURCES, + ENABLED_SERVICE_TEAMS, + TEAM_TYPE_CONFIGS, +} from "../config"; +import { SlackClient } from "../clients/SlackClient"; +import { AWSClient } from "../clients/AWSClient"; +import { checkPhotoHasFace } from "../clients/PhotoCheckClient/PhotoCheckClient"; +import { s3Client, BUCKET_NAME } from "../clients/AWSClient/S3Client"; +import { + GetObjectCommand, + PutObjectCommand, + CopyObjectCommand, + DeleteObjectCommand, +} from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; -import { sanitizeUserFullName, validateTeamName, capitalizeString, validatePersonName } from '../utils/strings'; -import { BindleController, EnabledBindlePermissions } from '../controllers/BindleController'; -import { AuthorizedUser } from '../clients/OpenIdClient'; -import { executiveAuthVerify } from '../auth'; -import { TeamCreationRequest, TeamCreationRequestStatus, ITeamCreationRequest } from '../models/TeamCreationRequest'; -import { CustomValidationError, ResourceAccessError, SharedResourcesError, describeUnknownError } from '../utils/errors'; -import { ExpressRequestBindleExtension } from '../types/express'; -import { validateS3FileSignature, FILE_SIGNATURES, getS3ObjectBytes, deleteTempAvatar } from '../utils/s3-validation'; -import { signAvatarUrl, invalidateAvatarUrlCache } from '../utils/avatars'; -import { normalizeLinkedInProfileUrl } from '../utils/linkedin'; +import { + sanitizeUserFullName, + validateTeamName, + capitalizeString, + validatePersonName, +} from "../utils/strings"; +import { + BindleController, + EnabledBindlePermissions, +} from "../controllers/BindleController"; +import { AuthorizedUser } from "../clients/OpenIdClient"; +import { executiveAuthVerify } from "../auth"; +import { + TeamCreationRequest, + TeamCreationRequestStatus, + ITeamCreationRequest, +} from "../models/TeamCreationRequest"; +import { + CustomValidationError, + ResourceAccessError, + SharedResourcesError, + describeUnknownError, +} from "../utils/errors"; +import { ExpressRequestBindleExtension } from "../types/express"; +import { + validateS3FileSignature, + FILE_SIGNATURES, + getS3ObjectBytes, + deleteTempAvatar, +} from "../utils/s3-validation"; +import { signAvatarUrl, invalidateAvatarUrlCache } from "../utils/avatars"; +import { normalizeLinkedInProfileUrl } from "../utils/linkedin"; import MarkdownIt from "markdown-it"; -import zxcvbn from 'zxcvbn'; -import { IUserReview, UserReview } from '../models/UserReview'; +import zxcvbn from "zxcvbn"; +import { IUserReview, UserReview } from "../models/UserReview"; import type { DocumentJSON } from "../utils/types"; export interface EnabledRootSettings { - [key: string]: boolean + [key: string]: boolean; } export interface RootTeamSettingMap { - [key: string]: RootTeamSettingInfo + [key: string]: RootTeamSettingInfo; } export interface RootTeamSettingInfo { - friendlyName: string, - description: string, + friendlyName: string; + description: string; } interface APIGetCommonTeamsResponse { @@ -87,8 +156,8 @@ interface APIGetReviewsOptions { interface APIGetReviewsResponse { reviews: DocumentJSON[]; aggregateData?: { - totalReviews: number, - averageRating: number + totalReviews: number; + averageRating: number; }; } @@ -104,42 +173,40 @@ interface APICreateReviewResponse { } /* Define Request Interfaces */ -interface APIUserInfoResponse extends UserInformationBrief { - -} +interface APIUserInfoResponse extends UserInformationBrief {} interface APICreateSubTeamRequest { - friendlyName: string, - description: string + friendlyName: string; + description: string; } export interface APICreateTeamRequest { - friendlyName: string, - teamType: TeamType, - seasonType: SeasonType, - seasonYear: number, - description: string, - teamStartDate?: string, - teamEndDate: string, - requestorRole: string + friendlyName: string; + teamType: TeamType; + seasonType: SeasonType; + seasonYear: number; + description: string; + teamStartDate?: string; + teamEndDate: string; + requestorRole: string; } interface APIUpdateTeamRequest { /** @minLength 1 */ - friendlyName?: string, + friendlyName?: string; /** @minLength 1 */ - description?: string, - teamStartDate?: string, - teamEndDate?: string, + description?: string; + teamStartDate?: string; + teamEndDate?: string; } interface APITeamInfoResponse { - team: GetGroupInfoResponse, - subteams: GetGroupInfoResponse[] + team: GetGroupInfoResponse; + subteams: GetGroupInfoResponse[]; } interface APITeamMemberAddResponse { - coreAdditionComplete: boolean, + coreAdditionComplete: boolean; } interface APITeamInviteCreateRequest { @@ -170,11 +237,11 @@ interface APITeamInviteAcceptRequest { } interface APIGetTeamsListOptions { - search?: string, - subgroupsOnly?: boolean, - includeUsers?: boolean, + search?: string; + subgroupsOnly?: boolean; + includeUsers?: boolean; /** When false (default), archived teams are excluded from the results. @default false */ - includeArchived?: boolean, + includeArchived?: boolean; /** @default 20 */ limit?: number; @@ -182,7 +249,6 @@ interface APIGetTeamsListOptions { cursor?: string; } - interface APIGetOrgChartResponse { root: OrgChartNode; } @@ -194,21 +260,20 @@ interface OrgChartNode { attributes?: { friendlyName?: string; description?: string; - role?: string; // The person's role (e.g., "President", "Developer") + role?: string; // The person's role (e.g., "President", "Developer") email?: string; - teamContext?: string[]; // Breadcrumb: ["SubteamName", "RootTeamName"] for display + teamContext?: string[]; // Breadcrumb: ["SubteamName", "RootTeamName"] for display [key: string]: any; }; children?: OrgChartNode[]; - siblings?: OrgChartNode[]; // Horizontal siblings at the same level (for frontend to render horizontally) - isPrimaryExpansion?: boolean; // If true, this person is the expansion anchor - hasChildren?: boolean; // For lazy loading indicator + siblings?: OrgChartNode[]; // Horizontal siblings at the same level (for frontend to render horizontally) + isPrimaryExpansion?: boolean; // If true, this person is the expansion anchor + hasChildren?: boolean; // For lazy loading indicator } - interface APIGetOrgChartOptions { - /** - * If true, recursively fetches all subteams and their members. + /** + * If true, recursively fetches all subteams and their members. * If false, returns only the Division Apex structure (without subteams populated). * @default true */ @@ -216,11 +281,11 @@ interface APIGetOrgChartOptions { } interface ExpressRequestAuthUserShim { - session: { authorizedUser: AuthorizedUser } + session: { authorizedUser: AuthorizedUser }; } interface ExpressRequestBindleShim { - bindle: ExpressRequestBindleExtension + bindle: ExpressRequestBindleExtension; } interface APITeamCreationRequestResponse { @@ -246,22 +311,25 @@ function isValidGradDate(value: string): boolean { @Route("/api/org") export class OrgController extends Controller { - private teamSettingList: { [key: string]: RootTeamSettingMap } = {} + private teamSettingList: { [key: string]: RootTeamSettingMap } = {}; private sharedResources: SharedResourceClient[]; private readonly authentikClient; private readonly emailClient; private readonly slackClient; constructor() { - super() - this.authentikClient = new AuthentikClient() - this.emailClient = new EmailClient() - this.slackClient = ENABLED_SHARED_RESOURCES.slackClient as SlackClient - this.sharedResources = Object.values(ENABLED_SHARED_RESOURCES) - - for (const teamSettingResource of Object.values(ENABLED_TEAMSETTING_RESOURCES)) { - const resourceName = teamSettingResource.getResourceName() - this.teamSettingList[resourceName] = teamSettingResource.getSupportedSettings() + super(); + this.authentikClient = new AuthentikClient(); + this.emailClient = new EmailClient(); + this.slackClient = ENABLED_SHARED_RESOURCES.slackClient as SlackClient; + this.sharedResources = Object.values(ENABLED_SHARED_RESOURCES); + + for (const teamSettingResource of Object.values( + ENABLED_TEAMSETTING_RESOURCES, + )) { + const resourceName = teamSettingResource.getResourceName(); + this.teamSettingList[resourceName] = + teamSettingResource.getSupportedSettings(); } } @@ -278,14 +346,17 @@ export class OrgController extends Controller { } if (parsedStart > parsedEnd) { - throw new CustomValidationError(400, "Team start date must be on or before team end date"); + throw new CustomValidationError( + 400, + "Team start date must be on or before team end date", + ); } } /** * Fetches the list of people in the organization. * Uses the Authentik Client for internal filtering. - * + * * @param options Options for searching and pagination * @returns Paginated List of People in the Organization */ @@ -293,13 +364,20 @@ export class OrgController extends Controller { @Tags("People Management") @SuccessResponse(200) @Security("oidc") - async getPeople(@Queries() options: GetUserListOptions): Promise { - const userList = await this.authentikClient.getUserList(options) + async getPeople( + @Queries() options: GetUserListOptions, + ): Promise { + const userList = await this.authentikClient.getUserList(options); /* Enrich with Avatars */ - await Promise.all(userList.users.map(async (user) => { - user.avatar = await signAvatarUrl(user.pk, user.attributes.avatar); - })); + await Promise.all( + userList.users.map(async (user) => { + user.avatar = await signAvatarUrl( + user.pk, + user.attributes.avatar, + ); + }), + ); return userList; } @@ -307,7 +385,7 @@ export class OrgController extends Controller { /** * Fetches basic user information and additional attributes set * by People Portal, given the user's primary key ID. - * + * * @param personId Internal User ID * @returns User Information */ @@ -315,19 +393,25 @@ export class OrgController extends Controller { @Tags("People Management") @SuccessResponse(200) @Security("oidc") - async getPersonInfo(@Path() personId: number): Promise { - const authentikUserInfo = await this.authentikClient.getUserInfo(personId) - authentikUserInfo.avatar = await signAvatarUrl(personId.toString(), authentikUserInfo.attributes.avatar); + async getPersonInfo( + @Path() personId: number, + ): Promise { + const authentikUserInfo = + await this.authentikClient.getUserInfo(personId); + authentikUserInfo.avatar = await signAvatarUrl( + personId.toString(), + authentikUserInfo.attributes.avatar, + ); return { - ...authentikUserInfo - } + ...authentikUserInfo, + }; } /** * Gets all common teams between the requesting user and * another person given their id. - * + * * @param req express Request object * @param personId id of the person to get common teams. * @returns APIGetCommonTeamsResponse @@ -338,30 +422,44 @@ export class OrgController extends Controller { @Security("oidc") async getCommonTeams( @Request() req: express.Request, - @Path() personId: number + @Path() personId: number, ): Promise { // Get user info - const userInfo = await this.authentikClient.getUserInfo(personId) + const userInfo = await this.authentikClient + .getUserInfo(personId) .catch((e) => { - throw new ResourceAccessError(400, `Failed to fetch user info: ${e}`) + throw new ResourceAccessError( + 400, + `Failed to fetch user info: ${e}`, + ); }); // Get common teams const authorizedUser = req.session.authorizedUser!; const [userTeams, requesterTeams] = await Promise.all([ - this.authentikClient.getRootTeamsForUsername(userInfo.username) + this.authentikClient + .getRootTeamsForUsername(userInfo.username) .catch((e) => { - throw new ResourceAccessError(400, `Failed to fetch user teams: ${e}`) + throw new ResourceAccessError( + 400, + `Failed to fetch user teams: ${e}`, + ); }), - this.authentikClient.getRootTeamsForUsername(authorizedUser.username) + this.authentikClient + .getRootTeamsForUsername(authorizedUser.username) .catch((e) => { - throw new ResourceAccessError(400, `Failed to fetch user teams: ${e}`) - }) + throw new ResourceAccessError( + 400, + `Failed to fetch user teams: ${e}`, + ); + }), ]); - const commonTeams = userTeams.teams.filter((t) => - requesterTeams.teams.find((team) => team.pk === t.pk) !== undefined + const commonTeams = userTeams.teams.filter( + (t) => + requesterTeams.teams.find((team) => team.pk === t.pk) !== + undefined, ); // Return team info @@ -371,7 +469,7 @@ export class OrgController extends Controller { /** * Gets the requestor's review (if it exists) of a user * for a certain team. - * + * * @param req express Request object * @param personId id of the target person * @param teamId id of the target team @@ -384,25 +482,34 @@ export class OrgController extends Controller { async getReview( @Request() req: express.Request, @Path() personId: number, - @Path() teamId: string + @Path() teamId: string, ): Promise { // 0. Ensure requester and user id are not equal if (req.session.authorizedUser!.pk === personId) { throw new CustomValidationError(403, "Can not access own reviews."); } // 1. Ensure user exists - const userInfo = await this.authentikClient.getUserInfo(personId) + const userInfo = await this.authentikClient + .getUserInfo(personId) .catch((e) => { - throw new ResourceAccessError(400, `Failed to fetch user info: ${e}`) + throw new ResourceAccessError( + 400, + `Failed to fetch user info: ${e}`, + ); }); // 2. Ensure user is in the target team let userTeams: GetTeamsForUsernameResponse; try { - userTeams = await this.authentikClient.getRootTeamsForUsername(userInfo.username); + userTeams = await this.authentikClient.getRootTeamsForUsername( + userInfo.username, + ); } catch (e) { console.error(e); - throw new ResourceAccessError(500, "Failed to get user team membership"); + throw new ResourceAccessError( + 500, + "Failed to get user team membership", + ); } if (userTeams.teams.findIndex((team) => team.pk === teamId) === -1) { @@ -416,7 +523,9 @@ export class OrgController extends Controller { userId: personId, creatorId: req.session.authorizedUser!.pk, teamId: teamId, - }).lean().exec(); + }) + .lean() + .exec(); return { review: review }; } catch (e) { console.error(e); @@ -424,13 +533,12 @@ export class OrgController extends Controller { } } - /** * Gets a list of reviews made on the given person. Requires * executive permissions. Options can be given to filter, * sort, and attain aggregate data such as average rating * and total review count (with the filters applied). - * + * * @param personId pk of the person. * @param options APIGetReviewsOptions * @returns APIGetReviewsResponse @@ -441,9 +549,8 @@ export class OrgController extends Controller { @Security("executive") async getReviews( @Path() personId: number, - @Queries() options: APIGetReviewsOptions + @Queries() options: APIGetReviewsOptions, ): Promise { - // Max 100 reviews returned. const limit = Math.min(100, options.limit ?? 10); @@ -451,19 +558,29 @@ export class OrgController extends Controller { throw new CustomValidationError(400, "Limit can not be negative."); } - if (options.before && options.after && options.before.getTime() < options.after.getTime()) { - throw new CustomValidationError(400, "The 'before' timestamp can not be before the 'after' timestamp."); + if ( + options.before && + options.after && + options.before.getTime() < options.after.getTime() + ) { + throw new CustomValidationError( + 400, + "The 'before' timestamp can not be before the 'after' timestamp.", + ); } // Build query const filters: Record = { userId: personId, - ...(options.before !== undefined || options.after !== undefined) && { + ...((options.before !== undefined || + options.after !== undefined) && { updatedAt: { - ...(options.before !== undefined && { $lte: options.before }), + ...(options.before !== undefined && { + $lte: options.before, + }), ...(options.after !== undefined && { $gte: options.after }), - } - }, + }, + }), ...(options.teamId !== undefined && { teamId: options.teamId }), }; @@ -482,25 +599,37 @@ export class OrgController extends Controller { $group: { _id: null, average: { $avg: "$rating" }, - count: { $sum: 1 } - } - } - ] - } - } + count: { $sum: 1 }, + }, + }, + ], + }, + }, ]).exec(); - const { average, count } = result[0]?.stats[0] ?? { average: 0, count: 0 }; - - return { reviews: [], aggregateData: { totalReviews: count, averageRating: average } }; + const { average, count } = result[0]?.stats[0] ?? { + average: 0, + count: 0, + }; + + return { + reviews: [], + aggregateData: { + totalReviews: count, + averageRating: average, + }, + }; } catch (e) { - throw new ResourceAccessError(500, `Failed to fetch reviews: ${e}`); + throw new ResourceAccessError( + 500, + `Failed to fetch reviews: ${e}`, + ); } } let query = UserReview.find(filters); if (options.sortBy !== undefined) { - const ascending = (options.ascending ?? true) ? 'asc' : 'desc'; + const ascending = (options.ascending ?? true) ? "asc" : "desc"; query = query.sort({ [options.sortBy]: ascending }); } @@ -518,21 +647,28 @@ export class OrgController extends Controller { { $match: filters }, { $facet: { - stats: [{ - $group: { - _id: null, - average: { $avg: "$rating" }, - count: { $sum: 1 } - } - }] - } - } - ]).exec() + stats: [ + { + $group: { + _id: null, + average: { $avg: "$rating" }, + count: { $sum: 1 }, + }, + }, + ], + }, + }, + ]).exec(), ]); - const { average, count } = aggregateResult[0]?.stats[0] ?? { average: 0, count: 0 }; - return { reviews: results, aggregateData: { totalReviews: count, averageRating: average } }; - + const { average, count } = aggregateResult[0]?.stats[0] ?? { + average: 0, + count: 0, + }; + return { + reviews: results, + aggregateData: { totalReviews: count, averageRating: average }, + }; } catch (e) { throw new ResourceAccessError(500, `Failed to fetch reviews: ${e}`); } @@ -542,7 +678,7 @@ export class OrgController extends Controller { * Write an internal review for a person. the requestor and person * must both be in body.teamId and the requestor must have the * corp:reviewaccess bindle in that team. - * + * * @param req express Request object * @param personId pk of the person being reviewed * @param body APICreateReviewRequest instance @@ -556,29 +692,45 @@ export class OrgController extends Controller { async writeReview( @Request() req: express.Request, @Path() personId: number, - @Body() body: APICreateReviewRequest + @Body() body: APICreateReviewRequest, ): Promise { // User refers to the personId, requester refers to the user making the request. // 0. Ensure user and requester aren't the same if (personId === req.session.authorizedUser!.pk) { - throw new CustomValidationError(403, "You can not review yourself."); + throw new CustomValidationError( + 403, + "You can not review yourself.", + ); } // 1. Ensure user exists - const userInfo = await this.authentikClient.getUserInfo(personId) - .catch((e) => { throw new ResourceAccessError(400, `Failed to fetch user info: ${e}`) }); + const userInfo = await this.authentikClient + .getUserInfo(personId) + .catch((e) => { + throw new ResourceAccessError( + 400, + `Failed to fetch user info: ${e}`, + ); + }); // 2. Ensure user is in the target team let userTeams: GetTeamsForUsernameResponse; try { - userTeams = await this.authentikClient.getRootTeamsForUsername(userInfo.username); + userTeams = await this.authentikClient.getRootTeamsForUsername( + userInfo.username, + ); } catch (e) { console.error(e); - throw new ResourceAccessError(500, "Failed to get user team membership"); + throw new ResourceAccessError( + 500, + "Failed to get user team membership", + ); } - if (userTeams.teams.findIndex((team) => team.pk === body.teamId) === -1) { + if ( + userTeams.teams.findIndex((team) => team.pk === body.teamId) === -1 + ) { // User is not in target team. throw new CustomValidationError(403, "User is not in target team."); } @@ -589,7 +741,9 @@ export class OrgController extends Controller { userId: personId, creatorId: req.session.authorizedUser!.pk, teamId: body.teamId, - }).lean().exec(); + }) + .lean() + .exec(); if (existingReview !== null) { throw new CustomValidationError(400, "Review already created."); } @@ -624,7 +778,7 @@ export class OrgController extends Controller { * Edit an existing review for a person. The requestor must * have the corp:reviewaccess bindle in the team being requested. * Only permits the review author to edit the review. - * + * * @param req express Request object * @param personId pk of the person being reviewed * @param reviewId ObjectID of the review @@ -640,16 +794,25 @@ export class OrgController extends Controller { @Request() req: express.Request, @Path() personId: number, @Path() reviewId: string, - @Body() body: APICreateReviewRequest + @Body() body: APICreateReviewRequest, ): Promise { // 0. Ensure user and requester aren't the same if (personId === req.session.authorizedUser!.pk) { - throw new CustomValidationError(403, "You can not review yourself."); + throw new CustomValidationError( + 403, + "You can not review yourself.", + ); } // 1. Get Review - const review = await UserReview.findById(reviewId).exec() - .catch((e) => { throw new ResourceAccessError(500, `Failed to fetch review: ${e}`) }); + const review = await UserReview.findById(reviewId) + .exec() + .catch((e) => { + throw new ResourceAccessError( + 500, + `Failed to fetch review: ${e}`, + ); + }); if (review === null) { throw new CustomValidationError(404, "Review does not exist"); @@ -682,9 +845,9 @@ export class OrgController extends Controller { /** * Delete an existing review for a person. - * Permites either the author or any executives to + * Permites either the author or any executives to * delete the review. - * + * * @param req express Request object * @param personId pk of the person being reviewed * @param reviewId ObjectID of the review @@ -697,11 +860,17 @@ export class OrgController extends Controller { async deleteReview( @Request() req: express.Request, @Path() personId: number, - @Path() reviewId: string + @Path() reviewId: string, ) { // 1. Get Review - const review = await UserReview.findById(reviewId).exec() - .catch((e) => { throw new ResourceAccessError(500, `Failed to fetch review: ${e}`) }); + const review = await UserReview.findById(reviewId) + .exec() + .catch((e) => { + throw new ResourceAccessError( + 500, + `Failed to fetch review: ${e}`, + ); + }); if (review === null) { throw new CustomValidationError(404, "Review does not exist"); @@ -746,7 +915,7 @@ export class OrgController extends Controller { * Updates the profile attributes of a user. Only the authenticated user * can update their own profile. Supports updating major, expected graduation * date, phone number, and avatar. - * + * * @param req Express Request Object * @param personId Internal User ID * @param body Update Request Body @@ -758,20 +927,27 @@ export class OrgController extends Controller { async updatePersonInfo( @Request() req: express.Request, @Path() personId: number, - @Body() body: { + @Body() + body: { major?: string; expectedGrad?: string; phoneNumber?: string; linkedinUrl?: string; avatarKey?: string; - } + }, ): Promise<{ success: boolean }> { - const authorizedUser = (req as any).session.authorizedUser as AuthorizedUser; - const callerInfo = await this.authentikClient.getUserInfoFromEmail(authorizedUser.email); + const authorizedUser = (req as any).session + .authorizedUser as AuthorizedUser; + const callerInfo = await this.authentikClient.getUserInfoFromEmail( + authorizedUser.email, + ); if (Number(callerInfo.pk) !== personId) { this.setStatus(403); - throw new CustomValidationError(403, "You can only update your own profile."); + throw new CustomValidationError( + 403, + "You can only update your own profile.", + ); } const updatePayload: Record = {}; @@ -779,7 +955,8 @@ export class OrgController extends Controller { if (body.major === "") { updatePayload.major = ""; } else { - if (body.major.length > 100) throw new CustomValidationError(400, "Invalid major."); + if (body.major.length > 100) + throw new CustomValidationError(400, "Invalid major."); updatePayload.major = capitalizeString(body.major); } } @@ -788,7 +965,11 @@ export class OrgController extends Controller { if (body.expectedGrad === "") { updatePayload.expectedGrad = ""; } else { - if (!isValidGradDate(body.expectedGrad)) throw new CustomValidationError(400, "Invalid graduation date."); + if (!isValidGradDate(body.expectedGrad)) + throw new CustomValidationError( + 400, + "Invalid graduation date.", + ); updatePayload.expectedGrad = body.expectedGrad; } } @@ -798,44 +979,72 @@ export class OrgController extends Controller { updatePayload.phoneNumber = ""; } else { const digits = body.phoneNumber.replace(/[\s\-().+]/g, ""); - if (!/^\d{10,15}$/.test(digits)) throw new CustomValidationError(400, "Invalid phone number."); + if (!/^\d{10,15}$/.test(digits)) + throw new CustomValidationError( + 400, + "Invalid phone number.", + ); updatePayload.phoneNumber = body.phoneNumber; } } if (body.linkedinUrl !== undefined) { - const normalizedLinkedinUrl = normalizeLinkedInProfileUrl(body.linkedinUrl); + const normalizedLinkedinUrl = normalizeLinkedInProfileUrl( + body.linkedinUrl, + ); if (normalizedLinkedinUrl === null) { - throw new CustomValidationError(400, "Please enter a valid LinkedIn profile URL."); + throw new CustomValidationError( + 400, + "Please enter a valid LinkedIn profile URL.", + ); } updatePayload.linkedinUrl = normalizedLinkedinUrl; } if (Object.keys(updatePayload).length > 0) { - await this.authentikClient.updateUserAttributes(personId, updatePayload); + await this.authentikClient.updateUserAttributes( + personId, + updatePayload, + ); } // Handle avatar update if (body.avatarKey) { const avatarKey = path.posix.normalize(body.avatarKey); const expectedPrefix = `avatars/temp/profile/${personId}/`; - if (!avatarKey.startsWith(expectedPrefix) || avatarKey.includes('..')) { + if ( + !avatarKey.startsWith(expectedPrefix) || + avatarKey.includes("..") + ) { throw new CustomValidationError(400, "Invalid avatar key"); } try { const filename = path.posix.basename(avatarKey); - const ext = filename.includes('.') ? filename.split('.').pop()?.toLowerCase() : ''; - const allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp']; + const ext = filename.includes(".") + ? filename.split(".").pop()?.toLowerCase() + : ""; + const allowedExtensions = ["png", "jpg", "jpeg", "gif", "webp"]; if (!ext || !allowedExtensions.includes(ext)) { - throw new CustomValidationError(400, "Invalid avatar file extension."); + throw new CustomValidationError( + 400, + "Invalid avatar file extension.", + ); } const newKey = `avatars/${personId}/avatar.${ext}`; // Validate magic bytes - const allowedAvatars = [FILE_SIGNATURES.PNG, FILE_SIGNATURES.JPEG, FILE_SIGNATURES.GIF, FILE_SIGNATURES.WEBP]; - const isValid = await validateS3FileSignature(avatarKey, allowedAvatars); + const allowedAvatars = [ + FILE_SIGNATURES.PNG, + FILE_SIGNATURES.JPEG, + FILE_SIGNATURES.GIF, + FILE_SIGNATURES.WEBP, + ]; + const isValid = await validateS3FileSignature( + avatarKey, + allowedAvatars, + ); if (!isValid) { await deleteTempAvatar(avatarKey); throw new CustomValidationError(400, "Invalid image file."); @@ -844,55 +1053,89 @@ export class OrgController extends Controller { const imageBytes = await getS3ObjectBytes(avatarKey); if (!imageBytes) { await deleteTempAvatar(avatarKey); - throw new CustomValidationError(400, "Upload failed, please try again."); + throw new CustomValidationError( + 400, + "Upload failed, please try again.", + ); } const faceCheckResult = await checkPhotoHasFace(imageBytes); if (faceCheckResult.passed === false) { await deleteTempAvatar(avatarKey); if (faceCheckResult.reason === "file_too_large") { - throw new CustomValidationError(400, "File is too large. Please upload an image smaller than 1 MB."); + throw new CustomValidationError( + 400, + "File is too large. Please upload an image smaller than 1 MB.", + ); } - if (faceCheckResult.reason === "image_dimensions_too_large") { - throw new CustomValidationError(400, "Image dimensions are too large. Please use an image no larger than 4096 x 4096 pixels."); + if ( + faceCheckResult.reason === "image_dimensions_too_large" + ) { + throw new CustomValidationError( + 400, + "Image dimensions are too large. Please use an image no larger than 4096 x 4096 pixels.", + ); } - throw new CustomValidationError(400, "Please upload a photo of only your face."); + throw new CustomValidationError( + 400, + "Please upload a photo of only your face.", + ); } // Capture old key before overwriting - const existingUser = await this.authentikClient.getUserInfo(personId); + const existingUser = + await this.authentikClient.getUserInfo(personId); const oldAvatarKey = existingUser.attributes.avatar; // Copy new avatar into place - await s3Client.send(new CopyObjectCommand({ - Bucket: BUCKET_NAME, - CopySource: `${BUCKET_NAME}/${avatarKey}`, - Key: newKey - })); + await s3Client.send( + new CopyObjectCommand({ + Bucket: BUCKET_NAME, + CopySource: `${BUCKET_NAME}/${avatarKey}`, + Key: newKey, + }), + ); // Commit: point DB at new avatar - await this.authentikClient.updateUserAttributes(personId, { avatar: newKey }); + await this.authentikClient.updateUserAttributes(personId, { + avatar: newKey, + }); invalidateAvatarUrlCache(personId); // Best-effort cleanup — failures here are non-fatal if (oldAvatarKey && oldAvatarKey !== newKey) { try { - await s3Client.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: oldAvatarKey })); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: oldAvatarKey, + }), + ); } catch (e) { console.error("Failed to delete old avatar", e); } } try { - await s3Client.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: avatarKey })); + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: avatarKey, + }), + ); } catch (e) { - console.error("Failed to delete temp avatar after commit", e); + console.error( + "Failed to delete temp avatar after commit", + e, + ); } - } catch (e) { if (e instanceof CustomValidationError) throw e; console.error("Failed to process avatar update", e); - throw new CustomValidationError(500, "Failed to update avatar."); + throw new CustomValidationError( + 500, + "Failed to update avatar.", + ); } } @@ -902,7 +1145,7 @@ export class OrgController extends Controller { /** * Generates a pre-signed URL for uploading a profile picture for an * authenticated user editing their own profile. - * + * * @param req Express Request Object * @param fileName Name of the file * @param contentType MIME type of the file @@ -914,21 +1157,41 @@ export class OrgController extends Controller { async getProfileAvatarUploadUrl( @Request() req: express.Request, @Query() fileName: string, - @Query() contentType: string - ): Promise<{ uploadUrl: string, key: string, fields: Record }> { - const authorizedUser = (req as any).session.authorizedUser as AuthorizedUser; - const callerInfo = await this.authentikClient.getUserInfoFromEmail(authorizedUser.email); + @Query() contentType: string, + ): Promise<{ + uploadUrl: string; + key: string; + fields: Record; + }> { + const authorizedUser = (req as any).session + .authorizedUser as AuthorizedUser; + const callerInfo = await this.authentikClient.getUserInfoFromEmail( + authorizedUser.email, + ); - const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + const allowedTypes = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + ]; if (!allowedTypes.includes(contentType)) { - throw new CustomValidationError(400, "Invalid file type. Only images are allowed."); + throw new CustomValidationError( + 400, + "Invalid file type. Only images are allowed.", + ); } const safeFileName = path.posix.basename(fileName); - const ext = safeFileName.includes('.') ? safeFileName.split('.').pop()?.toLowerCase() : ''; - const allowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp']; + const ext = safeFileName.includes(".") + ? safeFileName.split(".").pop()?.toLowerCase() + : ""; + const allowedExtensions = ["png", "jpg", "jpeg", "gif", "webp"]; if (!ext || !allowedExtensions.includes(ext)) { - throw new CustomValidationError(400, "Invalid file name. Only image extensions are allowed."); + throw new CustomValidationError( + 400, + "Invalid file name. Only image extensions are allowed.", + ); } const timestamp = Date.now(); @@ -952,12 +1215,12 @@ export class OrgController extends Controller { * Fetches the list of all People Portal teams in the organization that * the user is a member of. Uses the username query parameter for user * information. **List size is capped to 1000.** - * + * * **Code Duplication Warning:** * This API is the same as `/api/org/myteams` except that the latter uses * the session cookie to obtain the username. The latter API is still * kept and will not be deprecated considering breaking changes. - * + * * @param req Express Request Object * @param username People Portal Username * @returns Teams user is a member of @@ -966,8 +1229,11 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(200) @Security("oidc") - async getUserRootTeams(@Request() req: express.Request, @Path() username: string): Promise { - return await this.authentikClient.getRootTeamsForUsername(username) + async getUserRootTeams( + @Request() req: express.Request, + @Path() username: string, + ): Promise { + return await this.authentikClient.getRootTeamsForUsername(username); } /** @@ -977,14 +1243,18 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(200) @Security("oidc") - async getUserTeamMemberships(@Path() username: string): Promise { - return await this.authentikClient.getTeamMembershipsForUsername(username) + async getUserTeamMemberships( + @Path() username: string, + ): Promise { + return await this.authentikClient.getTeamMembershipsForUsername( + username, + ); } /** * Provides the list of available root team settings supported * by People Portal teams. - * + * * @returns Team Settings List */ @Get("teamsettings") @@ -999,7 +1269,7 @@ export class OrgController extends Controller { * Fetches the list of all People Portal teams in the organization. * API includes a Base64-encoded cursor for pagination to assist with * post fetch filtering from Authentik and infinite scrolling. - * + * * @param options Get Team List Options * @returns Cursor-Paginated List of Teams */ @@ -1007,31 +1277,51 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(200) @Security("oidc") - async getTeams(@Queries() options: APIGetTeamsListOptions): Promise { - return await this.authentikClient.getGroupsList(options) + async getTeams( + @Queries() options: APIGetTeamsListOptions, + ): Promise { + return await this.authentikClient.getGroupsList(options); } @Get("orgchart") @Tags("Team Management") @SuccessResponse(200) @Security("oidc") - async getOrgChart(@Queries() options?: APIGetOrgChartOptions): Promise { + async getOrgChart( + @Queries() options?: APIGetOrgChartOptions, + ): Promise { const shouldExpandAll = options?.expandAll ?? true; // 1. Fetch Root Team (Exec Board) let execTeam: any; try { - const pk = await this.authentikClient.getGroupPkFromName("ExecutiveBoardMembers"); - execTeam = await this.authentikClient.getGroupInfo(pk, { includeUsers: true }); + const pk = await this.authentikClient.getGroupPkFromName( + "ExecutiveBoardMembers", + ); + execTeam = await this.authentikClient.getGroupInfo(pk, { + includeUsers: true, + }); } catch { - return { root: { id: "error", name: "Exec Board Not Found", type: "ROOT_MEMBER" } }; + return { + root: { + id: "error", + name: "Exec Board Not Found", + type: "ROOT_MEMBER", + }, + }; } const execUsers = execTeam.users || []; const execTeamName = execTeam.attributes?.friendlyName || execTeam.name; if (execUsers.length === 0) { - return { root: { id: "error", name: "Exec Board Not Found2", type: "ROOT_MEMBER" } }; + return { + root: { + id: "error", + name: "Exec Board Not Found2", + type: "ROOT_MEMBER", + }, + }; } // 2. Identify President (Primary Root) @@ -1048,49 +1338,83 @@ export class OrgController extends Controller { name: presidentUser.name, type: "ROOT_MEMBER", attributes: { - role: presidentUser.attributes?.roles?.[execTeam.pk] || "President", + role: + presidentUser.attributes?.roles?.[execTeam.pk] || + "President", email: presidentUser.email, teamContext: [execTeamName], realUserPk: presidentUser.pk, - avatar: await signAvatarUrl(presidentUser.pk, presidentUser.attributes.avatar) + avatar: await signAvatarUrl( + presidentUser.pk, + presidentUser.attributes.avatar, + ), }, siblings: [], - children: [] + children: [], }; // 4. Add Other Execs as Siblings // We use Promise.all to map async operations - await Promise.all(execUsers.map(async (u: any, idx: number) => { - if (idx === presidentIndex) return; - rootNode.siblings!.push({ - id: u.pk.toString(), // Siblings are just people - name: u.name, - type: "ROOT_MEMBER", - attributes: { - role: u.attributes?.roles?.[execTeam.pk] || "Executive", - email: u.email, - teamContext: [execTeamName], - avatar: await signAvatarUrl(u.pk, u.attributes.avatar) - }, - children: [] - }); - })); + await Promise.all( + execUsers.map(async (u: any, idx: number) => { + if (idx === presidentIndex) return; + rootNode.siblings!.push({ + id: u.pk.toString(), // Siblings are just people + name: u.name, + type: "ROOT_MEMBER", + attributes: { + role: u.attributes?.roles?.[execTeam.pk] || "Executive", + email: u.email, + teamContext: [execTeamName], + avatar: await signAvatarUrl(u.pk, u.attributes.avatar), + }, + children: [], + }); + }), + ); // 5. Initialize Divisions /* Keyed by TeamType rather than string: Object.keys then yields real enum members, so the teamType comparison below is type-checked and a bad key fails to compile instead of silently matching no teams. */ const divisions: Partial> = { - [TeamType.PROJECT]: { id: "div_project", name: "Projects", type: "DIVISION", children: [], hasChildren: true }, - [TeamType.BOOTCAMP]: { id: "div_bootcamp", name: "Bootcamp", type: "DIVISION", children: [], hasChildren: true }, - [TeamType.CORPORATE]: { id: "div_corporate", name: "Corporate", type: "DIVISION", children: [], hasChildren: true }, + [TeamType.PROJECT]: { + id: "div_project", + name: "Projects", + type: "DIVISION", + children: [], + hasChildren: true, + }, + [TeamType.BOOTCAMP]: { + id: "div_bootcamp", + name: "Bootcamp", + type: "DIVISION", + children: [], + hasChildren: true, + }, + [TeamType.CORPORATE]: { + id: "div_corporate", + name: "Corporate", + type: "DIVISION", + children: [], + hasChildren: true, + }, }; let allTeamsRes: GetTeamsListDetailResponse; try { - allTeamsRes = await this.authentikClient.getGroupsListDetail({ limit: 500, includeUsers: true }); + allTeamsRes = await this.authentikClient.getGroupsListDetail({ + limit: 500, + includeUsers: true, + }); } catch (e) { - return { root: { id: "error", name: "Could not fetch groups list.", type: "ROOT_MEMBER" } }; + return { + root: { + id: "error", + name: "Could not fetch groups list.", + type: "ROOT_MEMBER", + }, + }; } const allTeams = allTeamsRes.teams; @@ -1099,85 +1423,109 @@ export class OrgController extends Controller { // We do this REGARDLESS of expandAll to ensure they are visible. // We check for "Roots" of each type. for (const type of Object.keys(divisions) as TeamType[]) { - const divRoots = allTeams.filter(t => - !t.flaggedForDeletion && - t.teamType === type && - !t.parent // Only "Root" teams of this type + const divRoots = allTeams.filter( + (t) => + !t.flaggedForDeletion && t.teamType === type && !t.parent, // Only "Root" teams of this type ); - // for (const detailedTeam of divRoots) - const nodes = await Promise.all(divRoots.map(async (detailedTeam) => { - // Fetch detailed info to get OWNERS - try { - const owners = detailedTeam.users || []; - const teamName = detailedTeam.friendlyName || detailedTeam.name; - - if (owners.length > 0) { - const primaryOwner = owners[0]!; - - // Check if this team has subteams (to set hasChildren for the Owner) - // Use the direct list from detailed info as source of truth - const hasSubteams = (detailedTeam.subteamPkList && detailedTeam.subteamPkList.length > 0) || false; - - const ownerNode: OrgChartNode = { - id: detailedTeam.pk, // USES TEAM PK so expansion fetches Team Members - name: primaryOwner.name, - type: "PERSON", - attributes: { - role: primaryOwner.attributes?.roles?.[detailedTeam.pk] || "Owner", - email: primaryOwner.email, - teamContext: [teamName], - realUserPk: primaryOwner.pk, - avatar: await signAvatarUrl(primaryOwner.pk, primaryOwner.attributes.avatar) - }, - siblings: [], - children: [], - hasChildren: hasSubteams // Lazy load indicator - }; - - // Add sibling owners - ownerNode.siblings = await Promise.all(owners.slice(1).map(async (o: any) => ({ - id: o.pk.toString(), - name: o.name, - type: "PERSON", - attributes: { - role: o.attributes?.roles?.[detailedTeam.pk] || "Co-Owner", - email: o.email, - avatar: await signAvatarUrl(o.pk, o.attributes.avatar) + // for (const detailedTeam of divRoots) + const nodes = await Promise.all( + divRoots.map(async (detailedTeam) => { + // Fetch detailed info to get OWNERS + try { + const owners = detailedTeam.users || []; + const teamName = + detailedTeam.friendlyName || detailedTeam.name; + + if (owners.length > 0) { + const primaryOwner = owners[0]!; + + // Check if this team has subteams (to set hasChildren for the Owner) + // Use the direct list from detailed info as source of truth + const hasSubteams = + (detailedTeam.subteamPkList && + detailedTeam.subteamPkList.length > 0) || + false; + + const ownerNode: OrgChartNode = { + id: detailedTeam.pk, // USES TEAM PK so expansion fetches Team Members + name: primaryOwner.name, + type: "PERSON", + attributes: { + role: + primaryOwner.attributes?.roles?.[ + detailedTeam.pk + ] || "Owner", + email: primaryOwner.email, + teamContext: [teamName], + realUserPk: primaryOwner.pk, + avatar: await signAvatarUrl( + primaryOwner.pk, + primaryOwner.attributes.avatar, + ), + }, + siblings: [], + children: [], + hasChildren: hasSubteams, // Lazy load indicator + }; + + // Add sibling owners + ownerNode.siblings = await Promise.all( + owners.slice(1).map(async (o: any) => ({ + id: o.pk.toString(), + name: o.name, + type: "PERSON", + attributes: { + role: + o.attributes?.roles?.[ + detailedTeam.pk + ] || "Co-Owner", + email: o.email, + avatar: await signAvatarUrl( + o.pk, + o.attributes.avatar, + ), + }, + })), + ); + + // If expandAll is true, populate children (Subteams) + if (shouldExpandAll && hasSubteams) { + // This part would duplicate the "getOrgChartNode" logic. + // For simplicity/robustness, we can leave it empty and let frontend lazy-load, + // OR implemented the recursive fetching here. + // Given "simplify", let's rely on the lazy load unless user REALLY wants full dump. + // User script: "we just populate them prehand...?" + // Let's populate if requested. + const subMembers = + await this.getAllSubteamMembers( + detailedTeam.pk, + teamName, + allTeams, + ); + ownerNode.children = subMembers; } - }))); - - // If expandAll is true, populate children (Subteams) - if (shouldExpandAll && hasSubteams) { - // This part would duplicate the "getOrgChartNode" logic. - // For simplicity/robustness, we can leave it empty and let frontend lazy-load, - // OR implemented the recursive fetching here. - // Given "simplify", let's rely on the lazy load unless user REALLY wants full dump. - // User script: "we just populate them prehand...?" - // Let's populate if requested. - const subMembers = await this.getAllSubteamMembers(detailedTeam.pk, teamName, allTeams); - ownerNode.children = subMembers; - } - return ownerNode; + return ownerNode; + } + } catch (e) { + // console.error(`Failed to process team ${team.name}`, e); } - } catch (e) { - // console.error(`Failed to process team ${team.name}`, e); - } - })); + }), + ); if (divisions[type]) { - divisions[type].children = nodes.filter((node) => node !== undefined); + divisions[type].children = nodes.filter( + (node) => node !== undefined, + ); } - - } - - // Attach populated divisions to Root // Only attach if they have children (Teams) - const activeDivisions = Object.values(divisions).filter(d => d.children && d.children.length > 0); + const activeDivisions = Object.values(divisions).filter( + (d) => d.children && d.children.length > 0, + ); rootNode.children = activeDivisions; rootNode.hasChildren = activeDivisions.length > 0; @@ -1188,7 +1536,7 @@ export class OrgController extends Controller { * Gets the expanded Org Chart Node for a specific person/team. * Useful for lazy loading subtrees in the Org Chart visualization. * Returns people from subteams under the given team. - * + * * @param teamId Team PK (the team whose subteam members to fetch) */ @Get("orgchart/node/{teamId}") @@ -1202,10 +1550,15 @@ export class OrgController extends Controller { // But let's keep it safe. if (teamId.startsWith("div_")) { // Re-use logic from getOrgChart to populate specifically this division - // For now return empty or implement if needed. + // For now return empty or implement if needed. // The frontend "Load Members" on a division triggers this if we returned empty children. // Since we pre-populate, this shouldn't be primary path. - return { id: teamId, name: "Division", type: "DIVISION", children: [] }; + return { + id: teamId, + name: "Division", + type: "DIVISION", + children: [], + }; } // Case B: Real Team ID (User clicked on a Team Owner) @@ -1215,7 +1568,9 @@ export class OrgController extends Controller { const safeTeamId = teamId; // It's a GUID let teamInfo; try { - teamInfo = await this.authentikClient.getGroupInfo(safeTeamId, { includeUsers: true }); + teamInfo = await this.authentikClient.getGroupInfo(safeTeamId, { + includeUsers: true, + }); } catch { // Fallback/Error return { id: safeTeamId, name: "Unknown", type: "PERSON" }; @@ -1227,19 +1582,28 @@ export class OrgController extends Controller { /* For now, allTeamsContext is unused in getAllSubteamMembers, so we don't need to make this call. */ // const allTeamsRes = await this.authentikClient.getGroupsList({ limit: 1000, includeUsers: false }); - // 3. Get Members (Subteams + Direct Members if any not owners?) + // 3. Get Members (Subteams + Direct Members if any not owners?) // Logic: "subteam members... added as reporting under" // Authentik structure: Root Team has "Owners" (Users) and "Subteams" (Groups). Users in Subteams are the members. const rootTeamName = teamInfo.attributes?.friendlyName || teamInfo.name; // const subMembers = await this.getAllSubteamMembers(safeTeamId, rootTeamName, allTeamsRes.teams); - const subMembers = await this.getAllSubteamMembers(safeTeamId, rootTeamName, []); // Removed unnecessary allTeamsRes (unused) + const subMembers = await this.getAllSubteamMembers( + safeTeamId, + rootTeamName, + [], + ); // Removed unnecessary allTeamsRes (unused) // Re-construct the Parent Node (Team Owner) to return with children // The frontend merges this result. const owners = teamInfo.users || []; - const primaryOwner = owners[0] || { name: "Unknown", pk: 0, email: "", attributes: { avatar: undefined } }; + const primaryOwner = owners[0] || { + name: "Unknown", + pk: 0, + email: "", + attributes: { avatar: undefined }, + }; return { id: safeTeamId, @@ -1254,20 +1618,30 @@ export class OrgController extends Controller { real pk separately or the UI has no way to link to them. Same reason as the other owner node. */ realUserPk: primaryOwner.pk, - avatar: await signAvatarUrl(primaryOwner.pk, primaryOwner.attributes.avatar) + avatar: await signAvatarUrl( + primaryOwner.pk, + primaryOwner.attributes.avatar, + ), }, children: subMembers, - hasChildren: subMembers.length > 0 + hasChildren: subMembers.length > 0, }; } // Helper to recursively collect all people under a team - private async getAllSubteamMembers(rootTeamId: string, rootTeamName: string, allTeamsContext: any[]): Promise { + private async getAllSubteamMembers( + rootTeamId: string, + rootTeamName: string, + allTeamsContext: any[], + ): Promise { const results: OrgChartNode[] = []; // Get full tree from Authentik (recursively fetches subteams) // Note: getGroupInfo internally recurses if we don't disable it. - const rootDetailed = await this.authentikClient.getGroupInfo(rootTeamId, { includeUsers: true }); + const rootDetailed = await this.authentikClient.getGroupInfo( + rootTeamId, + { includeUsers: true }, + ); const processSubteams = async (subteams: any[]) => { for (const sub of subteams) { @@ -1276,21 +1650,26 @@ export class OrgController extends Controller { const subName = sub.attributes?.friendlyName || sub.name; const members = sub.users || []; - await Promise.all(members.map(async (m: any) => { - results.push({ - id: m.pk.toString(), // Members are People - name: m.name, - type: "PERSON", - attributes: { - role: m.attributes?.roles?.[sub.pk] || "Member", - email: m.email, - teamContext: [rootTeamName, subName], - avatar: await signAvatarUrl(m.pk, m.attributes.avatar) - }, - children: [] - // Members are leaves in this structure - }); - })); + await Promise.all( + members.map(async (m: any) => { + results.push({ + id: m.pk.toString(), // Members are People + name: m.name, + type: "PERSON", + attributes: { + role: m.attributes?.roles?.[sub.pk] || "Member", + email: m.email, + teamContext: [rootTeamName, subName], + avatar: await signAvatarUrl( + m.pk, + m.attributes.avatar, + ), + }, + children: [], + // Members are leaves in this structure + }); + }), + ); if (sub.subteams) { await processSubteams(sub.subteams); @@ -1305,12 +1684,11 @@ export class OrgController extends Controller { return results; } - /** * Fetches the list of all People Portal teams in the organization that * the user is a member of. Uses the Request Session Cookie for user * information. **List size is capped to 1000.** - * + * * @param req Express Request Object * @returns Non-Paginated List of Teams */ @@ -1318,8 +1696,12 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(200) @Security("oidc") - async getMyTeams(@Request() req: express.Request): Promise { - return await this.authentikClient.getRootTeamsForUsername(req.session.authorizedUser!.username) + async getMyTeams( + @Request() req: express.Request, + ): Promise { + return await this.authentikClient.getRootTeamsForUsername( + req.session.authorizedUser!.username, + ); } /** @@ -1327,18 +1709,18 @@ export class OrgController extends Controller { * the user must either be a team owner or have the `corp:membermgmt` * bindle. The invitee is automatically sent an email with a unique * invite link for onboarding. - * + * * Bindle Exceptions: * - This function supports being called by the ATS Module allowing * the ATS to create invites on behalf of the requester. - * + * * - The ATS module is enforced to require the `corp:hiringaccess` bindle * to offset for the bindle exception. - * - * **Non-Standard Behavior:** We do not check if the subteam is archived - * or not. A team/subteam state can change before someone accepts an invite. + * + * **Non-Standard Behavior:** We do not check if the subteam is archived + * or not. A team/subteam state can change before someone accepts an invite. * Therefore, the check is done during onboarding. - * + * * @param req Express Request Object * @param inviteReq Invite Create Request */ @@ -1347,7 +1729,10 @@ export class OrgController extends Controller { @SuccessResponse(201) @Security("bindles", ["corp:membermgmt"]) async createInvite( - @Request() req: express.Request | ExpressRequestAuthUserShim & ExpressRequestBindleShim, + @Request() + req: + | express.Request + | (ExpressRequestAuthUserShim & ExpressRequestBindleShim), @Body() inviteReq: APITeamInviteCreateRequest, @Path("teamId") _teamId?: string, ) { @@ -1359,35 +1744,46 @@ export class OrgController extends Controller { if (!inviteReq.inviteeEmail.endsWith("@terpmail.umd.edu")) { throw new CustomValidationError( 400, - "You can only onboard people with @terpmail.umd.edu addresses!" - ) + "You can only onboard people with @terpmail.umd.edu addresses!", + ); } /* Check if External User is a part of Org */ try { /* We throw the Validation Error if the user is already in the org */ - const user = await this.authentikClient.getUserInfoFromEmail(inviteReq.inviteeEmail) + const user = await this.authentikClient.getUserInfoFromEmail( + inviteReq.inviteeEmail, + ); throw new CustomValidationError( 400, - `${user.name} is already in the organization! Please use the Existing Members feature to add them to your team.` - ) + `${user.name} is already in the organization! Please use the Existing Members feature to add them to your team.`, + ); } catch (e) { /* We Gracefully Handle All But Previous Error! */ - if (e instanceof CustomValidationError) - throw e; + if (e instanceof CustomValidationError) throw e; } /* Gather Additional Data */ const authorizedUser = req.session.authorizedUser!; - const invitorInfo = await this.authentikClient.getUserInfoFromEmail(authorizedUser.email) + const invitorInfo = await this.authentikClient.getUserInfoFromEmail( + authorizedUser.email, + ); const teamInfo = req.bindle!.teamInfo; - const destination = await this.authentikClient.getGroupInfo(inviteReq.subteamPk, { includeParentInfo: true }); - if (!teamInfo.attributes.peoplePortalCreation || + const destination = await this.authentikClient.getGroupInfo( + inviteReq.subteamPk, + { includeParentInfo: true }, + ); + if ( + !teamInfo.attributes.peoplePortalCreation || !destination.attributes.peoplePortalCreation || teamInfo.attributes.flaggedForDeletion || destination.attributes.flaggedForDeletion || - destination.parentPk !== teamInfo.pk) { - throw new CustomValidationError(400, "Invite destination must be an active subteam of the authorized team"); + destination.parentPk !== teamInfo.pk + ) { + throw new CustomValidationError( + 400, + "Invite destination must be an active subteam of the authorized team", + ); } /* Create New Invite */ @@ -1399,8 +1795,10 @@ export class OrgController extends Controller { teamPk: teamInfo.pk, subteamPk: inviteReq.subteamPk, inviterPk: invitorInfo.pk, - expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000) /* 48 Hours */ - }) + expiresAt: new Date( + Date.now() + 48 * 60 * 60 * 1000, + ) /* 48 Hours */, + }); /* Send an Email to the Invitee and the Invitor */ const onboardUrl = `${process.env.PEOPLEPORTAL_BASE_URL}/onboard/${createdInvite._id}`; @@ -1415,16 +1813,16 @@ export class OrgController extends Controller { invitorName: invitorInfo.name, teamName: teamInfo.attributes.friendlyName, roleTitle: inviteReq.roleTitle, - onboardUrl - } - }) + onboardUrl, + }, + }); } /** * Generates a pre-signed URL for uploading a profile picture. * This endpoint is public to allow users to upload avatars during onboarding * (before they have an account). - * + * * @param inviteId Invite ID for validation and path generation * @param fileName Name of the file * @param contentType MIME type of the file @@ -1435,9 +1833,12 @@ export class OrgController extends Controller { async getAvatarUploadUrl( @Query() inviteId: string, @Query() fileName: string, - @Query() contentType: string - ): Promise<{ uploadUrl: string, key: string, fields: Record }> { - + @Query() contentType: string, + ): Promise<{ + uploadUrl: string; + key: string; + fields: Record; + }> { const invite = await Invite.findById(inviteId).exec(); if (!invite) { throw new CustomValidationError(400, "Invalid Invite ID"); @@ -1447,12 +1848,20 @@ export class OrgController extends Controller { throw new CustomValidationError(400, "Invite has expired"); } - const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; + const allowedTypes = [ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + ]; if (!allowedTypes.includes(contentType)) { - throw new CustomValidationError(400, "Invalid file type. Only images are allowed."); + throw new CustomValidationError( + 400, + "Invalid file type. Only images are allowed.", + ); } - const ext = fileName.split('.').pop(); + const ext = fileName.split(".").pop(); const timestamp = Date.now(); const key = `avatars/temp/${inviteId}/${timestamp}.${ext}`; @@ -1476,15 +1885,16 @@ export class OrgController extends Controller { /** * Generates a pre-signed URL for downloading a user's avatar. * Uses in-memory caching to reduce S3 API calls. - * + * * @param userPk The PK of the user whose avatar to fetch */ @Get("people/avatar/download-url") @Tags("People Management") @SuccessResponse(200) @Security("oidc") - async getAvatarDownloadUrl(@Query() userPk: string): Promise<{ url: string }> { - + async getAvatarDownloadUrl( + @Query() userPk: string, + ): Promise<{ url: string }> { const pkNumber = parseInt(userPk); if (isNaN(pkNumber)) { throw new CustomValidationError(400, "Invalid User PK"); @@ -1495,29 +1905,33 @@ export class OrgController extends Controller { const url = await signAvatarUrl(userPk, avatarKey); - this.setHeader('Cache-Control', 'public, max-age=86400'); + this.setHeader("Cache-Control", "public, max-age=86400"); return { url }; } /** * Public API since Invite IDs are unique and can't be guessed. Additionally, * temporary authentication through OTP is supported but would need an overhaul. - * + * * @param inviteId Invite UUID * @returns Invite Information */ @Get("invites/{inviteId}") @Tags("User Onboarding") @SuccessResponse(200) - async getInviteInfo(@Path() inviteId: string): Promise { - const invite = await Invite.findById(inviteId).lean().exec() + async getInviteInfo( + @Path() inviteId: string, + ): Promise { + const invite = await Invite.findById(inviteId) + .lean() + .exec(); if (!invite || !invite.teamPk || invite.expiresAt <= new Date()) - throw new CustomValidationError(400, "Invalid or expired invite") + throw new CustomValidationError(400, "Invalid or expired invite"); return { ...invite, - slackInviteLink: this.slackClient.getSlackInviteLink() - } + slackInviteLink: this.slackClient.getSlackInviteLink(), + }; } /** @@ -1525,32 +1939,40 @@ export class OrgController extends Controller { * and major. The invitee is automatically added to the team and given * the role specified in the invite. Slack and other verification checks * are in place. - * + * * @param inviteId Invite UUID * @param req Accept Invite Request */ @Put("invites/{inviteId}") @Tags("User Onboarding") @SuccessResponse(201) - async acceptInvite(@Path() inviteId: string, @Body() req: APITeamInviteAcceptRequest) { + async acceptInvite( + @Path() inviteId: string, + @Body() req: APITeamInviteAcceptRequest, + ) { /* Sanitize Request */ req.major = capitalizeString(req.major); - const normalizedLinkedinUrl = normalizeLinkedInProfileUrl(req.linkedinUrl ?? ""); + const normalizedLinkedinUrl = normalizeLinkedInProfileUrl( + req.linkedinUrl ?? "", + ); if (normalizedLinkedinUrl === null) { - throw new CustomValidationError(400, "Please enter a valid LinkedIn profile URL."); + throw new CustomValidationError( + 400, + "Please enter a valid LinkedIn profile URL.", + ); } - const invite = await Invite.findById(inviteId).exec() + const invite = await Invite.findById(inviteId).exec(); if (!invite || !invite.teamPk || invite.expiresAt <= new Date()) - throw new CustomValidationError(400, "Invalid or expired invite") + throw new CustomValidationError(400, "Invalid or expired invite"); /* Validate Password Complexity */ if (req.password) { if (req.password.length < 12) { throw new CustomValidationError( 400, - "Password must be at least 12 characters long" + "Password must be at least 12 characters long", ); } @@ -1558,26 +1980,36 @@ export class OrgController extends Controller { if (strength.score < 2) { throw new CustomValidationError( 400, - "Password is too weak. Please choose a stronger password." + "Password is too weak. Please choose a stronger password.", ); } } /* Check Slack Presence! */ - const slackPresence = await this.slackClient.validateUserPresence(invite.inviteEmail) + const slackPresence = await this.slackClient.validateUserPresence( + invite.inviteEmail, + ); if (!slackPresence) - throw new Error("User has not joined the Slack Workspace!") + throw new Error("User has not joined the Slack Workspace!"); /* Check if Subteam is Valid and Isn't Archived */ - const rootTeam = await this.authentikClient.getGroupInfo(invite.teamPk) - const subteam = await this.authentikClient.getGroupInfo(invite.subteamPk, { includeParentInfo: true }) - const isSubteamArchived = subteam.attributes.flaggedForDeletion - if (!rootTeam.attributes.peoplePortalCreation || + const rootTeam = await this.authentikClient.getGroupInfo(invite.teamPk); + const subteam = await this.authentikClient.getGroupInfo( + invite.subteamPk, + { includeParentInfo: true }, + ); + const isSubteamArchived = subteam.attributes.flaggedForDeletion; + if ( + !rootTeam.attributes.peoplePortalCreation || !subteam.attributes.peoplePortalCreation || rootTeam.attributes.flaggedForDeletion || isSubteamArchived || - subteam.parentPk !== invite.teamPk) { - throw new CustomValidationError(400, "Invite destination is no longer valid"); + subteam.parentPk !== invite.teamPk + ) { + throw new CustomValidationError( + 400, + "Invite destination is no longer valid", + ); } /* Construct New Request */ @@ -1590,39 +2022,55 @@ export class OrgController extends Controller { expectedGrad: req.expectedGrad, phoneNumber: req.phoneNumber, linkedinUrl: normalizedLinkedinUrl, - roles: {} - } - } + roles: {}, + }, + }; if (!isSubteamArchived) { - createUserRequest.groupPk = invite.subteamPk + createUserRequest.groupPk = invite.subteamPk; createUserRequest.attributes.roles = { - [invite.subteamPk]: invite.roleTitle - } + [invite.subteamPk]: invite.roleTitle, + }; } if (req.avatarKey) { const normalizedKey = path.posix.normalize(req.avatarKey); const expectedPrefix = `avatars/temp/${inviteId}/`; - if (!normalizedKey.startsWith(expectedPrefix) || normalizedKey.includes('..')) { + if ( + !normalizedKey.startsWith(expectedPrefix) || + normalizedKey.includes("..") + ) { throw new CustomValidationError(400, "Invalid avatar key"); } } - await this.authentikClient.createNewUser(createUserRequest) + await this.authentikClient.createNewUser(createUserRequest); if (req.avatarKey) { try { - const createdUser = await this.authentikClient.getUserInfoFromEmail(createUserRequest.email); + const createdUser = + await this.authentikClient.getUserInfoFromEmail( + createUserRequest.email, + ); const userPk = createdUser.pk; - const ext = req.avatarKey.split('.').pop(); + const ext = req.avatarKey.split(".").pop(); const newKey = `avatars/${userPk}/avatar.${ext}`; - const allowedAvatars = [FILE_SIGNATURES.PNG, FILE_SIGNATURES.JPEG, FILE_SIGNATURES.GIF, FILE_SIGNATURES.WEBP]; - const isValid = await validateS3FileSignature(req.avatarKey, allowedAvatars); + const allowedAvatars = [ + FILE_SIGNATURES.PNG, + FILE_SIGNATURES.JPEG, + FILE_SIGNATURES.GIF, + FILE_SIGNATURES.WEBP, + ]; + const isValid = await validateS3FileSignature( + req.avatarKey, + allowedAvatars, + ); if (!isValid) { - console.error("Profile picture was not valid. Account created anyway."); + console.error( + "Profile picture was not valid. Account created anyway.", + ); await deleteTempAvatar(req.avatarKey); await invite.deleteOne(); return; @@ -1630,7 +2078,9 @@ export class OrgController extends Controller { const imageBytes = await getS3ObjectBytes(req.avatarKey); if (!imageBytes) { - console.error("Failed to fetch avatar bytes. Account created anyway."); + console.error( + "Failed to fetch avatar bytes. Account created anyway.", + ); await deleteTempAvatar(req.avatarKey); await invite.deleteOne(); return; @@ -1638,44 +2088,52 @@ export class OrgController extends Controller { const faceCheckResult = await checkPhotoHasFace(imageBytes); if (faceCheckResult.passed === false) { - console.error("Avatar failed face check. Account created anyway."); + console.error( + "Avatar failed face check. Account created anyway.", + ); await deleteTempAvatar(req.avatarKey); await invite.deleteOne(); return; } - await s3Client.send(new CopyObjectCommand({ - Bucket: BUCKET_NAME, - CopySource: `${BUCKET_NAME}/${req.avatarKey}`, - Key: newKey - })); + await s3Client.send( + new CopyObjectCommand({ + Bucket: BUCKET_NAME, + CopySource: `${BUCKET_NAME}/${req.avatarKey}`, + Key: newKey, + }), + ); - await this.authentikClient.updateUserAttributes(parseInt(userPk as any), { - avatar: newKey - }); + await this.authentikClient.updateUserAttributes( + parseInt(userPk as any), + { + avatar: newKey, + }, + ); /* Second avatar write path; mirror the invalidate the profile-edit path does so the two stay symmetric. */ invalidateAvatarUrlCache(parseInt(userPk as any)); - await s3Client.send(new DeleteObjectCommand({ - Bucket: BUCKET_NAME, - Key: req.avatarKey - })); - + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET_NAME, + Key: req.avatarKey, + }), + ); } catch (e) { // Don't fail the whole request, just log error. User is created. console.error("Failed to process avatar", e); } } - await invite.deleteOne() + await invite.deleteOne(); } /** * Verifies if a user is a member of the Slack Workspace. Uses the * Slack Shared Resources Client for operations. - * + * * @param req Verify Slack Request * @returns True if the user is a member of the Slack Workspace, false otherwise */ @@ -1683,14 +2141,14 @@ export class OrgController extends Controller { @Tags("Generic Organization Tools") @SuccessResponse(200) async verifySlack(@Body() req: { email: string }): Promise { - return await this.slackClient.validateUserPresence(req.email) + return await this.slackClient.validateUserPresence(req.email); } /** * Provides information about a specific team in the organization. Includes * subteams, users and attributes. Access granted to all OIDC authenticated * users without any bindle restrictions. - * + * * @param teamId Team ID * @returns Team Information */ @@ -1714,32 +2172,44 @@ export class OrgController extends Controller { // } if (primaryTeam.users) { - await Promise.all(primaryTeam.users.map(async (user) => { - user.avatar = await signAvatarUrl(user.pk, user.attributes.avatar); - })); + await Promise.all( + primaryTeam.users.map(async (user) => { + user.avatar = await signAvatarUrl( + user.pk, + user.attributes.avatar, + ); + }), + ); } if (primaryTeam.subteams) { - await Promise.all(primaryTeam.subteams.map(async (sub) => { - if (sub.users) { - await Promise.all(sub.users.map(async (user) => { - user.avatar = await signAvatarUrl(user.pk, user.attributes.avatar); - })); - } - })); + await Promise.all( + primaryTeam.subteams.map(async (sub) => { + if (sub.users) { + await Promise.all( + sub.users.map(async (user) => { + user.avatar = await signAvatarUrl( + user.pk, + user.attributes.avatar, + ); + }), + ); + } + }), + ); } return { team: primaryTeam, - subteams: primaryTeam.subteams - } + subteams: primaryTeam.subteams, + }; } /** * Provides a list of available bindles, collated from shared resources, * that are supported by teams. This feature is usually populated and used * in a subteam-level context. - * + * * @param teamId Team ID * @returns Bindle Permissions Map */ @@ -1747,21 +2217,25 @@ export class OrgController extends Controller { @Tags("Team Configuration", "Bindle Authorization Layer") @SuccessResponse(200) @Security("oidc") - async getTeamBindles(@Path() teamId: string): Promise<{ [key: string]: EnabledBindlePermissions }> { + async getTeamBindles( + @Path() teamId: string, + ): Promise<{ [key: string]: EnabledBindlePermissions }> { const teamInfo = await this.authentikClient.getGroupInfo(teamId); - return teamInfo.attributes.bindlePermissions ?? {}; /* Legacy Teams don't have bindles! */ + return ( + teamInfo.attributes.bindlePermissions ?? {} + ); /* Legacy Teams don't have bindles! */ } /** * Updates the bindle permissions for a team. This method is used in a * subteam-level context. Team Owners can update subteam level permissions * and other members need to hold the `corp:permissionsmgmt` bindle. - * + * * **WARNING:** * This method does not sync the bindles for users across the shared resources. * A seperate team bindle sync call needs to be made to sync bindles for all subteams and * users in a team. - * + * * @param teamId Team or Subteam ID * @param bindleConf Bindle Permissions Map */ @@ -1769,15 +2243,22 @@ export class OrgController extends Controller { @Tags("Team Configuration", "Bindle Authorization Layer") @SuccessResponse(201) @Security("bindles", ["corp:permissionsmgmt"]) - async updateTeamBindles(@Path() teamId: string, @Body() bindleConf: { [key: string]: EnabledBindlePermissions }) { - const bindlePermissions = BindleController.sanitizeBindlePermissions(bindleConf); - await this.authentikClient.updateBindlePermissions(teamId, bindlePermissions); + async updateTeamBindles( + @Path() teamId: string, + @Body() bindleConf: { [key: string]: EnabledBindlePermissions }, + ) { + const bindlePermissions = + BindleController.sanitizeBindlePermissions(bindleConf); + await this.authentikClient.updateBindlePermissions( + teamId, + bindlePermissions, + ); } /** * Signs the avatar URL for a given user. * Uses in-memory caching to reduce S3 API calls. - * + * * @param userPk User PK * @param avatarKey S3 Key for Avatar * @returns Signed URL or empty string @@ -1786,11 +2267,11 @@ export class OrgController extends Controller { * Generates a temporary link to access the team's AWS Console. Account Provisioning * is handled by AWSClient and Root Team Settings. Access is moderated by the Bindle * Authorization Layer. - * - * To enable AWS Access, the team owner or `corp:rootsettings` bindle is required. For + * + * To enable AWS Access, the team owner or `corp:rootsettings` bindle is required. For * generating a console link from this API, the user must either be a Team Owner or * hold the corp:awsaccess bindle. - * + * * @param req Express Request Object * @param teamId Team ID * @returns Temporary AWS Console Link @@ -1799,54 +2280,104 @@ export class OrgController extends Controller { @Tags("Team External Integrations") @SuccessResponse(201) @Security("bindles", ["corp:awsaccess"]) - async fetchAWSAccessCredentials(@Request() req: express.Request, @Path() teamId: string) { - const res = (req as any).res as express.Response - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Transfer-Encoding', 'chunked'); - - const awsRes = Object.values(ENABLED_TEAMSETTING_RESOURCES).find((res) => res.getResourceName() == "AWSClient") as unknown as AWSClient; - - res.write(JSON.stringify({ progressPercent: 10, status: "Retrieving Team Credentials..." })) - const teamInfo = req.bindle!.teamInfo + async fetchAWSAccessCredentials( + @Request() req: express.Request, + @Path() teamId: string, + ) { + const res = (req as any).res as express.Response; + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Transfer-Encoding", "chunked"); + + const awsRes = Object.values(ENABLED_TEAMSETTING_RESOURCES).find( + (res) => res.getResourceName() == "AWSClient", + ) as unknown as AWSClient; + + res.write( + JSON.stringify({ + progressPercent: 10, + status: "Retrieving Team Credentials...", + }), + ); + const teamInfo = req.bindle!.teamInfo; // Check if provisioning is enabled - const settings = teamInfo.attributes.rootTeamSettings?.[awsRes.getResourceName()]; - const shouldProvision = settings && settings["awsclient:provision"] === true; + const settings = + teamInfo.attributes.rootTeamSettings?.[awsRes.getResourceName()]; + const shouldProvision = + settings && settings["awsclient:provision"] === true; if (!shouldProvision) { - res.write(JSON.stringify({ progressPercent: 100, status: "AWS Provisioning is not enabled for this team.", error: true })) - res.end() - return + res.write( + JSON.stringify({ + progressPercent: 100, + status: "AWS Provisioning is not enabled for this team.", + error: true, + }), + ); + res.end(); + return; } /* Provide Progess Update */ - res.write(JSON.stringify({ progressPercent: 30, status: "Locating AWS Account..." })) + res.write( + JSON.stringify({ + progressPercent: 30, + status: "Locating AWS Account...", + }), + ); - const name = teamInfo.name + const name = teamInfo.name; const accountId = await awsRes.findAccountIdByName(teamInfo.name); if (!accountId) { - res.write(JSON.stringify({ progressPercent: 100, status: "AWS Account not found! Please contact an administrator.", error: true })) - res.end() - return + res.write( + JSON.stringify({ + progressPercent: 100, + status: "AWS Account not found! Please contact an administrator.", + error: true, + }), + ); + res.end(); + return; } - res.write(JSON.stringify({ progressPercent: 60, status: "Generating Session..." })) + res.write( + JSON.stringify({ + progressPercent: 60, + status: "Generating Session...", + }), + ); try { - const currentUser = req.session.authorizedUser?.name ?? "GenericDashboardUser" - const link = await awsRes.generateConsoleLink(accountId, sanitizeUserFullName(currentUser)) - res.write(JSON.stringify({ progressPercent: 100, status: "Link Generated!", link: link })) + const currentUser = + req.session.authorizedUser?.name ?? "GenericDashboardUser"; + const link = await awsRes.generateConsoleLink( + accountId, + sanitizeUserFullName(currentUser), + ); + res.write( + JSON.stringify({ + progressPercent: 100, + status: "Link Generated!", + link: link, + }), + ); } catch (e: any) { - res.write(JSON.stringify({ progressPercent: 100, status: "Failed to generate link: " + e.message, error: true })) + res.write( + JSON.stringify({ + progressPercent: 100, + status: "Failed to generate link: " + e.message, + error: true, + }), + ); } - res.end() + res.end(); } /** * Updates the Root Team Settings for a Team. To perform this action, the user * must either be a Team Owner or hold the `corp:rootsettings` bindle. - * + * * @param teamId Team ID * @param conf Root Team Settings Map */ @@ -1854,18 +2385,19 @@ export class OrgController extends Controller { @Tags("Team Configuration") @SuccessResponse(201) @Security("bindles", ["corp:rootsettings"]) - async updateRootTeamSetting(@Path() teamId: string, @Body() conf: { [key: string]: EnabledRootSettings }) { + async updateRootTeamSetting( + @Path() teamId: string, + @Body() conf: { [key: string]: EnabledRootSettings }, + ) { /* Filter for only the available settings */ const applySettingsList: { [key: string]: EnabledRootSettings } = {}; for (const client in conf) { - if (!this.teamSettingList[client]) - continue; + if (!this.teamSettingList[client]) continue; const supportedSettings = this.teamSettingList[client]; const filteredSettings: EnabledRootSettings = {}; for (const setting in conf[client]) { - if (!supportedSettings[setting]) - continue; + if (!supportedSettings[setting]) continue; /* Update Filtered Setting */ filteredSettings[setting] = conf[client][setting] ?? false; @@ -1876,7 +2408,10 @@ export class OrgController extends Controller { } /* Call Authentik to Update the Attributes */ - await this.authentikClient.updateRootTeamSettings(teamId, applySettingsList); + await this.authentikClient.updateRootTeamSettings( + teamId, + applySettingsList, + ); /* Get updated team info and sync each RootTeamSettingClient */ const updatedTeamInfo = await this.authentikClient.getGroupInfo(teamId); @@ -1886,9 +2421,9 @@ export class OrgController extends Controller { } /** - * Adds an existing member to a team. To perform this action, the user must + * Adds an existing member to a team. To perform this action, the user must * either be a Team Owner or hold the `corp:membermgmt` bindle. - * + * * @param teamId Team ID * @param req User PK */ @@ -1896,18 +2431,21 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(201) @Security("bindles", ["corp:membermgmt"]) - async addTeamMember(@Path() teamId: string, @Body() req: { userPk: number, roleTitle: string }) { + async addTeamMember( + @Path() teamId: string, + @Body() req: { userPk: number; roleTitle: string }, + ) { await this.addTeamMemberWrapper({ groupId: teamId, userPk: req.userPk, - roleTitle: req.roleTitle - }) + roleTitle: req.roleTitle, + }); } /** - * Removes an existing member from a team. To perform this action, the user must + * Removes an existing member from a team. To perform this action, the user must * either be a Team Owner or hold the `corp:membermgmt` bindle. - * + * * @param teamId Team ID * @param req User PK */ @@ -1915,19 +2453,23 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(201) @Security("bindles", ["corp:membermgmt"]) - async removeTeamMember(@Request() req: express.Request, @Path() teamId: string, @Body() body: { userPk: number }) { + async removeTeamMember( + @Request() req: express.Request, + @Path() teamId: string, + @Body() body: { userPk: number }, + ) { /* Needs Is Team owner Middleware?! */ await this.removeTeamMemberWrapper({ groupId: teamId, groupInfo: req.bindle!.teamInfo, - userPk: body.userPk - }) + userPk: body.userPk, + }); } /** * Creates a new subteam inside a team. To perform this action, the user must * either be a Team Owner or hold the `corp:subteamaccess` bindle. - * + * * @param teamId Team ID * @param req Subteam Information * @returns Created Subteam Information @@ -1936,7 +2478,11 @@ export class OrgController extends Controller { @Tags("Subteam Management") @SuccessResponse(201) @Security("bindles", ["corp:subteamaccess"]) - async createSubTeam(@Request() req: express.Request | ExpressRequestBindleShim, @Path() teamId: string, @Body() body: APICreateSubTeamRequest): Promise { + async createSubTeam( + @Request() req: express.Request | ExpressRequestBindleShim, + @Path() teamId: string, + @Body() body: APICreateSubTeamRequest, + ): Promise { body.friendlyName = validateTeamName(body.friendlyName); body.description = capitalizeString(body.description); const parentInfo = req.bindle!.teamInfo; @@ -1944,7 +2490,7 @@ export class OrgController extends Controller { if (parentInfo.subteams && parentInfo.subteams.length >= 15) { throw new CustomValidationError( 400, - "Maximum number of subteams (15) reached for this team." + "Maximum number of subteams (15) reached for this team.", ); } @@ -1957,19 +2503,17 @@ export class OrgController extends Controller { seasonType: parentInfo.attributes.seasonType, seasonYear: parentInfo.attributes.seasonYear, description: body.description, - } - }) + }, + }); /* Return the Sub team */ - return createdSubTeam + return createdSubTeam; } - - /** * Fetches information about a specific Team Creation Request. To access this endpoint, * the user must pass the Executive Authorization Layer. - * + * * @param requestId Request ID * @returns Team Creation Request Information */ @@ -1977,19 +2521,29 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(200) @Security("executive") - async getTeamCreationRequest(@Path() requestId: string): Promise { - const request = await TeamCreationRequest.findById(requestId).lean().exec(); + async getTeamCreationRequest( + @Path() requestId: string, + ): Promise { + const request = await TeamCreationRequest.findById(requestId) + .lean() + .exec(); if (!request) { - throw new CustomValidationError(404, "Team Creation Request not found"); + throw new CustomValidationError( + 404, + "Team Creation Request not found", + ); } /* Manually cast _id to string if needed, though lean() handles it mostly */ - return { ...request, _id: request._id.toString() } as unknown as APITeamCreationRequestResponse; + return { + ...request, + _id: request._id.toString(), + } as unknown as APITeamCreationRequestResponse; } /** * Approves a Team Creation Request, creates a new team and notifies the requester via * email. To access this endpoint, the user must pass the Executive Authorization Layer. - * + * * @param req Express Request * @param requestId Request ID */ @@ -1997,17 +2551,26 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(201, "Team Created & Request Approved") @Security("executive") - async approveTeamCreationRequest(@Request() req: express.Request, @Path() requestId: string) { + async approveTeamCreationRequest( + @Request() req: express.Request, + @Path() requestId: string, + ) { const authorizedUser = req.session.authorizedUser!; const request = await TeamCreationRequest.findById(requestId); if (!request) { - throw new CustomValidationError(404, "Team Creation Request not found"); + throw new CustomValidationError( + 404, + "Team Creation Request not found", + ); } try { /* Create the Team */ /* We use the requester's PK as the owner, not the approver! */ - await this.createTeamWrapper(request.createTeamRequest, request.requestorPk); + await this.createTeamWrapper( + request.createTeamRequest, + request.requestorPk, + ); /* Send Email Update */ await this.emailClient.send({ @@ -2016,11 +2579,15 @@ export class OrgController extends Controller { subject: `Update on your Team Creation Request`, templateName: "MgmtTeamRequestUpdate", templateVars: { - requesterName: (await this.authentikClient.getUserInfo(request.requestorPk)).name, + requesterName: ( + await this.authentikClient.getUserInfo( + request.requestorPk, + ) + ).name, teamName: request.createTeamRequest.friendlyName, status: "approved", - approverName: authorizedUser.name - } + approverName: authorizedUser.name, + }, }); } catch (e) { if (e instanceof AuthentikClientError) { @@ -2031,12 +2598,16 @@ export class OrgController extends Controller { subject: `Team Creation Failed`, templateName: "MgmtTeamRequestFailed", templateVars: { - requesterName: (await this.authentikClient.getUserInfo(request.requestorPk)).name, + requesterName: ( + await this.authentikClient.getUserInfo( + request.requestorPk, + ) + ).name, teamName: request.createTeamRequest.friendlyName, approverName: authorizedUser.name, errorMessage: e.message, - exceptionTrace: e.stack - } + exceptionTrace: e.stack, + }, }); } } finally { @@ -2047,9 +2618,9 @@ export class OrgController extends Controller { /** * Declines a Team Creation Request, Deletes it from the database and notifies - * the requester via email. To access this endpoint, the user must pass the + * the requester via email. To access this endpoint, the user must pass the * Executive Authorization Layer. - * + * * @param req Express Request * @param requestId Request ID */ @@ -2057,11 +2628,17 @@ export class OrgController extends Controller { @Tags("Team Management") @SuccessResponse(200, "Request Declined & Deleted") @Security("executive") - async declineTeamCreationRequest(@Request() req: express.Request, @Path() requestId: string) { + async declineTeamCreationRequest( + @Request() req: express.Request, + @Path() requestId: string, + ) { const authorizedUser = req.session.authorizedUser!; const request = await TeamCreationRequest.findById(requestId); if (!request) { - throw new CustomValidationError(404, "Team Creation Request not found"); + throw new CustomValidationError( + 404, + "Team Creation Request not found", + ); } /* Send Email Update */ @@ -2071,11 +2648,13 @@ export class OrgController extends Controller { subject: `Update on your Team Creation Request`, templateName: "MgmtTeamRequestUpdate", templateVars: { - requesterName: (await this.authentikClient.getUserInfo(request.requestorPk)).name, + requesterName: ( + await this.authentikClient.getUserInfo(request.requestorPk) + ).name, teamName: request.createTeamRequest.friendlyName, status: "declined", - approverName: authorizedUser.name - } + approverName: authorizedUser.name, + }, }); /* Delete the Request */ @@ -2084,10 +2663,10 @@ export class OrgController extends Controller { /** * Public API to request the creation of a new team. - * + * * If the requester is an Executive or Superuser, the team is created immediately (Auto-Approved). * Otherwise, a request is submitted for Executive Board review. - * + * * @param req Express Request * @param createTeamReq Team Creation Payload * @returns Created Team Info (201) or Pending Status (202) @@ -2098,13 +2677,20 @@ export class OrgController extends Controller { @Security("oidc") async createTeam( @Request() req: express.Request, - @Body() createTeamReq: APICreateTeamRequest - ): Promise { + @Body() createTeamReq: APICreateTeamRequest, + ): Promise { /* Santize Request */ - createTeamReq.friendlyName = validateTeamName(createTeamReq.friendlyName); + createTeamReq.friendlyName = validateTeamName( + createTeamReq.friendlyName, + ); createTeamReq.description = capitalizeString(createTeamReq.description); - createTeamReq.teamStartDate = createTeamReq.teamStartDate ?? new Date().toISOString().slice(0, 10); - this.validateTeamDateRange(createTeamReq.teamStartDate, createTeamReq.teamEndDate); + createTeamReq.teamStartDate = + createTeamReq.teamStartDate ?? + new Date().toISOString().slice(0, 10); + this.validateTeamDateRange( + createTeamReq.teamStartDate, + createTeamReq.teamEndDate, + ); /* Check for Authorized User */ const authorizedUser = req.session.authorizedUser!; @@ -2113,8 +2699,9 @@ export class OrgController extends Controller { /* Check for Executive Authority (Auto-Approve) */ try { isExecutive = await executiveAuthVerify( - req, [], - true /* Skip OIDC Check */ + req, + [], + true /* Skip OIDC Check */, ); } catch (e) { isExecutive = false; @@ -2122,26 +2709,35 @@ export class OrgController extends Controller { if (isExecutive) { /* Auto-Approve */ - const team = await this.createTeamWrapper(createTeamReq, authorizedUser.pk); + const team = await this.createTeamWrapper( + createTeamReq, + authorizedUser.pk, + ); this.setStatus(201); return team; } else { /* Fetch Executive Board (Service Team) Members for CC */ /* We do this BEFORE creating the request because if we can't notify the Exec Board, there is no point in creating the request as it will sit in limbo. */ - const execTeamPk = await this.authentikClient.getGroupPkFromName("ExecutiveBoardMembers"); - const execTeamInfo = await this.authentikClient.getGroupInfo(execTeamPk); - const execEmails = execTeamInfo.users.map(u => u.email); + const execTeamPk = await this.authentikClient.getGroupPkFromName( + "ExecutiveBoardMembers", + ); + const execTeamInfo = + await this.authentikClient.getGroupInfo(execTeamPk); + const execEmails = execTeamInfo.users.map((u) => u.email); if (execEmails.length < 1) - throw new CustomValidationError(500, "No Executive Board Members found. Please contact an Administrator."); + throw new CustomValidationError( + 500, + "No Executive Board Members found. Please contact an Administrator.", + ); /* Submit Request */ const teamCreationRequest = await TeamCreationRequest.create({ requestorPk: authorizedUser.pk, requestorName: authorizedUser.name, requestorEmail: authorizedUser.email, createTeamRequest: createTeamReq, - status: TeamCreationRequestStatus.PENDING + status: TeamCreationRequestStatus.PENDING, }); /* Send Email to Executive Board */ @@ -2158,14 +2754,15 @@ export class OrgController extends Controller { seasonType: createTeamReq.seasonType, seasonYear: createTeamReq.seasonYear, requesterRole: createTeamReq.requestorRole, - requestUrl: `${process.env.PEOPLEPORTAL_BASE_URL}/org/teamrequests/${teamCreationRequest.id}` - } + requestUrl: `${process.env.PEOPLEPORTAL_BASE_URL}/org/teamrequests/${teamCreationRequest.id}`, + }, }); this.setStatus(202); return { - message: "Your Team Creation Request has been Submitted for Review! Please check your email for updates.", - status: "PENDING" + message: + "Your Team Creation Request has been Submitted for Review! Please check your email for updates.", + status: "PENDING", }; } } @@ -2175,7 +2772,7 @@ export class OrgController extends Controller { * `handleOrgBindleSync` for each shared resource that is enabled. For better * User experience, this routine emits HTTP Server-Sent Events (SSEs) to the * client to provide real-time progress updates. - * + * * @param req Express Request Object * @param teamId Team ID */ @@ -2183,49 +2780,63 @@ export class OrgController extends Controller { @Tags("Team Configuration", "Bindle Authorization Layer") @SuccessResponse(200) @Security("bindles", ["corp:bindlesync"]) - async syncOrgBindles(@Request() req: express.Request, @Path() teamId: string) { - const res = (req as any).res as express.Response - res.setHeader('Content-Type', 'text/plain'); - res.setHeader('Transfer-Encoding', 'chunked'); + async syncOrgBindles( + @Request() req: express.Request, + @Path() teamId: string, + ) { + const res = (req as any).res as express.Response; + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Transfer-Encoding", "chunked"); /* Obtain Teams and Compute Progress Effort */ - const teamInfo = req.bindle!.teamInfo - const computeEffort = teamInfo.users.length + - teamInfo.subteams.reduce((acc, val) => acc + val.users.length, 0) + const teamInfo = req.bindle!.teamInfo; + const computeEffort = + teamInfo.users.length + + teamInfo.subteams.reduce((acc, val) => acc + val.users.length, 0); - let updatedResources = 0 + let updatedResources = 0; let errors: Map = new Map(); for (const sharedResource of this.sharedResources) { try { - await sharedResource.handleOrgBindleSync(teamInfo, (updatedResourceCount, status) => { - /* Update Progress and Write Output */ - updatedResources += updatedResourceCount - - /* Add Newline Delimiter to Ensure that client can parse distinct JSON objects, Helps when concurrent callbacks are fired causing TCP coalescing */ - res.write(JSON.stringify({ progressPercent: (updatedResources / computeEffort) * 100, status }) + "\n") - }) + await sharedResource.handleOrgBindleSync( + teamInfo, + (updatedResourceCount, status) => { + /* Update Progress and Write Output */ + updatedResources += updatedResourceCount; + + /* Add Newline Delimiter to Ensure that client can parse distinct JSON objects, Helps when concurrent callbacks are fired causing TCP coalescing */ + res.write( + JSON.stringify({ + progressPercent: + (updatedResources / computeEffort) * 100, + status, + }) + "\n", + ); + }, + ); } catch (e) { if (e instanceof Error) { errors.set(sharedResource.getResourceName(), e); } else { - errors.set(sharedResource.getResourceName(), new Error( - `Unknown Error: ${describeUnknownError(e)}` - )); + errors.set( + sharedResource.getResourceName(), + new Error(`Unknown Error: ${describeUnknownError(e)}`), + ); } } } /* Write All the Errors during the Process... */ console.log(errors); - res.end() + res.end(); } /** * Updates the team's name and description. To perform this action, * the user must either be a Team Owner or hold the `corp:rootsettings` * bindle. - * + * * @param teamId Team ID * @param conf Team Name and Description Update Payload */ @@ -2233,7 +2844,10 @@ export class OrgController extends Controller { @Tags("Team Configuration") @SuccessResponse(200) @Security("bindles", ["corp:rootsettings"]) - async updateTeamAttributes(@Path() teamId: string, @Body() conf: APIUpdateTeamRequest) { + async updateTeamAttributes( + @Path() teamId: string, + @Body() conf: APIUpdateTeamRequest, + ) { if (conf.friendlyName) { conf.friendlyName = validateTeamName(conf.friendlyName); } @@ -2241,42 +2855,50 @@ export class OrgController extends Controller { conf.description = capitalizeString(conf.description); } - if (conf.teamStartDate !== undefined || conf.teamEndDate !== undefined) { + if ( + conf.teamStartDate !== undefined || + conf.teamEndDate !== undefined + ) { const teamInfo = await this.authentikClient.getGroupInfo(teamId); - const effectiveStartDate = conf.teamStartDate ?? teamInfo.attributes.teamStartDate; - const effectiveEndDate = conf.teamEndDate ?? teamInfo.attributes.teamEndDate; + const effectiveStartDate = + conf.teamStartDate ?? teamInfo.attributes.teamStartDate; + const effectiveEndDate = + conf.teamEndDate ?? teamInfo.attributes.teamEndDate; if (!effectiveStartDate || !effectiveEndDate) { - throw new CustomValidationError(400, "Both team start date and end date are required"); + throw new CustomValidationError( + 400, + "Both team start date and end date are required", + ); } - this.validateTeamDateRange( - effectiveStartDate, - effectiveEndDate, - ); + this.validateTeamDateRange(effectiveStartDate, effectiveEndDate); } /* Strictly restrict updates to allowed fields to prevent attribute pollution */ - await this.authentikClient.updateGroupInformation(teamId, conf as Record); + await this.authentikClient.updateGroupInformation( + teamId, + conf as Record, + ); } /** * Performs a soft-delete by flagging the team for deletion. People Portal teams * can never be deleted considering the potential impacts caused by deleted states * on Shared Resources. Instead, the soft-delete mechanism follows these rules: - * - * - If the team is a subteam (has a parent), it removes + * + * - If the team is a subteam (has a parent), it removes * all members to immediately revoke access. - * + * * - If the team is a root team (has no parent), it is just * flagged for deletion as their deletion is overengineering. - * - * To perform this action on a subteam, the user must either be a Team Owner or + * + * To perform this action on a subteam, the user must either be a Team Owner or * hold the `corp:subteamaccess` bindle. - * + * * To perform this action on a root team, the user must pass the Executive * Authorization Layer as a **Superuser** (`su:exclusive` scope). - * + * * @param teamId Team ID */ @Delete("teams/{teamId}") @@ -2289,8 +2911,9 @@ export class OrgController extends Controller { /* If Root Team, Enforce Executive Authorization */ if (!teamInfo.parentPk) { await executiveAuthVerify( - req, ["su:exclusive"], - true /* Skip OIDC Check as its done by Bindles Auth */ + req, + ["su:exclusive"], + true /* Skip OIDC Check as its done by Bindles Auth */, ); } @@ -2327,33 +2950,41 @@ export class OrgController extends Controller { if (teamInfo.parentPk) { throw new CustomValidationError( 400, - "Only root teams can be archived." + "Only root teams can be archived.", ); } await executiveAuthVerify( - req, ["su:exclusive"], - true /* Skip OIDC Check as its done by Bindles Auth */ + req, + ["su:exclusive"], + true /* Skip OIDC Check as its done by Bindles Auth */, ); let errors: Map = new Map(); const recordError = (resourceName: string, e: unknown) => { - errors.set(resourceName, e instanceof Error ? e : new Error( - `Unknown Error: ${describeUnknownError(e)}` - )); + errors.set( + resourceName, + e instanceof Error + ? e + : new Error(`Unknown Error: ${describeUnknownError(e)}`), + ); }; /* 1. Archive Shared Resources (Gitea repos, Slack channels, etc.) */ for (const sharedResource of this.sharedResources) { try { - await sharedResource.archiveTeam(teamInfo, () => { /* progress not streamed */ }); + await sharedResource.archiveTeam(teamInfo, () => { + /* progress not streamed */ + }); } catch (e) { recordError(sharedResource.getResourceName(), e); } } /* 2. Archive Root Team Setting Resources (AWS, etc.) */ - for (const settingResource of Object.values(ENABLED_TEAMSETTING_RESOURCES)) { + for (const settingResource of Object.values( + ENABLED_TEAMSETTING_RESOURCES, + )) { try { await settingResource.archiveTeam(teamInfo); } catch (e) { @@ -2362,15 +2993,22 @@ export class OrgController extends Controller { } if (errors.size > 0) - console.error("[OrgController] Archive completed with errors:", errors); + console.error( + "[OrgController] Archive completed with errors:", + errors, + ); /* 3. Stamp the team as archived */ const actorEmail = req.session.authorizedUser?.email; let archivedBy: string | undefined; if (actorEmail) { try { - archivedBy = (await this.authentikClient.getUserInfoFromEmail(actorEmail)).pk; - } catch { /* Audit attribution is best-effort */ } + archivedBy = ( + await this.authentikClient.getUserInfoFromEmail(actorEmail) + ).pk; + } catch { + /* Audit attribution is best-effort */ + } } await this.authentikClient.archiveGroup(teamId, archivedBy); @@ -2382,9 +3020,14 @@ export class OrgController extends Controller { } /* Other Wrapper Functions */ - private async createTeamWrapper(createTeamReq: APICreateTeamRequest, ownerPk: number): Promise { + private async createTeamWrapper( + createTeamReq: APICreateTeamRequest, + ownerPk: number, + ): Promise { /* Validate Team Name Request */ - createTeamReq.friendlyName = validateTeamName(createTeamReq.friendlyName); + createTeamReq.friendlyName = validateTeamName( + createTeamReq.friendlyName, + ); /* Create the New Team */ const newTeam = await this.authentikClient.createNewTeam({ @@ -2394,10 +3037,12 @@ export class OrgController extends Controller { seasonType: createTeamReq.seasonType, seasonYear: createTeamReq.seasonYear, description: createTeamReq.description, - teamStartDate: createTeamReq.teamStartDate ?? new Date().toISOString().slice(0, 10), + teamStartDate: + createTeamReq.teamStartDate ?? + new Date().toISOString().slice(0, 10), teamEndDate: createTeamReq.teamEndDate, - } - }) + }, + }); /* Add the Creator to Team Owners. Awaited: unawaited, creation could report success before the owner existed, and a failure vanished as @@ -2405,30 +3050,37 @@ export class OrgController extends Controller { await this.addTeamMemberWrapper({ groupId: newTeam.pk, userPk: ownerPk, - roleTitle: createTeamReq.requestorRole - }) + roleTitle: createTeamReq.requestorRole, + }); /* Construct Bindle Shim for Optimized Create Sub Team Calls */ const bindleShim: ExpressRequestBindleShim = { bindle: { requestedPermissions: [], teamInfo: newTeam, - } - } + }, + }; /* Team Templates: Makes Life Easier! */ const teamConfig = TEAM_TYPE_CONFIGS[createTeamReq.teamType]; if (teamConfig) { for (const subteamConfig of teamConfig.defaultSubteams) { /* Create Subteam */ - const subteam = await this.createSubTeam(bindleShim, newTeam.pk, { - friendlyName: subteamConfig.friendlyName, - description: subteamConfig.description - }); + const subteam = await this.createSubTeam( + bindleShim, + newTeam.pk, + { + friendlyName: subteamConfig.friendlyName, + description: subteamConfig.description, + }, + ); /* Apply Bindles if Defined */ if (subteamConfig.bindles) { - await this.updateTeamBindles(subteam.pk, subteamConfig.bindles); + await this.updateTeamBindles( + subteam.pk, + subteamConfig.bindles, + ); } } } @@ -2436,15 +3088,21 @@ export class OrgController extends Controller { return newTeam; } - async addTeamMemberWrapper(request: AddGroupMemberRequest): Promise { + async addTeamMemberWrapper( + request: AddGroupMemberRequest, + ): Promise { const userInfo = await this.authentikClient.getUserInfo(request.userPk); - const targetGroupInfo = await this.authentikClient.getGroupInfo(request.groupId); + const targetGroupInfo = await this.authentikClient.getGroupInfo( + request.groupId, + ); let rootTeamInfo: GetGroupInfoResponse; /* Identify Root Team */ if (targetGroupInfo.parentInfo) { /* It's a subteam, fetch the parent (Root Team) */ - rootTeamInfo = await this.authentikClient.getGroupInfo(targetGroupInfo.parentInfo.pk); + rootTeamInfo = await this.authentikClient.getGroupInfo( + targetGroupInfo.parentInfo.pk, + ); } else { /* It is the Root Team */ rootTeamInfo = targetGroupInfo; @@ -2453,59 +3111,76 @@ export class OrgController extends Controller { /* Collect all Forbidden Group IDs (Root + All Subteams) */ const familyPks = new Set(); familyPks.add(rootTeamInfo.pk); - rootTeamInfo.subteamPkList.forEach(pk => familyPks.add(pk)); + rootTeamInfo.subteamPkList.forEach((pk) => familyPks.add(pk)); /* Check for Intersection with User's Current Groups */ const userGroupPks = userInfo.groups; for (const groupPk of userGroupPks) { if (familyPks.has(groupPk)) { this.setStatus(409); // Conflict - throw new CustomValidationError(409, "User is already a member of this team hierarchy (Root or another Subteam)."); + throw new CustomValidationError( + 409, + "User is already a member of this team hierarchy (Root or another Subteam).", + ); } } - const coreAdditionComplete = await this.authentikClient.addGroupMember(request) + const coreAdditionComplete = + await this.authentikClient.addGroupMember(request); /* Update User's Role Attribute. Awaited so the role is persisted before the caller is told the member was added. */ await this.authentikClient.updateUserAttributes(request.userPk, { roles: { ...userInfo.attributes.roles, - [request.groupId]: request.roleTitle - } - }) + [request.groupId]: request.roleTitle, + }, + }); return { coreAdditionComplete, - } + }; } - async removeTeamMemberWrapper(request: RemoveGroupMemberRequest): Promise { - const userInfo = await this.authentikClient.getUserInfo(request.userPk) - const groupInfo = request.groupInfo ?? await this.authentikClient.getGroupInfo(request.groupId) + async removeTeamMemberWrapper( + request: RemoveGroupMemberRequest, + ): Promise { + const userInfo = await this.authentikClient.getUserInfo(request.userPk); + const groupInfo = + request.groupInfo ?? + (await this.authentikClient.getGroupInfo(request.groupId)); /* Check if we're removing the last owner from a Root Team */ if (!this.isGroupSubteam(groupInfo)) { if (groupInfo.users.length <= 1) { this.setStatus(409); - throw new Error("Cannot Remove the last Team Owner. Add someone else to remove yourself."); + throw new Error( + "Cannot Remove the last Team Owner. Add someone else to remove yourself.", + ); } } /* Get Parent Team Name if available, otherwise fallback to current group */ - let teamName = groupInfo.attributes.friendlyName ?? groupInfo.name + let teamName = groupInfo.attributes.friendlyName ?? groupInfo.name; if (groupInfo.parentPk) { - const parentInfo = await this.authentikClient.getGroupInfo(groupInfo.parentPk) - teamName = parentInfo.attributes.friendlyName ?? parentInfo.name + const parentInfo = await this.authentikClient.getGroupInfo( + groupInfo.parentPk, + ); + teamName = parentInfo.attributes.friendlyName ?? parentInfo.name; } /* Remove the Role Attribute from Authentik if it exists */ - if (userInfo.attributes.roles && userInfo.attributes.roles[request.groupId]) { - const updatedRoles = { ...userInfo.attributes.roles } - delete updatedRoles[request.groupId] - await this.authentikClient.updateUserAttributes(request.userPk, { roles: updatedRoles }) + if ( + userInfo.attributes.roles && + userInfo.attributes.roles[request.groupId] + ) { + const updatedRoles = { ...userInfo.attributes.roles }; + delete updatedRoles[request.groupId]; + await this.authentikClient.updateUserAttributes(request.userPk, { + roles: updatedRoles, + }); } - await this.authentikClient.removeGroupMember(request) + await this.authentikClient.removeGroupMember(request); } } diff --git a/peopleportal/server/src/controllers/PlatformController.ts b/peopleportal/server/src/controllers/PlatformController.ts index f52e01f..9d806be 100644 --- a/peopleportal/server/src/controllers/PlatformController.ts +++ b/peopleportal/server/src/controllers/PlatformController.ts @@ -17,8 +17,8 @@ */ import { Controller, Get, Route, Tags, SuccessResponse, Security } from "tsoa"; -import fs from 'fs'; -import path from 'path'; +import fs from "fs"; +import path from "path"; import { getGPLv3License } from "../utils/strings"; interface PlatformLicenseResponse { @@ -28,7 +28,6 @@ interface PlatformLicenseResponse { @Route("/api/platform") export class PlatformController extends Controller { - /** * Retrieves the platform license text and list of backend dependencies. */ @@ -37,16 +36,20 @@ export class PlatformController extends Controller { @SuccessResponse(200) @Security("oidc") public async getPlatformLicense(): Promise { - const packageJsonPath = path.join(process.cwd(), 'package.json'); + const packageJsonPath = path.join(process.cwd(), "package.json"); let dependencies: { name: string; version: string }[] = []; try { if (fs.existsSync(packageJsonPath)) { - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8')); + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, "utf-8"), + ); if (packageJson.dependencies) { - dependencies = Object.keys(packageJson.dependencies).map(key => ({ - name: key, - version: packageJson.dependencies[key] - })); + dependencies = Object.keys(packageJson.dependencies).map( + (key) => ({ + name: key, + version: packageJson.dependencies[key], + }), + ); } } } catch (e) { @@ -55,7 +58,7 @@ export class PlatformController extends Controller { return { licenseText: getGPLv3License(), - dependencies + dependencies, }; } } diff --git a/peopleportal/server/src/models/Applicant.ts b/peopleportal/server/src/models/Applicant.ts index e750e3c..9683958 100644 --- a/peopleportal/server/src/models/Applicant.ts +++ b/peopleportal/server/src/models/Applicant.ts @@ -19,46 +19,48 @@ import { Document, Schema, Types, model } from "mongoose"; export interface IApplicant extends Document { - _id: Types.ObjectId; - email: string; - fullName: string; - profile: Map; // Made required with default - applicationIds: Types.ObjectId[]; - createdAt: Date; - updatedAt: Date; + _id: Types.ObjectId; + email: string; + fullName: string; + profile: Map; // Made required with default + applicationIds: Types.ObjectId[]; + createdAt: Date; + updatedAt: Date; } -const ApplicantSchema = new Schema({ - email: { - type: String, - required: true, - unique: true, - index: true, - lowercase: true, - trim: true, - validate: { - validator: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), - message: 'Invalid email format' - } - }, - fullName: { type: String, required: true, trim: true }, - profile: { - type: Map, - of: String, - required: true, - default: new Map() - }, - applicationIds: [{ type: Schema.Types.ObjectId, ref: 'Application' }] -}, { timestamps: true }); +const ApplicantSchema = new Schema( + { + email: { + type: String, + required: true, + unique: true, + index: true, + lowercase: true, + trim: true, + validate: { + validator: (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), + message: "Invalid email format", + }, + }, + fullName: { type: String, required: true, trim: true }, + profile: { + type: Map, + of: String, + required: true, + default: new Map(), + }, + applicationIds: [{ type: Schema.Types.ObjectId, ref: "Application" }], + }, + { timestamps: true }, +); -export const Applicant = model('Applicant', ApplicantSchema); +export const Applicant = model("Applicant", ApplicantSchema); export interface ApplicantProfile { - resumeUrl: string; - whyAppDev: string; - instagramFollow: string; - linkedinUrl?: string; - githubUrl?: string; - [key: string]: string | undefined; + resumeUrl: string; + whyAppDev: string; + instagramFollow: string; + linkedinUrl?: string; + githubUrl?: string; + [key: string]: string | undefined; } - diff --git a/peopleportal/server/src/models/Application.ts b/peopleportal/server/src/models/Application.ts index 4077840..168fe1f 100644 --- a/peopleportal/server/src/models/Application.ts +++ b/peopleportal/server/src/models/Application.ts @@ -19,78 +19,92 @@ import { Document, Schema, model } from "mongoose"; export enum ApplicationStage { - APPLIED = 'Applied', - INTERVIEW = 'Interview', - REJECTED = 'Rejected', - POTENTIAL_HIRE = 'Potential Hire', - HIRED = 'Hired' + APPLIED = "Applied", + INTERVIEW = "Interview", + REJECTED = "Rejected", + POTENTIAL_HIRE = "Potential Hire", + HIRED = "Hired", } export interface IApplication extends Document { - applicantId: Schema.Types.ObjectId; - teamPk: string; - rolePreferences: { role: string, subteamPk: string }[]; // Ordered array - stage: ApplicationStage; - responses: Record; - appliedAt: Date; - stageHistory: Array<{ + applicantId: Schema.Types.ObjectId; + teamPk: string; + rolePreferences: { role: string; subteamPk: string }[]; // Ordered array stage: ApplicationStage; - changedAt: Date; - changedBy?: string; - }>; - hiredRole?: string; - hiredSubteamPk?: string; - appDevInternalPk?: number; - stars: number; - notes?: string; + responses: Record; + appliedAt: Date; + stageHistory: Array<{ + stage: ApplicationStage; + changedAt: Date; + changedBy?: string; + }>; + hiredRole?: string; + hiredSubteamPk?: string; + appDevInternalPk?: number; + stars: number; + notes?: string; } -const ApplicationSchema = new Schema({ - applicantId: { type: Schema.Types.ObjectId, ref: 'Applicant', required: true, index: true }, - teamPk: { type: String, required: true, index: true }, - rolePreferences: { - type: [{ - role: { type: String, required: true }, - subteamPk: { type: String, required: true } - }], - required: true, - validate: { - validator: (v: any[]) => v && v.length > 0, - message: 'At least one role preference is required' - } - }, - stage: { - type: String, - enum: Object.values(ApplicationStage), - default: ApplicationStage.APPLIED, - required: true, - index: true - }, - responses: { - type: Schema.Types.Mixed, - required: true, - default: {} - }, - appliedAt: { type: Date, default: Date.now, required: true }, - appDevInternalPk: { type: Number, required: false }, - stageHistory: [{ - stage: { - type: String, - enum: Object.values(ApplicationStage), - required: true +const ApplicationSchema = new Schema( + { + applicantId: { + type: Schema.Types.ObjectId, + ref: "Applicant", + required: true, + index: true, + }, + teamPk: { type: String, required: true, index: true }, + rolePreferences: { + type: [ + { + role: { type: String, required: true }, + subteamPk: { type: String, required: true }, + }, + ], + required: true, + validate: { + validator: (v: any[]) => v && v.length > 0, + message: "At least one role preference is required", + }, + }, + stage: { + type: String, + enum: Object.values(ApplicationStage), + default: ApplicationStage.APPLIED, + required: true, + index: true, + }, + responses: { + type: Schema.Types.Mixed, + required: true, + default: {}, + }, + appliedAt: { type: Date, default: Date.now, required: true }, + appDevInternalPk: { type: Number, required: false }, + stageHistory: [ + { + stage: { + type: String, + enum: Object.values(ApplicationStage), + required: true, + }, + changedAt: { type: Date, default: Date.now }, + changedBy: { type: String }, + }, + ], + hiredRole: { type: String, required: false }, + hiredSubteamPk: { type: String, required: false }, + stars: { type: Number, required: true, default: 0, min: 0, max: 5 }, + notes: { type: String, required: false, maxlength: 10000 }, }, - changedAt: { type: Date, default: Date.now }, - changedBy: { type: String } - }], - hiredRole: { type: String, required: false }, - hiredSubteamPk: { type: String, required: false }, - stars: { type: Number, required: true, default: 0, min: 0, max: 5 }, - notes: { type: String, required: false, maxlength: 10000 } -}, { timestamps: true }); + { timestamps: true }, +); // Updated indexes for team-level queries ApplicationSchema.index({ teamPk: 1, stage: 1 }); ApplicationSchema.index({ applicantId: 1, teamPk: 1 }, { unique: true }); // One application per team per applicant -export const Application = model('Application', ApplicationSchema); - +export const Application = model( + "Application", + ApplicationSchema, +); diff --git a/peopleportal/server/src/models/Event.ts b/peopleportal/server/src/models/Event.ts index ba1fbe0..a2945ab 100644 --- a/peopleportal/server/src/models/Event.ts +++ b/peopleportal/server/src/models/Event.ts @@ -33,49 +33,58 @@ export interface IEvent extends Document { marketingChannels: string[]; } -const EventSchema = new Schema({ - eventName: { - type: String, - required: true - }, - eventDescription: { - type: String, - required: true - }, - creator: { - type: Number, - required: true - }, - startTime: { - type: Date, - required: true - }, - endTime: { - type: Date, - required: true - }, - location: { - type: String, - required: true - }, - scope: { // "public" | "exec" | "internal" - type: String, - required: true, - validate: { - validator: scope => SupportedScopes.includes(scope), - message: `scope must be one of ${SupportedScopes}` - } - }, - marketingChannels: { - type: [String], - required: true, - validate: { - validator: function(channels: string[]) { - return channels && channels.every(channel => SupportedMarketingChannels.includes(channel)); +const EventSchema = new Schema( + { + eventName: { + type: String, + required: true, + }, + eventDescription: { + type: String, + required: true, + }, + creator: { + type: Number, + required: true, + }, + startTime: { + type: Date, + required: true, + }, + endTime: { + type: Date, + required: true, + }, + location: { + type: String, + required: true, + }, + scope: { + // "public" | "exec" | "internal" + type: String, + required: true, + validate: { + validator: (scope) => SupportedScopes.includes(scope), + message: `scope must be one of ${SupportedScopes}`, + }, + }, + marketingChannels: { + type: [String], + required: true, + validate: { + validator: function (channels: string[]) { + return ( + channels && + channels.every((channel) => + SupportedMarketingChannels.includes(channel), + ) + ); + }, + message: `marketingChannels must be a string[] with only values from ${SupportedMarketingChannels}`, }, - message: `marketingChannels must be a string[] with only values from ${SupportedMarketingChannels}` - } + }, }, -}, {timestamps: true}); + { timestamps: true }, +); -export const Event = model('Event', EventSchema); \ No newline at end of file +export const Event = model("Event", EventSchema); diff --git a/peopleportal/server/src/models/EventRsvp.ts b/peopleportal/server/src/models/EventRsvp.ts index 60b201e..56d8ec4 100644 --- a/peopleportal/server/src/models/EventRsvp.ts +++ b/peopleportal/server/src/models/EventRsvp.ts @@ -19,32 +19,35 @@ import { Document, Schema, model } from "mongoose"; export enum RsvpStatus { - ACCEPT = 'Accept', - DECLINE = 'Decline', - CANCEL = 'Cancel', + ACCEPT = "Accept", + DECLINE = "Decline", + CANCEL = "Cancel", } export interface IRsvp extends Document { - eventId: string; // Event id of corresponding event. - status: RsvpStatus; - email: string; + eventId: string; // Event id of corresponding event. + status: RsvpStatus; + email: string; } -const rsvpSchema = new Schema({ - eventId: { - type: String, - required: true, - lowercase: true, +const rsvpSchema = new Schema( + { + eventId: { + type: String, + required: true, + lowercase: true, + }, + status: { + type: String, + enum: Object.values(RsvpStatus), + required: true, + }, + email: { + type: String, + required: true, + }, }, - status: { - type: String, - enum: Object.values(RsvpStatus), - required: true, - }, - email: { - type: String, - required: true, - }, -}, {timestamps: true}); + { timestamps: true }, +); -export const EventRsvp = model('RSVP', rsvpSchema); \ No newline at end of file +export const EventRsvp = model("RSVP", rsvpSchema); diff --git a/peopleportal/server/src/models/Invites.ts b/peopleportal/server/src/models/Invites.ts index 4871f29..8583a81 100644 --- a/peopleportal/server/src/models/Invites.ts +++ b/peopleportal/server/src/models/Invites.ts @@ -16,57 +16,63 @@ along with this program. If not, see . */ -import { Schema, model, Document } from 'mongoose'; +import { Schema, model, Document } from "mongoose"; export interface IInvite extends Document { - inviteName: string; - inviteEmail: string; - roleTitle: string; - teamName: string; - teamPk: string; - subteamPk: string; - inviterPk: number; - expiresAt: Date; + inviteName: string; + inviteEmail: string; + roleTitle: string; + teamName: string; + teamPk: string; + subteamPk: string; + inviterPk: number; + expiresAt: Date; } -const inviteSchema = new Schema({ - inviteName: { - type: String, - required: true, - minlength: [2, 'Name must be at least 2 characters'], - maxlength: [60, 'Name must be at most 60 characters'], - match: [/^[A-Za-z .'-]+$/, "Invalid name. Use only letters, spaces, apostrophes, periods, and hyphens."] - }, - inviteEmail: { - type: String, - required: true, - match: [/^\S+@\S+\.\S+$/, 'Please enter a valid email'], - }, - roleTitle: { - type: String, - required: true, - }, - teamName: { - type: String, - required: true, - }, - teamPk: { - type: String, - required: true, - }, - subteamPk: { - type: String, - required: true, - }, - inviterPk: { - type: Number, - required: true, - }, - expiresAt: { - type: Date, - required: true, - index: { expires: 0 } - } -}, { timestamps: true }); +const inviteSchema = new Schema( + { + inviteName: { + type: String, + required: true, + minlength: [2, "Name must be at least 2 characters"], + maxlength: [60, "Name must be at most 60 characters"], + match: [ + /^[A-Za-z .'-]+$/, + "Invalid name. Use only letters, spaces, apostrophes, periods, and hyphens.", + ], + }, + inviteEmail: { + type: String, + required: true, + match: [/^\S+@\S+\.\S+$/, "Please enter a valid email"], + }, + roleTitle: { + type: String, + required: true, + }, + teamName: { + type: String, + required: true, + }, + teamPk: { + type: String, + required: true, + }, + subteamPk: { + type: String, + required: true, + }, + inviterPk: { + type: Number, + required: true, + }, + expiresAt: { + type: Date, + required: true, + index: { expires: 0 }, + }, + }, + { timestamps: true }, +); -export const Invite = model('Invite', inviteSchema); +export const Invite = model("Invite", inviteSchema); diff --git a/peopleportal/server/src/models/MeetingAttendance.ts b/peopleportal/server/src/models/MeetingAttendance.ts index ad99c9b..96e91ce 100644 --- a/peopleportal/server/src/models/MeetingAttendance.ts +++ b/peopleportal/server/src/models/MeetingAttendance.ts @@ -16,10 +16,10 @@ along with this program. If not, see . */ -import { Schema, model, Document } from 'mongoose'; +import { Schema, model, Document } from "mongoose"; /** Whether an attendee is expected at the meeting or merely invited */ -export type AttendanceRole = 'required' | 'optional'; +export type AttendanceRole = "required" | "optional"; /** * One row of the attendance relation: links a single person (Authentik user PK) @@ -44,18 +44,24 @@ export interface IMeetingAttendance extends Document { updatedAt: Date; } -const MeetingAttendanceSchema = new Schema({ - meetingId: { type: String, required: true, index: true }, - teamPk: { type: String, required: true, index: true }, - seriesId: { type: String, required: true, index: true }, - userPk: { type: Number, required: true, index: true }, - role: { type: String, enum: ['required', 'optional'], required: true }, - present: { type: Boolean, default: false }, - markedBy: { type: Number, default: null }, - markedAt: { type: Date, default: null }, -}, { timestamps: true }); +const MeetingAttendanceSchema = new Schema( + { + meetingId: { type: String, required: true, index: true }, + teamPk: { type: String, required: true, index: true }, + seriesId: { type: String, required: true, index: true }, + userPk: { type: Number, required: true, index: true }, + role: { type: String, enum: ["required", "optional"], required: true }, + present: { type: Boolean, default: false }, + markedBy: { type: Number, default: null }, + markedAt: { type: Date, default: null }, + }, + { timestamps: true }, +); /* A person can appear at most once on a given meeting occurrence. */ MeetingAttendanceSchema.index({ meetingId: 1, userPk: 1 }, { unique: true }); -export const MeetingAttendance = model('MeetingAttendance', MeetingAttendanceSchema); +export const MeetingAttendance = model( + "MeetingAttendance", + MeetingAttendanceSchema, +); diff --git a/peopleportal/server/src/models/MeetingSubteamAttendance.ts b/peopleportal/server/src/models/MeetingSubteamAttendance.ts index 00a4dc3..2a489b9 100644 --- a/peopleportal/server/src/models/MeetingSubteamAttendance.ts +++ b/peopleportal/server/src/models/MeetingSubteamAttendance.ts @@ -16,8 +16,8 @@ along with this program. If not, see . */ -import { Schema, model, Document } from 'mongoose'; -import { AttendanceRole } from './MeetingAttendance'; +import { Schema, model, Document } from "mongoose"; +import { AttendanceRole } from "./MeetingAttendance"; /** * Invites an entire subteam to a meeting occurrence *by reference*: membership @@ -38,15 +38,24 @@ export interface IMeetingSubteamAttendance extends Document { updatedAt: Date; } -const MeetingSubteamAttendanceSchema = new Schema({ - meetingId: { type: String, required: true, index: true }, - teamPk: { type: String, required: true, index: true }, - seriesId: { type: String, required: true, index: true }, - subteamPk: { type: String, required: true, index: true }, - role: { type: String, enum: ['required', 'optional'], required: true }, -}, { timestamps: true }); +const MeetingSubteamAttendanceSchema = new Schema( + { + meetingId: { type: String, required: true, index: true }, + teamPk: { type: String, required: true, index: true }, + seriesId: { type: String, required: true, index: true }, + subteamPk: { type: String, required: true, index: true }, + role: { type: String, enum: ["required", "optional"], required: true }, + }, + { timestamps: true }, +); /* A subteam can appear at most once on a given meeting occurrence. */ -MeetingSubteamAttendanceSchema.index({ meetingId: 1, subteamPk: 1 }, { unique: true }); - -export const MeetingSubteamAttendance = model('MeetingSubteamAttendance', MeetingSubteamAttendanceSchema); +MeetingSubteamAttendanceSchema.index( + { meetingId: 1, subteamPk: 1 }, + { unique: true }, +); + +export const MeetingSubteamAttendance = model( + "MeetingSubteamAttendance", + MeetingSubteamAttendanceSchema, +); diff --git a/peopleportal/server/src/models/SubteamConfig.ts b/peopleportal/server/src/models/SubteamConfig.ts index 4b83d29..daf7f0b 100644 --- a/peopleportal/server/src/models/SubteamConfig.ts +++ b/peopleportal/server/src/models/SubteamConfig.ts @@ -16,23 +16,29 @@ along with this program. If not, see . */ -import { Schema, model, Document } from 'mongoose'; +import { Schema, model, Document } from "mongoose"; export interface ISubteamConfig extends Document { - subteamPk: string; - isRecruiting: boolean; - roles: string[]; - roleSpecificQuestions: Record; // role -> array of questions + subteamPk: string; + isRecruiting: boolean; + roles: string[]; + roleSpecificQuestions: Record; // role -> array of questions } -const SubteamConfigSchema = new Schema({ - subteamPk: { type: String, required: true, unique: true, index: true }, - isRecruiting: { type: Boolean, required: true, default: false }, - roles: [{ type: String, required: true }], - roleSpecificQuestions: { - type: Schema.Types.Mixed, - default: {} - } -}, { timestamps: true }); +const SubteamConfigSchema = new Schema( + { + subteamPk: { type: String, required: true, unique: true, index: true }, + isRecruiting: { type: Boolean, required: true, default: false }, + roles: [{ type: String, required: true }], + roleSpecificQuestions: { + type: Schema.Types.Mixed, + default: {}, + }, + }, + { timestamps: true }, +); -export const SubteamConfig = model('SubteamConfig', SubteamConfigSchema); +export const SubteamConfig = model( + "SubteamConfig", + SubteamConfigSchema, +); diff --git a/peopleportal/server/src/models/TeamCreationRequest.ts b/peopleportal/server/src/models/TeamCreationRequest.ts index 9cc682c..0eacc2d 100644 --- a/peopleportal/server/src/models/TeamCreationRequest.ts +++ b/peopleportal/server/src/models/TeamCreationRequest.ts @@ -20,9 +20,9 @@ import mongoose, { Schema, Document } from "mongoose"; import { APICreateTeamRequest } from "../controllers/OrgController"; export enum TeamCreationRequestStatus { - PENDING = 'PENDING', - APPROVED = 'APPROVED', - REJECTED = 'REJECTED' + PENDING = "PENDING", + APPROVED = "APPROVED", + REJECTED = "REJECTED", } export interface ITeamCreationRequest extends Document { @@ -37,26 +37,39 @@ export interface ITeamCreationRequest extends Document { createdAt: Date; } -const TeamCreationRequestSchema: Schema = new Schema({ - requestorPk: { type: Number, required: true }, - requestorName: { type: String, required: true }, - requestorEmail: { type: String, required: true }, - - createTeamRequest: { - friendlyName: { type: String, required: true }, - teamType: { type: String, required: true }, - seasonType: { type: String, required: true }, - seasonYear: { type: Number, required: true }, - teamStartDate: { type: String, required: true }, - teamEndDate: { type: String, required: true }, - description: { type: String, required: true }, - requestorRole: { type: String, required: true } - }, +const TeamCreationRequestSchema: Schema = new Schema( + { + requestorPk: { type: Number, required: true }, + requestorName: { type: String, required: true }, + requestorEmail: { type: String, required: true }, + + createTeamRequest: { + friendlyName: { type: String, required: true }, + teamType: { type: String, required: true }, + seasonType: { type: String, required: true }, + seasonYear: { type: Number, required: true }, + teamStartDate: { type: String, required: true }, + teamEndDate: { type: String, required: true }, + description: { type: String, required: true }, + requestorRole: { type: String, required: true }, + }, - status: { type: String, enum: Object.values(TeamCreationRequestStatus), default: TeamCreationRequestStatus.PENDING }, -}, { timestamps: true }); + status: { + type: String, + enum: Object.values(TeamCreationRequestStatus), + default: TeamCreationRequestStatus.PENDING, + }, + }, + { timestamps: true }, +); /* TTL Index: Expire after 24 hours (86400 seconds) */ -TeamCreationRequestSchema.index({ createdAt: 1 }, { expireAfterSeconds: 86400 }); +TeamCreationRequestSchema.index( + { createdAt: 1 }, + { expireAfterSeconds: 86400 }, +); -export const TeamCreationRequest = mongoose.model("TeamCreationRequest", TeamCreationRequestSchema); +export const TeamCreationRequest = mongoose.model( + "TeamCreationRequest", + TeamCreationRequestSchema, +); diff --git a/peopleportal/server/src/models/TeamMeeting.ts b/peopleportal/server/src/models/TeamMeeting.ts index b7b0458..4e0ee2e 100644 --- a/peopleportal/server/src/models/TeamMeeting.ts +++ b/peopleportal/server/src/models/TeamMeeting.ts @@ -16,7 +16,7 @@ along with this program. If not, see . */ -import { Schema, model, Document } from 'mongoose'; +import { Schema, model, Document } from "mongoose"; export interface ITeamMeeting extends Document { teamPk: string; @@ -39,15 +39,21 @@ export interface ITeamMeeting extends Document { updatedAt: Date; } -const TeamMeetingSchema = new Schema({ - teamPk: { type: String, required: true, index: true }, - seriesId: { type: String, required: true, index: true }, - name: { type: String, required: true }, - description: { type: String, default: '' }, - start: { type: Date, required: true }, - end: { type: Date, required: true }, - createdBy: { type: Number, required: true }, - visibleToAll: { type: Boolean, default: false }, -}, { timestamps: true }); +const TeamMeetingSchema = new Schema( + { + teamPk: { type: String, required: true, index: true }, + seriesId: { type: String, required: true, index: true }, + name: { type: String, required: true }, + description: { type: String, default: "" }, + start: { type: Date, required: true }, + end: { type: Date, required: true }, + createdBy: { type: Number, required: true }, + visibleToAll: { type: Boolean, default: false }, + }, + { timestamps: true }, +); -export const TeamMeeting = model('TeamMeeting', TeamMeetingSchema); +export const TeamMeeting = model( + "TeamMeeting", + TeamMeetingSchema, +); diff --git a/peopleportal/server/src/models/TeamRecruitingStatus.ts b/peopleportal/server/src/models/TeamRecruitingStatus.ts index 12b1aa5..045c65f 100644 --- a/peopleportal/server/src/models/TeamRecruitingStatus.ts +++ b/peopleportal/server/src/models/TeamRecruitingStatus.ts @@ -19,15 +19,21 @@ import { Document, model, Schema } from "mongoose"; export interface ITeamRecruitingStatus extends Document { - teamPk: string; - isRecruiting: boolean; - recruitingSubteamPks: string[]; + teamPk: string; + isRecruiting: boolean; + recruitingSubteamPks: string[]; } -const TeamRecruitingStatusSchema = new Schema({ - teamPk: { type: String, required: true, unique: true, index: true }, - isRecruiting: { type: Boolean, required: true, default: false }, - recruitingSubteamPks: [{ type: String, required: true }] -}, { timestamps: true }); +const TeamRecruitingStatusSchema = new Schema( + { + teamPk: { type: String, required: true, unique: true, index: true }, + isRecruiting: { type: Boolean, required: true, default: false }, + recruitingSubteamPks: [{ type: String, required: true }], + }, + { timestamps: true }, +); -export const TeamRecruitingStatus = model('TeamRecruitingStatus', TeamRecruitingStatusSchema); +export const TeamRecruitingStatus = model( + "TeamRecruitingStatus", + TeamRecruitingStatusSchema, +); diff --git a/peopleportal/server/src/models/UserReview.ts b/peopleportal/server/src/models/UserReview.ts index ca04930..03d5048 100644 --- a/peopleportal/server/src/models/UserReview.ts +++ b/peopleportal/server/src/models/UserReview.ts @@ -16,62 +16,69 @@ along with this program. If not, see . */ -import { Schema, model, Document } from 'mongoose'; +import { Schema, model, Document } from "mongoose"; const MAX_TITLE_LENGTH = 150; const MIN_REVIEW_LENGTH = 50; const MAX_REVIEW_LENGTH = 2000; - export interface IUserReview extends Document { - userId: number; - creatorId: number; - teamId: string; // The review will be regarding the user's work under this team - rating: number; - title: string; - content: string; - createdAt: Date; - updatedAt: Date; + userId: number; + creatorId: number; + teamId: string; // The review will be regarding the user's work under this team + rating: number; + title: string; + content: string; + createdAt: Date; + updatedAt: Date; } -const userReviewSchema = new Schema({ - userId: { - type: Number, - required: true, - index: true, - }, - creatorId: { - type: Number, - required: true, - }, - teamId: { - type: String, - required: true, - }, - rating: { - type: Number, - required: true, - validate: { - validator: (rating: number) => rating >= 1 && rating <= 5 && Number.isInteger(rating * 10), - message: "Rating must be between 1 and 5, and can only have up to 1 decimal place." - } - }, - title: { - type: String, - required: true, - validate: { - validator: (title: string) => title.length > 0 && title.length <= MAX_TITLE_LENGTH, - message: `Title must be non-empty and a maximum of ${MAX_TITLE_LENGTH} characters long.` - } - }, - content: { - type: String, - required: true, - validate: { - validator: (content: string) => content.length > MIN_REVIEW_LENGTH && content.length <= MAX_REVIEW_LENGTH, - message: `Content must be between ${MIN_REVIEW_LENGTH} and ${MAX_REVIEW_LENGTH} characters long.` - } - }, -}, { timestamps: true }); +const userReviewSchema = new Schema( + { + userId: { + type: Number, + required: true, + index: true, + }, + creatorId: { + type: Number, + required: true, + }, + teamId: { + type: String, + required: true, + }, + rating: { + type: Number, + required: true, + validate: { + validator: (rating: number) => + rating >= 1 && rating <= 5 && Number.isInteger(rating * 10), + message: + "Rating must be between 1 and 5, and can only have up to 1 decimal place.", + }, + }, + title: { + type: String, + required: true, + validate: { + validator: (title: string) => + title.length > 0 && title.length <= MAX_TITLE_LENGTH, + message: `Title must be non-empty and a maximum of ${MAX_TITLE_LENGTH} characters long.`, + }, + }, + content: { + type: String, + required: true, + validate: { + validator: (content: string) => + content.length > MIN_REVIEW_LENGTH && + content.length <= MAX_REVIEW_LENGTH, + message: `Content must be between ${MIN_REVIEW_LENGTH} and ${MAX_REVIEW_LENGTH} characters long.`, + }, + }, + }, + { timestamps: true }, +); -export const UserReview = model('UserReview', userReviewSchema); \ No newline at end of file +export const UserReview = model("UserReview", userReviewSchema); diff --git a/peopleportal/server/src/types/express-session.d.ts b/peopleportal/server/src/types/express-session.d.ts index 151c079..dbcb30d 100644 --- a/peopleportal/server/src/types/express-session.d.ts +++ b/peopleportal/server/src/types/express-session.d.ts @@ -1,34 +1,33 @@ +import "express-session"; +import { AuthorizedUser } from "../clients/OpenIdClient"; -import 'express-session'; -import { AuthorizedUser } from '../clients/OpenIdClient'; +declare module "express-session" { + interface SessionData { + authorizedUser?: AuthorizedUser; + accessToken?: string; + refreshToken?: string; + idToken?: string; + tokenExpiry?: number; + oidcState?: string; + state?: string; -declare module 'express-session' { - interface SessionData { - authorizedUser?: AuthorizedUser; - accessToken?: string; - refreshToken?: string; - idToken?: string; - tokenExpiry?: number; - oidcState?: string; - state?: string; + /* OTP Auth (Temporary Sessions) */ + tempsession?: { + otp?: string; + otpEmail?: string; + otpName?: string; + otpExpiry?: number; + jwt?: string; + user?: { + email: string; + name: string; + id: string; + }; - /* OTP Auth (Temporary Sessions) */ - tempsession?: { - otp?: string; - otpEmail?: string; - otpName?: string; - otpExpiry?: number; - jwt?: string; - user?: { - email: string, - name: string, - id: string, - } - - /* Redirect Logic */ - return_to?: string; - redirect_uri?: string; - state?: string; + /* Redirect Logic */ + return_to?: string; + redirect_uri?: string; + state?: string; + }; } - } } diff --git a/peopleportal/server/src/types/express.d.ts b/peopleportal/server/src/types/express.d.ts index 498ea1a..81dbe8a 100644 --- a/peopleportal/server/src/types/express.d.ts +++ b/peopleportal/server/src/types/express.d.ts @@ -2,12 +2,12 @@ import "express"; import { GetGroupInfoResponse } from "../clients/AuthentikClient/models"; export interface ExpressRequestBindleExtension { - teamInfo: GetGroupInfoResponse, - requestedPermissions: string[] + teamInfo: GetGroupInfoResponse; + requestedPermissions: string[]; } declare module "express" { interface Request { - bindle?: ExpressRequestBindleExtension + bindle?: ExpressRequestBindleExtension; } } diff --git a/peopleportal/server/src/utils/avatars.ts b/peopleportal/server/src/utils/avatars.ts index 3db96d1..6cb172a 100644 --- a/peopleportal/server/src/utils/avatars.ts +++ b/peopleportal/server/src/utils/avatars.ts @@ -25,7 +25,10 @@ import { s3Client, BUCKET_NAME } from "../clients/AWSClient/S3Client"; full day after any change, which is why an explicit invalidate helper was needed at every write site. Comparing avatarKey makes a stale hit impossible and leaves invalidateAvatarUrlCache as belt-and-braces. */ -const avatarUrlCache = new Map(); +const avatarUrlCache = new Map< + string, + { url: string; avatarKey: string; expiresAt: number } +>(); /* The cached entry must die before the URL it holds. Signing happens first and the entry is stored after, so an equal TTL always outlives its own URL. */ @@ -39,19 +42,26 @@ export function invalidateAvatarUrlCache(userPk: string | number): void { /** * Generates a pre-signed URL for downloading a user's avatar. * Uses in-memory caching to reduce S3 API calls. - * + * * @param userPk The PK of the user whose avatar to fetch * @param avatarKey The S3 key of the avatar image * @returns Promise The signed URL or an empty string if no avatar is provided */ -export async function signAvatarUrl(userPk: string | number | undefined, avatarKey?: string): Promise { +export async function signAvatarUrl( + userPk: string | number | undefined, + avatarKey?: string, +): Promise { if (!userPk) return ""; const pk = userPk.toString(); if (!avatarKey) return ""; const cached = avatarUrlCache.get(pk); - if (cached && cached.avatarKey === avatarKey && cached.expiresAt > Date.now()) { + if ( + cached && + cached.avatarKey === avatarKey && + cached.expiresAt > Date.now() + ) { return cached.url; } @@ -61,12 +71,14 @@ export async function signAvatarUrl(userPk: string | number | undefined, avatarK Key: avatarKey, }); - const url = await getSignedUrl(s3Client, command, { expiresIn: SIGNED_URL_TTL_SECONDS }); + const url = await getSignedUrl(s3Client, command, { + expiresIn: SIGNED_URL_TTL_SECONDS, + }); avatarUrlCache.set(pk, { url: url, avatarKey: avatarKey, - expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000 + expiresAt: Date.now() + CACHE_TTL_SECONDS * 1000, }); return url; diff --git a/peopleportal/server/src/utils/cache-envelope.ts b/peopleportal/server/src/utils/cache-envelope.ts index 1e4527d..59da069 100644 --- a/peopleportal/server/src/utils/cache-envelope.ts +++ b/peopleportal/server/src/utils/cache-envelope.ts @@ -18,12 +18,17 @@ export interface CacheEnvelope { value: T; } -export function wrapCacheValue(value: T, now: number = Date.now()): CacheEnvelope { +export function wrapCacheValue( + value: T, + now: number = Date.now(), +): CacheEnvelope { return { cachedAt: now, value }; } /** Guards against entries written before this format existed, or hand-edited. */ -export function isCacheEnvelope(candidate: unknown): candidate is CacheEnvelope { +export function isCacheEnvelope( + candidate: unknown, +): candidate is CacheEnvelope { return ( typeof candidate === "object" && candidate !== null && @@ -37,6 +42,10 @@ export function isCacheEnvelope(candidate: unknown): candidate is CacheEnvelo * `cachedAt` (clock skew between servers) counts as fresh rather than * triggering a refresh storm. */ -export function isCacheStale(envelope: CacheEnvelope, softTtlSeconds: number, now: number = Date.now()): boolean { +export function isCacheStale( + envelope: CacheEnvelope, + softTtlSeconds: number, + now: number = Date.now(), +): boolean { return now - envelope.cachedAt > softTtlSeconds * 1000; } diff --git a/peopleportal/server/src/utils/concurrency.ts b/peopleportal/server/src/utils/concurrency.ts index 65e4fc6..faf527e 100644 --- a/peopleportal/server/src/utils/concurrency.ts +++ b/peopleportal/server/src/utils/concurrency.ts @@ -14,7 +14,7 @@ export async function mapWithConcurrency( items: readonly T[], limit: number, - mapper: (item: T, index: number) => Promise + mapper: (item: T, index: number) => Promise, ): Promise[]> { if (items.length === 0) return []; @@ -30,7 +30,10 @@ export async function mapWithConcurrency( if (index >= items.length) return; try { - results[index] = { status: "fulfilled", value: await mapper(items[index]!, index) }; + results[index] = { + status: "fulfilled", + value: await mapper(items[index]!, index), + }; } catch (reason: unknown) { results[index] = { status: "rejected", reason }; } diff --git a/peopleportal/server/src/utils/errors.ts b/peopleportal/server/src/utils/errors.ts index 3220a07..c6a2ec5 100644 --- a/peopleportal/server/src/utils/errors.ts +++ b/peopleportal/server/src/utils/errors.ts @@ -17,7 +17,10 @@ */ export class ResourceAccessError extends Error { - constructor(public status: number, public message: string) { + constructor( + public status: number, + public message: string, + ) { super(message); this.name = "ResourceAccessError"; Object.setPrototypeOf(this, ResourceAccessError.prototype); @@ -25,7 +28,10 @@ export class ResourceAccessError extends Error { } export class CustomValidationError extends Error { - constructor(public status: number, public message: string) { + constructor( + public status: number, + public message: string, + ) { super(message); this.name = "CustomValidationError"; Object.setPrototypeOf(this, CustomValidationError.prototype); @@ -33,7 +39,10 @@ export class CustomValidationError extends Error { } export class SharedResourcesError extends Error { - constructor(public status: number, public message: string) { + constructor( + public status: number, + public message: string, + ) { super(message); this.name = "SharedResourcesError"; Object.setPrototypeOf(this, SharedResourcesError.prototype); diff --git a/peopleportal/server/src/utils/gitea-webhook-auth.ts b/peopleportal/server/src/utils/gitea-webhook-auth.ts index d303c28..eefe4bf 100644 --- a/peopleportal/server/src/utils/gitea-webhook-auth.ts +++ b/peopleportal/server/src/utils/gitea-webhook-auth.ts @@ -5,39 +5,48 @@ export const GITEA_WEBHOOK_AUTHORIZATION_HEADER = "Authorization"; const MIN_GITEA_WEBHOOK_SECRET_LENGTH = 32; export function getGiteaWebhookAuthorizationHeader(): string { - const secret = process.env.PEOPLEPORTAL_GITEA_WEBHOOK_SECRET; + const secret = process.env.PEOPLEPORTAL_GITEA_WEBHOOK_SECRET; - if (!secret || secret.length < MIN_GITEA_WEBHOOK_SECRET_LENGTH) { - throw new Error( - `PEOPLEPORTAL_GITEA_WEBHOOK_SECRET must be at least ${MIN_GITEA_WEBHOOK_SECRET_LENGTH} characters long` - ); - } + if (!secret || secret.length < MIN_GITEA_WEBHOOK_SECRET_LENGTH) { + throw new Error( + `PEOPLEPORTAL_GITEA_WEBHOOK_SECRET must be at least ${MIN_GITEA_WEBHOOK_SECRET_LENGTH} characters long`, + ); + } - return `Bearer ${secret}`; + return `Bearer ${secret}`; } export function isGiteaWebhookAuthorized(request: Request): boolean { - let expectedAuthorization: string; + let expectedAuthorization: string; - try { - expectedAuthorization = getGiteaWebhookAuthorizationHeader(); - } catch (_) { - return false; - } + try { + expectedAuthorization = getGiteaWebhookAuthorizationHeader(); + } catch (_) { + return false; + } - const suppliedAuthorization = request.get(GITEA_WEBHOOK_AUTHORIZATION_HEADER); - if (!suppliedAuthorization) return false; + const suppliedAuthorization = request.get( + GITEA_WEBHOOK_AUTHORIZATION_HEADER, + ); + if (!suppliedAuthorization) return false; - const expected = Buffer.from(expectedAuthorization, "utf8"); - const supplied = Buffer.from(suppliedAuthorization, "utf8"); + const expected = Buffer.from(expectedAuthorization, "utf8"); + const supplied = Buffer.from(suppliedAuthorization, "utf8"); - return expected.length === supplied.length && crypto.timingSafeEqual(expected, supplied); + return ( + expected.length === supplied.length && + crypto.timingSafeEqual(expected, supplied) + ); } -export function authenticateGiteaWebhook(request: Request, response: Response, next: NextFunction) { - if (!isGiteaWebhookAuthorized(request)) { - return response.status(401).send("Unauthorized"); - } +export function authenticateGiteaWebhook( + request: Request, + response: Response, + next: NextFunction, +) { + if (!isGiteaWebhookAuthorized(request)) { + return response.status(401).send("Unauthorized"); + } - return next(); + return next(); } diff --git a/peopleportal/server/src/utils/linkedin.ts b/peopleportal/server/src/utils/linkedin.ts index fdf949a..f8d0492 100644 --- a/peopleportal/server/src/utils/linkedin.ts +++ b/peopleportal/server/src/utils/linkedin.ts @@ -5,7 +5,9 @@ const LINKEDIN_PROFILE_PATH = /^\/in\/([^/]+)\/?$/i; * Validates and normalizes an optional LinkedIn profile URL. * Returns an empty string for a blank value and null for an invalid value. */ -export function normalizeLinkedInProfileUrl(value: string | null | undefined): string | null { +export function normalizeLinkedInProfileUrl( + value: string | null | undefined, +): string | null { /* OrgController calls this with body.linkedinUrl whenever it is not undefined, so a JSON body carrying an explicit null reaches here and used to throw a TypeError, turning a bad request into a 500. Treat any diff --git a/peopleportal/server/src/utils/operations.ts b/peopleportal/server/src/utils/operations.ts index b4a1c3b..bad2a27 100644 --- a/peopleportal/server/src/utils/operations.ts +++ b/peopleportal/server/src/utils/operations.ts @@ -16,12 +16,19 @@ along with this program. If not, see . */ -export function computeStringArrStateDiff(finalState: string[], currentState: string[]) { - const finalSet = new Set(finalState); - const currentSet = new Set(currentState); +export function computeStringArrStateDiff( + finalState: string[], + currentState: string[], +) { + const finalSet = new Set(finalState); + const currentSet = new Set(currentState); - const additions = Array.from(finalSet).filter(user => !currentSet.has(user)); - const deletions = Array.from(currentSet).filter(user => !finalSet.has(user)); + const additions = Array.from(finalSet).filter( + (user) => !currentSet.has(user), + ); + const deletions = Array.from(currentSet).filter( + (user) => !finalSet.has(user), + ); - return { additions, deletions }; -} \ No newline at end of file + return { additions, deletions }; +} diff --git a/peopleportal/server/src/utils/s3-validation.ts b/peopleportal/server/src/utils/s3-validation.ts index 3d50382..57ab5d3 100644 --- a/peopleportal/server/src/utils/s3-validation.ts +++ b/peopleportal/server/src/utils/s3-validation.ts @@ -17,7 +17,6 @@ along with this program. If not, see . */ - import { s3Client, BUCKET_NAME } from "../clients/AWSClient/S3Client"; import { GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; @@ -26,23 +25,28 @@ export const FILE_SIGNATURES = { JPEG: "ffd8ff", GIF: "47494638", WEBP: "52494646", // RIFF - PDF: "25504446" // %PDF + PDF: "25504446", // %PDF }; /** * Validates the file signature (magic numbers) of a file stored in S3. - * + * * @param key The S3 key of the file to validate. * @param allowedSignatures An array of allowed hexadecimal signatures. * @returns Promise True if the file signature matches any of the allowed signatures, false otherwise. */ -export async function validateS3FileSignature(key: string, allowedSignatures: string[]): Promise { +export async function validateS3FileSignature( + key: string, + allowedSignatures: string[], +): Promise { try { - const { Body } = await s3Client.send(new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: key, - Range: "bytes=0-7" - })); + const { Body } = await s3Client.send( + new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + Range: "bytes=0-7", + }), + ); if (!Body) return false; @@ -51,23 +55,29 @@ export async function validateS3FileSignature(key: string, allowedSignatures: st for (let i = 0; i < byteArray.length; i++) { const byte = byteArray[i]; if (byte !== undefined) { - header += byte.toString(16).padStart(2, '0'); + header += byte.toString(16).padStart(2, "0"); } } - return allowedSignatures.some(sig => header.startsWith(sig.toLowerCase())); + return allowedSignatures.some((sig) => + header.startsWith(sig.toLowerCase()), + ); } catch (e) { console.error(`Signature check failed for key: ${key}`, e); return false; } } -export async function getS3ObjectBytes(key: string): Promise { +export async function getS3ObjectBytes( + key: string, +): Promise { try { - const { Body } = await s3Client.send(new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: key - })); + const { Body } = await s3Client.send( + new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }), + ); if (!Body) return null; return await Body.transformToByteArray(); } catch (e) { @@ -78,9 +88,10 @@ export async function getS3ObjectBytes(key: string): Promise export async function deleteTempAvatar(key: string): Promise { try { - await s3Client.send(new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key })); + await s3Client.send( + new DeleteObjectCommand({ Bucket: BUCKET_NAME, Key: key }), + ); } catch (e) { console.error(`Failed to delete temp avatar: ${key}`, e); } } - diff --git a/peopleportal/server/src/utils/services.ts b/peopleportal/server/src/utils/services.ts index 1e0b6a5..7c01335 100644 --- a/peopleportal/server/src/utils/services.ts +++ b/peopleportal/server/src/utils/services.ts @@ -27,14 +27,14 @@ const getReservedTeamNames = (): Set => { const config = ENABLED_SERVICE_TEAMS[serviceTeamName]; if (config && config.subteams) { - config.subteams.forEach(subteam => { + config.subteams.forEach((subteam) => { reservedNames.add(subteam.uniqueName); }); } } return reservedNames; -} +}; /* Define Flattened Set of Service Teams */ -export const ENABLED_SERVICE_TEAM_NAMES: Set = getReservedTeamNames(); \ No newline at end of file +export const ENABLED_SERVICE_TEAM_NAMES: Set = getReservedTeamNames(); diff --git a/peopleportal/server/src/utils/strings.ts b/peopleportal/server/src/utils/strings.ts index d85e2fa..e2fb56c 100644 --- a/peopleportal/server/src/utils/strings.ts +++ b/peopleportal/server/src/utils/strings.ts @@ -16,20 +16,23 @@ along with this program. If not, see . */ -import crypto from 'crypto'; -import { CustomValidationError } from './errors'; +import crypto from "crypto"; +import { CustomValidationError } from "./errors"; export function generateSecureRandomString(length: number) { - return crypto.randomBytes(Math.ceil(length / 2)).toString('hex').slice(0, length); + return crypto + .randomBytes(Math.ceil(length / 2)) + .toString("hex") + .slice(0, length); } export function sanitizeGroupName(str: string) { - // Remove all non-alphanumeric and non-hyphen characters - // \w matches [A-Za-z0-9_], so replace underscores by removing them after - // [^-] will exclude anything that isn't a letter, a number, or a hyphen/underscore + // Remove all non-alphanumeric and non-hyphen characters + // \w matches [A-Za-z0-9_], so replace underscores by removing them after + // [^-] will exclude anything that isn't a letter, a number, or a hyphen/underscore - let sanitized = str.replace(/[^A-Za-z0-9-_]/g, ''); - return sanitized; + let sanitized = str.replace(/[^A-Za-z0-9-_]/g, ""); + return sanitized; } /** @@ -41,48 +44,60 @@ export function sanitizeGroupName(str: string) { * ("HELLO" has no lowercase, so it is treated as an acronym and preserved.) */ export function capitalizeString(str: string): string { - if (!str) return str; - return str.split(' ').map(word => { - if (word.length === 0) return ""; - - // 1. Preserve All Caps or Symbols (No lowercase letters) - // Example: "UI/UX", "HELLO", "USA" - if (/^[^a-z]*$/.test(word)) return word; - - // 2. Preserve Acronym Plurals - // Example: "PMs", "SWEs", "APIs" - if (/^[A-Z]+s$/.test(word)) return word; - - // 3. Default: Title Case (First Upper, Rest Lower) - return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); - }).join(' '); + if (!str) return str; + return str + .split(" ") + .map((word) => { + if (word.length === 0) return ""; + + // 1. Preserve All Caps or Symbols (No lowercase letters) + // Example: "UI/UX", "HELLO", "USA" + if (/^[^a-z]*$/.test(word)) return word; + + // 2. Preserve Acronym Plurals + // Example: "PMs", "SWEs", "APIs" + if (/^[A-Z]+s$/.test(word)) return word; + + // 3. Default: Title Case (First Upper, Rest Lower) + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); + }) + .join(" "); } /** * Normalizes a full name into FirstLast format, removing middle names and hyphens while capitalizing segments. */ export function sanitizeUserFullName(fullName: string): string { - if (!fullName || typeof fullName !== 'string') return ''; - - const parts = fullName.trim().split(/\s+/); - if (parts.length === 0) return ''; - - const firstNameRaw = parts[0] ?? ''; - const lastNameRaw = parts.length > 1 ? (parts[parts.length - 1] ?? '') : ''; - - const normalizeSegment = (str: string): string => { - if (!str) return ''; - return str.split('-').map(segment => { - return segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase(); - }).join(''); - }; - - /* The result becomes an AWS IAM session name, which permits only + if (!fullName || typeof fullName !== "string") return ""; + + const parts = fullName.trim().split(/\s+/); + if (parts.length === 0) return ""; + + const firstNameRaw = parts[0] ?? ""; + const lastNameRaw = parts.length > 1 ? (parts[parts.length - 1] ?? "") : ""; + + const normalizeSegment = (str: string): string => { + if (!str) return ""; + return str + .split("-") + .map((segment) => { + return ( + segment.charAt(0).toUpperCase() + + segment.slice(1).toLowerCase() + ); + }) + .join(""); + }; + + /* The result becomes an AWS IAM session name, which permits only [\w+=,.@-]. Apostrophes survived normalizeSegment, so "Mary-Kate O'Neil" produced "MaryKateO'neil" and STS rejected the console link outright. */ - const toSessionSafe = (str: string): string => str.replace(/[^\w+=,.@-]/g, ''); + const toSessionSafe = (str: string): string => + str.replace(/[^\w+=,.@-]/g, ""); - return toSessionSafe(normalizeSegment(firstNameRaw) + normalizeSegment(lastNameRaw)); + return toSessionSafe( + normalizeSegment(firstNameRaw) + normalizeSegment(lastNameRaw), + ); } /** @@ -94,51 +109,57 @@ export function sanitizeUserFullName(fullName: string): string { * Returns the trimmed name if valid; throws CustomValidationError otherwise. */ export function validatePersonName(name: string): string { - const trimmed = (name ?? '').trim(); - - if (trimmed.length < 2 || trimmed.length > 60) { - throw new CustomValidationError( - 400, - 'Invalid name. Must be between 2 and 60 characters.' - ); - } - - // Allow letters in any case, spaces, apostrophes, periods, and hyphens - const allowed = /^[A-Za-z .'-]+$/; - if (!allowed.test(trimmed)) { - throw new CustomValidationError( - 400, - "Invalid name. Use only letters, spaces, apostrophes, periods, and hyphens." - ); - } - - // Enforce presence of first and last name (at least two name parts with letters) - // Example valid: "Jane Doe", "Mary-Kate O'Neil", "J. Smith" - const parts = trimmed.split(/\s+/).filter(p => p.length > 0); - const alphaParts = parts.filter(p => /[A-Za-z]/.test(p)); - if (alphaParts.length < 2) { - throw new CustomValidationError(400, 'Please enter a first and last name.'); - } - - return trimmed; + const trimmed = (name ?? "").trim(); + + if (trimmed.length < 2 || trimmed.length > 60) { + throw new CustomValidationError( + 400, + "Invalid name. Must be between 2 and 60 characters.", + ); + } + + // Allow letters in any case, spaces, apostrophes, periods, and hyphens + const allowed = /^[A-Za-z .'-]+$/; + if (!allowed.test(trimmed)) { + throw new CustomValidationError( + 400, + "Invalid name. Use only letters, spaces, apostrophes, periods, and hyphens.", + ); + } + + // Enforce presence of first and last name (at least two name parts with letters) + // Example valid: "Jane Doe", "Mary-Kate O'Neil", "J. Smith" + const parts = trimmed.split(/\s+/).filter((p) => p.length > 0); + const alphaParts = parts.filter((p) => /[A-Za-z]/.test(p)); + if (alphaParts.length < 2) { + throw new CustomValidationError( + 400, + "Please enter a first and last name.", + ); + } + + return trimmed; } /** * Formats a bindle access error message listing team owners and missing bindles. */ -export function formatBindleAccessError(owners: string[], missingBindles: string[]): string { - const formatList = (items: string[], conjunction: string) => { - if (items.length === 0) return ""; - if (items.length === 1) return items[0]; - const last = items[items.length - 1]; - const rest = items.slice(0, -1).join(", "); - return `${rest} ${conjunction} ${last}`; - }; - - const ownersStr = formatList(owners, "or") || "an administrator"; - const bindlesStr = formatList(missingBindles, "and"); - - return `You do not have permission to access this resource. Please ask ${ownersStr} to grant you the ${bindlesStr} bindle${missingBindles.length === 1 ? "" : "s"}.`; +export function formatBindleAccessError( + owners: string[], + missingBindles: string[], +): string { + const formatList = (items: string[], conjunction: string) => { + if (items.length === 0) return ""; + if (items.length === 1) return items[0]; + const last = items[items.length - 1]; + const rest = items.slice(0, -1).join(", "); + return `${rest} ${conjunction} ${last}`; + }; + + const ownersStr = formatList(owners, "or") || "an administrator"; + const bindlesStr = formatList(missingBindles, "and"); + + return `You do not have permission to access this resource. Please ask ${ownersStr} to grant you the ${bindlesStr} bindle${missingBindles.length === 1 ? "" : "s"}.`; } /** @@ -149,35 +170,38 @@ export function formatBindleAccessError(owners: string[], missingBindles: string * @throws Error if invalid */ export function validateTeamName(name: string): string { - if (name.length < 3 || name.length > 25) { - throw new CustomValidationError( - 400, - "Invalid Team Name. Must be between 3 and 25 characters." - ); - } - - // Allow letters, numbers, spaces, and apostrophes - const regex = /^[a-zA-Z0-9' ]+$/; - if (!regex.test(name)) { - throw new CustomValidationError( - 400, - "Invalid Team Name. Use only letters, numbers, spaces, and apostrophes." - ); - } - - // Must contain at least one letter - if (!/[a-zA-Z]/.test(name)) { - throw new CustomValidationError( - 400, - "Invalid Team Name. Must contain at least one letter." - ); - } - - // Title Case Formatting - return name.split(' ').map(word => { - if (word.length === 0) return ""; - return word.charAt(0).toUpperCase() + word.slice(1); - }).join(' '); + if (name.length < 3 || name.length > 25) { + throw new CustomValidationError( + 400, + "Invalid Team Name. Must be between 3 and 25 characters.", + ); + } + + // Allow letters, numbers, spaces, and apostrophes + const regex = /^[a-zA-Z0-9' ]+$/; + if (!regex.test(name)) { + throw new CustomValidationError( + 400, + "Invalid Team Name. Use only letters, numbers, spaces, and apostrophes.", + ); + } + + // Must contain at least one letter + if (!/[a-zA-Z]/.test(name)) { + throw new CustomValidationError( + 400, + "Invalid Team Name. Must contain at least one letter.", + ); + } + + // Title Case Formatting + return name + .split(" ") + .map((word) => { + if (word.length === 0) return ""; + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(" "); } /** @@ -185,7 +209,7 @@ export function validateTeamName(name: string): string { * @returns GPL v3 License Text */ export function getGPLv3License() { - return `People Portal Server + return `People Portal Server Copyright (C) ${new Date().getFullYear()} Atheesh Thirumalairajan This program is free software: you can redistribute it and/or modify @@ -200,4 +224,4 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see .`; -} \ No newline at end of file +} diff --git a/peopleportal/server/src/utils/types.ts b/peopleportal/server/src/utils/types.ts index c502bb7..d966050 100644 --- a/peopleportal/server/src/utils/types.ts +++ b/peopleportal/server/src/utils/types.ts @@ -16,10 +16,11 @@ along with this program. If not, see . */ - -export type DeepPartial = T extends object ? { - [P in keyof T]?: DeepPartial; -} : T; +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; import type { FlattenMaps, Types } from "mongoose"; @@ -27,4 +28,5 @@ import type { FlattenMaps, Types } from "mongoose"; * The shape a Mongoose document takes after .lean()/.toJSON(). * Shared so tsoa sees a single model definition across controllers. */ -export type DocumentJSON = FlattenMaps & Required<{ _id: Types.ObjectId }> & { __v: number }; +export type DocumentJSON = FlattenMaps & + Required<{ _id: Types.ObjectId }> & { __v: number }; diff --git a/peopleportal/webui/src/App.tsx b/peopleportal/webui/src/App.tsx index 1c5624a..41381c8 100644 --- a/peopleportal/webui/src/App.tsx +++ b/peopleportal/webui/src/App.tsx @@ -16,26 +16,29 @@ along with this program. If not, see . */ -import { Route, Routes } from 'react-router-dom' -import { UserOnboarding } from './pages/UserOnboarding' -import { Toaster } from 'sonner' -import { CorpDashboard } from './pages/CorpDashboard' -import { ThemeProvider } from './components/fabric/ThemeProvider' -import { ATSDashboard } from './pages/ATSDashboard' +import { Route, Routes } from "react-router-dom"; +import { UserOnboarding } from "./pages/UserOnboarding"; +import { Toaster } from "sonner"; +import { CorpDashboard } from "./pages/CorpDashboard"; +import { ThemeProvider } from "./components/fabric/ThemeProvider"; +import { ATSDashboard } from "./pages/ATSDashboard"; function App() { - return ( - -
- - - } /> - } /> - } /> - -
-
- ) + return ( + +
+ + + } /> + } + /> + } /> + +
+
+ ); } -export default App +export default App; diff --git a/peopleportal/webui/src/commons/config.ts b/peopleportal/webui/src/commons/config.ts index 41b09e9..33c41b2 100644 --- a/peopleportal/webui/src/commons/config.ts +++ b/peopleportal/webui/src/commons/config.ts @@ -1,2 +1 @@ - -export const PEOPLEPORTAL_SERVER_ENDPOINT = "" \ No newline at end of file +export const PEOPLEPORTAL_SERVER_ENDPOINT = ""; diff --git a/peopleportal/webui/src/commons/strings.ts b/peopleportal/webui/src/commons/strings.ts index 8b32440..b65e5d7 100644 --- a/peopleportal/webui/src/commons/strings.ts +++ b/peopleportal/webui/src/commons/strings.ts @@ -1 +1 @@ -export const ORGANIZATION_NAME = "App Dev Club" \ No newline at end of file +export const ORGANIZATION_NAME = "App Dev Club"; diff --git a/peopleportal/webui/src/components/app-sidebar.tsx b/peopleportal/webui/src/components/app-sidebar.tsx index 9f1be9c..b9cdfcb 100644 --- a/peopleportal/webui/src/components/app-sidebar.tsx +++ b/peopleportal/webui/src/components/app-sidebar.tsx @@ -16,8 +16,8 @@ along with this program. If not, see . */ -import { Link } from "react-router-dom" -import * as React from "react" +import { Link } from "react-router-dom"; +import * as React from "react"; import { ArchiveIcon, LayoutListIcon, @@ -34,10 +34,10 @@ import { TicketIcon, Users, UsersRoundIcon, -} from "lucide-react" +} from "lucide-react"; -import { NavMain } from "@/components/nav-main" -import { NavUser } from "@/components/nav-user" +import { NavMain } from "@/components/nav-main"; +import { NavUser } from "@/components/nav-user"; import { Sidebar, SidebarContent, @@ -46,9 +46,9 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, -} from "@/components/ui/sidebar" -import logo from "../assets/logo.svg" -import type { CorpUserInfo } from "@/pages/CorpDashboard" +} from "@/components/ui/sidebar"; +import logo from "../assets/logo.svg"; +import type { CorpUserInfo } from "@/pages/CorpDashboard"; const buildNavMain = (isExecutive: boolean) => { const sections = [ @@ -58,7 +58,11 @@ const buildNavMain = (isExecutive: boolean) => { icon: Building2, isActive: true, items: [ - { icon: PersonStandingIcon, title: "People", url: "/org/people" }, + { + icon: PersonStandingIcon, + title: "People", + url: "/org/people", + }, { icon: UsersRoundIcon, title: "Teams", url: "/org/teams" }, { icon: NetworkIcon, title: "Org Chart", url: "/org/orgchart" }, ], @@ -68,8 +72,16 @@ const buildNavMain = (isExecutive: boolean) => { url: "#", icon: Terminal, items: [ - { icon: FolderGit2Icon, title: "Source Code Repository", url: "https://git.appdevclub.com" }, - { icon: FingerprintIcon, title: "Identity Management Portal", url: "https://auth.appdevclub.com" }, + { + icon: FolderGit2Icon, + title: "Source Code Repository", + url: "https://git.appdevclub.com", + }, + { + icon: FingerprintIcon, + title: "Identity Management Portal", + url: "https://auth.appdevclub.com", + }, ], }, { @@ -85,11 +97,19 @@ const buildNavMain = (isExecutive: boolean) => { url: "#", icon: BookOpen, items: [ - { icon: ScaleIcon, title: "Licensing", url: "/platform/license" }, - { icon: LifeBuoyIcon, title: "Support Docs", url: "https://wiki.appdevclub.com/people-portal-user-guide/intro" }, + { + icon: ScaleIcon, + title: "Licensing", + url: "/platform/license", + }, + { + icon: LifeBuoyIcon, + title: "Support Docs", + url: "https://wiki.appdevclub.com/people-portal-user-guide/intro", + }, ], }, - ] + ]; if (isExecutive) { sections.push({ @@ -97,16 +117,26 @@ const buildNavMain = (isExecutive: boolean) => { url: "#", icon: CrownIcon, items: [ - { icon: LayoutListIcon, title: "Active Teams", url: "/exec/active-teams" }, - { icon: ArchiveIcon, title: "Archive Teams", url: "/exec/archive-teams" }, + { + icon: LayoutListIcon, + title: "Active Teams", + url: "/exec/active-teams", + }, + { + icon: ArchiveIcon, + title: "Archive Teams", + url: "/exec/archive-teams", + }, ], - }) + }); } - return sections -} + return sections; +}; -export function AppSidebar({ ...props }: React.ComponentProps & { userInfo: CorpUserInfo }) { +export function AppSidebar({ + ...props +}: React.ComponentProps & { userInfo: CorpUserInfo }) { return ( @@ -118,8 +148,12 @@ export function AppSidebar({ ...props }: React.ComponentProps & ADC Logo
- App Dev Club - People Portal Platform + + App Dev Club + + + People Portal Platform +
@@ -130,14 +164,16 @@ export function AppSidebar({ ...props }: React.ComponentProps & - +
- ) + ); } diff --git a/peopleportal/webui/src/components/blocks/CalendarView.tsx b/peopleportal/webui/src/components/blocks/CalendarView.tsx index 09024b4..f8053fa 100644 --- a/peopleportal/webui/src/components/blocks/CalendarView.tsx +++ b/peopleportal/webui/src/components/blocks/CalendarView.tsx @@ -21,239 +21,354 @@ import { format, isSameDay, set } from "date-fns"; import React, { useMemo, useState, type ComponentType } from "react"; export type CalendarEvent = { - start: Date; - end: Date; + start: Date; + end: Date; }; /* Drag math is easiest in decimal hours; everything the consumer sees is a Date. */ -const dateToDecimalHour = (date: Date): number => date.getHours() + date.getMinutes() / 60; +const dateToDecimalHour = (date: Date): number => + date.getHours() + date.getMinutes() / 60; const decimalHourToDate = (base: Date, decimal: number): Date => - set(base, { - hours: Math.floor(decimal), - minutes: Math.round((decimal - Math.floor(decimal)) * 60), - seconds: 0, - milliseconds: 0, - }); + set(base, { + hours: Math.floor(decimal), + minutes: Math.round((decimal - Math.floor(decimal)) * 60), + seconds: 0, + milliseconds: 0, + }); type CalendarViewProps = { - /** Visible day columns, controlled by the parent. */ - days: Date[]; - events?: T[]; - editable?: boolean; - onNewEvent?: (start: Date, end: Date) => void; - /** Dims a column and blocks editing on it (e.g. days outside the team's lifespan). */ - isDateDisabled?: (date: Date) => boolean; - options?: { - start?: number; - end?: number; - snapMinutes?: number; - }; - component?: ComponentType<{ event: T }>; - previewComponent?: ComponentType<{ event: CalendarEvent }>; + /** Visible day columns, controlled by the parent. */ + days: Date[]; + events?: T[]; + editable?: boolean; + onNewEvent?: (start: Date, end: Date) => void; + /** Dims a column and blocks editing on it (e.g. days outside the team's lifespan). */ + isDateDisabled?: (date: Date) => boolean; + options?: { + start?: number; + end?: number; + snapMinutes?: number; + }; + component?: ComponentType<{ event: T }>; + previewComponent?: ComponentType<{ event: CalendarEvent }>; }; -export const CalendarView = ({ days, events = [], editable = true, onNewEvent, isDateDisabled, options, component, previewComponent }: CalendarViewProps) => { - const START_HOUR = options?.start ?? 8; - const END_HOUR = options?.end ?? 20; - const SNAP_MINUTES = options?.snapMinutes ?? 15; - const TOTAL_HOURS = END_HOUR - START_HOUR; - const dayCount = days.length; +export const CalendarView = ({ + days, + events = [], + editable = true, + onNewEvent, + isDateDisabled, + options, + component, + previewComponent, +}: CalendarViewProps) => { + const START_HOUR = options?.start ?? 8; + const END_HOUR = options?.end ?? 20; + const SNAP_MINUTES = options?.snapMinutes ?? 15; + const TOTAL_HOURS = END_HOUR - START_HOUR; + const dayCount = days.length; - const [isDragging, setIsDragging] = useState(false); - const [newEvent, setNewEvent] = useState<{ day: number; start: number; end: number } | null>(null); - const [dragAnchor, setDragAnchor] = useState<{ day: number; hour: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [newEvent, setNewEvent] = useState<{ + day: number; + start: number; + end: number; + } | null>(null); + const [dragAnchor, setDragAnchor] = useState<{ + day: number; + hour: number; + } | null>(null); - const positionedEvents = useMemo(() => { - const positioned: Array<{ event: T; day: number; start: number; end: number; column: number; columnCount: number }> = []; + const positionedEvents = useMemo(() => { + const positioned: Array<{ + event: T; + day: number; + start: number; + end: number; + column: number; + columnCount: number; + }> = []; - for (let day = 0; day < dayCount; day += 1) { - const date = days[day]; - const dayEvents = events - .map((event, index) => ({ event, index })) - .filter(({ event }) => isSameDay(event.start, date)) - .map(({ event, index }) => ({ event, index, start: dateToDecimalHour(event.start), end: dateToDecimalHour(event.end) })) - .sort((a, b) => a.start - b.start || a.end - b.end || a.index - b.index); + for (let day = 0; day < dayCount; day += 1) { + const date = days[day]; + const dayEvents = events + .map((event, index) => ({ event, index })) + .filter(({ event }) => isSameDay(event.start, date)) + .map(({ event, index }) => ({ + event, + index, + start: dateToDecimalHour(event.start), + end: dateToDecimalHour(event.end), + })) + .sort( + (a, b) => + a.start - b.start || a.end - b.end || a.index - b.index, + ); - let group: Array<{ event: T; day: number; start: number; end: number; column: number }> = []; - let columnEnds: number[] = []; - let groupEnd = -Infinity; + let group: Array<{ + event: T; + day: number; + start: number; + end: number; + column: number; + }> = []; + let columnEnds: number[] = []; + let groupEnd = -Infinity; - const flush = () => { - const columnCount = columnEnds.length; - positioned.push(...group.map(item => ({ ...item, columnCount }))); - }; + const flush = () => { + const columnCount = columnEnds.length; + positioned.push( + ...group.map((item) => ({ ...item, columnCount })), + ); + }; - for (const { event, start, end } of dayEvents) { - const startsNewGroup = group.length > 0 && start >= groupEnd; - if (startsNewGroup) { - flush(); - group = []; - columnEnds = []; - groupEnd = -Infinity; - } + for (const { event, start, end } of dayEvents) { + const startsNewGroup = group.length > 0 && start >= groupEnd; + if (startsNewGroup) { + flush(); + group = []; + columnEnds = []; + groupEnd = -Infinity; + } + + let column = columnEnds.findIndex( + (columnEnd) => start >= columnEnd, + ); + if (column < 0) { + column = columnEnds.length; + columnEnds.push(end); + } else { + columnEnds[column] = end; + } - let column = columnEnds.findIndex(columnEnd => start >= columnEnd); - if (column < 0) { - column = columnEnds.length; - columnEnds.push(end); - } else { - columnEnds[column] = end; + group.push({ event, day, start, end, column }); + groupEnd = Math.max(groupEnd, end); + } + + flush(); } - group.push({ event, day, start, end, column }); - groupEnd = Math.max(groupEnd, end); - } + return positioned; + }, [days, dayCount, events]); - flush(); + function getDragRange( + anchor: number, + targetHour: number, + ): [number, number] { + return targetHour >= anchor + ? [anchor, targetHour] + : [targetHour, anchor]; } - return positioned; - }, [days, dayCount, events]); - - function getDragRange(anchor: number, targetHour: number): [number, number] { - return targetHour >= anchor ? [anchor, targetHour] : [targetHour, anchor]; - } + function getTimeFromPosition(e: React.MouseEvent): { + day: number; + hour: number; + } { + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; - function getTimeFromPosition(e: React.MouseEvent): { day: number; hour: number } { - const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; + const day = Math.min( + dayCount - 1, + Math.max(0, Math.floor((x / rect.width) * dayCount)), + ); + let hour = Math.min( + END_HOUR, + Math.max(START_HOUR, (y / rect.height) * TOTAL_HOURS + START_HOUR), + ); - const day = Math.min(dayCount - 1, Math.max(0, Math.floor((x / rect.width) * dayCount))); - let hour = Math.min(END_HOUR, Math.max(START_HOUR, (y / rect.height) * TOTAL_HOURS + START_HOUR)); + if (e.shiftKey && SNAP_MINUTES > 0) { + const step = SNAP_MINUTES / 60; + hour = Math.min( + END_HOUR, + Math.max(START_HOUR, Math.round(hour / step) * step), + ); + } - if (e.shiftKey && SNAP_MINUTES > 0) { - const step = SNAP_MINUTES / 60; - hour = Math.min(END_HOUR, Math.max(START_HOUR, Math.round(hour / step) * step)); + return { day, hour }; } - return { day, hour }; - } - - return ( -
-
- {Array.from({ length: TOTAL_HOURS + 1 }).map((_, index) => ( -
-
- {index % 2 === 0 && ( -

- {format(new Date(0, 0, 0, 0, (index + START_HOUR) * 60), "h a")} -

- )} -
-
- {days.map((date, dayIndex) => ( - - {index === 0 && ( - <> - {format(date, "EEE")} - - {format(date, "MMM d")} - - - )} - - ))} -
-
- ))} + return ( +
+
+ {Array.from({ length: TOTAL_HOURS + 1 }).map((_, index) => ( +
+
+ {index % 2 === 0 && ( +

+ {format( + new Date( + 0, + 0, + 0, + 0, + (index + START_HOUR) * 60, + ), + "h a", + )} +

+ )} +
+
+ {days.map((date, dayIndex) => ( + + {index === 0 && ( + <> + {format(date, "EEE")} + + {format(date, "MMM d")} + + + )} + + ))} +
+
+ ))} -
{ - if (!editable) return; - if (e.target !== e.currentTarget) return; - const { day, hour } = getTimeFromPosition(e); - if (isDateDisabled?.(days[day])) return; - setDragAnchor({ day, hour }); - setIsDragging(true); - setNewEvent({ day, start: hour, end: hour }); - }} - onMouseUp={() => { - if (!editable) return; - setIsDragging(false); - setDragAnchor(null); - if (!newEvent || newEvent.end <= newEvent.start) { - setNewEvent(null); - return; - } - const base = days[newEvent.day]; - onNewEvent?.(decimalHourToDate(base, newEvent.start), decimalHourToDate(base, newEvent.end)); - setNewEvent(null); - }} - onMouseLeave={() => { - if (!editable) return; - setIsDragging(false); - setDragAnchor(null); - if (!newEvent || newEvent.end <= newEvent.start) { - setNewEvent(null); - return; - } - setNewEvent(null); - }} - onMouseMove={(e) => { - if (!editable || !isDragging || !newEvent || !dragAnchor) return; - const { hour } = getTimeFromPosition(e); - const [start, end] = getDragRange(dragAnchor.hour, hour); - setNewEvent(prev => prev ? { ...prev, start, end } : null); - }} - > - {positionedEvents.map(({ event, day, start, end, column, columnCount }, idx) => { - const slotWidth = 100 / columnCount; +
{ + if (!editable) return; + if (e.target !== e.currentTarget) return; + const { day, hour } = getTimeFromPosition(e); + if (isDateDisabled?.(days[day])) return; + setDragAnchor({ day, hour }); + setIsDragging(true); + setNewEvent({ day, start: hour, end: hour }); + }} + onMouseUp={() => { + if (!editable) return; + setIsDragging(false); + setDragAnchor(null); + if (!newEvent || newEvent.end <= newEvent.start) { + setNewEvent(null); + return; + } + const base = days[newEvent.day]; + onNewEvent?.( + decimalHourToDate(base, newEvent.start), + decimalHourToDate(base, newEvent.end), + ); + setNewEvent(null); + }} + onMouseLeave={() => { + if (!editable) return; + setIsDragging(false); + setDragAnchor(null); + if (!newEvent || newEvent.end <= newEvent.start) { + setNewEvent(null); + return; + } + setNewEvent(null); + }} + onMouseMove={(e) => { + if ( + !editable || + !isDragging || + !newEvent || + !dragAnchor + ) + return; + const { hour } = getTimeFromPosition(e); + const [start, end] = getDragRange( + dragAnchor.hour, + hour, + ); + setNewEvent((prev) => + prev ? { ...prev, start, end } : null, + ); + }} + > + {positionedEvents.map( + ( + { event, day, start, end, column, columnCount }, + idx, + ) => { + const slotWidth = 100 / columnCount; - return ( -
-
- {component && React.createElement(component, { event })} + return ( +
+
+ {component && + React.createElement(component, { + event, + })} +
+
+ ); + }, + )} + {previewComponent && + editable && + newEvent && + newEvent.end > newEvent.start && ( +
+ {React.createElement(previewComponent, { + event: { + start: decimalHourToDate( + days[newEvent.day], + newEvent.start, + ), + end: decimalHourToDate( + days[newEvent.day], + newEvent.end, + ), + }, + })} +
+ )} +
-
- ); - })} - {previewComponent && editable && newEvent && newEvent.end > newEvent.start && ( -
- {React.createElement(previewComponent, { - event: { - start: decimalHourToDate(days[newEvent.day], newEvent.start), - end: decimalHourToDate(days[newEvent.day], newEvent.end), - }, - })} -
- )} -
-
-
- ); +
+ ); }; diff --git a/peopleportal/webui/src/components/blocks/editor-00/editor.tsx b/peopleportal/webui/src/components/blocks/editor-00/editor.tsx index e0ff19b..e9f57f6 100644 --- a/peopleportal/webui/src/components/blocks/editor-00/editor.tsx +++ b/peopleportal/webui/src/components/blocks/editor-00/editor.tsx @@ -1,66 +1,67 @@ -"use client" +"use client"; import { - type InitialConfigType, - LexicalComposer, -} from "@lexical/react/LexicalComposer" -import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin" -import { type EditorState, type SerializedEditorState } from "lexical" + type InitialConfigType, + LexicalComposer, +} from "@lexical/react/LexicalComposer"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { type EditorState, type SerializedEditorState } from "lexical"; -import { editorTheme } from "@/components/editor/themes/editor-theme" -import { TooltipProvider } from "@/components/ui/tooltip" +import { editorTheme } from "@/components/editor/themes/editor-theme"; +import { TooltipProvider } from "@/components/ui/tooltip"; -import { nodes } from "./nodes" -import { Plugins } from "./plugins" +import { nodes } from "./nodes"; +import { Plugins } from "./plugins"; const editorConfig: InitialConfigType = { - namespace: "Editor", - theme: editorTheme, - nodes, - onError: (error: Error) => { - console.error(error) - }, -} + namespace: "Editor", + theme: editorTheme, + nodes, + onError: (error: Error) => { + console.error(error); + }, +}; -import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown" +import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown"; export function Editor({ - editorState, - editorSerializedState, - onChange, - onSerializedChange, + editorState, + editorSerializedState, + onChange, + onSerializedChange, }: { - editorState?: EditorState - editorSerializedState?: SerializedEditorState - onChange?: (markdown: string) => void - onSerializedChange?: (editorSerializedState: SerializedEditorState) => void + editorState?: EditorState; + editorSerializedState?: SerializedEditorState; + onChange?: (markdown: string) => void; + onSerializedChange?: (editorSerializedState: SerializedEditorState) => void; }) { - return ( -
- - - + return ( +
+ + + - { - editorState.read(() => { - const markdown = $convertToMarkdownString(TRANSFORMERS) - onChange?.(markdown) - }) - onSerializedChange?.(editorState.toJSON()) - }} - /> - - -
- ) + { + editorState.read(() => { + const markdown = + $convertToMarkdownString(TRANSFORMERS); + onChange?.(markdown); + }); + onSerializedChange?.(editorState.toJSON()); + }} + /> +
+
+
+ ); } diff --git a/peopleportal/webui/src/components/blocks/editor-00/nodes.ts b/peopleportal/webui/src/components/blocks/editor-00/nodes.ts index 67fd742..a704a8e 100644 --- a/peopleportal/webui/src/components/blocks/editor-00/nodes.ts +++ b/peopleportal/webui/src/components/blocks/editor-00/nodes.ts @@ -1,25 +1,25 @@ -import { CodeHighlightNode, CodeNode } from "@lexical/code" -import { AutoLinkNode, LinkNode } from "@lexical/link" -import { ListItemNode, ListNode } from "@lexical/list" -import { HeadingNode, QuoteNode } from "@lexical/rich-text" +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { - type Klass, - type LexicalNode, - type LexicalNodeReplacement, - ParagraphNode, - TextNode, -} from "lexical" - -export const nodes: ReadonlyArray | LexicalNodeReplacement> = - [ - HeadingNode, + type Klass, + type LexicalNode, + type LexicalNodeReplacement, ParagraphNode, TextNode, - QuoteNode, - ListNode, - ListItemNode, - CodeNode, - CodeHighlightNode, - AutoLinkNode, - LinkNode, - ] +} from "lexical"; + +export const nodes: ReadonlyArray | LexicalNodeReplacement> = + [ + HeadingNode, + ParagraphNode, + TextNode, + QuoteNode, + ListNode, + ListItemNode, + CodeNode, + CodeHighlightNode, + AutoLinkNode, + LinkNode, + ]; diff --git a/peopleportal/webui/src/components/blocks/editor-00/plugins.tsx b/peopleportal/webui/src/components/blocks/editor-00/plugins.tsx index 85b9e36..706f8f5 100644 --- a/peopleportal/webui/src/components/blocks/editor-00/plugins.tsx +++ b/peopleportal/webui/src/components/blocks/editor-00/plugins.tsx @@ -1,105 +1,126 @@ -import { useState } from "react" -import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary" -import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin" -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" -import { FORMAT_TEXT_COMMAND } from "lexical" -import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND } from "@lexical/list" -import { ListPlugin } from "@lexical/react/LexicalListPlugin" -import { Bold, Code, Italic, List, ListOrdered, Underline } from "lucide-react" +import { useState } from "react"; +import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { FORMAT_TEXT_COMMAND } from "lexical"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from "@lexical/list"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { Bold, Code, Italic, List, ListOrdered, Underline } from "lucide-react"; -import { Toggle } from "@/components/ui/toggle" -import { ContentEditable } from "@/components/editor/editor-ui/content-editable" +import { Toggle } from "@/components/ui/toggle"; +import { ContentEditable } from "@/components/editor/editor-ui/content-editable"; -import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin" -import { TRANSFORMERS } from "@lexical/markdown" +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; -import { CounterCharacterPlugin } from "@/components/editor/plugins/actions/counter-character-plugin" +import { CounterCharacterPlugin } from "@/components/editor/plugins/actions/counter-character-plugin"; function Toolbar() { - const [editor] = useLexicalComposerContext() + const [editor] = useLexicalComposerContext(); - return ( -
- editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")} - > - - - editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")} - > - - - editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")} - > - - - editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code")} - > - - -
- editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)} - > - - - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)} - > - - -
- ) + return ( +
+ + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold") + } + > + + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic") + } + > + + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline") + } + > + + + + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code") + } + > + + +
+ + editor.dispatchCommand( + INSERT_UNORDERED_LIST_COMMAND, + undefined, + ) + } + > + + + + editor.dispatchCommand( + INSERT_ORDERED_LIST_COMMAND, + undefined, + ) + } + > + + +
+ ); } export function Plugins() { - const [_floatingAnchorElem, setFloatingAnchorElem] = - useState(null) + const [_floatingAnchorElem, setFloatingAnchorElem] = + useState(null); - const onRef = (_floatingAnchorElem: HTMLDivElement) => { - if (_floatingAnchorElem !== null) { - setFloatingAnchorElem(_floatingAnchorElem) - } - } + const onRef = (_floatingAnchorElem: HTMLDivElement) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem); + } + }; - return ( -
- {/* toolbar plugins */} - -
- -
- + {/* toolbar plugins */} + +
+ +
+ +
+
+ } + ErrorBoundary={LexicalErrorBoundary} /> -
+ +
- } - ErrorBoundary={LexicalErrorBoundary} - /> - - -
-
- -
-
- ) +
+ +
+
+ ); } diff --git a/peopleportal/webui/src/components/editor/editor-ui/content-editable.tsx b/peopleportal/webui/src/components/editor/editor-ui/content-editable.tsx index aa6a78d..19904e8 100644 --- a/peopleportal/webui/src/components/editor/editor-ui/content-editable.tsx +++ b/peopleportal/webui/src/components/editor/editor-ui/content-editable.tsx @@ -1,34 +1,34 @@ -import { type JSX } from "react" -import { ContentEditable as LexicalContentEditable } from "@lexical/react/LexicalContentEditable" +import { type JSX } from "react"; +import { ContentEditable as LexicalContentEditable } from "@lexical/react/LexicalContentEditable"; type Props = { - placeholder: string - className?: string - placeholderClassName?: string -} + placeholder: string; + className?: string; + placeholderClassName?: string; +}; export function ContentEditable({ - placeholder, - className, - placeholderClassName, + placeholder, + className, + placeholderClassName, }: Props): JSX.Element { - return ( - - {placeholder} -
- } - /> - ) + return ( + + {placeholder} + + } + /> + ); } diff --git a/peopleportal/webui/src/components/editor/plugins/actions/actions-plugin.tsx b/peopleportal/webui/src/components/editor/plugins/actions/actions-plugin.tsx index 9d3e184..2132980 100644 --- a/peopleportal/webui/src/components/editor/plugins/actions/actions-plugin.tsx +++ b/peopleportal/webui/src/components/editor/plugins/actions/actions-plugin.tsx @@ -1,3 +1,3 @@ export function ActionsPlugin({ children }: { children: React.ReactNode }) { - return children + return children; } diff --git a/peopleportal/webui/src/components/editor/plugins/actions/counter-character-plugin.tsx b/peopleportal/webui/src/components/editor/plugins/actions/counter-character-plugin.tsx index d4fd9ed..75d262d 100644 --- a/peopleportal/webui/src/components/editor/plugins/actions/counter-character-plugin.tsx +++ b/peopleportal/webui/src/components/editor/plugins/actions/counter-character-plugin.tsx @@ -1,73 +1,73 @@ -import { useEffect, useState } from "react" -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext" -import { $rootTextContent } from "@lexical/text" +import { useEffect, useState } from "react"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $rootTextContent } from "@lexical/text"; -let textEncoderInstance: null | TextEncoder = null +let textEncoderInstance: null | TextEncoder = null; function textEncoder(): null | TextEncoder { - if (window.TextEncoder === undefined) { - return null - } + if (window.TextEncoder === undefined) { + return null; + } - if (textEncoderInstance === null) { - textEncoderInstance = new window.TextEncoder() - } + if (textEncoderInstance === null) { + textEncoderInstance = new window.TextEncoder(); + } - return textEncoderInstance + return textEncoderInstance; } function utf8Length(text: string) { - const currentTextEncoder = textEncoder() + const currentTextEncoder = textEncoder(); - if (currentTextEncoder === null) { - // http://stackoverflow.com/a/5515960/210370 - const m = encodeURIComponent(text).match(/%[89ABab]/g) - return text.length + (m ? m.length : 0) - } + if (currentTextEncoder === null) { + // http://stackoverflow.com/a/5515960/210370 + const m = encodeURIComponent(text).match(/%[89ABab]/g); + return text.length + (m ? m.length : 0); + } - return currentTextEncoder.encode(text).length + return currentTextEncoder.encode(text).length; } interface CounterCharacterPluginProps { - charset?: "UTF-8" | "UTF-16" + charset?: "UTF-8" | "UTF-16"; } const strlen = (text: string, charset: "UTF-8" | "UTF-16") => { - if (charset === "UTF-8") { - return utf8Length(text) - } else if (charset === "UTF-16") { - return text.length - } -} + if (charset === "UTF-8") { + return utf8Length(text); + } else if (charset === "UTF-16") { + return text.length; + } +}; const countWords = (text: string) => { - return text.split(/\s+/).filter((word) => word.length > 0).length -} + return text.split(/\s+/).filter((word) => word.length > 0).length; +}; export function CounterCharacterPlugin({ - charset = "UTF-16", + charset = "UTF-16", }: CounterCharacterPluginProps) { - const [editor] = useLexicalComposerContext() - const [stats, setStats] = useState(() => { - const initialText = editor.getEditorState().read($rootTextContent) - return { - characters: strlen(initialText, charset), - words: countWords(initialText), - } - }) + const [editor] = useLexicalComposerContext(); + const [stats, setStats] = useState(() => { + const initialText = editor.getEditorState().read($rootTextContent); + return { + characters: strlen(initialText, charset), + words: countWords(initialText), + }; + }); - useEffect(() => { - return editor.registerTextContentListener((currentText: string) => { - setStats({ - characters: strlen(currentText, charset), - words: countWords(currentText), - }) - }) - }, [editor, charset]) + useEffect(() => { + return editor.registerTextContentListener((currentText: string) => { + setStats({ + characters: strlen(currentText, charset), + words: countWords(currentText), + }); + }); + }, [editor, charset]); - return ( -
-

{stats.characters} characters

|

{stats.words} words

-
- ) + return ( +
+

{stats.characters} characters

|

{stats.words} words

+
+ ); } diff --git a/peopleportal/webui/src/components/editor/themes/editor-theme.css b/peopleportal/webui/src/components/editor/themes/editor-theme.css index 9fefb03..beac9aa 100644 --- a/peopleportal/webui/src/components/editor/themes/editor-theme.css +++ b/peopleportal/webui/src/components/editor/themes/editor-theme.css @@ -1,91 +1,91 @@ .EditorTheme__code { - background-color: transparent; - font-family: Menlo, Consolas, Monaco, monospace; - display: block; - padding: 8px 8px 8px 52px; - line-height: 1.53; - font-size: 13px; - margin: 0; - margin-top: 8px; - margin-bottom: 8px; - overflow-x: auto; - border: 1px solid #ccc; - position: relative; - border-radius: 8px; - tab-size: 2; + background-color: transparent; + font-family: Menlo, Consolas, Monaco, monospace; + display: block; + padding: 8px 8px 8px 52px; + line-height: 1.53; + font-size: 13px; + margin: 0; + margin-top: 8px; + margin-bottom: 8px; + overflow-x: auto; + border: 1px solid #ccc; + position: relative; + border-radius: 8px; + tab-size: 2; } .EditorTheme__code:before { - content: attr(data-gutter); - position: absolute; - background-color: transparent; - border-right: 1px solid #ccc; - left: 0; - top: 0; - padding: 8px; - color: #777; - white-space: pre-wrap; - text-align: right; - min-width: 25px; + content: attr(data-gutter); + position: absolute; + background-color: transparent; + border-right: 1px solid #ccc; + left: 0; + top: 0; + padding: 8px; + color: #777; + white-space: pre-wrap; + text-align: right; + min-width: 25px; } .EditorTheme__table { - border-collapse: collapse; - border-spacing: 0; - overflow-y: scroll; - overflow-x: scroll; - table-layout: fixed; - width: fit-content; - width: 100%; - margin: 0px 0px 30px 0px; + border-collapse: collapse; + border-spacing: 0; + overflow-y: scroll; + overflow-x: scroll; + table-layout: fixed; + width: fit-content; + width: 100%; + margin: 0px 0px 30px 0px; } .EditorTheme__tokenComment { - color: slategray; + color: slategray; } .EditorTheme__tokenPunctuation { - color: #999; + color: #999; } .EditorTheme__tokenProperty { - color: #905; + color: #905; } .EditorTheme__tokenSelector { - color: #690; + color: #690; } .EditorTheme__tokenOperator { - color: #9a6e3a; + color: #9a6e3a; } .EditorTheme__tokenAttr { - color: #07a; + color: #07a; } .EditorTheme__tokenVariable { - color: #e90; + color: #e90; } .EditorTheme__tokenFunction { - color: #dd4a68; + color: #dd4a68; } .Collapsible__container { - background-color: var(--background); - border: 1px solid #ccc; - border-radius: 0.5rem; - margin-bottom: 0.5rem; + background-color: var(--background); + border: 1px solid #ccc; + border-radius: 0.5rem; + margin-bottom: 0.5rem; } -.Collapsible__title{ - padding: 0.25rem; - padding-left: 1rem; - position: relative; - font-weight: bold; - outline: none; - cursor: pointer; - list-style-type: disclosure-closed; - list-style-position: inside; +.Collapsible__title { + padding: 0.25rem; + padding-left: 1rem; + position: relative; + font-weight: bold; + outline: none; + cursor: pointer; + list-style-type: disclosure-closed; + list-style-position: inside; } -.Collapsible__title p{ - display: inline-flex; +.Collapsible__title p { + display: inline-flex; } -.Collapsible__title::marker{ - color: lightgray; +.Collapsible__title::marker { + color: lightgray; } -.Collapsible__container[open] >.Collapsible__title { - list-style-type: disclosure-open; +.Collapsible__container[open] > .Collapsible__title { + list-style-type: disclosure-open; } diff --git a/peopleportal/webui/src/components/editor/themes/editor-theme.ts b/peopleportal/webui/src/components/editor/themes/editor-theme.ts index 24190ca..01969d6 100644 --- a/peopleportal/webui/src/components/editor/themes/editor-theme.ts +++ b/peopleportal/webui/src/components/editor/themes/editor-theme.ts @@ -1,130 +1,131 @@ -import { EditorThemeClasses } from "lexical" +import { EditorThemeClasses } from "lexical"; -import "./editor-theme.css" +import "./editor-theme.css"; export const editorTheme: EditorThemeClasses = { - ltr: "text-left", - rtl: "text-right", - heading: { - h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl", - h2: "scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0", - h3: "scroll-m-20 text-2xl font-semibold tracking-tight", - h4: "scroll-m-20 text-xl font-semibold tracking-tight", - h5: "scroll-m-20 text-lg font-semibold tracking-tight", - h6: "scroll-m-20 text-base font-semibold tracking-tight", - }, - paragraph: "leading-7 [&:not(:first-child)]:mt-6", - quote: "mt-6 border-l-2 pl-6 italic", - link: "text-blue-600 hover:underline hover:cursor-pointer", - list: { - checklist: "relative", - listitem: "mx-8", - listitemChecked: - 'relative mx-2 px-6 list-none outline-none line-through before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded before:bg-primary before:bg-no-repeat after:content-[""] after:cursor-pointer after:border-white after:border-solid after:absolute after:block after:top-[6px] after:w-[3px] after:left-[7px] after:right-[7px] after:h-[6px] after:rotate-45 after:border-r-2 after:border-b-2 after:border-l-0 after:border-t-0', - listitemUnchecked: - 'relative mx-2 px-6 list-none outline-none before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded', - nested: { - listitem: "list-none before:hidden after:hidden", + ltr: "text-left", + rtl: "text-right", + heading: { + h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl", + h2: "scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0", + h3: "scroll-m-20 text-2xl font-semibold tracking-tight", + h4: "scroll-m-20 text-xl font-semibold tracking-tight", + h5: "scroll-m-20 text-lg font-semibold tracking-tight", + h6: "scroll-m-20 text-base font-semibold tracking-tight", }, - ol: "m-0 p-0 list-decimal [&>li]:mt-2", - olDepth: [ - "list-outside !list-decimal", - "list-outside !list-[upper-roman]", - "list-outside !list-[lower-roman]", - "list-outside !list-[upper-alpha]", - "list-outside !list-[lower-alpha]", - ], - ul: "m-0 p-0 list-outside [&>li]:mt-2", - ulDepth: [ - "list-outside !list-disc", - "list-outside !list-disc", - "list-outside !list-disc", - "list-outside !list-disc", - "list-outside !list-disc", - ], - }, - hashtag: "text-blue-600 bg-blue-100 rounded-md px-1", - text: { - bold: "font-bold", - code: "bg-gray-100 p-1 rounded-md", - italic: "italic", - strikethrough: "line-through", - subscript: "sub", - superscript: "sup", - underline: "underline", - underlineStrikethrough: "[text-decoration:underline_line-through]", - }, - image: "relative inline-block user-select-none cursor-default editor-image", - inlineImage: - "relative inline-block user-select-none cursor-default inline-editor-image", - keyword: "text-purple-900 font-bold", - code: "EditorTheme__code", - codeHighlight: { - atrule: "EditorTheme__tokenAttr", - attr: "EditorTheme__tokenAttr", - boolean: "EditorTheme__tokenProperty", - builtin: "EditorTheme__tokenSelector", - cdata: "EditorTheme__tokenComment", - char: "EditorTheme__tokenSelector", - class: "EditorTheme__tokenFunction", - "class-name": "EditorTheme__tokenFunction", - comment: "EditorTheme__tokenComment", - constant: "EditorTheme__tokenProperty", - deleted: "EditorTheme__tokenProperty", - doctype: "EditorTheme__tokenComment", - entity: "EditorTheme__tokenOperator", - function: "EditorTheme__tokenFunction", - important: "EditorTheme__tokenVariable", - inserted: "EditorTheme__tokenSelector", - keyword: "EditorTheme__tokenAttr", - namespace: "EditorTheme__tokenVariable", - number: "EditorTheme__tokenProperty", - operator: "EditorTheme__tokenOperator", - prolog: "EditorTheme__tokenComment", - property: "EditorTheme__tokenProperty", - punctuation: "EditorTheme__tokenPunctuation", - regex: "EditorTheme__tokenVariable", - selector: "EditorTheme__tokenSelector", - string: "EditorTheme__tokenSelector", - symbol: "EditorTheme__tokenProperty", - tag: "EditorTheme__tokenProperty", - url: "EditorTheme__tokenOperator", - variable: "EditorTheme__tokenVariable", - }, - characterLimit: "!bg-destructive/50", - table: "EditorTheme__table w-fit overflow-scroll border-collapse", - tableCell: - 'EditorTheme__tableCell w-24 relative border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"', - tableCellActionButton: - "EditorTheme__tableCellActionButton bg-background block border-0 rounded-2xl w-5 h-5 text-foreground cursor-pointer", - tableCellActionButtonContainer: - "EditorTheme__tableCellActionButtonContainer block right-1 top-1.5 absolute z-10 w-5 h-5", - tableCellEditing: "EditorTheme__tableCellEditing rounded-sm shadow-sm", - tableCellHeader: - "EditorTheme__tableCellHeader bg-muted border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right", - tableCellPrimarySelected: - "EditorTheme__tableCellPrimarySelected border border-primary border-solid block h-[calc(100%-2px)] w-[calc(100%-2px)] absolute -left-[1px] -top-[1px] z-10 ", - tableCellResizer: - "EditorTheme__tableCellResizer absolute -right-1 h-full w-2 cursor-ew-resize z-10 top-0", - tableCellSelected: "EditorTheme__tableCellSelected bg-muted", - tableCellSortedIndicator: - "EditorTheme__tableCellSortedIndicator block opacity-50 bsolute bottom-0 left-0 w-full h-1 bg-muted", - tableResizeRuler: - "EditorTheme__tableCellResizeRuler block absolute w-[1px] h-full bg-primary top-0", - tableRowStriping: - "EditorTheme__tableRowStriping m-0 border-t p-0 even:bg-muted", - tableSelected: "EditorTheme__tableSelected ring-2 ring-primary ring-offset-2", - tableSelection: "EditorTheme__tableSelection bg-transparent", - layoutItem: "border border-dashed px-4 py-2", - layoutContainer: "grid gap-2.5 my-2.5 mx-0", - autocomplete: "text-muted-foreground", - blockCursor: "", - embedBlock: { - base: "user-select-none", - focus: "ring-2 ring-primary ring-offset-2", - }, - hr: 'p-0.5 border-none my-1 mx-0 cursor-pointer after:content-[""] after:block after:h-0.5 after:bg-muted selected:ring-2 selected:ring-primary selected:ring-offset-2 selected:user-select-none', - indent: "[--lexical-indent-base-value:40px]", - mark: "", - markOverlap: "", -} + paragraph: "leading-7 [&:not(:first-child)]:mt-6", + quote: "mt-6 border-l-2 pl-6 italic", + link: "text-blue-600 hover:underline hover:cursor-pointer", + list: { + checklist: "relative", + listitem: "mx-8", + listitemChecked: + 'relative mx-2 px-6 list-none outline-none line-through before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded before:bg-primary before:bg-no-repeat after:content-[""] after:cursor-pointer after:border-white after:border-solid after:absolute after:block after:top-[6px] after:w-[3px] after:left-[7px] after:right-[7px] after:h-[6px] after:rotate-45 after:border-r-2 after:border-b-2 after:border-l-0 after:border-t-0', + listitemUnchecked: + 'relative mx-2 px-6 list-none outline-none before:content-[""] before:w-4 before:h-4 before:top-0.5 before:left-0 before:cursor-pointer before:block before:bg-cover before:absolute before:border before:border-primary before:rounded', + nested: { + listitem: "list-none before:hidden after:hidden", + }, + ol: "m-0 p-0 list-decimal [&>li]:mt-2", + olDepth: [ + "list-outside !list-decimal", + "list-outside !list-[upper-roman]", + "list-outside !list-[lower-roman]", + "list-outside !list-[upper-alpha]", + "list-outside !list-[lower-alpha]", + ], + ul: "m-0 p-0 list-outside [&>li]:mt-2", + ulDepth: [ + "list-outside !list-disc", + "list-outside !list-disc", + "list-outside !list-disc", + "list-outside !list-disc", + "list-outside !list-disc", + ], + }, + hashtag: "text-blue-600 bg-blue-100 rounded-md px-1", + text: { + bold: "font-bold", + code: "bg-gray-100 p-1 rounded-md", + italic: "italic", + strikethrough: "line-through", + subscript: "sub", + superscript: "sup", + underline: "underline", + underlineStrikethrough: "[text-decoration:underline_line-through]", + }, + image: "relative inline-block user-select-none cursor-default editor-image", + inlineImage: + "relative inline-block user-select-none cursor-default inline-editor-image", + keyword: "text-purple-900 font-bold", + code: "EditorTheme__code", + codeHighlight: { + atrule: "EditorTheme__tokenAttr", + attr: "EditorTheme__tokenAttr", + boolean: "EditorTheme__tokenProperty", + builtin: "EditorTheme__tokenSelector", + cdata: "EditorTheme__tokenComment", + char: "EditorTheme__tokenSelector", + class: "EditorTheme__tokenFunction", + "class-name": "EditorTheme__tokenFunction", + comment: "EditorTheme__tokenComment", + constant: "EditorTheme__tokenProperty", + deleted: "EditorTheme__tokenProperty", + doctype: "EditorTheme__tokenComment", + entity: "EditorTheme__tokenOperator", + function: "EditorTheme__tokenFunction", + important: "EditorTheme__tokenVariable", + inserted: "EditorTheme__tokenSelector", + keyword: "EditorTheme__tokenAttr", + namespace: "EditorTheme__tokenVariable", + number: "EditorTheme__tokenProperty", + operator: "EditorTheme__tokenOperator", + prolog: "EditorTheme__tokenComment", + property: "EditorTheme__tokenProperty", + punctuation: "EditorTheme__tokenPunctuation", + regex: "EditorTheme__tokenVariable", + selector: "EditorTheme__tokenSelector", + string: "EditorTheme__tokenSelector", + symbol: "EditorTheme__tokenProperty", + tag: "EditorTheme__tokenProperty", + url: "EditorTheme__tokenOperator", + variable: "EditorTheme__tokenVariable", + }, + characterLimit: "!bg-destructive/50", + table: "EditorTheme__table w-fit overflow-scroll border-collapse", + tableCell: + 'EditorTheme__tableCell w-24 relative border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right"', + tableCellActionButton: + "EditorTheme__tableCellActionButton bg-background block border-0 rounded-2xl w-5 h-5 text-foreground cursor-pointer", + tableCellActionButtonContainer: + "EditorTheme__tableCellActionButtonContainer block right-1 top-1.5 absolute z-10 w-5 h-5", + tableCellEditing: "EditorTheme__tableCellEditing rounded-sm shadow-sm", + tableCellHeader: + "EditorTheme__tableCellHeader bg-muted border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right", + tableCellPrimarySelected: + "EditorTheme__tableCellPrimarySelected border border-primary border-solid block h-[calc(100%-2px)] w-[calc(100%-2px)] absolute -left-[1px] -top-[1px] z-10 ", + tableCellResizer: + "EditorTheme__tableCellResizer absolute -right-1 h-full w-2 cursor-ew-resize z-10 top-0", + tableCellSelected: "EditorTheme__tableCellSelected bg-muted", + tableCellSortedIndicator: + "EditorTheme__tableCellSortedIndicator block opacity-50 bsolute bottom-0 left-0 w-full h-1 bg-muted", + tableResizeRuler: + "EditorTheme__tableCellResizeRuler block absolute w-[1px] h-full bg-primary top-0", + tableRowStriping: + "EditorTheme__tableRowStriping m-0 border-t p-0 even:bg-muted", + tableSelected: + "EditorTheme__tableSelected ring-2 ring-primary ring-offset-2", + tableSelection: "EditorTheme__tableSelection bg-transparent", + layoutItem: "border border-dashed px-4 py-2", + layoutContainer: "grid gap-2.5 my-2.5 mx-0", + autocomplete: "text-muted-foreground", + blockCursor: "", + embedBlock: { + base: "user-select-none", + focus: "ring-2 ring-primary ring-offset-2", + }, + hr: 'p-0.5 border-none my-1 mx-0 cursor-pointer after:content-[""] after:block after:h-0.5 after:bg-muted selected:ring-2 selected:ring-primary selected:ring-offset-2 selected:user-select-none', + indent: "[--lexical-indent-base-value:40px]", + mark: "", + markOverlap: "", +}; diff --git a/peopleportal/webui/src/components/fabric/DashboardPeopleInfo.tsx b/peopleportal/webui/src/components/fabric/DashboardPeopleInfo.tsx index 41aa030..5f65861 100644 --- a/peopleportal/webui/src/components/fabric/DashboardPeopleInfo.tsx +++ b/peopleportal/webui/src/components/fabric/DashboardPeopleInfo.tsx @@ -16,31 +16,95 @@ along with this program. If not, see . */ -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; -import { Card, CardContent, CardTitle } from '@/components/ui/card'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; -import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; -import { Mail, Phone, Calendar, GraduationCap, Briefcase, ShieldCheck, MapPin, Clock, Tag, AlertCircle, Users, Loader2, Star, PenLine, Trash2Icon, SquarePen, ChevronRight, ChevronDown, PencilIcon, Loader2Icon, Check, ChevronsUpDown, Minus, Plus, UploadCloudIcon, Linkedin } from 'lucide-react'; -import { PEOPLEPORTAL_SERVER_ENDPOINT } from '@/commons/config'; -import { format, formatDistanceToNow } from 'date-fns'; -import { cn } from '@/lib/utils'; -import { Button } from '@/components/ui/button'; -import { Dialog, DialogClose, DialogContent, DialogContentWide, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { Label } from '@/components/ui/label'; -import { Input } from '@/components/ui/input'; -import { Textarea } from '@/components/ui/textarea'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { toast } from 'sonner'; -import { DialogDescription } from '@radix-ui/react-dialog'; -import { PhoneInput } from '@/components/ui/phone-input'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; -import { Slider } from '@/components/ui/slider'; -import Cropper, { type Area, type Point } from 'react-easy-crop'; -import { LINKEDIN_PROFILE_ERROR, normalizeLinkedInProfileUrl } from '@/lib/linkedin'; -import imageCompression from 'browser-image-compression'; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { Card, CardContent, CardTitle } from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Mail, + Phone, + Calendar, + GraduationCap, + Briefcase, + ShieldCheck, + MapPin, + Clock, + Tag, + AlertCircle, + Users, + Loader2, + Star, + PenLine, + Trash2Icon, + SquarePen, + ChevronRight, + ChevronDown, + PencilIcon, + Loader2Icon, + Check, + ChevronsUpDown, + Minus, + Plus, + UploadCloudIcon, + Linkedin, +} from "lucide-react"; +import { PEOPLEPORTAL_SERVER_ENDPOINT } from "@/commons/config"; +import { format, formatDistanceToNow } from "date-fns"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogContentWide, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "sonner"; +import { DialogDescription } from "@radix-ui/react-dialog"; +import { PhoneInput } from "@/components/ui/phone-input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Slider } from "@/components/ui/slider"; +import Cropper, { type Area, type Point } from "react-easy-crop"; +import { + LINKEDIN_PROFILE_ERROR, + normalizeLinkedInProfileUrl, +} from "@/lib/linkedin"; +import imageCompression from "browser-image-compression"; // --- Interfaces matching the Backend --- @@ -48,20 +112,21 @@ const TeamType = { PROJECT: "PROJECT", CORPORATE: "CORPORATE", BOOTCAMP: "BOOTCAMP", - SERVICE: "SERVICE" + SERVICE: "SERVICE", } as const; -type TeamType = typeof TeamType[keyof typeof TeamType]; +type TeamType = (typeof TeamType)[keyof typeof TeamType]; const SeasonType = { FALL: "FALL", - SPRING: "SPRING" + SPRING: "SPRING", } as const; -type SeasonType = typeof SeasonType[keyof typeof SeasonType]; +type SeasonType = (typeof SeasonType)[keyof typeof SeasonType]; const ServiceSeasonType = { - ROLLING: "ROLLING" + ROLLING: "ROLLING", } as const; -type ServiceSeasonType = typeof ServiceSeasonType[keyof typeof ServiceSeasonType]; +type ServiceSeasonType = + (typeof ServiceSeasonType)[keyof typeof ServiceSeasonType]; interface TeamAttributeDefinition { friendlyName: string; @@ -118,16 +183,16 @@ interface TeamInformationBrief { parentFriendlyName?: string; } -interface ReviewData { - _id: string; - userId: number; - creatorId: number; - teamId: string; - rating: number; - title: string; - content: string; - createdAt: Date; - updatedAt: Date; +interface ReviewData { + _id: string; + userId: number; + creatorId: number; + teamId: string; + rating: number; + title: string; + content: string; + createdAt: Date; + updatedAt: Date; } interface UMDMajor { @@ -138,15 +203,38 @@ interface UMDMajor { } // Reusable Info Card Component -const InfoItem = ({ icon: Icon, label, value, href, className }: { icon: React.ElementType, label: string, value: string | React.ReactNode, href?: string, className?: string }) => ( -
+const InfoItem = ({ + icon: Icon, + label, + value, + href, + className, +}: { + icon: React.ElementType; + label: string; + value: string | React.ReactNode; + href?: string; + className?: string; +}) => ( +
{label}
-
+
{href ? ( - + {value} ) : ( @@ -156,16 +244,29 @@ const InfoItem = ({ icon: Icon, label, value, href, className }: { icon: React.E
); -const SEASONS = ['Spring', 'Summer', 'Fall', 'Winter']; -const YEARS = Array.from({ length: 10 }, (_, i) => new Date().getFullYear() + i); +const SEASONS = ["Spring", "Summer", "Fall", "Winter"]; +const YEARS = Array.from( + { length: 10 }, + (_, i) => new Date().getFullYear() + i, +); -const MonthYearPicker = ({ value, onChange }: { value: string, onChange: (v: string) => void }) => { +const MonthYearPicker = ({ + value, + onChange, +}: { + value: string; + onChange: (v: string) => void; +}) => { const [selSeason, setSelSeason] = React.useState(() => { - if (value) { const idx = SEASONS.indexOf(value.split('-')[0]); return idx >= 0 ? idx : 0; } + if (value) { + const idx = SEASONS.indexOf(value.split("-")[0]); + return idx >= 0 ? idx : 0; + } return 0; }); const [selYear, setSelYear] = React.useState(() => { - if (value) return parseInt(value.split('-')[1]) || new Date().getFullYear(); + if (value) + return parseInt(value.split("-")[1]) || new Date().getFullYear(); return new Date().getFullYear(); }); @@ -173,29 +274,47 @@ const MonthYearPicker = ({ value, onChange }: { value: string, onChange: (v: str const yearRefs = useRef<(HTMLDivElement | null)[]>([]); React.useEffect(() => { - seasonRefs.current[selSeason]?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + seasonRefs.current[selSeason]?.scrollIntoView({ + block: "center", + behavior: "smooth", + }); }, [selSeason]); React.useEffect(() => { const idx = YEARS.indexOf(selYear); - if (idx >= 0) yearRefs.current[idx]?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + if (idx >= 0) + yearRefs.current[idx]?.scrollIntoView({ + block: "center", + behavior: "smooth", + }); }, [selYear]); React.useEffect(() => { onChange(`${SEASONS[selSeason]}-${selYear}`); }, [selSeason, selYear]); - const colClass = "h-48 overflow-y-auto scrollbar-none flex flex-col items-center w-1/2 snap-y snap-mandatory"; - const itemClass = (active: boolean) => cn( - "w-full text-center py-2 text-sm cursor-pointer snap-center rounded-md transition-all select-none", - active ? "font-bold text-foreground bg-muted" : "text-muted-foreground hover:text-foreground" - ); + const colClass = + "h-48 overflow-y-auto scrollbar-none flex flex-col items-center w-1/2 snap-y snap-mandatory"; + const itemClass = (active: boolean) => + cn( + "w-full text-center py-2 text-sm cursor-pointer snap-center rounded-md transition-all select-none", + active + ? "font-bold text-foreground bg-muted" + : "text-muted-foreground hover:text-foreground", + ); return (
{SEASONS.map((s, i) => ( -
{ seasonRefs.current[i] = el; }} className={itemClass(i === selSeason)} onClick={() => setSelSeason(i)}> +
{ + seasonRefs.current[i] = el; + }} + className={itemClass(i === selSeason)} + onClick={() => setSelSeason(i)} + > {s}
))} @@ -203,7 +322,14 @@ const MonthYearPicker = ({ value, onChange }: { value: string, onChange: (v: str
{YEARS.map((y, i) => ( -
{ yearRefs.current[i] = el; }} className={itemClass(y === selYear)} onClick={() => setSelYear(y)}> +
{ + yearRefs.current[i] = el; + }} + className={itemClass(y === selYear)} + onClick={() => setSelYear(y)} + > {y}
))} @@ -216,19 +342,25 @@ export const DashboardPeopleInfo = () => { const { userPk } = useParams<{ userPk: string }>(); const navigate = useNavigate(); const [user, setUser] = useState(null); - const [userTeamsMap, setUserTeamsMap] = useState>(new Map()); + const [userTeamsMap, setUserTeamsMap] = useState< + Map + >(new Map()); const [loading, setLoading] = useState(true); const [loggedInPk, setLoggedInPk] = useState(null); // Edit modal state const [editOpen, setEditOpen] = useState(false); - const [editPhone, setEditPhone] = useState(''); + const [editPhone, setEditPhone] = useState(""); const [editMajor, setEditMajor] = useState(undefined); - const [editGrad, setEditGrad] = useState(''); - const [editLinkedin, setEditLinkedin] = useState(''); - const [editLinkedinError, setEditLinkedinError] = useState(''); - const [editAvatarKey, setEditAvatarKey] = useState(undefined); - const [editAvatarPreview, setEditAvatarPreview] = useState(null); + const [editGrad, setEditGrad] = useState(""); + const [editLinkedin, setEditLinkedin] = useState(""); + const [editLinkedinError, setEditLinkedinError] = useState(""); + const [editAvatarKey, setEditAvatarKey] = useState( + undefined, + ); + const [editAvatarPreview, setEditAvatarPreview] = useState( + null, + ); const [isSaving, setIsSaving] = useState(false); const [isUploading, setIsUploading] = useState(false); const [majors, setMajors] = useState([]); @@ -238,7 +370,9 @@ export const DashboardPeopleInfo = () => { const [cropImage, setCropImage] = useState(null); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [zoom, setZoom] = useState(1); - const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); + const [croppedAreaPixels, setCroppedAreaPixels] = useState( + null, + ); const [isCropOpen, setIsCropOpen] = useState(false); const fileInputRef = useRef(null); @@ -247,7 +381,9 @@ export const DashboardPeopleInfo = () => { const [reviewDialogOpen, setReviewDialogOpen] = useState(false); const [expandReviewDialogOpen, setExpandReviewDialogOpen] = useState(false); - const [selectedReview, setSelectedReview] = useState(null); + const [selectedReview, setSelectedReview] = useState( + null, + ); const [deleteReviewDialogOpen, setDeleteReviewDialogOpen] = useState(false); @@ -257,14 +393,19 @@ export const DashboardPeopleInfo = () => { const [allReviewsOpen, setAllReviewsOpen] = useState(false); const [reviewsLoading, setReviewsLoading] = useState(false); const [allReviews, setAllReviews] = useState([]); - const [userInfoCache, setUserInfoCache] = useState>(new Map()); - + const [userInfoCache, setUserInfoCache] = useState< + Map + >(new Map()); const allReviewsLengthRef = useRef(0); - useEffect(() => { allReviewsLengthRef.current = allReviews.length; }, [allReviews]); + useEffect(() => { + allReviewsLengthRef.current = allReviews.length; + }, [allReviews]); const userInfoCacheRef = useRef(userInfoCache); - useEffect(() => { userInfoCacheRef.current = userInfoCache; }, [userInfoCache]); + useEffect(() => { + userInfoCacheRef.current = userInfoCache; + }, [userInfoCache]); useEffect(() => { if (!userPk) return; @@ -272,15 +413,20 @@ export const DashboardPeopleInfo = () => { const fetchData = async () => { try { // 1. Fetch user info - const userRes = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}`); + const userRes = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}`, + ); if (!userRes.ok) throw new Error("Failed to fetch user"); const userData: UserInformationDetail = await userRes.json(); setUser(userData); // 2. Fetch user's root teams using their username - const teamsRes = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userData.username}/memberships`, { - credentials: 'include' - }); + const teamsRes = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userData.username}/memberships`, + { + credentials: "include", + }, + ); if (teamsRes.ok) { const teamsData = await teamsRes.json(); // Build map: teamPk -> TeamInfo @@ -308,26 +454,33 @@ export const DashboardPeopleInfo = () => { setReviews([]); - const commonTeamsRes = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/commonteams`, { - credentials: 'include' - }); - if (!commonTeamsRes.ok) throw Error("Failed to fetch common teams"); - - const commonTeams = await commonTeamsRes.json() + const commonTeamsRes = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/commonteams`, + { + credentials: "include", + }, + ); + if (!commonTeamsRes.ok) + throw Error("Failed to fetch common teams"); + + const commonTeams = await commonTeamsRes.json(); const reviewableTeams: TeamInformationBrief[] = []; const existingReviews: ReviewData[] = []; - - const results = await Promise.all( - commonTeams.teams.map(async (team: TeamInformationBrief) => { - const res = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews/${team.pk}`, { - credentials: 'include' - }); - if (!res.ok) return { team: null, review: null }; - const data = await res.json(); - return { team, review: data.review }; - }) + commonTeams.teams.map( + async (team: TeamInformationBrief) => { + const res = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews/${team.pk}`, + { + credentials: "include", + }, + ); + if (!res.ok) return { team: null, review: null }; + const data = await res.json(); + return { team, review: data.review }; + }, + ), ); for (const { team, review } of results) { @@ -339,10 +492,16 @@ export const DashboardPeopleInfo = () => { setReviews(existingReviews); // Don't fetch any actual data. Just check if user is authorized to get this data. - const queryString = new URLSearchParams({ limit: '0', getAggregateData: 'true' }).toString(); - const allReviewsRes = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews?${queryString}`, { - credentials: 'include' - }); + const queryString = new URLSearchParams({ + limit: "0", + getAggregateData: "true", + }).toString(); + const allReviewsRes = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews?${queryString}`, + { + credentials: "include", + }, + ); // If true, unauthorized. if (!allReviewsRes.ok) { @@ -351,14 +510,16 @@ export const DashboardPeopleInfo = () => { } setIsExec(true); - + const allReviewsData = await allReviewsRes.json(); setTotalReviews(allReviewsData.aggregateData.totalReviews ?? 0); setAvgRating(allReviewsData.aggregateData.averageRating ?? 0); - } catch (err) { } finally { } + } catch (err) { + } finally { + } // If an error occured, its likely due to lacking permissions. // Dont show error message as to keep reviews hidden from normal user. - } + }; fetchUserReviews(); }, [userPk]); @@ -368,43 +529,64 @@ export const DashboardPeopleInfo = () => { setReviewsLoading(true); const queryString = new URLSearchParams({ - limit: '12', + limit: "12", offset: allReviewsLengthRef.current.toString(), - getAggregateData: 'false', + getAggregateData: "false", sortBy: "updatedAt", - ascending: "false" + ascending: "false", }).toString(); - const allReviewsRes = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews?${queryString}`, { - credentials: 'include' - }); + const allReviewsRes = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews?${queryString}`, + { + credentials: "include", + }, + ); if (!allReviewsRes.ok) { - throw new Error(`Failed to fetch reviews: ${allReviewsRes.statusText}`); + throw new Error( + `Failed to fetch reviews: ${allReviewsRes.statusText}`, + ); } const allReviewsData = await allReviewsRes.json(); - const uniqueCreatorIds: number[] = [...new Set(allReviewsData.reviews.map((r: ReviewData) => r.creatorId))]; - - const reviewers = await Promise.all(uniqueCreatorIds.map(async (creatorId) => { - if (userInfoCacheRef.current.has(creatorId)) return null; - const userRes = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${creatorId}`,{ - credentials: 'include' - }); - if (!userRes.ok) { - toast.error(`Failed to load user information for user ${creatorId}`); - }; - const userData: UserInformationDetail = await userRes.json(); - return [creatorId, userData]; - })); + const uniqueCreatorIds: number[] = [ + ...new Set( + allReviewsData.reviews.map((r: ReviewData) => r.creatorId), + ), + ]; + + const reviewers = await Promise.all( + uniqueCreatorIds.map(async (creatorId) => { + if (userInfoCacheRef.current.has(creatorId)) return null; + const userRes = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${creatorId}`, + { + credentials: "include", + }, + ); + if (!userRes.ok) { + toast.error( + `Failed to load user information for user ${creatorId}`, + ); + } + const userData: UserInformationDetail = + await userRes.json(); + return [creatorId, userData]; + }), + ); - setUserInfoCache(prev => { + setUserInfoCache((prev) => { const newUserInfoCache = new Map(prev); - reviewers.forEach(entry => { - if (entry) newUserInfoCache.set(entry[0] as number, entry[1] as UserInformationDetail); + reviewers.forEach((entry) => { + if (entry) + newUserInfoCache.set( + entry[0] as number, + entry[1] as UserInformationDetail, + ); }); return newUserInfoCache; - }) - setAllReviews(prev => [...prev, ...allReviewsData.reviews]); + }); + setAllReviews((prev) => [...prev, ...allReviewsData.reviews]); } catch (e: any) { toast.error(e.message); } finally { @@ -413,44 +595,76 @@ export const DashboardPeopleInfo = () => { }, [userPk]); useEffect(() => { - if (!allReviewsOpen || !isExec || totalReviews === 0 || allReviewsLengthRef.current > 0) return; + if ( + !allReviewsOpen || + !isExec || + totalReviews === 0 || + allReviewsLengthRef.current > 0 + ) + return; fetchReviews(); }, [allReviewsOpen, isExec, totalReviews, allReviewsLengthRef.current]); - const handleAddReview = useCallback((review: ReviewData) => { - if (isExec) { - setAllReviews(prev => [review, ...prev]); - setAvgRating(prev => (prev * totalReviews + review.rating) / (totalReviews + 1)); - setTotalReviews(prev => prev + 1); - } - setReviews(prev => [review, ...prev]); - }, [isExec, totalReviews]); - - const handleEditReview = useCallback((review: ReviewData) => { - if (isExec) { - setAllReviews(prev => { - const idx = prev.findIndex(r => r._id === review._id); - if (idx === -1) return prev; + const handleAddReview = useCallback( + (review: ReviewData) => { + if (isExec) { + setAllReviews((prev) => [review, ...prev]); + setAvgRating( + (prev) => + (prev * totalReviews + review.rating) / + (totalReviews + 1), + ); + setTotalReviews((prev) => prev + 1); + } + setReviews((prev) => [review, ...prev]); + }, + [isExec, totalReviews], + ); - setAvgRating(avg => avg + (review.rating - prev[idx].rating) / totalReviews); - return [review, ...prev.filter((_, i) => i !== idx)]; - }); - } - setReviews(prev => [review, ...prev.filter(r => r._id !== review._id)]); - }, [isExec, totalReviews]); + const handleEditReview = useCallback( + (review: ReviewData) => { + if (isExec) { + setAllReviews((prev) => { + const idx = prev.findIndex((r) => r._id === review._id); + if (idx === -1) return prev; + + setAvgRating( + (avg) => + avg + + (review.rating - prev[idx].rating) / totalReviews, + ); + return [review, ...prev.filter((_, i) => i !== idx)]; + }); + } + setReviews((prev) => [ + review, + ...prev.filter((r) => r._id !== review._id), + ]); + }, + [isExec, totalReviews], + ); - const handleDeleteReview = useCallback((review: ReviewData) => { - if (isExec) { - setAllReviews(prev => prev.filter(r => r._id !== review._id)); - if (totalReviews == 1) { - setAvgRating(0); - } else { - setAvgRating(prev => (prev * totalReviews - review.rating) / (totalReviews - 1)); + const handleDeleteReview = useCallback( + (review: ReviewData) => { + if (isExec) { + setAllReviews((prev) => + prev.filter((r) => r._id !== review._id), + ); + if (totalReviews == 1) { + setAvgRating(0); + } else { + setAvgRating( + (prev) => + (prev * totalReviews - review.rating) / + (totalReviews - 1), + ); + } + setTotalReviews((prev) => prev - 1); } - setTotalReviews(prev => prev - 1); - } - setReviews(prev => prev.filter(r => r._id !== review._id)); - }, [isExec, totalReviews]); + setReviews((prev) => prev.filter((r) => r._id !== review._id)); + }, + [isExec, totalReviews], + ); const handleExpandOpen = useCallback((review: ReviewData) => { setSelectedReview(review); @@ -470,46 +684,66 @@ export const DashboardPeopleInfo = () => { // Fetch logged-in user's pk useEffect(() => { fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/auth/userinfo`) - .then(r => r.json()) - .then(d => setLoggedInPk(d.pk)) + .then((r) => r.json()) + .then((d) => setLoggedInPk(d.pk)) .catch(() => {}); }, []); // Fetch UMD majors when edit modal opens useEffect(() => { if (!editOpen || majors.length > 0) return; - fetch('https://api.umd.io/v1/majors/list') - .then(r => r.json()) + fetch("https://api.umd.io/v1/majors/list") + .then((r) => r.json()) .then(setMajors) - .catch(() => toast.error('Failed to load majors')); + .catch(() => toast.error("Failed to load majors")); }, [editOpen]); const openEdit = () => { if (!user) return; - setEditPhone(user.attributes?.phoneNumber ?? ''); - setEditGrad(user.attributes?.expectedGrad ?? ''); - setEditLinkedin(user.attributes?.linkedinUrl ?? ''); - setEditLinkedinError(''); + setEditPhone(user.attributes?.phoneNumber ?? ""); + setEditGrad(user.attributes?.expectedGrad ?? ""); + setEditLinkedin(user.attributes?.linkedinUrl ?? ""); + setEditLinkedinError(""); setEditAvatarPreview(user.avatar || null); setEditAvatarKey(undefined); if (user.attributes?.major) { - setEditMajor(majors.find(m => m.name === user.attributes.major)); + setEditMajor(majors.find((m) => m.name === user.attributes.major)); } setEditOpen(true); }; - const getCroppedImg = (imageSrc: string, pixelCrop: Area): Promise => { + const getCroppedImg = ( + imageSrc: string, + pixelCrop: Area, + ): Promise => { return new Promise((resolve, reject) => { const image = new Image(); image.src = imageSrc; image.onload = () => { - const canvas = document.createElement('canvas'); + const canvas = document.createElement("canvas"); canvas.width = pixelCrop.width; canvas.height = pixelCrop.height; - const ctx = canvas.getContext('2d'); - if (!ctx) return reject(new Error('No 2d context')); - ctx.drawImage(image, pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height, 0, 0, pixelCrop.width, pixelCrop.height); - canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas empty')), 'image/webp', 1.0); + const ctx = canvas.getContext("2d"); + if (!ctx) return reject(new Error("No 2d context")); + ctx.drawImage( + image, + pixelCrop.x, + pixelCrop.y, + pixelCrop.width, + pixelCrop.height, + 0, + 0, + pixelCrop.width, + pixelCrop.height, + ); + canvas.toBlob( + (blob) => + blob + ? resolve(blob) + : reject(new Error("Canvas empty")), + "image/webp", + 1.0, + ); }; image.onerror = reject; }); @@ -518,44 +752,71 @@ export const DashboardPeopleInfo = () => { const onFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; - const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; - if (!allowed.includes(file.type)) return toast.error('Invalid file type'); - if (file.size > 20 * 1024 * 1024) return toast.error('File too large (max 20MB)'); + const allowed = ["image/jpeg", "image/png", "image/webp", "image/gif"]; + if (!allowed.includes(file.type)) + return toast.error("Invalid file type"); + if (file.size > 20 * 1024 * 1024) + return toast.error("File too large (max 20MB)"); setCrop({ x: 0, y: 0 }); setZoom(1); const reader = new FileReader(); - reader.onload = () => { setCropImage(reader.result as string); setIsCropOpen(true); }; + reader.onload = () => { + setCropImage(reader.result as string); + setIsCropOpen(true); + }; reader.readAsDataURL(file); - if (fileInputRef.current) fileInputRef.current.value = ''; + if (fileInputRef.current) fileInputRef.current.value = ""; }; const processAndUpload = async () => { if (!cropImage || !croppedAreaPixels || !userPk) return; - toast.info('Processing image...'); + toast.info("Processing image..."); setIsUploading(true); setIsCropOpen(false); try { - const croppedBlob = await getCroppedImg(cropImage, croppedAreaPixels); - const compressed = await imageCompression(new File([croppedBlob], 'avatar.webp', { type: 'image/webp' }), { - maxSizeMB: 0.45, maxWidthOrHeight: 512, useWebWorker: true, fileType: 'image/webp' + const croppedBlob = await getCroppedImg( + cropImage, + croppedAreaPixels, + ); + const compressed = await imageCompression( + new File([croppedBlob], "avatar.webp", { type: "image/webp" }), + { + maxSizeMB: 0.45, + maxWidthOrHeight: 512, + useWebWorker: true, + fileType: "image/webp", + }, + ); + const uploadFile = new File([compressed], "avatar.webp", { + type: "image/webp", }); - const uploadFile = new File([compressed], 'avatar.webp', { type: 'image/webp' }); setEditAvatarPreview(URL.createObjectURL(uploadFile)); - const res = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/avatar/profile-upload-url?fileName=${encodeURIComponent(uploadFile.name)}&contentType=${encodeURIComponent(uploadFile.type)}`, { credentials: 'include' }); - if (!res.ok) throw new Error((await res.json()).message || 'Failed to get upload URL'); + const res = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/avatar/profile-upload-url?fileName=${encodeURIComponent(uploadFile.name)}&contentType=${encodeURIComponent(uploadFile.type)}`, + { credentials: "include" }, + ); + if (!res.ok) + throw new Error( + (await res.json()).message || "Failed to get upload URL", + ); const { uploadUrl, key, fields } = await res.json(); const formData = new FormData(); - Object.entries(fields).forEach(([k, v]) => formData.append(k, v as string)); - formData.append('file', uploadFile); - const uploadRes = await fetch(uploadUrl, { method: 'POST', body: formData }); - if (!uploadRes.ok) throw new Error('Upload failed'); + Object.entries(fields).forEach(([k, v]) => + formData.append(k, v as string), + ); + formData.append("file", uploadFile); + const uploadRes = await fetch(uploadUrl, { + method: "POST", + body: formData, + }); + if (!uploadRes.ok) throw new Error("Upload failed"); setEditAvatarKey(key); - toast.success('Profile picture uploaded!'); + toast.success("Profile picture uploaded!"); } catch (e: any) { - toast.error('Upload failed', { description: e.message }); + toast.error("Upload failed", { description: e.message }); } finally { setIsUploading(false); setCropImage(null); @@ -574,34 +835,46 @@ export const DashboardPeopleInfo = () => { setIsSaving(true); try { const body: Record = {}; - if (editPhone !== (user?.attributes?.phoneNumber ?? '')) body.phoneNumber = editPhone; - if (editGrad !== (user?.attributes?.expectedGrad ?? '')) body.expectedGrad = editGrad; - if (editMajor && editMajor.name !== user?.attributes?.major) body.major = editMajor.name; - if (normalizedLinkedinUrl !== (user?.attributes?.linkedinUrl ?? '')) body.linkedinUrl = normalizedLinkedinUrl; + if (editPhone !== (user?.attributes?.phoneNumber ?? "")) + body.phoneNumber = editPhone; + if (editGrad !== (user?.attributes?.expectedGrad ?? "")) + body.expectedGrad = editGrad; + if (editMajor && editMajor.name !== user?.attributes?.major) + body.major = editMajor.name; + if (normalizedLinkedinUrl !== (user?.attributes?.linkedinUrl ?? "")) + body.linkedinUrl = normalizedLinkedinUrl; if (editAvatarKey) body.avatarKey = editAvatarKey; - if (Object.keys(body).length === 0) { setEditOpen(false); return; } + if (Object.keys(body).length === 0) { + setEditOpen(false); + return; + } - const res = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(body) - }); + const res = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body), + }, + ); if (!res.ok) { const err = await res.json(); - throw new Error(err.message || 'Failed to save'); + throw new Error(err.message || "Failed to save"); } - toast.success('Profile updated!'); + toast.success("Profile updated!"); setEditOpen(false); // Refresh profile data - const updated = await fetch(`${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}`); + const updated = await fetch( + `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}`, + ); if (updated.ok) setUser(await updated.json()); } catch (e: any) { - toast.error('Failed to save', { description: e.message }); + toast.error("Failed to save", { description: e.message }); } finally { setIsSaving(false); } @@ -624,12 +897,23 @@ export const DashboardPeopleInfo = () => { } if (!user) { - return
User not found.
; + return ( +
+ User not found. +
+ ); } const { attributes } = user; - const initials = user.name.split(" ").map(n => n[0]).join("").toUpperCase().slice(0, 2); - const linkedinHref = normalizeLinkedInProfileUrl(attributes?.linkedinUrl ?? ''); + const initials = user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + const linkedinHref = normalizeLinkedInProfileUrl( + attributes?.linkedinUrl ?? "", + ); // Build role entries from attributes.roles, looking up team info from map // Filter out entries where team info couldn't be found @@ -637,16 +921,15 @@ export const DashboardPeopleInfo = () => { .map(([teamPk, roleTitle]) => ({ teamPk, roleTitle, - teamInfo: userTeamsMap.get(teamPk) + teamInfo: userTeamsMap.get(teamPk), })) - .filter(entry => entry.teamInfo !== undefined); + .filter((entry) => entry.teamInfo !== undefined); const copyToClipboard = async (text: string) => { try { await navigator.clipboard.writeText(text); toast.info("Email copied to clipboard"); - } - catch (err) { + } catch (err) { toast.error("Failed to copy email"); } }; @@ -656,7 +939,7 @@ export const DashboardPeopleInfo = () => { { - setReviewDialogOpen(open) + setReviewDialogOpen(open); if (!open) setSelectedReview(null); }} reviewTeams={reviewTeams} @@ -686,41 +969,72 @@ export const DashboardPeopleInfo = () => { handleDeleteReview={handleDeleteReview} />
- {/* Left Column: User Profile Sidebar (Slim) */}
{/* Avatar & Basic Identity */}
- - {initials} + + + {initials} +
-

{user.name}

- {loggedInPk !== null && Number(userPk) === loggedInPk && ( - - )} +

+ {user.name} +

+ {loggedInPk !== null && + Number(userPk) === loggedInPk && ( + + )}
-

{user.username}

+

+ {user.username} +

- + {user.active ? "Active" : "Inactive"} {user.is_superuser && ( - + Admin )} {attributes?.alumniAccount && ( - + Alumni @@ -732,7 +1046,13 @@ export const DashboardPeopleInfo = () => {
-
@@ -740,7 +1060,11 @@ export const DashboardPeopleInfo = () => { {attributes?.phoneNumber && ( @@ -766,7 +1090,6 @@ export const DashboardPeopleInfo = () => { {/* Right Column: Main Content */}
- {/* Section 1: Personal Details Grid */}

@@ -775,19 +1098,53 @@ export const DashboardPeopleInfo = () => {

{attributes?.major && ( - + )} {attributes?.expectedGrad && ( - + )} {user.memberSince && ( - + )} {user.last_login && ( - + )} - {user.type && user.type !== 'internal' && ( - {user.type}} /> + {user.type && user.type !== "internal" && ( + + {user.type} + + } + /> )}
@@ -798,170 +1155,315 @@ export const DashboardPeopleInfo = () => { Team Memberships - + {roleEntries.length} Total
{roleEntries.length > 0 ? (
- {roleEntries.map(({ teamPk, roleTitle, teamInfo }) => ( - navigate(`/org/teams/${teamPk}`)} - > - -
-
- {teamInfo?.parentFriendlyName || teamInfo?.parentName ? ( -
- - {teamInfo.parentFriendlyName || teamInfo.parentName} - - - {teamInfo.friendlyName} + {roleEntries.map( + ({ teamPk, roleTitle, teamInfo }) => ( + + navigate(`/org/teams/${teamPk}`) + } + > + +
+
+ {teamInfo?.parentFriendlyName || + teamInfo?.parentName ? ( +
+ + {teamInfo.parentFriendlyName || + teamInfo.parentName} + + + { + teamInfo.friendlyName + } + +
+ ) : ( + + {teamInfo?.friendlyName || + "Unknown Team"} -
- ) : ( - - {teamInfo?.friendlyName || "Unknown Team"} - - )} - {teamInfo?.teamType && ( - - {teamInfo.teamType} - - )} -
-
- - {roleTitle} -
+ )} + {teamInfo?.teamType && ( + + { + teamInfo.teamType + } + + )} +
+
+ + {roleTitle} +
- {teamInfo?.description && ( -

- {teamInfo.description} -

- )} + {teamInfo?.description && ( +

+ { + teamInfo.description + } +

+ )} -
-
- {(teamInfo?.seasonType || teamInfo?.seasonYear) ? ( - <> - - {teamInfo.seasonType} {teamInfo.seasonYear} - - ) : ( - Ongoing - )} +
+
+ {teamInfo?.seasonType || + teamInfo?.seasonYear ? ( + <> + + + { + teamInfo.seasonType + }{" "} + { + teamInfo.seasonYear + } + + + ) : ( + + Ongoing + + )} +
+ + {teamPk.substring( + 0, + 6, + )} +
- {teamPk.substring(0, 6)}
-
- - - ))} + + + ), + )}
) : (
-

No team memberships found.

+

+ No team memberships found. +

)} - {reviewTeams.length !== 0 && + {reviewTeams.length !== 0 && (

Review User -

+
- {reviews.length > 0 ? + {reviews.length > 0 ? (
-

Your Reviews

+

+ Your Reviews +

{reviews.map((review) => ( - ))}
- +
- :
-

You have not written an internal review for this user yet.

- + ) : ( +
+

+ You have not written an internal review + for this user yet. +

+
- } + )}
- } + )} - {isExec && + {isExec && (

All Reviews -

Average rating: {avgRating.toFixed(1)} - + {totalReviews} Total
-
- {allReviewsOpen && (totalReviews > 0 ? -
-
- {allReviews.map((review) => ( - - ))} - {reviewsLoading && - Array.from({ length: Math.min(12, totalReviews - allReviews.length)}, (_, i) => ( - - )) - } + {allReviewsOpen && + (totalReviews > 0 ? ( +
+
+ {allReviews.map((review) => ( + + ))} + {reviewsLoading && + Array.from( + { + length: Math.min( + 12, + totalReviews - + allReviews.length, + ), + }, + (_, i) => ( + + ), + )} +
+ {allReviews.length < totalReviews && ( + + )}
- {(allReviews.length < totalReviews) && } -
: -
-

This user has not been reviewed.

-
- )} - + ) : ( +
+

+ This user has not been reviewed. +

+
+ ))}
- } + )}
@@ -979,22 +1481,47 @@ export const DashboardPeopleInfo = () => { className="h-24 w-24 cursor-pointer ring-2 ring-muted hover:ring-primary transition-all" onClick={() => fileInputRef.current?.click()} > - + - {isUploading ? : } + {isUploading ? ( + + ) : ( + + )} -

Click to change photo

- +

+ Click to change photo +

+
{/* Major */}
- + - @@ -1002,15 +1529,36 @@ export const DashboardPeopleInfo = () => { - No majors found. + + No majors found. + - {majors.map(m => ( - { setEditMajor(m); setMajorOpen(false); }}> + {majors.map((m) => ( + { + setEditMajor(m); + setMajorOpen(false); + }} + >
- {m.name} - {m.college} + + {m.name} + + + {m.college} +
- +
))}
@@ -1023,19 +1571,30 @@ export const DashboardPeopleInfo = () => { {/* Expected Graduation */}
- +
{/* Phone */}
- +
{/* LinkedIn */}
{ aria-invalid={Boolean(editLinkedinError)} onChange={(e) => { setEditLinkedin(e.target.value); - if (editLinkedinError) setEditLinkedinError(''); + if (editLinkedinError) + setEditLinkedinError(""); }} onBlur={() => { - const normalized = normalizeLinkedInProfileUrl(editLinkedin); + const normalized = + normalizeLinkedInProfileUrl( + editLinkedin, + ); if (normalized === null) { - setEditLinkedinError(LINKEDIN_PROFILE_ERROR); + setEditLinkedinError( + LINKEDIN_PROFILE_ERROR, + ); } else { setEditLinkedin(normalized); - setEditLinkedinError(''); + setEditLinkedinError(""); } }} /> - {editLinkedinError &&

{editLinkedinError}

} + {editLinkedinError && ( +

+ {editLinkedinError} +

+ )}
- - + @@ -1075,27 +1654,60 @@ export const DashboardPeopleInfo = () => { {/* Crop Dialog */} - { setIsCropOpen(open); if (!open) setCropImage(null); }}> + { + setIsCropOpen(open); + if (!open) setCropImage(null); + }} + > Crop Profile Picture
{cropImage && ( - setCroppedAreaPixels(pixels)} + + setCroppedAreaPixels(pixels) + } /> )}
- setZoom(z => Math.max(1, z - 0.1))} /> - setZoom(v[0])} className="flex-1" /> - setZoom(z => Math.min(3, z + 0.1))} /> + setZoom((z) => Math.max(1, z - 0.1))} + /> + setZoom(v[0])} + className="flex-1" + /> + setZoom((z) => Math.min(3, z + 0.1))} + />
- - + +
@@ -1103,82 +1715,131 @@ export const DashboardPeopleInfo = () => { ); }; -const ReviewCard = memo(({ - review, - teamName, - onExpand, - onEdit, - onDelete, - reviewer -}: { - review: ReviewData; - teamName: string | undefined; - onExpand: (review: ReviewData) => void; - onEdit: (review: ReviewData) => void; - onDelete: (review: ReviewData) => void; - reviewer?: UserInformationBrief; -}) => { - const formattedDate = useMemo(() => - new Date(review.updatedAt).toLocaleString(), - [review.updatedAt] - ); +const ReviewCard = memo( + ({ + review, + teamName, + onExpand, + onEdit, + onDelete, + reviewer, + }: { + review: ReviewData; + teamName: string | undefined; + onExpand: (review: ReviewData) => void; + onEdit: (review: ReviewData) => void; + onDelete: (review: ReviewData) => void; + reviewer?: UserInformationBrief; + }) => { + const formattedDate = useMemo( + () => new Date(review.updatedAt).toLocaleString(), + [review.updatedAt], + ); - const handleExpand = useCallback(() => onExpand(review), [review, onExpand]); - const handleEdit = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onEdit(review); }, [review, onEdit]); - const handleDelete = useCallback((e: React.MouseEvent) => { e.stopPropagation(); onDelete(review); }, [review, onDelete]); + const handleExpand = useCallback( + () => onExpand(review), + [review, onExpand], + ); + const handleEdit = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onEdit(review); + }, + [review, onEdit], + ); + const handleDelete = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDelete(review); + }, + [review, onDelete], + ); - return ( - - -
-
- {reviewer && ( -
- - - - {reviewer.name.split(" ").map(n => n[0]).join("").toUpperCase().slice(0, 2)} - - -
- )} - - - {review.title} - -
- - - {review.rating} - -
+ return ( + + +
+
+ {reviewer && ( +
+ + + + {reviewer.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2)} + + +
+ )} -
- - {teamName} -
+ + {review.title} + +
+ + + {review.rating} + +
- {review.content && ( -

- {review.content} -

- )} +
+ + {teamName} +
-
- - {!reviewer && } - - - {formattedDate} -
-
-
- ); -}); + {review.content && ( +

+ {review.content} +

+ )} + +
+ + {!reviewer && ( + + )} + + + + {formattedDate} + +
+
+
+ ); + }, +); interface ReviewUserDialogProps { open?: boolean; @@ -1194,10 +1855,10 @@ const ReviewUserDialog = (props: ReviewUserDialogProps) => { const { userPk } = useParams<{ userPk: string }>(); const reviewURL = `${PEOPLEPORTAL_SERVER_ENDPOINT}/api/org/people/${userPk}/reviews`; - const [teamId, setTeamId] = useState(''); + const [teamId, setTeamId] = useState(""); const [rating, setRating] = useState(5); - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -1208,7 +1869,7 @@ const ReviewUserDialog = (props: ReviewUserDialogProps) => { teamId: teamId, rating: rating, title: title, - content: content + content: content, }); const headers = { "Content-Type": "application/json" }; @@ -1217,12 +1878,22 @@ const ReviewUserDialog = (props: ReviewUserDialogProps) => { let res: Response; if (props.editReview === undefined) { - res = await fetch(reviewURL, { credentials: 'include', method: 'POST', headers: headers, body: payload }); + res = await fetch(reviewURL, { + credentials: "include", + method: "POST", + headers: headers, + body: payload, + }); } else { - res = await fetch(`${reviewURL}/${props.editReview._id}`, { credentials: 'include', method: 'PUT', headers: headers, body: payload }); + res = await fetch(`${reviewURL}/${props.editReview._id}`, { + credentials: "include", + method: "PUT", + headers: headers, + body: payload, + }); } - if (!res.ok) throw new Error((await res.text())); + if (!res.ok) throw new Error(await res.text()); const resData = await res.json(); if (props.editReview === undefined) { @@ -1233,28 +1904,32 @@ const ReviewUserDialog = (props: ReviewUserDialogProps) => { // props.setReviews([resData.review, ...filtered]); props.handleEditReview(resData.review); } - + toast.success("Review Submitted"); props.openChanged(false); - setContent(''); + setContent(""); setRating(5); - setTitle(''); - setTeamId(''); - + setTitle(""); + setTeamId(""); } catch (err: any) { toast.error(err.message); } finally { setIsLoading(false); } - } + }; const reviewableTeams = useMemo(() => { if (props.editReview === undefined) { - return props.reviewTeams.filter((team) => - props.reviews.findIndex((r) => r.teamId === team.pk) === -1 - ) + return props.reviewTeams.filter( + (team) => + props.reviews.findIndex((r) => r.teamId === team.pk) === -1, + ); } - return [props.reviewTeams.find((team) => team.pk === props.editReview!.teamId)!] + return [ + props.reviewTeams.find( + (team) => team.pk === props.editReview!.teamId, + )!, + ]; }, [props.reviewTeams, props.reviews, props.editReview]); useEffect(() => { @@ -1264,67 +1939,105 @@ const ReviewUserDialog = (props: ReviewUserDialogProps) => { setContent(props.editReview.content); setRating(props.editReview.rating); } else { - setTeamId(''); - setTitle(''); - setContent(''); + setTeamId(""); + setTitle(""); + setContent(""); setRating(5); } }, [props.editReview]); - return ( - {props.editReview === undefined ? "Write a Review" : "Edit Review"} + + {props.editReview === undefined + ? "Write a Review" + : "Edit Review"} +
- { + const idx = reviewableTeams.findIndex( + (t) => t.pk === val, + ); + if (idx === -1) { + setTeamId(""); + return; + } + setTeamId(val); + }} + > Team - {reviewableTeams.map((team) => {team.friendlyName || team.name})} + {reviewableTeams.map((team) => ( + + {team.friendlyName || team.name} + + ))}
- {teamId !== '' && + {teamId !== "" && ( <> -
- - setRating(Number(e.target.value))} /> -
-
- - setTitle(e.target.value)} /> -
-
- -