Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
5ef7eac
Add PandaDoc webhook endpoint to create applications from form submis…
rayyanmridha Apr 12, 2026
b5c124b
Remove unused BadRequestException import from webhook service
rayyanmridha Apr 12, 2026
0d49ebd
Update example.env
rayyanmridha Apr 12, 2026
60150b7
Merge branch 'main' into rm-221-pandadoc-webhook-create-app
rayyanmridha May 1, 2026
85c8d93
feat(pandadoc-webhook): add signature guard and drop creation-error f…
ostepan8 May 19, 2026
033e60a
refactor(pandadoc-webhook): wrap creates in a single transaction
ostepan8 May 19, 2026
d1ab2c7
Merge branch 'main' into rm-221-pandadoc-webhook-create-app
SamNie2027 Jun 10, 2026
c44cb47
Merge branch 'main' into rm-221-pandadoc-webhook-create-app
SamNie2027 Jun 10, 2026
342e133
thorough logging
SamNie2027 Jun 10, 2026
9343182
logging entire request that comes from pandadoc
SamNie2027 Jun 11, 2026
1f175c7
Useing HMAC-SHA256 to compare keys as that's what pandadoc is sending
SamNie2027 Jun 11, 2026
82519fc
feat: fetch PandaDoc fields via API and create application on webhook
ostepan8 Jun 11, 2026
63b19c5
fix: create User record on PandaDoc webhook so names show in admin UI
ostepan8 Jun 12, 2026
beb0f96
DOB from Pandadoc is optional
SamNie2027 Jun 13, 2026
d6540dc
Pandadoc - A volunteer is a learner contingent solely on school affil…
SamNie2027 Jun 13, 2026
ad9c9e2
first passthrough at file upload?
SamNie2027 Jun 14, 2026
284c27f
best-effort phone number formatting for emergency contact
SamNie2027 Jun 14, 2026
0e830c3
Merge branch 'main' into rm-221-pandadoc-webhook-create-app
SamNie2027 Jun 14, 2026
d085796
pandadoc calls existing services to create records
SamNie2027 Jun 15, 2026
9623d52
invalid input email hooked up again
SamNie2027 Jun 15, 2026
b608e52
restoring invalid email sending flow and reducing stricness of email
SamNie2027 Jun 15, 2026
5cf95d2
Other school affiliation actually shows on frontend
SamNie2027 Jun 17, 2026
9a2ba6c
Fix pandadoc tests
SamNie2027 Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -36,6 +37,8 @@ import { AdminProvisioningModule } from './admin-provisioning/admin-provisioning
AdminProvisioningModule,
LearnerInfoModule,
ApplicationsModule,
CandidateInfoModule,
PandadocWebhookModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
46 changes: 1 addition & 45 deletions apps/backend/src/applications/application.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,50 +613,6 @@ describe('ApplicationsService', () => {
await expect(service.create(createApplicationDto)).rejects.toThrow();
});

it('should not accept 0 weekly hours', async () => {
const createApplicationDto: CreateApplicationDto = {
...dummyCreateApplicationDto,
weeklyHours: 0,
};

const savedApplication: Application = {
appId: 1,
...createApplicationDto,
proposedStartDate: new Date('2024-01-01'),
endDate: new Date('2024-06-30'),
actualStartDate: undefined,
resume: 'janedoe_resume_2_6_2026.pdf',
coverLetter: 'janedoe_coverLetter_2_6_2026.pdf',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
};

mockRepository.save.mockResolvedValue(savedApplication);
await expect(service.create(createApplicationDto)).rejects.toThrow();
});

it('should not accept negative weekly hours', async () => {
const createApplicationDto: CreateApplicationDto = {
...dummyCreateApplicationDto,
weeklyHours: -5,
};

const savedApplication: Application = {
appId: 1,
...createApplicationDto,
proposedStartDate: new Date('2024-01-01'),
endDate: new Date('2024-06-30'),
actualStartDate: undefined,
resume: 'janedoe_resume_2_6_2026.pdf',
coverLetter: 'janedoe_coverLetter_2_6_2026.pdf',
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
};

mockRepository.save.mockResolvedValue(savedApplication);
await expect(service.create(createApplicationDto)).rejects.toThrow();
});

