From 5ef7eac3f90f7f3b7ef5a8991d8bcd95dacda86b Mon Sep 17 00:00:00 2001 From: Rayyan Mridha <66543565+rayyanmridha@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:01:44 -0400 Subject: [PATCH 01/27] Add PandaDoc webhook endpoint to create applications from form submissions Adds a public POST /api/pandadoc-webhook endpoint that receives PandaDoc webhook payloads when a recipient completes the application form. The endpoint runs the payload through the existing pandadocMapper, sets defaults (appStatus=APP_SUBMITTED, derives applicantType from schoolDepartment), then creates Application, CandidateInfo, and LearnerInfo records in sequence with logging at each step. - New PandadocWebhookModule with controller, service, and tests - Export ApplicationsService and LearnerInfoService from their modules - Register PandadocWebhookModule and CandidateInfoModule in AppModule - Optional webhook signature verification via PANDADOC_WEBHOOK_KEY env var - Reuses existing error email filters for applicant notifications Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/backend/src/app.module.ts | 5 +- .../src/applications/applications.module.ts | 1 + .../src/learner-info/learner-info.module.ts | 1 + .../pandadoc-webhook.controller.ts | 57 +++++ .../pandadoc-webhook.module.ts | 21 ++ .../pandadoc-webhook.service.spec.ts | 215 ++++++++++++++++++ .../pandadoc-webhook.service.ts | 117 ++++++++++ 7 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts create mode 100644 apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts create mode 100644 apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts create mode 100644 apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index c5d6ec3a9..89baa9f4f 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -13,6 +13,8 @@ import { UsersModule } from './users/users.module'; import { ConfigModule } from '@nestjs/config'; import { DisciplinesModule } from './disciplines/disciplines.module'; import { AdminInfoModule } from './admin-info/admin-info.module'; +import { PandadocWebhookModule } from './pandadoc-webhook/pandadoc-webhook.module'; +import { CandidateInfoModule } from './candidate-info/candidate-info.module'; @Module({ imports: [ @@ -32,7 +34,8 @@ import { AdminInfoModule } from './admin-info/admin-info.module'; DisciplinesModule, LearnerInfoModule, ApplicationsModule, - ApplicationsModule, + CandidateInfoModule, + PandadocWebhookModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/applications/applications.module.ts b/apps/backend/src/applications/applications.module.ts index c2f38e41d..ba4ee92ca 100644 --- a/apps/backend/src/applications/applications.module.ts +++ b/apps/backend/src/applications/applications.module.ts @@ -24,5 +24,6 @@ import { UtilModule } from '../util/util.module'; ApplicationValidationEmailFilter, ApplicationCreationErrorFilter, ], + exports: [ApplicationsService], }) export class ApplicationsModule {} diff --git a/apps/backend/src/learner-info/learner-info.module.ts b/apps/backend/src/learner-info/learner-info.module.ts index 4301a28b6..0d3ffe18c 100644 --- a/apps/backend/src/learner-info/learner-info.module.ts +++ b/apps/backend/src/learner-info/learner-info.module.ts @@ -11,5 +11,6 @@ import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor imports: [TypeOrmModule.forFeature([LearnerInfo]), AuthModule, UsersModule], controllers: [LearnerInfoController], providers: [LearnerInfoService, CurrentUserInterceptor], + exports: [LearnerInfoService], }) export class LearnerInfoModule {} diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts new file mode 100644 index 000000000..4ac5a313c --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts @@ -0,0 +1,57 @@ +import { + Controller, + Post, + Body, + Logger, + UnauthorizedException, + Headers, + UseFilters, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ApiTags } from '@nestjs/swagger'; +import { PandadocWebhookService } from './pandadoc-webhook.service'; +import { ApplicationValidationEmailFilter } from '../applications/filters/application-validation-email.filter'; +import { ApplicationCreationErrorFilter } from '../applications/filters/application-creation-validation.filter'; + +/** + * Public endpoint that receives PandaDoc webhook events. + * No JWT auth — PandaDoc calls this externally. + */ +@ApiTags('PandaDoc Webhook') +@Controller('pandadoc-webhook') +export class PandadocWebhookController { + private readonly logger = new Logger(PandadocWebhookController.name); + private readonly webhookKey: string | undefined; + + constructor( + private readonly webhookService: PandadocWebhookService, + configService: ConfigService, + ) { + this.webhookKey = configService.get('PANDADOC_WEBHOOK_KEY'); + if (!this.webhookKey) { + this.logger.warn( + 'PANDADOC_WEBHOOK_KEY is not set — webhook signature verification is disabled', + ); + } + } + + @Post() + @UseFilters(ApplicationCreationErrorFilter, ApplicationValidationEmailFilter) + async handleWebhook( + @Body() body: Record, + @Headers('x-pandadoc-signature') signature?: string, + ) { + this.logger.log('[PandaDoc] Incoming webhook request'); + + // Verify webhook key if configured + if (this.webhookKey) { + if (!signature || signature !== this.webhookKey) { + this.logger.warn('[PandaDoc] Invalid or missing webhook signature'); + throw new UnauthorizedException('Invalid webhook signature'); + } + } + + const result = await this.webhookService.processWebhook(body); + return { status: 'ok', appId: result.appId }; + } +} diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts new file mode 100644 index 000000000..32dd0e6fd --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PandadocWebhookController } from './pandadoc-webhook.controller'; +import { PandadocWebhookService } from './pandadoc-webhook.service'; +import { ApplicationsModule } from '../applications/applications.module'; +import { CandidateInfoModule } from '../candidate-info/candidate-info.module'; +import { LearnerInfoModule } from '../learner-info/learner-info.module'; +import { UtilModule } from '../util/util.module'; + +@Module({ + imports: [ + ConfigModule, + ApplicationsModule, + CandidateInfoModule, + LearnerInfoModule, + UtilModule, + ], + controllers: [PandadocWebhookController], + providers: [PandadocWebhookService], +}) +export class PandadocWebhookModule {} diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts new file mode 100644 index 000000000..6166126cc --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -0,0 +1,215 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PandadocWebhookService } from './pandadoc-webhook.service'; +import { ApplicationsService } from '../applications/applications.service'; +import { CandidateInfoService } from '../candidate-info/candidate-info.service'; +import { LearnerInfoService } from '../learner-info/learner-info.service'; +import { AppStatus, ApplicantType } from '../applications/types'; + +jest.mock('../util/aws-exports', () => ({ + __esModule: true, + default: { + AWSConfig: { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + region: 'us-east-2', + bucket: 'bucket', + }, + CognitoAuthConfig: { + userPoolId: 'test-user-pool-id', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }, +})); + +function buildFullPayload(): Record { + return { + Volunteer_StartDate: '06-01-2026', + Volunteer_EndDate: '12-01-2026', + email: 'test@example.com', + Volunteer_Pronouns: 'he/him', + Volunteer_Phone: '617-555-0199', + Volunteer_Languages: '', + Volunteer_Experience: 'Volunteer/Intern', + Volunteer_Affiliation: 'Northeastern', + 'Volunteer_ Affiliation_Other': '', + Volunteer_Discipline: 'Public Health', + Volunteer_Discipline_Other: '', + Volunteer_License: 'N/A', + Volunteer_Referred: 'No', + Volunteer_ReferredEmail: '', + Volunteer_TotalHours: '10', + Volunteer_AvailabilityMonday: '9am-12pm', + Volunteer_AvailabilityTuesday: '', + Volunteer_AvailabilityWednesday: '1pm-5pm', + Volunteer_AvailabilityThursday: '', + Volunteer_AvailabilityFriday: '9am-12pm', + Volunteer_AvailabilitySaturday: '', + Volunteer_ResumeUpload2: 'resume.pdf', + Volunteer_CoverletterUpload2: 'cl.pdf', + Volunteer_EmergencyContactName: 'Jane Doe', + Volunteer_EmergencyContactPhone: '617-555-0100', + Volunteer_EmergencyContactRelationship: 'Mother', + Volunteer_Interest_PrimaryCare: 'on', + Volunteer_HearAboutUs_School: 'on', + Volunteer_FormFor: 'Supervisor/Instructor', + Volunteer_Age: 'Yes', + Volunteer_DOB: '01-15-2000', + Volunteer_Department: 'Khoury College', + Volunteer_CourseRequirements: '120 clinical hours', + Volunteer_InstructorInfo: 'Dr. Smith', + Volunteer_SyllabusUpload: 'syllabus.pdf', + }; +} + +describe('PandadocWebhookService', () => { + let service: PandadocWebhookService; + + const mockApplicationsService = { + create: jest.fn(), + }; + const mockCandidateInfoService = { + create: jest.fn(), + }; + const mockLearnerInfoService = { + create: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PandadocWebhookService, + { provide: ApplicationsService, useValue: mockApplicationsService }, + { provide: CandidateInfoService, useValue: mockCandidateInfoService }, + { provide: LearnerInfoService, useValue: mockLearnerInfoService }, + ], + }).compile(); + + service = module.get(PandadocWebhookService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('processWebhook - happy path', () => { + it('should create all three records in order with correct appId', async () => { + const payload = buildFullPayload(); + mockApplicationsService.create.mockResolvedValue({ appId: 42 }); + mockCandidateInfoService.create.mockResolvedValue({ + appId: 42, + email: 'test@example.com', + }); + mockLearnerInfoService.create.mockResolvedValue({ appId: 42 }); + + const result = await service.processWebhook(payload); + + expect(result).toEqual({ appId: 42 }); + + // Application created first + expect(mockApplicationsService.create).toHaveBeenCalledTimes(1); + const appDto = mockApplicationsService.create.mock.calls[0][0]; + expect(appDto.email).toBe('test@example.com'); + expect(appDto.appStatus).toBe(AppStatus.APP_SUBMITTED); + expect(appDto.phone).toBe('617-555-0199'); + + // CandidateInfo created with the returned appId + expect(mockCandidateInfoService.create).toHaveBeenCalledWith( + 42, + 'test@example.com', + ); + + // LearnerInfo created with the returned appId + expect(mockLearnerInfoService.create).toHaveBeenCalledTimes(1); + const learnerDto = mockLearnerInfoService.create.mock.calls[0][0]; + expect(learnerDto.appId).toBe(42); + }); + + it('should set applicantType to LEARNER when schoolDepartment is present', async () => { + const payload = buildFullPayload(); + mockApplicationsService.create.mockResolvedValue({ appId: 1 }); + mockCandidateInfoService.create.mockResolvedValue({}); + mockLearnerInfoService.create.mockResolvedValue({}); + + await service.processWebhook(payload); + + const appDto = mockApplicationsService.create.mock.calls[0][0]; + expect(appDto.applicantType).toBe(ApplicantType.LEARNER); + }); + + it('should set applicantType to VOLUNTEER when schoolDepartment is empty', async () => { + const payload = { + ...buildFullPayload(), + Volunteer_Department: '', + }; + mockApplicationsService.create.mockResolvedValue({ appId: 1 }); + mockCandidateInfoService.create.mockResolvedValue({}); + mockLearnerInfoService.create.mockResolvedValue({}); + + await service.processWebhook(payload); + + const appDto = mockApplicationsService.create.mock.calls[0][0]; + expect(appDto.applicantType).toBe(ApplicantType.VOLUNTEER); + }); + }); + + describe('processWebhook - date conversion', () => { + it('should convert Date objects to YYYY-MM-DD strings', async () => { + const payload = buildFullPayload(); + mockApplicationsService.create.mockResolvedValue({ appId: 1 }); + mockCandidateInfoService.create.mockResolvedValue({}); + mockLearnerInfoService.create.mockResolvedValue({}); + + await service.processWebhook(payload); + + const appDto = mockApplicationsService.create.mock.calls[0][0]; + // proposedStartDate should be a YYYY-MM-DD string, not a Date + expect(typeof appDto.proposedStartDate).toBe('string'); + expect(appDto.proposedStartDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); + + describe('processWebhook - error handling', () => { + it('should propagate mapper errors for missing required fields', async () => { + const incompletePayload = { email: 'test@example.com' }; + + await expect(service.processWebhook(incompletePayload)).rejects.toThrow( + 'Missing required PandaDoc fields', + ); + + expect(mockApplicationsService.create).not.toHaveBeenCalled(); + }); + + it('should not create CandidateInfo or LearnerInfo if Application creation fails', async () => { + const payload = buildFullPayload(); + mockApplicationsService.create.mockRejectedValue( + new Error('Validation failed'), + ); + + await expect(service.processWebhook(payload)).rejects.toThrow( + 'Validation failed', + ); + + expect(mockCandidateInfoService.create).not.toHaveBeenCalled(); + expect(mockLearnerInfoService.create).not.toHaveBeenCalled(); + }); + + it('should propagate CandidateInfo errors after Application is created', async () => { + const payload = buildFullPayload(); + mockApplicationsService.create.mockResolvedValue({ appId: 99 }); + mockCandidateInfoService.create.mockRejectedValue( + new Error('Duplicate email'), + ); + + await expect(service.processWebhook(payload)).rejects.toThrow( + 'Duplicate email', + ); + + expect(mockLearnerInfoService.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts new file mode 100644 index 000000000..bbc5c597c --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -0,0 +1,117 @@ +import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { ApplicationsService } from '../applications/applications.service'; +import { CandidateInfoService } from '../candidate-info/candidate-info.service'; +import { LearnerInfoService } from '../learner-info/learner-info.service'; +import { pandadocMapper } from '../pandadoc-helpers/pandadoc-mapper'; +import { AppStatus, ApplicantType } from '../applications/types'; +import { CreateApplicationDto } from '../applications/dto/create-application.request.dto'; +import { CreateLearnerInfoDto } from '../learner-info/dto/create-learner-info.request.dto'; + +/** + * Orchestrates creation of Application, CandidateInfo, and LearnerInfo + * records from a PandaDoc webhook payload. + */ +@Injectable() +export class PandadocWebhookService { + private readonly logger = new Logger(PandadocWebhookService.name); + + constructor( + private readonly applicationsService: ApplicationsService, + private readonly candidateInfoService: CandidateInfoService, + private readonly learnerInfoService: LearnerInfoService, + ) {} + + /** + * Formats a Date object into a YYYY-MM-DD string. + * Returns the value as-is if it is already a string. + */ + private formatDate(value: unknown): string | undefined { + if (value == null) return undefined; + if (typeof value === 'string') return value; + if (value instanceof Date) { + const yyyy = value.getFullYear(); + const mm = String(value.getMonth() + 1).padStart(2, '0'); + const dd = String(value.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; + } + return String(value); + } + + /** + * Process a PandaDoc webhook payload: map fields, create all three records. + * + * @param payload - Raw PandaDoc webhook body (flat field id -> value record) + * @returns Object containing the created appId + */ + async processWebhook( + payload: Record, + ): Promise<{ appId: number }> { + this.logger.log('[PandaDoc] Received webhook payload'); + + // Run the raw payload through the field mapper + const buckets = pandadocMapper(payload); + this.logger.log( + `[PandaDoc] Mapped payload into buckets: application(${ + Object.keys(buckets.application).length + } fields), ` + + `candidateInfo(${Object.keys(buckets.candidateInfo).length} fields), ` + + `learnerInfo(${Object.keys(buckets.learnerInfo).length} fields)`, + ); + + // Set defaults for fields the mapper does not produce + buckets.application['appStatus'] = AppStatus.APP_SUBMITTED; + + // Derive applicantType: if schoolDepartment is present, it's a learner + buckets.application['applicantType'] = buckets.learnerInfo[ + 'schoolDepartment' + ] + ? ApplicantType.LEARNER + : ApplicantType.VOLUNTEER; + + // Convert Date objects to YYYY-MM-DD strings for DTO validation + if (buckets.application['proposedStartDate']) { + buckets.application['proposedStartDate'] = this.formatDate( + buckets.application['proposedStartDate'], + ); + } + if (buckets.application['endDate']) { + buckets.application['endDate'] = this.formatDate( + buckets.application['endDate'], + ); + } + if (buckets.learnerInfo['dateOfBirth']) { + buckets.learnerInfo['dateOfBirth'] = this.formatDate( + buckets.learnerInfo['dateOfBirth'], + ); + } + + this.logger.log( + `[PandaDoc] Creating application for email=${buckets.application['email']}`, + ); + + // 1. Create Application (generates appId) + const application = await this.applicationsService.create( + buckets.application as unknown as CreateApplicationDto, + ); + const { appId } = application; + this.logger.log(`[PandaDoc] Application created with appId=${appId}`); + + // 2. Create CandidateInfo + const email = String(buckets.candidateInfo['email'] ?? ''); + await this.candidateInfoService.create(appId, email); + this.logger.log(`[PandaDoc] CandidateInfo created for appId=${appId}`); + + // 3. Create LearnerInfo + const learnerDto = { + ...buckets.learnerInfo, + appId, + } as unknown as CreateLearnerInfoDto; + await this.learnerInfoService.create(learnerDto); + this.logger.log(`[PandaDoc] LearnerInfo created for appId=${appId}`); + + this.logger.log( + `[PandaDoc] Webhook processing complete for appId=${appId}`, + ); + return { appId }; + } +} From b5c124b72da83eef9dc39965ec39b2f2b7ace289 Mon Sep 17 00:00:00 2001 From: Rayyan Mridha <66543565+rayyanmridha@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:03:56 -0400 Subject: [PATCH 02/27] Remove unused BadRequestException import from webhook service Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index bbc5c597c..b8663b360 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, BadRequestException } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { ApplicationsService } from '../applications/applications.service'; import { CandidateInfoService } from '../candidate-info/candidate-info.service'; import { LearnerInfoService } from '../learner-info/learner-info.service'; From 0d49ebdd9a509e9df4b93dc9a8737cba6093148c Mon Sep 17 00:00:00 2001 From: Rayyan Mridha <66543565+rayyanmridha@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:04:48 -0400 Subject: [PATCH 03/27] Update example.env --- example.env | 2 ++ 1 file changed, 2 insertions(+) diff --git a/example.env b/example.env index 44bcba605..e78e60ef2 100644 --- a/example.env +++ b/example.env @@ -16,6 +16,8 @@ COGNITO_APP_CLIENT_ID= COGNITO_CLIENT_SECRET= VITE_COGNITO_USER_POOL_ID= VITE_COGNITO_REGION=us-east-2 +PANDADOC_WEBHOOK_KEY= + #Frontend Variable that uses app client with no secret requirement VITE_COGNITO_APP_CLIENT_ID= VITE_AWS_REGION=us-east-2 From 85c8d93066bb9fe318c0899d8a72553cb74b2d2f Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Tue, 19 May 2026 13:29:05 -0400 Subject: [PATCH 04/27] feat(pandadoc-webhook): add signature guard and drop creation-error filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The controller previously did the x-pandadoc-signature check inline and applied ApplicationCreationErrorFilter + ApplicationValidationEmailFilter intended for the human application-form route. Those filters use @Catch(Error), which swallowed UnauthorizedException and rewrote it as 500, and also emailed the applicant on every failure — wrong actor on a webhook where PandaDoc, not the applicant, is the caller. Move the signature check into PandadocSignatureGuard so 401s flow through Nest's default handler, and remove @UseFilters from the webhook controller. The filters remain on POST /applications where they belong. --- .../pandadoc-signature.guard.spec.ts | 58 +++++++++++++++++++ .../pandadoc-signature.guard.ts | 55 ++++++++++++++++++ .../pandadoc-webhook.controller.ts | 46 +++------------ 3 files changed, 120 insertions(+), 39 deletions(-) create mode 100644 apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts create mode 100644 apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts new file mode 100644 index 000000000..ac269266f --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts @@ -0,0 +1,58 @@ +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PandadocSignatureGuard } from './pandadoc-signature.guard'; + +function makeContext( + headers: Record, +): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ headers }), + }), + } as unknown as ExecutionContext; +} + +function buildGuard(key: string | undefined): PandadocSignatureGuard { + const configService = { + get: jest.fn().mockReturnValue(key), + } as unknown as ConfigService; + return new PandadocSignatureGuard(configService); +} + +describe('PandadocSignatureGuard', () => { + describe('when PANDADOC_WEBHOOK_KEY is set', () => { + const KEY = 'sandbox-key-abc123'; + + it('allows the request when signature matches', () => { + const guard = buildGuard(KEY); + const context = makeContext({ 'x-pandadoc-signature': KEY }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('rejects with UnauthorizedException when signature is missing', () => { + const guard = buildGuard(KEY); + const context = makeContext({}); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('rejects with UnauthorizedException when signature is wrong', () => { + const guard = buildGuard(KEY); + const context = makeContext({ 'x-pandadoc-signature': 'WRONG' }); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('handles array-valued header by checking the first entry', () => { + const guard = buildGuard(KEY); + const context = makeContext({ 'x-pandadoc-signature': [KEY, 'extra'] }); + expect(guard.canActivate(context)).toBe(true); + }); + }); + + describe('when PANDADOC_WEBHOOK_KEY is unset', () => { + it('allows the request and skips signature check', () => { + const guard = buildGuard(undefined); + const context = makeContext({}); + expect(guard.canActivate(context)).toBe(true); + }); + }); +}); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts new file mode 100644 index 000000000..60f741115 --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request } from 'express'; + +const SIGNATURE_HEADER = 'x-pandadoc-signature'; + +/** + * Verifies the `x-pandadoc-signature` header against the configured + * `PANDADOC_WEBHOOK_KEY`. If the env var is unset, the guard logs a warning + * once and allows requests through (useful for local dev). Otherwise the + * header must match exactly or the request is rejected with 401. + * + * Implemented as a guard so `UnauthorizedException` is handled by Nest's + * default 401 response rather than being intercepted by route-scoped + * `@Catch(Error)` exception filters. + */ +@Injectable() +export class PandadocSignatureGuard implements CanActivate { + private readonly logger = new Logger(PandadocSignatureGuard.name); + private readonly webhookKey: string | undefined; + private warnedAboutMissingKey = false; + + constructor(configService: ConfigService) { + this.webhookKey = configService.get('PANDADOC_WEBHOOK_KEY'); + } + + canActivate(context: ExecutionContext): boolean { + if (!this.webhookKey) { + if (!this.warnedAboutMissingKey) { + this.logger.warn( + 'PANDADOC_WEBHOOK_KEY is not set — webhook signature verification is disabled', + ); + this.warnedAboutMissingKey = true; + } + return true; + } + + const request = context.switchToHttp().getRequest(); + const signature = request.headers[SIGNATURE_HEADER]; + const provided = Array.isArray(signature) ? signature[0] : signature; + + if (!provided || provided !== this.webhookKey) { + this.logger.warn('[PandaDoc] Invalid or missing webhook signature'); + throw new UnauthorizedException('Invalid webhook signature'); + } + + return true; + } +} diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts index 4ac5a313c..319149b98 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts @@ -1,56 +1,24 @@ -import { - Controller, - Post, - Body, - Logger, - UnauthorizedException, - Headers, - UseFilters, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Body, Controller, Logger, Post, UseGuards } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { PandadocWebhookService } from './pandadoc-webhook.service'; -import { ApplicationValidationEmailFilter } from '../applications/filters/application-validation-email.filter'; -import { ApplicationCreationErrorFilter } from '../applications/filters/application-creation-validation.filter'; +import { PandadocSignatureGuard } from './pandadoc-signature.guard'; /** * Public endpoint that receives PandaDoc webhook events. - * No JWT auth — PandaDoc calls this externally. + * Authenticated by `x-pandadoc-signature` (see {@link PandadocSignatureGuard}), + * not by JWT — PandaDoc calls this externally. */ @ApiTags('PandaDoc Webhook') @Controller('pandadoc-webhook') +@UseGuards(PandadocSignatureGuard) export class PandadocWebhookController { private readonly logger = new Logger(PandadocWebhookController.name); - private readonly webhookKey: string | undefined; - constructor( - private readonly webhookService: PandadocWebhookService, - configService: ConfigService, - ) { - this.webhookKey = configService.get('PANDADOC_WEBHOOK_KEY'); - if (!this.webhookKey) { - this.logger.warn( - 'PANDADOC_WEBHOOK_KEY is not set — webhook signature verification is disabled', - ); - } - } + constructor(private readonly webhookService: PandadocWebhookService) {} @Post() - @UseFilters(ApplicationCreationErrorFilter, ApplicationValidationEmailFilter) - async handleWebhook( - @Body() body: Record, - @Headers('x-pandadoc-signature') signature?: string, - ) { + async handleWebhook(@Body() body: Record) { this.logger.log('[PandaDoc] Incoming webhook request'); - - // Verify webhook key if configured - if (this.webhookKey) { - if (!signature || signature !== this.webhookKey) { - this.logger.warn('[PandaDoc] Invalid or missing webhook signature'); - throw new UnauthorizedException('Invalid webhook signature'); - } - } - const result = await this.webhookService.processWebhook(body); return { status: 'ok', appId: result.appId }; } From 033e60ae570fda266e9982e83496994f9c043e5b Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Tue, 19 May 2026 13:29:43 -0400 Subject: [PATCH 05/27] refactor(pandadoc-webhook): wrap creates in a single transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the service called ApplicationsService.create, CandidateInfoService.create, and LearnerInfoService.create sequentially. Any failure mid-sequence — including the success-confirmation email inside ApplicationsService.create — left an Application row without its CandidateInfo / LearnerInfo siblings. Inject DataSource and run all three em.save calls inside dataSource.transaction so a failure rolls everything back. Drop the inter-service dependency from the module (now only ConfigModule is needed; entities are resolved via the global DataSource). Also harden formatDate to convert ISO-8601 strings to YYYY-MM-DD instead of returning them as-is. --- .../pandadoc-webhook.module.ts | 15 +- .../pandadoc-webhook.service.spec.ts | 255 +++++++++--------- .../pandadoc-webhook.service.ts | 151 ++++++----- 3 files changed, 213 insertions(+), 208 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts index 32dd0e6fd..f7de4699a 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts @@ -2,20 +2,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PandadocWebhookController } from './pandadoc-webhook.controller'; import { PandadocWebhookService } from './pandadoc-webhook.service'; -import { ApplicationsModule } from '../applications/applications.module'; -import { CandidateInfoModule } from '../candidate-info/candidate-info.module'; -import { LearnerInfoModule } from '../learner-info/learner-info.module'; -import { UtilModule } from '../util/util.module'; +import { PandadocSignatureGuard } from './pandadoc-signature.guard'; @Module({ - imports: [ - ConfigModule, - ApplicationsModule, - CandidateInfoModule, - LearnerInfoModule, - UtilModule, - ], + imports: [ConfigModule], controllers: [PandadocWebhookController], - providers: [PandadocWebhookService], + providers: [PandadocWebhookService, PandadocSignatureGuard], }) export class PandadocWebhookModule {} diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 6166126cc..0b734d8c8 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -1,27 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; import { PandadocWebhookService } from './pandadoc-webhook.service'; -import { ApplicationsService } from '../applications/applications.service'; -import { CandidateInfoService } from '../candidate-info/candidate-info.service'; -import { LearnerInfoService } from '../learner-info/learner-info.service'; import { AppStatus, ApplicantType } from '../applications/types'; -jest.mock('../util/aws-exports', () => ({ - __esModule: true, - default: { - AWSConfig: { - accessKeyId: 'test-access-key', - secretAccessKey: 'test-secret-key', - region: 'us-east-2', - bucket: 'bucket', - }, - CognitoAuthConfig: { - userPoolId: 'test-user-pool-id', - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - }, - }, -})); - function buildFullPayload(): Record { return { Volunteer_StartDate: '06-01-2026', @@ -62,154 +45,172 @@ function buildFullPayload(): Record { }; } -describe('PandadocWebhookService', () => { - let service: PandadocWebhookService; +interface Saved { + Application?: Record; + CandidateInfo?: Record; + LearnerInfo?: Record; +} - const mockApplicationsService = { - create: jest.fn(), - }; - const mockCandidateInfoService = { - create: jest.fn(), - }; - const mockLearnerInfoService = { - create: jest.fn(), - }; +function buildMockDataSource(opts: { + generatedAppId?: number; + failOn?: 'Application' | 'CandidateInfo' | 'LearnerInfo'; + saved: Saved; +}): DataSource { + const generatedAppId = opts.generatedAppId ?? 42; + + const em = { + create: ( + entityClass: new () => unknown, + data: Record, + ) => ({ __entity: entityClass.name, ...data }), + save: jest.fn( + async (entity: Record & { __entity: string }) => { + const name = entity.__entity; + if (opts.failOn === name) { + throw new Error(`Forced failure on ${name}`); + } + const stored = { ...entity }; + if (name === 'Application') { + stored.appId = generatedAppId; + } + opts.saved[name as keyof Saved] = stored; + return stored; + }, + ), + } as unknown as EntityManager; + + return { + transaction: async (cb: (em: EntityManager) => Promise) => cb(em), + } as unknown as DataSource; +} - beforeEach(async () => { +describe('PandadocWebhookService', () => { + async function buildService( + dataSource: DataSource, + ): Promise { const module: TestingModule = await Test.createTestingModule({ providers: [ PandadocWebhookService, - { provide: ApplicationsService, useValue: mockApplicationsService }, - { provide: CandidateInfoService, useValue: mockCandidateInfoService }, - { provide: LearnerInfoService, useValue: mockLearnerInfoService }, + { provide: getDataSourceToken(), useValue: dataSource }, ], }).compile(); + return module.get(PandadocWebhookService); + } - service = module.get(PandadocWebhookService); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should be defined', () => { + it('should be defined', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); expect(service).toBeDefined(); }); describe('processWebhook - happy path', () => { - it('should create all three records in order with correct appId', async () => { - const payload = buildFullPayload(); - mockApplicationsService.create.mockResolvedValue({ appId: 42 }); - mockCandidateInfoService.create.mockResolvedValue({ - appId: 42, - email: 'test@example.com', - }); - mockLearnerInfoService.create.mockResolvedValue({ appId: 42 }); + it('creates all three records in a single transaction with shared appId', async () => { + const saved: Saved = {}; + const dataSource = buildMockDataSource({ generatedAppId: 42, saved }); + const txSpy = jest.spyOn(dataSource, 'transaction'); + const service = await buildService(dataSource); - const result = await service.processWebhook(payload); + const result = await service.processWebhook(buildFullPayload()); expect(result).toEqual({ appId: 42 }); - - // Application created first - expect(mockApplicationsService.create).toHaveBeenCalledTimes(1); - const appDto = mockApplicationsService.create.mock.calls[0][0]; - expect(appDto.email).toBe('test@example.com'); - expect(appDto.appStatus).toBe(AppStatus.APP_SUBMITTED); - expect(appDto.phone).toBe('617-555-0199'); - - // CandidateInfo created with the returned appId - expect(mockCandidateInfoService.create).toHaveBeenCalledWith( - 42, - 'test@example.com', + expect(txSpy).toHaveBeenCalledTimes(1); + expect(saved.Application?.email).toBe('test@example.com'); + expect(saved.Application?.appStatus).toBe(AppStatus.APP_SUBMITTED); + expect(saved.Application?.phone).toBe('617-555-0199'); + expect(saved.CandidateInfo).toEqual( + expect.objectContaining({ appId: 42, email: 'test@example.com' }), ); - - // LearnerInfo created with the returned appId - expect(mockLearnerInfoService.create).toHaveBeenCalledTimes(1); - const learnerDto = mockLearnerInfoService.create.mock.calls[0][0]; - expect(learnerDto.appId).toBe(42); + expect(saved.LearnerInfo).toEqual(expect.objectContaining({ appId: 42 })); }); - it('should set applicantType to LEARNER when schoolDepartment is present', async () => { - const payload = buildFullPayload(); - mockApplicationsService.create.mockResolvedValue({ appId: 1 }); - mockCandidateInfoService.create.mockResolvedValue({}); - mockLearnerInfoService.create.mockResolvedValue({}); - - await service.processWebhook(payload); - - const appDto = mockApplicationsService.create.mock.calls[0][0]; - expect(appDto.applicantType).toBe(ApplicantType.LEARNER); + it('sets applicantType=LEARNER when schoolDepartment is present', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + await service.processWebhook(buildFullPayload()); + expect(saved.Application?.applicantType).toBe(ApplicantType.LEARNER); }); - it('should set applicantType to VOLUNTEER when schoolDepartment is empty', async () => { - const payload = { + it('sets applicantType=VOLUNTEER when schoolDepartment is empty', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + await service.processWebhook({ ...buildFullPayload(), Volunteer_Department: '', - }; - mockApplicationsService.create.mockResolvedValue({ appId: 1 }); - mockCandidateInfoService.create.mockResolvedValue({}); - mockLearnerInfoService.create.mockResolvedValue({}); - - await service.processWebhook(payload); + }); + expect(saved.Application?.applicantType).toBe(ApplicantType.VOLUNTEER); + }); - const appDto = mockApplicationsService.create.mock.calls[0][0]; - expect(appDto.applicantType).toBe(ApplicantType.VOLUNTEER); + it('formats proposedStartDate as YYYY-MM-DD', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + await service.processWebhook(buildFullPayload()); + expect(saved.Application?.proposedStartDate).toMatch( + /^\d{4}-\d{2}-\d{2}$/, + ); }); }); - describe('processWebhook - date conversion', () => { - it('should convert Date objects to YYYY-MM-DD strings', async () => { - const payload = buildFullPayload(); - mockApplicationsService.create.mockResolvedValue({ appId: 1 }); - mockCandidateInfoService.create.mockResolvedValue({}); - mockLearnerInfoService.create.mockResolvedValue({}); + describe('processWebhook - validation', () => { + it('throws for missing required PandaDoc fields', async () => { + const saved: Saved = {}; + const dataSource = buildMockDataSource({ saved }); + const txSpy = jest.spyOn(dataSource, 'transaction'); + const service = await buildService(dataSource); - await service.processWebhook(payload); + await expect( + service.processWebhook({ email: 'x@example.com' }), + ).rejects.toThrow('Missing required PandaDoc fields'); - const appDto = mockApplicationsService.create.mock.calls[0][0]; - // proposedStartDate should be a YYYY-MM-DD string, not a Date - expect(typeof appDto.proposedStartDate).toBe('string'); - expect(appDto.proposedStartDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(txSpy).not.toHaveBeenCalled(); }); - }); - describe('processWebhook - error handling', () => { - it('should propagate mapper errors for missing required fields', async () => { - const incompletePayload = { email: 'test@example.com' }; + it('throws BadRequestException for malformed phone number', async () => { + const saved: Saved = {}; + const dataSource = buildMockDataSource({ saved }); + const txSpy = jest.spyOn(dataSource, 'transaction'); + const service = await buildService(dataSource); - await expect(service.processWebhook(incompletePayload)).rejects.toThrow( - 'Missing required PandaDoc fields', + const payload = { ...buildFullPayload(), Volunteer_Phone: 'not-a-phone' }; + await expect(service.processWebhook(payload)).rejects.toThrow( + BadRequestException, ); - - expect(mockApplicationsService.create).not.toHaveBeenCalled(); + expect(txSpy).not.toHaveBeenCalled(); }); + }); - it('should not create CandidateInfo or LearnerInfo if Application creation fails', async () => { - const payload = buildFullPayload(); - mockApplicationsService.create.mockRejectedValue( - new Error('Validation failed'), + describe('processWebhook - transaction rollback', () => { + it('propagates the error when Application save fails', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ failOn: 'Application', saved }), ); - - await expect(service.processWebhook(payload)).rejects.toThrow( - 'Validation failed', + await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( + 'Forced failure on Application', ); - - expect(mockCandidateInfoService.create).not.toHaveBeenCalled(); - expect(mockLearnerInfoService.create).not.toHaveBeenCalled(); + expect(saved.Application).toBeUndefined(); + expect(saved.CandidateInfo).toBeUndefined(); + expect(saved.LearnerInfo).toBeUndefined(); }); - it('should propagate CandidateInfo errors after Application is created', async () => { - const payload = buildFullPayload(); - mockApplicationsService.create.mockResolvedValue({ appId: 99 }); - mockCandidateInfoService.create.mockRejectedValue( - new Error('Duplicate email'), + it('propagates the error when CandidateInfo save fails (transaction rolls back)', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ failOn: 'CandidateInfo', saved }), ); - - await expect(service.processWebhook(payload)).rejects.toThrow( - 'Duplicate email', + await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( + 'Forced failure on CandidateInfo', ); + expect(saved.LearnerInfo).toBeUndefined(); + }); - expect(mockLearnerInfoService.create).not.toHaveBeenCalled(); + it('propagates the error when LearnerInfo save fails (transaction rolls back)', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ failOn: 'LearnerInfo', saved }), + ); + await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( + 'Forced failure on LearnerInfo', + ); }); }); }); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index b8663b360..3a9d53f8e 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -1,46 +1,55 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ApplicationsService } from '../applications/applications.service'; -import { CandidateInfoService } from '../candidate-info/candidate-info.service'; -import { LearnerInfoService } from '../learner-info/learner-info.service'; +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; import { pandadocMapper } from '../pandadoc-helpers/pandadoc-mapper'; -import { AppStatus, ApplicantType } from '../applications/types'; -import { CreateApplicationDto } from '../applications/dto/create-application.request.dto'; -import { CreateLearnerInfoDto } from '../learner-info/dto/create-learner-info.request.dto'; +import { AppStatus, ApplicantType, PHONE_REGEX } from '../applications/types'; +import { Application } from '../applications/application.entity'; +import { CandidateInfo } from '../candidate-info/candidate-info.entity'; +import { LearnerInfo } from '../learner-info/learner-info.entity'; /** * Orchestrates creation of Application, CandidateInfo, and LearnerInfo * records from a PandaDoc webhook payload. + * + * All three inserts run inside a single TypeORM transaction so a failure + * in any step rolls back the others — preventing orphaned Application + * rows without their candidate/learner data. */ @Injectable() export class PandadocWebhookService { private readonly logger = new Logger(PandadocWebhookService.name); - constructor( - private readonly applicationsService: ApplicationsService, - private readonly candidateInfoService: CandidateInfoService, - private readonly learnerInfoService: LearnerInfoService, - ) {} + constructor(@InjectDataSource() private readonly dataSource: DataSource) {} /** - * Formats a Date object into a YYYY-MM-DD string. - * Returns the value as-is if it is already a string. + * Formats a Date object or ISO-8601 string into a YYYY-MM-DD string. + * Returns `undefined` when the input is null/undefined. */ private formatDate(value: unknown): string | undefined { if (value == null) return undefined; - if (typeof value === 'string') return value; - if (value instanceof Date) { - const yyyy = value.getFullYear(); - const mm = String(value.getMonth() + 1).padStart(2, '0'); - const dd = String(value.getDate()).padStart(2, '0'); - return `${yyyy}-${mm}-${dd}`; + if (value instanceof Date) return this.toYmd(value); + if (typeof value === 'string') { + // Already YYYY-MM-DD? leave as-is. + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value; + // Parse ISO or other date strings and reformat. + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : this.toYmd(parsed); } return String(value); } + private toYmd(date: Date): string { + const yyyy = date.getUTCFullYear(); + const mm = String(date.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(date.getUTCDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; + } + /** - * Process a PandaDoc webhook payload: map fields, create all three records. + * Process a PandaDoc webhook payload: map fields, create all three + * records inside a single transaction. * - * @param payload - Raw PandaDoc webhook body (flat field id -> value record) + * @param payload Raw PandaDoc webhook body (flat field id -> value record) * @returns Object containing the created appId */ async processWebhook( @@ -48,70 +57,74 @@ export class PandadocWebhookService { ): Promise<{ appId: number }> { this.logger.log('[PandaDoc] Received webhook payload'); - // Run the raw payload through the field mapper const buckets = pandadocMapper(payload); this.logger.log( `[PandaDoc] Mapped payload into buckets: application(${ Object.keys(buckets.application).length - } fields), ` + - `candidateInfo(${Object.keys(buckets.candidateInfo).length} fields), ` + - `learnerInfo(${Object.keys(buckets.learnerInfo).length} fields)`, + } fields), candidateInfo(${ + Object.keys(buckets.candidateInfo).length + } fields), learnerInfo(${ + Object.keys(buckets.learnerInfo).length + } fields)`, ); - // Set defaults for fields the mapper does not produce - buckets.application['appStatus'] = AppStatus.APP_SUBMITTED; + const applicationData = { + ...buckets.application, + appStatus: AppStatus.APP_SUBMITTED, + applicantType: buckets.learnerInfo['schoolDepartment'] + ? ApplicantType.LEARNER + : ApplicantType.VOLUNTEER, + proposedStartDate: this.formatDate( + buckets.application['proposedStartDate'], + ), + endDate: this.formatDate(buckets.application['endDate']), + }; + this.validatePhone(applicationData['phone']); - // Derive applicantType: if schoolDepartment is present, it's a learner - buckets.application['applicantType'] = buckets.learnerInfo[ - 'schoolDepartment' - ] - ? ApplicantType.LEARNER - : ApplicantType.VOLUNTEER; + const learnerData = { + ...buckets.learnerInfo, + dateOfBirth: this.formatDate(buckets.learnerInfo['dateOfBirth']), + }; - // Convert Date objects to YYYY-MM-DD strings for DTO validation - if (buckets.application['proposedStartDate']) { - buckets.application['proposedStartDate'] = this.formatDate( - buckets.application['proposedStartDate'], - ); - } - if (buckets.application['endDate']) { - buckets.application['endDate'] = this.formatDate( - buckets.application['endDate'], - ); - } - if (buckets.learnerInfo['dateOfBirth']) { - buckets.learnerInfo['dateOfBirth'] = this.formatDate( - buckets.learnerInfo['dateOfBirth'], - ); + const email = String(buckets.candidateInfo['email'] ?? ''); + if (!email.trim()) { + throw new BadRequestException('Webhook payload missing applicant email'); } - this.logger.log( - `[PandaDoc] Creating application for email=${buckets.application['email']}`, - ); + this.logger.log(`[PandaDoc] Creating application for email=${email}`); - // 1. Create Application (generates appId) - const application = await this.applicationsService.create( - buckets.application as unknown as CreateApplicationDto, - ); - const { appId } = application; - this.logger.log(`[PandaDoc] Application created with appId=${appId}`); + const appId = await this.dataSource.transaction( + async (em: EntityManager) => { + const application = em.create(Application, applicationData); + const saved = await em.save(application); - // 2. Create CandidateInfo - const email = String(buckets.candidateInfo['email'] ?? ''); - await this.candidateInfoService.create(appId, email); - this.logger.log(`[PandaDoc] CandidateInfo created for appId=${appId}`); + const candidate = em.create(CandidateInfo, { + appId: saved.appId, + email: email.trim(), + }); + await em.save(candidate); - // 3. Create LearnerInfo - const learnerDto = { - ...buckets.learnerInfo, - appId, - } as unknown as CreateLearnerInfoDto; - await this.learnerInfoService.create(learnerDto); - this.logger.log(`[PandaDoc] LearnerInfo created for appId=${appId}`); + const learner = em.create(LearnerInfo, { + ...learnerData, + appId: saved.appId, + }); + await em.save(learner); + + return saved.appId; + }, + ); this.logger.log( `[PandaDoc] Webhook processing complete for appId=${appId}`, ); return { appId }; } + + private validatePhone(phone: unknown): void { + if (typeof phone !== 'string' || !PHONE_REGEX.test(phone)) { + throw new BadRequestException( + 'Phone number must be in ###-###-#### format', + ); + } + } } From 342e1331371a8ec841722b8fbdbd166dee849cee Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:31:07 -0400 Subject: [PATCH 06/27] thorough logging --- .../pandadoc-signature.guard.ts | 36 ++- .../pandadoc-webhook.controller.ts | 51 +++- .../pandadoc-webhook.service.ts | 226 +++++++++++++----- 3 files changed, 250 insertions(+), 63 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts index 60f741115..52be0c46e 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts @@ -30,7 +30,18 @@ export class PandadocSignatureGuard implements CanActivate { this.webhookKey = configService.get('PANDADOC_WEBHOOK_KEY'); } + private maskSignature(signature: string | undefined): string { + if (!signature) return 'missing'; + if (signature.length <= 8) return '***'; + return `${signature.slice(0, 4)}...${signature.slice(-4)}`; + } + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const signature = request.headers[SIGNATURE_HEADER]; + const provided = Array.isArray(signature) ? signature[0] : signature; + const sourceIp = request.ip ?? request.socket?.remoteAddress ?? 'unknown'; + if (!this.webhookKey) { if (!this.warnedAboutMissingKey) { this.logger.warn( @@ -38,18 +49,35 @@ export class PandadocSignatureGuard implements CanActivate { ); this.warnedAboutMissingKey = true; } + this.logger.debug( + `[PandaDoc] Signature verification bypassed sourceIp=${sourceIp} reason=missing-config`, + ); return true; } - const request = context.switchToHttp().getRequest(); - const signature = request.headers[SIGNATURE_HEADER]; - const provided = Array.isArray(signature) ? signature[0] : signature; + this.logger.debug( + `[PandaDoc] Verifying signature sourceIp=${sourceIp} headerPresent=${Boolean( + provided, + )}`, + ); if (!provided || provided !== this.webhookKey) { - this.logger.warn('[PandaDoc] Invalid or missing webhook signature'); + this.logger.warn( + `[PandaDoc] Invalid or missing webhook signature sourceIp=${sourceIp} providedLength=${ + provided?.length ?? 0 + } providedMask=${this.maskSignature(provided)} expectedLength=${ + this.webhookKey.length + }`, + ); throw new UnauthorizedException('Invalid webhook signature'); } + this.logger.debug( + `[PandaDoc] Signature verified sourceIp=${sourceIp} signatureMask=${this.maskSignature( + provided, + )}`, + ); + return true; } } diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts index 319149b98..a7b34227d 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts @@ -16,10 +16,55 @@ export class PandadocWebhookController { constructor(private readonly webhookService: PandadocWebhookService) {} + private getBodyStringField( + body: Record, + key: string, + ): string | undefined { + const value = body[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + @Post() async handleWebhook(@Body() body: Record) { - this.logger.log('[PandaDoc] Incoming webhook request'); - const result = await this.webhookService.processWebhook(body); - return { status: 'ok', appId: result.appId }; + const startedAt = Date.now(); + const payloadKeys = Object.keys(body ?? {}); + const eventType = this.getBodyStringField(body, 'event') ?? 'unknown'; + const documentId = + this.getBodyStringField(body, 'document_id') ?? + this.getBodyStringField(body, 'id') ?? + 'unknown'; + + this.logger.log( + `[PandaDoc] Incoming webhook request event=${eventType} documentId=${documentId} payloadFieldCount=${payloadKeys.length}`, + ); + this.logger.debug( + `[PandaDoc] Payload key sample: ${ + payloadKeys.slice(0, 20).join(', ') || 'none' + }`, + ); + + try { + const result = await this.webhookService.processWebhook(body); + this.logger.log( + `[PandaDoc] Webhook request completed appId=${ + result.appId + } durationMs=${Date.now() - startedAt}`, + ); + return { status: 'ok', appId: result.appId }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const durationMs = Date.now() - startedAt; + if (error instanceof Error) { + this.logger.error( + `[PandaDoc] Webhook request failed event=${eventType} documentId=${documentId} durationMs=${durationMs} error=${message}`, + error.stack, + ); + } else { + this.logger.error( + `[PandaDoc] Webhook request failed event=${eventType} documentId=${documentId} durationMs=${durationMs} error=${message}`, + ); + } + throw error; + } } } diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 3a9d53f8e..3ab616aa2 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -21,6 +21,28 @@ export class PandadocWebhookService { constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + private maskEmail(email: string): string { + const [localPart, domain] = email.split('@'); + if (!localPart || !domain) return 'invalid-email'; + if (localPart.length <= 2) return `***@${domain}`; + return `${localPart.slice(0, 2)}***@${domain}`; + } + + private maskPhone(phone: unknown): string { + if (typeof phone !== 'string' || !phone.trim()) return 'missing'; + const digits = phone.replace(/\D/g, ''); + if (digits.length < 4) return '***'; + return `***-***-${digits.slice(-4)}`; + } + + private getPayloadString( + payload: Record, + key: string, + ): string | undefined { + const value = payload[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; + } + /** * Formats a Date object or ISO-8601 string into a YYYY-MM-DD string. * Returns `undefined` when the input is null/undefined. @@ -55,76 +77,168 @@ export class PandadocWebhookService { async processWebhook( payload: Record, ): Promise<{ appId: number }> { - this.logger.log('[PandaDoc] Received webhook payload'); + const startedAt = Date.now(); + const payloadKeys = Object.keys(payload ?? {}); + const eventType = this.getPayloadString(payload, 'event') ?? 'unknown'; + const documentId = + this.getPayloadString(payload, 'document_id') ?? + this.getPayloadString(payload, 'id') ?? + 'unknown'; - const buckets = pandadocMapper(payload); this.logger.log( - `[PandaDoc] Mapped payload into buckets: application(${ - Object.keys(buckets.application).length - } fields), candidateInfo(${ - Object.keys(buckets.candidateInfo).length - } fields), learnerInfo(${ - Object.keys(buckets.learnerInfo).length - } fields)`, + `[PandaDoc] Received webhook payload event=${eventType} documentId=${documentId} payloadFieldCount=${payloadKeys.length}`, + ); + this.logger.debug( + `[PandaDoc] Incoming payload key sample: ${ + payloadKeys.slice(0, 30).join(', ') || 'none' + }`, ); - const applicationData = { - ...buckets.application, - appStatus: AppStatus.APP_SUBMITTED, - applicantType: buckets.learnerInfo['schoolDepartment'] - ? ApplicantType.LEARNER - : ApplicantType.VOLUNTEER, - proposedStartDate: this.formatDate( - buckets.application['proposedStartDate'], - ), - endDate: this.formatDate(buckets.application['endDate']), - }; - this.validatePhone(applicationData['phone']); - - const learnerData = { - ...buckets.learnerInfo, - dateOfBirth: this.formatDate(buckets.learnerInfo['dateOfBirth']), - }; - - const email = String(buckets.candidateInfo['email'] ?? ''); - if (!email.trim()) { - throw new BadRequestException('Webhook payload missing applicant email'); - } - - this.logger.log(`[PandaDoc] Creating application for email=${email}`); - - const appId = await this.dataSource.transaction( - async (em: EntityManager) => { - const application = em.create(Application, applicationData); - const saved = await em.save(application); - - const candidate = em.create(CandidateInfo, { - appId: saved.appId, - email: email.trim(), - }); - await em.save(candidate); + try { + this.logger.debug( + '[PandaDoc] Mapping webhook payload into persistence buckets', + ); + const buckets = pandadocMapper(payload); + this.logger.log( + `[PandaDoc] Mapped payload into buckets: application(${ + Object.keys(buckets.application).length + } fields), candidateInfo(${ + Object.keys(buckets.candidateInfo).length + } fields), learnerInfo(${ + Object.keys(buckets.learnerInfo).length + } fields)`, + ); - const learner = em.create(LearnerInfo, { - ...learnerData, - appId: saved.appId, - }); - await em.save(learner); + const applicantType = buckets.learnerInfo['schoolDepartment'] + ? ApplicantType.LEARNER + : ApplicantType.VOLUNTEER; + + const applicationData = { + ...buckets.application, + appStatus: AppStatus.APP_SUBMITTED, + applicantType, + proposedStartDate: this.formatDate( + buckets.application['proposedStartDate'], + ), + endDate: this.formatDate(buckets.application['endDate']), + }; + + this.logger.debug( + `[PandaDoc] Prepared application record applicantType=${applicantType} phoneMask=${this.maskPhone( + applicationData['phone'], + )}`, + ); + this.validatePhone(applicationData['phone']); + + const learnerData = { + ...buckets.learnerInfo, + dateOfBirth: this.formatDate(buckets.learnerInfo['dateOfBirth']), + }; + + const email = String(buckets.candidateInfo['email'] ?? ''); + const normalizedEmail = email.trim(); + if (!normalizedEmail) { + this.logger.warn( + `[PandaDoc] Candidate email missing after mapping event=${eventType} documentId=${documentId}`, + ); + throw new BadRequestException( + 'Webhook payload missing applicant email', + ); + } + + this.logger.log( + `[PandaDoc] Creating application transaction emailMask=${this.maskEmail( + normalizedEmail, + )} applicantType=${applicantType}`, + ); - return saved.appId; - }, - ); + const appId = await this.dataSource.transaction( + async (em: EntityManager) => { + this.logger.debug('[PandaDoc] Transaction started'); + + this.logger.debug('[PandaDoc] Persisting Application entity'); + const application = em.create(Application, applicationData); + const saved = await em.save(application); + this.logger.debug( + `[PandaDoc] Application saved appId=${saved.appId}`, + ); + + this.logger.debug( + `[PandaDoc] Persisting CandidateInfo entity appId=${ + saved.appId + } emailMask=${this.maskEmail(normalizedEmail)}`, + ); + const candidate = em.create(CandidateInfo, { + appId: saved.appId, + email: normalizedEmail, + }); + await em.save(candidate); + this.logger.debug( + `[PandaDoc] CandidateInfo saved appId=${saved.appId}`, + ); + + this.logger.debug( + `[PandaDoc] Persisting LearnerInfo entity appId=${ + saved.appId + } learnerFieldCount=${Object.keys(learnerData).length}`, + ); + const learner = em.create(LearnerInfo, { + ...learnerData, + appId: saved.appId, + }); + await em.save(learner); + this.logger.debug( + `[PandaDoc] LearnerInfo saved appId=${saved.appId}`, + ); + + this.logger.debug( + `[PandaDoc] Transaction complete appId=${saved.appId}`, + ); + return saved.appId; + }, + ); - this.logger.log( - `[PandaDoc] Webhook processing complete for appId=${appId}`, - ); - return { appId }; + this.logger.log( + `[PandaDoc] Webhook processing complete appId=${appId} durationMs=${ + Date.now() - startedAt + }`, + ); + return { appId }; + } catch (error) { + const durationMs = Date.now() - startedAt; + const message = error instanceof Error ? error.message : String(error); + + if (error instanceof BadRequestException) { + this.logger.warn( + `[PandaDoc] Webhook rejected event=${eventType} documentId=${documentId} durationMs=${durationMs} reason=${message}`, + ); + } else if (error instanceof Error) { + this.logger.error( + `[PandaDoc] Webhook processing failed event=${eventType} documentId=${documentId} durationMs=${durationMs} error=${message}`, + error.stack, + ); + } else { + this.logger.error( + `[PandaDoc] Webhook processing failed event=${eventType} documentId=${documentId} durationMs=${durationMs} error=${message}`, + ); + } + + throw error; + } } private validatePhone(phone: unknown): void { if (typeof phone !== 'string' || !PHONE_REGEX.test(phone)) { + this.logger.warn( + `[PandaDoc] Phone validation failed phoneMask=${this.maskPhone(phone)}`, + ); throw new BadRequestException( 'Phone number must be in ###-###-#### format', ); } + + this.logger.debug( + `[PandaDoc] Phone validation passed phoneMask=${this.maskPhone(phone)}`, + ); } } From 93431828241b82f2169222313032488807307dd2 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:41:11 -0400 Subject: [PATCH 07/27] logging entire request that comes from pandadoc --- .../pandadoc-signature.guard.ts | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts index 52be0c46e..8cd1245d8 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts @@ -36,8 +36,36 @@ export class PandadocSignatureGuard implements CanActivate { return `${signature.slice(0, 4)}...${signature.slice(-4)}`; } + private logIncomingRequestSnapshot(request: Request): void { + try { + const snapshot = { + method: request.method, + originalUrl: request.originalUrl, + url: request.url, + baseUrl: request.baseUrl, + path: request.path, + ip: request.ip, + params: request.params, + query: request.query, + headers: request.headers, + body: request.body, + }; + + this.logger.debug( + `[PandaDoc] Incoming request snapshot: ${JSON.stringify(snapshot)}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn( + `[PandaDoc] Failed to serialize incoming request snapshot error=${message}`, + ); + } + } + canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); + this.logIncomingRequestSnapshot(request); + const signature = request.headers[SIGNATURE_HEADER]; const provided = Array.isArray(signature) ? signature[0] : signature; const sourceIp = request.ip ?? request.socket?.remoteAddress ?? 'unknown'; From 1f175c71702692189d020da7a4ccb1c55d21fe83 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:05:18 -0400 Subject: [PATCH 08/27] Useing HMAC-SHA256 to compare keys as that's what pandadoc is sending --- apps/backend/src/main.ts | 2 +- .../pandadoc-signature.guard.spec.ts | 62 ++++++++++---- .../pandadoc-signature.guard.ts | 80 +++++++++++++------ 3 files changed, 105 insertions(+), 39 deletions(-) diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index a96db06b8..1a172ea9b 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -11,7 +11,7 @@ import { AppModule } from './app.module'; import { TypeOrmExceptionFilter } from './filters/typeorm-exception.filter'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { rawBody: true }); app.enableCors(); const globalPrefix = 'api'; diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts index ac269266f..ae7fb17ce 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts @@ -1,13 +1,39 @@ +import { createHmac } from 'crypto'; import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PandadocSignatureGuard } from './pandadoc-signature.guard'; -function makeContext( - headers: Record, -): ExecutionContext { +const KEY = 'sandbox-key-abc123'; +const BODY = Buffer.from(JSON.stringify([{ event: 'document_completed' }])); + +function hmac(key: string, body: Buffer): string { + return createHmac('sha256', key).update(body).digest('hex'); +} + +function makeContext(opts: { + querySignature?: string; + rawBody?: Buffer; + headers?: Record; +}): ExecutionContext { return { switchToHttp: () => ({ - getRequest: () => ({ headers }), + getRequest: () => ({ + method: 'POST', + originalUrl: '/api/pandadoc-webhook', + url: '/api/pandadoc-webhook', + baseUrl: '', + path: '/api/pandadoc-webhook', + ip: '127.0.0.1', + params: {}, + query: + opts.querySignature !== undefined + ? { signature: opts.querySignature } + : {}, + headers: opts.headers ?? { 'content-type': 'application/json' }, + body: {}, + rawBody: opts.rawBody ?? Buffer.alloc(0), + socket: { remoteAddress: '127.0.0.1' }, + }), }), } as unknown as ExecutionContext; } @@ -21,30 +47,38 @@ function buildGuard(key: string | undefined): PandadocSignatureGuard { describe('PandadocSignatureGuard', () => { describe('when PANDADOC_WEBHOOK_KEY is set', () => { - const KEY = 'sandbox-key-abc123'; - - it('allows the request when signature matches', () => { + it('allows the request when HMAC-SHA256 signature matches', () => { const guard = buildGuard(KEY); - const context = makeContext({ 'x-pandadoc-signature': KEY }); + const context = makeContext({ + querySignature: hmac(KEY, BODY), + rawBody: BODY, + }); expect(guard.canActivate(context)).toBe(true); }); - it('rejects with UnauthorizedException when signature is missing', () => { + it('rejects with UnauthorizedException when signature query param is absent', () => { const guard = buildGuard(KEY); - const context = makeContext({}); + const context = makeContext({ rawBody: BODY }); expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); }); it('rejects with UnauthorizedException when signature is wrong', () => { const guard = buildGuard(KEY); - const context = makeContext({ 'x-pandadoc-signature': 'WRONG' }); + const context = makeContext({ + querySignature: 'deadbeef', + rawBody: BODY, + }); expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); }); - it('handles array-valued header by checking the first entry', () => { + it('rejects when signature computed from a different body', () => { const guard = buildGuard(KEY); - const context = makeContext({ 'x-pandadoc-signature': [KEY, 'extra'] }); - expect(guard.canActivate(context)).toBe(true); + const otherBody = Buffer.from(JSON.stringify([{ event: 'other' }])); + const context = makeContext({ + querySignature: hmac(KEY, otherBody), + rawBody: BODY, + }); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); }); }); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts index 8cd1245d8..b16aacbb6 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts @@ -6,19 +6,17 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { createHmac, timingSafeEqual } from 'crypto'; import { Request } from 'express'; -const SIGNATURE_HEADER = 'x-pandadoc-signature'; - /** - * Verifies the `x-pandadoc-signature` header against the configured - * `PANDADOC_WEBHOOK_KEY`. If the env var is unset, the guard logs a warning - * once and allows requests through (useful for local dev). Otherwise the - * header must match exactly or the request is rejected with 401. + * PandaDoc sends an HMAC-SHA256 hex digest of the raw JSON payload as a + * `?signature=` query parameter (not a header). The guard recomputes the + * expected digest from the raw body bytes captured by NestJS (`rawBody: true` + * in `NestFactory.create`) and performs a timing-safe comparison. * - * Implemented as a guard so `UnauthorizedException` is handled by Nest's - * default 401 response rather than being intercepted by route-scoped - * `@Catch(Error)` exception filters. + * If PANDADOC_WEBHOOK_KEY is unset the guard logs a one-time warning and + * allows the request through so local development still works. */ @Injectable() export class PandadocSignatureGuard implements CanActivate { @@ -30,10 +28,10 @@ export class PandadocSignatureGuard implements CanActivate { this.webhookKey = configService.get('PANDADOC_WEBHOOK_KEY'); } - private maskSignature(signature: string | undefined): string { - if (!signature) return 'missing'; - if (signature.length <= 8) return '***'; - return `${signature.slice(0, 4)}...${signature.slice(-4)}`; + private maskHex(value: string | undefined): string { + if (!value) return 'missing'; + if (value.length <= 8) return '***'; + return `${value.slice(0, 6)}...${value.slice(-6)}`; } private logIncomingRequestSnapshot(request: Request): void { @@ -62,12 +60,21 @@ export class PandadocSignatureGuard implements CanActivate { } } + private computeExpectedSignature(rawBody: Buffer): string { + return createHmac('sha256', this.webhookKey!).update(rawBody).digest('hex'); + } + + private signaturesMatch(expected: string, provided: string): boolean { + const expectedBuf = Buffer.from(expected, 'utf8'); + const providedBuf = Buffer.from(provided, 'utf8'); + if (expectedBuf.length !== providedBuf.length) return false; + return timingSafeEqual(expectedBuf, providedBuf); + } + canActivate(context: ExecutionContext): boolean { const request = context.switchToHttp().getRequest(); this.logIncomingRequestSnapshot(request); - const signature = request.headers[SIGNATURE_HEADER]; - const provided = Array.isArray(signature) ? signature[0] : signature; const sourceIp = request.ip ?? request.socket?.remoteAddress ?? 'unknown'; if (!this.webhookKey) { @@ -83,27 +90,52 @@ export class PandadocSignatureGuard implements CanActivate { return true; } + const provided = + typeof request.query['signature'] === 'string' + ? request.query['signature'] + : undefined; + this.logger.debug( - `[PandaDoc] Verifying signature sourceIp=${sourceIp} headerPresent=${Boolean( + `[PandaDoc] Verifying HMAC-SHA256 signature sourceIp=${sourceIp} signaturePresent=${Boolean( provided, - )}`, + )} signatureLength=${provided?.length ?? 0}`, ); - if (!provided || provided !== this.webhookKey) { + if (!provided) { + this.logger.warn( + `[PandaDoc] Missing signature query param sourceIp=${sourceIp} url=${request.originalUrl}`, + ); + throw new UnauthorizedException('Missing webhook signature'); + } + + const rawBody: Buffer | undefined = ( + request as unknown as { rawBody?: Buffer } + ).rawBody; + + if (!rawBody) { + this.logger.error( + '[PandaDoc] rawBody is unavailable — ensure rawBody:true is set in NestFactory.create()', + ); + throw new UnauthorizedException('Cannot verify webhook signature'); + } + + const expected = this.computeExpectedSignature(rawBody); + + if (!this.signaturesMatch(expected, provided)) { this.logger.warn( - `[PandaDoc] Invalid or missing webhook signature sourceIp=${sourceIp} providedLength=${ - provided?.length ?? 0 - } providedMask=${this.maskSignature(provided)} expectedLength=${ - this.webhookKey.length + `[PandaDoc] Signature mismatch sourceIp=${sourceIp} providedMask=${this.maskHex( + provided, + )} expectedMask=${this.maskHex(expected)} rawBodyLength=${ + rawBody.length }`, ); throw new UnauthorizedException('Invalid webhook signature'); } this.logger.debug( - `[PandaDoc] Signature verified sourceIp=${sourceIp} signatureMask=${this.maskSignature( + `[PandaDoc] Signature verified sourceIp=${sourceIp} signatureMask=${this.maskHex( provided, - )}`, + )} rawBodyLength=${rawBody.length}`, ); return true; From 82519fc2b60f5c7288bd53fdc4d913631644df3e Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:49:42 -0400 Subject: [PATCH 09/27] feat: fetch PandaDoc fields via API and create application on webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the webhook handler tried to read form field values directly from the webhook payload, but PandaDoc only sends event metadata (doc ID, event type) — not field values. This commit wires up the full flow: - Receive webhook → extract document ID from array payload format - Call PandaDoc fields API (GET /documents/{id}/fields) with PANDADOC_API_KEY - Map fields by field_id (not name — name is a generic type like "Text") - Inject email and phone from assigned_to recipient metadata since they are not form fields - Coalesce upload slots: *1 = supervisor flow, *2 = applicant flow; fall back to *1 when *2 is empty so both submission paths work - Normalize Volunteer_Discipline label to kebab-case key to satisfy the discipline catalog FK constraint - Make Volunteer_Phone optional (form label exists but no input field in the current PandaDoc template) - Convert missing-required-fields mapper errors to 400 Bad Request instead of 500 Tested against all 8 completed PandaDoc documents: 6 real submissions create applications successfully, 2 blank/junk test forms return 400. --- .../pandadoc-helpers/pandadoc-field-map.ts | 16 +- .../pandadoc-webhook.controller.ts | 32 +--- .../pandadoc-webhook.service.spec.ts | 94 ++++++++++++ .../pandadoc-webhook.service.ts | 145 +++++++++++++++++- example.env | 1 + 5 files changed, 253 insertions(+), 35 deletions(-) diff --git a/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts b/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts index 6fc2366ff..01f496e04 100644 --- a/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts +++ b/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts @@ -76,6 +76,18 @@ function normalizeSchoolLabel(value: string): string { .replace(/[^a-z0-9]/g, ''); } +/** + * Convert a PandaDoc discipline label to the kebab-case key stored in the + * discipline catalog table (e.g. "Psychiatry or Psychiatric NP/PA" → + * "psychiatry-or-psychiatric-np-pa"). + */ +function normalizeDisciplineKey(value: string): string { + return String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + const LEGACY_SCHOOL_ALIASES: Array<[string, School]> = [ [ 'BMC School of Medicine - Center for Multicultural Training in Psychology', @@ -185,7 +197,8 @@ export const PANDADOC_FIELD_MAP: ValidPayload[] = [ { pandaDocKey: 'Volunteer_Phone', backendField: 'phone', - required: true, + required: false, + defaultValue: '', targetTable: 'application', }, { @@ -203,6 +216,7 @@ export const PANDADOC_FIELD_MAP: ValidPayload[] = [ { pandaDocKey: 'Volunteer_Discipline', backendField: 'discipline', + transform: (value: string) => normalizeDisciplineKey(value), required: true, targetTable: 'application', }, diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts index a7b34227d..dc074b03c 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts @@ -16,35 +16,13 @@ export class PandadocWebhookController { constructor(private readonly webhookService: PandadocWebhookService) {} - private getBodyStringField( - body: Record, - key: string, - ): string | undefined { - const value = body[key]; - return typeof value === 'string' && value.trim() ? value.trim() : undefined; - } - @Post() - async handleWebhook(@Body() body: Record) { + async handleWebhook(@Body() body: unknown) { const startedAt = Date.now(); - const payloadKeys = Object.keys(body ?? {}); - const eventType = this.getBodyStringField(body, 'event') ?? 'unknown'; - const documentId = - this.getBodyStringField(body, 'document_id') ?? - this.getBodyStringField(body, 'id') ?? - 'unknown'; - - this.logger.log( - `[PandaDoc] Incoming webhook request event=${eventType} documentId=${documentId} payloadFieldCount=${payloadKeys.length}`, - ); - this.logger.debug( - `[PandaDoc] Payload key sample: ${ - payloadKeys.slice(0, 20).join(', ') || 'none' - }`, - ); + this.logger.log('[PandaDoc] Incoming webhook request'); try { - const result = await this.webhookService.processWebhook(body); + const result = await this.webhookService.handleIncomingWebhook(body); this.logger.log( `[PandaDoc] Webhook request completed appId=${ result.appId @@ -56,12 +34,12 @@ export class PandadocWebhookController { const durationMs = Date.now() - startedAt; if (error instanceof Error) { this.logger.error( - `[PandaDoc] Webhook request failed event=${eventType} documentId=${documentId} durationMs=${durationMs} error=${message}`, + `[PandaDoc] Webhook request failed durationMs=${durationMs} error=${message}`, error.stack, ); } else { this.logger.error( - `[PandaDoc] Webhook request failed event=${eventType} documentId=${documentId} durationMs=${durationMs} error=${message}`, + `[PandaDoc] Webhook request failed durationMs=${durationMs} error=${message}`, ); } throw error; diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 0b734d8c8..988e7fec0 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -1,10 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getDataSourceToken } from '@nestjs/typeorm'; import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; import { DataSource, EntityManager } from 'typeorm'; import { PandadocWebhookService } from './pandadoc-webhook.service'; import { AppStatus, ApplicantType } from '../applications/types'; +jest.mock('axios'); + function buildFullPayload(): Record { return { Volunteer_StartDate: '06-01-2026', @@ -84,25 +88,115 @@ function buildMockDataSource(opts: { } as unknown as DataSource; } +const mockedAxios = axios as jest.Mocked; + +function buildMockConfigService(apiKey = 'test-api-key'): ConfigService { + return { + get: jest.fn((key: string) => { + if (key === 'PANDADOC_API_KEY') return apiKey; + return undefined; + }), + } as unknown as ConfigService; +} + +function buildWebhookEvent(documentId = 'doc-abc123') { + return [{ event: 'document_completed_pdf_ready', data: { id: documentId } }]; +} + describe('PandadocWebhookService', () => { async function buildService( dataSource: DataSource, + configService?: ConfigService, ): Promise { const module: TestingModule = await Test.createTestingModule({ providers: [ PandadocWebhookService, { provide: getDataSourceToken(), useValue: dataSource }, + { + provide: ConfigService, + useValue: configService ?? buildMockConfigService(), + }, ], }).compile(); return module.get(PandadocWebhookService); } + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', async () => { const saved: Saved = {}; const service = await buildService(buildMockDataSource({ saved })); expect(service).toBeDefined(); }); + describe('handleIncomingWebhook', () => { + it('fetches fields from PandaDoc API and creates the application', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ generatedAppId: 99, saved }), + ); + + mockedAxios.get = jest.fn().mockResolvedValue({ + data: { + fields: Object.entries(buildFullPayload()).map( + ([field_id, value]) => ({ + field_id, + value, + assigned_to: { email: 'test@example.com' }, + }), + ), + }, + }); + + const result = await service.handleIncomingWebhook( + buildWebhookEvent('doc-xyz'), + ); + + expect(result).toEqual({ appId: 99 }); + expect(mockedAxios.get).toHaveBeenCalledWith( + expect.stringContaining('/documents/doc-xyz/fields'), + expect.objectContaining({ + headers: { Authorization: 'API-Key test-api-key' }, + }), + ); + expect(saved.Application?.email).toBe('test@example.com'); + }); + + it('throws BadRequestException when payload is not an array of events', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + + await expect(service.handleIncomingWebhook({})).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when data.id is missing', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + + await expect( + service.handleIncomingWebhook([ + { event: 'document_completed_pdf_ready', data: {} }, + ]), + ).rejects.toThrow('document ID'); + }); + + it('throws InternalServerErrorException when PANDADOC_API_KEY is not set', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ saved }), + buildMockConfigService(''), + ); + + await expect( + service.handleIncomingWebhook(buildWebhookEvent()), + ).rejects.toThrow('PandaDoc API key is not configured'); + }); + }); + describe('processWebhook - happy path', () => { it('creates all three records in a single transaction with shared appId', async () => { const saved: Saved = {}; diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 3ab616aa2..9e8df1c65 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -1,5 +1,12 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; +import axios from 'axios'; import { DataSource, EntityManager } from 'typeorm'; import { pandadocMapper } from '../pandadoc-helpers/pandadoc-mapper'; import { AppStatus, ApplicantType, PHONE_REGEX } from '../applications/types'; @@ -7,6 +14,8 @@ import { Application } from '../applications/application.entity'; import { CandidateInfo } from '../candidate-info/candidate-info.entity'; import { LearnerInfo } from '../learner-info/learner-info.entity'; +const PANDADOC_API_BASE = 'https://api.pandadoc.com/public/v1'; + /** * Orchestrates creation of Application, CandidateInfo, and LearnerInfo * records from a PandaDoc webhook payload. @@ -19,7 +28,10 @@ import { LearnerInfo } from '../learner-info/learner-info.entity'; export class PandadocWebhookService { private readonly logger = new Logger(PandadocWebhookService.name); - constructor(@InjectDataSource() private readonly dataSource: DataSource) {} + constructor( + @InjectDataSource() private readonly dataSource: DataSource, + private readonly configService: ConfigService, + ) {} private maskEmail(email: string): string { const [localPart, domain] = email.split('@'); @@ -68,10 +80,119 @@ export class PandadocWebhookService { } /** - * Process a PandaDoc webhook payload: map fields, create all three - * records inside a single transaction. + * Entry point called by the controller. Receives the raw PandaDoc webhook + * body (an array of events), extracts the document ID, fetches the field + * values from the PandaDoc API, then delegates to `processWebhook`. + * + * PandaDoc webhook format: [{ event: string, data: { id: string, ... } }] + */ + async handleIncomingWebhook(rawBody: unknown): Promise<{ appId: number }> { + const documentId = this.extractDocumentId(rawBody); + this.logger.log( + `[PandaDoc] Extracted documentId=${documentId} from webhook event`, + ); + + const fields = await this.fetchDocumentFields(documentId); + this.logger.log( + `[PandaDoc] Fetched ${ + Object.keys(fields).length + } fields for documentId=${documentId}`, + ); + + return this.processWebhook(fields); + } + + private extractDocumentId(rawBody: unknown): string { + const events = Array.isArray(rawBody) ? rawBody : [rawBody]; + const firstEvent = events[0]; + + if (!firstEvent || typeof firstEvent !== 'object') { + throw new BadRequestException( + 'Invalid webhook payload: expected an array of events', + ); + } + + const event = firstEvent as Record; + const data = event['data']; + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new BadRequestException('Webhook event missing data field'); + } + + const id = (data as Record)['id']; + if (typeof id !== 'string' || !id.trim()) { + throw new BadRequestException( + 'Webhook event missing document ID in data.id', + ); + } + + return id.trim(); + } + + private async fetchDocumentFields( + documentId: string, + ): Promise> { + const apiKey = this.configService.get('PANDADOC_API_KEY'); + if (!apiKey) { + this.logger.error('[PandaDoc] PANDADOC_API_KEY is not configured'); + throw new InternalServerErrorException( + 'PandaDoc API key is not configured', + ); + } + + this.logger.debug( + `[PandaDoc] Fetching fields from API documentId=${documentId}`, + ); + + const response = await axios.get<{ + fields: Array<{ + field_id: string; + value: unknown; + assigned_to?: { email?: string; phone?: string }; + }>; + }>(`${PANDADOC_API_BASE}/documents/${documentId}/fields`, { + headers: { Authorization: `API-Key ${apiKey}` }, + }); + + const fields = response.data?.fields ?? []; + this.logger.debug( + `[PandaDoc] API returned ${fields.length} fields for documentId=${documentId}`, + ); + + const result = Object.fromEntries(fields.map((f) => [f.field_id, f.value])); + + // Email and phone are not form fields — inject from recipient assigned_to. + // PandaDoc populates one or the other depending on how the doc was sent. + const recipient = fields[0]?.assigned_to; + if (!result['email'] && recipient?.email) { + result['email'] = recipient.email; + this.logger.debug(`[PandaDoc] Injected email from recipient assigned_to`); + } + if (!result['Volunteer_Phone'] && recipient?.phone) { + result['Volunteer_Phone'] = recipient.phone; + this.logger.debug(`[PandaDoc] Injected phone from recipient assigned_to`); + } + + // Resume and cover letter each have two upload slots (*1 = supervisor, *2 = applicant). + // Coalesce so the mapper always sees the value regardless of which slot was used. + const isEmpty = (v: unknown) => + !v || (typeof v === 'object' && Object.keys(v as object).length === 0); + if (isEmpty(result['Volunteer_ResumeUpload2'])) { + result['Volunteer_ResumeUpload2'] = result['Volunteer_ResumeUpload1']; + } + if (isEmpty(result['Volunteer_CoverletterUpload2'])) { + result['Volunteer_CoverletterUpload2'] = + result['Volunteer_CoverletterUpload1']; + } + + return result; + } + + /** + * Process a flat PandaDoc field map: map fields into persistence buckets and + * create all three records inside a single transaction. * - * @param payload Raw PandaDoc webhook body (flat field id -> value record) + * @param payload Flat field id -> value record (e.g. from the PandaDoc fields API) * @returns Object containing the created appId */ async processWebhook( @@ -98,7 +219,16 @@ export class PandadocWebhookService { this.logger.debug( '[PandaDoc] Mapping webhook payload into persistence buckets', ); - const buckets = pandadocMapper(payload); + let buckets: ReturnType; + try { + buckets = pandadocMapper(payload); + } catch (mapErr) { + const msg = mapErr instanceof Error ? mapErr.message : String(mapErr); + if (msg.startsWith('Missing required PandaDoc fields')) { + throw new BadRequestException(msg); + } + throw mapErr; + } this.logger.log( `[PandaDoc] Mapped payload into buckets: application(${ Object.keys(buckets.application).length @@ -228,7 +358,8 @@ export class PandadocWebhookService { } private validatePhone(phone: unknown): void { - if (typeof phone !== 'string' || !PHONE_REGEX.test(phone)) { + if (typeof phone !== 'string' || phone === '') return; + if (!PHONE_REGEX.test(phone)) { this.logger.warn( `[PandaDoc] Phone validation failed phoneMask=${this.maskPhone(phone)}`, ); diff --git a/example.env b/example.env index 8c1667d00..3701b4ffb 100644 --- a/example.env +++ b/example.env @@ -18,6 +18,7 @@ COGNITO_CLIENT_SECRET= VITE_COGNITO_USER_POOL_ID= VITE_COGNITO_REGION=us-east-2 PANDADOC_WEBHOOK_KEY= +PANDADOC_API_KEY= #Frontend Variable that uses app client with no secret requirement VITE_COGNITO_APP_CLIENT_ID= From 63b19c5d1233ebbdc4c1fd3ba1efba99724a6f35 Mon Sep 17 00:00:00 2001 From: Owen Stepan <106773727+ostepan8@users.noreply.github.com> Date: Fri, 12 Jun 2026 14:12:06 -0400 Subject: [PATCH 10/27] fix: create User record on PandaDoc webhook so names show in admin UI Extract first_name/last_name from PandaDoc recipient assigned_to metadata (same source as email/phone) and upsert a User row inside the transaction. Without this the frontend fell back to displaying raw email addresses because useApplications.ts resolves display names via the users table. Tested end-to-end: fired a signed webhook for a real completed doc, confirmed Application + CandidateInfo + LearnerInfo + User (Sam Nie) all created atomically. --- .../pandadoc-webhook.service.spec.ts | 59 ++++++++++++++++++- .../pandadoc-webhook.service.ts | 54 ++++++++++++++++- 2 files changed, 109 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 988e7fec0..3c515a0c0 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -53,12 +53,14 @@ interface Saved { Application?: Record; CandidateInfo?: Record; LearnerInfo?: Record; + User?: Record; } function buildMockDataSource(opts: { generatedAppId?: number; - failOn?: 'Application' | 'CandidateInfo' | 'LearnerInfo'; + failOn?: 'Application' | 'CandidateInfo' | 'LearnerInfo' | 'User'; saved: Saved; + existingUser?: Record | null; }): DataSource { const generatedAppId = opts.generatedAppId ?? 42; @@ -81,6 +83,7 @@ function buildMockDataSource(opts: { return stored; }, ), + findOneBy: jest.fn(async () => opts.existingUser ?? null), } as unknown as EntityManager; return { @@ -244,6 +247,60 @@ describe('PandadocWebhookService', () => { }); }); + describe('processWebhook - user creation', () => { + it('creates a User record when _firstName is present and no user exists', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ generatedAppId: 42, saved }), + ); + + await service.processWebhook({ + ...buildFullPayload(), + _firstName: 'Jane', + _lastName: 'Doe', + }); + + expect(saved.User).toEqual( + expect.objectContaining({ + email: 'test@example.com', + firstName: 'Jane', + lastName: 'Doe', + }), + ); + }); + + it('skips User creation when _firstName is absent', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + + await service.processWebhook(buildFullPayload()); + + expect(saved.User).toBeUndefined(); + }); + + it('skips User creation when a user with that email already exists', async () => { + const saved: Saved = {}; + const service = await buildService( + buildMockDataSource({ + saved, + existingUser: { + email: 'test@example.com', + firstName: 'Old', + lastName: 'Name', + }, + }), + ); + + await service.processWebhook({ + ...buildFullPayload(), + _firstName: 'Jane', + _lastName: 'Doe', + }); + + expect(saved.User).toBeUndefined(); + }); + }); + describe('processWebhook - validation', () => { it('throws for missing required PandaDoc fields', async () => { const saved: Saved = {}; diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 9e8df1c65..028e102cb 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -13,6 +13,8 @@ import { AppStatus, ApplicantType, PHONE_REGEX } from '../applications/types'; import { Application } from '../applications/application.entity'; import { CandidateInfo } from '../candidate-info/candidate-info.entity'; import { LearnerInfo } from '../learner-info/learner-info.entity'; +import { User } from '../users/user.entity'; +import { UserType } from '../users/types'; const PANDADOC_API_BASE = 'https://api.pandadoc.com/public/v1'; @@ -148,7 +150,12 @@ export class PandadocWebhookService { fields: Array<{ field_id: string; value: unknown; - assigned_to?: { email?: string; phone?: string }; + assigned_to?: { + email?: string; + phone?: string; + first_name?: string; + last_name?: string; + }; }>; }>(`${PANDADOC_API_BASE}/documents/${documentId}/fields`, { headers: { Authorization: `API-Key ${apiKey}` }, @@ -161,8 +168,8 @@ export class PandadocWebhookService { const result = Object.fromEntries(fields.map((f) => [f.field_id, f.value])); - // Email and phone are not form fields — inject from recipient assigned_to. - // PandaDoc populates one or the other depending on how the doc was sent. + // Email, phone, and name are not form fields — inject from recipient assigned_to. + // PandaDoc populates these depending on how the doc was sent. const recipient = fields[0]?.assigned_to; if (!result['email'] && recipient?.email) { result['email'] = recipient.email; @@ -172,6 +179,18 @@ export class PandadocWebhookService { result['Volunteer_Phone'] = recipient.phone; this.logger.debug(`[PandaDoc] Injected phone from recipient assigned_to`); } + if (recipient?.first_name) { + result['_firstName'] = recipient.first_name.trim(); + this.logger.debug( + `[PandaDoc] Injected firstName from recipient assigned_to`, + ); + } + if (recipient?.last_name) { + result['_lastName'] = recipient.last_name.trim(); + this.logger.debug( + `[PandaDoc] Injected lastName from recipient assigned_to`, + ); + } // Resume and cover letter each have two upload slots (*1 = supervisor, *2 = applicant). // Coalesce so the mapper always sees the value regardless of which slot was used. @@ -265,6 +284,9 @@ export class PandadocWebhookService { dateOfBirth: this.formatDate(buckets.learnerInfo['dateOfBirth']), }; + const firstName = String(payload['_firstName'] ?? '').trim(); + const lastName = String(payload['_lastName'] ?? '').trim(); + const email = String(buckets.candidateInfo['email'] ?? ''); const normalizedEmail = email.trim(); if (!normalizedEmail) { @@ -321,6 +343,32 @@ export class PandadocWebhookService { `[PandaDoc] LearnerInfo saved appId=${saved.appId}`, ); + if (firstName) { + const existingUser = await em.findOneBy(User, { + email: normalizedEmail, + }); + if (!existingUser) { + const user = em.create(User, { + email: normalizedEmail, + firstName, + lastName, + userType: UserType.STANDARD, + }); + await em.save(user); + this.logger.debug( + `[PandaDoc] User record created emailMask=${this.maskEmail( + normalizedEmail, + )}`, + ); + } else { + this.logger.debug( + `[PandaDoc] User already exists, skipping creation emailMask=${this.maskEmail( + normalizedEmail, + )}`, + ); + } + } + this.logger.debug( `[PandaDoc] Transaction complete appId=${saved.appId}`, ); From beb0f9647b2b4c0eed03bda9b61a0e0b5dbdf153 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:19:15 -0400 Subject: [PATCH 11/27] DOB from Pandadoc is optional --- .../src/pandadoc-helpers/pandadoc-field-map.ts | 2 +- .../pandadoc-webhook.service.spec.ts | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts b/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts index 01f496e04..f7324d15a 100644 --- a/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts +++ b/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts @@ -520,7 +520,7 @@ export const PANDADOC_FIELD_MAP: ValidPayload[] = [ pandaDocKey: 'Volunteer_DOB', backendField: 'dateOfBirth', transform: (value: string) => parseDate(value), - required: true, + required: false, targetTable: 'learnerInfo', }, { diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 3c515a0c0..942d9bb69 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -302,6 +302,19 @@ describe('PandadocWebhookService', () => { }); describe('processWebhook - validation', () => { + it('does not throw when Volunteer_DOB is missing', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + + const { Volunteer_DOB, ...payloadWithoutDob } = buildFullPayload(); + + await expect(service.processWebhook(payloadWithoutDob)).resolves.toEqual({ + appId: 42, + }); + expect(Volunteer_DOB).toBe('01-15-2000'); + expect(saved.LearnerInfo?.dateOfBirth).toBeUndefined(); + }); + it('throws for missing required PandaDoc fields', async () => { const saved: Saved = {}; const dataSource = buildMockDataSource({ saved }); From d6540dca4ce8268bfa3410862f50c059c026afda Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:50:24 -0400 Subject: [PATCH 12/27] Pandadoc - A volunteer is a learner contingent solely on school affiliation --- .../src/pandadoc-webhook/pandadoc-webhook.service.spec.ts | 7 ++++--- .../src/pandadoc-webhook/pandadoc-webhook.service.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 942d9bb69..4de042ee9 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -220,21 +220,22 @@ describe('PandadocWebhookService', () => { expect(saved.LearnerInfo).toEqual(expect.objectContaining({ appId: 42 })); }); - it('sets applicantType=LEARNER when schoolDepartment is present', async () => { + it('sets applicantType=LEARNER when school is present', async () => { const saved: Saved = {}; const service = await buildService(buildMockDataSource({ saved })); await service.processWebhook(buildFullPayload()); expect(saved.Application?.applicantType).toBe(ApplicantType.LEARNER); }); - it('sets applicantType=VOLUNTEER when schoolDepartment is empty', async () => { + it('sets applicantType=LEARNER when school affiliation is present even if schoolDepartment is empty', async () => { const saved: Saved = {}; const service = await buildService(buildMockDataSource({ saved })); await service.processWebhook({ ...buildFullPayload(), + Volunteer_Affiliation: 'Boston University', Volunteer_Department: '', }); - expect(saved.Application?.applicantType).toBe(ApplicantType.VOLUNTEER); + expect(saved.Application?.applicantType).toBe(ApplicantType.LEARNER); }); it('formats proposedStartDate as YYYY-MM-DD', async () => { diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 028e102cb..0b9e36494 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -258,7 +258,7 @@ export class PandadocWebhookService { } fields)`, ); - const applicantType = buckets.learnerInfo['schoolDepartment'] + const applicantType = buckets.learnerInfo['school'] ? ApplicantType.LEARNER : ApplicantType.VOLUNTEER; @@ -271,13 +271,14 @@ export class PandadocWebhookService { ), endDate: this.formatDate(buckets.application['endDate']), }; + const applicationRecord = applicationData as Record; this.logger.debug( `[PandaDoc] Prepared application record applicantType=${applicantType} phoneMask=${this.maskPhone( - applicationData['phone'], + applicationRecord['phone'], )}`, ); - this.validatePhone(applicationData['phone']); + this.validatePhone(applicationRecord['phone']); const learnerData = { ...buckets.learnerInfo, From ad9c9e2c1f5ad9c545f7691d452beed85c056894 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sun, 14 Jun 2026 16:18:06 -0400 Subject: [PATCH 13/27] first passthrough at file upload? --- .../pandadoc-webhook.module.ts | 3 +- .../pandadoc-webhook.service.spec.ts | 83 +++++++++++++++-- .../pandadoc-webhook.service.ts | 92 +++++++++++++++++++ 3 files changed, 169 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts index f7de4699a..0dff85388 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { AWSS3Module } from '../util/aws-s3/aws-s3.module'; import { PandadocWebhookController } from './pandadoc-webhook.controller'; import { PandadocWebhookService } from './pandadoc-webhook.service'; import { PandadocSignatureGuard } from './pandadoc-signature.guard'; @Module({ - imports: [ConfigModule], + imports: [ConfigModule, AWSS3Module], controllers: [PandadocWebhookController], providers: [PandadocWebhookService, PandadocSignatureGuard], }) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 4de042ee9..3ef9c987f 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -6,6 +6,7 @@ import axios from 'axios'; import { DataSource, EntityManager } from 'typeorm'; import { PandadocWebhookService } from './pandadoc-webhook.service'; import { AppStatus, ApplicantType } from '../applications/types'; +import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; jest.mock('axios'); @@ -106,10 +107,27 @@ function buildWebhookEvent(documentId = 'doc-abc123') { return [{ event: 'document_completed_pdf_ready', data: { id: documentId } }]; } +function buildMockS3Service(): Pick { + return { + uploadWithKey: jest + .fn() + .mockImplementation( + async (_buffer: Buffer, fileName: string, _mimeType: string) => ({ + key: `${fileName.replace(/\.pdf$/i, '')}-stored.pdf`, + url: `https://bucket.s3.us-east-2.amazonaws.com/${fileName.replace( + /\.pdf$/i, + '', + )}-stored.pdf`, + }), + ), + }; +} + describe('PandadocWebhookService', () => { async function buildService( dataSource: DataSource, configService?: ConfigService, + awsS3Service?: Pick, ): Promise { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -119,6 +137,10 @@ describe('PandadocWebhookService', () => { provide: ConfigService, useValue: configService ?? buildMockConfigService(), }, + { + provide: AWSS3Service, + useValue: awsS3Service ?? buildMockS3Service(), + }, ], }).compile(); return module.get(PandadocWebhookService); @@ -137,21 +159,42 @@ describe('PandadocWebhookService', () => { describe('handleIncomingWebhook', () => { it('fetches fields from PandaDoc API and creates the application', async () => { const saved: Saved = {}; + const s3Service = buildMockS3Service(); const service = await buildService( buildMockDataSource({ generatedAppId: 99, saved }), + undefined, + s3Service, ); - mockedAxios.get = jest.fn().mockResolvedValue({ - data: { - fields: Object.entries(buildFullPayload()).map( - ([field_id, value]) => ({ + mockedAxios.get = jest + .fn() + .mockResolvedValueOnce({ + data: { + fields: Object.entries({ + ...buildFullPayload(), + Volunteer_ResumeUpload2: { + name: 'resume.pdf', + url: 'https://files.pandadoc.test/resume.pdf', + }, + Volunteer_CoverletterUpload2: { + name: 'cover-letter.pdf', + url: 'https://files.pandadoc.test/cover-letter.pdf', + }, + Volunteer_SyllabusUpload: { + name: 'syllabus.pdf', + url: 'https://files.pandadoc.test/syllabus.pdf', + }, + }).map(([field_id, value]) => ({ field_id, value, assigned_to: { email: 'test@example.com' }, - }), - ), - }, - }); + })), + }, + }) + .mockResolvedValue({ + data: Buffer.from('file-bytes'), + headers: { 'content-type': 'application/pdf' }, + }); const result = await service.handleIncomingWebhook( buildWebhookEvent('doc-xyz'), @@ -164,6 +207,30 @@ describe('PandadocWebhookService', () => { headers: { Authorization: 'API-Key test-api-key' }, }), ); + expect(s3Service.uploadWithKey).toHaveBeenCalledTimes(3); + expect(s3Service.uploadWithKey).toHaveBeenNthCalledWith( + 1, + expect.any(Buffer), + 'resumes/resume.pdf', + 'application/pdf', + ); + expect(s3Service.uploadWithKey).toHaveBeenNthCalledWith( + 2, + expect.any(Buffer), + 'cover-letters/cover-letter.pdf', + 'application/pdf', + ); + expect(s3Service.uploadWithKey).toHaveBeenNthCalledWith( + 3, + expect.any(Buffer), + 'syllabus/syllabus.pdf', + 'application/pdf', + ); + expect(saved.Application?.resume).toBe('resumes/resume-stored.pdf'); + expect(saved.Application?.coverLetter).toBe( + 'cover-letters/cover-letter-stored.pdf', + ); + expect(saved.LearnerInfo?.syllabus).toBe('syllabus/syllabus-stored.pdf'); expect(saved.Application?.email).toBe('test@example.com'); }); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 0b9e36494..f2d2a2d55 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -13,10 +13,33 @@ import { AppStatus, ApplicantType, PHONE_REGEX } from '../applications/types'; import { Application } from '../applications/application.entity'; import { CandidateInfo } from '../candidate-info/candidate-info.entity'; import { LearnerInfo } from '../learner-info/learner-info.entity'; +import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; import { User } from '../users/user.entity'; import { UserType } from '../users/types'; const PANDADOC_API_BASE = 'https://api.pandadoc.com/public/v1'; +const PANDADOC_FILE_FIELDS = [ + { + fieldId: 'Volunteer_ResumeUpload2', + folder: 'resumes', + label: 'resume', + }, + { + fieldId: 'Volunteer_CoverletterUpload2', + folder: 'cover-letters', + label: 'coverLetter', + }, + { + fieldId: 'Volunteer_SyllabusUpload', + folder: 'syllabus', + label: 'syllabus', + }, +] as const; + +type PandaDocFileValue = { + name?: string; + url?: string; +}; /** * Orchestrates creation of Application, CandidateInfo, and LearnerInfo @@ -33,6 +56,7 @@ export class PandadocWebhookService { constructor( @InjectDataSource() private readonly dataSource: DataSource, private readonly configService: ConfigService, + private readonly awsS3Service: AWSS3Service, ) {} private maskEmail(email: string): string { @@ -81,6 +105,72 @@ export class PandadocWebhookService { return `${yyyy}-${mm}-${dd}`; } + private isPandaDocFileValue(value: unknown): value is PandaDocFileValue { + return !!value && typeof value === 'object' && !Array.isArray(value); + } + + private inferMimeType(fileName: string, contentType?: string): string { + if (contentType && contentType.trim()) { + return contentType; + } + + const lower = fileName.toLowerCase(); + if (lower.endsWith('.pdf')) return 'application/pdf'; + if (lower.endsWith('.doc')) return 'application/msword'; + if (lower.endsWith('.docx')) { + return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + } + if (lower.endsWith('.txt')) return 'text/plain'; + return 'application/octet-stream'; + } + + private async uploadPandaDocFiles( + payload: Record, + apiKey: string, + ): Promise { + for (const fileField of PANDADOC_FILE_FIELDS) { + const rawValue = payload[fileField.fieldId]; + if (!this.isPandaDocFileValue(rawValue)) { + continue; + } + + const fileName = rawValue.name?.trim(); + const fileUrl = rawValue.url?.trim(); + if (!fileName || !fileUrl) { + this.logger.warn( + `[PandaDoc] Skipping ${fileField.label} upload field=${fileField.fieldId} because name or url is missing`, + ); + continue; + } + + this.logger.log( + `[PandaDoc] Downloading PandaDoc ${fileField.label} field=${fileField.fieldId} fileName=${fileName}`, + ); + + const response = await axios.get(fileUrl, { + headers: { Authorization: `API-Key ${apiKey}` }, + responseType: 'arraybuffer', + }); + + const contentTypeHeader = response.headers['content-type']; + const mimeType = this.inferMimeType( + fileName, + typeof contentTypeHeader === 'string' ? contentTypeHeader : undefined, + ); + const uploadBaseName = `${fileField.folder}/${fileName}`; + const uploadResult = await this.awsS3Service.uploadWithKey( + Buffer.from(response.data), + uploadBaseName, + mimeType, + ); + + payload[fileField.fieldId] = uploadResult.key; + this.logger.log( + `[PandaDoc] Uploaded ${fileField.label} to S3 field=${fileField.fieldId} s3Key=${uploadResult.key}`, + ); + } + } + /** * Entry point called by the controller. Receives the raw PandaDoc webhook * body (an array of events), extracts the document ID, fetches the field @@ -204,6 +294,8 @@ export class PandadocWebhookService { result['Volunteer_CoverletterUpload1']; } + await this.uploadPandaDocFiles(result, apiKey); + return result; } From 284c27f44bf7ca47b24d01c89aae99490120de9d Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:52:18 -0400 Subject: [PATCH 14/27] best-effort phone number formatting for emergency contact --- .../components/EmergencyContactFrame.spec.tsx | 37 +++++++++++++++++++ .../src/components/EmergencyContactFrame.tsx | 19 +++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 apps/frontend/src/components/EmergencyContactFrame.spec.tsx diff --git a/apps/frontend/src/components/EmergencyContactFrame.spec.tsx b/apps/frontend/src/components/EmergencyContactFrame.spec.tsx new file mode 100644 index 000000000..96a2c2359 --- /dev/null +++ b/apps/frontend/src/components/EmergencyContactFrame.spec.tsx @@ -0,0 +1,37 @@ +// @vitest-environment jsdom + +import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import EmergencyContactFrame from './EmergencyContactFrame'; + +function renderWithChakra(ui: React.ReactElement) { + return render({ui}); +} + +describe('EmergencyContactFrame', () => { + it('formats a plain 10-digit emergency contact phone number for display', () => { + renderWithChakra( + , + ); + + expect(screen.getByText('123-456-7890')).toBeTruthy(); + }); + + it('falls back to the original value when the phone number is not formatable', () => { + renderWithChakra( + , + ); + + expect(screen.getByText('ext. 45')).toBeTruthy(); + }); +}); diff --git a/apps/frontend/src/components/EmergencyContactFrame.tsx b/apps/frontend/src/components/EmergencyContactFrame.tsx index a78a86746..79daeb825 100644 --- a/apps/frontend/src/components/EmergencyContactFrame.tsx +++ b/apps/frontend/src/components/EmergencyContactFrame.tsx @@ -7,11 +7,28 @@ interface EmergencyContactFrameProps { relationship: string; } +function formatBestEffortPhone(phone: string): string { + const digits = phone.replace(/\D/g, ''); + const normalizedDigits = + digits.length === 11 && digits.startsWith('1') ? digits.slice(1) : digits; + + if (normalizedDigits.length !== 10) { + return phone; + } + + return `${normalizedDigits.slice(0, 3)}-${normalizedDigits.slice( + 3, + 6, + )}-${normalizedDigits.slice(6)}`; +} + const EmergencyContactFrame = ({ name, phone, relationship, }: EmergencyContactFrameProps) => { + const formattedPhone = formatBestEffortPhone(phone); + return ( - {phone} + {formattedPhone} From d08579699318d6d29cbe2c1004ca02c9d1ea26ca Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sun, 14 Jun 2026 20:24:24 -0400 Subject: [PATCH 15/27] pandadoc calls existing services to create records --- .../pandadoc-webhook.module.ts | 4 +- .../pandadoc-webhook.service.spec.ts | 311 ++++++++---------- .../pandadoc-webhook.service.ts | 125 +++---- 3 files changed, 184 insertions(+), 256 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts index 0dff85388..0786bca1f 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts @@ -1,12 +1,14 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; +import { ApplicationsModule } from '../applications/applications.module'; +import { LearnerInfoModule } from '../learner-info/learner-info.module'; import { AWSS3Module } from '../util/aws-s3/aws-s3.module'; import { PandadocWebhookController } from './pandadoc-webhook.controller'; import { PandadocWebhookService } from './pandadoc-webhook.service'; import { PandadocSignatureGuard } from './pandadoc-signature.guard'; @Module({ - imports: [ConfigModule, AWSS3Module], + imports: [ConfigModule, AWSS3Module, ApplicationsModule, LearnerInfoModule], controllers: [PandadocWebhookController], providers: [PandadocWebhookService, PandadocSignatureGuard], }) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 3ef9c987f..94cf85e3f 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -1,11 +1,11 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { getDataSourceToken } from '@nestjs/typeorm'; import { BadRequestException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; -import { DataSource, EntityManager } from 'typeorm'; import { PandadocWebhookService } from './pandadoc-webhook.service'; import { AppStatus, ApplicantType } from '../applications/types'; +import { ApplicationsService } from '../applications/applications.service'; +import { LearnerInfoService } from '../learner-info/learner-info.service'; import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; jest.mock('axios'); @@ -50,48 +50,6 @@ function buildFullPayload(): Record { }; } -interface Saved { - Application?: Record; - CandidateInfo?: Record; - LearnerInfo?: Record; - User?: Record; -} - -function buildMockDataSource(opts: { - generatedAppId?: number; - failOn?: 'Application' | 'CandidateInfo' | 'LearnerInfo' | 'User'; - saved: Saved; - existingUser?: Record | null; -}): DataSource { - const generatedAppId = opts.generatedAppId ?? 42; - - const em = { - create: ( - entityClass: new () => unknown, - data: Record, - ) => ({ __entity: entityClass.name, ...data }), - save: jest.fn( - async (entity: Record & { __entity: string }) => { - const name = entity.__entity; - if (opts.failOn === name) { - throw new Error(`Forced failure on ${name}`); - } - const stored = { ...entity }; - if (name === 'Application') { - stored.appId = generatedAppId; - } - opts.saved[name as keyof Saved] = stored; - return stored; - }, - ), - findOneBy: jest.fn(async () => opts.existingUser ?? null), - } as unknown as EntityManager; - - return { - transaction: async (cb: (em: EntityManager) => Promise) => cb(em), - } as unknown as DataSource; -} - const mockedAxios = axios as jest.Mocked; function buildMockConfigService(apiKey = 'test-api-key'): ConfigService { @@ -123,16 +81,31 @@ function buildMockS3Service(): Pick { }; } +function buildMockApplicationsService(generatedAppId = 42) { + return { + create: jest.fn(async (dto) => ({ + appId: generatedAppId, + ...dto, + })), + } as unknown as Pick; +} + +function buildMockLearnerInfoService() { + return { + create: jest.fn(async (dto) => dto), + } as unknown as Pick; +} + describe('PandadocWebhookService', () => { async function buildService( - dataSource: DataSource, configService?: ConfigService, awsS3Service?: Pick, + applicationsService?: Pick, + learnerInfoService?: Pick, ): Promise { const module: TestingModule = await Test.createTestingModule({ providers: [ PandadocWebhookService, - { provide: getDataSourceToken(), useValue: dataSource }, { provide: ConfigService, useValue: configService ?? buildMockConfigService(), @@ -141,6 +114,14 @@ describe('PandadocWebhookService', () => { provide: AWSS3Service, useValue: awsS3Service ?? buildMockS3Service(), }, + { + provide: ApplicationsService, + useValue: applicationsService ?? buildMockApplicationsService(), + }, + { + provide: LearnerInfoService, + useValue: learnerInfoService ?? buildMockLearnerInfoService(), + }, ], }).compile(); return module.get(PandadocWebhookService); @@ -151,19 +132,20 @@ describe('PandadocWebhookService', () => { }); it('should be defined', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); + const service = await buildService(); expect(service).toBeDefined(); }); describe('handleIncomingWebhook', () => { - it('fetches fields from PandaDoc API and creates the application', async () => { - const saved: Saved = {}; + it('fetches fields from PandaDoc API and delegates creation through existing services', async () => { const s3Service = buildMockS3Service(); + const applicationsService = buildMockApplicationsService(99); + const learnerInfoService = buildMockLearnerInfoService(); const service = await buildService( - buildMockDataSource({ generatedAppId: 99, saved }), undefined, s3Service, + applicationsService, + learnerInfoService, ); mockedAxios.get = jest @@ -226,17 +208,24 @@ describe('PandadocWebhookService', () => { 'syllabus/syllabus.pdf', 'application/pdf', ); - expect(saved.Application?.resume).toBe('resumes/resume-stored.pdf'); - expect(saved.Application?.coverLetter).toBe( - 'cover-letters/cover-letter-stored.pdf', + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + appStatus: AppStatus.APP_SUBMITTED, + resume: 'resumes/resume-stored.pdf', + coverLetter: 'cover-letters/cover-letter-stored.pdf', + }), + ); + expect(learnerInfoService.create).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 99, + syllabus: 'syllabus/syllabus-stored.pdf', + }), ); - expect(saved.LearnerInfo?.syllabus).toBe('syllabus/syllabus-stored.pdf'); - expect(saved.Application?.email).toBe('test@example.com'); }); it('throws BadRequestException when payload is not an array of events', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); + const service = await buildService(); await expect(service.handleIncomingWebhook({})).rejects.toThrow( BadRequestException, @@ -244,8 +233,7 @@ describe('PandadocWebhookService', () => { }); it('throws BadRequestException when data.id is missing', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); + const service = await buildService(); await expect( service.handleIncomingWebhook([ @@ -255,11 +243,7 @@ describe('PandadocWebhookService', () => { }); it('throws InternalServerErrorException when PANDADOC_API_KEY is not set', async () => { - const saved: Saved = {}; - const service = await buildService( - buildMockDataSource({ saved }), - buildMockConfigService(''), - ); + const service = await buildService(buildMockConfigService('')); await expect( service.handleIncomingWebhook(buildWebhookEvent()), @@ -268,111 +252,92 @@ describe('PandadocWebhookService', () => { }); describe('processWebhook - happy path', () => { - it('creates all three records in a single transaction with shared appId', async () => { - const saved: Saved = {}; - const dataSource = buildMockDataSource({ generatedAppId: 42, saved }); - const txSpy = jest.spyOn(dataSource, 'transaction'); - const service = await buildService(dataSource); + it('delegates application and learner creation to existing services', async () => { + const applicationsService = buildMockApplicationsService(42); + const learnerInfoService = buildMockLearnerInfoService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + learnerInfoService, + ); const result = await service.processWebhook(buildFullPayload()); expect(result).toEqual({ appId: 42 }); - expect(txSpy).toHaveBeenCalledTimes(1); - expect(saved.Application?.email).toBe('test@example.com'); - expect(saved.Application?.appStatus).toBe(AppStatus.APP_SUBMITTED); - expect(saved.Application?.phone).toBe('617-555-0199'); - expect(saved.CandidateInfo).toEqual( - expect.objectContaining({ appId: 42, email: 'test@example.com' }), + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + appStatus: AppStatus.APP_SUBMITTED, + phone: '617-555-0199', + }), + ); + expect(learnerInfoService.create).toHaveBeenCalledWith( + expect.objectContaining({ appId: 42 }), ); - expect(saved.LearnerInfo).toEqual(expect.objectContaining({ appId: 42 })); }); it('sets applicantType=LEARNER when school is present', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); - await service.processWebhook(buildFullPayload()); - expect(saved.Application?.applicantType).toBe(ApplicantType.LEARNER); - }); - - it('sets applicantType=LEARNER when school affiliation is present even if schoolDepartment is empty', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); - await service.processWebhook({ - ...buildFullPayload(), - Volunteer_Affiliation: 'Boston University', - Volunteer_Department: '', - }); - expect(saved.Application?.applicantType).toBe(ApplicantType.LEARNER); - }); + const applicationsService = buildMockApplicationsService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + ); - it('formats proposedStartDate as YYYY-MM-DD', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); await service.processWebhook(buildFullPayload()); - expect(saved.Application?.proposedStartDate).toMatch( - /^\d{4}-\d{2}-\d{2}$/, + + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ applicantType: ApplicantType.LEARNER }), ); }); - }); - describe('processWebhook - user creation', () => { - it('creates a User record when _firstName is present and no user exists', async () => { - const saved: Saved = {}; + it('sets applicantType=LEARNER when school affiliation is present even if schoolDepartment is empty', async () => { + const applicationsService = buildMockApplicationsService(); const service = await buildService( - buildMockDataSource({ generatedAppId: 42, saved }), + undefined, + undefined, + applicationsService, ); await service.processWebhook({ ...buildFullPayload(), - _firstName: 'Jane', - _lastName: 'Doe', + Volunteer_Affiliation: 'Boston University', + Volunteer_Department: '', }); - expect(saved.User).toEqual( - expect.objectContaining({ - email: 'test@example.com', - firstName: 'Jane', - lastName: 'Doe', - }), + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ applicantType: ApplicantType.LEARNER }), ); }); - it('skips User creation when _firstName is absent', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); + it('formats proposedStartDate as YYYY-MM-DD', async () => { + const applicationsService = buildMockApplicationsService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + ); await service.processWebhook(buildFullPayload()); - expect(saved.User).toBeUndefined(); - }); - - it('skips User creation when a user with that email already exists', async () => { - const saved: Saved = {}; - const service = await buildService( - buildMockDataSource({ - saved, - existingUser: { - email: 'test@example.com', - firstName: 'Old', - lastName: 'Name', - }, + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + proposedStartDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), }), ); - - await service.processWebhook({ - ...buildFullPayload(), - _firstName: 'Jane', - _lastName: 'Doe', - }); - - expect(saved.User).toBeUndefined(); }); }); describe('processWebhook - validation', () => { it('does not throw when Volunteer_DOB is missing', async () => { - const saved: Saved = {}; - const service = await buildService(buildMockDataSource({ saved })); + const learnerInfoService = buildMockLearnerInfoService(); + const service = await buildService( + undefined, + undefined, + undefined, + learnerInfoService, + ); const { Volunteer_DOB, ...payloadWithoutDob } = buildFullPayload(); @@ -380,66 +345,76 @@ describe('PandadocWebhookService', () => { appId: 42, }); expect(Volunteer_DOB).toBe('01-15-2000'); - expect(saved.LearnerInfo?.dateOfBirth).toBeUndefined(); + expect(learnerInfoService.create).toHaveBeenCalledWith( + expect.objectContaining({ dateOfBirth: undefined }), + ); }); it('throws for missing required PandaDoc fields', async () => { - const saved: Saved = {}; - const dataSource = buildMockDataSource({ saved }); - const txSpy = jest.spyOn(dataSource, 'transaction'); - const service = await buildService(dataSource); + const applicationsService = buildMockApplicationsService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + ); await expect( service.processWebhook({ email: 'x@example.com' }), ).rejects.toThrow('Missing required PandaDoc fields'); - expect(txSpy).not.toHaveBeenCalled(); + expect(applicationsService.create).not.toHaveBeenCalled(); }); it('throws BadRequestException for malformed phone number', async () => { - const saved: Saved = {}; - const dataSource = buildMockDataSource({ saved }); - const txSpy = jest.spyOn(dataSource, 'transaction'); - const service = await buildService(dataSource); + const applicationsService = buildMockApplicationsService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + ); const payload = { ...buildFullPayload(), Volunteer_Phone: 'not-a-phone' }; await expect(service.processWebhook(payload)).rejects.toThrow( BadRequestException, ); - expect(txSpy).not.toHaveBeenCalled(); + expect(applicationsService.create).not.toHaveBeenCalled(); }); }); - describe('processWebhook - transaction rollback', () => { - it('propagates the error when Application save fails', async () => { - const saved: Saved = {}; + describe('processWebhook - delegated service failures', () => { + it('propagates the error when ApplicationsService.create fails', async () => { + const applicationsService = { + create: jest + .fn() + .mockRejectedValue(new Error('Forced failure on Application')), + } as unknown as Pick; + const learnerInfoService = buildMockLearnerInfoService(); const service = await buildService( - buildMockDataSource({ failOn: 'Application', saved }), + undefined, + undefined, + applicationsService, + learnerInfoService, ); + await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( 'Forced failure on Application', ); - expect(saved.Application).toBeUndefined(); - expect(saved.CandidateInfo).toBeUndefined(); - expect(saved.LearnerInfo).toBeUndefined(); + expect(learnerInfoService.create).not.toHaveBeenCalled(); }); - it('propagates the error when CandidateInfo save fails (transaction rolls back)', async () => { - const saved: Saved = {}; + it('propagates the error when LearnerInfoService.create fails', async () => { + const learnerInfoService = { + create: jest + .fn() + .mockRejectedValue(new Error('Forced failure on LearnerInfo')), + } as unknown as Pick; const service = await buildService( - buildMockDataSource({ failOn: 'CandidateInfo', saved }), - ); - await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( - 'Forced failure on CandidateInfo', + undefined, + undefined, + undefined, + learnerInfoService, ); - expect(saved.LearnerInfo).toBeUndefined(); - }); - it('propagates the error when LearnerInfo save fails (transaction rolls back)', async () => { - const saved: Saved = {}; - const service = await buildService( - buildMockDataSource({ failOn: 'LearnerInfo', saved }), - ); await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( 'Forced failure on LearnerInfo', ); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index f2d2a2d55..26875445d 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -5,17 +5,14 @@ import { Logger, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { InjectDataSource } from '@nestjs/typeorm'; import axios from 'axios'; -import { DataSource, EntityManager } from 'typeorm'; import { pandadocMapper } from '../pandadoc-helpers/pandadoc-mapper'; import { AppStatus, ApplicantType, PHONE_REGEX } from '../applications/types'; -import { Application } from '../applications/application.entity'; -import { CandidateInfo } from '../candidate-info/candidate-info.entity'; -import { LearnerInfo } from '../learner-info/learner-info.entity'; +import { ApplicationsService } from '../applications/applications.service'; +import { CreateApplicationDto } from '../applications/dto/create-application.request.dto'; +import { CreateLearnerInfoDto } from '../learner-info/dto/create-learner-info.request.dto'; +import { LearnerInfoService } from '../learner-info/learner-info.service'; import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; -import { User } from '../users/user.entity'; -import { UserType } from '../users/types'; const PANDADOC_API_BASE = 'https://api.pandadoc.com/public/v1'; const PANDADOC_FILE_FIELDS = [ @@ -54,9 +51,10 @@ export class PandadocWebhookService { private readonly logger = new Logger(PandadocWebhookService.name); constructor( - @InjectDataSource() private readonly dataSource: DataSource, private readonly configService: ConfigService, private readonly awsS3Service: AWSS3Service, + private readonly applicationsService: ApplicationsService, + private readonly learnerInfoService: LearnerInfoService, ) {} private maskEmail(email: string): string { @@ -373,12 +371,10 @@ export class PandadocWebhookService { this.validatePhone(applicationRecord['phone']); const learnerData = { - ...buckets.learnerInfo, + ...(buckets.learnerInfo as Record), dateOfBirth: this.formatDate(buckets.learnerInfo['dateOfBirth']), }; - - const firstName = String(payload['_firstName'] ?? '').trim(); - const lastName = String(payload['_lastName'] ?? '').trim(); + const learnerRecord = learnerData as Record; const email = String(buckets.candidateInfo['email'] ?? ''); const normalizedEmail = email.trim(); @@ -392,89 +388,44 @@ export class PandadocWebhookService { } this.logger.log( - `[PandaDoc] Creating application transaction emailMask=${this.maskEmail( + `[PandaDoc] Delegating application creation emailMask=${this.maskEmail( normalizedEmail, )} applicantType=${applicantType}`, ); - const appId = await this.dataSource.transaction( - async (em: EntityManager) => { - this.logger.debug('[PandaDoc] Transaction started'); - - this.logger.debug('[PandaDoc] Persisting Application entity'); - const application = em.create(Application, applicationData); - const saved = await em.save(application); - this.logger.debug( - `[PandaDoc] Application saved appId=${saved.appId}`, - ); - - this.logger.debug( - `[PandaDoc] Persisting CandidateInfo entity appId=${ - saved.appId - } emailMask=${this.maskEmail(normalizedEmail)}`, - ); - const candidate = em.create(CandidateInfo, { - appId: saved.appId, - email: normalizedEmail, - }); - await em.save(candidate); - this.logger.debug( - `[PandaDoc] CandidateInfo saved appId=${saved.appId}`, - ); - - this.logger.debug( - `[PandaDoc] Persisting LearnerInfo entity appId=${ - saved.appId - } learnerFieldCount=${Object.keys(learnerData).length}`, - ); - const learner = em.create(LearnerInfo, { - ...learnerData, - appId: saved.appId, - }); - await em.save(learner); - this.logger.debug( - `[PandaDoc] LearnerInfo saved appId=${saved.appId}`, - ); - - if (firstName) { - const existingUser = await em.findOneBy(User, { - email: normalizedEmail, - }); - if (!existingUser) { - const user = em.create(User, { - email: normalizedEmail, - firstName, - lastName, - userType: UserType.STANDARD, - }); - await em.save(user); - this.logger.debug( - `[PandaDoc] User record created emailMask=${this.maskEmail( - normalizedEmail, - )}`, - ); - } else { - this.logger.debug( - `[PandaDoc] User already exists, skipping creation emailMask=${this.maskEmail( - normalizedEmail, - )}`, - ); - } - } - - this.logger.debug( - `[PandaDoc] Transaction complete appId=${saved.appId}`, - ); - return saved.appId; - }, + const createdApplication = await this.applicationsService.create( + applicationData as CreateApplicationDto, + ); + + this.logger.debug( + `[PandaDoc] ApplicationsService.create complete appId=${createdApplication.appId}`, ); + if (learnerRecord['school']) { + this.logger.debug( + `[PandaDoc] Delegating learner info creation appId=${ + createdApplication.appId + } learnerFieldCount=${Object.keys(learnerData).length}`, + ); + const learnerCreateData = learnerData as unknown as Omit< + CreateLearnerInfoDto, + 'appId' + >; + await this.learnerInfoService.create({ + ...learnerCreateData, + appId: createdApplication.appId, + }); + this.logger.debug( + `[PandaDoc] LearnerInfoService.create complete appId=${createdApplication.appId}`, + ); + } + this.logger.log( - `[PandaDoc] Webhook processing complete appId=${appId} durationMs=${ - Date.now() - startedAt - }`, + `[PandaDoc] Webhook processing complete appId=${ + createdApplication.appId + } durationMs=${Date.now() - startedAt}`, ); - return { appId }; + return { appId: createdApplication.appId }; } catch (error) { const durationMs = Date.now() - startedAt; const message = error instanceof Error ? error.message : String(error); From 9623d527b9ba7d3ef79166e40fd0ca89dc2a2dd6 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:18:03 -0400 Subject: [PATCH 16/27] invalid input email hooked up again --- .../src/applications/applications.service.ts | 27 +++++++ .../pandadoc-webhook.service.spec.ts | 80 ++++++++++++++++--- .../pandadoc-webhook.service.ts | 19 ++++- 3 files changed, 115 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 4c3b68b83..2b13c178a 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -213,6 +213,8 @@ export class ApplicationsService { 'Confidentiality_Form.pdf'; private static readonly CONFIDENTIALITY_UPLOAD_FOLDER = 'confidentiality-forms'; + private static readonly PANDADOC_RESUBMISSION_LINK = + 'https://eform.pandadoc.com/?eform=e27f6460-7fa2-40f2-825b-4a83c507b9fe'; private static readonly APPLICATION_EXPORT_HEADERS = APPLICATION_EXPORT_COLUMNS.map(([, header]) => header).join(','); @@ -890,6 +892,31 @@ export class ApplicationsService { await this.applicationRepository.remove(application); } + async sendSubmissionErrorEmail( + applicantDto: CreateApplicationDto, + errorMessage: string, + applicantName = 'Applicant', + ): Promise { + const recipientEmail = applicantDto.email?.trim(); + + if (!recipientEmail) { + return; + } + + const emailBody = this.buildApplicationSubmissionErrorEmailBody( + applicantName, + applicantDto, + errorMessage, + ApplicationsService.PANDADOC_RESUBMISSION_LINK, + ); + + await this.emailService.queueEmail( + recipientEmail, + 'Action Required: Issue with Your Application Submission', + emailBody, + ); + } + /** * Builds the HTML email body for a failed application submission. * Uses data directly from the DTO — no database lookup required. diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 94cf85e3f..da89df1d2 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -70,13 +70,16 @@ function buildMockS3Service(): Pick { uploadWithKey: jest .fn() .mockImplementation( - async (_buffer: Buffer, fileName: string, _mimeType: string) => ({ - key: `${fileName.replace(/\.pdf$/i, '')}-stored.pdf`, - url: `https://bucket.s3.us-east-2.amazonaws.com/${fileName.replace( - /\.pdf$/i, - '', - )}-stored.pdf`, - }), + async (_buffer: Buffer, fileName: string, mimeType: string) => { + void mimeType; + return { + key: `${fileName.replace(/\.pdf$/i, '')}-stored.pdf`, + url: `https://bucket.s3.us-east-2.amazonaws.com/${fileName.replace( + /\.pdf$/i, + '', + )}-stored.pdf`, + }; + }, ), }; } @@ -87,7 +90,11 @@ function buildMockApplicationsService(generatedAppId = 42) { appId: generatedAppId, ...dto, })), - } as unknown as Pick; + sendSubmissionErrorEmail: jest.fn().mockResolvedValue(undefined), + } as unknown as Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >; } function buildMockLearnerInfoService() { @@ -100,7 +107,10 @@ describe('PandadocWebhookService', () => { async function buildService( configService?: ConfigService, awsS3Service?: Pick, - applicationsService?: Pick, + applicationsService?: Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >, learnerInfoService?: Pick, ): Promise { const module: TestingModule = await Test.createTestingModule({ @@ -363,6 +373,9 @@ describe('PandadocWebhookService', () => { ).rejects.toThrow('Missing required PandaDoc fields'); expect(applicationsService.create).not.toHaveBeenCalled(); + expect( + applicationsService.sendSubmissionErrorEmail, + ).not.toHaveBeenCalled(); }); it('throws BadRequestException for malformed phone number', async () => { @@ -378,6 +391,46 @@ describe('PandadocWebhookService', () => { BadRequestException, ); expect(applicationsService.create).not.toHaveBeenCalled(); + expect(applicationsService.sendSubmissionErrorEmail).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + phone: 'not-a-phone', + }), + 'Phone number must be in ###-###-#### format', + ); + }); + + it('sends the invalid-input email through ApplicationsService when create rejects a BadRequestException', async () => { + const applicationsService = { + ...buildMockApplicationsService(), + create: jest + .fn() + .mockRejectedValue( + new BadRequestException( + 'Weekly hours must be greater than 0 and less than 7 * 24 hours', + ), + ), + } as unknown as Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >; + const service = await buildService( + undefined, + undefined, + applicationsService, + ); + + await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( + 'Weekly hours must be greater than 0 and less than 7 * 24 hours', + ); + + expect(applicationsService.sendSubmissionErrorEmail).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'test@example.com', + weeklyHours: 10, + }), + 'Weekly hours must be greater than 0 and less than 7 * 24 hours', + ); }); }); @@ -387,7 +440,11 @@ describe('PandadocWebhookService', () => { create: jest .fn() .mockRejectedValue(new Error('Forced failure on Application')), - } as unknown as Pick; + sendSubmissionErrorEmail: jest.fn().mockResolvedValue(undefined), + } as unknown as Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >; const learnerInfoService = buildMockLearnerInfoService(); const service = await buildService( undefined, @@ -400,6 +457,9 @@ describe('PandadocWebhookService', () => { 'Forced failure on Application', ); expect(learnerInfoService.create).not.toHaveBeenCalled(); + expect( + applicationsService.sendSubmissionErrorEmail, + ).not.toHaveBeenCalled(); }); it('propagates the error when LearnerInfoService.create fails', async () => { diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 26875445d..e43073547 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -309,6 +309,7 @@ export class PandadocWebhookService { ): Promise<{ appId: number }> { const startedAt = Date.now(); const payloadKeys = Object.keys(payload ?? {}); + let applicationDto: CreateApplicationDto | undefined; const eventType = this.getPayloadString(payload, 'event') ?? 'unknown'; const documentId = this.getPayloadString(payload, 'document_id') ?? @@ -361,6 +362,7 @@ export class PandadocWebhookService { ), endDate: this.formatDate(buckets.application['endDate']), }; + applicationDto = applicationData as CreateApplicationDto; const applicationRecord = applicationData as Record; this.logger.debug( @@ -394,7 +396,7 @@ export class PandadocWebhookService { ); const createdApplication = await this.applicationsService.create( - applicationData as CreateApplicationDto, + applicationDto, ); this.logger.debug( @@ -431,6 +433,21 @@ export class PandadocWebhookService { const message = error instanceof Error ? error.message : String(error); if (error instanceof BadRequestException) { + if (applicationDto) { + try { + await this.applicationsService.sendSubmissionErrorEmail( + applicationDto, + message, + ); + } catch (emailError) { + this.logger.error( + `[PandaDoc] Failed to send invalid-submission email event=${eventType} documentId=${documentId}`, + emailError instanceof Error + ? emailError.stack + : String(emailError), + ); + } + } this.logger.warn( `[PandaDoc] Webhook rejected event=${eventType} documentId=${documentId} durationMs=${durationMs} reason=${message}`, ); From b608e52ea7cedf980f1615e22ed281ed19a0e50b Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:27:29 -0400 Subject: [PATCH 17/27] restoring invalid email sending flow and reducing stricness of email --- .../applications/application.service.spec.ts | 46 +------------------ .../src/applications/applications.service.ts | 4 +- 2 files changed, 3 insertions(+), 47 deletions(-) diff --git a/apps/backend/src/applications/application.service.spec.ts b/apps/backend/src/applications/application.service.spec.ts index dcd10767a..4343bc876 100644 --- a/apps/backend/src/applications/application.service.spec.ts +++ b/apps/backend/src/applications/application.service.spec.ts @@ -613,50 +613,6 @@ describe('ApplicationsService', () => { await expect(service.create(createApplicationDto)).rejects.toThrow(); }); - it('should not accept 0 weekly hours', async () => { - const createApplicationDto: CreateApplicationDto = { - ...dummyCreateApplicationDto, - weeklyHours: 0, - }; - - const savedApplication: Application = { - appId: 1, - ...createApplicationDto, - proposedStartDate: new Date('2024-01-01'), - endDate: new Date('2024-06-30'), - actualStartDate: undefined, - resume: 'janedoe_resume_2_6_2026.pdf', - coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }; - - mockRepository.save.mockResolvedValue(savedApplication); - await expect(service.create(createApplicationDto)).rejects.toThrow(); - }); - - it('should not accept negative weekly hours', async () => { - const createApplicationDto: CreateApplicationDto = { - ...dummyCreateApplicationDto, - weeklyHours: -5, - }; - - const savedApplication: Application = { - appId: 1, - ...createApplicationDto, - proposedStartDate: new Date('2024-01-01'), - endDate: new Date('2024-06-30'), - actualStartDate: undefined, - resume: 'janedoe_resume_2_6_2026.pdf', - coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', - createdAt: new Date('2024-01-01'), - updatedAt: new Date('2024-01-01'), - }; - - mockRepository.save.mockResolvedValue(savedApplication); - await expect(service.create(createApplicationDto)).rejects.toThrow(); - }); - it('should send an email when creating an application', async () => { const savedApplication: Application = { appId: 2, @@ -738,7 +694,7 @@ describe('ApplicationsService', () => { }; await expect(service.create(createApplicationDto)).rejects.toThrow( - 'Weekly hours must be greater than 0 and less than 7 * 24 hours', + 'Weekly hours must be less than 7 * 24 hours', ); }); }); diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 2b13c178a..6149a19e5 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -360,9 +360,9 @@ export class ApplicationsService { } // Validate weeklyHours is positive - if (dto.weeklyHours <= 0 || dto.weeklyHours > 7 * 24) { + if (dto.weeklyHours > 7 * 24) { throw new BadRequestException( - 'Weekly hours must be greater than 0 and less than 7 * 24 hours', + 'Weekly hours must be less than 7 * 24 hours', ); } } From 5cf95d2827f5650870cca82061b5ad12ef3df1a7 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:30:11 -0400 Subject: [PATCH 18/27] Other school affiliation actually shows on frontend --- .../src/applications/candidate-provisioning.service.ts | 3 +-- .../frontend/src/components/SchoolAffiliationFrame.tsx | 10 +++++++++- apps/frontend/src/containers/AdminViewApplication.tsx | 1 + .../src/containers/CandidateViewApplication.tsx | 1 + 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/applications/candidate-provisioning.service.ts b/apps/backend/src/applications/candidate-provisioning.service.ts index 21f7af551..d4726d627 100644 --- a/apps/backend/src/applications/candidate-provisioning.service.ts +++ b/apps/backend/src/applications/candidate-provisioning.service.ts @@ -123,7 +123,6 @@ export class CandidateProvisioningService { loginUrl: string, temporaryPassword?: string, ): string { - const { firstName } = this.deriveNameParts(email); const passwordBlock = temporaryPassword ? `

Temporary password: ${temporaryPassword}

@@ -131,7 +130,7 @@ export class CandidateProvisioningService { : `

You can log in with your existing applicant account to track your status.

`; return ` -

Hello ${firstName},

+

Hello Applicant,

Your application has been submitted successfully.

diff --git a/apps/frontend/src/components/SchoolAffiliationFrame.tsx b/apps/frontend/src/components/SchoolAffiliationFrame.tsx index cf49447c8..149086f6f 100644 --- a/apps/frontend/src/components/SchoolAffiliationFrame.tsx +++ b/apps/frontend/src/components/SchoolAffiliationFrame.tsx @@ -13,6 +13,7 @@ import { schoolEmblemPublicUrl } from '@utils/schoolEmblemUrl'; export interface SchoolAffiliationProps { schoolName: string; + otherSchool?: string; schoolDepartment: string; license: string; desiredExperience: string; @@ -31,6 +32,7 @@ export interface SchoolAffiliationProps { const SchoolAffiliationFrame = ({ schoolName, + otherSchool, schoolDepartment, license, desiredExperience, @@ -56,6 +58,12 @@ const SchoolAffiliationFrame = ({ const [isEditing, setIsEditing] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + const displaySchoolName = + schoolName === 'Other' + ? otherSchool?.trim() + ? `Other - ${otherSchool.trim()}` + : 'Other' + : schoolName; const emblemUrl = schoolEmblemPublicUrl(schoolName); const showWhiteBg = schoolName !== 'Other' && schoolName !== 'Does not apply'; const [emblemFailed, setEmblemFailed] = useState(false); @@ -174,7 +182,7 @@ const SchoolAffiliationFrame = ({ {/* University Column */} - {schoolName} + {displaySchoolName} diff --git a/apps/frontend/src/containers/AdminViewApplication.tsx b/apps/frontend/src/containers/AdminViewApplication.tsx index d9a3aac5e..dc9df7d67 100644 --- a/apps/frontend/src/containers/AdminViewApplication.tsx +++ b/apps/frontend/src/containers/AdminViewApplication.tsx @@ -375,6 +375,7 @@ const AdminViewApplication: React.FC = () => { { Date: Wed, 17 Jun 2026 21:39:56 -0400 Subject: [PATCH 19/27] Fix pandadoc tests --- .../pandadoc-helpers/pandadoc-mapper.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/backend/src/pandadoc-helpers/pandadoc-mapper.spec.ts b/apps/backend/src/pandadoc-helpers/pandadoc-mapper.spec.ts index 3e94e36d8..9be5c3514 100644 --- a/apps/backend/src/pandadoc-helpers/pandadoc-mapper.spec.ts +++ b/apps/backend/src/pandadoc-helpers/pandadoc-mapper.spec.ts @@ -7,6 +7,23 @@ import { import { School } from '../learner-info/types'; import { PANDADOC_FIELD_MAP } from './pandadoc-field-map'; +jest.mock('../util/aws-exports', () => ({ + __esModule: true, + default: { + AWSConfig: { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + region: 'us-east-2', + bucketName: 'bucket', + }, + CognitoAuthConfig: { + userPoolId: 'test-user-pool-id', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }, +})); + const mappingPairKey = (item: { targetTable: string; backendField: string; From 5dc610ea39dc306e22c14a4571a821bce0cc5ad3 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Wed, 24 Jun 2026 22:06:24 -0400 Subject: [PATCH 20/27] Fixing env variables propagation mock on tests --- .../pandadoc-signature.guard.spec.ts | 17 +++++++++++++++++ .../pandadoc-webhook.service.spec.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts index ae7fb17ce..243e6c77a 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts @@ -3,6 +3,23 @@ import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PandadocSignatureGuard } from './pandadoc-signature.guard'; +jest.mock('../util/aws-exports', () => ({ + __esModule: true, + default: { + AWSConfig: { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + region: 'us-east-2', + bucketName: 'bucket', + }, + CognitoAuthConfig: { + userPoolId: 'test-user-pool-id', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }, +})); + const KEY = 'sandbox-key-abc123'; const BODY = Buffer.from(JSON.stringify([{ event: 'document_completed' }])); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index da89df1d2..e6c2dd5a8 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -10,6 +10,23 @@ import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; jest.mock('axios'); +jest.mock('../util/aws-exports', () => ({ + __esModule: true, + default: { + AWSConfig: { + accessKeyId: 'test-access-key', + secretAccessKey: 'test-secret-key', + region: 'us-east-2', + bucketName: 'bucket', + }, + CognitoAuthConfig: { + userPoolId: 'test-user-pool-id', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }, + }, +})); + function buildFullPayload(): Record { return { Volunteer_StartDate: '06-01-2026', From ccbfe97e4ec9cfd138499328ef601acf7a84ecf4 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:20:04 -0400 Subject: [PATCH 21/27] Fixing Learner CandidateView regression --- apps/backend/src/data-source.ts | 7 +----- .../learner-info/learner-info.controller.ts | 24 ++++++++++++++++--- .../src/learner-info/learner-info.module.ts | 8 ++++++- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index b5727c480..6c116c242 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -21,12 +21,7 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - ssl: sslCa - ? { - rejectUnauthorized: true, - ca: sslCa, - } - : undefined, + ssl: { rejectUnauthorized: false }, entities: [ Application, CandidateInfo, diff --git a/apps/backend/src/learner-info/learner-info.controller.ts b/apps/backend/src/learner-info/learner-info.controller.ts index ff24d3336..99516c74b 100644 --- a/apps/backend/src/learner-info/learner-info.controller.ts +++ b/apps/backend/src/learner-info/learner-info.controller.ts @@ -1,9 +1,11 @@ import { Body, Controller, + ForbiddenException, Get, Param, Post, + Req, UseGuards, UseInterceptors, } from '@nestjs/common'; @@ -11,6 +13,7 @@ import { LearnerInfo } from './learner-info.entity'; import { ApiTags } from '@nestjs/swagger'; import { LearnerInfoService } from './learner-info.service'; import { CreateLearnerInfoDto } from './dto/create-learner-info.request.dto'; +import { ApplicationsService } from '../applications/applications.service'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthGuard } from '@nestjs/passport'; import { UserType } from '../users/types'; @@ -25,7 +28,10 @@ import { RolesGuard } from '../auth/roles.guard'; @UseInterceptors(CurrentUserInterceptor) @UseGuards(AuthGuard('jwt'), RolesGuard) export class LearnerInfoController { - constructor(private learnerInfoService: LearnerInfoService) {} + constructor( + private learnerInfoService: LearnerInfoService, + private applicationsService: ApplicationsService, + ) {} /** * Exposes an endpoint to create a learner info. @@ -51,8 +57,20 @@ export class LearnerInfoController { * @throws {BadRequestException} if the id field is invalid (e.g. null or undefined) */ @Get('/:appId') - @Roles(UserType.ADMIN) - async getLearnerInfo(@Param('appId') appId: number): Promise { + @Roles(UserType.ADMIN, UserType.STANDARD) + async getLearnerInfo( + @Param('appId') appId: number, + @Req() req: { user?: { email?: string; userType?: UserType } }, + ): Promise { + if (req.user?.userType === UserType.STANDARD) { + const application = await this.applicationsService.findById(appId); + if (req.user.email !== application.email) { + throw new ForbiddenException( + 'Standard users can only access their own learner info.', + ); + } + } + return await this.learnerInfoService.findById(appId); } } diff --git a/apps/backend/src/learner-info/learner-info.module.ts b/apps/backend/src/learner-info/learner-info.module.ts index 0d3ffe18c..67a801aee 100644 --- a/apps/backend/src/learner-info/learner-info.module.ts +++ b/apps/backend/src/learner-info/learner-info.module.ts @@ -6,9 +6,15 @@ import { LearnerInfo } from './learner-info.entity'; import { AuthModule } from '../auth/auth.module'; import { UsersModule } from '../users/users.module'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { ApplicationsModule } from '../applications/applications.module'; @Module({ - imports: [TypeOrmModule.forFeature([LearnerInfo]), AuthModule, UsersModule], + imports: [ + TypeOrmModule.forFeature([LearnerInfo]), + AuthModule, + UsersModule, + ApplicationsModule, + ], controllers: [LearnerInfoController], providers: [LearnerInfoService, CurrentUserInterceptor], exports: [LearnerInfoService], From fcf2fd311f655160262ff10b221375b61f391078 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 19:42:28 -0400 Subject: [PATCH 22/27] name regression fix --- .../src/applications/applications.service.ts | 24 +++++++-- .../candidate-provisioning.service.spec.ts | 21 ++++++++ .../candidate-provisioning.service.ts | 49 ++++++++++++++++--- .../pandadoc-webhook.service.spec.ts | 12 ++++- .../pandadoc-webhook.service.ts | 24 +++++++-- 5 files changed, 116 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index 93f7fdc34..fce3b3aad 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -21,6 +21,13 @@ import { LearnerInfo } from '../learner-info/learner-info.entity'; import { PaginatedResult } from '../common/paginated-result.interface'; import { ApplicationQueryDto } from './dto/application-query.dto'; +type CandidateCreateOptions = { + candidateName?: { + firstName?: string; + lastName?: string; + }; +}; + /** * Columns returned by the paginated list endpoints. Only the fields the admin * table renders are selected, so the bulk of each row (availability strings, @@ -882,6 +889,7 @@ export class ApplicationsService { */ async create( createApplicationDto: CreateApplicationDto, + options?: CandidateCreateOptions, ): Promise { this.validateApplicationDto(createApplicationDto); const normalizedEmail = createApplicationDto.email.trim().toLowerCase(); @@ -898,10 +906,18 @@ export class ApplicationsService { }); const saved = await this.applicationRepository.save(application); - await this.candidateProvisioningService.provisionSubmittedCandidate( - saved, - existingApplicationCount === 0, - ); + if (options?.candidateName) { + await this.candidateProvisioningService.provisionSubmittedCandidate( + saved, + existingApplicationCount === 0, + options.candidateName, + ); + } else { + await this.candidateProvisioningService.provisionSubmittedCandidate( + saved, + existingApplicationCount === 0, + ); + } return saved; } diff --git a/apps/backend/src/applications/candidate-provisioning.service.spec.ts b/apps/backend/src/applications/candidate-provisioning.service.spec.ts index 8b1f0d06a..5da169479 100644 --- a/apps/backend/src/applications/candidate-provisioning.service.spec.ts +++ b/apps/backend/src/applications/candidate-provisioning.service.spec.ts @@ -188,4 +188,25 @@ describe('CandidateProvisioningService', () => { expect.not.stringContaining('Temporary password:'), ); }); + + it('uses provided candidate names for database user creation and email greeting', async () => { + mockCognitoIdentityProvider.send.mockResolvedValue({}); + + await service.provisionSubmittedCandidate(application, true, { + firstName: 'Avery', + lastName: 'Johnson', + }); + + expect(mockUsersService.create).toHaveBeenCalledWith( + 'jane.doe@example.com', + 'Avery', + 'Johnson', + UserType.STANDARD, + ); + expect(mockEmailService.queueEmail).toHaveBeenCalledWith( + 'jane.doe@example.com', + 'Your application has been submitted', + expect.stringContaining('Hello Avery,'), + ); + }); }); diff --git a/apps/backend/src/applications/candidate-provisioning.service.ts b/apps/backend/src/applications/candidate-provisioning.service.ts index 76870677b..4a0469a58 100644 --- a/apps/backend/src/applications/candidate-provisioning.service.ts +++ b/apps/backend/src/applications/candidate-provisioning.service.ts @@ -12,6 +12,11 @@ import { CandidateInfoService } from '../candidate-info/candidate-info.service'; import { UsersService } from '../users/users.service'; import { UserType } from '../users/types'; +type CandidateName = { + firstName?: string; + lastName?: string; +}; + @Injectable() export class CandidateProvisioningService { private readonly logger = new Logger(CandidateProvisioningService.name); @@ -72,6 +77,28 @@ export class CandidateProvisioningService { return { firstName, lastName }; } + private cleanNamePart(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const trimmed = value.trim(); + return trimmed ? this.toTitleCase(trimmed) : undefined; + } + + private resolveCandidateName( + email: string, + candidateName?: CandidateName, + ): { firstName: string; lastName: string } { + const derived = this.deriveNameParts(email); + + return { + firstName: + this.cleanNamePart(candidateName?.firstName) ?? derived.firstName, + lastName: this.cleanNamePart(candidateName?.lastName) ?? derived.lastName, + }; + } + private generateTemporaryPassword(): string { const randomSegment = randomBytes(12).toString('base64url'); return `Bhchp-${randomSegment}Aa1!`; @@ -95,13 +122,19 @@ export class CandidateProvisioningService { await this.cognitoIdentityProvider.send(command); } - private async ensureStandardUser(email: string): Promise { + private async ensureStandardUser( + email: string, + candidateName?: CandidateName, + ): Promise { const existingUser = await this.usersService.findOne(email); if (existingUser) { return; } - const { firstName, lastName } = this.deriveNameParts(email); + const { firstName, lastName } = this.resolveCandidateName( + email, + candidateName, + ); await this.usersService.create( email, firstName, @@ -121,14 +154,16 @@ export class CandidateProvisioningService { private buildSubmissionEmailBody( email: string, loginUrl: string, + candidateName?: CandidateName, temporaryPassword?: string, ): string { + const { firstName } = this.resolveCandidateName(email, candidateName); const passwordBlock = temporaryPassword ? `

Temporary password: ${temporaryPassword}

` : ''; return ` -

Hello Applicant,

+

Hello ${firstName},

Thank you for submitting your application! You can now create an account here on the portal to track your status. Please use the same email as your application.

@@ -146,6 +181,7 @@ export class CandidateProvisioningService { async provisionSubmittedCandidate( application: Application, isFirstApplication: boolean, + candidateName?: CandidateName, ): Promise { const normalizedEmail = application.email.trim().toLowerCase(); const loginUrl = this.getPublicLoginUrl(); @@ -159,20 +195,20 @@ export class CandidateProvisioningService { normalizedEmail, temporaryPassword, ); - await this.ensureStandardUser(normalizedEmail); + await this.ensureStandardUser(normalizedEmail, candidateName); } catch (error) { if (this.isUsernameExistsError(error)) { this.logger.warn( `Candidate Cognito user already exists for ${normalizedEmail}; sending login link without temporary password.`, ); temporaryPassword = undefined; - await this.ensureStandardUser(normalizedEmail); + await this.ensureStandardUser(normalizedEmail, candidateName); } else { throw error; } } } else { - await this.ensureStandardUser(normalizedEmail); + await this.ensureStandardUser(normalizedEmail, candidateName); } await this.candidateInfoService.create(application.appId, normalizedEmail); @@ -183,6 +219,7 @@ export class CandidateProvisioningService { this.buildSubmissionEmailBody( normalizedEmail, loginUrl, + candidateName, temporaryPassword, ), ); diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index e6c2dd5a8..30750b42a 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -196,7 +196,11 @@ describe('PandadocWebhookService', () => { }).map(([field_id, value]) => ({ field_id, value, - assigned_to: { email: 'test@example.com' }, + assigned_to: { + email: 'test@example.com', + first_name: 'Jamie', + last_name: 'Smith', + }, })), }, }) @@ -242,6 +246,12 @@ describe('PandadocWebhookService', () => { resume: 'resumes/resume-stored.pdf', coverLetter: 'cover-letters/cover-letter-stored.pdf', }), + expect.objectContaining({ + candidateName: expect.objectContaining({ + firstName: 'Jamie', + lastName: 'Smith', + }), + }), ); expect(learnerInfoService.create).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index e43073547..51f466cfd 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -380,6 +380,14 @@ export class PandadocWebhookService { const email = String(buckets.candidateInfo['email'] ?? ''); const normalizedEmail = email.trim(); + const candidateFirstName = + this.getPayloadString(payload, '_firstName') ?? + this.getPayloadString(payload, 'Volunteer_FirstName') ?? + this.getPayloadString(payload, 'firstName'); + const candidateLastName = + this.getPayloadString(payload, '_lastName') ?? + this.getPayloadString(payload, 'Volunteer_LastName') ?? + this.getPayloadString(payload, 'lastName'); if (!normalizedEmail) { this.logger.warn( `[PandaDoc] Candidate email missing after mapping event=${eventType} documentId=${documentId}`, @@ -395,9 +403,19 @@ export class PandadocWebhookService { )} applicantType=${applicantType}`, ); - const createdApplication = await this.applicationsService.create( - applicationDto, - ); + const candidateName = + candidateFirstName || candidateLastName + ? { + firstName: candidateFirstName, + lastName: candidateLastName, + } + : undefined; + + const createdApplication = candidateName + ? await this.applicationsService.create(applicationDto, { + candidateName, + }) + : await this.applicationsService.create(applicationDto); this.logger.debug( `[PandaDoc] ApplicationsService.create complete appId=${createdApplication.appId}`, From 15cdee600aa2d632973c72188f181850e6199fa9 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:32:37 -0400 Subject: [PATCH 23/27] better discipline formatting for candidate view --- .../src/containers/CandidateViewApplication.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/containers/CandidateViewApplication.tsx b/apps/frontend/src/containers/CandidateViewApplication.tsx index 5652ed747..e30008e18 100644 --- a/apps/frontend/src/containers/CandidateViewApplication.tsx +++ b/apps/frontend/src/containers/CandidateViewApplication.tsx @@ -7,6 +7,7 @@ import axios from 'axios'; import { ApplicantType, Application, + DisciplineCatalogItem, LearnerInfo, User, UserType, @@ -30,9 +31,15 @@ const CandidateViewApplication: React.FC = () => { const [learnerInfo, setLearnerInfo] = useState(null); const [loading, setLoading] = useState(true); const [user, setUser] = useState(null); + const [disciplines, setDisciplines] = useState([]); const [error, setError] = useState(null); const pronouns = application?.pronouns; - const discipline = application?.discipline; + const disciplineLabelByKey = new Map( + disciplines.map((d) => [d.key, d.label]), + ); + const discipline = application?.discipline + ? disciplineLabelByKey.get(application.discipline) ?? application.discipline + : undefined; const formatDate = (iso?: string) => { if (!iso) return 'N/A'; try { @@ -124,9 +131,13 @@ const CandidateViewApplication: React.FC = () => { appId: latestAppId, }); - const app = await apiClient.getCurrentApplication(); + const [app, loadedDisciplines] = await Promise.all([ + apiClient.getCurrentApplication(), + apiClient.getDisciplines(), + ]); if (cancelled) return; setApplication(app); + setDisciplines(loadedDisciplines); if (!app) { console.debug( From 6b8dd2c4660b7603a135f7b6c5115e688ca7a513 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:35:46 -0400 Subject: [PATCH 24/27] Fixing applicantType determination logic from pandadoc --- .../pandadoc-webhook.service.spec.ts | 24 +++++++++++++++++++ .../pandadoc-webhook.service.ts | 14 +++++++---- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts index 30750b42a..f92efc939 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { PandadocWebhookService } from './pandadoc-webhook.service'; import { AppStatus, ApplicantType } from '../applications/types'; +import { School } from '../learner-info/types'; import { ApplicationsService } from '../applications/applications.service'; import { LearnerInfoService } from '../learner-info/learner-info.service'; import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; @@ -329,6 +330,29 @@ describe('PandadocWebhookService', () => { ); }); + it('sets applicantType=VOLUNTEER when affiliation is "Does Not Apply"', async () => { + const applicationsService = buildMockApplicationsService(); + const learnerInfoService = buildMockLearnerInfoService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + learnerInfoService, + ); + + const payload = { + ...buildFullPayload(), + Volunteer_Affiliation: School.DOES_NOT_APPLY, + }; + + await service.processWebhook(payload); + + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ applicantType: ApplicantType.VOLUNTEER }), + ); + expect(learnerInfoService.create).not.toHaveBeenCalled(); + }); + it('sets applicantType=LEARNER when school affiliation is present even if schoolDepartment is empty', async () => { const applicationsService = buildMockApplicationsService(); const service = await buildService( diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index 51f466cfd..ab945a905 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { pandadocMapper } from '../pandadoc-helpers/pandadoc-mapper'; import { AppStatus, ApplicantType, PHONE_REGEX } from '../applications/types'; +import { School } from '../learner-info/types'; import { ApplicationsService } from '../applications/applications.service'; import { CreateApplicationDto } from '../applications/dto/create-application.request.dto'; import { CreateLearnerInfoDto } from '../learner-info/dto/create-learner-info.request.dto'; @@ -349,9 +350,11 @@ export class PandadocWebhookService { } fields)`, ); - const applicantType = buckets.learnerInfo['school'] - ? ApplicantType.LEARNER - : ApplicantType.VOLUNTEER; + const applicantType = + buckets.learnerInfo['school'] && + buckets.learnerInfo['school'] !== School.DOES_NOT_APPLY + ? ApplicantType.LEARNER + : ApplicantType.VOLUNTEER; const applicationData = { ...buckets.application, @@ -421,7 +424,10 @@ export class PandadocWebhookService { `[PandaDoc] ApplicationsService.create complete appId=${createdApplication.appId}`, ); - if (learnerRecord['school']) { + if ( + learnerRecord['school'] && + learnerRecord['school'] !== School.DOES_NOT_APPLY + ) { this.logger.debug( `[PandaDoc] Delegating learner info creation appId=${ createdApplication.appId From f9405945867dee29addcf5a7a805b5196d0476b0 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:39:40 -0400 Subject: [PATCH 25/27] returning data source to normal --- apps/backend/src/data-source.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 6c116c242..da6cf88e3 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -21,7 +21,13 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - ssl: { rejectUnauthorized: false }, + ssl: sslCa + ? { + rejectUnauthorized: true, + ca: sslCa, + } + : undefined, + // ssl: { rejectUnauthorized: false }, entities: [ Application, CandidateInfo, From 71409afe20781f38eec2c059b62afa1f07bf0d88 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:24:08 -0400 Subject: [PATCH 26/27] more logging --- .../pandadoc-webhook.service.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts index ab945a905..c9c28036f 100644 --- a/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -257,6 +257,10 @@ export class PandadocWebhookService { const result = Object.fromEntries(fields.map((f) => [f.field_id, f.value])); + this.logger.log( + `[PandaDoc] Raw Volunteer_Affiliation from API: "${result['Volunteer_Affiliation']}"`, + ); + // Email, phone, and name are not form fields — inject from recipient assigned_to. // PandaDoc populates these depending on how the doc was sent. const recipient = fields[0]?.assigned_to; @@ -350,12 +354,22 @@ export class PandadocWebhookService { } fields)`, ); + const rawAffiliation = payload['Volunteer_Affiliation']; + const mappedSchool = buckets.learnerInfo['school']; + const isDoesNotApply = mappedSchool === School.DOES_NOT_APPLY; const applicantType = - buckets.learnerInfo['school'] && - buckets.learnerInfo['school'] !== School.DOES_NOT_APPLY + mappedSchool && !isDoesNotApply ? ApplicantType.LEARNER : ApplicantType.VOLUNTEER; + this.logger.log( + `[PandaDoc] Applicant type determination:` + + ` rawAffiliation="${rawAffiliation}"` + + ` mappedSchool="${mappedSchool}"` + + ` isDoesNotApply=${isDoesNotApply}` + + ` applicantType=${applicantType}`, + ); + const applicationData = { ...buckets.application, appStatus: AppStatus.APP_SUBMITTED, From 35c202b191f92070194edd8a5b0164dd5257b8f6 Mon Sep 17 00:00:00 2001 From: Sam Nie <147653722+SamNie2027@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:53:04 -0400 Subject: [PATCH 27/27] Allowing standard users to get disciplines so frontend formatting is right --- apps/backend/src/disciplines/disciplines.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/disciplines/disciplines.controller.ts b/apps/backend/src/disciplines/disciplines.controller.ts index 89d66df56..6dd25f981 100644 --- a/apps/backend/src/disciplines/disciplines.controller.ts +++ b/apps/backend/src/disciplines/disciplines.controller.ts @@ -34,7 +34,7 @@ export class DisciplinesController { * @returns a list of all disciplines */ @Get() - @Roles(UserType.ADMIN) + @Roles(UserType.ADMIN, UserType.STANDARD) async getAll( @Query('includeInactive') includeInactive?: string, ): Promise {