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 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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', + ); + } + } }