it('should send an email when creating an application', async () => {
const savedApplication: Application = {
appId: 2,
Expand Down Expand Up @@ -738,7 +694,7 @@ describe('ApplicationsService', () => {
};

await expect(service.create(createApplicationDto)).rejects.toThrow(
'Weekly hours must be greater than 0 and less than 7 * 24 hours',
'Weekly hours must be less than 7 * 24 hours',
);
});
});
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/applications/applications.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,6 @@ import { cognitoIdentityProviderFactory } from '../admin-provisioning/cognito.pr
ApplicationValidationEmailFilter,
ApplicationCreationErrorFilter,
],
exports: [ApplicationsService],
})
export class ApplicationsModule {}
31 changes: 29 additions & 2 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,8 @@ export class ApplicationsService {
'Confidentiality_Form.pdf';
private static readonly CONFIDENTIALITY_UPLOAD_FOLDER =
'confidentiality-forms';
private static readonly PANDADOC_RESUBMISSION_LINK =
'https://eform.pandadoc.com/?eform=e27f6460-7fa2-40f2-825b-4a83c507b9fe';

private static readonly APPLICATION_EXPORT_HEADERS =
APPLICATION_EXPORT_COLUMNS.map(([, header]) => header).join(',');
Expand Down Expand Up @@ -358,9 +360,9 @@ export class ApplicationsService {
}

// Validate weeklyHours is positive
if (dto.weeklyHours <= 0 || dto.weeklyHours > 7 * 24) {
if (dto.weeklyHours > 7 * 24) {
throw new BadRequestException(
'Weekly hours must be greater than 0 and less than 7 * 24 hours',
'Weekly hours must be less than 7 * 24 hours',
);
}
}
Expand Down Expand Up @@ -890,6 +892,31 @@ export class ApplicationsService {
await this.applicationRepository.remove(application);
}

async sendSubmissionErrorEmail(
applicantDto: CreateApplicationDto,
errorMessage: string,
applicantName = 'Applicant',
): Promise<void> {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,14 @@ export class CandidateProvisioningService {
loginUrl: string,
temporaryPassword?: string,
): string {
const { firstName } = this.deriveNameParts(email);
const passwordBlock = temporaryPassword
? `<p><strong>Temporary password:</strong> ${temporaryPassword}</p>

<p>We created your applicant account so you can log in and track your status.</p>`
: `<p>You can log in with your existing applicant account to track your status.</p>`;

return `
<p>Hello ${firstName},</p>
<p>Hello Applicant,</p>

<p>Your application has been submitted successfully.</p>

Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/learner-info/learner-info.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
2 changes: 1 addition & 1 deletion apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
18 changes: 16 additions & 2 deletions apps/backend/src/pandadoc-helpers/pandadoc-field-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -185,7 +197,8 @@ export const PANDADOC_FIELD_MAP: ValidPayload[] = [
{
pandaDocKey: 'Volunteer_Phone',
backendField: 'phone',
required: true,
required: false,
defaultValue: '',
targetTable: 'application',
},
{
Expand All @@ -203,6 +216,7 @@ export const PANDADOC_FIELD_MAP: ValidPayload[] = [
{
pandaDocKey: 'Volunteer_Discipline',
backendField: 'discipline',
transform: (value: string) => normalizeDisciplineKey(value),
required: true,
targetTable: 'application',
},
Expand Down Expand Up @@ -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',
},
{
Expand Down
17 changes: 17 additions & 0 deletions apps/backend/src/pandadoc-helpers/pandadoc-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
92 changes: 92 additions & 0 deletions apps/backend/src/pandadoc-webhook/pandadoc-signature.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createHmac } from 'crypto';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PandadocSignatureGuard } from './pandadoc-signature.guard';

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<string, string | string[]>;
}): 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);
});
});
});
Loading
Loading