Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
5dc610e
Fixing env variables propagation mock on tests
SamNie2027 Jun 25, 2026
7ce0001
Merge branch 'main' into rm-221-pandadoc-webhook-create-app
SamNie2027 Jun 25, 2026
ccbfe97
Fixing Learner CandidateView regression
SamNie2027 Jun 25, 2026
fcf2fd3
name regression fix
SamNie2027 Jun 25, 2026
15cdee6
better discipline formatting for candidate view
SamNie2027 Jun 26, 2026
6b8dd2c
Fixing applicantType determination logic from pandadoc
SamNie2027 Jun 26, 2026
f940594
returning data source to normal
SamNie2027 Jun 26, 2026
71409af
more logging
SamNie2027 Jun 26, 2026
35c202b
Allowing standard users to get disciplines so frontend formatting is …
SamNie2027 Jun 26, 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 @@ -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,
Expand Down Expand Up @@ -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',
);
});
});
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 {}
55 changes: 49 additions & 6 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(',');
Expand Down Expand Up @@ -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',
);
}
}
Expand Down Expand Up @@ -880,6 +889,7 @@ export class ApplicationsService {
*/
async create(
createApplicationDto: CreateApplicationDto,
options?: CandidateCreateOptions,
): Promise<Application> {
this.validateApplicationDto(createApplicationDto);
const normalizedEmail = createApplicationDto.email.trim().toLowerCase();
Expand All @@ -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;
}
Expand Down Expand Up @@ -1091,6 +1109,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 @@ -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,'),
);
});
});
48 changes: 42 additions & 6 deletions apps/backend/src/applications/candidate-provisioning.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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!`;
Expand All @@ -95,13 +122,19 @@ export class CandidateProvisioningService {
await this.cognitoIdentityProvider.send(command);
}

private async ensureStandardUser(email: string): Promise<void> {
private async ensureStandardUser(
email: string,
candidateName?: CandidateName,
): Promise<void> {
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,
Expand All @@ -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
? `<p><strong>Temporary password:</strong> ${temporaryPassword}</p>`
: '';
Expand All @@ -147,6 +181,7 @@ export class CandidateProvisioningService {
async provisionSubmittedCandidate(
application: Application,
isFirstApplication: boolean,
candidateName?: CandidateName,
): Promise<void> {
const normalizedEmail = application.email.trim().toLowerCase();
const loginUrl = this.getPublicLoginUrl();
Expand All @@ -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);
Expand All @@ -184,6 +219,7 @@ export class CandidateProvisioningService {
this.buildSubmissionEmailBody(
normalizedEmail,
loginUrl,
candidateName,
temporaryPassword,
),
);
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const AppDataSource = new DataSource({
ca: sslCa,
}
: undefined,
// ssl: { rejectUnauthorized: false },
entities: [
Application,
CandidateInfo,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/disciplines/disciplines.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Discipline[]> {
Expand Down
24 changes: 21 additions & 3 deletions apps/backend/src/learner-info/learner-info.controller.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Post,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
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';
Expand All @@ -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.
Expand All @@ -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<LearnerInfo> {
@Roles(UserType.ADMIN, UserType.STANDARD)
async getLearnerInfo(
@Param('appId') appId: number,
@Req() req: { user?: { email?: string; userType?: UserType } },
): Promise<LearnerInfo> {
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);
}
}
Loading
Loading