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/application.service.spec.ts b/apps/backend/src/applications/application.service.spec.ts index 615da28bb..b689e17ca 100644 --- a/apps/backend/src/applications/application.service.spec.ts +++ b/apps/backend/src/applications/application.service.spec.ts @@ -691,50 +691,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, @@ -816,7 +772,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.module.ts b/apps/backend/src/applications/applications.module.ts index e66d3202c..5bb730b73 100644 --- a/apps/backend/src/applications/applications.module.ts +++ b/apps/backend/src/applications/applications.module.ts @@ -36,5 +36,6 @@ import { cognitoIdentityProviderFactory } from '../admin-provisioning/cognito.pr ApplicationValidationEmailFilter, ApplicationCreationErrorFilter, ], + exports: [ApplicationsService], }) export class ApplicationsModule {} diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index c0858f15a..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, @@ -242,6 +249,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(','); @@ -387,9 +396,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', ); } } @@ -880,6 +889,7 @@ export class ApplicationsService { */ async create( createApplicationDto: CreateApplicationDto, + options?: CandidateCreateOptions, ): Promise { this.validateApplicationDto(createApplicationDto); const normalizedEmail = createApplicationDto.email.trim().toLowerCase(); @@ -896,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; } @@ -1091,6 +1109,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/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 557039be2..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,9 +154,10 @@ export class CandidateProvisioningService { private buildSubmissionEmailBody( email: string, loginUrl: string, + candidateName?: CandidateName, temporaryPassword?: string, ): string { - const { firstName } = this.deriveNameParts(email); + const { firstName } = this.resolveCandidateName(email, candidateName); const passwordBlock = temporaryPassword ? `

Temporary password: ${temporaryPassword}

` : ''; @@ -147,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(); @@ -160,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); @@ -184,6 +219,7 @@ export class CandidateProvisioningService { this.buildSubmissionEmailBody( normalizedEmail, loginUrl, + candidateName, temporaryPassword, ), ); diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index b5727c480..da6cf88e3 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -27,6 +27,7 @@ const AppDataSource = new DataSource({ ca: sslCa, } : undefined, + // ssl: { rejectUnauthorized: false }, entities: [ Application, CandidateInfo, 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 { 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 4301a28b6..67a801aee 100644 --- a/apps/backend/src/learner-info/learner-info.module.ts +++ b/apps/backend/src/learner-info/learner-info.module.ts @@ -6,10 +6,17 @@ 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], }) export class LearnerInfoModule {} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 5ca7ae6d9..7ff5381f0 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-helpers/pandadoc-field-map.ts b/apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts index 6fc2366ff..f7324d15a 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', }, @@ -506,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-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; 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..243e6c77a --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts @@ -0,0 +1,109 @@ +import { createHmac } from 'crypto'; +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' }])); + +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: () => ({ + 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; +} + +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', () => { + it('allows the request when HMAC-SHA256 signature matches', () => { + const guard = buildGuard(KEY); + const context = makeContext({ + querySignature: hmac(KEY, BODY), + rawBody: BODY, + }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('rejects with UnauthorizedException when signature query param is absent', () => { + const guard = buildGuard(KEY); + 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({ + querySignature: 'deadbeef', + rawBody: BODY, + }); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + + it('rejects when signature computed from a different body', () => { + const guard = buildGuard(KEY); + const otherBody = Buffer.from(JSON.stringify([{ event: 'other' }])); + const context = makeContext({ + querySignature: hmac(KEY, otherBody), + rawBody: BODY, + }); + expect(() => guard.canActivate(context)).toThrow(UnauthorizedException); + }); + }); + + 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..b16aacbb6 --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.ts @@ -0,0 +1,143 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac, timingSafeEqual } from 'crypto'; +import { Request } from 'express'; + +/** + * 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. + * + * 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 { + 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'); + } + + 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 { + 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}`, + ); + } + } + + 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 sourceIp = request.ip ?? request.socket?.remoteAddress ?? 'unknown'; + + if (!this.webhookKey) { + if (!this.warnedAboutMissingKey) { + this.logger.warn( + 'PANDADOC_WEBHOOK_KEY is not set — webhook signature verification is disabled', + ); + this.warnedAboutMissingKey = true; + } + this.logger.debug( + `[PandaDoc] Signature verification bypassed sourceIp=${sourceIp} reason=missing-config`, + ); + return true; + } + + const provided = + typeof request.query['signature'] === 'string' + ? request.query['signature'] + : undefined; + + this.logger.debug( + `[PandaDoc] Verifying HMAC-SHA256 signature sourceIp=${sourceIp} signaturePresent=${Boolean( + provided, + )} signatureLength=${provided?.length ?? 0}`, + ); + + 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] 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.maskHex( + provided, + )} rawBodyLength=${rawBody.length}`, + ); + + 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..dc074b03c --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.controller.ts @@ -0,0 +1,48 @@ +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: unknown) { + const startedAt = Date.now(); + this.logger.log('[PandaDoc] Incoming webhook request'); + + try { + const result = await this.webhookService.handleIncomingWebhook(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 durationMs=${durationMs} error=${message}`, + error.stack, + ); + } else { + this.logger.error( + `[PandaDoc] Webhook request failed durationMs=${durationMs} error=${message}`, + ); + } + throw error; + } + } +} 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..0786bca1f --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.module.ts @@ -0,0 +1,15 @@ +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, ApplicationsModule, LearnerInfoModule], + 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..f92efc939 --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.spec.ts @@ -0,0 +1,534 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +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'; + +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', + 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', + }; +} + +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 } }]; +} + +function buildMockS3Service(): Pick { + return { + uploadWithKey: jest + .fn() + .mockImplementation( + 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`, + }; + }, + ), + }; +} + +function buildMockApplicationsService(generatedAppId = 42) { + return { + create: jest.fn(async (dto) => ({ + appId: generatedAppId, + ...dto, + })), + sendSubmissionErrorEmail: jest.fn().mockResolvedValue(undefined), + } as unknown as Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >; +} + +function buildMockLearnerInfoService() { + return { + create: jest.fn(async (dto) => dto), + } as unknown as Pick; +} + +describe('PandadocWebhookService', () => { + async function buildService( + configService?: ConfigService, + awsS3Service?: Pick, + applicationsService?: Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >, + learnerInfoService?: Pick, + ): Promise { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PandadocWebhookService, + { + provide: ConfigService, + useValue: configService ?? buildMockConfigService(), + }, + { + provide: AWSS3Service, + useValue: awsS3Service ?? buildMockS3Service(), + }, + { + provide: ApplicationsService, + useValue: applicationsService ?? buildMockApplicationsService(), + }, + { + provide: LearnerInfoService, + useValue: learnerInfoService ?? buildMockLearnerInfoService(), + }, + ], + }).compile(); + return module.get(PandadocWebhookService); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', async () => { + const service = await buildService(); + expect(service).toBeDefined(); + }); + + describe('handleIncomingWebhook', () => { + 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( + undefined, + s3Service, + applicationsService, + learnerInfoService, + ); + + 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', + first_name: 'Jamie', + last_name: 'Smith', + }, + })), + }, + }) + .mockResolvedValue({ + data: Buffer.from('file-bytes'), + headers: { 'content-type': 'application/pdf' }, + }); + + 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(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(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.objectContaining({ + candidateName: expect.objectContaining({ + firstName: 'Jamie', + lastName: 'Smith', + }), + }), + ); + expect(learnerInfoService.create).toHaveBeenCalledWith( + expect.objectContaining({ + appId: 99, + syllabus: 'syllabus/syllabus-stored.pdf', + }), + ); + }); + + it('throws BadRequestException when payload is not an array of events', async () => { + const service = await buildService(); + + await expect(service.handleIncomingWebhook({})).rejects.toThrow( + BadRequestException, + ); + }); + + it('throws BadRequestException when data.id is missing', async () => { + const service = await buildService(); + + 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 service = await buildService(buildMockConfigService('')); + + await expect( + service.handleIncomingWebhook(buildWebhookEvent()), + ).rejects.toThrow('PandaDoc API key is not configured'); + }); + }); + + describe('processWebhook - happy path', () => { + 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(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 }), + ); + }); + + it('sets applicantType=LEARNER when school is present', async () => { + const applicationsService = buildMockApplicationsService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + ); + + await service.processWebhook(buildFullPayload()); + + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ applicantType: ApplicantType.LEARNER }), + ); + }); + + 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( + undefined, + undefined, + applicationsService, + ); + + await service.processWebhook({ + ...buildFullPayload(), + Volunteer_Affiliation: 'Boston University', + Volunteer_Department: '', + }); + + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ applicantType: ApplicantType.LEARNER }), + ); + }); + + it('formats proposedStartDate as YYYY-MM-DD', async () => { + const applicationsService = buildMockApplicationsService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + ); + + await service.processWebhook(buildFullPayload()); + + expect(applicationsService.create).toHaveBeenCalledWith( + expect.objectContaining({ + proposedStartDate: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), + }), + ); + }); + }); + + describe('processWebhook - validation', () => { + it('does not throw when Volunteer_DOB is missing', async () => { + const learnerInfoService = buildMockLearnerInfoService(); + const service = await buildService( + undefined, + undefined, + undefined, + learnerInfoService, + ); + + const { Volunteer_DOB, ...payloadWithoutDob } = buildFullPayload(); + + await expect(service.processWebhook(payloadWithoutDob)).resolves.toEqual({ + appId: 42, + }); + expect(Volunteer_DOB).toBe('01-15-2000'); + expect(learnerInfoService.create).toHaveBeenCalledWith( + expect.objectContaining({ dateOfBirth: undefined }), + ); + }); + + it('throws for missing required PandaDoc fields', async () => { + 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(applicationsService.create).not.toHaveBeenCalled(); + expect( + applicationsService.sendSubmissionErrorEmail, + ).not.toHaveBeenCalled(); + }); + + it('throws BadRequestException for malformed phone number', async () => { + 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(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', + ); + }); + }); + + 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')), + sendSubmissionErrorEmail: jest.fn().mockResolvedValue(undefined), + } as unknown as Pick< + ApplicationsService, + 'create' | 'sendSubmissionErrorEmail' + >; + const learnerInfoService = buildMockLearnerInfoService(); + const service = await buildService( + undefined, + undefined, + applicationsService, + learnerInfoService, + ); + + await expect(service.processWebhook(buildFullPayload())).rejects.toThrow( + 'Forced failure on Application', + ); + expect(learnerInfoService.create).not.toHaveBeenCalled(); + expect( + applicationsService.sendSubmissionErrorEmail, + ).not.toHaveBeenCalled(); + }); + + 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( + undefined, + undefined, + undefined, + learnerInfoService, + ); + + 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..c9c28036f --- /dev/null +++ b/apps/backend/src/pandadoc-webhook/pandadoc-webhook.service.ts @@ -0,0 +1,522 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +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'; +import { LearnerInfoService } from '../learner-info/learner-info.service'; +import { AWSS3Service } from '../util/aws-s3/aws-s3.service'; + +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 + * 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 configService: ConfigService, + private readonly awsS3Service: AWSS3Service, + private readonly applicationsService: ApplicationsService, + private readonly learnerInfoService: LearnerInfoService, + ) {} + + 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. + */ + 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}`; + } + + 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 + * 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; + first_name?: string; + last_name?: 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])); + + 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; + 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`); + } + 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. + 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']; + } + + await this.uploadPandaDocFiles(result, apiKey); + + return result; + } + + /** + * Process a flat PandaDoc field map: map fields into persistence buckets and + * create all three records inside a single transaction. + * + * @param payload Flat field id -> value record (e.g. from the PandaDoc fields API) + * @returns Object containing the created appId + */ + async processWebhook( + payload: Record, + ): 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') ?? + this.getPayloadString(payload, 'id') ?? + 'unknown'; + + this.logger.log( + `[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' + }`, + ); + + try { + this.logger.debug( + '[PandaDoc] Mapping webhook payload into persistence buckets', + ); + 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 + } fields), candidateInfo(${ + Object.keys(buckets.candidateInfo).length + } fields), learnerInfo(${ + Object.keys(buckets.learnerInfo).length + } fields)`, + ); + + const rawAffiliation = payload['Volunteer_Affiliation']; + const mappedSchool = buckets.learnerInfo['school']; + const isDoesNotApply = mappedSchool === School.DOES_NOT_APPLY; + const applicantType = + 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, + applicantType, + proposedStartDate: this.formatDate( + buckets.application['proposedStartDate'], + ), + endDate: this.formatDate(buckets.application['endDate']), + }; + applicationDto = applicationData as CreateApplicationDto; + const applicationRecord = applicationData as Record; + + this.logger.debug( + `[PandaDoc] Prepared application record applicantType=${applicantType} phoneMask=${this.maskPhone( + applicationRecord['phone'], + )}`, + ); + this.validatePhone(applicationRecord['phone']); + + const learnerData = { + ...(buckets.learnerInfo as Record), + dateOfBirth: this.formatDate(buckets.learnerInfo['dateOfBirth']), + }; + const learnerRecord = learnerData as Record; + + 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}`, + ); + throw new BadRequestException( + 'Webhook payload missing applicant email', + ); + } + + this.logger.log( + `[PandaDoc] Delegating application creation emailMask=${this.maskEmail( + normalizedEmail, + )} applicantType=${applicantType}`, + ); + + 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}`, + ); + + if ( + learnerRecord['school'] && + learnerRecord['school'] !== School.DOES_NOT_APPLY + ) { + 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=${ + createdApplication.appId + } durationMs=${Date.now() - startedAt}`, + ); + return { appId: createdApplication.appId }; + } catch (error) { + const durationMs = Date.now() - startedAt; + 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}`, + ); + } 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 === '') return; + if (!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)}`, + ); + } +} 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} 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 = () => { { 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( @@ -275,6 +286,7 @@ const CandidateViewApplication: React.FC = () => {