From d0539f5907d2242641cb95c03a7c2e566a0e31be Mon Sep 17 00:00:00 2001 From: Kai Cempura Date: Tue, 27 Oct 2026 13:57:06 -0400 Subject: [PATCH] refactor(peopleportal): split ATS controller and harden application workflows --- .../deployment/local-development.md | 8 +- peopleportal/dev-stack/README.md | 9 +- peopleportal/dev-stack/docker-compose.yml | 8 +- peopleportal/dev-stack/scripts/portal-env.sh | 2 +- peopleportal/dev-stack/scripts/verify.sh | 1 + peopleportal/server/README.md | 173 ++ .../server/src/controllers/ATSController.ts | 1816 +---------------- .../server/src/controllers/OrgController.ts | 52 +- peopleportal/server/src/models/Application.ts | 62 + .../src/services/ats/ATSApplicantService.ts | 512 +++++ .../src/services/ats/ATSApplicationService.ts | 978 +++++++++ .../server/src/services/ats/ATSHelpers.ts | 430 ++++ .../src/services/ats/ATSRecruitmentService.ts | 456 +++++ .../test/fixtures/ats-behavior.sha256.json | 422 ++++ .../test/integration/ats-behavior.test.js | 1033 ++++++++++ .../test/integration/ats-reliability.test.js | 465 +++++ 16 files changed, 4693 insertions(+), 1734 deletions(-) create mode 100644 peopleportal/server/src/services/ats/ATSApplicantService.ts create mode 100644 peopleportal/server/src/services/ats/ATSApplicationService.ts create mode 100644 peopleportal/server/src/services/ats/ATSHelpers.ts create mode 100644 peopleportal/server/src/services/ats/ATSRecruitmentService.ts create mode 100644 peopleportal/server/test/fixtures/ats-behavior.sha256.json create mode 100644 peopleportal/server/test/integration/ats-behavior.test.js create mode 100644 peopleportal/server/test/integration/ats-reliability.test.js diff --git a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md index 19fff5e..eb1ca76 100644 --- a/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md +++ b/corpwiki/docs/people-portal-dev-guide/deployment/local-development.md @@ -32,6 +32,12 @@ before starting containers, starts healthy services, bootstraps Authentik and Gitea, and runs `scripts/verify.sh`. Both up and verify print the selected ports. The first image pull, bootstrap and Nx dependency install can take several minutes. +Application submission requires MongoDB transactions. The dev stack initializes +a single-node replica set named `ppdev` without deleting existing data. After +updating an existing stack, rerun `up.sh --write-env` and update an existing +server env file from the printed block. Its `PEOPLEPORTAL_MONGO_URL` must include +`replicaSet=ppdev&directConnection=true`, especially when using a custom port. + Open `http://localhost:5173`. Sign in as `akadmin` with the generated `AUTHENTIK_BOOTSTRAP_PASSWORD` in this directory's `.env`. Skip initial Authentik setup. The backend callback is `http://localhost:3000/api/auth/redirect`. @@ -139,7 +145,7 @@ docker compose stop ``` `verify.sh` checks all eight included containers and backing-service APIs with -16 checks, and exits 0 when all pass. It always prints a visible NOTE about S3. +17 checks, and exits 0 when all pass. It always prints a visible NOTE about S3. Reset preserves stack and server/UI env files, deletes service data and exported OIDC/token files, and changes the Gitea token on the next bootstrap. Update the server env from the new output. Keep the same `COMPOSE_PROJECT_NAME` between diff --git a/peopleportal/dev-stack/README.md b/peopleportal/dev-stack/README.md index 62c2da3..fb95354 100644 --- a/peopleportal/dev-stack/README.md +++ b/peopleportal/dev-stack/README.md @@ -28,6 +28,13 @@ before starting containers, starts healthy services, bootstraps Authentik and Gitea, and runs `scripts/verify.sh`. Both up and verify print the selected ports. The first image pull, bootstrap and Nx dependency install can take several minutes. +Application submission uses a MongoDB transaction. This stack initializes a +single-node replica set named `ppdev` automatically and preserves existing data. +After updating an existing stack, rerun `up.sh --write-env`. If the server env +file already exists, update its `PEOPLEPORTAL_MONGO_URL` from the printed block; +it must include `replicaSet=ppdev&directConnection=true`, including on custom ports. +Other development databases must also support transactions; MongoDB Atlas does. + Open . Sign in as `akadmin` with the generated `AUTHENTIK_BOOTSTRAP_PASSWORD` in this directory's `.env`. Skip initial Authentik setup. The backend callback is . @@ -130,7 +137,7 @@ docker compose stop ``` `verify.sh` checks all eight included containers and backing-service APIs with -16 checks, and exits 0 when all pass. It always prints a visible NOTE about S3. +17 checks, and exits 0 when all pass. It always prints a visible NOTE about S3. Reset preserves stack and server/UI env files, deletes service data and exported OIDC/token files, and changes the Gitea token on the next bootstrap. Update the server env from the new output. Keep the same `COMPOSE_PROJECT_NAME` between diff --git a/peopleportal/dev-stack/docker-compose.yml b/peopleportal/dev-stack/docker-compose.yml index dcaf276..53beb26 100644 --- a/peopleportal/dev-stack/docker-compose.yml +++ b/peopleportal/dev-stack/docker-compose.yml @@ -118,13 +118,19 @@ services: mongo: image: mongo:7 restart: unless-stopped + command: [mongod, --replSet, ppdev, --bind_ip_all] ports: - "${BIND_ADDRESS:-127.0.0.1}:${MONGO_PORT:-27017}:27017" volumes: - mongo:/data/db - mongo-config:/data/configdb healthcheck: - test: [CMD, mongosh, --quiet, --eval, "quit(db.adminCommand('ping').ok ? 0 : 1)"] + test: + - CMD + - mongosh + - --quiet + - --eval + - "try { rs.status(); } catch (e) { if (e.codeName !== 'NotYetInitialized') throw e; rs.initiate({_id: 'ppdev', members: [{_id: 0, host: 'localhost:27017'}]}); } quit(db.hello().isWritablePrimary ? 0 : 1)" interval: 5s timeout: 10s retries: 30 diff --git a/peopleportal/dev-stack/scripts/portal-env.sh b/peopleportal/dev-stack/scripts/portal-env.sh index 445fc34..6352ff8 100755 --- a/peopleportal/dev-stack/scripts/portal-env.sh +++ b/peopleportal/dev-stack/scripts/portal-env.sh @@ -29,7 +29,7 @@ PEOPLEPORTAL_OIDC_CLIENTID=$PORTAL_OIDC_CLIENT_ID PEOPLEPORTAL_OIDC_CLIENTSECRET=$PORTAL_OIDC_CLIENT_SECRET PEOPLEPORTAL_BASE_URL=$PORTAL_BASE_URL PEOPLEPORTAL_TOKEN_SECRET=$PORTAL_TOKEN_SECRET -PEOPLEPORTAL_MONGO_URL=mongodb://127.0.0.1:$MONGO_PORT/people-portal +PEOPLEPORTAL_MONGO_URL=mongodb://127.0.0.1:$MONGO_PORT/people-portal?replicaSet=ppdev&directConnection=true PEOPLEPORTAL_REDIS_URL=redis://127.0.0.1:$REDIS_PORT PEOPLEPORTAL_GITEA_ENDPOINT=$GITEA_URL PEOPLEPORTAL_GITEA_TOKEN=$gitea_token diff --git a/peopleportal/dev-stack/scripts/verify.sh b/peopleportal/dev-stack/scripts/verify.sh index e39e4a2..10d511e 100755 --- a/peopleportal/dev-stack/scripts/verify.sh +++ b/peopleportal/dev-stack/scripts/verify.sh @@ -66,6 +66,7 @@ check 'Gitea API version HTTP 200' http_200 "http://127.0.0.1:$GITEA_PORT/api/v1 check 'Gitea authentik auth source exists' gitea_source check 'Gitea can discover authentik via public OIDC URL' gitea_discovery check 'MongoDB ping' compose exec -T mongo mongosh --quiet --eval "quit(db.adminCommand('ping').ok ? 0 : 1)" +check 'MongoDB writable replica set' compose exec -T mongo mongosh --quiet --eval "const h = db.hello(); quit(h.setName === 'ppdev' && h.isWritablePrimary ? 0 : 1)" check 'Redis PONG' redis_ping check 'Mailpit API HTTP 200' http_200 "http://127.0.0.1:$MAILPIT_PORT/api/v1/messages" if (( failures > 0 )); then die "$failures smoke check(s) failed."; fi diff --git a/peopleportal/server/README.md b/peopleportal/server/README.md index 7c5fcad..10349e8 100644 --- a/peopleportal/server/README.md +++ b/peopleportal/server/README.md @@ -15,3 +15,176 @@ You should execute tasks for this project via the Nx CLI from the root of the wo * **Run DB Seed**: `nx seed pplserver` *Note: Make sure you run these commands from the root of the monorepo.* + +## ATS code map + +`src/controllers/ATSController.ts` defines the HTTP routes, required access, +and response status. Its methods delegate to the following files in +`src/services/ats/`: + +| File | Responsibility | +| --- | --- | +| `ATSApplicantService.ts` | Resume upload/download links, shared applicant profile, and application submission. | +| `ATSApplicationService.ts` | Recruiter application lists/details, manual feedback, resume links, applicant history, and stage changes. | +| `ATSRecruitmentService.ts` | Team/subteam recruitment settings, open-role listings, cache updates, and recruitment statistics. | +| `ATSHelpers.ts` | Shared types, validation rules, resume keys/signing, and response formatting. PDF verification reads object storage. | + +The controller's undecorated forwarding methods support existing internal +callers; they are not extra HTTP endpoints. Authentication itself lives in +`AuthController` and `auth.ts`. The ATS has manual star ratings, but does not +extract resume text, call an AI model, or calculate an AI score yet. + +### Request flow + +1. **Configure recruitment:** an authorized recruiter saves a subteam's roles, + questions, and open/closed setting. The parent team's recruiting list is + updated and the public-listing cache is invalidated. +2. **Browse:** applicants see recruiting teams and their roles. MongoDB supplies + configuration, Authentik supplies team information, and Redis caches the + public list. Missing directory results are hidden without deleting records. +3. **Authenticate:** an applicant verifies their email through the guest/OTP + flow. That flow creates their applicant record. Recruiters use normal OIDC + login and need team ownership or `corp:hiringaccess`. +4. **Upload a PDF:** the server returns a short-lived upload form. The browser + sends the file directly to S3 under `resumes/.pdf`. The profile + stores the object key; it does not store the PDF bytes or a permanent URL. +5. **Submit:** check the session, open team, role ownership, profile, and required + answers; reject duplicates; verify the resume key and stored PDF header. + Then save the profile, application, and applicant/application link in one + MongoDB transaction. Failure rolls back all three writes. +6. **Review:** recruiters open application cards, read answers, open a temporary + resume link, and add manual stars or notes. Notes include the author and time. +7. **Change stage:** check access and allowed transitions, then save the new + stage, one history entry, and any pending follow-up work. Send email or + perform hiring actions afterward. A repeated request resumes unfinished work. + +### Stage rules + +| Current stage | Allowed next stages | Follow-up when entering the next stage | +| --- | --- | --- | +| Applied | Interview, Rejected | Interview requires a link and 50–500 characters of guidelines; rejection sends an email. | +| Interview | Potential Hire, Hired, Rejected | Potential Hire sends no email. Hired adds an existing member or sends an external applicant an onboarding invite. | +| Potential Hire | Hired, Rejected | The same hiring/rejection actions apply. | +| Hired / Rejected | No new stage | Repeating the current stage can still retry pending work. | + +## Testing the ATS refactor + +### Automated checks (no S3 access required) + +From the monorepo root: + +```bash +./nx run pplserver:test +``` + +This builds the server and runs the full backend suite. The ATS coverage is in: + +- `test/integration/ats-behavior.test.js`: 105 saved behavior comparisons for + responses, status codes, redirects, and relevant client calls. The fixtures + come from the controller before the split. The submission, stage, and public + listing cases compare responses while separate tests cover their intentional + persistence changes. +- `test/integration/ats-reliability.test.js`: a disposable MongoDB replica set + verifies transaction commit/rollback, simultaneous duplicate submissions, + foreign-team role rejection, retry payload retention, concurrent stage claims, + expired leases, membership recovery, invite reuse, current permissions, and + preservation of recruitment data during directory failures. + +These tests replace S3, email, and directory calls with controlled responses. +They do not verify AWS credentials or access, and they do not modify the database +from your `.env`. The database reliability tests create their own database and +remove only their own fixture records. + +To run just the ATS tests after building: + +```bash +./nx run pplserver:build +cd peopleportal/server +../../nx exec -p pplserver -- node --require ./test/helpers/bootstrap.js --test ./test/integration/ats-behavior.test.js ./test/integration/ats-reliability.test.js +``` + +### Browser/API checklist + +Open `http://localhost:5173`. Use a dedicated development team and synthetic +applicants for write tests. If your environment points to a shared database or +directory, creating teams or hiring people changes those shared services. +Available email capture is at `http://localhost:8025` when SMTP points to the +local Mailpit service; the original login provider does not use its passwords. + +| Test | Action | Expected result | +| --- | --- | --- | +| Normal login | Sign in with the configured existing OIDC account. | Dashboard loads; recruiter permissions match the account. | +| Recruitment settings | Open a development team's Recruitment Tracker → Recruitment Settings. Add a role/question, enable recruitment, save, and refresh. | Saved settings persist and the team appears at `/apply`. Disabling the last recruiting subteam removes it from the open list. | +| Team detail | Open that team's card at `/apply`. | Only that team's recruiting subteams, roles, and questions appear. | +| Application details | In Recruitment Tracker → Applications, open a synthetic application. | Name, role order, answers, current stage, and history are correct. This does not require opening its resume. | +| Feedback | Give the test application 1–5 stars and a Markdown note. Refresh and add a second note. | Rating persists; the newest note appears first with author/time; previous notes remain. | +| Invalid feedback | Through the API, send stars outside 1–5, blank notes, or an unclosed code fence. | HTTP 400; previous feedback remains. | +| Valid stages | Move separate test applications through the paths in the stage table. | One history entry per successful move; expected emails appear in the configured test inbox. Potential Hire sends none. | +| Invalid stages | Through the API, try Applied → Hired, move a terminal application to another stage, or omit the interview link/guidelines. | HTTP 400; no new stage history or notification. | +| Review access | Request protected ATS data while logged out or as a user without access to that team. | Access is denied. An application ID belonging to another team cannot be read through the current team's route. | +| Retry behavior | Run the reliability tests listed above. | Failed work resumes with saved input; concurrent work returns 409; completed membership is skipped and an invite ID is reused. | +| Statistics | As an executive, open team recruitment totals. | Counts match test applications; active teams with no applications show zeroes. | + +Use `/api/docs` to inspect request bodies and routes when a negative case cannot +be sent through the browser form. The browser can prevent some invalid input +before it reaches the API. For failed stage requests, refreshing may show the +already-saved stage; retry the same `PUT /api/ats/applications/{teamId}/{applicationId}/stage` +request to finish pending work. The board has no dedicated retry button. + +### Resume checks and the S3 limitation + +Without access to the configured bucket, you cannot view existing resumes or +fully test new uploads, resume profile changes, or successful submission in the +browser. MongoDB contains only file keys, so there are no PDF copies to recover +from the applicant records. Signing a download URL can succeed even when S3 +later denies access or the object does not exist. A successful signing response +alone does not prove the PDF is readable. + +The upload API checks the filename and content type, allows only PDFs up to +10 MiB, and fixes the storage key to the signed-in applicant. Submission reads +the stored file header; it must remain enabled when testing. An unavailable +bucket can therefore surface as `InvalidFileSignature`, not just an AWS error +in the browser. + +Once a test bucket is available, test these end to end: + +1. Upload a synthetic PDF below 10 MiB and open it from both the applicant view + and the authorized recruiter view. Confirm it is the same file. +2. Submit with the required profile fields and answers. Expect one application + in Applied, one applicant/application link, and the same ordered roles and + answers in the recruiter view. +3. Repeat submission to the same team. Expect HTTP 409 and one application. +4. Reject a non-PDF filename/type, a file exceeding 10 MiB, and non-PDF bytes + renamed to `.pdf`. A guest must not upload or download another applicant's key. +5. Test missing profile fields, more than 200 words for Why App Dev, missing or + shorter-than-10-word answers, answers over 2,000 characters, and roles from + another team. Invalid requests must not create an application or update the + submitted profile. Use API requests for cases blocked by the form. + +Existing AWS resumes require access to their original bucket and objects. A +local S3-compatible test bucket can hold new synthetic PDFs, but cannot retrieve +those originals. The current S3 client does not consume `S3_ENDPOINT` or +`S3_FORCE_PATH_STYLE`; setting them in `.env` alone does not enable local storage. +Local storage would require explicit client support, a bucket with browser CORS, +and a separate test configuration. Do not replace real resumes with dummy files +or skip file validation to make a test pass. + +## ATS persistence and retries + +Application submission requires a MongoDB replica set or sharded cluster for +transactions. The local dev stack initializes its own single-node replica set; +see `peopleportal/dev-stack/README.md` when upgrading an existing environment. + +Stage changes save their follow-up work with the application before sending +notifications or changing membership. If a request fails after the stage was +saved, repeat the same stage request to resume its pending work. The saved +payload is reused and hiring access is checked again. A concurrent request +receives HTTP 409 while work is running, and another stage cannot overwrite +unfinished work. Processing leases renew while active and can be reclaimed +after five minutes if a worker stops. + +Completed membership steps are recorded and onboarding retries reuse one invite +ID. SMTP delivery cannot be made exactly once: a process failure after SMTP +accepts an email but before the completion record is written can still cause +a duplicate email on retry. There is no automatic background retry worker; +retrying the same stage request resumes the saved action. diff --git a/peopleportal/server/src/controllers/ATSController.ts b/peopleportal/server/src/controllers/ATSController.ts index babbb46..0acda2f 100644 --- a/peopleportal/server/src/controllers/ATSController.ts +++ b/peopleportal/server/src/controllers/ATSController.ts @@ -20,7 +20,6 @@ import { Body, Request, Controller, - Delete, Get, Path, Post, @@ -28,127 +27,34 @@ import { Route, SuccessResponse, Tags, + Security, + Query, } from "tsoa"; -import { SubteamConfig, ISubteamConfig } from "../models/SubteamConfig"; -import { - TeamRecruitingStatus, - ITeamRecruitingStatus, -} from "../models/TeamRecruitingStatus"; +import type * as express from "express"; 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 { Security } from "tsoa"; -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"; -import { Query } from "tsoa"; +import type { GetGroupInfoResponse } from "../clients/AuthentikClient/models"; +import type { IApplicant, ApplicantProfile } from "../models/Applicant"; +import type { IApplication } from "../models/Application"; 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 MarkdownIt from "markdown-it"; -import { RedisClient } from "../clients/RedisClient"; -import { mapWithConcurrency } from "../utils/concurrency"; -import { - CacheEnvelope, - isCacheEnvelope, - isCacheStale, - wrapCacheValue, -} from "../utils/cache-envelope"; - -// ==================== -// Type Definitions -// ==================== - -interface ATSSubteamConfigRequest { - isRecruiting: boolean; - roles: string[]; - roleSpecificQuestions: { [role: string]: string[] }; -} - -// Team-level application request with ordered role preferences -interface ATSTeamApplicationRequest { - teamPk: string; - rolePreferences: { role: string; subteamPk: string }[]; // Ordered array - profile: ApplicantProfile; - responses: Record; -} - -interface ATSUpdateApplicationStageRequest { - stage: ApplicationStage; - interviewLink?: string; - interviewGuidelines?: string; - hiredRole?: string; - hiredSubteamPk?: string; -} - -interface ATSKanbanApplicationCard { - id: string; - applicantId: string; - name: 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 - */ - stars: number; -} - -interface ATSApplicationDetails extends ATSKanbanApplicationCard { - email: string; - profile: Record; - responses: Record; - stageHistory?: { - stage: ApplicationStage; - changedAt: Date; - changedBy?: string; - }[]; - hiredRole?: string; - appDevInternalPk?: number; - notes?: string; -} - -// Constants -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; -const OPEN_TEAMS_CACHE_KEY = "ats:openteams"; -/* Past this the entry is refreshed, but it is still served while that happens, - so no applicant waits for the rebuild. */ -const OPEN_TEAMS_CACHE_SOFT_TTL_SECONDS = 300; -/* The Redis expiry, and the absolute ceiling on staleness if every background - refresh fails. Correctness comes from invalidating on write; this only bounds - how long a *missed* invalidation can serve a stale roster. */ -const OPEN_TEAMS_CACHE_HARD_TTL_SECONDS = 3600; - +import type { AuthorizedUser } from "../clients/OpenIdClient"; +import { ATSApplicantService } from "../services/ats/ATSApplicantService"; +import { ATSApplicationService } from "../services/ats/ATSApplicationService"; +import { ATSRecruitmentService } from "../services/ats/ATSRecruitmentService"; +import type { + ATSSubteamConfigRequest, + ATSTeamApplicationRequest, + ATSUpdateApplicationStageRequest, + ATSKanbanApplicationCard, + ATSApplicationDetails, +} from "../services/ats/ATSHelpers"; + +/* HTTP entry points for recruitment. Decorators describe access rules and the + * public API; each method delegates the workflow to one of three services: + * applicants: profiles, resume links, and submissions; + * applications: recruiter review, feedback, and stage changes; + * recruitment: open roles, team settings, and recruitment totals. + * Shared validation and formatting live in ATSHelpers. */ @Route("/api/ats") export class ATSController extends Controller { private readonly authentikClient: AuthentikClient; @@ -162,6 +68,24 @@ export class ATSController extends Controller { this.orgController = new OrgController(); } + // Services use this controller's response status and its current clients. + private get applicants() { + return new ATSApplicantService(this, this.authentikClient); + } + + private get applications() { + return new ATSApplicationService( + this, + this.authentikClient, + this.emailClient, + this.orgController, + ); + } + + private get recruitment() { + return new ATSRecruitmentService(this, this.authentikClient); + } + /** * Generates a presigned URL for uploading a resume to the AWS S3 bucket. * Checks for PDF File Type, Prevents Path Traversals and requires valid @@ -181,83 +105,11 @@ export class ATSController extends Controller { @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.", - }; - } - - // Validate PDF only - 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(", ")}`, - }; - } - - if (!ALLOWED_CONTENT_TYPES.includes(contentType as any)) { - this.setStatus(400); - return { - error: "InvalidContentType", - message: "Invalid content type. Only PDF is accepted.", - }; - } - - // Prevent path traversal - if ( - fileName.includes("..") || - fileName.includes("/") || - fileName.includes("\\") - ) { - this.setStatus(400); - return { error: "InvalidFileName", message: "Invalid file name" }; - } - - // Applicant must exist because of ats_otp security but, we'll check just in case - const applicant = await Applicant.findOne({ email: userEmail }).exec(); - if (!applicant) { - this.setStatus(404); - return { - error: "ApplicantNotFound", - message: "Applicant not found", - }; - } - - const key = `resumes/${applicant._id}.pdf`; - - // Use presigned POST to enforce file size limits on S3 side - const { url, fields } = await createPresignedPost(s3Client, { - Bucket: BUCKET_NAME, - Key: key, - Conditions: [ - ["content-length-range", 0, 10 * 1024 * 1024], // 10MB limit - { "content-type": "application/pdf" }, - ], - Fields: { - "content-type": "application/pdf", - }, - Expires: 600, // 10 minutes - }); - - // Construct the proxy URL using the request headers - const protocol = request.protocol; - const host = request.get("host"); - const publicUrl = `${protocol}://${host}/api/ats/resume/download?key=${encodeURIComponent(key)}`; - - return { - uploadUrl: url, - fields, - publicUrl, - key, - }; + return this.applicants.getResumeUploadUrl( + request, + fileName, + contentType, + ); } /** @@ -274,59 +126,7 @@ 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; - - // 1. Verify Authentication - if (!userEmail) { - this.setStatus(401); - 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("..") - ) { - this.setStatus(403); - return { - error: "Forbidden", - message: "You do not have permission to access this file.", - }; - } - - // 3. Generate Presigned GET URL - try { - /* Sign the string that was validated. Signing the raw `key` while - checking `normalizedKey` lets the two diverge, which is the shape - every path-traversal bug takes even when S3 happens to be literal. */ - const command = new GetObjectCommand({ - Bucket: BUCKET_NAME, - Key: normalizedKey, - }); - - const downloadUrl = await getSignedUrl(s3Client, command, { - expiresIn: 900, - }); // 15 minutes - - // 4. Redirect - this.setStatus(307); - 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 this.applicants.getResumeDownloadUrl(request, key); } /** @@ -343,53 +143,7 @@ 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; - if (!config) { - this.setStatus(404); - return { - error: "Not Found", - message: `Subteam config with ID ${teamId} not found`, - }; - } - - /* Legacy Implementation w/o Authentik 2025.12+ Call Optimizations */ - // const authentikSubteamInfo = await this.authentikClient.getGroupInfo(config.subteamPk) - // const authentikParentInfo = await this.authentikClient.getGroupInfo(authentikSubteamInfo.parentPk); - - /* 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 }, - ); - - let authentikParentInfo = authentikSubteamInfo.parentInfo; - if (!authentikParentInfo) { - /* Fallback for Legacy Implementation (Authentik <2025.12) */ - authentikParentInfo = await this.authentikClient.getGroupInfo( - authentikSubteamInfo.parentPk, - ); - } - - return { - ...config, - subteamInfo: { - name: authentikSubteamInfo.name, - friendlyName: authentikSubteamInfo.attributes.friendlyName, - description: authentikSubteamInfo.attributes.description, - seasonText: `${authentikSubteamInfo.attributes.seasonType} ${authentikSubteamInfo.attributes.seasonYear}`, - pk: authentikSubteamInfo.pk, - }, - - parentInfo: { - name: authentikParentInfo.name, - friendlyName: authentikParentInfo.attributes.friendlyName, - description: authentikParentInfo.attributes.description, - seasonText: `${authentikParentInfo.attributes.seasonType} ${authentikParentInfo.attributes.seasonYear}`, - pk: authentikParentInfo.pk, - }, - }; + return this.recruitment.getSubTeamATSConfig(teamId); } /** @@ -410,52 +164,7 @@ export class ATSController extends Controller { @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", - }; - } - - // Use findOneAndUpdate with upsert to create or update - const updatedConfig = await SubteamConfig.findOneAndUpdate( - { subteamPk: teamId }, // filter - { - subteamPk: teamId, - isRecruiting, - roles, - 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(); - - /* Update Team Recruiting Status */ - const subteamInfo = await this.authentikClient.getGroupInfo( - updatedConfig.subteamPk, - ); - if (updatedConfig.isRecruiting) { - await this.addSubteamToRecruiting( - subteamInfo.parentPk, - updatedConfig.subteamPk, - ); - } else { - await this.removeSubteamFromRecruiting( - subteamInfo.parentPk, - updatedConfig.subteamPk, - ); - } - - // Convert Map back to object for JSON response - return updatedConfig; + return this.recruitment.createOrUpdateSubteamConfig(teamId, body); } /** @@ -469,171 +178,7 @@ export class ATSController extends Controller { @Tags("Applicant Portal") @SuccessResponse(200) async getAllRecruitingTeams() { - const cached = - await RedisClient.get>(OPEN_TEAMS_CACHE_KEY); - - if (isCacheEnvelope(cached)) { - if (isCacheStale(cached, OPEN_TEAMS_CACHE_SOFT_TTL_SECONDS)) - this.refreshOpenTeamsInBackground(); - - return cached.value; - } - - /* A cold or invalidated cache is the only path that waits on a build. */ - return this.buildAndCacheOpenTeams(); - } - - /** - * Rebuilds the cache without making the current request wait for it. - * - * Guarded so that a burst of readers arriving after the soft TTL triggers - * one rebuild rather than one per request, which is the stampede a plain - * TTL causes. The guard is per-process, so a multi-instance deployment - * rebuilds once per instance; that is a far cry from once per request, and - * a distributed lock is not worth its failure modes here. - */ - private static openTeamsRefreshInFlight = false; - - private refreshOpenTeamsInBackground() { - if (ATSController.openTeamsRefreshInFlight) return; - ATSController.openTeamsRefreshInFlight = true; - - /* 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; - }); - } - - private async buildAndCacheOpenTeams() { - const validRecruitingTeams = await this.buildOpenTeams(); - await RedisClient.set( - OPEN_TEAMS_CACHE_KEY, - wrapCacheValue(validRecruitingTeams), - OPEN_TEAMS_CACHE_HARD_TTL_SECONDS, - ); - - return validRecruitingTeams; - } - - /** 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(); - - /* - * 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 - * 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 configMap = new Map(allConfigs.map((c: any) => [c.subteamPk, c])); - - /* - * The remaining O(N) was N sequential Authentik round trips, one per - * recruiting team, each of which then fetched every subteam again just - * to populate members this endpoint never reads. At 13 teams that was - * roughly 65 serial requests. Both are gone: members are not requested, - * 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, - }, - ); - - /* Populate Team and Subteam Info */ - const validRecruitingTeams: any[] = []; - const missingTeamPks: string[] = []; - - for (const team of recruitingTeams) { - const authentikTeamInfo = teamInfoByPk.get(team.teamPk); - if (!authentikTeamInfo) { - /* Absent from a successful bulk lookup means the group is gone - from Authentik. Collected, not deleted mid-iteration. */ - missingTeamPks.push(team.teamPk); - continue; - } - - 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, - }; - - team.teamInfo = parentInfoObj; - team.subteamInfo = {}; - for (const sub of authentikTeamInfo.subteams) { - if (recruitingSubteams.has(sub.pk)) { - const config = configMap.get(sub.pk); - - const subteamInfoObj = { - name: sub.name, - friendlyName: sub.attributes.friendlyName, - description: sub.attributes.description, - seasonText: `${sub.attributes.seasonType} ${sub.attributes.seasonYear}`, - pk: sub.pk, - }; - - team.subteamInfo[sub.pk] = { - ...subteamInfoObj, - recruitmentInfo: config - ? { - ...config, - subteamInfo: subteamInfoObj, - parentInfo: parentInfoObj, - } - : null, - }; - } - } - - validRecruitingTeams.push(team); - } - - /* - * 02-06-2026 (@atheesh): Data Integrity Compromise, See COE at - * https://wiki.appdevclub.com/people-portal-dev-guide/coe/ats-open-roles-failure - * Rows for groups Authentik no longer has are still cleaned up, now in - * 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(); - } - - return validRecruitingTeams; + return this.recruitment.getAllRecruitingTeams(); } /** @@ -647,13 +192,7 @@ export class ATSController extends Controller { @SuccessResponse(200) @Security("oidc") async getApplicationStages() { - // Return the ApplicationStage enum as an array of {id, name} objects - const stages = Object.entries(ApplicationStage).map(([key, value]) => ({ - id: value, - name: value, - })); - - return stages; + return this.applications.getApplicationStages(); } /** @@ -669,74 +208,7 @@ export class ATSController extends Controller { @Tags("Applicant Portal") @SuccessResponse(200) async getTeamDetails(@Path() teamId: string) { - try { - // Get team info from Authentik - const teamInfo = await this.authentikClient.getGroupInfo(teamId); - - // Get team recruiting status - 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", - }; - } - - // 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); - - return { - subteamPk: subteamPk, - friendlyName: - subteamInfo.attributes.friendlyName || - subteamInfo.name, - description: subteamInfo.attributes.description, - roles: config?.roles || [], - roleSpecificQuestions: - config?.roleSpecificQuestions || {}, - }; - }), - ); - - return { - teamPk: teamId, - teamInfo: { - name: teamInfo.name, - friendlyName: teamInfo.attributes.friendlyName, - description: teamInfo.attributes.description, - seasonText: `${teamInfo.attributes.seasonType} ${teamInfo.attributes.seasonYear}`, - pk: teamInfo.pk, - }, - recruitingSubteams: recruitingSubteams, - }; - } catch (err) { - if ( - err instanceof AuthentikClientError && - err.code === AuthentikClientErrorType.GROUP_NOT_FOUND - ) { - this.setStatus(404); - return { - error: "TeamNotFound", - message: "The requested team could not be found.", - }; - } - - this.setStatus(500); - return { - error: "ServerError", - message: "Failed to fetch team details", - }; - } + return this.recruitment.getTeamDetails(teamId); } /** @@ -754,39 +226,7 @@ export class ATSController extends Controller { 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, - }) - .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, - }), - ); - - return kanbanData; - } catch (err) { - console.error("Error fetching applications:", err); - this.setStatus(500); - throw new Error("Failed to fetch applications"); - } + return this.applications.getTeamApplications(teamId); } /** @@ -807,48 +247,7 @@ export class ATSController extends Controller { @Path() teamId: string, @Path() applicationId: string, ): Promise { - try { - const app = await Application.findOne({ - _id: applicationId, - teamPk: teamId, - }) - .populate<{ applicantId: IApplicant }>("applicantId") - .lean() - .exec(); - - if (!app) { - this.setStatus(404); - return { error: "NotFound", message: "Application not found" }; - } - - return { - id: app._id.toString(), - applicantId: app.applicantId._id.toString(), - name: app.applicantId.fullName || "Unknown Applicant", - email: app.applicantId.email || "", - stage: app.stage, - rolePreferences: app.rolePreferences, - appliedAt: app.appliedAt, - stars: app.stars || 0, - - // Detailed fields - profile: app.applicantId.profile as any, - responses: app.responses as any, - stageHistory: app.stageHistory, - ...(app.notes ? { notes: app.notes } : {}), - ...(app.hiredRole ? { hiredRole: app.hiredRole } : {}), - ...(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 this.applications.getApplicationDetails(teamId, applicationId); } /** @@ -858,8 +257,7 @@ export class ATSController extends Controller { * * @param teamId People Portal Team ID * @param applicationId Application ID - * @param stars New star rating (1-5) - * @param notes Interview Feedback in Markdown Format + * @param body Optional manual star rating (1-5) and feedback notes in Markdown */ @Put("applications/{teamId}/{applicationId}/feedback") @@ -872,103 +270,12 @@ export class ATSController extends Controller { @Path() applicationId: 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", - }; - } - - 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", - }; - } - - // 1. Fetch Existing Application - const app = await Application.findOne({ - _id: applicationId, - teamPk: teamId, - }); - if (!app) { - this.setStatus(404); - return { error: "NotFound", message: "Application not found" }; - } - - // 2. Update Stars - if (stars !== undefined) { - app.stars = stars; - } - - // 3. Update Notes (Append with Header) - if (notes) { - // Validate Markdown (Empty Check) - if (!notes.trim()) { - this.setStatus(400); - return { - error: "BadRequest", - message: "Notes cannot be empty", - }; - } - - // Validate Markdown Structure (Unclosed Fences) - const md = new MarkdownIt({ html: false }); - const sentinel = "___MARKDOWN_SENTINEL___"; - 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), - ); - - if (isTrapped) { - this.setStatus(400); - return { - error: "BadRequest", - message: "Invalid Markdown Structure", - }; - } - - 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, - }).format(now); - - const noteHeader = `###### ${authorName} \n*${formattedDate}*`; - const existingNotes = app.notes || ""; - const separator = existingNotes ? "\n\n---\n\n" : ""; - app.notes = `${noteHeader}\n\n${notes}${separator}${existingNotes}`; - } - - // 4. Save Changes - await app.save(); - return { success: true, stars: app.stars, notes: app.notes }; - } catch (err) { - this.setStatus(500); - return { error: "ServerError", message: "Failed to update stars" }; - } + return this.applications.updateApplicationFeedback( + req, + teamId, + applicationId, + body, + ); } /** @@ -992,61 +299,7 @@ export class ATSController extends Controller { @Path() teamId: string, @Path() applicantId: string, ) { - try { - const teamApplication = await Application.exists({ - applicantId, - teamPk: teamId, - }); - if (!teamApplication) { - this.setStatus(404); - return { error: "NotFound", message: "Applicant not found" }; - } - - const applicant = await Applicant.findById(applicantId); - if (!applicant) { - this.setStatus(404); - return { error: "NotFound", message: "Applicant not found" }; - } - - const profile = applicant.profile || new Map(); - let resumeDownloadUrl: string | undefined; - - const resumeUrl = profile.get("resumeUrl"); - if (resumeUrl) { - try { - const expectedKey = `resumes/${applicant._id}.pdf`; - 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 - } - } catch (s3Error) { - console.error("Error signing S3 URL:", s3Error); - // Fallback or just ignore, maybe client can deal with the raw key or empty string? - // We'll leave it undefined if signing fails. - } - } - - 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 this.applications.getApplicantUrls(teamId, applicantId); } /** @@ -1060,12 +313,12 @@ export class ATSController extends Controller { * - 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: + * Stage changes save their history and pending follow-up work before sending email + * or changing team membership. Additional information depends on the target stage: * * - 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 + * - Transitions to **Potential Hire** update the stage without sending an email * - 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. @@ -1073,6 +326,9 @@ export class ATSController extends Controller { * - Otherwise, we generate a unique invite link, valid for 48 hours, and send them an email * with onboarding instructions. * + * If follow-up work fails, repeat the same stage request to resume it with the saved + * input. Completed work is skipped; another stage is blocked until this work finishes. + * * 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. @@ -1093,217 +349,12 @@ export class ATSController extends Controller { @Path() applicationId: string, @Body() body: ATSUpdateApplicationStageRequest, ) { - try { - const { - stage, - interviewLink, - interviewGuidelines, - hiredRole, - hiredSubteamPk, - } = body; - - if (!req.session?.authorizedUser) { - this.setStatus(401); - 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, - }); - if (!application) { - this.setStatus(404); - return { error: "NotFound", message: "Application not found" }; - } - - 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", - }; - } - - const applicant = await Applicant.findById(application.applicantId); - if (!applicant) { - this.setStatus(404); - return { error: "NotFound", message: "Applicant not found" }; - } - - const currentStage = application.stage; - - // Idempotency check - if (currentStage === 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.HIRED]: [], - [ApplicationStage.REJECTED]: [], - }; - - const allowed = VALID_TRANSITIONS[currentStage] || []; - if (!allowed.includes(stage)) { - this.setStatus(400); - 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", - }; - } - - if ( - !interviewGuidelines || - interviewGuidelines.length < 50 || - interviewGuidelines.length > 500 - ) { - this.setStatus(400); - 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", - }; - } - - 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", - }; - } - - /* Add Hired Role and Hired Subteam PK to the Application */ - application.hiredRole = hiredRole; - application.hiredSubteamPk = hiredSubteamPk; - } - - // Update stage and history - application.stage = stage; - application.stageHistory.push({ - stage: stage, - changedAt: new Date(), - changedBy: `${req.session.authorizedUser?.name} (${req.session.authorizedUser?.username})`, - }); - - /* Save the Application and Send Emails */ - await application.save(); - - /* Get Parent Team Information and Handle Stage Specific Logic */ - if (stage === ApplicationStage.INTERVIEW) { - await this.emailClient.send({ - to: applicant.email, - cc: [req.session.authorizedUser!.email], - replyTo: [req.session.authorizedUser!.email], - subject: `Next Steps: ${teamInfo.attributes.friendlyName} Interview`, - templateName: "RecruitInterviewRequest", - templateVars: { - applicantName: applicant.fullName, - interviewerName: req.session.authorizedUser!.name, - teamName: teamInfo.attributes.friendlyName, - interviewLink: interviewLink, - 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 - over before that was true. Requested by the bootcamp leads, - 2026-09-02. The RecruitPotentialHireInfo template is kept for - a future opt-in send. */ - } else if (stage === ApplicationStage.HIRED) { - await this.processHiredStageTransition( - req.session.authorizedUser!, - teamInfo, - application, - applicant, - ); - } else if (stage === ApplicationStage.REJECTED) { - await this.emailClient.send({ - to: applicant.email, - cc: [req.session.authorizedUser!.email], - replyTo: [req.session.authorizedUser!.email], - subject: `Update on ${teamInfo.attributes.friendlyName}`, - templateName: "RecruitApplicationDeclined", - templateVars: { - applicantName: applicant.fullName, - teamName: teamInfo.attributes.friendlyName, - }, - }); - } - - return { - message: "Stage updated successfully", - application: { - id: application._id, - stage: application.stage, - 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 this.applications.updateApplicationStage( + req, + teamId, + applicationId, + body, + ); } /** @@ -1326,89 +377,7 @@ export class ATSController extends Controller { @Path() teamId: string, @Path() applicantId: string, ) { - try { - 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, - }) - .lean() - .sort({ appliedAt: -1 }) - .exec(); - - 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; - } catch (e) { - console.error( - `Failed to fetch subteam info for ${pref.subteamPk}`, - e, - ); - } - return { ...pref, subteamName }; - }), - ); - - 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 this.applications.getApplicantApplications(teamId, applicantId); } /** @@ -1419,8 +388,8 @@ export class ATSController extends Controller { * 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. + * and verifies the uploaded PDF. The applicant must already exist from guest authentication. + * Profile updates, the new application, and its applicant link are saved in one transaction. * * @param body Team Application Request * @param request Express Request Object @@ -1434,340 +403,7 @@ export class ATSController extends Controller { @Body() body: ATSTeamApplicationRequest, @Request() request: express.Request, ) { - try { - const { teamPk, rolePreferences, profile, responses } = body; - - // 1. Basic Validation - if ( - !teamPk || - !rolePreferences || - rolePreferences.length === 0 || - !profile - ) { - this.setStatus(400); - return { - error: "InvalidRequest", - 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.", - }; - } - - // 2. Verify team exists and is recruiting - 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.", - }; - } - - // 3. Collect all available roles from recruiting subteams - const allAvailableRoles = new Set(); - const allQuestions = new Set(); - - for (const subteamPk of teamStatus.recruitingSubteamPks) { - const config = await SubteamConfig.findOne({ - subteamPk, - }).exec(); - if (config && config.isRecruiting) { - 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) - ) { - this.setStatus(400); - return { - error: "InvalidRole", - 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(); - 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 (config.roles.includes(pref.role)) { - const roleQuestions = - config.roleSpecificQuestions[ - pref.role - ] || []; - roleQuestions.forEach((q: string) => - allQuestions.add(q), - ); - } - } - } - } - } - } - - // 6. Validate personal profile - if (!profile.resumeUrl || profile.resumeUrl.trim() === "") { - this.setStatus(400); - return { - error: "MissingRequiredField", - message: "Resume URL is required", - }; - } - - if (!profile.resumeUrl.toLowerCase().endsWith(".pdf")) { - this.setStatus(400); - return { - error: "InvalidFileType", - message: "Only PDF resumes are accepted", - }; - } - - if (!profile.whyAppDev || profile.whyAppDev.trim() === "") { - this.setStatus(400); - return { - error: "MissingRequiredField", - message: "Please provide 'Why App Dev'", - }; - } - - if ( - !profile.instagramFollow || - profile.instagramFollow.trim() === "" - ) { - this.setStatus(400); - return { - error: "MissingRequiredField", - message: "Please confirm Instagram follow", - }; - } - - if (profile.linkedinUrl && profile.linkedinUrl.trim() !== "") { - try { - new URL(profile.linkedinUrl); - } catch { - this.setStatus(400); - return { - error: "InvalidURL", - message: "Please provide a valid LinkedIn URL", - }; - } - } - - if (profile.githubUrl && profile.githubUrl.trim() !== "") { - try { - new URL(profile.githubUrl); - } catch { - this.setStatus(400); - return { - error: "InvalidURL", - message: "Please provide a valid GitHub URL", - }; - } - } - - 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`, - }; - } - - // 7. Validate responses - Team question - const teamName = teamInfo.attributes.friendlyName || teamInfo.name; - const teamQuestionKey = `Why are you interested in ${teamName}?`; - allQuestions.add(teamQuestionKey); - - for (const question of Array.from(allQuestions)) { - if (!responses[question] || responses[question].trim() === "") { - this.setStatus(400); - return { - error: "MissingResponse", - message: `Please answer: ${question}`, - }; - } - - if (responses[question].length > MAX_RESPONSE_LENGTH) { - this.setStatus(400); - return { - error: "ResponseTooLong", - message: `Response too long for: ${question}. Maximum ${MAX_RESPONSE_LENGTH} characters.`, - }; - } - - 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.`, - }; - } - } - - // 8. Check for duplicate application - const applicant = await Applicant.findOne({ - email: userEmail, - }).exec(); - - if (!applicant) { - this.setStatus(404); - return { - error: "ApplicantNotFound", - message: "Applicant profile not found.", - }; - } - - const existingApp = await Application.findOne({ - applicantId: applicant._id, - teamPk: teamPk, - }).exec(); - - if (existingApp) { - this.setStatus(409); - return { - error: "DuplicateApplication", - 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("..") - ) { - this.setStatus(400); - return { - error: "InvalidResumeKey", - message: "Invalid resume upload.", - }; - } - - 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.", - }; - } - } catch (e) { - console.error("Failed to validate resume", e); - this.setStatus(500); - return { - error: "FileProcessingError", - message: "Failed to process resume file.", - }; - } - - // 9.1 Update the already authenticated applicant. - const updatedApplicant = await Applicant.findByIdAndUpdate( - applicant._id, - { - $set: { - fullName: - request.session.tempsession?.user?.name || - userEmail.split("@")[0], - profile: new Map(Object.entries(profile)), - updatedAt: new Date(), - }, - }, - { new: true }, - ).exec(); - if (!updatedApplicant) { - this.setStatus(404); - 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; - } catch (_e) { - /* Not previously in App Dev */ - } - - // 10. Create application with ordered role preferences - const application = await Application.create({ - applicantId: updatedApplicant._id, - teamPk: teamPk, - 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(), - }, - ], - }); - - await Applicant.findByIdAndUpdate(updatedApplicant._id, { - $push: { applicationIds: application._id }, - }).exec(); - - return { - message: "Application submitted successfully", - id: application._id, - }; - } catch (err) { - console.error("Application submission error:", err); - this.setStatus(500); - return { - error: "ServerError", - message: "Failed to submit application", - }; - } + return this.applicants.applyToTeam(body, request); } /** @@ -1787,265 +423,37 @@ export class ATSController extends Controller { @Body() body: ApplicantProfile, @Request() request: express.Request, ) { - try { - const userEmail = request.session?.tempsession?.user?.email; - if (!userEmail) { - this.setStatus(401); - return { - error: "Unauthorized", - message: "User session not found.", - }; - } - - const applicant = await Applicant.findOne({ email: userEmail }); - if (!applicant) { - this.setStatus(404); - return { error: "NotFound", message: "Applicant not found." }; - } - - // Merge existing profile with new updates - 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("..") - ) { - this.setStatus(400); - return { - error: "InvalidResumeKey", - message: "Invalid resume upload.", - }; - } - - 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.", - }; - } - } - - const updatedProfile = { ...currentProfile, ...body }; - - await Applicant.findOneAndUpdate( - { email: userEmail }, - { - $set: { - profile: updatedProfile, - 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 this.applicants.updateProfile(body, request); } - /* ==== APPLICATION STAGE CHANGE EMAIL HELPERS ==== */ + /* Compatibility methods for existing callers; workflows stay in the services. + * These methods have no HTTP route decorator and are not additional endpoints. */ 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( + return this.applications.processHiredStageTransition( authorizedUser, - application.teamPk, - ); - if ( - !authorizedTeam || - !application.hiredRole || - !application.hiredSubteamPk || - !(await this.isValidHireDestination( - authorizedTeam, - application.hiredSubteamPk, - application.hiredRole, - )) - ) { - throw new Error("Unauthorized hired-stage transition"); - } - - teamInfo = authorizedTeam; - /* If Applicant is an internal member, we do not send onboard invites! */ - const applicantEmail = applicant.email; - let user: UserInformationBrief; - try { - user = - await this.authentikClient.getUserInfoFromEmail(applicantEmail); - } catch (_) { - /* Onboard the User */ - await this.orgController.createInvite( - { - session: { authorizedUser }, - bindle: { - teamInfo, - requestedPermissions: [], - }, - }, - { - inviteeName: applicant.fullName, - inviteeEmail: applicantEmail, - roleTitle: application.hiredRole!, - 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.emailClient.send({ - to: applicantEmail, - cc: [authorizedUser.email], - replyTo: [authorizedUser.email], - subject: `Congrats! You're accepted to ${teamInfo.attributes.friendlyName}`, - templateName: "RecruitExistingMemberAcceptance", - templateVars: { - inviteeName: applicant.fullName, - 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 { - const teamInfo = await this.authentikClient.getGroupInfo(teamPk); - 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, + application, + applicant, ); - 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 || - destination.parentPk !== teamInfo.pk || - 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, ) { - const updatedTeam = await TeamRecruitingStatus.findOneAndUpdate( - { teamPk: teamId }, - { - $addToSet: { recruitingSubteamPks: subteamId }, - teamPk: teamId, - }, - { upsert: true, new: true }, - ) - .lean() - .exec(); - - // Set isRecruiting based on array length - if ( - updatedTeam.recruitingSubteamPks.length > 0 && - !updatedTeam.isRecruiting - ) { - await TeamRecruitingStatus.updateOne( - { teamPk: teamId }, - { isRecruiting: true }, - ); - updatedTeam.isRecruiting = true; - } - - /* After every write, never before: invalidating first leaves a window in - which a concurrent read repopulates the cache from pre-update rows. */ - await RedisClient.delete(OPEN_TEAMS_CACHE_KEY); - return updatedTeam; + return this.recruitment.addSubteamToRecruiting(teamId, subteamId); } async removeSubteamFromRecruiting( @Path() teamId: string, @Path() subteamId: string, ) { - const updatedTeam = await TeamRecruitingStatus.findOneAndUpdate( - { teamPk: teamId }, - { $pull: { recruitingSubteamPks: subteamId } }, - { new: true }, - ) - .lean() - .exec(); - - if (!updatedTeam) { - this.setStatus(404); - return { error: "Not Found", message: `Team ${teamId} not found` }; - } - - // Set isRecruiting to false if no subteams left - if ( - updatedTeam.recruitingSubteamPks.length === 0 && - updatedTeam.isRecruiting - ) { - await TeamRecruitingStatus.updateOne( - { teamPk: teamId }, - { isRecruiting: false }, - ); - updatedTeam.isRecruiting = false; - } - - await RedisClient.delete(OPEN_TEAMS_CACHE_KEY); - return updatedTeam; + return this.recruitment.removeSubteamFromRecruiting(teamId, subteamId); } /** @@ -2068,54 +476,6 @@ export class ATSController extends Controller { @SuccessResponse(200) @Security("executive") async getTeamRecruitmentStats() { - const [teamsRes, grouped] = await Promise.all([ - this.authentikClient.getGroupsList({ limit: 500 }), - Application.aggregate([ - { - $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; - }[]) { - const stages = byTeam.get(row._id.teamPk) ?? {}; - stages[row._id.stage] = row.count; - byTeam.set(row._id.teamPk, stages); - } - - const stageKeys = Object.values(ApplicationStage); - - const teams = teamsRes.teams.map((team) => { - const stages = byTeam.get(team.pk) ?? {}; - const counts: Record = {}; - let total = 0; - - for (const stage of stageKeys) { - const n = stages[stage] ?? 0; - counts[stage] = n; - total += n; - } - - return { - teamPk: team.pk, - name: team.friendlyName || team.name, - counts, - total, - }; - }); - - /* Busiest first: an executive scanning this wants the teams with - something happening, not alphabetical order. */ - teams.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); - - return { stages: stageKeys, teams }; + return this.recruitment.getTeamRecruitmentStats(); } } diff --git a/peopleportal/server/src/controllers/OrgController.ts b/peopleportal/server/src/controllers/OrgController.ts index f622565..be92165 100644 --- a/peopleportal/server/src/controllers/OrgController.ts +++ b/peopleportal/server/src/controllers/OrgController.ts @@ -1735,6 +1735,24 @@ export class OrgController extends Controller { | (ExpressRequestAuthUserShim & ExpressRequestBindleShim), @Body() inviteReq: APITeamInviteCreateRequest, @Path("teamId") _teamId?: string, + ) { + return this.createInviteRecordAndSend(req, inviteReq); + } + + async createInviteForATS( + req: ExpressRequestAuthUserShim & ExpressRequestBindleShim, + inviteReq: APITeamInviteCreateRequest, + inviteId: string, + ) { + return this.createInviteRecordAndSend(req, inviteReq, inviteId); + } + + private async createInviteRecordAndSend( + req: + | express.Request + | (ExpressRequestAuthUserShim & ExpressRequestBindleShim), + inviteReq: APITeamInviteCreateRequest, + inviteId?: string, ) { /* Validate & Normalize Name */ const cleanedName = validatePersonName(inviteReq.inviteeName); @@ -1761,6 +1779,12 @@ export class OrgController extends Controller { } catch (e) { /* We Gracefully Handle All But Previous Error! */ if (e instanceof CustomValidationError) throw e; + if ( + inviteId && + (!(e instanceof AuthentikClientError) || + e.code !== AuthentikClientErrorType.USER_NOT_FOUND) + ) + throw e; } /* Gather Additional Data */ @@ -1787,7 +1811,7 @@ export class OrgController extends Controller { } /* Create New Invite */ - const createdInvite = await Invite.create({ + const inviteData = { inviteName: inviteReq.inviteeName, inviteEmail: inviteReq.inviteeEmail, roleTitle: inviteReq.roleTitle, @@ -1798,7 +1822,31 @@ export class OrgController extends Controller { expiresAt: new Date( Date.now() + 48 * 60 * 60 * 1000, ) /* 48 Hours */, - }); + }; + const createdInvite = inviteId + ? await Invite.findOneAndUpdate( + { _id: inviteId }, + { $setOnInsert: inviteData }, + { upsert: true, new: true, runValidators: true }, + ) + : await Invite.create(inviteData); + if ( + createdInvite.inviteEmail !== inviteReq.inviteeEmail || + createdInvite.teamPk !== teamInfo.pk || + createdInvite.subteamPk !== inviteReq.subteamPk || + createdInvite.roleTitle !== inviteReq.roleTitle + ) { + throw new CustomValidationError( + 409, + "Invite does not match this hiring action", + ); + } + if (inviteId && createdInvite.expiresAt.getTime() <= Date.now()) { + createdInvite.expiresAt = new Date( + Date.now() + 48 * 60 * 60 * 1000, + ); + await createdInvite.save(); + } /* Send an Email to the Invitee and the Invitor */ const onboardUrl = `${process.env.PEOPLEPORTAL_BASE_URL}/onboard/${createdInvite._id}`; diff --git a/peopleportal/server/src/models/Application.ts b/peopleportal/server/src/models/Application.ts index 168fe1f..f059036 100644 --- a/peopleportal/server/src/models/Application.ts +++ b/peopleportal/server/src/models/Application.ts @@ -26,6 +26,27 @@ export enum ApplicationStage { HIRED = "Hired", } +export interface PendingStageAction { + id: string; + stage: ApplicationStage; + input: { + stage: ApplicationStage; + interviewLink?: string; + interviewGuidelines?: string; + hiredRole?: string; + hiredSubteamPk?: string; + }; + state: "pending" | "processing"; + completedSteps: string[]; + leaseToken?: string; + leaseUntil?: Date; + recipientType?: "member" | "external"; + memberPk?: string; + membershipStarted?: boolean; + inviteId: string; + sender: { name: string; email: string }; +} + export interface IApplication extends Document { applicantId: Schema.Types.ObjectId; teamPk: string; @@ -43,8 +64,48 @@ export interface IApplication extends Document { appDevInternalPk?: number; stars: number; notes?: string; + pendingStageAction?: PendingStageAction; } +const PendingStageActionSchema = new Schema( + { + id: { type: String, required: true }, + stage: { + type: String, + enum: Object.values(ApplicationStage), + required: true, + }, + input: { + stage: { + type: String, + enum: Object.values(ApplicationStage), + required: true, + }, + interviewLink: String, + interviewGuidelines: String, + hiredRole: String, + hiredSubteamPk: String, + }, + state: { + type: String, + enum: ["pending", "processing"], + required: true, + }, + completedSteps: { type: [String], default: [] }, + leaseToken: String, + leaseUntil: Date, + recipientType: { type: String, enum: ["member", "external"] }, + memberPk: String, + membershipStarted: Boolean, + inviteId: { type: String, required: true }, + sender: { + name: { type: String, required: true }, + email: { type: String, required: true }, + }, + }, + { _id: false }, +); + const ApplicationSchema = new Schema( { applicantId: { @@ -96,6 +157,7 @@ const ApplicationSchema = new Schema( hiredSubteamPk: { type: String, required: false }, stars: { type: Number, required: true, default: 0, min: 0, max: 5 }, notes: { type: String, required: false, maxlength: 10000 }, + pendingStageAction: { type: PendingStageActionSchema, required: false }, }, { timestamps: true }, ); diff --git a/peopleportal/server/src/services/ats/ATSApplicantService.ts b/peopleportal/server/src/services/ats/ATSApplicantService.ts new file mode 100644 index 0000000..66bcc24 --- /dev/null +++ b/peopleportal/server/src/services/ats/ATSApplicantService.ts @@ -0,0 +1,512 @@ +/** + People Portal Server + Copyright (C) 2026 Atheesh Thirumalairajan + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + 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 . +*/ + +import type * as express from "express"; +import { Types } from "mongoose"; +import path from "path"; +import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; +import { AuthentikClient } from "../../clients/AuthentikClient"; +import type { GetGroupInfoResponse } from "../../clients/AuthentikClient/models"; +import { + Applicant, + IApplicant, + ApplicantProfile, +} from "../../models/Applicant"; +import { Application, ApplicationStage } from "../../models/Application"; +import { SubteamConfig } from "../../models/SubteamConfig"; +import { TeamRecruitingStatus } from "../../models/TeamRecruitingStatus"; +import { s3Client, BUCKET_NAME } from "../../clients/AWSClient/S3Client"; +import { + ATSResponse, + ATSTeamApplicationRequest, + resumeKeyFor, + createResumeDownloadUrl, + validateResumeUpload, + validateApplicantProfile, + validateApplicationResponses, + verifyApplicantResume, +} from "./ATSHelpers"; + +/* Applicant workflow: authorize the guest session, validate the request and + * uploaded PDF, then save the submission. File bytes stay in object storage; + * MongoDB stores the applicant's resume key and application data. */ +export class ATSApplicantService { + constructor( + private readonly response: ATSResponse, + private readonly authentikClient: AuthentikClient, + ) {} + + async getResumeUploadUrl( + request: express.Request, + fileName: string, + contentType: string, + ) { + const userEmail = request.session.tempsession?.user?.email; + if (!userEmail) { + this.response.setStatus(401); + return { + error: "Unauthorized", + message: "User session not found.", + }; + } + + const validationError = validateResumeUpload( + fileName, + contentType, + this.response, + ); + if (validationError) return validationError; + + // Guest authentication creates the applicant before requesting an upload. + const applicant = await Applicant.findOne({ email: userEmail }).exec(); + if (!applicant) { + this.response.setStatus(404); + return { + error: "ApplicantNotFound", + message: "Applicant not found", + }; + } + + const key = resumeKeyFor(applicant._id); + + // Use presigned POST to enforce file size limits on S3 side + const { url, fields } = await createPresignedPost(s3Client, { + Bucket: BUCKET_NAME, + Key: key, + Conditions: [ + ["content-length-range", 0, 10 * 1024 * 1024], // 10MB limit + { "content-type": "application/pdf" }, + ], + Fields: { + "content-type": "application/pdf", + }, + Expires: 600, // 10 minutes + }); + + // Uploads go straight to storage. This app URL checks ownership on download. + const protocol = request.protocol; + const host = request.get("host"); + const publicUrl = `${protocol}://${host}/api/ats/resume/download?key=${encodeURIComponent(key)}`; + + return { + uploadUrl: url, + fields, + publicUrl, + key, + }; + } + + async getResumeDownloadUrl(request: any, key: string) { + const userEmail = request.session?.tempsession?.user?.email; + + // A guest can download only the resume belonging to their own applicant ID. + if (!userEmail) { + this.response.setStatus(401); + return { + error: "Unauthorized", + message: "User session not found.", + }; + } + + const applicant = await Applicant.findOne({ email: userEmail }); + const normalizedKey = path.posix.normalize(key); + const expectedKey = resumeKeyFor(applicant?._id); + + if ( + !applicant || + normalizedKey !== expectedKey || + normalizedKey.includes("..") + ) { + this.response.setStatus(403); + return { + error: "Forbidden", + message: "You do not have permission to access this file.", + }; + } + + // Signing a URL does not check whether the object exists or is readable. + try { + const downloadUrl = await createResumeDownloadUrl(normalizedKey); + + // Storage serves the PDF; the backend returns only a temporary redirect. + this.response.setStatus(307); + this.response.setHeader("Location", downloadUrl); + return; + } catch (error) { + console.error("Error generating download URL:", error); + this.response.setStatus(404); + return { + error: "NotFound", + message: "File not found or access denied.", + }; + } + } + + async applyToTeam( + body: ATSTeamApplicationRequest, + request: express.Request, + ) { + try { + const { teamPk, rolePreferences, profile, responses } = body; + + // 1. Require an application payload and a verified applicant session. + if ( + !teamPk || + !rolePreferences || + rolePreferences.length === 0 || + !profile + ) { + this.response.setStatus(400); + return { + error: "InvalidRequest", + message: + "TeamPk, role preferences, and profile are required.", + }; + } + + const userEmail = request.session.tempsession?.user?.email; + if (!userEmail) { + this.response.setStatus(401); + return { + error: "Unauthorized", + message: + "User session not found. Please verify your email.", + }; + } + + // 2. Require an open team and roles from its own recruiting subteams. + const teamInfo: GetGroupInfoResponse = + await this.authentikClient.getGroupInfo(teamPk); + const teamStatus = await TeamRecruitingStatus.findOne({ + teamPk, + }).exec(); + + if (!teamStatus || !teamStatus.isRecruiting) { + this.response.setStatus(403); + return { + error: "RecruitmentClosed", + message: + "This team is not currently accepting applications.", + }; + } + + const roleError = await this.validateRolePreferences( + rolePreferences, + teamStatus.recruitingSubteamPks, + teamInfo, + ); + if (roleError) return roleError; + + // 3. Validate the shared profile and every question for the selected roles. + const allQuestions = await this.collectRoleQuestions( + teamStatus.recruitingSubteamPks, + rolePreferences, + ); + + const profileError = validateApplicantProfile( + profile, + this.response, + ); + if (profileError) return profileError; + + // The team-interest question is required in addition to role questions. + const teamName = teamInfo.attributes.friendlyName || teamInfo.name; + const teamQuestionKey = `Why are you interested in ${teamName}?`; + allQuestions.add(teamQuestionKey); + + const responseError = validateApplicationResponses( + responses, + allQuestions, + this.response, + ); + if (responseError) return responseError; + + // 4. Reject duplicates before reading the uploaded file from storage. + const applicant = await Applicant.findOne({ + email: userEmail, + }).exec(); + + if (!applicant) { + this.response.setStatus(404); + return { + error: "ApplicantNotFound", + message: "Applicant profile not found.", + }; + } + + const existingApp = await Application.findOne({ + applicantId: applicant._id, + teamPk: teamPk, + }).exec(); + + if (existingApp) { + this.response.setStatus(409); + return { + error: "DuplicateApplication", + message: "You have already applied to this team.", + }; + } + + // Check ownership and PDF bytes before changing any database records. + try { + const resumeError = await verifyApplicantResume( + applicant._id, + profile.resumeUrl, + this.response, + ); + if (resumeError) return resumeError; + } catch (e) { + console.error("Failed to validate resume", e); + this.response.setStatus(500); + return { + error: "FileProcessingError", + message: "Failed to process resume file.", + }; + } + + // 5. Link an existing member, then commit all submission records together. + const appDevInternalPk = await this.findInternalMemberPk(userEmail); + const application = await this.createAndLinkApplication( + applicant, + body, + appDevInternalPk, + request.session.tempsession?.user?.name || + userEmail.split("@")[0]!, + ); + + return { + message: "Application submitted successfully", + id: application._id, + }; + } catch (err) { + if (err instanceof MissingSubmissionApplicant) { + this.response.setStatus(404); + return { + error: "ApplicantNotFound", + message: "Applicant profile not found.", + }; + } + if ( + typeof err === "object" && + err !== null && + "code" in err && + err.code === 11000 + ) { + this.response.setStatus(409); + return { + error: "DuplicateApplication", + message: "You have already applied to this team.", + }; + } + console.error("Application submission error:", err); + this.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to submit application", + }; + } + } + + async updateProfile(body: ApplicantProfile, request: express.Request) { + try { + const userEmail = request.session?.tempsession?.user?.email; + if (!userEmail) { + this.response.setStatus(401); + return { + error: "Unauthorized", + message: "User session not found.", + }; + } + + const applicant = await Applicant.findOne({ email: userEmail }); + if (!applicant) { + this.response.setStatus(404); + return { error: "NotFound", message: "Applicant not found." }; + } + + // Merge existing profile with new updates + const currentProfile = applicant.profile + ? Object.fromEntries(applicant.profile) + : {}; + + // Immediate Signature Validation for new Resume Uploads + if (body.resumeUrl && body.resumeUrl !== currentProfile.resumeUrl) { + const resumeError = await verifyApplicantResume( + applicant._id, + body.resumeUrl, + this.response, + ); + if (resumeError) { + return { + error: resumeError.error, + message: resumeError.message, + }; + } + } + + const updatedProfile = { ...currentProfile, ...body }; + + await Applicant.findOneAndUpdate( + { email: userEmail }, + { + $set: { + profile: updatedProfile, + updatedAt: new Date(), + }, + }, + ); + + return { message: "Profile updated successfully" }; + } catch (err) { + console.error("Error updating profile:", err); + this.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to update profile", + }; + } + } + + private async validateRolePreferences( + rolePreferences: ATSTeamApplicationRequest["rolePreferences"], + recruitingSubteamPks: string[], + teamInfo: GetGroupInfoResponse, + ) { + const allowedSubteams = new Set(recruitingSubteamPks); + for (const pref of rolePreferences) { + if ( + !allowedSubteams.has(pref.subteamPk) || + !teamInfo.subteamPkList.includes(pref.subteamPk) + ) { + this.response.setStatus(400); + return { + error: "InvalidRole", + message: + "Selected roles must belong to a recruiting subteam of this team", + }; + } + // 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) + ) { + this.response.setStatus(400); + return { + error: "InvalidRole", + message: `Role '${pref.role}' is not available in the specified subteam`, + }; + } + } + } + + private async collectRoleQuestions( + subteamPks: string[], + rolePreferences: ATSTeamApplicationRequest["rolePreferences"], + ) { + const allQuestions = new Set(); + for (const subteamPk of subteamPks) { + const config = await SubteamConfig.findOne({ + subteamPk, + }).exec(); + if (config && config.isRecruiting) { + for (const pref of rolePreferences) { + 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), + ); + } + } + } + } + } + return allQuestions; + } + + private async findInternalMemberPk(userEmail: string) { + let appDevInternalPk = null; + try { + const internalUser = + await this.authentikClient.getUserInfoFromEmail(userEmail); + appDevInternalPk = internalUser.pk; + } catch (_e) { + /* Not previously in App Dev */ + } + return appDevInternalPk; + } + + private async createAndLinkApplication( + applicant: IApplicant, + input: ATSTeamApplicationRequest, + appDevInternalPk: string | null, + fullName: string, + ) { + // Reuse the ID if MongoDB retries the transaction. Any failure rolls back + // the profile, application link, and application as one unit. The unique + // applicant/team index also resolves simultaneous duplicate submissions. + const applicationId = new Types.ObjectId(); + return Applicant.db.transaction(async (session) => { + const updatedApplicant = await Applicant.findByIdAndUpdate( + applicant._id, + { + $set: { + fullName, + profile: new Map(Object.entries(input.profile)), + updatedAt: new Date(), + }, + $addToSet: { applicationIds: applicationId }, + }, + { new: true, session }, + ).exec(); + if (!updatedApplicant) throw new MissingSubmissionApplicant(); + + const [application] = await Application.create( + [ + { + _id: applicationId, + applicantId: applicant._id, + teamPk: input.teamPk, + rolePreferences: input.rolePreferences, + stage: ApplicationStage.APPLIED, + responses: new Map(Object.entries(input.responses)), + appliedAt: new Date(), + appDevInternalPk, + stageHistory: [ + { + stage: ApplicationStage.APPLIED, + changedAt: new Date(), + }, + ], + }, + ], + { session }, + ); + if (!application) throw new Error("Application was not created"); + return application; + }); + } +} + +class MissingSubmissionApplicant extends Error {} diff --git a/peopleportal/server/src/services/ats/ATSApplicationService.ts b/peopleportal/server/src/services/ats/ATSApplicationService.ts new file mode 100644 index 0000000..9465dec --- /dev/null +++ b/peopleportal/server/src/services/ats/ATSApplicationService.ts @@ -0,0 +1,978 @@ +/** + People Portal Server + Copyright (C) 2026 Atheesh Thirumalairajan + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + 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 . +*/ + +import type * as express from "express"; +import { randomUUID } from "crypto"; +import { Types } from "mongoose"; +import { AuthentikClient } from "../../clients/AuthentikClient"; +import { + AuthentikClientError, + AuthentikClientErrorType, + type GetGroupInfoResponse, +} from "../../clients/AuthentikClient/models"; +import { EmailClient } from "../../clients/EmailClient"; +import { OrgController } from "../../controllers/OrgController"; +import { BindleController } from "../../controllers/BindleController"; +import type { AuthorizedUser } from "../../clients/OpenIdClient"; +import { Applicant, IApplicant } from "../../models/Applicant"; +import { + Application, + IApplication, + ApplicationStage, + PendingStageAction, +} from "../../models/Application"; +import { SubteamConfig } from "../../models/SubteamConfig"; +import { + ATSResponse, + ATSKanbanApplicationCard, + ATSApplicationDetails, + ATSUpdateApplicationStageRequest, + isApplicantResumeKey, + resumeKeyFor, + createResumeDownloadUrl, + validateFeedback, + validateFeedbackNotes, + formatFeedbackNote, + validateStageTransition, +} from "./ATSHelpers"; + +/* Recruiter workflow: read applications, save manual feedback, and move an + * application through the allowed stages. Stage follow-up work is stored with + * the application so a failed email or membership update can be retried. */ +export class ATSApplicationService { + constructor( + private readonly response: ATSResponse, + private readonly authentikClient: AuthentikClient, + private readonly emailClient: EmailClient, + private readonly orgController: OrgController, + ) {} + + async getApplicationStages() { + // Return the ApplicationStage enum as an array of {id, name} objects + const stages = Object.entries(ApplicationStage).map(([, value]) => ({ + id: value, + name: value, + })); + + return stages; + } + + async getTeamApplications( + 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, + }) + .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, + }), + ); + + return kanbanData; + } catch (err) { + console.error("Error fetching applications:", err); + this.response.setStatus(500); + throw new Error("Failed to fetch applications"); + } + } + + async getApplicationDetails( + teamId: string, + applicationId: string, + ): Promise { + try { + const app = await Application.findOne({ + _id: applicationId, + teamPk: teamId, + }) + .populate<{ applicantId: IApplicant }>("applicantId") + .lean() + .exec(); + + if (!app) { + this.response.setStatus(404); + return { error: "NotFound", message: "Application not found" }; + } + + return { + id: app._id.toString(), + applicantId: app.applicantId._id.toString(), + name: app.applicantId.fullName || "Unknown Applicant", + email: app.applicantId.email || "", + stage: app.stage, + rolePreferences: app.rolePreferences, + appliedAt: app.appliedAt, + stars: app.stars || 0, + + // Detailed fields + profile: app.applicantId.profile as any, + responses: app.responses as any, + stageHistory: app.stageHistory, + ...(app.notes ? { notes: app.notes } : {}), + ...(app.hiredRole ? { hiredRole: app.hiredRole } : {}), + ...(app.appDevInternalPk + ? { appDevInternalPk: app.appDevInternalPk } + : {}), + }; + } catch (err) { + console.error("Error fetching application details:", err); + this.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to fetch application details", + }; + } + } + + async updateApplicationFeedback( + req: express.Request, + teamId: string, + applicationId: string, + body: { stars?: number; notes?: string }, + ) { + try { + const { stars, notes } = body; + + const feedbackError = validateFeedback(stars, notes, this.response); + if (feedbackError) + return { + error: feedbackError.error, + message: feedbackError.message, + }; + + // 1. Fetch Existing Application + const app = await Application.findOne({ + _id: applicationId, + teamPk: teamId, + }); + if (!app) { + this.response.setStatus(404); + return { error: "NotFound", message: "Application not found" }; + } + + // 2. Update Stars + if (stars !== undefined) { + app.stars = stars; + } + + // 3. Prepend the new note with its author and timestamp. + if (notes) { + const notesError = validateFeedbackNotes(notes, this.response); + if (notesError) + return { + error: notesError.error, + message: notesError.message, + }; + + const authorizedUser: AuthorizedUser = + req.session.authorizedUser!; + const authorName = authorizedUser.name; + + app.notes = formatFeedbackNote(authorName, notes, app.notes); + } + + // 4. Save Changes + await app.save(); + return { success: true, stars: app.stars, notes: app.notes }; + } catch (err) { + this.response.setStatus(500); + return { error: "ServerError", message: "Failed to update stars" }; + } + } + + async getApplicantUrls(teamId: string, applicantId: string) { + try { + const teamApplication = await Application.exists({ + applicantId, + teamPk: teamId, + }); + if (!teamApplication) { + this.response.setStatus(404); + return { error: "NotFound", message: "Applicant not found" }; + } + + const applicant = await Applicant.findById(applicantId); + if (!applicant) { + this.response.setStatus(404); + return { error: "NotFound", message: "Applicant not found" }; + } + + const profile = applicant.profile || new Map(); + let resumeDownloadUrl: string | undefined; + + const resumeUrl = profile.get("resumeUrl"); + if (resumeUrl) { + try { + const expectedKey = resumeKeyFor(applicant._id); + if (isApplicantResumeKey(applicant._id, resumeUrl)) { + resumeDownloadUrl = + await createResumeDownloadUrl(expectedKey); + } + } catch (s3Error) { + console.error("Error signing S3 URL:", s3Error); + // Keep other profile links available if resume signing fails. + } + } + + return { + resumeUrl: resumeDownloadUrl, + }; + } catch (err) { + console.error("Error fetching applicant URLs:", err); + this.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to fetch applicant URLs", + }; + } + } + + async updateApplicationStage( + req: express.Request, + teamId: string, + applicationId: string, + body: ATSUpdateApplicationStageRequest, + ) { + try { + const { stage, hiredRole, hiredSubteamPk } = body; + + if (!req.session?.authorizedUser) { + this.response.setStatus(401); + return { + error: "Unauthorized", + message: "Authenticated user not found", + }; + } + + // 1. Scope the application to this team and check current hiring access. + const application = await Application.findOne({ + _id: applicationId, + teamPk: teamId, + }); + if (!application) { + this.response.setStatus(404); + return { error: "NotFound", message: "Application not found" }; + } + + const teamInfo = await this.getAuthorizedHiringTeam( + req.session.authorizedUser, + application.teamPk, + ); + if (!teamInfo) { + this.response.setStatus(403); + return { + error: "Forbidden", + message: "You do not have hiring access to this team", + }; + } + + const applicant = await Applicant.findById(application.applicantId); + if (!applicant) { + this.response.setStatus(404); + return { error: "NotFound", message: "Applicant not found" }; + } + + // 2. A repeated stage resumes pending work, or does nothing if completed. + const currentStage = application.stage; + if (currentStage === stage) { + if (!application.pendingStageAction) { + return { + message: "Stage already set", + application: { + id: application._id, + stage: application.stage, + }, + }; + } + await this.finishPendingStageAction( + req.session.authorizedUser, + teamInfo, + application, + applicant, + ); + return { + message: "Stage updated successfully", + application: { + id: application._id, + stage: application.stage, + hiredRole: application.hiredRole, + }, + }; + } + + if (application.pendingStageAction) { + throw new StageActionConflict( + "Finish the pending stage action before moving this application again", + ); + } + + // 3. Validate the transition and, for a hire, the destination role. + const transitionError = validateStageTransition( + currentStage, + body, + this.response, + ); + if (transitionError) return transitionError; + + if (stage === ApplicationStage.HIRED) { + if (!hiredRole || !hiredSubteamPk) { + this.response.setStatus(400); + return { + error: "BadRequest", + message: + "Hired Role and Subteam are required for Hired stage", + }; + } + if ( + !(await this.isValidHireDestination( + teamInfo, + hiredSubteamPk, + hiredRole, + )) + ) { + this.response.setStatus(400); + return { + error: "BadRequest", + message: + "Hired role and subteam must belong to an active team destination", + }; + } + } + + // 4. Save the stage, history entry, and follow-up payload atomically. + // Potential Hire has no external action to retry. + const changes: Record = { stage }; + if (stage === ApplicationStage.HIRED) + Object.assign(changes, { hiredRole, hiredSubteamPk }); + if (stage !== ApplicationStage.POTENTIAL_HIRE) { + changes.pendingStageAction = { + id: randomUUID(), + stage, + input: body, + state: "pending", + completedSteps: [], + inviteId: new Types.ObjectId().toString(), + sender: { + name: req.session.authorizedUser.name, + email: req.session.authorizedUser.email, + }, + }; + } + // Match the stage we read so concurrent reviewers cannot overwrite it. + const updated = await Application.findOneAndUpdate( + { + _id: applicationId, + teamPk: teamId, + stage: currentStage, + pendingStageAction: { $exists: false }, + }, + { + $set: changes, + $push: { + stageHistory: { + stage, + changedAt: new Date(), + changedBy: `${req.session.authorizedUser.name} (${req.session.authorizedUser.username})`, + }, + }, + }, + { new: true, runValidators: true }, + ); + if (!updated) + throw new StageActionConflict( + "Application changed; refresh and retry", + ); + // 5. Claim and perform the saved work after the database accepts it. + if (updated.pendingStageAction) { + await this.finishPendingStageAction( + req.session.authorizedUser, + teamInfo, + updated, + applicant, + ); + } + + return { + message: "Stage updated successfully", + application: { + id: updated._id, + stage: updated.stage, + hiredRole: updated.hiredRole, + }, + }; + } catch (err) { + if (err instanceof StageActionConflict) { + this.response.setStatus(409); + return { error: "StageActionConflict", message: err.message }; + } + console.error("Error updating application stage:", err); + this.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to update application stage", + }; + } + } + + async getApplicantApplications(teamId: string, applicantId: string) { + try { + const teamApplication = await Application.exists({ + applicantId, + teamPk: teamId, + }); + if (!teamApplication) { + this.response.setStatus(404); + return { error: "NotFound", message: "Applicant not found" }; + } + + 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; + + 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; + } catch (e) { + console.error( + `Failed to fetch subteam info for ${pref.subteamPk}`, + e, + ); + } + return { ...pref, subteamName }; + }), + ); + + 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.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to fetch applicant history", + }; + } + } + + async processHiredStageTransition( + authorizedUser: AuthorizedUser, + teamInfo: GetGroupInfoResponse, + application: IApplication, + applicant: IApplicant, + leaseToken?: string, + ): 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, + )) + ) { + throw new Error("Unauthorized hired-stage transition"); + } + + teamInfo = authorizedTeam; + const action = application.pendingStageAction; + let memberPk = action?.memberPk; + let recipientType = action?.recipientType; + if (!recipientType) { + try { + const user = await this.authentikClient.getUserInfoFromEmail( + applicant.email, + ); + memberPk = user.pk; + recipientType = "member"; + } catch (error) { + if ( + !(error instanceof AuthentikClientError) || + error.code !== AuthentikClientErrorType.USER_NOT_FOUND + ) + throw error; + recipientType = "external"; + } + if (leaseToken) { + await this.updateStageAction(application, leaseToken, { + recipientType, + ...(memberPk ? { memberPk } : {}), + }); + } + } + + if (recipientType === "external") { + if (action?.completedSteps.includes("notification")) return; + const request = { + session: { authorizedUser }, + bindle: { teamInfo, requestedPermissions: [] }, + }; + const invite = { + inviteeName: applicant.fullName, + inviteeEmail: applicant.email, + roleTitle: application.hiredRole!, + subteamPk: application.hiredSubteamPk!, + }; + if (action && leaseToken) { + await this.extendStageActionLease(application, leaseToken); + await this.orgController.createInviteForATS( + request, + invite, + action.inviteId, + ); + await this.completeStageActionStep( + application, + leaseToken, + "notification", + ); + } else { + await this.orgController.createInvite(request, invite); + } + return; + } + + // Membership can succeed before a later role write fails. On retry, inspect + // the directory and finish the role instead of blindly adding the member again. + if (!action?.completedSteps.includes("membership")) { + if (leaseToken) + await this.extendStageActionLease(application, leaseToken); + let alreadyAdded = false; + if (action?.membershipStarted) { + const member = await this.authentikClient.getUserInfo( + +memberPk!, + ); + alreadyAdded = member.groups.includes( + application.hiredSubteamPk!, + ); + const roles = member.attributes.roles ?? {}; + if ( + alreadyAdded && + roles[application.hiredSubteamPk!] !== application.hiredRole + ) { + await this.authentikClient.updateUserAttributes( + +memberPk!, + { + roles: { + ...roles, + [application.hiredSubteamPk!]: + application.hiredRole!, + }, + }, + ); + } + } + if (!alreadyAdded) { + if (leaseToken) + await this.updateStageAction(application, leaseToken, { + membershipStarted: true, + }); + await this.orgController.addTeamMember( + application.hiredSubteamPk!, + { userPk: +memberPk!, roleTitle: application.hiredRole! }, + ); + } + if (leaseToken) + await this.completeStageActionStep( + application, + leaseToken, + "membership", + ); + } + if (action?.completedSteps.includes("notification")) return; + if (leaseToken) + await this.extendStageActionLease(application, leaseToken); + // Record membership before email, so an email retry does not repeat the add. + const sender = action?.sender ?? authorizedUser; + await this.emailClient.send({ + to: applicant.email, + cc: [sender.email], + replyTo: [sender.email], + subject: `Congrats! You're accepted to ${teamInfo.attributes.friendlyName}`, + templateName: "RecruitExistingMemberAcceptance", + templateVars: { + inviteeName: applicant.fullName, + invitorName: sender.name, + teamName: teamInfo.attributes.friendlyName, + roleTitle: application.hiredRole!, + }, + }); + if (leaseToken) + await this.completeStageActionStep( + application, + leaseToken, + "notification", + ); + } + + 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 (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; + } + + 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; + + const config = await SubteamConfig.findOne({ subteamPk }).lean().exec(); + return !!config && config.roles.includes(role); + } + // A lease gives one request ownership of this action. Renew it during slow + // external calls; after a stopped worker's lease expires, a retry can claim it. + private static readonly STAGE_ACTION_LEASE_MS = 5 * 60 * 1000; + + private stageActionFilter(application: IApplication, leaseToken: string) { + return { + _id: application._id, + teamPk: application.teamPk, + "pendingStageAction.id": application.pendingStageAction!.id, + "pendingStageAction.leaseToken": leaseToken, + }; + } + + private async extendStageActionLease( + application: IApplication, + leaseToken: string, + ) { + const result = await Application.updateOne( + this.stageActionFilter(application, leaseToken), + { + $set: { + "pendingStageAction.leaseUntil": new Date( + Date.now() + + ATSApplicationService.STAGE_ACTION_LEASE_MS, + ), + }, + }, + ); + if (result.matchedCount !== 1) + throw new StageActionConflict( + "Stage action lease was lost; refresh and retry", + ); + } + + private async updateStageAction( + application: IApplication, + leaseToken: string, + values: Partial, + ) { + const result = await Application.updateOne( + this.stageActionFilter(application, leaseToken), + { + $set: Object.fromEntries( + Object.entries(values).map(([key, value]) => [ + `pendingStageAction.${key}`, + value, + ]), + ), + }, + ); + if (result.matchedCount !== 1) + throw new StageActionConflict( + "Stage action lease was lost; refresh and retry", + ); + Object.assign(application.pendingStageAction!, values); + } + + private async completeStageActionStep( + application: IApplication, + leaseToken: string, + step: string, + ) { + const result = await Application.updateOne( + this.stageActionFilter(application, leaseToken), + { + $addToSet: { "pendingStageAction.completedSteps": step }, + }, + ); + if (result.matchedCount !== 1) + throw new StageActionConflict( + "Stage action lease was lost; refresh and retry", + ); + if (!application.pendingStageAction!.completedSteps.includes(step)) { + application.pendingStageAction!.completedSteps.push(step); + } + } + + private async finishPendingStageAction( + authorizedUser: AuthorizedUser, + teamInfo: GetGroupInfoResponse, + application: IApplication, + applicant: IApplicant, + ) { + // Claim -> run unfinished steps -> clear on success. On failure, release + // the claim but retain the payload and completed steps for the next retry. + const pending = application.pendingStageAction!; + if ( + pending.stage !== application.stage || + pending.input.stage !== application.stage + ) { + throw new StageActionConflict( + "Pending action does not match the application stage", + ); + } + const leaseToken = randomUUID(); + const claimed = await Application.findOneAndUpdate( + { + _id: application._id, + teamPk: application.teamPk, + "pendingStageAction.id": pending.id, + $or: [ + { "pendingStageAction.state": "pending" }, + { + "pendingStageAction.state": "processing", + "pendingStageAction.leaseUntil": { $lte: new Date() }, + }, + ], + }, + { + $set: { + "pendingStageAction.state": "processing", + "pendingStageAction.leaseToken": leaseToken, + "pendingStageAction.leaseUntil": new Date( + Date.now() + + ATSApplicationService.STAGE_ACTION_LEASE_MS, + ), + }, + }, + { new: true }, + ); + if (!claimed) + throw new StageActionConflict( + "This stage action is already running; retry shortly", + ); + + let heartbeat: Promise | undefined; + let leaseFailure: unknown; + const timer = setInterval(() => { + if (heartbeat) return; + heartbeat = this.extendStageActionLease(claimed, leaseToken) + .catch((error: unknown) => { + leaseFailure = error; + }) + .finally(() => { + heartbeat = undefined; + }); + }, 30_000); + timer.unref(); + const stopHeartbeat = async () => { + clearInterval(timer); + await heartbeat; + }; + try { + await this.performStageActions( + authorizedUser, + teamInfo, + claimed, + applicant, + leaseToken, + ); + await stopHeartbeat(); + if (leaseFailure) throw leaseFailure; + const result = await Application.updateOne( + this.stageActionFilter(claimed, leaseToken), + { $unset: { pendingStageAction: 1 } }, + ); + if (result.matchedCount !== 1) + throw new StageActionConflict( + "Stage action lease was lost; refresh and retry", + ); + } catch (error) { + await stopHeartbeat(); + await Application.updateOne( + this.stageActionFilter(claimed, leaseToken), + { + $set: { "pendingStageAction.state": "pending" }, + $unset: { + "pendingStageAction.leaseToken": 1, + "pendingStageAction.leaseUntil": 1, + }, + }, + ); + throw error; + } finally { + await stopHeartbeat(); + } + } + + private async performStageActions( + authorizedUser: AuthorizedUser, + teamInfo: GetGroupInfoResponse, + application: IApplication, + applicant: IApplicant, + leaseToken: string, + ) { + const action = application.pendingStageAction!; + if (action.completedSteps.includes("notification")) return; + const { stage, interviewLink, interviewGuidelines } = action.input; + const sender = action.sender; + if (stage === ApplicationStage.HIRED) { + await this.processHiredStageTransition( + authorizedUser, + teamInfo, + application, + applicant, + leaseToken, + ); + return; + } + await this.extendStageActionLease(application, leaseToken); + if (stage === ApplicationStage.INTERVIEW) { + await this.emailClient.send({ + to: applicant.email, + cc: [sender.email], + replyTo: [sender.email], + subject: `Next Steps: ${teamInfo.attributes.friendlyName} Interview`, + templateName: "RecruitInterviewRequest", + templateVars: { + applicantName: applicant.fullName, + interviewerName: sender.name, + teamName: teamInfo.attributes.friendlyName, + interviewLink, + interviewGuidelines, + }, + }); + } else if (stage === ApplicationStage.REJECTED) { + await this.emailClient.send({ + to: applicant.email, + cc: [sender.email], + replyTo: [sender.email], + subject: `Update on ${teamInfo.attributes.friendlyName}`, + templateName: "RecruitApplicationDeclined", + templateVars: { + applicantName: applicant.fullName, + teamName: teamInfo.attributes.friendlyName, + }, + }); + } + // SMTP has no atomic commit with MongoDB. A crash after delivery but before + // this checkpoint can still cause a duplicate email on retry. + await this.completeStageActionStep( + application, + leaseToken, + "notification", + ); + } +} + +class StageActionConflict extends Error {} diff --git a/peopleportal/server/src/services/ats/ATSHelpers.ts b/peopleportal/server/src/services/ats/ATSHelpers.ts new file mode 100644 index 0000000..ef8df5c --- /dev/null +++ b/peopleportal/server/src/services/ats/ATSHelpers.ts @@ -0,0 +1,430 @@ +/** + People Portal Server + Copyright (C) 2026 Atheesh Thirumalairajan + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + 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 . +*/ + +import type { Controller } from "tsoa"; +import type { GetGroupInfoResponse } from "../../clients/AuthentikClient/models"; +import type { ApplicantProfile } from "../../models/Applicant"; +import { ApplicationStage } from "../../models/Application"; +import path from "path"; +import MarkdownIt from "markdown-it"; +import { GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { s3Client, BUCKET_NAME } from "../../clients/AWSClient/S3Client"; +import { + validateS3FileSignature, + FILE_SIGNATURES, +} from "../../utils/s3-validation"; + +/* Shared request/response types, validation, and formatting. Most helpers do not + * access services; resume signing and PDF verification are the exceptions. */ +export type ATSResponse = Pick; + +export interface ATSSubteamConfigRequest { + isRecruiting: boolean; + roles: string[]; + roleSpecificQuestions: { [role: string]: string[] }; +} + +// Team-level application request with ordered role preferences +export interface ATSTeamApplicationRequest { + teamPk: string; + rolePreferences: { role: string; subteamPk: string }[]; // Ordered array + profile: ApplicantProfile; + responses: Record; +} + +export interface ATSUpdateApplicationStageRequest { + stage: ApplicationStage; + interviewLink?: string; + interviewGuidelines?: string; + hiredRole?: string; + hiredSubteamPk?: string; +} + +export interface ATSKanbanApplicationCard { + id: string; + applicantId: string; + name: 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 + */ + stars: number; +} + +export interface ATSApplicationDetails extends ATSKanbanApplicationCard { + email: string; + profile: Record; + responses: Record; + stageHistory?: { + stage: ApplicationStage; + changedAt: Date; + changedBy?: string; + }[]; + hiredRole?: string; + appDevInternalPk?: number; + notes?: string; +} + +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; + +// One PDF key per applicant. The profile stores this key, not a permanent URL. +export function resumeKeyFor(applicantId: unknown): string { + return `resumes/${applicantId}.pdf`; +} + +export function isApplicantResumeKey( + applicantId: unknown, + key: string, +): boolean { + return ( + path.posix.normalize(key) === resumeKeyFor(applicantId) && + !key.includes("..") + ); +} + +// This authorizes a temporary storage request; actual download still needs a +// readable object and storage permissions. It does not retrieve the PDF here. +export function createResumeDownloadUrl(key: string): Promise { + return getSignedUrl( + s3Client, + new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }), + { expiresIn: 900 }, + ); +} + +// Verify both ownership and the stored file header; a .pdf filename alone is +// insufficient. Storage failures also fail validation rather than skipping it. +export async function verifyApplicantResume( + applicantId: unknown, + key: string, + response: ATSResponse, +) { + if (!isApplicantResumeKey(applicantId, key)) { + response.setStatus(400); + return { error: "InvalidResumeKey", message: "Invalid resume upload." }; + } + + if (!(await validateS3FileSignature(key, [FILE_SIGNATURES.PDF]))) { + response.setStatus(400); + return { + error: "InvalidFileSignature", + message: "The uploaded file is not a valid PDF.", + }; + } +} + +export function formatTeamSummary(team: GetGroupInfoResponse) { + return { + name: team.name, + friendlyName: team.attributes.friendlyName, + description: team.attributes.description, + seasonText: `${team.attributes.seasonType} ${team.attributes.seasonYear}`, + pk: team.pk, + }; +} + +export function validateResumeUpload( + fileName: string, + contentType: string, + response: ATSResponse, +) { + // Validate PDF only + const fileExtension = fileName.split(".").pop()?.toLowerCase(); + if ( + !fileExtension || + !ALLOWED_RESUME_EXTENSIONS.includes(fileExtension as any) + ) { + response.setStatus(400); + return { + error: "InvalidFileType", + message: `Only PDF resumes are accepted. Allowed types: ${ALLOWED_RESUME_EXTENSIONS.join(", ")}`, + }; + } + + if (!ALLOWED_CONTENT_TYPES.includes(contentType as any)) { + response.setStatus(400); + return { + error: "InvalidContentType", + message: "Invalid content type. Only PDF is accepted.", + }; + } + + // Prevent path traversal + if ( + fileName.includes("..") || + fileName.includes("/") || + fileName.includes("\\") + ) { + response.setStatus(400); + return { error: "InvalidFileName", message: "Invalid file name" }; + } +} + +export function validateApplicantProfile( + profile: ApplicantProfile, + response: ATSResponse, +) { + if (!profile.resumeUrl || profile.resumeUrl.trim() === "") { + response.setStatus(400); + return { + error: "MissingRequiredField", + message: "Resume URL is required", + }; + } + + if (!profile.resumeUrl.toLowerCase().endsWith(".pdf")) { + response.setStatus(400); + return { + error: "InvalidFileType", + message: "Only PDF resumes are accepted", + }; + } + + if (!profile.whyAppDev || profile.whyAppDev.trim() === "") { + response.setStatus(400); + return { + error: "MissingRequiredField", + message: "Please provide 'Why App Dev'", + }; + } + + if (!profile.instagramFollow || profile.instagramFollow.trim() === "") { + response.setStatus(400); + return { + error: "MissingRequiredField", + message: "Please confirm Instagram follow", + }; + } + + if (profile.linkedinUrl && profile.linkedinUrl.trim() !== "") { + try { + new URL(profile.linkedinUrl); + } catch { + response.setStatus(400); + return { + error: "InvalidURL", + message: "Please provide a valid LinkedIn URL", + }; + } + } + + if (profile.githubUrl && profile.githubUrl.trim() !== "") { + try { + new URL(profile.githubUrl); + } catch { + response.setStatus(400); + return { + error: "InvalidURL", + message: "Please provide a valid GitHub URL", + }; + } + } + + const whyAppDevWords = profile.whyAppDev + .trim() + .split(/\s+/) + .filter(Boolean).length; + if (whyAppDevWords > MAX_WHY_APPDEV_WORDS) { + response.setStatus(400); + return { + error: "WordCountExceeded", + message: `'Why App Dev' must be ${MAX_WHY_APPDEV_WORDS} words or less`, + }; + } +} + +export function validateApplicationResponses( + responses: Record, + allQuestions: Set, + response: ATSResponse, +) { + for (const question of Array.from(allQuestions)) { + if (!responses[question] || responses[question].trim() === "") { + response.setStatus(400); + return { + error: "MissingResponse", + message: `Please answer: ${question}`, + }; + } + + if (responses[question].length > MAX_RESPONSE_LENGTH) { + response.setStatus(400); + return { + error: "ResponseTooLong", + message: `Response too long for: ${question}. Maximum ${MAX_RESPONSE_LENGTH} characters.`, + }; + } + + const wordCount = responses[question] + .trim() + .split(/\s+/) + .filter(Boolean).length; + if (wordCount < MIN_RESPONSE_WORDS) { + response.setStatus(400); + return { + error: "ResponseTooShort", + message: `Response too short for: ${question}. Please provide at least ${MIN_RESPONSE_WORDS} words.`, + }; + } + } +} + +export function validateFeedback( + stars: number | undefined, + notes: string | undefined, + response: ATSResponse, +) { + if (stars === undefined && !notes) { + response.setStatus(400); + return { + error: "BadRequest", + message: "Stars or notes must be provided", + }; + } + + if ( + stars !== undefined && + (typeof stars !== "number" || stars < 1 || stars > 5) + ) { + response.setStatus(400); + return { + error: "BadRequest", + message: "Stars must be a number between 1 and 5", + }; + } +} + +export function validateFeedbackNotes(notes: string, response: ATSResponse) { + // Validate Markdown (Empty Check) + if (!notes.trim()) { + response.setStatus(400); + return { + error: "BadRequest", + message: "Notes cannot be empty", + }; + } + + // Validate Markdown Structure (Unclosed Fences) + const md = new MarkdownIt({ html: false }); + const sentinel = "___MARKDOWN_SENTINEL___"; + 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), + ); + + if (isTrapped) { + response.setStatus(400); + return { + error: "BadRequest", + message: "Invalid Markdown Structure", + }; + } +} + +export function formatFeedbackNote( + authorName: string, + notes: string, + previousNotes: string | undefined, +): string { + // 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, + }).format(now); + + const noteHeader = `###### ${authorName} \n*${formattedDate}*`; + const existingNotes = previousNotes || ""; + const separator = existingNotes ? "\n\n---\n\n" : ""; + return `${noteHeader}\n\n${notes}${separator}${existingNotes}`; +} + +export function validateStageTransition( + currentStage: ApplicationStage, + input: ATSUpdateApplicationStageRequest, + response: ATSResponse, +) { + const { stage, interviewLink, interviewGuidelines } = input; + 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.HIRED]: [], + [ApplicationStage.REJECTED]: [], + }; + + const allowed = VALID_TRANSITIONS[currentStage] || []; + if (!allowed.includes(stage)) { + response.setStatus(400); + return { + error: "BadRequest", + message: `Invalid transition from ${currentStage} to ${stage}`, + }; + } + + if (stage === ApplicationStage.INTERVIEW) { + if (!interviewLink) { + response.setStatus(400); + return { + error: "BadRequest", + message: "Interview Link is required for Interview stage", + }; + } + + if ( + !interviewGuidelines || + interviewGuidelines.length < 50 || + interviewGuidelines.length > 500 + ) { + response.setStatus(400); + return { + error: "BadRequest", + message: + "Interview Guidelines are required (50-500 characters)", + }; + } + } +} diff --git a/peopleportal/server/src/services/ats/ATSRecruitmentService.ts b/peopleportal/server/src/services/ats/ATSRecruitmentService.ts new file mode 100644 index 0000000..f47ebec --- /dev/null +++ b/peopleportal/server/src/services/ats/ATSRecruitmentService.ts @@ -0,0 +1,456 @@ +/** + People Portal Server + Copyright (C) 2026 Atheesh Thirumalairajan + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + 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 . +*/ + +import { AuthentikClient } from "../../clients/AuthentikClient"; +import { + AuthentikClientError, + AuthentikClientErrorType, +} from "../../clients/AuthentikClient/models"; +import { RedisClient } from "../../clients/RedisClient"; +import { SubteamConfig } from "../../models/SubteamConfig"; +import { TeamRecruitingStatus } from "../../models/TeamRecruitingStatus"; +import { Application, ApplicationStage } from "../../models/Application"; +import { + CacheEnvelope, + isCacheEnvelope, + isCacheStale, + wrapCacheValue, +} from "../../utils/cache-envelope"; +import { + ATSResponse, + ATSSubteamConfigRequest, + formatTeamSummary, +} from "./ATSHelpers"; + +const OPEN_TEAMS_CACHE_KEY = "ats:openteams"; +/* Past this the entry is refreshed, but it is still served while that happens, + so no applicant waits for the rebuild. */ +const OPEN_TEAMS_CACHE_SOFT_TTL_SECONDS = 300; +/* The Redis expiry, and the absolute ceiling on staleness if every background + refresh fails. Correctness comes from invalidating on write; this only bounds + how long a *missed* invalidation can serve a stale roster. */ +const OPEN_TEAMS_CACHE_HARD_TTL_SECONDS = 3600; + +/* Recruitment configuration and discovery. MongoDB owns the roles and open/closed + * settings; Authentik supplies team metadata; Redis caches the public listings. */ +export class ATSRecruitmentService { + constructor( + private readonly response: ATSResponse, + private readonly authentikClient: AuthentikClient, + ) {} + + private static openTeamsRefreshInFlight = false; + + async getSubTeamATSConfig(teamId: string) { + const config = (await SubteamConfig.findOne({ subteamPk: teamId }) + .lean() + .exec()) as any; + if (!config) { + this.response.setStatus(404); + return { + error: "Not Found", + message: `Subteam config with ID ${teamId} not found`, + }; + } + + // Fetch the subteam and parent together when the directory supports it. + const authentikSubteamInfo = await this.authentikClient.getGroupInfo( + config.subteamPk, + { includeParentInfo: true }, + ); + + let authentikParentInfo = authentikSubteamInfo.parentInfo; + if (!authentikParentInfo) { + /* Fallback for Legacy Implementation (Authentik <2025.12) */ + authentikParentInfo = await this.authentikClient.getGroupInfo( + authentikSubteamInfo.parentPk, + ); + } + + return { + ...config, + subteamInfo: formatTeamSummary(authentikSubteamInfo), + + parentInfo: formatTeamSummary(authentikParentInfo), + }; + } + + async createOrUpdateSubteamConfig( + teamId: string, + body: ATSSubteamConfigRequest, + ) { + const { isRecruiting, roles, roleSpecificQuestions } = body; + + if (roles.length === 0) { + this.response.setStatus(400); + return { + error: "Bad Request", + message: "Roles are required", + }; + } + + // Use findOneAndUpdate with upsert to create or update + const updatedConfig = await SubteamConfig.findOneAndUpdate( + { subteamPk: teamId }, // filter + { + subteamPk: teamId, + isRecruiting, + roles, + 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(); + + // Keep the parent team's public recruitment status in sync with this subteam. + const subteamInfo = await this.authentikClient.getGroupInfo( + updatedConfig.subteamPk, + ); + if (updatedConfig.isRecruiting) { + await this.addSubteamToRecruiting( + subteamInfo.parentPk, + updatedConfig.subteamPk, + ); + } else { + await this.removeSubteamFromRecruiting( + subteamInfo.parentPk, + updatedConfig.subteamPk, + ); + } + + // Convert Map back to object for JSON response + return updatedConfig; + } + + async getAllRecruitingTeams() { + const cached = + await RedisClient.get>(OPEN_TEAMS_CACHE_KEY); + + if (isCacheEnvelope(cached)) { + if (isCacheStale(cached, OPEN_TEAMS_CACHE_SOFT_TTL_SECONDS)) + this.refreshOpenTeamsInBackground(); + + return cached.value; + } + + /* A cold or invalidated cache is the only path that waits on a build. */ + return this.buildAndCacheOpenTeams(); + } + + private refreshOpenTeamsInBackground() { + if (ATSRecruitmentService.openTeamsRefreshInFlight) return; + ATSRecruitmentService.openTeamsRefreshInFlight = true; + + /* 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(() => { + ATSRecruitmentService.openTeamsRefreshInFlight = false; + }); + } + + private async buildAndCacheOpenTeams() { + const validRecruitingTeams = await this.buildOpenTeams(); + await RedisClient.set( + OPEN_TEAMS_CACHE_KEY, + wrapCacheValue(validRecruitingTeams), + OPEN_TEAMS_CACHE_HARD_TTL_SECONDS, + ); + + return validRecruitingTeams; + } + + private async buildOpenTeams() { + /* Fetch All teams that are recruiting currently */ + 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 + * 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 configMap = new Map(allConfigs.map((c: any) => [c.subteamPk, c])); + + /* + * The remaining O(N) was N sequential Authentik round trips, one per + * recruiting team, each of which then fetched every subteam again just + * to populate members this endpoint never reads. At 13 teams that was + * roughly 65 serial requests. Both are gone: members are not requested, + * 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, + }, + ); + + /* Populate Team and Subteam Info */ + const validRecruitingTeams: any[] = []; + const missingTeamPks: string[] = []; + + for (const team of recruitingTeams) { + const authentikTeamInfo = teamInfoByPk.get(team.teamPk); + if (!authentikTeamInfo) { + missingTeamPks.push(team.teamPk); + continue; + } + + const recruitingSubteams = new Set(team.recruitingSubteamPks); + const parentInfoObj = formatTeamSummary(authentikTeamInfo); + + team.teamInfo = parentInfoObj; + team.subteamInfo = {}; + for (const sub of authentikTeamInfo.subteams) { + if (recruitingSubteams.has(sub.pk)) { + const config = configMap.get(sub.pk); + + const subteamInfoObj = formatTeamSummary(sub); + + team.subteamInfo[sub.pk] = { + ...subteamInfoObj, + recruitmentInfo: config + ? { + ...config, + subteamInfo: subteamInfoObj, + parentInfo: parentInfoObj, + } + : null, + }; + } + } + + validRecruitingTeams.push(team); + } + + // A missing directory result may be temporary. Hide it from this response, + // but retain its MongoDB configuration so it can return when lookup recovers. + if (missingTeamPks.length > 0) { + console.warn( + `Recruiting teams missing from the directory response: ${missingTeamPks.join(", ")}. Records preserved for review.`, + ); + } + + return validRecruitingTeams; + } + + async getTeamDetails(teamId: string) { + try { + // Get team info from Authentik + const teamInfo = await this.authentikClient.getGroupInfo(teamId); + + // Get team recruiting status + const teamStatus = await TeamRecruitingStatus.findOne({ + teamPk: teamId, + }).exec(); + + if (!teamStatus || !teamStatus.isRecruiting) { + this.response.setStatus(403); + return { + error: "NotRecruiting", + 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); + + return { + subteamPk: subteamPk, + friendlyName: + subteamInfo.attributes.friendlyName || + subteamInfo.name, + description: subteamInfo.attributes.description, + roles: config?.roles || [], + roleSpecificQuestions: + config?.roleSpecificQuestions || {}, + }; + }), + ); + + return { + teamPk: teamId, + teamInfo: formatTeamSummary(teamInfo), + recruitingSubteams: recruitingSubteams, + }; + } catch (err) { + if ( + err instanceof AuthentikClientError && + err.code === AuthentikClientErrorType.GROUP_NOT_FOUND + ) { + this.response.setStatus(404); + return { + error: "TeamNotFound", + message: "The requested team could not be found.", + }; + } + + this.response.setStatus(500); + return { + error: "ServerError", + message: "Failed to fetch team details", + }; + } + } + + async addSubteamToRecruiting(teamId: string, subteamId: string) { + const updatedTeam = await TeamRecruitingStatus.findOneAndUpdate( + { teamPk: teamId }, + { + $addToSet: { recruitingSubteamPks: subteamId }, + teamPk: teamId, + }, + { upsert: true, new: true }, + ) + .lean() + .exec(); + + // Set isRecruiting based on array length + if ( + updatedTeam.recruitingSubteamPks.length > 0 && + !updatedTeam.isRecruiting + ) { + await TeamRecruitingStatus.updateOne( + { teamPk: teamId }, + { isRecruiting: true }, + ); + updatedTeam.isRecruiting = true; + } + + /* After every write, never before: invalidating first leaves a window in + which a concurrent read repopulates the cache from pre-update rows. */ + await RedisClient.delete(OPEN_TEAMS_CACHE_KEY); + return updatedTeam; + } + + async removeSubteamFromRecruiting(teamId: string, subteamId: string) { + const updatedTeam = await TeamRecruitingStatus.findOneAndUpdate( + { teamPk: teamId }, + { $pull: { recruitingSubteamPks: subteamId } }, + { new: true }, + ) + .lean() + .exec(); + + if (!updatedTeam) { + this.response.setStatus(404); + return { error: "Not Found", message: `Team ${teamId} not found` }; + } + + // Set isRecruiting to false if no subteams left + if ( + updatedTeam.recruitingSubteamPks.length === 0 && + updatedTeam.isRecruiting + ) { + await TeamRecruitingStatus.updateOne( + { teamPk: teamId }, + { isRecruiting: false }, + ); + updatedTeam.isRecruiting = false; + } + + await RedisClient.delete(OPEN_TEAMS_CACHE_KEY); + return updatedTeam; + } + + async getTeamRecruitmentStats() { + const [teamsRes, grouped] = await Promise.all([ + this.authentikClient.getGroupsList({ limit: 500 }), + Application.aggregate([ + { + $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; + }[]) { + const stages = byTeam.get(row._id.teamPk) ?? {}; + stages[row._id.stage] = row.count; + byTeam.set(row._id.teamPk, stages); + } + + const stageKeys = Object.values(ApplicationStage); + + const teams = teamsRes.teams.map((team) => { + const stages = byTeam.get(team.pk) ?? {}; + const counts: Record = {}; + let total = 0; + + for (const stage of stageKeys) { + const n = stages[stage] ?? 0; + counts[stage] = n; + total += n; + } + + return { + teamPk: team.pk, + name: team.friendlyName || team.name, + counts, + total, + }; + }); + + /* Busiest first: an executive scanning this wants the teams with + something happening, not alphabetical order. */ + teams.sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); + + return { stages: stageKeys, teams }; + } +} diff --git a/peopleportal/server/test/fixtures/ats-behavior.sha256.json b/peopleportal/server/test/fixtures/ats-behavior.sha256.json new file mode 100644 index 0000000..c6e3fcc --- /dev/null +++ b/peopleportal/server/test/fixtures/ats-behavior.sha256.json @@ -0,0 +1,422 @@ +{ + "upload: valid PDF": { + "trace": "d07b3550174210cc16660e91497e126dde9a978a69272822b2a5e96fa1c7b50b", + "response": "96b97ed2780ce0adfb2fbb16ef18e915c01c78d7ac1959564c5aa667d95e0649" + }, + "upload: missing session": { + "trace": "e6f513ee400c2472bfbd81967676e133537ea5e145020648d4af6e09a3685646", + "response": "dab88098c23f68d9f49a351dcaf2948f36f79e064979249a2ec098f38b7452f8" + }, + "upload: rejects resume.txt": { + "trace": "2eb0adc9ccfdf73e4b63dda414045a2c033a61e79609b6139a343155c461db5a", + "response": "73ba07d4361688070d394b5d49eecbe9291b56bb90523457b5f09ee687eea64f" + }, + "upload: rejects resume": { + "trace": "2eb0adc9ccfdf73e4b63dda414045a2c033a61e79609b6139a343155c461db5a", + "response": "73ba07d4361688070d394b5d49eecbe9291b56bb90523457b5f09ee687eea64f" + }, + "upload: rejects ../resume.pdf": { + "trace": "79923b48be3d5952a7251da5a5d8af9173d9fefb755ecccf6a9c07cae4b49f03", + "response": "e8d64aeb54ec5369ec649518f643d7c17278920831f7b58199de340536e93b35" + }, + "upload: rejects a/resume.pdf": { + "trace": "79923b48be3d5952a7251da5a5d8af9173d9fefb755ecccf6a9c07cae4b49f03", + "response": "e8d64aeb54ec5369ec649518f643d7c17278920831f7b58199de340536e93b35" + }, + "upload: rejects a\\resume.pdf": { + "trace": "79923b48be3d5952a7251da5a5d8af9173d9fefb755ecccf6a9c07cae4b49f03", + "response": "e8d64aeb54ec5369ec649518f643d7c17278920831f7b58199de340536e93b35" + }, + "upload: invalid MIME": { + "trace": "5aa69a911f45f73dedf86b692c3ebaa9eaf820fc39a3b7b61cfcb39bec22839a", + "response": "48e48ac4c57bec3f56da188b84ae1f3f3e5fcc8448ad902405cea06d9553ea0f" + }, + "upload: missing applicant": { + "trace": "31e7b0f9296ea7ee608039e99620f9cfcc9a88044f9bdd9429e080c7e70155ef", + "response": "3344539987515262d3d8e2c7a86a476bb68c620f977b3af137ff463186ad00f3" + }, + "upload: signing failure propagates": { + "trace": "9ed153eac547d194b3bfc1ddd53c71d2c3b0067e8d844c61b6d573c7cf2ed4c9", + "response": "dfed4d19a11d529a92356d3378b4902e14871bd5599ae13e587180d5f2188c61" + }, + "download: resumes/65a000000000000000000001.pdf": { + "trace": "7beb2b86031a3b291fb78436cb572421fae8ebc59fdd16c5d806f63720eb80b6", + "response": "b671f38e1db41cead7eee60c112c9b61296544b7ce63c8ae0bfde80c3ac39f8a" + }, + "download: resumes/./65a000000000000000000001.pdf": { + "trace": "7beb2b86031a3b291fb78436cb572421fae8ebc59fdd16c5d806f63720eb80b6", + "response": "b671f38e1db41cead7eee60c112c9b61296544b7ce63c8ae0bfde80c3ac39f8a" + }, + "download: resumes/folder/../65a000000000000000000001.pdf": { + "trace": "7beb2b86031a3b291fb78436cb572421fae8ebc59fdd16c5d806f63720eb80b6", + "response": "b671f38e1db41cead7eee60c112c9b61296544b7ce63c8ae0bfde80c3ac39f8a" + }, + "download: another applicant": { + "trace": "3bfe28dc6de7565d1d2503456939985041bc5c41e13c7bdaab66c307a1bc5454", + "response": "fff792d918111e3434a09bf66728eb2da2d0315895f6bc06e9e645ad03836605" + }, + "download: no guest": { + "trace": "e6f513ee400c2472bfbd81967676e133537ea5e145020648d4af6e09a3685646", + "response": "dab88098c23f68d9f49a351dcaf2948f36f79e064979249a2ec098f38b7452f8" + }, + "download: signing failure": { + "trace": "2afcc699b56aedd159daf410cfa726d863a54fdb9d7d0ec7ee4e7b14e9e7a845", + "response": "645196276e817e25da446bdd6f017e1e1ce38b8462639b02484939e30a8da533" + }, + "submission: success": { + "trace": "574130bc551decb5ea60cf2b80df4e71c98b4be46dde95d167b7d181fb44b0c7", + "response": "0f5a085548ac950a8aed975ae46de78742fa8b95df856f8a9e02ab2acb102b77" + }, + "submission: internal member": { + "trace": "5be3b361f76d8a45521d211c2b198a51f119b9752b8a00b67ff0659fcb1d8fe9", + "response": "0f5a085548ac950a8aed975ae46de78742fa8b95df856f8a9e02ab2acb102b77" + }, + "submission: incomplete request": { + "trace": "e23424d4e35b321b081fc98b99a09ed3c969aadc090b92f252d977c3fe23c11e", + "response": "9e6d97fd78ffa0cada91ea0e7e4dd655e1c770e1a153e0a03edc1525581c5e44" + }, + "submission: no guest": { + "trace": "89ce731c0575305f74d4f0384601052efa31c03751a57a067e9e64ccf2f4e306", + "response": "1a4a24b970292880a3a6f93fea27499ea049f5e64d0a38347b0847650804a139" + }, + "submission: recruitment closed": { + "trace": "fb7a37c015abf1851f9f0b30b5cefb58acc72d980e3c9ac7c7007aefec1e5bfc", + "response": "18d76117a154526eeb10daae30de01780c3634f573e4bbe08a4effb79d784d5f" + }, + "submission: invalid role": { + "trace": "8b163edccc784c26d99563e55efb98ff489afedb4c2ae34aa4534df7c8cce8fd", + "response": "fe0b5875e98bad6347901b2269101f37fc4df53505d4a4c6fa342c3c2806190b" + }, + "submission: invalid resumeUrl MissingRequiredField": { + "trace": "5b05af59be06c47ba901f9ca8162d055ac838b34d54a528b946e8b425ad4cf32", + "response": "8f54f451bde06210749dd65577d245fdd218c7d21122db8bcb07c905fe532f9e" + }, + "submission: invalid resumeUrl InvalidFileType": { + "trace": "55a3fb27f4da35faeb1a9d100e4b8e9a3cc99a4dad83da198a5d0c0596575b32", + "response": "0e52b8f4f712f5909bc073bcd9729c5855219dfc5d9db8d940a82b756776c709" + }, + "submission: invalid whyAppDev MissingRequiredField": { + "trace": "8a1e865a0821788719b3dbed7a820fd2d827e0feb9d64d0ed403056065705c01", + "response": "ba64918d297859a68359fbe0406938fdacb284c0ad8cf862b4adaab22c4799f8" + }, + "submission: invalid whyAppDev WordCountExceeded": { + "trace": "dd533898e3db02319e708ad2037800255e0108ddd6afd256da13ff1647eb9dc4", + "response": "c428d5c24d9190f25295604930df52a06e9b97b6f56e7865fe4f8fb20c5fad73" + }, + "submission: invalid instagramFollow MissingRequiredField": { + "trace": "83873f42016378eecf4c4d134bdff6c9286f95dddf4846213f3e167743517891", + "response": "9f0cf2a25cb8e4a0585f9e15e0e12200ecd0b9af25ce77e1987b9dc039b433db" + }, + "submission: invalid linkedinUrl InvalidURL": { + "trace": "99c96540290608ed2a2c2f7530aa3622e1c834338654b7c45c0f5c54701023fd", + "response": "6b9adfccd1d5cb25b23356e330165fa296ed433336a22d5676767bce5e55e269" + }, + "submission: invalid githubUrl InvalidURL": { + "trace": "0252f5ffebcb1b6aa0911198c0aca5b45a8cd819a87a94bcd713d05f954ab6f7", + "response": "7dc8fbc4170f65114f120b4daacd9282b0a399542d5bfb1e7c2c0d5cba75244f" + }, + "submission: MissingResponse": { + "trace": "8325ec2f5e6e9b9818391c4b9394cb6eee0c4dd5e7c5bb092d7196acf352d6cd", + "response": "58f861c0c3aa331b4a02b492418952a121b33306e3b96f338267244ece8f96fc" + }, + "submission: ResponseTooShort": { + "trace": "f2d7d15d426d44d20558c2ab03de742aa7f283526c9c437e0393dd42e675d8be", + "response": "74abd5d6fbf2315cd9aa4671f8017711d0985a7a0e44825e9fc979c8b8de696a" + }, + "submission: ResponseTooLong": { + "trace": "aa4afad56cdc6342b34bb30f0549f7ef39d335ff8bbd242f8d8d37833f7d43e0", + "response": "a1610640e238fdb2033ce8f5fac0ae43a3ca889e30f57cb956f0fbc89ff3a220" + }, + "submission: missing applicant": { + "trace": "60a69a225176cc26dfd10e193e8b13a742cc56d24495603359e8041d60ab51d7", + "response": "c5fa44d54f436d4e9e3d9a3fd842d4cd6feb0332f51863b4881311cac529f26a" + }, + "submission: duplicate": { + "trace": "b96fa5ea38ff7eec0b4f9cc09d3dd10c0ccc114bfb61999d3753b6a8eb9ea062", + "response": "acbe02393b9eef4d485b450af5a1ac2f433e11f1eb2aa64825065dfffccd2e4d" + }, + "submission: resume belongs to someone else": { + "trace": "13bb06971ce5ea7d0e2edb04e6a34a691d2ff7fe1b6eb895b4f453407b8a4a9b", + "response": "9421a789d28f4a59574fe2443139b123a462af1e0855257c33e1cef188f84531" + }, + "submission: invalid PDF signature": { + "trace": "55e72674b402788d4118b296fe7706066af1e2c2235ef1b81d68a017db1950ed", + "response": "0638b472c301799015db159759f2652adf03f47b7efeb13dec931463813ef636" + }, + "submission: storage exception": { + "trace": "5e86807d8efd263e49112b9b33610dc674b231c5ac6bb31202a49aed5c36d7a5", + "response": "52c2ae32c7d874a41e07ebb5ae0efe71bf4ce90e59d0fd1c1d34ce01d44f5b6c" + }, + "submission: applicant disappears": { + "trace": "d48f86b4cb791aadfc2644cb9f6fefb2faf6196f1a79ff71c95826d94009e8bc", + "response": "c5fa44d54f436d4e9e3d9a3fd842d4cd6feb0332f51863b4881311cac529f26a" + }, + "submission: application creation fails": { + "trace": "70046bed5f7f160d16c5e8c6d2140b341c9dd858885d71a65551b4b3fb42a9a1", + "response": "cf2abb90606f0edce82b1998a1187086a06ac11c83ffa3ec1df2b4a5e82e2f5b" + }, + "submission: directory fails": { + "trace": "55c428a901c6fc71299928c7702ab04c5c026d6743e96cf7dd224b3d45e43698", + "response": "cf2abb90606f0edce82b1998a1187086a06ac11c83ffa3ec1df2b4a5e82e2f5b" + }, + "profile: merges changes": { + "trace": "85687eade144505c3d54c3f64331fcbbadd384945206b3d603d6edb6a2520284", + "response": "f6962767760c9da9282dc7b33f560d36e63c4b84202dd20c732616c7357bede7" + }, + "profile: no guest": { + "trace": "e6f513ee400c2472bfbd81967676e133537ea5e145020648d4af6e09a3685646", + "response": "dab88098c23f68d9f49a351dcaf2948f36f79e064979249a2ec098f38b7452f8" + }, + "profile: missing applicant": { + "trace": "2022d580654ad73c07fb76ac8c4def2731b17200ba353f259f7465222dae9548", + "response": "a9d9412cf19e1ab6257563a841639064a56ac737842e5ee0c168d217452ea96e" + }, + "profile: rejects another resume": { + "trace": "b6fbcf52e23de515e8aaffdfe4bf52041a1680624603cefbf189b9662f166809", + "response": "9421a789d28f4a59574fe2443139b123a462af1e0855257c33e1cef188f84531" + }, + "profile: verifies changed resume": { + "trace": "6ba15df5ccd6defa312ae0b8fa1c3978714309794b16f891606eb8e66d39bd38", + "response": "f6962767760c9da9282dc7b33f560d36e63c4b84202dd20c732616c7357bede7" + }, + "profile: invalid signature": { + "trace": "ebe9cc8b60bade5a51711c17a76336fcd6a8e74c22b8e9ab162d3a29f22ec658", + "response": "0638b472c301799015db159759f2652adf03f47b7efeb13dec931463813ef636" + }, + "applications: cards": { + "trace": "d098f4cfe61e71398c1d39369918990727ef9da148c9ed7387361a9e2c97fa35", + "response": "ff76b5330411db9a922a2120d1a937d624fa95a4020e423af13410c50dd86ff7" + }, + "applications: empty": { + "trace": "9a2b7f31d199a63c407aff243f7c1c1fc6f2bcc5e61ab24d49913179f7e539b7", + "response": "2c7fedfa54ce85b5b9c44c330215a564b61da0f2059806b5e0ab72fbbccc88e5" + }, + "applications: query failure throws": { + "trace": "9d993e3b9a04b67a9d516130fc090d2553ffe9983624cc5cd9b3347f59ccd59e", + "response": "1692769322b69076924b4c68b9bb6fad3b19f64b0c4d047dbac8890fdabb4d36" + }, + "application: details": { + "trace": "a23576d13f5fb5845b3f10e0694c82f0277699e5d36bd5a823732db7f4530af4", + "response": "71469414be4ff36d254e68d7f368a13812ae9d3747d451d928b36058b9922c47" + }, + "application: missing": { + "trace": "9b1e99ee269f144acfb5b37a18eab65a6067f560485837d8497c2c8ca21f27e0", + "response": "5fba7f2b2af58be36d892198159d913bfb77f10e78e6e914fca35488782174cf" + }, + "application: optional fields": { + "trace": "31f33506347618b9b5b507f487a740bc2cf84504818f47fb2b67ce5d94798a43", + "response": "89a90d25cbcf2ce293978a391a75ea2cf37b024afe48e7528d8c03733f220866" + }, + "feedback: empty": { + "trace": "9270f34e092db41f6b21214e268324cf41acaaee3fb95ffac2a994b0c15b0e32", + "response": "124cb6aa46f288c82295116458e0ee49ba4985be3d7395bcbee2b5531567f4fd" + }, + "feedback: zero stars": { + "trace": "6ddae63d249d747e26a63bbfa39d13eb9890a428bc8b0fc6a6530c1bf972d333", + "response": "e66d184d680bcacee66b7c7a2e3708724c64641f0ed84d9bef5588b3e3fb46ea" + }, + "feedback: high stars": { + "trace": "6ddae63d249d747e26a63bbfa39d13eb9890a428bc8b0fc6a6530c1bf972d333", + "response": "e66d184d680bcacee66b7c7a2e3708724c64641f0ed84d9bef5588b3e3fb46ea" + }, + "feedback: rating": { + "trace": "66b0a96688a5073e61f34cd3e81d0e52dead554031a98fed402475724bdeb95a", + "response": "b081702f2709e09097c44a5ad5a0b994d6d5673d488c23a88e51e7fa9a123351" + }, + "feedback: notes": { + "trace": "6538f09f1a56f15282aaddc4e6e45ff6e3bc79476acc4d74939d28d212bc4829", + "response": "1b33559af94627637a03af803005e5378bcc47697941d0eb9b51af2bc0fbdda1" + }, + "feedback: both": { + "trace": "61268111589c6063dc77f8c26d3678b0b5019e5898396525c290036523f6ba9e", + "response": "4c1962afaa2fdb741c3a723e3a777701684867ca8be663ac91744043b7b3802f" + }, + "feedback: blank notes": { + "trace": "db446a3bf93d06b54263946cd63409729cc2e994890cff599774e29dc58e2294", + "response": "1f3ec20bcbc8e0e14dc2334894972794a7de930be54454d1bed138d32c6d1928" + }, + "feedback: unclosed fence": { + "trace": "c19e59f175b6a3b658432c76fa06dc38a922429fdb113aa9cddd61d10b3a7cd5", + "response": "a748c4c814c7bb970ed6a17dd364fc4ae959649805328ef035a2a250eab9041e" + }, + "feedback: prepends existing notes": { + "trace": "9a04adc123d38da74f3dbc4a5bd5960285a63dbcc88fd297afc3b846a9b22f08", + "response": "6f9adb980aea121b9a14ca1be43732aa07a108e5539a59c3ca7f317ae45a5dbf" + }, + "feedback: wrong team": { + "trace": "5f2acb815d6c43325002c6f87c2b5b194b26226649970131b8de0d08060cc909", + "response": "5fba7f2b2af58be36d892198159d913bfb77f10e78e6e914fca35488782174cf" + }, + "feedback: save failure": { + "trace": "2765a67076427125ae01fb7ecf72f9fc008529a81c0664ddb58b76a477ff3d28", + "response": "667f211a72842355e2cf83720afa6b66894c0d73968109a07fdc4a114d76a290" + }, + "recruiter resume: signed URL": { + "trace": "a114f7f2be593cc5d3bb4c815062c37c5e500fe77a760603e983c94b7b4ddd0d", + "response": "3389a7ba6becc470865846418ddc03735440a7275175bb02d43b0a134a7fccfb" + }, + "recruiter resume: no application": { + "trace": "dba3be5b214e22c740f9e3e8dfce3684feb1720e3cd7401555be3016727de3cb", + "response": "5f1341834663e7db30e3e783a5210f6c2aef7e29372f970ca22c4e068148f269" + }, + "recruiter resume: bad key omitted": { + "trace": "e79abfc5641d2d581ff3110b11de63962f6b66816064bd2e424c3417c380829c", + "response": "cb520303b259f62b23c4c8d4ed7c71402166970e91c9d18435729c48c2d9ffe9" + }, + "recruiter resume: signing failure omitted": { + "trace": "6ae0696342faa3e77565791458cc5741d8eda9d735302b7b02541f9ce20243b6", + "response": "cb520303b259f62b23c4c8d4ed7c71402166970e91c9d18435729c48c2d9ffe9" + }, + "history: application exists": { + "trace": "ab1280c72d08432fba8344eceeb6f841d826bb2d428302512bdcb24bae029558", + "response": "f4d40da3256425665630a37582523bd906e8298fd5a35cc5c251bbf70199f8f1" + }, + "history: no team application": { + "trace": "dba3be5b214e22c740f9e3e8dfce3684feb1720e3cd7401555be3016727de3cb", + "response": "5f1341834663e7db30e3e783a5210f6c2aef7e29372f970ca22c4e068148f269" + }, + "stages: list": { + "trace": "d938e196140864f64098302d214d777e5adb808ea3d7f44775be831717d04eb3", + "response": "5cc6e85e4cc900323ef56a079ee29a96f0c8c343622c2d4d5a056f8f3f149fc3" + }, + "stage: missing user": { + "trace": "fdea12925ec2f2d6bb5c03c3a590daf97249141857ce5fd1671edf9583dc0244", + "response": "4dd0d141f69080ae77c668f12643a3f294f5ddb0c06d6e421026a15ad4f0869a" + }, + "stage: missing application": { + "trace": "99419a2006b93b62fd4cdefc6f8134d80da9bb6a0cebbb15fc115e4952bbe8b4", + "response": "5fba7f2b2af58be36d892198159d913bfb77f10e78e6e914fca35488782174cf" + }, + "stage: no hiring permission": { + "trace": "265256cf69dc08ceb33e040b69bd4581961926e23596ce4f55b83a0103053982", + "response": "0a79ce35d72925d0c6352ac6c08c5a3e148c261d82af700d158f4ddc4a8c124e" + }, + "stage: same stage": { + "trace": "831977f716215ddc2ce4ec8afd4a6807a2a86fc6b308b199e72ec0034f8b509d", + "response": "8e1ad41009a01537ad727c31eb112595543f09fdf196225336991ce9d8202b54" + }, + "stage: invalid transition": { + "trace": "96b2cf05d2fe5087e8258cb71701c12b3455810085c7ec94e8ec932f8241bfa9", + "response": "dbe23427c236d81a8e416c6e367fa66be718aebff27de1e7158d2432e4d7256c" + }, + "stage: interview requires link": { + "trace": "f4606c7975c160f3a79c16e09f48a9aec1cb28ac5e93955f3b19e3fe5d78226a", + "response": "f452561525c3fb52026130adf8aeda89751d9a74eec695260e47b1301beb335f" + }, + "stage: interview requires guidelines": { + "trace": "0d9378ab17d3afce153d45199a03f75b4961be117e342d3ef2498416f9740984", + "response": "b10fb1036b09e271c3302b198c3e2bcb1695898d3d687cc305add8703300c0ae" + }, + "stage: interview notification": { + "trace": "03188299d855fb498e63de61998357158eb9bf2b541774235a65bd9156f1d58a", + "response": "7e7fc92a68f765df6d726d8aa9b6444cc92b1a14861e773c7f0e69e572f7e917" + }, + "stage: rejection notification": { + "trace": "d2ed3eb4d429373804d7851585fc60ee7572c3b011d23554a60f5e3eac26efd0", + "response": "f8fac661dbf8ec0bdd1c3d3a903022c5c2942f1acbb12731c6a6546a6b7f53a1" + }, + "stage: potential hire stays silent": { + "trace": "9786b5eb84d146cc9f388f0a6dc4b40ee36e2c0b532c376862eb6a38f28074e6", + "response": "cfd71364b030735da4e088cc98dd45828491be1f662375d90df0c78472adb8ca" + }, + "stage: missing hire destination": { + "trace": "6b064a5384de2ab9a7235c3bc65e097d4f919977e2d4d341b1cd8ef2c3954adc", + "response": "be8bbebaa9193f729a7c8cd387716cac05e5cca5e946f9af9812427038535b5f" + }, + "stage: hired new member": { + "trace": "38b3b2bd48705dd24a148685d1a000211c480f5b79f1d8829d8ae91d0f2f59a3", + "response": "99be1eb03c04c96815efc775bc09d770ee0b6be06514fb10ff1de2c74641da50" + }, + "stage: hired existing member": { + "trace": "5da8e35274e337ec06de226767d455903e0a9235f11ec6d424725cb77c0fa3a4", + "response": "99be1eb03c04c96815efc775bc09d770ee0b6be06514fb10ff1de2c74641da50" + }, + "stage: outside hire destination": { + "trace": "48a9e9ba1c2faa53a2da7620df539f562c260bfa8d5b226db2f53b80a7b6b614", + "response": "7885163fc364436c12ac92b76e4bea9272afc3ed1262eb92304c44bbbb274518" + }, + "stage: email failure after save": { + "trace": "9ec63af787850d1790a831ce8ac703718c672dc13a7a386fde20fd89c9163ae9", + "response": "8d697a2b7ffe3062dfb7577fd7cf73b3988dfa96241e98a81d5437d7a9816282" + }, + "stage: membership failure": { + "trace": "0a7a45d96aa9fb14125f9ff305cbd598d5690dbb52b645b083a7fdc330085662", + "response": "8d697a2b7ffe3062dfb7577fd7cf73b3988dfa96241e98a81d5437d7a9816282" + }, + "config: get": { + "trace": "d6869922cab2281a31e8259c35e2ef27d3492cf6d142f4a0fdb96ac42cdb4600", + "response": "2accb07a5733b45dcacacc9d2564f9dd60e99a86d94a5141f9555e02b3f41726" + }, + "config: missing": { + "trace": "04ee4505a875dedf7aaac19f2ca45ddf54997be7f0ba033812c3e8516d9e2a0d", + "response": "48ef1d82cee46bd0b55baffef4642903eb075d936b2e7f6f143b5ed81e5cb239" + }, + "config: update": { + "trace": "71b5e33a8037b2c941da408f0c156ca518c16dc246597a4cf31eeab15748f8da", + "response": "bc9bc09c79be0bf5c6f41fed389d5b8feb5b6cb6b07b092b20aaf588ff18582c" + }, + "config: empty roles": { + "trace": "fb0fdb16730bba00aa04f3c6e99417628e77c29fb069f65b7e9451e85c3debcb", + "response": "76d488b6db86978e10cd13c7c481076b36dcf6dfd4624963c08fee22da6f5494" + }, + "config: stop recruiting": { + "trace": "62b7ecddf0a41734db5bf3179251608bb119cf05884f24e26b106d07c48b349a", + "response": "dd44042a88f413c4507ef418b1b67b6a08b1e9813da66166801cda3beedb9484" + }, + "config: removal of missing recruiting status preserves response": { + "trace": "6d81d8daed0bb8acda0be84724f90bc5721391de430cc8618c59e427a2950a7a", + "response": "dc8436fcfa25528228db62e8b2ed9f9d2dc48eb2fa2c228761cc4da7e971e78e" + }, + "recruitment: add subteam": { + "trace": "0347c96890fb2e1ed023923ef6ca64d772eee0ff92d8992c223f5be32111aa29", + "response": "72db90ae934396880f3a5e0f0641b81389ebccb6d4a874d13a437eb70195c908" + }, + "recruitment: remove last subteam": { + "trace": "306e91d914221b3df4ce18aa4b7949190eb234714b6fb2bed5d302b9809c90bd", + "response": "ab71df2aa549c345ba0fb5795633ae84ec7d18309d615f4bb3653d972cab1e30" + }, + "recruitment: missing team removal": { + "trace": "90e38ee6a2f7e8420e303e2134ee3373a7b31f58db736d3f17ba13834eeb6c91", + "response": "e6c6e1f244e1fd4e29db5348f7bb3c3c63233daf91a61b933e9175c236ab41f9" + }, + "open team: details": { + "trace": "320f72f91a5556c0f71ed6996ad5851a2f9949ac78a0cda536867817fe3657c3", + "response": "4ede0ebe06db680690c906d110f7951f22dfbf0bdba637fa24212a376acdf716" + }, + "open team: closed": { + "trace": "32bfab20308ee2375d3c98cc4a80a0fecc0110ca63cb370f48fcf07aca801345", + "response": "a501cb6cc8c7c13e9113bf33f696719e6fe833d8c2dd3fcdd0bc3f9aaa9ba330" + }, + "open team: directory failure": { + "trace": "d34a9a57c800eca79f38cf446026b52f92655b813b666d250b3f4947b2e6fba8", + "response": "38883a38684e7c2d17285ed85eafff6b66239dd2ce21c7f90487dfa5e431646d" + }, + "open team: group not found": { + "trace": "97ed60f7d3e554dc61486f4bbe7eee6f14b7c0267d97a23b541664155306ad2e", + "response": "5011e39a9441194e87a7de9dd545645eecaef9d8d762a28fedb276e1ed81d0fd" + }, + "open teams: cold cache": { + "trace": "df844f598cc82631bfbf6d5545454a78ea9610ee5b0584591d274525e7a82f10", + "response": "803e60c0df8792cc203120d715da5a6ff1d049a0e23b1dbf8edca7919fe94555" + }, + "open teams: fresh cache": { + "trace": "4b527e86b96b65b3f05acf019ff4fedcc7f568090e109f2edcbeb1ae15336952", + "response": "9b30ccbc10e1379cc8bd229e1ff230edbce5065d9cf16697694493249995f669" + }, + "open teams: missing group cleanup": { + "trace": "4b70366b5f8f4426eaae251b4dd19b6e286b0bfe7f10ed82045397ece1383a7f", + "response": "2c7fedfa54ce85b5b9c44c330215a564b61da0f2059806b5e0ab72fbbccc88e5" + }, + "open teams: stale cache survives refresh failure": { + "trace": "7de109c66e741762f0414aa39ac1b683a7f7c1c69f21b7018df5cef1714b75b6", + "response": "9b30ccbc10e1379cc8bd229e1ff230edbce5065d9cf16697694493249995f669" + }, + "open teams: shared refresh across controller instances": { + "trace": "5f4bf681eba1999d78cdb8d92e8894db7633cd26b9e0b089aafa19c20f361314", + "response": "45a7235b29de83fa104b57e0f4fdbc0807c112e27319ffbc1d58252eb2c3d0e7" + }, + "statistics: populated and empty teams": { + "trace": "81646c138278287003e72989f9c1c79afc81f3a1ae304855df265929b1e6cbf3", + "response": "7c8ed405e46612e90c8a3809624df72ee6b47de94a7b46b7befe4ee721988009" + } +} diff --git a/peopleportal/server/test/integration/ats-behavior.test.js b/peopleportal/server/test/integration/ats-behavior.test.js new file mode 100644 index 0000000..5f723d0 --- /dev/null +++ b/peopleportal/server/test/integration/ats-behavior.test.js @@ -0,0 +1,1033 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); +const Module = require("node:module"); +const { createHash } = require("node:crypto"); + +process.env.TZ = "UTC"; +process.env.S3_BUCKET_NAME = "ats-test-bucket"; + +const { Applicant } = require("../../dist/models/Applicant.js"); +const { Application, ApplicationStage: Stage } = require("../../dist/models/Application.js"); +const { SubteamConfig } = require("../../dist/models/SubteamConfig.js"); +const { TeamRecruitingStatus } = require("../../dist/models/TeamRecruitingStatus.js"); +const { RedisClient } = require("../../dist/clients/RedisClient/index.js"); +const signature = require("../../dist/utils/s3-validation.js"); +const presigner = require("@aws-sdk/s3-request-presigner"); +const presignedPost = require("@aws-sdk/s3-presigned-post"); +const { BindleController } = require("../../dist/controllers/BindleController.js"); +const { + AuthentikClientError, + AuthentikClientErrorType, +} = require("../../dist/clients/AuthentikClient/models.js"); + +// Run the same cases against a saved pre-refactor build when comparing behavior. +function loadController() { + if (!process.env.ATS_BASELINE_CONTROLLER) { + return require("../../dist/controllers/ATSController.js").ATSController; + } + const filename = path.resolve(__dirname, "../../dist/controllers/ATSController.before.js"); + const baseline = new Module(filename, module); + baseline.filename = filename; + baseline.paths = Module._nodeModulePaths(path.dirname(filename)); + baseline._compile(fs.readFileSync(process.env.ATS_BASELINE_CONTROLLER, "utf8"), filename); + return baseline.exports.ATSController; +} + +const ATSController = loadController(); +const observations = {}; +const digestPath = path.resolve(__dirname, "../fixtures/ats-behavior.sha256.json"); +const baselineDigests = process.env.ATS_CAPTURE_BASELINE + ? undefined + : JSON.parse(fs.readFileSync(digestPath, "utf8")); +const digest = (observation) => + createHash("sha256").update(JSON.stringify(observation)).digest("hex"); +const TEAM = "team-1"; +const SUBTEAM = "subteam-1"; +const APPLICANT = "65a000000000000000000001"; +const APPLICATION = "65a000000000000000000002"; +const RESUME = `resumes/${APPLICANT}.pdf`; +const ANSWER = "one two three four five six seven eight nine ten"; +const NOW = Date.UTC(2026, 9, 27, 12); + +function normalize(value) { + if (value === undefined) return { $undefined: true }; + if (value instanceof Date) return { $date: value.toISOString() }; + if (value instanceof Map) return { $map: [...value].map(([k, v]) => [k, normalize(v)]) }; + if (Array.isArray(value)) return value.map(normalize); + if (value instanceof Error) return { $error: value.name, message: value.message }; + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, v]) => typeof v !== "function") + .map(([k, v]) => [k, normalize(v)]), + ); + } + return value; +} + +function fixture(t, options = {}) { + t.mock.timers.enable({ apis: ["Date"], now: NOW }); + const calls = []; + const record = (name, ...args) => calls.push([name, ...normalize(args)]); + const actor = { + name: "Recruiter", + username: "recruiter", + email: "reviewer@example.invalid", + groups: [], + is_superuser: true, + }; + const profile = { + resumeUrl: RESUME, + whyAppDev: "I enjoy building useful tools.", + instagramFollow: "yes", + }; + const applicant = { + _id: APPLICANT, + email: "student@example.invalid", + fullName: "Student Name", + profile: new Map(Object.entries(profile)), + }; + const config = { + subteamPk: SUBTEAM, + isRecruiting: true, + roles: ["SWE"], + roleSpecificQuestions: { SWE: ["Explain a project"] }, + }; + const status = { teamPk: TEAM, isRecruiting: true, recruitingSubteamPks: [SUBTEAM] }; + const root = { + pk: TEAM, + name: "team-name", + parentPk: null, + subteamPkList: [SUBTEAM], + attributes: { + friendlyName: "Team One", + description: "A team", + seasonType: "Fall", + seasonYear: "2026", + peoplePortalCreation: true, + flaggedForDeletion: false, + }, + subteams: [], + }; + const subteam = { + ...root, + pk: SUBTEAM, + name: "subteam-name", + parentPk: TEAM, + parentInfo: root, + subteams: [], + }; + root.subteams.push({ ...subteam, parentInfo: undefined }); + const application = { + _id: APPLICATION, + applicantId: APPLICANT, + teamPk: TEAM, + rolePreferences: [{ role: "SWE", subteamPk: SUBTEAM }], + stage: options.stage || Stage.APPLIED, + appliedAt: new Date(NOW), + stars: 0, + responses: new Map([["Explain a project", ANSWER]]), + stageHistory: [], + async save() { + record("Application.save", this); + if (options.saveFails) throw new Error("save failed"); + }, + }; + + function query(value) { + const q = { + select(...args) { + record("select", ...args); + return q; + }, + populate(...args) { + record("populate", ...args); + const populate = (app) => (app ? { ...app, applicantId: applicant } : app); + value = Array.isArray(value) ? value.map(populate) : populate(value); + return q; + }, + lean() { + record("lean"); + return q; + }, + sort(...args) { + record("sort", ...args); + return q; + }, + exec: async () => value, + then: (resolve, reject) => Promise.resolve(value).then(resolve, reject), + }; + return q; + } + + function modelMethod(model, name, label, result) { + t.mock.method(model, name, (...args) => { + record(label, ...args); + if (options.databaseFails === label) throw new Error("database failed"); + return query(typeof result === "function" ? result(...args) : result); + }); + } + modelMethod(Applicant, "findOne", "Applicant.findOne", () => + options.noApplicant ? null : applicant, + ); + modelMethod(Applicant, "findById", "Applicant.findById", () => + options.noApplicant ? null : applicant, + ); + modelMethod(Applicant, "findByIdAndUpdate", "Applicant.findByIdAndUpdate", () => + options.applicantDisappears ? null : applicant, + ); + modelMethod(Applicant, "findOneAndUpdate", "Applicant.findOneAndUpdate", applicant); + t.mock.method(Applicant.db, "transaction", async (operation) => operation({})); + modelMethod(Application, "findOne", "Application.findOne", (filter) => { + if (options.noApplication || filter.teamPk !== TEAM) return null; + if (!filter._id) return options.duplicate ? application : null; + return application; + }); + modelMethod(Application, "find", "Application.find", () => + options.emptyApplications ? [] : [application], + ); + modelMethod(Application, "exists", "Application.exists", () => !options.noApplication); + modelMethod(Application, "aggregate", "Application.aggregate", [ + { _id: { teamPk: TEAM, stage: Stage.APPLIED }, count: 2 }, + ]); + t.mock.method(Application, "create", async (data) => { + record("Application.create", data); + if (options.createFails) throw new Error("create failed"); + return Array.isArray(data) ? [{ ...data[0], _id: APPLICATION }] : { ...data, _id: APPLICATION }; + }); + function patchApplication(update) { + const set = (key, value, remove = false) => { + const parts = key.split("."); + let target = application; + for (const part of parts.slice(0, -1)) target = target[part] ??= {}; + if (remove) delete target[parts.at(-1)]; + else target[parts.at(-1)] = value; + }; + for (const [key, value] of Object.entries(update.$set || {})) set(key, value); + for (const key of Object.keys(update.$unset || {})) set(key, undefined, true); + for (const [key, value] of Object.entries(update.$push || {})) application[key].push(value); + } + modelMethod(Application, "findOneAndUpdate", "Application.findOneAndUpdate", (filter, update) => { + if (options.saveFails) throw new Error("save failed"); + patchApplication(update); + return application; + }); + modelMethod(Application, "updateOne", "Application.updateOne", (filter, update) => { + patchApplication(update); + return { matchedCount: 1 }; + }); + modelMethod(SubteamConfig, "findOne", "SubteamConfig.findOne", () => + options.noConfig ? null : config, + ); + modelMethod(SubteamConfig, "find", "SubteamConfig.find", () => + options.noConfig ? [] : [config], + ); + modelMethod(SubteamConfig, "findOneAndUpdate", "SubteamConfig.findOneAndUpdate", () => config); + modelMethod(TeamRecruitingStatus, "findOne", "TeamRecruitingStatus.findOne", () => + options.noStatus ? null : status, + ); + modelMethod(TeamRecruitingStatus, "find", "TeamRecruitingStatus.find", [status]); + modelMethod(TeamRecruitingStatus, "deleteMany", "TeamRecruitingStatus.deleteMany", {}); + modelMethod(TeamRecruitingStatus, "updateOne", "TeamRecruitingStatus.updateOne", {}); + modelMethod( + TeamRecruitingStatus, + "findOneAndUpdate", + "TeamRecruitingStatus.findOneAndUpdate", + (filter, update) => { + if (options.noStatus) return null; + if (update.$pull) status.recruitingSubteamPks = []; + return status; + }, + ); + + for (const method of ["get", "set", "delete"]) { + t.mock.method(RedisClient, method, async (...args) => { + record(`Redis.${method}`, ...args); + if (method === "get") return options.cache; + return true; + }); + } + t.mock.method(signature, "validateS3FileSignature", async (...args) => { + record("validateS3FileSignature", ...args); + if (options.signatureThrows) throw new Error("storage failed"); + return options.signature !== false; + }); + t.mock.method(presigner, "getSignedUrl", async (client, command, settings) => { + record("getSignedUrl", command.input, settings); + if (options.signingFails) throw new Error("signing failed"); + return `https://storage.example.invalid/${command.input.Key}`; + }); + t.mock.method(presignedPost, "createPresignedPost", async (client, settings) => { + record("createPresignedPost", settings); + if (options.signingFails) throw new Error("signing failed"); + return { url: "https://storage.example.invalid/upload", fields: { key: settings.Key } }; + }); + t.mock.method( + BindleController, + "getEffectivePermissionSet", + () => new Set(options.hiringPermission ? ["corp:hiringaccess"] : []), + ); + + const controller = new ATSController(); + controller.authentikClient = { + async getGroupInfo(pk, settings) { + record("getGroupInfo", pk, settings); + if (options.groupNotFound) + throw new AuthentikClientError(AuthentikClientErrorType.GROUP_NOT_FOUND, "missing group"); + if (options.authentikFails) throw new Error("directory failed"); + return pk === SUBTEAM ? subteam : root; + }, + async getGroupsInfoByPks(...args) { + record("getGroupsInfoByPks", ...args); + if (options.authentikFails) throw new Error("directory failed"); + return new Map(options.missingGroup ? [] : [[TEAM, root]]); + }, + async getGroupsList(...args) { + record("getGroupsList", ...args); + return { + teams: [ + { pk: TEAM, friendlyName: "Team One", name: "team-name" }, + { pk: "team-empty", name: "Empty" }, + ], + }; + }, + async getUserInfoFromEmail(...args) { + record("getUserInfoFromEmail", ...args); + if (!options.internalMember) + throw new AuthentikClientError(AuthentikClientErrorType.USER_NOT_FOUND); + return { pk: "7" }; + }, + }; + controller.emailClient = { + async send(message) { + record("email", message); + if (options.emailFails) throw new Error("mail failed"); + return true; + }, + }; + controller.orgController = { + async createInvite(...args) { + record("createInvite", ...args); + if (options.inviteFails) throw new Error("invite failed"); + }, + async addTeamMember(...args) { + record("addTeamMember", ...args); + if (options.membershipFails) throw new Error("membership failed"); + }, + async createInviteForATS(...args) { + record("createInviteForATS", ...args); + if (options.inviteFails) throw new Error("invite failed"); + }, + }; + const req = { + session: { + authorizedUser: actor, + tempsession: { user: { email: applicant.email, name: applicant.fullName } }, + }, + protocol: "http", + get: () => "localhost:3000", + }; + const input = { + teamPk: TEAM, + rolePreferences: [{ role: "SWE", subteamPk: SUBTEAM }], + profile: { ...profile }, + responses: { "Explain a project": ANSWER, "Why are you interested in Team One?": ANSWER }, + }; + if (options.prepare) + options.prepare({ + actor, + profile, + applicant, + config, + status, + root, + subteam, + application, + req, + input, + }); + t.mock.method(console, "error", () => {}); + t.mock.method(console, "warn", () => {}); + return { controller, calls, req, input, applicant, application, config, status, root }; +} + +function check(name, method, args, expected = {}, options = {}) { + test(name, async (t) => { + const context = fixture(t, options); + let body, thrown; + try { + body = await (typeof method === "function" + ? method(context) + : context.controller[method](...args(context))); + } catch (error) { + thrown = error; + } + await new Promise(setImmediate); + await new Promise(setImmediate); + const actual = { + body: normalize(body), + status: normalize(context.controller.getStatus()), + headers: normalize(context.controller.getHeaders()), + thrown: normalize(thrown), + calls: context.calls, + }; + observations[name] = actual; + if (baselineDigests) { + // These workflows deliberately changed persistence. Their API responses + // still match the original; the reliability suite checks their new writes. + const changedWorkflow = [ + "applyToTeam", + "updateApplicationStage", + "getAllRecruitingTeams", + ].includes(method); + const { calls, ...response } = actual; + assert.equal( + digest(changedWorkflow ? response : actual), + changedWorkflow ? baselineDigests[name].response : baselineDigests[name].trace, + "ATS response or side effects changed; use ATS_BEHAVIOR_SNAPSHOT to inspect the trace", + ); + } + if ("status" in expected) assert.equal(context.controller.getStatus(), expected.status); + if (expected.error) assert.equal(body?.error, expected.error); + if (expected.message) assert.equal(body?.message, expected.message); + if (expected.thrown) assert.equal(thrown?.message, expected.thrown); + else assert.equal(thrown, undefined); + if (expected.verify) expected.verify(body, context); + }); +} + +const upload = ({ req }) => [req, "resume.pdf", "application/pdf"]; +const submission = ({ input, req }) => [input, req]; +const stage = + (value, extra = {}) => + ({ req }) => [req, TEAM, APPLICATION, { stage: value, ...extra }]; +const noGuest = ({ req }) => { + delete req.session.tempsession; +}; + +check("upload: valid PDF", "getResumeUploadUrl", upload, { + verify: (body) => assert.equal(body.key, RESUME), +}); +check( + "upload: missing session", + "getResumeUploadUrl", + upload, + { status: 401, error: "Unauthorized" }, + { prepare: noGuest }, +); +for (const name of ["resume.txt", "resume", "../resume.pdf", "a/resume.pdf", "a\\resume.pdf"]) { + check( + `upload: rejects ${name}`, + "getResumeUploadUrl", + ({ req }) => [req, name, "application/pdf"], + { status: 400 }, + ); +} +check( + "upload: invalid MIME", + "getResumeUploadUrl", + ({ req }) => [req, "resume.pdf", "text/plain"], + { status: 400, error: "InvalidContentType" }, +); +check( + "upload: missing applicant", + "getResumeUploadUrl", + upload, + { status: 404 }, + { noApplicant: true }, +); +check( + "upload: signing failure propagates", + "getResumeUploadUrl", + upload, + { thrown: "signing failed" }, + { signingFails: true }, +); + +for (const key of [RESUME, `resumes/./${APPLICANT}.pdf`, `resumes/folder/../${APPLICANT}.pdf`]) { + check(`download: ${key}`, "getResumeDownloadUrl", ({ req }) => [req, key], { + status: 307, + verify: (body, c) => + assert.equal(c.controller.getHeaders().Location, `https://storage.example.invalid/${RESUME}`), + }); +} +check( + "download: another applicant", + "getResumeDownloadUrl", + ({ req }) => [req, "resumes/other.pdf"], + { status: 403 }, +); +check( + "download: no guest", + "getResumeDownloadUrl", + ({ req }) => [req, RESUME], + { status: 401 }, + { prepare: noGuest }, +); +check( + "download: signing failure", + "getResumeDownloadUrl", + ({ req }) => [req, RESUME], + { status: 404 }, + { signingFails: true }, +); + +check("submission: success", "applyToTeam", submission, { + verify: (body) => assert.equal(body.id, APPLICATION), +}); +check("submission: internal member", "applyToTeam", submission, {}, { internalMember: true }); +check("submission: incomplete request", "applyToTeam", ({ req }) => [{}, req], { + status: 400, + error: "InvalidRequest", +}); +check("submission: no guest", "applyToTeam", submission, { status: 401 }, { prepare: noGuest }); +check( + "submission: recruitment closed", + "applyToTeam", + submission, + { status: 403 }, + { + prepare: ({ status }) => { + status.isRecruiting = false; + }, + }, +); +check( + "submission: invalid role", + "applyToTeam", + submission, + { status: 400, error: "InvalidRole" }, + { + prepare: ({ input }) => { + input.rolePreferences[0].role = "PM"; + }, + }, +); +for (const [field, value, error] of [ + ["resumeUrl", "", "MissingRequiredField"], + ["resumeUrl", "resume.txt", "InvalidFileType"], + ["whyAppDev", "", "MissingRequiredField"], + ["whyAppDev", "word ".repeat(201), "WordCountExceeded"], + ["instagramFollow", "", "MissingRequiredField"], + ["linkedinUrl", "broken", "InvalidURL"], + ["githubUrl", "broken", "InvalidURL"], +]) { + check( + `submission: invalid ${field} ${error}`, + "applyToTeam", + submission, + { status: 400, error }, + { + prepare: ({ input }) => { + input.profile[field] = value; + }, + }, + ); +} +for (const [value, error] of [ + ["", "MissingResponse"], + ["short", "ResponseTooShort"], + ["a".repeat(2001), "ResponseTooLong"], +]) { + check( + `submission: ${error}`, + "applyToTeam", + submission, + { status: 400, error }, + { + prepare: ({ input }) => { + input.responses["Explain a project"] = value; + }, + }, + ); +} +check( + "submission: missing applicant", + "applyToTeam", + submission, + { status: 404 }, + { noApplicant: true }, +); +check( + "submission: duplicate", + "applyToTeam", + submission, + { status: 409, error: "DuplicateApplication" }, + { duplicate: true }, +); +check( + "submission: resume belongs to someone else", + "applyToTeam", + submission, + { status: 400, error: "InvalidResumeKey" }, + { + prepare: ({ input }) => { + input.profile.resumeUrl = "resumes/other.pdf"; + }, + }, +); +check( + "submission: invalid PDF signature", + "applyToTeam", + submission, + { status: 400, error: "InvalidFileSignature" }, + { signature: false }, +); +check( + "submission: storage exception", + "applyToTeam", + submission, + { status: 500, error: "FileProcessingError" }, + { signatureThrows: true }, +); +check( + "submission: applicant disappears", + "applyToTeam", + submission, + { status: 404 }, + { applicantDisappears: true }, +); +check( + "submission: application creation fails", + "applyToTeam", + submission, + { status: 500 }, + { createFails: true }, +); +check( + "submission: directory fails", + "applyToTeam", + submission, + { status: 500 }, + { authentikFails: true }, +); + +check( + "profile: merges changes", + "updateProfile", + ({ req }) => [{ githubUrl: "https://example.invalid" }, req], + { message: "Profile updated successfully" }, +); +check( + "profile: no guest", + "updateProfile", + ({ req }) => [{}, req], + { status: 401 }, + { prepare: noGuest }, +); +check( + "profile: missing applicant", + "updateProfile", + ({ req }) => [{}, req], + { status: 404 }, + { noApplicant: true }, +); +check( + "profile: rejects another resume", + "updateProfile", + ({ req }) => [{ resumeUrl: "resumes/other.pdf" }, req], + { status: 400 }, +); +check( + "profile: verifies changed resume", + "updateProfile", + ({ req }) => [{ resumeUrl: RESUME }, req], + {}, + { + prepare: ({ applicant }) => { + applicant.profile.delete("resumeUrl"); + }, + }, +); +check( + "profile: invalid signature", + "updateProfile", + ({ req }) => [{ resumeUrl: RESUME }, req], + { status: 400 }, + { + signature: false, + prepare: ({ applicant }) => { + applicant.profile.delete("resumeUrl"); + }, + }, +); + +check("applications: cards", "getTeamApplications", () => [TEAM], { + verify: (body) => assert.equal(body[0].applicantId, APPLICANT), +}); +check( + "applications: empty", + "getTeamApplications", + () => [TEAM], + { verify: (body) => assert.deepEqual(body, []) }, + { emptyApplications: true }, +); +check( + "applications: query failure throws", + "getTeamApplications", + () => [TEAM], + { status: 500, thrown: "Failed to fetch applications" }, + { databaseFails: "Application.find" }, +); +check("application: details", "getApplicationDetails", () => [TEAM, APPLICATION], { + verify: (body) => assert.equal(body.id, APPLICATION), +}); +check( + "application: missing", + "getApplicationDetails", + () => [TEAM, APPLICATION], + { status: 404 }, + { noApplication: true }, +); +check( + "application: optional fields", + "getApplicationDetails", + () => [TEAM, APPLICATION], + {}, + { + prepare: ({ application }) => { + application.notes = "Existing"; + application.hiredRole = "SWE"; + application.appDevInternalPk = 7; + }, + }, +); + +for (const [name, body, error] of [ + ["empty", {}, "BadRequest"], + ["zero stars", { stars: 0 }, "BadRequest"], + ["high stars", { stars: 6 }, "BadRequest"], + ["rating", { stars: 4 }, undefined], + ["notes", { notes: "Some feedback" }, undefined], + ["both", { stars: 5, notes: "Useful feedback" }, undefined], + ["blank notes", { notes: " " }, "BadRequest"], + ["unclosed fence", { notes: "```js\nhello" }, "BadRequest"], +]) { + check( + `feedback: ${name}`, + "updateApplicationFeedback", + ({ req }) => [req, TEAM, APPLICATION, body], + error ? { status: 400, error } : { verify: (result) => assert.equal(result.success, true) }, + ); +} +check( + "feedback: prepends existing notes", + "updateApplicationFeedback", + ({ req }) => [req, TEAM, APPLICATION, { notes: "New" }], + {}, + { + prepare: ({ application }) => { + application.notes = "Old"; + }, + }, +); +check( + "feedback: wrong team", + "updateApplicationFeedback", + ({ req }) => [req, "other", APPLICATION, { stars: 4 }], + { status: 404 }, +); +check( + "feedback: save failure", + "updateApplicationFeedback", + ({ req }) => [req, TEAM, APPLICATION, { stars: 4 }], + { status: 500 }, + { saveFails: true }, +); + +check("recruiter resume: signed URL", "getApplicantUrls", () => [TEAM, APPLICANT], { + verify: (body) => assert.equal(body.resumeUrl, `https://storage.example.invalid/${RESUME}`), +}); +check( + "recruiter resume: no application", + "getApplicantUrls", + () => [TEAM, APPLICANT], + { status: 404 }, + { noApplication: true }, +); +check( + "recruiter resume: bad key omitted", + "getApplicantUrls", + () => [TEAM, APPLICANT], + { verify: (body) => assert.equal(body.resumeUrl, undefined) }, + { + prepare: ({ applicant }) => { + applicant.profile.set("resumeUrl", "resumes/other.pdf"); + }, + }, +); +check( + "recruiter resume: signing failure omitted", + "getApplicantUrls", + () => [TEAM, APPLICANT], + {}, + { signingFails: true }, +); +check("history: application exists", "getApplicantApplications", () => [TEAM, APPLICANT], { + verify: (body) => assert.equal(body[0].rolePreferences[0].subteamName, "Team One"), +}); +check( + "history: no team application", + "getApplicantApplications", + () => [TEAM, APPLICANT], + { status: 404 }, + { noApplication: true }, +); + +check("stages: list", "getApplicationStages", () => [], { + verify: (body) => + assert.deepEqual( + body.map((row) => row.name), + Object.values(Stage), + ), +}); +check( + "stage: missing user", + "updateApplicationStage", + stage(Stage.INTERVIEW), + { status: 401 }, + { + prepare: ({ req }) => { + delete req.session.authorizedUser; + }, + }, +); +check( + "stage: missing application", + "updateApplicationStage", + stage(Stage.INTERVIEW), + { status: 404 }, + { noApplication: true }, +); +check( + "stage: no hiring permission", + "updateApplicationStage", + stage(Stage.INTERVIEW), + { status: 403 }, + { + prepare: ({ req }) => { + req.session.authorizedUser.is_superuser = false; + }, + }, +); +check("stage: same stage", "updateApplicationStage", stage(Stage.APPLIED), { + message: "Stage already set", +}); +check("stage: invalid transition", "updateApplicationStage", stage(Stage.HIRED), { status: 400 }); +check("stage: interview requires link", "updateApplicationStage", stage(Stage.INTERVIEW), { + status: 400, +}); +check( + "stage: interview requires guidelines", + "updateApplicationStage", + stage(Stage.INTERVIEW, { interviewLink: "https://example.invalid" }), + { status: 400 }, +); +check( + "stage: interview notification", + "updateApplicationStage", + stage(Stage.INTERVIEW, { + interviewLink: "https://example.invalid", + interviewGuidelines: "a".repeat(50), + }), + { message: "Stage updated successfully" }, +); +check("stage: rejection notification", "updateApplicationStage", stage(Stage.REJECTED), { + message: "Stage updated successfully", +}); +check( + "stage: potential hire stays silent", + "updateApplicationStage", + stage(Stage.POTENTIAL_HIRE), + { verify: (_, c) => assert.equal(c.calls.filter(([name]) => name === "email").length, 0) }, + { stage: Stage.INTERVIEW }, +); +check( + "stage: missing hire destination", + "updateApplicationStage", + stage(Stage.HIRED), + { status: 400 }, + { stage: Stage.INTERVIEW }, +); +for (const internalMember of [false, true]) { + check( + `stage: hired ${internalMember ? "existing" : "new"} member`, + "updateApplicationStage", + stage(Stage.HIRED, { hiredRole: "SWE", hiredSubteamPk: SUBTEAM }), + { message: "Stage updated successfully" }, + { stage: Stage.INTERVIEW, internalMember }, + ); +} +check( + "stage: outside hire destination", + "updateApplicationStage", + stage(Stage.HIRED, { hiredRole: "SWE", hiredSubteamPk: "other" }), + { status: 400 }, + { stage: Stage.INTERVIEW }, +); +check( + "stage: email failure after save", + "updateApplicationStage", + stage(Stage.REJECTED), + { status: 500 }, + { emailFails: true }, +); +check( + "stage: membership failure", + "updateApplicationStage", + stage(Stage.HIRED, { hiredRole: "SWE", hiredSubteamPk: SUBTEAM }), + { status: 500 }, + { stage: Stage.INTERVIEW, internalMember: true, membershipFails: true }, +); + +check("config: get", "getSubTeamATSConfig", () => [SUBTEAM], { + verify: (body) => assert.equal(body.parentInfo.pk, TEAM), +}); +check( + "config: missing", + "getSubTeamATSConfig", + () => [SUBTEAM], + { status: 404 }, + { noConfig: true }, +); +check("config: update", "createOrUpdateSubteamConfig", ({ config }) => [SUBTEAM, config]); +check( + "config: empty roles", + "createOrUpdateSubteamConfig", + () => [SUBTEAM, { isRecruiting: true, roles: [], roleSpecificQuestions: {} }], + { status: 400 }, +); +check( + "config: stop recruiting", + "createOrUpdateSubteamConfig", + ({ config }) => [SUBTEAM, config], + {}, + { + prepare: ({ config }) => { + config.isRecruiting = false; + }, + }, +); +check( + "config: removal of missing recruiting status preserves response", + "createOrUpdateSubteamConfig", + ({ config }) => [SUBTEAM, config], + { status: 404 }, + { + noStatus: true, + prepare: ({ config }) => { + config.isRecruiting = false; + }, + }, +); +check( + "recruitment: add subteam", + "addSubteamToRecruiting", + () => [TEAM, SUBTEAM], + {}, + { + prepare: ({ status }) => { + status.isRecruiting = false; + }, + }, +); +check("recruitment: remove last subteam", "removeSubteamFromRecruiting", () => [TEAM, SUBTEAM], { + verify: (body) => assert.equal(body.isRecruiting, false), +}); +check( + "recruitment: missing team removal", + "removeSubteamFromRecruiting", + () => [TEAM, SUBTEAM], + { status: 404 }, + { noStatus: true }, +); +check("open team: details", "getTeamDetails", () => [TEAM], { + verify: (body) => assert.equal(body.recruitingSubteams[0].subteamPk, SUBTEAM), +}); +check("open team: closed", "getTeamDetails", () => [TEAM], { status: 403 }, { noStatus: true }); +check( + "open team: directory failure", + "getTeamDetails", + () => [TEAM], + { status: 500 }, + { authentikFails: true }, +); +check( + "open team: group not found", + "getTeamDetails", + () => [TEAM], + { status: 404, error: "TeamNotFound" }, + { groupNotFound: true }, +); +check("open teams: cold cache", "getAllRecruitingTeams", () => [], { + verify: (body) => assert.equal(body[0].teamInfo.pk, TEAM), +}); +check( + "open teams: fresh cache", + "getAllRecruitingTeams", + () => [], + { verify: (body) => assert.deepEqual(body, [{ cached: true }]) }, + { cache: { value: [{ cached: true }], cachedAt: NOW } }, +); +check( + "open teams: missing group cleanup", + "getAllRecruitingTeams", + () => [], + { verify: (body) => assert.deepEqual(body, []) }, + { missingGroup: true }, +); +check( + "open teams: stale cache survives refresh failure", + "getAllRecruitingTeams", + () => [], + { verify: (body) => assert.deepEqual(body, [{ cached: true }]) }, + { cache: { value: [{ cached: true }], cachedAt: NOW - 301000 }, authentikFails: true }, +); +check( + "open teams: shared refresh across controller instances", + async ({ controller, calls }) => { + const other = new ATSController(); + other.authentikClient = controller.authentikClient; + const results = await Promise.all([ + controller.getAllRecruitingTeams(), + other.getAllRecruitingTeams(), + ]); + await new Promise(setImmediate); + assert.equal(calls.filter(([name]) => name === "getGroupsInfoByPks").length, 1); + return results; + }, + () => [], + {}, + { cache: { value: [{ cached: true }], cachedAt: NOW - 301000 } }, +); +check("statistics: populated and empty teams", "getTeamRecruitmentStats", () => [], { + verify: (body) => + assert.deepEqual( + body.teams.map((team) => team.total), + [2, 0], + ), +}); + +test.after(() => { + if (process.env.ATS_CAPTURE_BASELINE) { + fs.mkdirSync(path.dirname(digestPath), { recursive: true }); + fs.writeFileSync( + digestPath, + JSON.stringify( + Object.fromEntries( + Object.entries(observations).map(([name, trace]) => { + const { calls, ...response } = trace; + return [name, { trace: digest(trace), response: digest(response) }]; + }), + ), + null, + 2, + ) + "\n", + ); + } + if (process.env.ATS_BEHAVIOR_SNAPSHOT) { + fs.writeFileSync(process.env.ATS_BEHAVIOR_SNAPSHOT, JSON.stringify(observations, null, 2)); + } +}); diff --git a/peopleportal/server/test/integration/ats-reliability.test.js b/peopleportal/server/test/integration/ats-reliability.test.js new file mode 100644 index 0000000..0bf02c3 --- /dev/null +++ b/peopleportal/server/test/integration/ats-reliability.test.js @@ -0,0 +1,465 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const mongoose = require("mongoose"); +const { MongoMemoryReplSet } = require("mongodb-memory-server"); +const { Applicant } = require("../../dist/models/Applicant.js"); +const { Application, ApplicationStage: Stage } = require("../../dist/models/Application.js"); +const { SubteamConfig } = require("../../dist/models/SubteamConfig.js"); +const { TeamRecruitingStatus } = require("../../dist/models/TeamRecruitingStatus.js"); +const { Invite } = require("../../dist/models/Invites.js"); +const { ATSController } = require("../../dist/controllers/ATSController.js"); +const { BindleController } = require("../../dist/controllers/BindleController.js"); +const { RedisClient } = require("../../dist/clients/RedisClient/index.js"); +const { + AuthentikClientError, + AuthentikClientErrorType: ErrorType, +} = require("../../dist/clients/AuthentikClient/models.js"); +const signature = require("../../dist/utils/s3-validation.js"); + +const TEAM = "team-ats-test"; +const SUBTEAM = "subteam-ats-test"; +const ANSWER = "one two three four five six seven eight nine ten"; +const INTERVIEW = { + stage: Stage.INTERVIEW, + interviewLink: "https://interview.example.invalid", + interviewGuidelines: "Please prepare to discuss your projects and answer questions.", +}; +let mongo; + +// Use a disposable replica set so transaction tests never use the app's database. +test.before(async () => { + mongo = await MongoMemoryReplSet.create({ replSet: { count: 1, storageEngine: "wiredTiger" } }); + await mongoose.connect(mongo.getUri("ats-reliability-test")); + await Promise.all([ + Applicant.init(), + Application.init(), + Invite.init(), + SubteamConfig.init(), + TeamRecruitingStatus.init(), + ]); +}); +test.after(async () => { + await mongoose.disconnect(); + if (mongo) await mongo.stop(); +}); +test.beforeEach(async () => { + await Promise.all([ + Application.deleteMany({}), + Applicant.deleteMany({}), + Invite.deleteMany({}), + SubteamConfig.deleteMany({}), + TeamRecruitingStatus.deleteMany({}), + ]); +}); + +async function setup(t, stage) { + // Every case starts with one applicant and an open team/subteam. Directory, + // email, and S3 calls are replaced below so failures can be tested deliberately. + const applicant = await Applicant.create({ + email: "student@terpmail.umd.edu", + fullName: "Student Tester", + profile: { whyAppDev: "Original profile" }, + }); + const root = { + pk: TEAM, + name: "ats-team", + attributes: { friendlyName: "ATS Team", peoplePortalCreation: true, flaggedForDeletion: false }, + subteamPkList: [SUBTEAM], + subteams: [], + }; + const sub = { ...root, pk: SUBTEAM, parentPk: TEAM, parentInfo: root }; + root.subteams = [{ ...sub, parentInfo: undefined }]; + await SubteamConfig.create({ + subteamPk: SUBTEAM, + isRecruiting: true, + roles: ["SWE"], + roleSpecificQuestions: {}, + }); + await TeamRecruitingStatus.create({ + teamPk: TEAM, + isRecruiting: true, + recruitingSubteamPks: [SUBTEAM], + }); + const actor = { + pk: 7, + name: "Recruiter", + email: "recruiter@terpmail.umd.edu", + username: "recruiter", + groups: [], + is_superuser: true, + attributes: {}, + }; + const request = { + session: { + authorizedUser: actor, + tempsession: { user: { email: applicant.email, name: "Student Updated" } }, + }, + }; + const body = { + teamPk: TEAM, + rolePreferences: [{ role: "SWE", subteamPk: SUBTEAM }], + profile: { + resumeUrl: `resumes/${applicant._id}.pdf`, + whyAppDev: "Updated profile", + instagramFollow: "yes", + }, + responses: { "Why are you interested in ATS Team?": ANSWER }, + }; + const controller = new ATSController(); + const sent = [], + members = []; + controller.authentikClient = { + getGroupInfo: async (pk) => (pk === SUBTEAM ? sub : root), + getGroupsInfoByPks: async () => new Map([[TEAM, root]]), + getUserInfoFromEmail: async (email) => { + if (email === actor.email) return { pk: "7", email: actor.email, name: actor.name }; + throw new AuthentikClientError(ErrorType.USER_NOT_FOUND); + }, + }; + controller.emailClient = { + send: async (message) => { + sent.push(message); + return true; + }, + }; + controller.orgController.authentikClient = controller.authentikClient; + controller.orgController.emailClient = controller.emailClient; + controller.orgController.addTeamMember = async (...args) => { + members.push(args); + }; + t.mock.method(signature, "validateS3FileSignature", async () => true); + t.mock.method(BindleController, "getEffectivePermissionSet", () => new Set()); + t.mock.method(RedisClient, "get", async () => null); + t.mock.method(RedisClient, "set", async () => true); + t.mock.method(console, "error", () => {}); + t.mock.method(console, "warn", () => {}); + let application; + if (stage) + application = await Application.create({ + applicantId: applicant._id, + teamPk: TEAM, + rolePreferences: body.rolePreferences, + stage, + responses: {}, + }); + return { applicant, application, root, request, body, controller, sent, members, actor }; +} + +test("submission commits profile, application and application link together", async (t) => { + const c = await setup(t); + const result = await c.controller.applyToTeam(c.body, c.request); + assert.equal(result.error, undefined); + const saved = await Applicant.findById(c.applicant._id); + assert.equal(saved.profile.get("whyAppDev"), "Updated profile"); + assert.deepEqual(saved.applicationIds.map(String), [String(result.id)]); + assert.equal(await Application.countDocuments({ _id: result.id }), 1); +}); + +test("application creation failure rolls back profile and application link", async (t) => { + const c = await setup(t); + t.mock.method(Application, "create", async () => { + throw new Error("creation failed"); + }); + const result = await c.controller.applyToTeam(c.body, c.request); + assert.equal(c.controller.getStatus(), 500); + assert.equal(result.error, "ServerError"); + const saved = await Applicant.findById(c.applicant._id); + assert.equal(saved.profile.get("whyAppDev"), "Original profile"); + assert.deepEqual(saved.applicationIds, []); + assert.equal(await Application.countDocuments(), 0); +}); + +test("concurrent submissions produce one application, one link and a 409", async (t) => { + const c = await setup(t); + const original = Application.findOne; + let arrived = 0, + release; + const gate = new Promise((resolve) => { + release = resolve; + }); + t.mock.method(Application, "findOne", function (...args) { + const query = original.apply(this, args); + const exec = query.exec.bind(query); + query.exec = async () => { + const result = await exec(); + if (++arrived === 2) release(); + await gate; + return result; + }; + return query; + }); + const other = new ATSController(); + other.authentikClient = c.controller.authentikClient; + const results = await Promise.all([ + c.controller.applyToTeam(c.body, c.request), + other.applyToTeam(c.body, c.request), + ]); + assert.equal(results.filter((r) => r.id).length, 1); + assert.equal(results.filter((r) => r.error === "DuplicateApplication").length, 1); + assert.ok([c.controller.getStatus(), other.getStatus()].includes(409)); + assert.equal(await Application.countDocuments(), 1); + assert.equal((await Applicant.findById(c.applicant._id)).applicationIds.length, 1); +}); + +for (const corruptedList of [false, true]) { + test(`foreign subteam is rejected even when it offers SWE${corruptedList ? " and appears in the recruitment list" : ""}`, async (t) => { + const c = await setup(t); + await SubteamConfig.create({ subteamPk: "foreign", isRecruiting: true, roles: ["SWE"] }); + if (corruptedList) + await TeamRecruitingStatus.updateOne( + { teamPk: TEAM }, + { $addToSet: { recruitingSubteamPks: "foreign" } }, + ); + c.body.rolePreferences = [{ role: "SWE", subteamPk: "foreign" }]; + const result = await c.controller.applyToTeam(c.body, c.request); + assert.equal(result.error, "InvalidRole"); + assert.equal(c.controller.getStatus(), 400); + assert.equal(await Application.countDocuments(), 0); + assert.equal( + (await Applicant.findById(c.applicant._id)).profile.get("whyAppDev"), + "Original profile", + ); + }); +} + +test("failed interview email is retried with saved payload without a second history entry", async (t) => { + const c = await setup(t, Stage.APPLIED); + c.controller.emailClient.send = async (message) => { + c.sent.push(message); + if (c.sent.length === 1) throw new Error("SMTP offline"); + return true; + }; + const first = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + INTERVIEW, + ); + assert.equal(first.error, "ServerError"); + const pending = await Application.findById(c.application._id); + assert.equal(pending.stage, Stage.INTERVIEW); + assert.equal(pending.pendingStageAction.state, "pending"); + // Send a different link on retry to prove the original saved payload wins. + const result = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.INTERVIEW, interviewLink: "https://changed.example.invalid" }, + ); + assert.equal(result.error, undefined); + const saved = await Application.findById(c.application._id); + assert.equal(saved.pendingStageAction, undefined); + assert.equal(saved.stageHistory.length, 1); + assert.equal(c.sent[1].templateVars.interviewLink, INTERVIEW.interviewLink); + await c.controller.updateApplicationStage(c.request, TEAM, String(c.application._id), { + stage: Stage.INTERVIEW, + }); + assert.equal(c.sent.length, 2); +}); + +test("a pending action blocks concurrent retries and later stages", async (t) => { + const c = await setup(t, Stage.APPLIED); + let entered, finish; + const started = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + finish = resolve; + }); + c.controller.emailClient.send = async () => { + c.sent.push("email"); + entered(); + await gate; + return true; + }; + const first = c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + INTERVIEW, + ); + try { + await started; + for (const stage of [Stage.INTERVIEW, Stage.REJECTED]) { + const result = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage }, + ); + assert.equal(result.error, "StageActionConflict"); + assert.equal(c.controller.getStatus(), 409); + } + } finally { + finish(); + } + assert.equal((await first).error, undefined); + assert.equal(c.sent.length, 1); + assert.equal((await Application.findById(c.application._id)).stage, Stage.INTERVIEW); +}); + +test("expired processing lease can be reclaimed after a server restart", async (t) => { + const c = await setup(t, Stage.INTERVIEW); + await Application.updateOne( + { _id: c.application._id }, + { + $set: { + pendingStageAction: { + id: "restart-action", + stage: Stage.INTERVIEW, + input: INTERVIEW, + state: "processing", + completedSteps: [], + leaseToken: "dead-worker", + leaseUntil: new Date(Date.now() - 1000), + inviteId: new mongoose.Types.ObjectId().toString(), + sender: { name: c.actor.name, email: c.actor.email }, + }, + }, + }, + ); + const result = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.INTERVIEW }, + ); + assert.equal(result.error, undefined); + assert.equal(c.sent.length, 1); + assert.equal((await Application.findById(c.application._id)).pendingStageAction, undefined); +}); + +test("hiring retry skips completed membership and retries only acceptance email", async (t) => { + const c = await setup(t, Stage.INTERVIEW); + c.controller.authentikClient.getUserInfoFromEmail = async () => ({ pk: "8" }); + c.controller.emailClient.send = async (message) => { + c.sent.push(message); + if (c.sent.length === 1) throw new Error("SMTP offline"); + return true; + }; + const first = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED, hiredRole: "SWE", hiredSubteamPk: SUBTEAM }, + ); + assert.equal(first.error, "ServerError"); + assert.ok( + (await Application.findById(c.application._id)).pendingStageAction.completedSteps.includes( + "membership", + ), + ); + const retry = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED }, + ); + assert.equal(retry.error, undefined); + assert.equal(c.members.length, 1); + assert.equal(c.sent.length, 2); +}); + +test("new-member retry reuses the invite and onboarding URL after email failure", async (t) => { + const c = await setup(t, Stage.INTERVIEW); + c.controller.emailClient.send = async (message) => { + c.sent.push(message); + if (c.sent.length === 1) throw new Error("SMTP offline"); + return true; + }; + const first = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED, hiredRole: "SWE", hiredSubteamPk: SUBTEAM }, + ); + assert.equal(first.error, "ServerError"); + assert.equal(await Invite.countDocuments(), 1); + const retry = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED }, + ); + assert.equal(retry.error, undefined); + assert.equal(await Invite.countDocuments(), 1); + assert.equal(c.sent[0].templateVars.onboardUrl, c.sent[1].templateVars.onboardUrl); +}); + +test("directory outage during hiring leaves pending work without creating an invite", async (t) => { + const c = await setup(t, Stage.INTERVIEW); + c.controller.authentikClient.getUserInfoFromEmail = async () => { + throw new AuthentikClientError(ErrorType.SERVER_CONNECTION_ERROR); + }; + const result = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED, hiredRole: "SWE", hiredSubteamPk: SUBTEAM }, + ); + assert.equal(result.error, "ServerError"); + assert.equal(await Invite.countDocuments(), 0); + assert.equal(c.members.length, 0); + assert.equal(c.sent.length, 0); + assert.equal((await Application.findById(c.application._id)).pendingStageAction.state, "pending"); +}); + +test("membership completed before a role-write failure is reconciled on retry", async (t) => { + const c = await setup(t, Stage.INTERVIEW); + c.controller.authentikClient.getUserInfoFromEmail = async () => ({ pk: "8" }); + c.controller.orgController.addTeamMember = async (...args) => { + c.members.push(args); + throw new Error("group added but role write failed"); + }; + let roles = {}; + c.controller.authentikClient.getUserInfo = async () => ({ + groups: [SUBTEAM], + attributes: { roles }, + }); + c.controller.authentikClient.updateUserAttributes = async (_, attributes) => { + roles = attributes.roles; + }; + const first = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED, hiredRole: "SWE", hiredSubteamPk: SUBTEAM }, + ); + assert.equal(first.error, "ServerError"); + const retry = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.HIRED }, + ); + assert.equal(retry.error, undefined); + assert.equal(c.members.length, 1); + assert.equal(roles[SUBTEAM], "SWE"); + assert.equal(c.sent.length, 1); +}); + +test("retry still requires the current user's hiring permission", async (t) => { + const c = await setup(t, Stage.APPLIED); + c.controller.emailClient.send = async () => { + throw new Error("SMTP offline"); + }; + await c.controller.updateApplicationStage(c.request, TEAM, String(c.application._id), INTERVIEW); + c.request.session.authorizedUser.is_superuser = false; + const retry = await c.controller.updateApplicationStage( + c.request, + TEAM, + String(c.application._id), + { stage: Stage.INTERVIEW }, + ); + assert.equal(retry.error, "Forbidden"); + assert.equal(c.controller.getStatus(), 403); +}); + +test("a missing directory group is hidden without deleting recruitment data", async (t) => { + const c = await setup(t); + c.controller.authentikClient.getGroupsInfoByPks = async () => new Map(); + assert.deepEqual(await c.controller.getAllRecruitingTeams(), []); + assert.equal(await TeamRecruitingStatus.countDocuments({ teamPk: TEAM }), 1); + c.controller.authentikClient.getGroupsInfoByPks = async () => new Map([[TEAM, c.root]]); + assert.equal((await c.controller.getAllRecruitingTeams()).length, 1); +});