diff --git a/apps/backend/PANDADOC_WEBHOOK_SETUP.md b/apps/backend/PANDADOC_WEBHOOK_SETUP.md new file mode 100644 index 00000000..b8854bb5 --- /dev/null +++ b/apps/backend/PANDADOC_WEBHOOK_SETUP.md @@ -0,0 +1,90 @@ +# PandaDoc Webhook + +This document describes the PandaDoc webhook integration for capturing volunteer application data. If confused talk to Owen! + +## Overview + +The backend now includes an endpoint that receives PandaDoc webhook events when a volunteer application form is completed. The endpoint automatically parses the form data and creates a new application record in the database. + +## Endpoint Details + +**URL:** `POST /api/applications/webhook/pandadoc` + +**Supported Events:** + +- `document_completed` - Triggered when a document is fully completed +- `recipient_completed` - Triggered when a recipient completes their part + +**Response:** + +```json +{ + "success": true, + "application": { + "appId": 1, + "name": "John Doe", + "email": "john.doe@example.com", + ... + } +} +``` + +## Database Schema Changes + +The following fields have been added to the `Application` entity: + +- **name** (varchar, required) - Applicant's full name +- **email** (varchar, required) - Applicant's email address +- **disciplineId** (integer, optional) - Foreign key reference to Discipline table + +I wasn't entirely sure why this wasn't here already but my ticket mentioned these fields so i added them. Check new migration + +## Field Mapping + +The webhook handler maps PandaDoc form fields to Application fields. It supports **snake_case**, **Title Case**, and **camelCase** field name variations: + +| PandaDoc Field Name | Application Field | Type | Notes | +| ------------------------------------------------------ | ----------------- | ------- | -------------------------------------------------------------- | +| `name`, `Name`, `Full Name` | name | string | Falls back to combining first_name + last_name from recipients | +| `email`, `Email` | email | string | Falls back to recipient email | +| `phone`, `Phone` | phone | string | | +| `discipline`, `Discipline` | disciplineId | number | Parsed as integer | +| `school`, `School` | school | enum | Must match School enum values | +| `experience_type`, `Experience Type`, `experienceType` | experienceType | enum | BS, MS, PhD, MD, etc. | +| `interest`, `Interest Area`, `interestArea` | interest | enum | Nursing, HarmReduction, WomensHealth | +| `days_available`, `Days Available`, `daysAvailable` | daysAvailable | string | | +| `weekly_hours`, `Weekly Hours`, `weeklyHours` | weeklyHours | number | | +| `license`, `License` | license | string | | +| `is_international`, `International`, `isInternational` | isInternational | boolean | Accepts: true/false, yes/no, 1/0 | +| `is_learner`, `Learner`, `isLearner` | isLearner | boolean | | +| `referred`, `Referred` | referred | boolean | | +| `referred_email`, `Referred Email`, `referredEmail` | referredEmail | string | | +| `file_uploads`, `File Uploads`, `fileUploads` | fileUploads | array | JSON array or comma-separated | + +## Setting Up PandaDoc Webhook + +1. Log in to your PandaDoc account +2. Navigate to Settings > Integrations > Webhooks +3. Click "Create Webhook" +4. Configure the webhook: + - **URL:** `https://your-backend-domain.com/api/applications/webhook/pandadoc` + - **Events:** Select `document.completed` and/or `recipient.completed` + - **Active:** Enable the webhook +5. Save the webhook configuration + +## Local Testing + +1. Ensure the database is running +2. Run migrations: `npm run migration:run` +3. Start the backend server + +```bash +cd apps/backend/test-data +./test-webhook.sh +``` + +## API Documentation + +``` +http://localhost:3000/api +``` diff --git a/apps/backend/e2e-tests/mock-pandadoc-webhook.json b/apps/backend/e2e-tests/mock-pandadoc-webhook.json new file mode 100644 index 00000000..654424f1 --- /dev/null +++ b/apps/backend/e2e-tests/mock-pandadoc-webhook.json @@ -0,0 +1,110 @@ +{ + "event": "document_completed", + "data": { + "id": "abc123-pandadoc-document-id", + "name": "BHCHP Volunteer Application - John Doe", + "status": "document.completed", + "recipients": [ + { + "email": "john.doe@example.com", + "first_name": "John", + "last_name": "Doe" + } + ], + "fields": [ + { + "uuid": "field-1", + "name": "name", + "title": "Full Name", + "value": "John Doe" + }, + { + "uuid": "field-2", + "name": "email", + "title": "Email", + "value": "john.doe@example.com" + }, + { + "uuid": "field-3", + "name": "phone", + "title": "Phone", + "value": "617-555-1234" + }, + { + "uuid": "field-4", + "name": "discipline", + "title": "Discipline", + "value": "" + }, + { + "uuid": "field-5", + "name": "school", + "title": "School", + "value": "Harvard Medical School" + }, + { + "uuid": "field-6", + "name": "experience_type", + "title": "Experience Type", + "value": "MD" + }, + { + "uuid": "field-7", + "name": "interest", + "title": "Interest Area", + "value": "Nursing" + }, + { + "uuid": "field-8", + "name": "days_available", + "title": "Days Available", + "value": "Monday, Wednesday, Friday" + }, + { + "uuid": "field-9", + "name": "weekly_hours", + "title": "Weekly Hours", + "value": "10" + }, + { + "uuid": "field-10", + "name": "license", + "title": "License", + "value": "MD-12345" + }, + { + "uuid": "field-11", + "name": "is_international", + "title": "International", + "value": "false" + }, + { + "uuid": "field-12", + "name": "is_learner", + "title": "Learner", + "value": "true" + }, + { + "uuid": "field-13", + "name": "referred", + "title": "Referred", + "value": "true" + }, + { + "uuid": "field-14", + "name": "referred_email", + "title": "Referred Email", + "value": "referrer@example.com" + }, + { + "uuid": "field-15", + "name": "file_uploads", + "title": "File Uploads", + "value": "[\"https://example.com/resume.pdf\", \"https://example.com/cv.pdf\"]" + } + ], + "metadata": { + "source": "volunteer_application_form" + } + } +} diff --git a/apps/backend/e2e-tests/test-webhook.sh b/apps/backend/e2e-tests/test-webhook.sh new file mode 100755 index 00000000..b096a75e --- /dev/null +++ b/apps/backend/e2e-tests/test-webhook.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +# Test script for PandaDoc webhook endpoint +# Usage: ./test-webhook.sh [port] +# Default port is 3000 + +PORT=${1:-3000} +BASE_URL="http://localhost:${PORT}/api/applications/webhook/pandadoc" + +echo "Testing PandaDoc webhook endpoint at: ${BASE_URL}" + +# Send the mock webhook payload +curl -X POST "${BASE_URL}" \ + -H "Content-Type: application/json" \ + -d @mock-pandadoc-webhook.json \ + -v + +echo "Test completed!" diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 54fb044e..fa7e56a9 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,12 +3,17 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { TaskModule } from './task/task.module'; import AppDataSource from './data-source'; +import { ApplicationsController } from './applications/applications.controller'; +import { ApplicationsService } from './applications/applications.service'; +import { Application } from './applications/application.entity'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options), TaskModule], - controllers: [AppController], - providers: [AppService], + imports: [ + TypeOrmModule.forRoot(AppDataSource.options), + TypeOrmModule.forFeature([Application]), + ], + controllers: [AppController, ApplicationsController], + providers: [AppService, ApplicationsService], }) export class AppModule {} diff --git a/apps/backend/src/applications/application.entity.ts b/apps/backend/src/applications/application.entity.ts new file mode 100644 index 00000000..ae3e79b0 --- /dev/null +++ b/apps/backend/src/applications/application.entity.ts @@ -0,0 +1,68 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToOne, + JoinColumn, +} from 'typeorm'; + +import { AppStatus, ExperienceType, InterestArea, School } from './types'; +import { Discipline } from '../disciplines/disciplines.entity'; + +@Entity('application') +export class Application { + @PrimaryGeneratedColumn() + appId!: number; + + @Column({ type: 'varchar' }) + name!: string; + + @Column({ type: 'varchar' }) + email!: string; + + @Column({ type: 'int', nullable: true }) + disciplineId?: number; + + @ManyToOne(() => Discipline, { nullable: true }) + @JoinColumn({ name: 'disciplineId' }) + discipline?: Discipline; + + @Column({ type: 'enum', enum: AppStatus, default: AppStatus.APP_SUBMITTED }) + appStatus!: AppStatus; + + @Column({ type: 'varchar' }) + daysAvailable!: string; + + @Column({ type: 'enum', enum: ExperienceType }) + experienceType!: ExperienceType; + + @Column('text', { array: true, default: [] }) + fileUploads!: string[]; + + @Column({ type: 'enum', enum: InterestArea }) + interest!: InterestArea; + + @Column({ type: 'varchar' }) + license!: string; + + @Column({ type: 'boolean', default: false }) + isInternational!: boolean; + + @Column({ type: 'boolean', default: false }) + isLearner!: boolean; + + @Column({ type: 'varchar' }) + phone!: string; + + @Column({ type: 'enum', enum: School }) + school!: School; + + @Column({ type: 'boolean', default: false, nullable: true }) + referred?: boolean; + + @Column({ type: 'varchar', nullable: true }) + referredEmail?: string; + + @Column({ type: 'int' }) + weeklyHours!: number; +} diff --git a/apps/backend/src/applications/application.service.spec.ts b/apps/backend/src/applications/application.service.spec.ts new file mode 100644 index 00000000..ada5eace --- /dev/null +++ b/apps/backend/src/applications/application.service.spec.ts @@ -0,0 +1,270 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { ApplicationsService } from './applications.service'; +import { Application } from './application.entity'; +import { CreateApplicationDto } from './dto/create-application.request.dto'; +import { AppStatus, ExperienceType, InterestArea, School } from './types'; + +describe('ApplicationsService', () => { + let service: ApplicationsService; + let repository: Repository; + + const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicationsService, + { + provide: getRepositoryToken(Application), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(ApplicationsService); + repository = module.get>( + getRepositoryToken(Application), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return an array of applications', async () => { + const mockApplications: Application[] = [ + { + appId: 1, + name: 'Test User', + email: 'test@example.com', + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }, + ]; + + mockRepository.find.mockResolvedValue(mockApplications); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual(mockApplications); + }); + }); + + describe('findById', () => { + it('should return a single application', async () => { + const mockApplication: Application = { + appId: 1, + name: 'Test User', + email: 'test@example.com', + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + + const result = await service.findById(1); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(result).toEqual(mockApplication); + }); + + it('should throw NotFoundException when application is not found', async () => { + const nonExistentId = 999; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.findById(nonExistentId)).rejects.toThrow( + new NotFoundException(`Application with ID ${nonExistentId} not found`), + ); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { appId: nonExistentId }, + }); + }); + }); + + describe('create', () => { + it('should create and save a new application', async () => { + const createApplicationDto: CreateApplicationDto = { + name: 'John Doe', + email: 'john.doe@example.com', + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Tuesday', + experienceType: ExperienceType.BS, + fileUploads: [], + interest: InterestArea.NURSING, + license: null, + isInternational: false, + isLearner: false, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + referredEmail: null, + weeklyHours: 20, + }; + + const savedApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + ...createApplicationDto, + }; + + mockRepository.save.mockResolvedValue(savedApplication); + + const result = await service.create(createApplicationDto); + + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(savedApplication); + }); + }); + + describe('handlePandaDocWebhook', () => { + it('should process a document_completed webhook and create an application', async () => { + const webhookData = { + event: 'document_completed', + data: { + id: 'doc-123', + name: 'Test Document', + status: 'document.completed', + recipients: [ + { + email: 'jane.doe@example.com', + first_name: 'Jane', + last_name: 'Doe', + }, + ], + fields: [ + { uuid: 'f1', name: 'phone', value: '617-555-1234' }, + { uuid: 'f2', name: 'school', value: 'Harvard Medical School' }, + { uuid: 'f3', name: 'days_available', value: 'Monday, Wednesday' }, + { uuid: 'f4', name: 'weekly_hours', value: '10' }, + { uuid: 'f5', name: 'experience_type', value: 'MD' }, + { uuid: 'f6', name: 'interest', value: 'Nursing' }, + { uuid: 'f7', name: 'license', value: 'MD-12345' }, + { uuid: 'f8', name: 'is_international', value: 'false' }, + { uuid: 'f9', name: 'is_learner', value: 'true' }, + { uuid: 'f10', name: 'referred', value: 'false' }, + { uuid: 'f11', name: 'file_uploads', value: '[]' }, + ], + }, + }; + + const expectedApplication = { + appId: 1, + name: 'Jane Doe', + email: 'jane.doe@example.com', + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: 'Monday, Wednesday', + experienceType: ExperienceType.MD, + fileUploads: [], + interest: InterestArea.NURSING, + license: 'MD-12345', + isInternational: false, + isLearner: true, + phone: '617-555-1234', + school: School.HARVARD_MEDICAL_SCHOOL, + referred: false, + weeklyHours: 10, + }; + + mockRepository.create.mockReturnValue(expectedApplication); + mockRepository.save.mockResolvedValue(expectedApplication); + + const result = await service.handlePandaDocWebhook(webhookData); + + expect(repository.create).toHaveBeenCalled(); + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(expectedApplication); + }); + + it('should throw an error for unsupported webhook events', async () => { + const webhookData = { + event: 'document_created', + data: { + id: 'doc-123', + fields: [], + recipients: [], + }, + }; + + await expect(service.handlePandaDocWebhook(webhookData)).rejects.toThrow( + 'Event type document_created is not processed', + ); + }); + + it('should extract name from name field in webhook data', async () => { + const webhookData = { + event: 'document_completed', + data: { + id: 'doc-456', + recipients: [], + fields: [ + { uuid: 'f1', name: 'name', value: 'Alice Smith' }, + { uuid: 'f2', name: 'email', value: 'alice@example.com' }, + { uuid: 'f3', name: 'phone', value: '555-0001' }, + { uuid: 'f4', name: 'school', value: 'Other' }, + { uuid: 'f5', name: 'days_available', value: 'Friday' }, + { uuid: 'f6', name: 'weekly_hours', value: '5' }, + { uuid: 'f7', name: 'experience_type', value: 'BS' }, + { uuid: 'f8', name: 'interest', value: 'Nursing' }, + { uuid: 'f9', name: 'license', value: '' }, + { uuid: 'f10', name: 'is_international', value: 'true' }, + { uuid: 'f11', name: 'is_learner', value: 'false' }, + { uuid: 'f12', name: 'referred', value: 'false' }, + { uuid: 'f13', name: 'file_uploads', value: '[]' }, + ], + }, + }; + + const expectedApplication = { + appId: 2, + name: 'Alice Smith', + email: 'alice@example.com', + }; + + mockRepository.create.mockReturnValue(expectedApplication); + mockRepository.save.mockResolvedValue(expectedApplication); + + const result = await service.handlePandaDocWebhook(webhookData); + + expect(result.name).toBe('Alice Smith'); + expect(result.email).toBe('alice@example.com'); + }); + }); +}); diff --git a/apps/backend/src/applications/applications.controller.ts b/apps/backend/src/applications/applications.controller.ts new file mode 100644 index 00000000..871600f1 --- /dev/null +++ b/apps/backend/src/applications/applications.controller.ts @@ -0,0 +1,92 @@ +import { + Body, + Controller, + Get, + Param, + ParseIntPipe, + Post, + HttpCode, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { ApplicationsService } from './applications.service'; +import { Application } from './application.entity'; +import { CreateApplicationDto } from './dto/create-application.request.dto'; +import { PandaDocWebhookDto } from './dto/pandadoc-webhook.dto'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@ApiTags('Applications') +@Controller('applications') +export class ApplicationsController { + private readonly logger = new Logger(ApplicationsController.name); + + constructor(private applicationsService: ApplicationsService) {} + + @Get() + async getAllApplications(): Promise { + return await this.applicationsService.findAll(); + } + + @Get('/:appId') + async getApplicationById( + @Param('appId', ParseIntPipe) appId: number, + ): Promise { + return await this.applicationsService.findById(appId); + } + + @Post() + async createApplication( + @Body() createApplicationDto: CreateApplicationDto, + ): Promise { + return await this.applicationsService.create(createApplicationDto); + } + + @Post('webhook/pandadoc') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Receive PandaDoc webhook events' }) + @ApiResponse({ + status: 200, + description: 'Webhook processed successfully', + type: Application, + }) + @ApiResponse({ + status: 400, + description: 'Invalid webhook payload or unsupported event type', + }) + async receivePandaDocWebhook( + @Body() webhookData: PandaDocWebhookDto, + ): Promise<{ + success: boolean; + application?: Application; + message?: string; + }> { + try { + this.logger.log( + `Received PandaDoc webhook: event=${webhookData.event}, documentId=${webhookData.data.id}`, + ); + + const application = await this.applicationsService.handlePandaDocWebhook( + webhookData, + ); + + this.logger.log( + `Successfully processed webhook and created application ID: ${application.appId}`, + ); + + return { + success: true, + application, + }; + } catch (error) { + this.logger.error( + `Failed to process PandaDoc webhook: ${error.message}`, + error.stack, + ); + + return { + success: false, + message: error.message, + }; + } + } +} diff --git a/apps/backend/src/applications/applications.module.ts b/apps/backend/src/applications/applications.module.ts new file mode 100644 index 00000000..723a51c7 --- /dev/null +++ b/apps/backend/src/applications/applications.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationsController } from './applications.controller'; +import { ApplicationsService } from './applications.service'; +import { Application } from './application.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([Application])], + controllers: [ApplicationsController], + providers: [ApplicationsService], +}) +export class ApplicationsModule {} diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts new file mode 100644 index 00000000..ccba4ea9 --- /dev/null +++ b/apps/backend/src/applications/applications.service.ts @@ -0,0 +1,197 @@ +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Application } from './application.entity'; +import { CreateApplicationDto } from './dto/create-application.request.dto'; +import { PandaDocWebhookDto } from './dto/pandadoc-webhook.dto'; +import { AppStatus, ExperienceType, InterestArea, School } from './types'; + +@Injectable() +export class ApplicationsService { + private readonly logger = new Logger(ApplicationsService.name); + + constructor( + @InjectRepository(Application) + private applicationRepository: Repository, + ) {} + + async findAll(): Promise { + return await this.applicationRepository.find(); + } + + async findById(appId: number): Promise { + const application: Application = await this.applicationRepository.findOne({ + where: { appId }, + }); + + if (!application) { + throw new NotFoundException(`Application with ID ${appId} not found`); + } + + return application; + } + + async create( + createApplicationDto: CreateApplicationDto, + ): Promise { + const application = this.applicationRepository.create(createApplicationDto); + return await this.applicationRepository.save(application); + } + + async handlePandaDocWebhook( + webhookData: PandaDocWebhookDto, + ): Promise { + this.logger.log( + `Received PandaDoc webhook event: ${webhookData.event} for document ${webhookData.data.id}`, + ); + this.logger.debug(`Webhook payload: ${JSON.stringify(webhookData)}`); + + // Only process completed document events + if ( + webhookData.event !== 'document_completed' && + webhookData.event !== 'recipient_completed' + ) { + this.logger.log(`Ignoring webhook event of type: ${webhookData.event}`); + throw new Error(`Event type ${webhookData.event} is not processed`); + } + + // Extract applicant data from webhook fields + const applicationData = this.mapPandaDocToApplication(webhookData); + + this.logger.log( + `Creating application for: ${applicationData.name} (${applicationData.email})`, + ); + + // Create and save the application + const application = this.applicationRepository.create(applicationData); + const savedApplication = await this.applicationRepository.save(application); + + this.logger.log( + `Successfully created application with ID: ${savedApplication.appId}`, + ); + + return savedApplication; + } + + private mapPandaDocToApplication( + webhookData: PandaDocWebhookDto, + ): CreateApplicationDto { + const fields = webhookData.data.fields || []; + const recipients = webhookData.data.recipients || []; + + // Helper function to find field value by name + const getFieldValue = (fieldName: string): string => { + const field = fields.find( + (f) => f.name === fieldName || f.title === fieldName, + ); + return (field?.value as string) || ''; + }; + + // Extract email from recipients (typically the first recipient is the applicant) + const email = + recipients[0]?.email || getFieldValue('email') || getFieldValue('Email'); + + // Extract name from recipients or fields + const firstName = + recipients[0]?.first_name || + getFieldValue('first_name') || + getFieldValue('First Name'); + const lastName = + recipients[0]?.last_name || + getFieldValue('last_name') || + getFieldValue('Last Name'); + const fullName = + getFieldValue('name') || + getFieldValue('Name') || + getFieldValue('Full Name'); + const name = fullName || `${firstName} ${lastName}`.trim(); + + // Map other fields - these field names should match what PandaDoc sends + // Supports snake_case, Title Case, and camelCase variations + return { + name, + email, + disciplineId: this.parseDisciplineId( + getFieldValue('discipline') || getFieldValue('Discipline'), + ), + appStatus: AppStatus.APP_SUBMITTED, + daysAvailable: + getFieldValue('days_available') || + getFieldValue('Days Available') || + getFieldValue('daysAvailable') || + '', + experienceType: this.parseEnumValue( + getFieldValue('experience_type') || + getFieldValue('Experience Type') || + getFieldValue('experienceType'), + ) as ExperienceType, + fileUploads: this.parseFileUploads( + getFieldValue('file_uploads') || + getFieldValue('File Uploads') || + getFieldValue('fileUploads'), + ), + interest: this.parseEnumValue( + getFieldValue('interest') || + getFieldValue('Interest Area') || + getFieldValue('Interest') || + getFieldValue('interestArea'), + ) as InterestArea, + license: getFieldValue('license') || getFieldValue('License') || '', + isInternational: this.parseBoolean( + getFieldValue('is_international') || + getFieldValue('International') || + getFieldValue('isInternational'), + ), + isLearner: this.parseBoolean( + getFieldValue('is_learner') || + getFieldValue('Learner') || + getFieldValue('isLearner'), + ), + phone: getFieldValue('phone') || getFieldValue('Phone') || '', + school: this.parseEnumValue( + getFieldValue('school') || getFieldValue('School'), + ) as School, + referred: this.parseBoolean( + getFieldValue('referred') || getFieldValue('Referred'), + ), + referredEmail: + getFieldValue('referred_email') || + getFieldValue('Referred Email') || + getFieldValue('referredEmail'), + weeklyHours: parseInt( + getFieldValue('weekly_hours') || + getFieldValue('Weekly Hours') || + getFieldValue('weeklyHours') || + '0', + 10, + ), + }; + } + + private parseDisciplineId(value: string): number | undefined { + const parsed = parseInt(value, 10); + return isNaN(parsed) ? undefined : parsed; + } + + private parseBoolean(value: string): boolean { + if (!value) return false; + const lowerValue = value.toLowerCase(); + return lowerValue === 'true' || lowerValue === 'yes' || lowerValue === '1'; + } + + private parseEnumValue(value: string): string | undefined { + return value || undefined; + } + + private parseFileUploads(value: string): string[] { + if (!value) return []; + try { + return JSON.parse(value); + } catch { + return value + .split(',') + .map((s) => s.trim()) + .filter((s) => s); + } + } +} diff --git a/apps/backend/src/applications/dto/create-application.request.dto.ts b/apps/backend/src/applications/dto/create-application.request.dto.ts new file mode 100644 index 00000000..099a857e --- /dev/null +++ b/apps/backend/src/applications/dto/create-application.request.dto.ts @@ -0,0 +1,65 @@ +import { + IsBoolean, + IsEnum, + IsNumber, + IsString, + IsArray, + IsOptional, + IsEmail, +} from 'class-validator'; +import { AppStatus, ExperienceType, InterestArea, School } from '../types'; + +export class CreateApplicationDto { + @IsString() + name: string; + + @IsEmail() + email: string; + + @IsNumber() + @IsOptional() + disciplineId?: number; + + @IsEnum(AppStatus) + @IsOptional() + appStatus?: AppStatus; + + @IsString() + daysAvailable: string; + + @IsEnum(ExperienceType) + experienceType: ExperienceType; + + @IsArray() + @IsString({ each: true }) + fileUploads: string[]; + + @IsEnum(InterestArea) + interest: InterestArea; + + @IsString() + license: string; + + @IsBoolean() + isInternational: boolean; + + @IsBoolean() + isLearner: boolean; + + @IsString() + phone: string; + + @IsEnum(School) + school: School; + + @IsBoolean() + @IsOptional() + referred?: boolean; + + @IsString() + @IsOptional() + referredEmail?: string; + + @IsNumber() + weeklyHours: number; +} diff --git a/apps/backend/src/applications/dto/pandadoc-webhook.dto.ts b/apps/backend/src/applications/dto/pandadoc-webhook.dto.ts new file mode 100644 index 00000000..ad1f7e7d --- /dev/null +++ b/apps/backend/src/applications/dto/pandadoc-webhook.dto.ts @@ -0,0 +1,63 @@ +import { IsString, IsObject, IsOptional, IsArray } from 'class-validator'; + +export class PandaDocField { + @IsString() + uuid: string; + + @IsString() + name: string; + + @IsOptional() + value?: unknown; + + @IsString() + @IsOptional() + title?: string; +} + +export class PandaDocRecipient { + @IsString() + @IsOptional() + email?: string; + + @IsString() + @IsOptional() + first_name?: string; + + @IsString() + @IsOptional() + last_name?: string; +} + +export class PandaDocData { + @IsString() + id: string; + + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + status?: string; + + @IsArray() + @IsOptional() + fields?: PandaDocField[]; + + @IsArray() + @IsOptional() + recipients?: PandaDocRecipient[]; + + @IsObject() + @IsOptional() + metadata?: Record; +} + +export class PandaDocWebhookDto { + @IsString() + event: string; + + @IsObject() + data: PandaDocData; +} diff --git a/apps/backend/src/applications/types.ts b/apps/backend/src/applications/types.ts new file mode 100644 index 00000000..666a1f24 --- /dev/null +++ b/apps/backend/src/applications/types.ts @@ -0,0 +1,33 @@ +export enum AppStatus { + APP_SUBMITTED = 'App submitted', + IN_REVIEW = 'In review', + FORMS_SENT = 'Forms sent', + ACCEPTED = 'Accepted', + REJECTED = 'Rejected', +} + +export enum ExperienceType { + BS = 'BS', + MS = 'MS', + PHD = 'PhD', + MD = 'MD', + MD_PHD = 'MD PhD', + RN = 'RN', + NP = 'NP', + PA = 'PA', + OTHER = 'Other', +} + +export enum InterestArea { + NURSING = 'Nursing', + HARM_REDUCTION = 'HarmReduction', + WOMENS_HEALTH = 'WomensHealth', +} + +export enum School { + HARVARD_MEDICAL_SCHOOL = 'Harvard Medical School', + JOHNS_HOPKINS = 'Johns Hopkins', + STANFORD_MEDICINE = 'Stanford Medicine', + MAYO_CLINIC = 'Mayo Clinic', + OTHER = 'Other', +} diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 4cd06624..aa003ccc 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,7 +1,8 @@ import { DataSource } from 'typeorm'; import { PluralNamingStrategy } from './strategies/plural-naming.strategy'; -import { Task } from './task/types/task.entity'; import * as dotenv from 'dotenv'; +import { Application } from './applications/application.entity'; +import { Discipline } from './disciplines/disciplines.entity'; dotenv.config(); @@ -12,8 +13,10 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - entities: [Task], + entities: [Application, Discipline], migrations: ['apps/backend/src/migrations/*.js'], + // migrations: ['apps/backend/src/migrations/*.ts'], // use this line instead of the above when running migrations locally, + // then switch back to the above before pushing to github so that it works on the deployment server // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: false, namingStrategy: new PluralNamingStrategy(), diff --git a/apps/backend/src/disciplines/disciplines.controller.spec.ts b/apps/backend/src/disciplines/disciplines.controller.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/src/disciplines/disciplines.controller.ts b/apps/backend/src/disciplines/disciplines.controller.ts new file mode 100644 index 00000000..7ebd90cd --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.controller.ts @@ -0,0 +1,12 @@ +import { Controller, UseGuards, UseInterceptors } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { DisciplinesService } from './disciplines.service'; + +@Controller('disciplines') +@UseInterceptors(CurrentUserInterceptor) +@UseGuards(AuthGuard('jwt')) +export class DisciplinesController { + constructor(private disciplinesService: DisciplinesService) {} + // TODO: fill out with actual API endpoints +} diff --git a/apps/backend/src/disciplines/disciplines.entity.ts b/apps/backend/src/disciplines/disciplines.entity.ts new file mode 100644 index 00000000..5a94390e --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { DISCIPLINE_VALUES } from '../disciplines/discplines.constants'; + +// describes a BHCHP medical discipline +// Current list of disciplines: Volunteers, Nursing, Public Health, MD, PA, NP, +// Research, Social work, Psychiatry, Pharmacy, IT +@Entity('discipline') +export class Discipline { + @PrimaryGeneratedColumn() + id: number; + + // enforce discipline names to be one of the predefined values + @Column({ + type: 'enum', + enum: DISCIPLINE_VALUES, + nullable: false, + }) + name: DISCIPLINE_VALUES; + + @Column({ type: 'int', array: true, default: () => "'{}'" }) + admin_ids: number[]; +} diff --git a/apps/backend/src/disciplines/disciplines.module.ts b/apps/backend/src/disciplines/disciplines.module.ts new file mode 100644 index 00000000..fe0a576b --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DisciplinesController } from './disciplines.controller'; +import { DisciplinesService } from './disciplines.service'; +import { Discipline } from './disciplines.entity'; +import { JwtStrategy } from '../auth/jwt.strategy'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; + +@Module({ + imports: [TypeOrmModule.forFeature([Discipline])], + controllers: [DisciplinesController], + providers: [DisciplinesService, JwtStrategy, CurrentUserInterceptor], +}) +export class DiscplinesModule {} diff --git a/apps/backend/src/disciplines/disciplines.service.spec.ts b/apps/backend/src/disciplines/disciplines.service.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/src/disciplines/disciplines.service.ts b/apps/backend/src/disciplines/disciplines.service.ts new file mode 100644 index 00000000..720f81e2 --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Discipline } from './disciplines.entity'; + +@Injectable() +export class DisciplinesService { + constructor( + @InjectRepository(Discipline) + private disciplinesRepository: Repository, + ) {} + + // TODO: fill out with actual methods +} diff --git a/apps/backend/src/disciplines/discplines.constants.ts b/apps/backend/src/disciplines/discplines.constants.ts new file mode 100644 index 00000000..70ec97fe --- /dev/null +++ b/apps/backend/src/disciplines/discplines.constants.ts @@ -0,0 +1,13 @@ +export enum DISCIPLINE_VALUES { + Volunteers = 'Volunteers', + Nursing = 'Nursing', + PublicHealth = 'Public Health', + MD = 'MD', + PA = 'PA', + NP = 'NP', + Research = 'Research', + SocialWork = 'Social Work', + Psychiatry = 'Psychiatry', + Pharmacy = 'Pharmacy', + IT = 'IT', +} diff --git a/apps/backend/src/migrations/1754254886189-add_task.ts b/apps/backend/src/migrations/1754254886189-add_task.ts deleted file mode 100644 index 450a6415..00000000 --- a/apps/backend/src/migrations/1754254886189-add_task.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { MigrationInterface, QueryRunner } from 'typeorm'; - -export class AddTask1754254886189 implements MigrationInterface { - name = 'AddTask1754254886189'; - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query( - `CREATE TYPE "public"."tasks_category_enum" AS ENUM('Draft', 'To Do', 'In Progress', 'Completed')`, - ); - await queryRunner.query( - `CREATE TABLE "task" ("id" SERIAL NOT NULL, "title" character varying NOT NULL, "description" character varying, "dateCreated" TIMESTAMP NOT NULL DEFAULT now(), "dueDate" TIMESTAMP, "labels" jsonb NOT NULL DEFAULT '[]', "category" "public"."tasks_category_enum" NOT NULL DEFAULT 'Draft', CONSTRAINT "PK_8d12ff38fcc62aaba2cab748772" PRIMARY KEY ("id"))`, - ); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP TABLE "task"`); - await queryRunner.query(`DROP TYPE "public"."tasks_category_enum"`); - } -} diff --git a/apps/backend/src/migrations/1754254886189-init.ts b/apps/backend/src/migrations/1754254886189-init.ts new file mode 100644 index 00000000..75373550 --- /dev/null +++ b/apps/backend/src/migrations/1754254886189-init.ts @@ -0,0 +1,93 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Init1754254886189 implements MigrationInterface { + name = 'Init1754254886189'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."commit_length_enum" AS ENUM('Semester', 'Month', 'Year')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."site_enum" AS ENUM('Downtown Campus', 'North Campus', 'West Campus', 'East Campus')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."app_status_enum" AS ENUM('App submitted', 'In review', 'Forms sent', 'Accepted', 'Rejected')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."school_enum" AS ENUM('Harvard Medical School', 'Johns Hopkins', 'Stanford Medicine', 'Mayo Clinic', 'Other')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."experience_type_enum" AS ENUM('BS', 'MS', 'PhD', 'MD', 'MD PhD', 'RN', 'NP', 'PA', 'Other')`, + ); + + await queryRunner.query( + `CREATE TYPE "public"."interest_area_enum" AS ENUM('Nursing', 'HarmReduction', 'WomensHealth')`, + ); + + await queryRunner.query( + `CREATE TABLE "admin" ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "email" character varying NOT NULL UNIQUE, + CONSTRAINT "PK_admin_id" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "discipline" ( + "id" SERIAL NOT NULL, + "name" character varying NOT NULL, + "admin_ids" integer[] NOT NULL DEFAULT '{}', + CONSTRAINT "PK_discipline_id" PRIMARY KEY ("id") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "application" ( + "appId" SERIAL NOT NULL, + "phone" character varying NOT NULL, + "school" "public"."school_enum" NOT NULL, + "daysAvailable" character varying NOT NULL, + "weeklyHours" integer NOT NULL, + "experienceType" "public"."experience_type_enum" NOT NULL, + "interest" "public"."interest_area_enum" NOT NULL, + "license" character varying NOT NULL, + "appStatus" "public"."app_status_enum" NOT NULL DEFAULT 'App submitted', + "isInternational" boolean NOT NULL DEFAULT false, + "isLearner" boolean NOT NULL DEFAULT false, + "referredEmail" character varying, + "referred" boolean DEFAULT false, + "fileUploads" text[] NOT NULL DEFAULT '{}', + CONSTRAINT "PK_application_appId" PRIMARY KEY ("appId") + )`, + ); + + await queryRunner.query( + `CREATE TABLE "learner" ( + "id" SERIAL NOT NULL, + "app_id" integer NOT NULL, + "name" character varying NOT NULL, + "startDate" DATE NOT NULL, + "endDate" DATE NOT NULL, + CONSTRAINT "PK_learner_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_learner_app_id" FOREIGN KEY ("app_id") REFERENCES "application"("appId") ON DELETE CASCADE + )`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "learner"`); + await queryRunner.query(`DROP TABLE "application"`); + await queryRunner.query(`DROP TABLE "discipline"`); + await queryRunner.query(`DROP TABLE "admin"`); + await queryRunner.query(`DROP TYPE "public"."interest_area_enum"`); + await queryRunner.query(`DROP TYPE "public"."experience_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."school_enum"`); + await queryRunner.query(`DROP TYPE "public"."app_status_enum"`); + await queryRunner.query(`DROP TYPE "public"."site_enum"`); + await queryRunner.query(`DROP TYPE "public"."commit_length_enum"`); + } +} diff --git a/apps/backend/src/migrations/1763586010682-add-name-email-discipline-to-application.ts b/apps/backend/src/migrations/1763586010682-add-name-email-discipline-to-application.ts new file mode 100644 index 00000000..4be1658d --- /dev/null +++ b/apps/backend/src/migrations/1763586010682-add-name-email-discipline-to-application.ts @@ -0,0 +1,157 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNameEmailDisciplineToApplication1763586010682 + implements MigrationInterface +{ + name = 'AddNameEmailDisciplineToApplication1763586010682'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" ADD "name" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "application" ADD "email" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "application" ADD "disciplineId" integer`, + ); + await queryRunner.query( + `ALTER TABLE "application" ADD "fileUploads" text array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query(`ALTER TABLE "discipline" DROP COLUMN "name"`); + await queryRunner.query( + `CREATE TYPE "public"."discipline_name_enum" AS ENUM('Volunteers', 'Nursing', 'Public Health', 'MD', 'PA', 'NP', 'Research', 'Social Work', 'Psychiatry', 'Pharmacy', 'IT')`, + ); + await queryRunner.query( + `ALTER TABLE "discipline" ADD "name" "public"."discipline_name_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TYPE "public"."app_status_enum" RENAME TO "app_status_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."application_appstatus_enum" AS ENUM('App submitted', 'In review', 'Forms sent', 'Accepted', 'Rejected')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" TYPE "public"."application_appstatus_enum" USING "appStatus"::"text"::"public"."application_appstatus_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" SET DEFAULT 'App submitted'`, + ); + await queryRunner.query(`DROP TYPE "public"."app_status_enum_old"`); + await queryRunner.query( + `ALTER TYPE "public"."experience_type_enum" RENAME TO "experience_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."application_experiencetype_enum" AS ENUM('BS', 'MS', 'PhD', 'MD', 'MD PhD', 'RN', 'NP', 'PA', 'Other')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "experienceType" TYPE "public"."application_experiencetype_enum" USING "experienceType"::"text"::"public"."application_experiencetype_enum"`, + ); + await queryRunner.query(`DROP TYPE "public"."experience_type_enum_old"`); + await queryRunner.query( + `ALTER TYPE "public"."interest_area_enum" RENAME TO "interest_area_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."application_interest_enum" AS ENUM('Nursing', 'HarmReduction', 'WomensHealth')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "interest" TYPE "public"."application_interest_enum" USING "interest"::"text"::"public"."application_interest_enum"`, + ); + await queryRunner.query(`DROP TYPE "public"."interest_area_enum_old"`); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "license" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TYPE "public"."school_enum" RENAME TO "school_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."application_school_enum" AS ENUM('Harvard Medical School', 'Johns Hopkins', 'Stanford Medicine', 'Mayo Clinic', 'Other')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "school" TYPE "public"."application_school_enum" USING "school"::"text"::"public"."application_school_enum"`, + ); + await queryRunner.query(`DROP TYPE "public"."school_enum_old"`); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "referred" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "application" ADD CONSTRAINT "FK_6a25198b8a8ccc5c0e2b2b758ee" FOREIGN KEY ("disciplineId") REFERENCES "discipline"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" DROP CONSTRAINT "FK_6a25198b8a8ccc5c0e2b2b758ee"`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "referred" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE TYPE "public"."school_enum_old" AS ENUM('Harvard Medical School', 'Johns Hopkins', 'Stanford Medicine', 'Mayo Clinic', 'Other')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "school" TYPE "public"."school_enum_old" USING "school"::"text"::"public"."school_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "public"."application_school_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."school_enum_old" RENAME TO "school_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "license" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE TYPE "public"."interest_area_enum_old" AS ENUM('Nursing', 'HarmReduction', 'WomensHealth')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "interest" TYPE "public"."interest_area_enum_old" USING "interest"::"text"::"public"."interest_area_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "public"."application_interest_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."interest_area_enum_old" RENAME TO "interest_area_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."experience_type_enum_old" AS ENUM('BS', 'MS', 'PhD', 'MD', 'MD PhD', 'RN', 'NP', 'PA', 'Other')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "experienceType" TYPE "public"."experience_type_enum_old" USING "experienceType"::"text"::"public"."experience_type_enum_old"`, + ); + await queryRunner.query( + `DROP TYPE "public"."application_experiencetype_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."experience_type_enum_old" RENAME TO "experience_type_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."app_status_enum_old" AS ENUM('App submitted', 'in review', 'forms sent', 'accepted', 'rejected')`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" TYPE "public"."app_status_enum_old" USING "appStatus"::"text"::"public"."app_status_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "application" ALTER COLUMN "appStatus" SET DEFAULT 'App submitted'`, + ); + await queryRunner.query(`DROP TYPE "public"."application_appstatus_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."app_status_enum_old" RENAME TO "app_status_enum"`, + ); + await queryRunner.query(`ALTER TABLE "discipline" DROP COLUMN "name"`); + await queryRunner.query(`DROP TYPE "public"."discipline_name_enum"`); + await queryRunner.query( + `ALTER TABLE "discipline" ADD "name" character varying NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "application" DROP COLUMN "fileUploads"`, + ); + await queryRunner.query( + `ALTER TABLE "application" DROP COLUMN "disciplineId"`, + ); + await queryRunner.query(`ALTER TABLE "application" DROP COLUMN "email"`); + await queryRunner.query(`ALTER TABLE "application" DROP COLUMN "name"`); + } +} diff --git a/apps/backend/src/seeds/seed.ts b/apps/backend/src/seeds/seed.ts new file mode 100644 index 00000000..cb5f7436 --- /dev/null +++ b/apps/backend/src/seeds/seed.ts @@ -0,0 +1,47 @@ +import dataSource from '../data-source'; +import { Discipline } from '../disciplines/disciplines.entity'; +import { DISCIPLINE_VALUES } from '../disciplines/discplines.constants'; + +async function seed() { + try { + console.log('๐ŸŒฑ Starting database seed...'); + + // Initialize the data source + await dataSource.initialize(); + console.log('โœ… Database connection established'); + + // Clear existing data + console.log('๐Ÿงน Clearing existing data...'); + await dataSource.query('DROP SCHEMA public CASCADE; CREATE SCHEMA public;'); + + // Recreate tables + await dataSource.synchronize(); + console.log('โœ… Database schema synchronized'); + + // Create disciplines using enum values + console.log('๐Ÿ“š Creating disciplines...'); + const disciplines = await dataSource.getRepository(Discipline).save( + Object.values(DISCIPLINE_VALUES).map((name) => ({ + name, + admin_ids: [], + })), + ); + console.log(`โœ… Created ${disciplines.length} disciplines`); + + console.log('๐ŸŽ‰ Database seed completed successfully!'); + } catch (error) { + console.error('โŒ Seed failed:', error); + throw error; + } finally { + if (dataSource.isInitialized) { + await dataSource.destroy(); + console.log('โœ… Database connection closed'); + } + } +} + +// Run the seed +seed().catch((error) => { + console.error('โŒ Fatal error during seed:', error); + process.exit(1); +}); diff --git a/example.env b/example.env index 211b1472..a4a120c7 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,5 @@ -NX_DB_HOST=localhost, -NX_DB_USERNAME=postgres, -NX_DB_PASSWORD=, -NX_DB_DATABASE=jumpstart, -NX_DB_PORT=5432, \ No newline at end of file +NX_DB_HOST=localhost +NX_DB_PORT=5432 +NX_DB_USERNAME=postgres +NX_DB_PASSWORD= +NX_DB_DATABASE=bhchp diff --git a/package.json b/package.json index ed9b3fe5..9b051d3b 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "migration:run": "npm run typeorm -- migration:run -d apps/backend/src/data-source.ts", "migration:revert": "npm run typeorm -- migration:revert -d apps/backend/src/data-source.ts", "migration:create": "npm run typeorm -- migration:create apps/backend/src/migrations/$npm_config_name", + "seed": "ts-node --project typeorm.tsconfig.json apps/backend/src/seeds/seed.ts", "test": "jest" }, "private": true, diff --git a/typeorm.tsconfig.json b/typeorm.tsconfig.json new file mode 100644 index 00000000..5633bde4 --- /dev/null +++ b/typeorm.tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es2017", + "moduleResolution": "node", + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["apps/backend/src/**/*"], + "exclude": ["apps/backend/src/**/*.spec.ts"] +} \ No newline at end of file