diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index a7cfe8a4a..fac23539d 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { DisciplinesModule } from './disciplines/disciplines.module'; import { AdminInfoModule } from './admin-info/admin-info.module'; import { CandidateInfoModule } from './candidate-info/candidate-info.module'; import { AdminProvisioningModule } from './admin-provisioning/admin-provisioning.module'; +import { PandadocWebhookModule } from './pandadoc-webhook/pandadoc-webhook.module'; @Module({ imports: [ @@ -36,6 +37,8 @@ import { AdminProvisioningModule } from './admin-provisioning/admin-provisioning AdminProvisioningModule, LearnerInfoModule, 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 ed04c45fb..1bad2471d 100644 --- a/apps/backend/src/applications/applications.module.ts +++ b/apps/backend/src/applications/applications.module.ts @@ -30,5 +30,6 @@ import { EmailService } from '../util/email/email.service'; 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-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 new file mode 100644 index 000000000..319149b98 --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Logger, Post, UseGuards } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { PandadocWebhookService } from './pandadoc-webhook.service'; +import { PandadocSignatureGuard } from './pandadoc-signature.guard'; + +/** + * Public endpoint that receives PandaDoc webhook events. + * 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); + + constructor(private readonly webhookService: PandadocWebhookService) {} + + @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 }; + } +} 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..f7de4699a --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PandadocWebhookController } from './pandadoc-webhook.controller'; +import { PandadocWebhookService } from './pandadoc-webhook.service'; +import { PandadocSignatureGuard } from './pandadoc-signature.guard'; + +@Module({ + imports: [ConfigModule], + controllers: [PandadocWebhookController], + 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 new file mode 100644 index 000000000..0b734d8c8 --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -0,0 +1,216 @@ +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 { AppStatus, ApplicantType } from '../applications/types'; + +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', + }; +} + +interface Saved { + Application?: Record; + CandidateInfo?: Record; + LearnerInfo?: Record; +} + +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; +} + +describe('PandadocWebhookService', () => { + async function buildService( + dataSource: DataSource, + ): Promise { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PandadocWebhookService, + { provide: getDataSourceToken(), useValue: dataSource }, + ], + }).compile(); + return module.get(PandadocWebhookService); + } + + it('should be defined', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + expect(service).toBeDefined(); + }); + + 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); + + 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(saved.LearnerInfo).toEqual(expect.objectContaining({ appId: 42 })); + }); + + 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('sets applicantType=VOLUNTEER when schoolDepartment is empty', async () => { + const saved: Saved = {}; + const service = await buildService(buildMockDataSource({ saved })); + await service.processWebhook({ + ...buildFullPayload(), + Volunteer_Department: '', + }); + expect(saved.Application?.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 - 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 expect( + service.processWebhook({ email: 'x@example.com' }), + ).rejects.toThrow('Missing required PandaDoc fields'); + + expect(txSpy).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 payload = { ...buildFullPayload(), Volunteer_Phone: 'not-a-phone' }; + await expect(service.processWebhook(payload)).rejects.toThrow( + BadRequestException, + ); + expect(txSpy).not.toHaveBeenCalled(); + }); + }); + + 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(buildFullPayload())).rejects.toThrow( + 'Forced failure on Application', + ); + expect(saved.Application).toBeUndefined(); + expect(saved.CandidateInfo).toBeUndefined(); + expect(saved.LearnerInfo).toBeUndefined(); + }); + + 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(buildFullPayload())).rejects.toThrow( + 'Forced failure on CandidateInfo', + ); + 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 new file mode 100644 index 000000000..3a9d53f8e --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -0,0 +1,130 @@ +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, 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(@InjectDataSource() private readonly dataSource: DataSource) {} + + /** + * 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 (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 inside a single transaction. + * + * @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'); + + 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 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); + + 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', + ); + } + } +} 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