diff --git a/.github/workflows/backend_build.yml b/.github/workflows/backend_build.yml new file mode 100644 index 000000000..d539871aa --- /dev/null +++ b/.github/workflows/backend_build.yml @@ -0,0 +1,34 @@ +name: Backend build + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use NodeJS 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: 'yarn' + + - name: Create .env file + run: | + + echo "AWS_BUCKET_NAME=dummy" >> .env + echo "AWS_SECRET_ACCESS_KEY=dummy" >> .env + echo "AWS_ACCESS_KEY_ID=dummy" >> .env + echo "AWS_REGION=us-east-2" >> .env + + - name: Install Dependencies + run: yarn install + + - name: Build backend + run: npx nx build backend \ No newline at end of file diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml deleted file mode 100644 index bf0fcbef9..000000000 --- a/.github/workflows/ci-cd.yml +++ /dev/null @@ -1,113 +0,0 @@ -name: CI/CD - -# First runs linter and tests all affected projects -# Then, for each project that requires deployment, deploy-- is added -# Environment variables are labelled _SHORT_DESCRIPTION - -on: - push: - branches: ['main'] - pull_request: - branches: ['main'] - workflow_dispatch: - inputs: - manual-deploy: - description: 'App to Deploy' - required: false - default: '' - -concurrency: - # Never have two deployments happening at the same time (potential race condition) - group: '{{ github.head_ref || github.ref }}' - -jobs: - pre-deploy: - runs-on: ubuntu-latest - outputs: - affected: ${{ steps.should-deploy.outputs.affected }} - steps: - - uses: actions/checkout@v3 - with: - # We need to fetch all branches and commits so that Nx affected has a base to compare against. - fetch-depth: 0 - - name: Use Node.js 20 - uses: actions/setup-node@v3 - with: - node-version: 20.x - cache: 'yarn' - - - name: Install Dependencies - run: yarn install - - # In any subsequent steps within this job (myjob) we can reference the resolved SHAs - # using either the step outputs or environment variables: - - name: Derive appropriate SHAs for base and head for `nx affected` commands - uses: nrwl/nx-set-shas@v3 - - - run: | - echo "BASE: ${{ env.NX_BASE }}" - echo "HEAD: ${{ env.NX_HEAD }}" - - - name: Nx Affected Lint - run: npx nx affected -t lint - - # - name: Nx Affected Test - # run: npx nx affected -t test - - - name: Nx Affected Build - run: npx nx affected -t build - - - name: Determine who needs to be deployed - id: should-deploy - run: | - echo "The following projects have been affected: [$(npx nx print-affected -t build --select=tasks.target.project)]"; - echo "affected=$(npx nx print-affected -t build --select=tasks.target.project)" >> "$GITHUB_OUTPUT" - - deploy-debug: - needs: pre-deploy - runs-on: ubuntu-latest - steps: - - name: Debug logs - run: | - echo "Manual Deploy: ${{github.event.inputs.manual-deploy}}"; - echo "Affected Names: ${{needs.pre-deploy.outputs.affected}}"; - echo "Event: ${{github.event_name}}"; - echo "Ref: ${{github.ref}}"; - echo "Will deploy?: ${{(github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main'}}"; - - deploy-frontend: - needs: pre-deploy - if: (contains(github.event.inputs.manual-deploy, 'c4c-ops-frontend') || contains(needs.pre-deploy.outputs.affected, 'scaffolding-frontend')) && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - # For "simplicity", deployment settings are configured in the AWS Amplify Console - # This just posts to a webhook telling Amplify to redeploy the main branch - steps: - - name: Tell Amplify to rebuild - run: curl -X POST -d {} ${C4C_OPS_WEBHOOK_DEPLOY} -H "Content-Type:application/json" - env: - C4C_OPS_WEBHOOK_DEPLOY: ${{ secrets.C4C_OPS_WEBHOOK_DEPLOY }} - - deploy-backend: - needs: pre-deploy - if: (contains(github.event.inputs.manual-deploy, 'c4c-ops-backend') || contains(needs.pre-deploy.outputs.affected, 'scaffolding-backend')) && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - name: Use Node.js 16 - uses: actions/setup-node@v3 - with: - node-version: 16.x - cache: 'yarn' - - - name: Install Dependencies - run: yarn install - - - run: npx nx build c4c-ops-backend --configuration production - - name: default deploy - uses: appleboy/lambda-action@master - with: - aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - aws_region: ${{ secrets.AWS_REGION }} - function_name: c4c-ops-monolith-lambda - source: dist/apps/c4c-ops/c4c-ops-backend/main.js diff --git a/.github/workflows/frontend_build.yml b/.github/workflows/frontend_build.yml new file mode 100644 index 000000000..0c3a7221a --- /dev/null +++ b/.github/workflows/frontend_build.yml @@ -0,0 +1,34 @@ +name: Frontend build + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use NodeJS 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: 'yarn' + + - name: Create .env file + run: | + + echo "AWS_BUCKET_NAME=dummy" >> .env + echo "AWS_SECRET_ACCESS_KEY=dummy" >> .env + echo "AWS_ACCESS_KEY_ID=dummy" >> .env + echo "AWS_REGION=us-east-2" >> .env + + - name: Install Dependencies + run: yarn install + + - name: Build frontend + run: npx nx build frontend \ No newline at end of file diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml new file mode 100644 index 000000000..b892c48f0 --- /dev/null +++ b/.github/workflows/jest.yml @@ -0,0 +1,35 @@ +name: Jest run + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use NodeJS 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: 'yarn' + + - name: Create .env file + run: | + + echo "AWS_BUCKET_NAME=dummy" >> .env + echo "AWS_SECRET_ACCESS_KEY=dummy" >> .env + echo "AWS_ACCESS_KEY_ID=dummy" >> .env + echo "AWS_REGION=us-east-2" >> .env + + - name: Install Dependencies + run: yarn install + + - name: Run Jest tests + run: yarn test diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..0a01be89c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,27 @@ +name: Linter run + +on: + push: + branches: ['main'] + pull_request: + branches: ['main'] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: 'yarn' + + - name: Install Dependencies + run: yarn install + + - name: Run linter + run: yarn lint \ No newline at end of file diff --git a/README.md b/README.md index 08515a9fd..077e04ade 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,63 @@ -# Scaffolding +## BHCHP Application and Admin Portal - Setup for local development - +### Setting up the database +Windows +- Download [PostgreSQL](https://www.postgresql.org/download/windows/) +- Say yes/ or check the box when it asks if you want to install PgAdmin with it + - Alternatively install [PgAdmin 4](https://www.pgadmin.org/download/) separately +- Make username postgres, password can be anything you will remember -✨ **This workspace has been generated by [Nx, a Smart, fast and extensible build system.](https://nx.dev)** ✨ - -## Setup - -Clone this repo and run `yarn` at the root to install this project's dependencies. +MacOS +- You can use brew to install. You may need to install pgadmin separately, [link](https://www.postgresql.org/download/macosx/) +``` +brew install postrgesql +``` +- Also you need to start postgres +``` +brew services start postgresql +``` +- Make username postgres, password can be anything you will remember -You can optionally install `nx` globally with `npm install -g nx` - if you don't, you'll just need to prefix the commands below with `npx` (e.g. `npx nx serve frontend`). +To set up the database, open PgAdmin. Once you have set up your postgres credentials, right click on the Databases dropdown. You may need to create a Server first. You will need the default password for postgres to do this, which is root. Select “Create/-> “Database…”. Name the database exactly bhchp. -## Start the app +### Adding tables and dummy data +You will need to run the migrations which adds the tables from the repo to your database +``` +npm run migration:run +``` -To start the development server run `nx serve frontend`. Open your browser and navigate to http://localhost:4200/. Happy coding! -## Running tasks +### Setting up the dependencies +- Clone this repo +- Run `yarn` at the root (in vscode) to install this project's dependencies. +- You can optionally install nx globally with: +``` +npm install -g nx +``` +- if you don't, you'll just need to prefix the commands below with `npx` (e.g. `npx nx serve frontend`). -To run just the frontend (port 4200): +### Setting up your environment +- Make a copy of `example.env` (in `/apps/example.env`) +- Rename the copy to EXACTLY `.env` - If you name it anything else you risk leaking security credentials! +### Running the application +- To run just the frontend (port 4200): ``` nx serve frontend ``` - -To run just the backend (port 3000): - +- To run just the backend (port 3000): ``` nx serve backend ``` - -To run both the frontend and backend with one command: - +- Run both in the same terminal: ``` nx run-many -t serve -p frontend backend ``` -## Other commands - -Run `git submodule update --remote` to pull the latest changes from the component library - -When cloning the repo, make sure to add the `--recurse-modules` flag to also clone the component library submodule (e.g. `git clone --recurse-submodules https://github.com/Code-4-Community/scaffolding.git` for the `scaffolding` repo) +### Testing with Postman +- Download [Postman](https://www.postman.com/) +- Open postman +- Set the parameter to GET +- Enter `http://localhost:3000/api` +- Press SEND +- if the backend setup was successful, you should see a 200 OK response \ No newline at end of file diff --git a/apps/backend/src/app.controller.spec.ts b/apps/backend/src/app.controller.spec.ts index de8007e18..f27e3afe1 100644 --- a/apps/backend/src/app.controller.spec.ts +++ b/apps/backend/src/app.controller.spec.ts @@ -1,5 +1,4 @@ import { Test, TestingModule } from '@nestjs/testing'; - import { AppController } from './app.controller'; import { AppService } from './app.service'; diff --git a/apps/backend/src/app.controller.ts b/apps/backend/src/app.controller.ts index dff210a84..bb8be8edb 100644 --- a/apps/backend/src/app.controller.ts +++ b/apps/backend/src/app.controller.ts @@ -2,6 +2,9 @@ import { Controller, Get } from '@nestjs/common'; import { AppService } from './app.service'; +/** + * Higher-level controller that exposes all sub-controller endpoints. + */ @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 54fb044e9..5f7070c61 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -3,11 +3,41 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; -import { TaskModule } from './task/task.module'; +import { AWSS3Module } from './aws-s3/aws-s3.module'; import AppDataSource from './data-source'; +import { UtilModule } from './util/util.module'; +import { ApplicationsModule } from './applications/applications.module'; +import { ApplicantsModule } from './applicants/applicants.module'; +import { LearnerInfoModule } from './learner-info/learner-info.module'; +import { VolunteerInfoModule } from './volunteer-info/volunteer-info.module'; +import { Application } from './applications/application.entity'; +import { AdminsModule } from './users/admins.module'; +import { UsersModule } from './users/users.module'; +import { Admin } from './users/admin.entity'; +import { ConfigModule } from '@nestjs/config'; +import { DisciplinesModule } from './disciplines/disciplines.module'; @Module({ - imports: [TypeOrmModule.forRoot(AppDataSource.options), TaskModule], + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '../../.env', + }), + TypeOrmModule.forRoot({ + ...AppDataSource.options, + migrations: [], // Don't load migrations when server starts - only load them when running migration commands (i had to add this to get the server to run without errors) + }), + UtilModule, + AdminsModule, + UsersModule, + AWSS3Module, + TypeOrmModule.forFeature([Application]), + DisciplinesModule, + LearnerInfoModule, + VolunteerInfoModule, + ApplicationsModule, + ApplicantsModule, + ], controllers: [AppController], providers: [AppService], }) diff --git a/apps/backend/src/applicants/applicant.entity.ts b/apps/backend/src/applicants/applicant.entity.ts new file mode 100644 index 000000000..75df50198 --- /dev/null +++ b/apps/backend/src/applicants/applicant.entity.ts @@ -0,0 +1,45 @@ +import { Entity, Column, PrimaryColumn } from 'typeorm'; + +/** + * Represents the desired columns for the database table in the repository for the system's applicants. + */ +@Entity() +export class Applicant { + /** + * Corresponding application id of the applicant. + */ + @PrimaryColumn({ name: 'app_id' }) + appId: number; + + /** + * The applicant's first name. + * + * Example: 'Jane'. + */ + @Column() + firstName: string; + + /** + * The applicant's last name. + * + * Example: 'Doe'. + */ + @Column() + lastName: string; + + /** + * The expected start date for the applicant's commitment, stored in YYYY-MM-DD format. + * + * Example: new Date('2024-06-30'). + */ + @Column({ type: 'date' }) + startDate: Date; + + /** + * The expected end date for the applicant's commitment, stored in YYYY-MM-DD format. + * + * Example: new Date('2024-06-30'). + */ + @Column({ type: 'date' }) + endDate: Date; +} diff --git a/apps/backend/src/applicants/applicants.controller.spec.ts b/apps/backend/src/applicants/applicants.controller.spec.ts new file mode 100644 index 000000000..23e0b26c4 --- /dev/null +++ b/apps/backend/src/applicants/applicants.controller.spec.ts @@ -0,0 +1,272 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ApplicantsController } from './applicants.controller'; +import { ApplicantsService } from './applicants.service'; +import { Applicant } from './applicant.entity'; +import { AuthService } from '../auth/auth.service'; +import { UsersService } from '../users/users.service'; +import { applicantFactory } from '../testing/factories/applicant.factory'; + +const mockApplicantsService: Partial = { + create: jest.fn(), + findOne: jest.fn(), + findAll: jest.fn(), + findByAppId: jest.fn(), + updateStartDate: jest.fn(), + updateEndDate: jest.fn(), + delete: jest.fn(), +}; + +const mockAuthService = { + getUser: jest.fn(), +}; + +const mockUsersService = { + find: jest.fn(), +}; + +const defaultApplicant: Applicant = applicantFactory({ + appId: 1, + firstName: 'John', + lastName: 'Doe', +}); + +describe('ApplicantsController', () => { + let controller: ApplicantsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApplicantsController], + providers: [ + { + provide: ApplicantsService, + useValue: mockApplicantsService, + }, + { + provide: getRepositoryToken(Applicant), + useValue: {}, + }, + { + provide: AuthService, + useValue: mockAuthService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + controller = module.get(ApplicantsController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('createApplicant', () => { + it('should create a new applicant', async () => { + const createApplicantDto = { + appId: 1, + firstName: 'John', + lastName: 'Doe', + startDate: '2024-01-01', + endDate: '2024-06-30', + }; + + jest + .spyOn(mockApplicantsService, 'create') + .mockResolvedValue(defaultApplicant); + + const result = await controller.createApplicant(createApplicantDto); + + expect(result).toEqual(defaultApplicant); + expect(mockApplicantsService.create).toHaveBeenCalledWith( + 1, + 'John', + 'Doe', + new Date('2024-01-01'), + new Date('2024-06-30'), + ); + }); + + it('should handle service errors when creating applicant', async () => { + const createApplicantDto = { + appId: 1, + firstName: 'John', + lastName: 'Doe', + startDate: '2024-01-01', + endDate: '2024-06-30', + }; + + const errorMessage = 'Failed to create applicant'; + jest + .spyOn(mockApplicantsService, 'create') + .mockRejectedValue(new Error(errorMessage)); + + await expect( + controller.createApplicant(createApplicantDto), + ).rejects.toThrow(errorMessage); + }); + }); + + describe('getAllApplicants', () => { + it('should return all applicants', async () => { + const applicants = [ + defaultApplicant, + applicantFactory({ appId: 2, firstName: 'Jane', lastName: 'Doe' }), + ]; + jest + .spyOn(mockApplicantsService, 'findAll') + .mockResolvedValue(applicants); + + const result = await controller.getAllApplicants(); + + expect(result).toEqual(applicants); + expect(mockApplicantsService.findAll).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no applicants exist', async () => { + jest.spyOn(mockApplicantsService, 'findAll').mockResolvedValue([]); + + const result = await controller.getAllApplicants(); + + expect(result).toEqual([]); + }); + + it('should error out without information loss if the service throws an error', async () => { + jest + .spyOn(mockApplicantsService, 'findAll') + .mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(controller.getAllApplicants()).rejects.toThrow( + `There was a problem retrieving the info`, + ); + }); + }); + + describe('getApplicant', () => { + it('should return a specific applicant', async () => { + jest + .spyOn(mockApplicantsService, 'findOne') + .mockResolvedValue(defaultApplicant); + + const result = await controller.getApplicant(1); + + expect(result).toEqual(defaultApplicant); + expect(mockApplicantsService.findOne).toHaveBeenCalledWith(1); + }); + + it('should throw an error if applicant is not found', async () => { + const errorMessage = 'Applicant with ID 999 not found'; + jest + .spyOn(mockApplicantsService, 'findOne') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getApplicant(999)).rejects.toThrow(errorMessage); + }); + }); + + describe('updateStartDate', () => { + const updatedStartDate = '2024-02-01'; + const updatedApplicant = { + ...defaultApplicant, + startDate: new Date(updatedStartDate), + }; + + it('should update the start date of a applicant', async () => { + jest + .spyOn(mockApplicantsService, 'updateStartDate') + .mockResolvedValue(updatedApplicant); + + const result = await controller.updateStartDate(1, updatedStartDate); + + expect(result).toEqual(updatedApplicant); + expect(mockApplicantsService.updateStartDate).toHaveBeenCalledWith( + 1, + new Date(updatedStartDate), + ); + }); + + it('should handle service errors when updating start date', async () => { + const errorMessage = 'Start date must be before end date'; + jest + .spyOn(mockApplicantsService, 'updateStartDate') + .mockRejectedValue(new Error(errorMessage)); + + await expect( + controller.updateStartDate(1, updatedStartDate), + ).rejects.toThrow(errorMessage); + }); + }); + + describe('updateEndDate', () => { + const updatedEndDate = '2024-07-31'; + const updatedApplicant = { + ...defaultApplicant, + endDate: new Date(updatedEndDate), + }; + + it('should update the end date of a applicant', async () => { + jest + .spyOn(mockApplicantsService, 'updateEndDate') + .mockResolvedValue(updatedApplicant); + + const result = await controller.updateEndDate(1, updatedEndDate); + + expect(result).toEqual(updatedApplicant); + expect(mockApplicantsService.updateEndDate).toHaveBeenCalledWith( + 1, + new Date(updatedEndDate), + ); + }); + + it('should handle service errors when updating end date', async () => { + const errorMessage = 'End date must be after start date'; + jest + .spyOn(mockApplicantsService, 'updateEndDate') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.updateEndDate(1, updatedEndDate)).rejects.toThrow( + errorMessage, + ); + }); + }); + + describe('deleteApplicant', () => { + it('should delete a applicant', async () => { + jest + .spyOn(mockApplicantsService, 'delete') + .mockResolvedValue(defaultApplicant); + + const result = await controller.deleteApplicant(1); + + expect(result).toEqual(defaultApplicant); + expect(mockApplicantsService.delete).toHaveBeenCalledWith(1); + }); + + it('should handle service errors when deleting applicant', async () => { + const errorMessage = 'Failed to delete applicant'; + jest + .spyOn(mockApplicantsService, 'delete') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.deleteApplicant(1)).rejects.toThrow( + 'Failed to delete applicant', + ); + }); + + it('should throw an error if applicant is not found', async () => { + const errorMessage = 'Applicant with ID 999 not found'; + jest + .spyOn(mockApplicantsService, 'delete') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.deleteApplicant(999)).rejects.toThrow( + 'Applicant with ID 999 not found', + ); + }); + }); +}); diff --git a/apps/backend/src/applicants/applicants.controller.ts b/apps/backend/src/applicants/applicants.controller.ts new file mode 100644 index 000000000..cca6ac394 --- /dev/null +++ b/apps/backend/src/applicants/applicants.controller.ts @@ -0,0 +1,126 @@ +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + ParseIntPipe, + UseGuards, + UseInterceptors, + Delete, +} from '@nestjs/common'; +import { ApplicantsService } from './applicants.service'; +import { AuthGuard } from '@nestjs/passport'; +import { Applicant } from './applicant.entity'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { CreateApplicantDto } from './dto/applicant.dto'; + +/** + * Controller exposing HTTP endpoints to get, create, and change information + * about the app's applicants, including start and end dates. + */ +@Controller('applicants') +// @UseGuards(AuthGuard('jwt')) +@UseInterceptors(CurrentUserInterceptor) +export class ApplicantsController { + constructor(private applicantsService: ApplicantsService) {} + + /** + * Exposes an endpoint to create a applicant. + * @param createApplicantDto Object with the necessary starting data for the + * applicant corresponding to their application. + * @returns The new applicant. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any fields are invalid. + */ + @Post() + async createApplicant( + @Body() + createApplicantDto: CreateApplicantDto, + ): Promise { + return this.applicantsService.create( + createApplicantDto.appId, + createApplicantDto.firstName, + createApplicantDto.lastName, + new Date(createApplicantDto.startDate), + new Date(createApplicantDto.endDate), + ); + } + + /** + * Exposes an endpoint to return all applicants in the system. + * @returns An array of applicant objects. + * @throws {Error} If the repository throws an error. + */ + @Get() + async getAllApplicants(): Promise { + return this.applicantsService.findAll(); + } + + /** + * Exposes an endpoint to return a specific applicant by appId. + * @param appId The appId of the desired applicant to return. + * @returns The applicant with the desired appId. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if the id field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + @Get('/:appId') + async getApplicant( + @Param('appId', ParseIntPipe) appId: number, + ): Promise { + return this.applicantsService.findOne(appId); + } + + /** + * Exposes an endpoint to update a applicant's commitment starting date. + * @param appId The appId of the applicant to update. + * @param startDate The new starting date for the applicant's commitment. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + @Patch('/:appId/start-date') + async updateStartDate( + @Param('appId', ParseIntPipe) appId: number, + @Body('startDate') startDate: string, + ): Promise { + return this.applicantsService.updateStartDate(appId, new Date(startDate)); + } + + /** + * Exposes an endpoint to update a applicant's commitment ending date. + * @param appId The appId of the applicant to update. + * @param endDate The new ending date for the applicant's commitment. + * @returns The updated applicant object. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + @Patch('/:appId/end-date') + async updateEndDate( + @Param('appId', ParseIntPipe) appId: number, + @Body('endDate') endDate: string, + ): Promise { + return this.applicantsService.updateEndDate(appId, new Date(endDate)); + } + + /** + * Exposes an endpoint to delete a applicant by id. + * @param id The id of the applicant to delete. + * @returns The deleted applicant object. + * @throws {Error} If the repository throws an error. + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified id does not exist. + */ + @Delete('/:id') + async deleteApplicant( + @Param('id', ParseIntPipe) id: number, + ): Promise { + return this.applicantsService.delete(id); + } +} diff --git a/apps/backend/src/applicants/applicants.module.ts b/apps/backend/src/applicants/applicants.module.ts new file mode 100644 index 000000000..888733e06 --- /dev/null +++ b/apps/backend/src/applicants/applicants.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicantsController } from './applicants.controller'; +import { ApplicantsService } from './applicants.service'; +import { Applicant } from './applicant.entity'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { UsersModule } from '../users/users.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Applicant]), UsersModule, AuthModule], + controllers: [ApplicantsController], + providers: [ApplicantsService, CurrentUserInterceptor], + exports: [ApplicantsService], +}) +export class ApplicantsModule {} diff --git a/apps/backend/src/applicants/applicants.service.spec.ts b/apps/backend/src/applicants/applicants.service.spec.ts new file mode 100644 index 000000000..9f979cd5f --- /dev/null +++ b/apps/backend/src/applicants/applicants.service.spec.ts @@ -0,0 +1,562 @@ +import { NotFoundException } from '@nestjs/common'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Repository } from 'typeorm'; + +import { ApplicantsService } from './applicants.service'; +import { Applicant } from './applicant.entity'; +import { applicantFactory } from '../testing/factories/applicant.factory'; + +const mockApplicantsRepository: Partial> = { + create: jest.fn(), + save: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), + remove: jest.fn(), +}; + +const applicant1: Applicant = applicantFactory({ + appId: 1, + firstName: 'John', + lastName: 'Doe', +}); +const applicant2: Applicant = applicantFactory({ + appId: 2, + firstName: 'Jane', + lastName: 'Doe', +}); + +describe('ApplicantsService', () => { + let service: ApplicantsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicantsService, + { + provide: getRepositoryToken(Applicant), + useValue: mockApplicantsRepository, + }, + ], + }).compile(); + + service = module.get(ApplicantsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', async () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create a new applicant', async () => { + const createData = { + appId: 1, + firstName: 'John', + lastName: 'Doe', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + }; + + jest + .spyOn(mockApplicantsRepository, 'create') + .mockReturnValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'save') + .mockResolvedValue(applicant1); + + const result = await service.create( + createData.appId, + createData.firstName, + createData.lastName, + createData.startDate, + createData.endDate, + ); + + expect(result).toEqual(applicant1); + expect(mockApplicantsRepository.create).toHaveBeenCalledWith(createData); + expect(mockApplicantsRepository.save).toHaveBeenCalledWith(applicant1); + }); + + it('should throw error if appId is invalid', async () => { + await expect( + service.create( + 0, + 'John', + 'Doe', + new Date('2024-01-01'), + new Date('2024-06-30'), + ), + ).rejects.toThrow('Valid app ID is required'); + }); + + it('should throw error if first name is empty', async () => { + await expect( + service.create( + 1, + '', + 'Doe', + new Date('2024-01-01'), + new Date('2024-06-30'), + ), + ).rejects.toThrow('Applicant first name is required'); + }); + + it('should throw error if last name is empty', async () => { + await expect( + service.create( + 1, + 'Jane', + '', + new Date('2024-01-01'), + new Date('2024-06-30'), + ), + ).rejects.toThrow('Applicant last name is required'); + }); + + it('should throw error if start date is after end date', async () => { + await expect( + service.create( + 1, + 'John', + 'Doe', + new Date('2024-06-30'), + new Date('2024-01-01'), + ), + ).rejects.toThrow('Start date must be before end date'); + }); + + it('should throw error if start date is invalid', async () => { + await expect( + service.create( + 1, + 'John', + 'Doe', + new Date('not-a-date'), + new Date('2024-06-30'), + ), + ).rejects.toThrow('Start date and end date must be valid dates'); + }); + + it('should throw error if end date is invalid', async () => { + await expect( + service.create( + 1, + 'John', + 'Doe', + new Date('2024-01-01'), + new Date('not-a-date'), + ), + ).rejects.toThrow('Start date and end date must be valid dates'); + }); + + it('should error out without information loss if the repository throws an error during create', async () => { + const createData = { + appId: 1, + firstName: 'John', + lastName: 'Doe', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + }; + + jest + .spyOn(mockApplicantsRepository, 'create') + .mockImplementationOnce(() => { + throw new Error('There was a problem retrieving the info'); + }); + + await expect( + service.create( + createData.appId, + createData.firstName, + createData.lastName, + createData.startDate, + createData.endDate, + ), + ).rejects.toThrow(`There was a problem retrieving the info`); + }); + + it('should error out without information loss if the repository throws an error during save', async () => { + const createData = { + appId: 1, + firstName: 'John', + lastName: 'Doe', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), + }; + + jest + .spyOn(mockApplicantsRepository, 'save') + .mockImplementationOnce(() => { + throw new Error('There was a problem saving the info'); + }); + + await expect( + service.create( + createData.appId, + createData.firstName, + createData.lastName, + createData.startDate, + createData.endDate, + ), + ).rejects.toThrow(`There was a problem saving the info`); + }); + }); + + describe('findOne', () => { + it('should throw error if id is not provided', async () => { + await expect(service.findOne(null)).rejects.toThrow( + 'Applicant ID is required', + ); + expect(mockApplicantsRepository.findOneBy).not.toHaveBeenCalled(); + }); + + it('should find a applicant by id', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + + const result = await service.findOne(1); + + expect(result).toEqual(applicant1); + expect(mockApplicantsRepository.findOneBy).toHaveBeenCalledWith({ + appId: 1, + }); + }); + + it('should throw error if applicant is not found', async () => { + jest.spyOn(mockApplicantsRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow( + 'Applicant with ID 999 not found', + ); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findOne(1)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + }); + + describe('findAll', () => { + it('should return all applicants', async () => { + const applicants = [applicant1, applicant2]; + jest + .spyOn(mockApplicantsRepository, 'find') + .mockResolvedValue(applicants); + + const result = await service.findAll(); + + expect(result).toEqual(applicants); + expect(mockApplicantsRepository.find).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no applicants exist', async () => { + jest.spyOn(mockApplicantsRepository, 'find').mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockApplicantsRepository, 'find') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findAll()).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + }); + + describe('findByAppId', () => { + it('should find applicants by app id', async () => { + const applicants = [applicant1]; + jest + .spyOn(mockApplicantsRepository, 'find') + .mockResolvedValue(applicants); + + const result = await service.findByAppId(1); + + expect(result).toEqual(applicants); + expect(mockApplicantsRepository.find).toHaveBeenCalledWith({ + where: { appId: 1 }, + }); + }); + + it('should return empty array when no applicants found for app id', async () => { + jest.spyOn(mockApplicantsRepository, 'find').mockResolvedValue([]); + + const result = await service.findByAppId(999); + + expect(result).toEqual([]); + }); + + it('should throw error if appId is invalid', async () => { + await expect(service.findByAppId(0)).rejects.toThrow( + 'Valid app ID is required', + ); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockApplicantsRepository, 'find') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findByAppId(8)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + }); + + describe('updateStartDate', () => { + const updatedStartDate = new Date('2024-02-01'); + const updatedApplicant = { ...applicant1, startDate: updatedStartDate }; + + it('should update applicant start date', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'save') + .mockResolvedValue(updatedApplicant); + + const result = await service.updateStartDate(1, updatedStartDate); + + expect(result).toEqual(updatedApplicant); + expect(mockApplicantsRepository.findOneBy).toHaveBeenCalledWith({ + appId: 1, + }); + expect(mockApplicantsRepository.save).toHaveBeenCalledWith({ + ...applicant1, + startDate: updatedStartDate, + }); + }); + + it('should throw error if applicant is not found', async () => { + jest.spyOn(mockApplicantsRepository, 'findOneBy').mockResolvedValue(null); + + await expect( + service.updateStartDate(999, updatedStartDate), + ).rejects.toThrow('Applicant with ID 999 not found'); + }); + + it('should throw error if start date is after end date', async () => { + const existingApplicant = { + ...applicant1, + endDate: new Date('2024-01-15'), + }; + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(existingApplicant); + + await expect( + service.updateStartDate(1, updatedStartDate), + ).rejects.toThrow('Start date must be before end date'); + }); + + it('should throw error if no start date provided', async () => { + await expect(service.updateStartDate(1, null)).rejects.toThrow( + 'Start date is required', + ); + }); + + it('should throw error if start date is invalid', async () => { + await expect( + service.updateStartDate(1, new Date('not-a-date')), + ).rejects.toThrow('Start date must be a valid date'); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.updateStartDate(999, updatedStartDate), + ).rejects.toThrow('There was a problem retrieving the info'); + }); + + it('should error out without information loss if the repository throws an error during save', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'save') + .mockRejectedValueOnce( + new Error('There was a problem saving the info'), + ); + + await expect( + service.updateStartDate(1, updatedStartDate), + ).rejects.toThrow('There was a problem saving the info'); + }); + }); + + describe('updateEndDate', () => { + const updatedEndDate = new Date('2024-07-31'); + const updatedApplicant = { ...applicant1, endDate: updatedEndDate }; + + it('should update applicant end date', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'save') + .mockResolvedValue(updatedApplicant); + + const result = await service.updateEndDate(1, updatedEndDate); + + expect(result).toEqual(updatedApplicant); + expect(mockApplicantsRepository.findOneBy).toHaveBeenCalledWith({ + appId: 1, + }); + expect(mockApplicantsRepository.save).toHaveBeenCalledWith({ + ...applicant1, + endDate: updatedEndDate, + }); + }); + + it('should throw error if applicant is not found', async () => { + jest.spyOn(mockApplicantsRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.updateEndDate(999, updatedEndDate)).rejects.toThrow( + 'Applicant with ID 999 not found', + ); + }); + + it('should throw error if end date is before start date', async () => { + const existingApplicant = { + ...applicant1, + startDate: new Date('2024-08-15'), + }; + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(existingApplicant); + + await expect(service.updateEndDate(1, updatedEndDate)).rejects.toThrow( + 'End date must be after start date', + ); + }); + + it('should throw error if no end date provided', async () => { + await expect(service.updateEndDate(1, null)).rejects.toThrow( + 'End date is required', + ); + }); + + it('should throw error if end date is invalid', async () => { + await expect( + service.updateEndDate(1, new Date('not-a-date')), + ).rejects.toThrow('End date must be a valid date'); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.updateEndDate(999, updatedEndDate)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + + it('should error out without information loss if the repository throws an error during save', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'save') + .mockRejectedValueOnce( + new Error('There was a problem saving the info'), + ); + + await expect(service.updateEndDate(1, updatedEndDate)).rejects.toThrow( + 'There was a problem saving the info', + ); + }); + }); + + describe('delete', () => { + it('should delete a learner successfully', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'remove') + .mockResolvedValue(applicant1); + + const result = await service.delete(1); + + // returns the deleted applicant + expect(result).toEqual(applicant1); + expect(mockApplicantsRepository.findOneBy).toHaveBeenCalledWith({ + appId: 1, + }); + expect(mockApplicantsRepository.remove).toHaveBeenCalledWith(applicant1); + }); + + it('should throw NotFoundException if learner is not found', async () => { + jest.spyOn(mockApplicantsRepository, 'findOneBy').mockResolvedValue(null); + + await expect(service.delete(999)).rejects.toThrow( + new NotFoundException('Applicant with ID 999 not found'), + ); + expect(mockApplicantsRepository.findOneBy).toHaveBeenCalledWith({ + appId: 999, + }); + expect(mockApplicantsRepository.remove).not.toHaveBeenCalled(); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.delete(1)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + expect(mockApplicantsRepository.remove).not.toHaveBeenCalled(); + }); + + it('should error out without information loss if the repository throws an error during removal', async () => { + jest + .spyOn(mockApplicantsRepository, 'findOneBy') + .mockResolvedValue(applicant1); + jest + .spyOn(mockApplicantsRepository, 'remove') + .mockRejectedValueOnce( + new Error('There was a problem removing the info'), + ); + + await expect(service.delete(1)).rejects.toThrow( + 'There was a problem removing the info', + ); + expect(mockApplicantsRepository.findOneBy).toHaveBeenCalledWith({ + appId: 1, + }); + expect(mockApplicantsRepository.remove).toHaveBeenCalledWith(applicant1); + }); + }); +}); diff --git a/apps/backend/src/applicants/applicants.service.ts b/apps/backend/src/applicants/applicants.service.ts new file mode 100644 index 000000000..cc3b4b7ec --- /dev/null +++ b/apps/backend/src/applicants/applicants.service.ts @@ -0,0 +1,207 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Applicant } from './applicant.entity'; + +/** + * Service to interface with the applicant repository. + */ +@Injectable() +export class ApplicantsService { + constructor( + @InjectRepository(Applicant) + private readonly repo: Repository, + ) {} + + /** + * Creates a applicant in the repository. + * @param appId The corresponding application id of the applicant to create. + * @param name The name of the applicant to create (generally in the format 'First Last'). + * @param startDate The expected starting date of the applicant's commitment. + * @param endDate The expected ending date of the applicant's commitment. + * @returns The created applicant. + * @throws {BadRequestException} if any of the fields are invalid. + * @throws {Error} If the repository throws an error. + */ + async create( + appId: number, + firstName: string, + lastName: string, + startDate: Date, + endDate: Date, + ) { + if (!appId || appId <= 0) { + throw new BadRequestException('Valid app ID is required'); + } + + if (!firstName || firstName.trim().length === 0) { + throw new BadRequestException('Applicant first name is required'); + } + + if (!lastName || lastName.trim().length === 0) { + throw new BadRequestException('Applicant last name is required'); + } + + if (!startDate || !endDate) { + throw new BadRequestException('Start date and end date are required'); + } + + if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) { + throw new BadRequestException( + 'Start date and end date must be valid dates', + ); + } + if (startDate >= endDate) { + throw new BadRequestException('Start date must be before end date'); + } + + const applicant: Applicant = this.repo.create({ + appId, + firstName, + lastName, + startDate, + endDate, + }); + + return await this.repo.save(applicant); + } + + /** + * Returns a specific applicant by id. + * @param appId The id of the desired applicant to return. + * @returns The applicant with the desired id. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + async findOne(appId: number) { + if (!appId) { + throw new BadRequestException('Applicant ID is required'); + } + + const applicant = await this.repo.findOneBy({ appId }); + if (!applicant) { + throw new NotFoundException(`Applicant with ID ${appId} not found`); + } + + return applicant; + } + + /** + * Returns all applicants in the repository. + * @returns All applicants in the repository. + * @throws {Error} If the repository throws an error. + */ + findAll() { + return this.repo.find(); + } + + /** + * Updates a applicant's commitment starting date. + * @param appId The appId of the applicant to update. + * @param startDate The new starting date for the applicant's commitment. + * @returns The updated applicant object. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + async updateStartDate(appId: number, startDate: Date) { + if (!appId) { + throw new BadRequestException('Applicant ID is required'); + } + + if (!startDate) { + throw new BadRequestException('Start date is required'); + } + + if (isNaN(startDate.getTime())) { + throw new BadRequestException('Start date must be a valid date'); + } + + const applicant = await this.repo.findOneBy({ appId }); + if (!applicant) { + throw new NotFoundException(`Applicant with ID ${appId} not found`); + } + + if (applicant.endDate && startDate >= applicant.endDate) { + throw new BadRequestException('Start date must be before end date'); + } + + applicant.startDate = startDate; + return this.repo.save(applicant); + } + + /** + * Updates a applicant's commitment ending date. + * @param appId The appId of the applicant to update. + * @param endDate The new ending date for the applicant's commitment. + * @returns The updated applicant object. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + async updateEndDate(appId: number, endDate: Date) { + if (!appId) { + throw new BadRequestException('Applicant ID is required'); + } + + if (!endDate) { + throw new BadRequestException('End date is required'); + } + + if (isNaN(endDate.getTime())) { + throw new BadRequestException('End date must be a valid date'); + } + + const applicant = await this.repo.findOneBy({ appId }); + if (!applicant) { + throw new NotFoundException(`Applicant with ID ${appId} not found`); + } + + if (applicant.startDate && applicant.startDate >= endDate) { + throw new BadRequestException('End date must be after start date'); + } + + applicant.endDate = endDate; + return this.repo.save(applicant); + } + + async findByAppId(appId: number) { + if (!appId || appId <= 0) { + throw new BadRequestException('Valid app ID is required'); + } + + const applicants = await this.repo.find({ where: { appId } }); + + // If we want to error out instead of returning an empty array: + // if (applicants.length === 0) { + // throw new NotFoundException(`No applicants found for app ID ${appId}`); + // } + + return applicants; + } + + /** + * Deletes a applicant by id. + * @param id The id of the applicant to delete. + * @returns The deleted applicant. + * @throws {Error} If the repository throws an error. + * @throws {BadRequestException} if any field is invalid (e.g. null or undefined). + * @throws {NotFoundException} with message 'Applicant with ID not found' + * if the applicant with the specified appId does not exist. + */ + async delete(id: number) { + const applicant = await this.findOne(id); + if (!applicant) { + throw new NotFoundException(`Applicant with ID ${id} not found`); + } + return this.repo.remove(applicant); + } +} diff --git a/apps/backend/src/applicants/dto/applicant.dto.ts b/apps/backend/src/applicants/dto/applicant.dto.ts new file mode 100644 index 000000000..cee7b3705 --- /dev/null +++ b/apps/backend/src/applicants/dto/applicant.dto.ts @@ -0,0 +1,65 @@ +import { + IsString, + IsNotEmpty, + IsDefined, + Matches, + IsInt, + IsPositive, +} from 'class-validator'; + +/** + * Defines the expected shape of data for creating a applicant. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class CreateApplicantDto { + /** + * Corresponding application id number. + */ + @IsInt() + @IsPositive() + @IsDefined() + appId: number; + + /** + * The applicant's first name. + * + * Example: 'Jane'. + */ + @IsString() + @IsNotEmpty() + firstName: string; + + /** + * The applicant's last name. + * + * Example: 'Jane'. + */ + @IsString() + @IsNotEmpty() + lastName: string; + + /** + * The expected start date for the applicant's commitment, stored in YYYY-MM-DD format. + * + * Example: '2024-06-30'. + */ + @IsString() + @IsDefined() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Date must be in YYYY-MM-DD format', + }) + startDate: string; + + /** + * The expected end date for the applicant's commitment, stored in YYYY-MM-DD format. + * + * Example: '2024-06-30'. + */ + @IsString() + @IsDefined() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Date must be in YYYY-MM-DD format', + }) + endDate: string; +} diff --git a/apps/backend/src/applications/application.entity.ts b/apps/backend/src/applications/application.entity.ts new file mode 100644 index 000000000..174a300f5 --- /dev/null +++ b/apps/backend/src/applications/application.entity.ts @@ -0,0 +1,272 @@ +import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; + +import { + AppStatus, + ExperienceType, + InterestArea, + School, + ApplicantType, +} from './types'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; + +/** + * Represents the desired columns for the database table in the repository for the system's applications. + */ +@Entity('application') +export class Application { + /** + * Autogenerated application id number. + */ + @PrimaryGeneratedColumn() + appId!: number; + + /** + * Email of the applicant. + * + * Example: "bob.ross@example.com" + */ + @Column({ type: 'varchar' }) + email!: string; + + /** + * Discipline associated with the applicant. + * + * Example: "Nursing" + */ + @Column({ type: 'enum', enum: DISCIPLINE_VALUES }) + discipline!: DISCIPLINE_VALUES; + + /** + * Discipline or area of interest description of applicant clicked other + */ + @Column({ type: 'varchar', nullable: true }) + otherDisciplineDescription?: string; + + /** + * Status of the application in the review process. + * + * Example: AppStatus.APP_SUBMITTED. + */ + @Column({ type: 'enum', enum: AppStatus, default: AppStatus.APP_SUBMITTED }) + appStatus!: AppStatus; + + /** + * Applicant's Sunday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar', default: '' }) + sundayAvailability: string; + + /** + * Applicant's Monday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar' }) + mondayAvailability: string; + + /** + * Applicant's Tuesday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar' }) + tuesdayAvailability: string; + + /** + * Applicant's Wednesday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar' }) + wednesdayAvailability: string; + + /** + * Applicant's Thursday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar' }) + thursdayAvailability: string; + + /** + * Applicant's Friday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar' }) + fridayAvailability: string; + + /** + * Applicant's Saturday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @Column({ type: 'varchar' }) + saturdayAvailability: string; + + /** + * Experience type/ level of the applicant, generally in terms of medical experience or degree. + * + * Example: ExperienceType.BS. + */ + @Column({ type: 'enum', enum: ExperienceType }) + experienceType!: ExperienceType; + + /** + * Applicant's area of interest for the commitment. + * + * Example: [InterestArea.NURSING, InterestArea.HARM_REDUCTION]. + */ + @Column({ type: 'enum', enum: InterestArea, array: true, default: [] }) + interest!: InterestArea[]; + + /** + * Any licenses that the applicant holds + * + * Example: PHYSICIAN LICENSE + */ + @Column({ type: 'varchar' }) + license!: string; + + /** + * Phone number of the applicant in ###-###-#### format. + * + * Example: "123-456-7890". + */ + @Column({ type: 'varchar' }) + phone!: string; + + /** + * Type of applicant, currently either a learner or a volunteer. + * + * Example: ApplicantType.LEARNER. + */ + @Column({ type: 'enum', enum: ApplicantType }) + applicantType!: ApplicantType; + + /** + * School of the applicant; includes well-known medical schools or an 'other' option. + * + * Example: School.STANFORD_MEDICINE. + */ + @Column({ type: 'enum', enum: School }) + school!: School; + + /** + * Name of school if chose other + * + * Example: Northeastern University + */ + @Column({ type: 'varchar', nullable: true }) + otherSchool?: string; + + /** + * Whether or not the applicant was referred by someone else. + * + * Example: false. + */ + @Column({ type: 'boolean', default: false, nullable: true }) + referred?: boolean; + + /** + * The email of the person who referred this applicant, if applicable. + * + * Example: jane.doe@example.com. + */ + @Column({ type: 'varchar', nullable: true }) + referredEmail?: string; + + /** + * Applicant's desired commitment in hours per week. + * + * Example: 20. + */ + @Column({ type: 'int' }) + weeklyHours!: number; + + /** + * Application's pronouns + * + * Example: they/them + */ + @Column({ type: 'varchar' }) + pronouns: string; + + /** + * Applicant's languages spoken other than English + * + * Example: some german + */ + @Column({ type: 'varchar', nullable: true }) + nonEnglishLangs?: string; + + /** + * Description of the type of experience the applicant is looking for + * + * Example: I want to give back to the boston community and learn to talk better with patients + */ + @Column({ type: 'varchar' }) + desiredExperience: string; + + /** + * Field for someone to elaborate on their discipline if they chose other for discipline dropdown + * + * Example: + */ + @Column({ type: 'varchar', nullable: true }) + elaborateOtherDiscipline?: string; + + /** + * Name of the resume file stored in S3 with its extension + * + * Example: janedoe_resume_2_6_2026.pdf + * + * Note: In the code when accessing the files we would prepend the s3 address, e.g. + * a full link looks like this: + * https://shelter-link-shelters.s3.us-east-2.amazonaws.com/test_photo.webp + * But since "https://shelter-link-shelters.s3.us-east-2.amazonaws.com/" would look the same + * for every single file we can just store the file with its extension e.g. "test_photo.webp" + */ + @Column({ type: 'varchar' }) + resume: string; + + /** + * Name of the cover letter file stored in S3 with its extension + * + * Example: jane_doe_coverLetter_2_6_2026.pdf + * + * Note: In the code when accessing the files we would prepend the s3 address, e.g. + * a full link looks like this: + * https://shelter-link-shelters.s3.us-east-2.amazonaws.com/test_photo.webp + * But since "https://shelter-link-shelters.s3.us-east-2.amazonaws.com/" would look the same + * for every single file we can just store the file with its extension e.g. "test_photo.webp" + */ + @Column({ type: 'varchar' }) + coverLetter: string; + + /** + * Name of the applicant's emergency contact + * + * Example: Jane Doe + */ + @Column({ type: 'varchar' }) + emergencyContactName!: string; + + /** + * Phone number of the applicant's emergency contact + * + * Example: 111-111-1111 + */ + @Column({ type: 'varchar' }) + emergencyContactPhone!: string; + + /** + * Relationship between the applicant and their emergency contact + * + * Example: Mother + */ + @Column({ type: 'varchar' }) + emergencyContactRelationship!: string; +} 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 000000000..fb8f5d60d --- /dev/null +++ b/apps/backend/src/applications/application.service.spec.ts @@ -0,0 +1,959 @@ +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, + ApplicantType, +} from './types'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; + +describe('ApplicationsService', () => { + let service: ApplicationsService; + let repository: Repository; + + const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + remove: 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, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'some french, native spanish speaker', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }, + ]; + + mockRepository.find.mockResolvedValue(mockApplications); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual(mockApplications); + }); + + it('should return an empty array if the repo returns one', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.find.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findAll()).rejects.toThrow( + `There was a problem retrieving the info`, + ); + }); + }); + + describe('findById', () => { + it('should return a single application', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + nonEnglishLangs: 'spoken chinese only', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + 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 }, + }); + }); + + // TODO: Address this in codebase so it passes. + // Note: Adding .skip for now so it doesn't confuse people in their develop then tests all pass work cycle + it.skip('should not return an application from the repo if the id is not the same as asked for', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: 'n/a', + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'some french, native spanish speaker', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + + const result = await service.findById(10); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 10 } }); + expect(repository.findOne).toThrow(); + }); + + it('should handle returning an application with no changes when optional fields are ommitted', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'none', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + + const result = await service.findById(1); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(result).toEqual(mockApplication); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findById(1)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); + }); + + describe('create', () => { + it('should create and save a new application', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'some chinese', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.save.mockResolvedValue(savedApplication); + + const result = await service.create(createApplicationDto); + + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(savedApplication); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + const mockApplication: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + weeklyHours: 20, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + await expect(service.create(mockApplication)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); + + it('should not accept a phone number that is too long', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-78901231', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + + it('should not accept a phone number that is too short', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-4562', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + + it('should not accept a phone number that is the right length but not in ###-###-#### format', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-8-90', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + + it('should not accept 0 weekly hours', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 0, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + + it('should not accept negative weekly hours', async () => { + const createApplicationDto: CreateApplicationDto = { + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-78901231', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: -5, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + elaborateOtherDiscipline: 'text', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + heardAboutFrom: [], + }; + + const savedApplication: Application = { + appId: 1, + ...createApplicationDto, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.save.mockResolvedValue(savedApplication); + await expect(service.create(createApplicationDto)).rejects.toThrow(); + }); + }); + + describe('update', () => { + it('should update application status', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + const updatedApplication: Application = { + ...mockApplication, + appStatus: AppStatus.IN_REVIEW, + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.save.mockResolvedValue(updatedApplication); + + const result = await service.update(1, { + appStatus: AppStatus.IN_REVIEW, + }); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(repository.save).toHaveBeenCalledWith({ + ...mockApplication, + appStatus: AppStatus.IN_REVIEW, + }); + expect(result).toEqual(updatedApplication); + }); + + it('should update application discipline', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + const updatedApplication: Application = { + ...mockApplication, + interest: [InterestArea.STREET_MEDICINE], + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.save.mockResolvedValue(updatedApplication); + + const result = await service.update(1, { + interest: [InterestArea.STREET_MEDICINE], + }); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(repository.save).toHaveBeenCalledWith({ + ...mockApplication, + interest: [InterestArea.STREET_MEDICINE], + }); + expect(result).toEqual(updatedApplication); + }); + + it('should throw NotFoundException when updating non-existent application', async () => { + const nonExistentId = 999; + + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.update(nonExistentId, { appStatus: AppStatus.IN_REVIEW }), + ).rejects.toThrow( + new NotFoundException(`Application with ID ${nonExistentId} not found`), + ); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { appId: nonExistentId }, + }); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('should pass along any repo errors from retrieval without information loss when saving a new discipline', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { interest: [InterestArea.STREET_MEDICINE] }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should pass along any repo errors from retrieval without information loss when saving a new application status', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { appStatus: AppStatus.IN_REVIEW }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should pass along any repo errors from saving the new info without information loss when saving a new discipline', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { interest: [InterestArea.STREET_MEDICINE] }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should pass along any repo errors from saving the new info without information loss when saving a new application status', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.update(1, { appStatus: AppStatus.IN_REVIEW }), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + }); + + describe('delete', () => { + it('should delete an application', async () => { + const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + elaborateOtherDiscipline: 'text', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }; + + mockRepository.findOne.mockResolvedValue(mockApplication); + mockRepository.remove.mockResolvedValue(mockApplication); + + await service.delete(1); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(repository.remove).toHaveBeenCalledWith(mockApplication); + }); + + it('should throw NotFoundException when deleting non-existent application', async () => { + const nonExistentId = 999; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.delete(nonExistentId)).rejects.toThrow( + new NotFoundException(`Application with ID ${nonExistentId} not found`), + ); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { appId: nonExistentId }, + }); + expect(repository.remove).not.toHaveBeenCalled(); + }); + }); + + describe('findByDiscipline', () => { + it('should return applications with the specified discipline', async () => { + const mockApplications: Application[] = [ + { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'some french, native spanish speaker', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }, + { + appId: 2, + appStatus: AppStatus.IN_REVIEW, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.MS, + interest: [InterestArea.WOMENS_HEALTH], + license: null, + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'some french, native spanish speaker', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }, + ]; + + mockRepository.find.mockResolvedValue(mockApplications); + + const result = await service.findByDiscipline(DISCIPLINE_VALUES.RN); + + expect(repository.find).toHaveBeenCalledWith({ + where: { discipline: DISCIPLINE_VALUES.RN }, + }); + expect(result).toEqual(mockApplications); + }); + + it('should return an empty array when no applications match the discipline', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findByDiscipline(DISCIPLINE_VALUES.RN); + + expect(repository.find).toHaveBeenCalledWith({ + where: { discipline: DISCIPLINE_VALUES.RN }, + }); + expect(result).toEqual([]); + }); + + it('should throw BadRequestException for invalid discipline', async () => { + const invalidDiscipline = 'InvalidDiscipline'; + + await expect(service.findByDiscipline(invalidDiscipline)).rejects.toThrow( + expect.objectContaining({ + message: expect.stringContaining( + `Invalid discipline: ${invalidDiscipline}`, + ), + }), + ); + + expect(repository.find).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException with list of valid disciplines', async () => { + const invalidDiscipline = 'InvalidDiscipline'; + + try { + await service.findByDiscipline(invalidDiscipline); + fail('Expected BadRequestException to be thrown'); + } catch (error) { + expect(error.message).toContain('Invalid discipline'); + expect(error.message).toContain('Valid disciplines are:'); + expect(error.message).toContain('MD/Medical Student/Pre-Med'); + expect(error.message).toContain('Medical NP/PA'); + expect(error.message).toContain('Psychiatry or Psychiatric NP/PA'); + expect(error.message).toContain('Public Health'); + expect(error.message).toContain('RN'); + expect(error.message).toContain('Social Work'); + expect(error.message).toContain('Other'); + } + + expect(repository.find).not.toHaveBeenCalled(); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.find.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.findByDiscipline(DISCIPLINE_VALUES.RN), + ).rejects.toThrow(`There was a problem retrieving the info`); + }); + + it('should work with all valid discipline values', async () => { + const allDisciplines = Object.values(DISCIPLINE_VALUES); + + for (const discipline of allDisciplines) { + mockRepository.find.mockResolvedValue([]); + + await service.findByDiscipline(discipline); + + expect(repository.find).toHaveBeenCalledWith({ + where: { discipline }, + }); + } + }); + }); +}); diff --git a/apps/backend/src/applications/applications.controller.spec.ts b/apps/backend/src/applications/applications.controller.spec.ts new file mode 100644 index 000000000..3ca777d97 --- /dev/null +++ b/apps/backend/src/applications/applications.controller.spec.ts @@ -0,0 +1,258 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { ApplicationsController } from './applications.controller'; +import { ApplicationsService } from './applications.service'; +import { Application } from './application.entity'; +import { + AppStatus, + ExperienceType, + InterestArea, + School, + ApplicantType, +} from './types'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; + +const mockApplicationsService: Partial = { + findAll: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findByDiscipline: jest.fn(), +}; + +const mockApplication: Application = { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.WOMENS_HEALTH], + license: 'n/a', + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'they/them', + nonEnglishLangs: 'some french, native spanish speaker', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', +}; + +describe('ApplicationsController', () => { + let controller: ApplicationsController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApplicationsController], + providers: [ + { + provide: ApplicationsService, + useValue: mockApplicationsService, + }, + ], + }).compile(); + + controller = module.get(ApplicationsController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getApplicationsByDiscipline', () => { + it('should return applications with the specified discipline', async () => { + const mockApplications: Application[] = [ + mockApplication, + { + ...mockApplication, + appId: 2, + email: 'test2@example.com', + }, + ]; + + jest + .spyOn(mockApplicationsService, 'findByDiscipline') + .mockResolvedValue(mockApplications); + + const result = await controller.getApplicationsByDiscipline( + DISCIPLINE_VALUES.RN, + {}, + ); + + expect(result).toEqual(mockApplications); + expect(mockApplicationsService.findByDiscipline).toHaveBeenCalledWith( + DISCIPLINE_VALUES.RN, + ); + }); + + it('should return an empty array when no applications match the discipline', async () => { + jest + .spyOn(mockApplicationsService, 'findByDiscipline') + .mockResolvedValue([]); + + const result = await controller.getApplicationsByDiscipline( + DISCIPLINE_VALUES.RN, + {}, + ); + + expect(result).toEqual([]); + expect(mockApplicationsService.findByDiscipline).toHaveBeenCalledWith( + DISCIPLINE_VALUES.RN, + ); + }); + + it('should throw BadRequestException for invalid discipline', async () => { + const invalidDiscipline = 'InvalidDiscipline'; + const errorMessage = `Invalid discipline: ${invalidDiscipline}. Valid disciplines are: ${Object.values( + DISCIPLINE_VALUES, + ).join(', ')}`; + + jest + .spyOn(mockApplicationsService, 'findByDiscipline') + .mockRejectedValue(new BadRequestException(errorMessage)); + + await expect( + controller.getApplicationsByDiscipline(invalidDiscipline, {}), + ).rejects.toThrow(BadRequestException); + + expect(mockApplicationsService.findByDiscipline).toHaveBeenCalledWith( + invalidDiscipline, + ); + }); + + it('should pass along service errors without information loss', async () => { + const errorMessage = 'There was a problem retrieving the info'; + + jest + .spyOn(mockApplicationsService, 'findByDiscipline') + .mockRejectedValue(new Error(errorMessage)); + + await expect( + controller.getApplicationsByDiscipline(DISCIPLINE_VALUES.RN, {}), + ).rejects.toThrow(errorMessage); + + expect(mockApplicationsService.findByDiscipline).toHaveBeenCalledWith( + DISCIPLINE_VALUES.RN, + ); + }); + + it('should work with all valid discipline values', async () => { + const allDisciplines = Object.values(DISCIPLINE_VALUES); + + for (const discipline of allDisciplines) { + jest + .spyOn(mockApplicationsService, 'findByDiscipline') + .mockResolvedValue([]); + + await controller.getApplicationsByDiscipline(discipline, {}); + + expect(mockApplicationsService.findByDiscipline).toHaveBeenCalledWith( + discipline, + ); + } + }); + }); + + /** + * Tests for PATCH /:appId/discipline (updateApplicationDiscipline). + * Verifies that the controller delegates to the service and returns or throws as documented. + */ + describe('updateApplicationDiscipline', () => { + /** + * When the service returns an updated application, the controller should return that same application. + */ + it('should return the updated application when discipline is updated successfully', async () => { + const updateDisciplineDto = { + discipline: DISCIPLINE_VALUES.PublicHealth, + }; + const updatedApplication: Application = { + ...mockApplication, + discipline: DISCIPLINE_VALUES.PublicHealth, + }; + + jest + .spyOn(mockApplicationsService, 'update') + .mockResolvedValue(updatedApplication); + + const result = await controller.updateApplicationDiscipline( + 1, + updateDisciplineDto, + {}, + ); + + expect(result).toEqual(updatedApplication); + expect(mockApplicationsService.update).toHaveBeenCalledWith(1, { + discipline: DISCIPLINE_VALUES.PublicHealth, + }); + }); + + /** + * The returned application's discipline field must equal the discipline sent in the request (discipline is changeable). + */ + it('should return an application whose discipline field equals the requested discipline', async () => { + const requestedDiscipline = DISCIPLINE_VALUES.PublicHealth; + const updateDisciplineDto = { discipline: requestedDiscipline }; + const updatedApplication: Application = { + ...mockApplication, + discipline: requestedDiscipline, + }; + + jest + .spyOn(mockApplicationsService, 'update') + .mockResolvedValue(updatedApplication); + + const result = await controller.updateApplicationDiscipline( + 1, + updateDisciplineDto, + {}, + ); + + expect(result.discipline).toBe(requestedDiscipline); + expect(result.discipline).not.toBe(mockApplication.discipline); + }); + + /** + * The controller should call the service with the application id and the discipline from the DTO. + */ + it('should call the service with the correct appId and discipline', async () => { + const appId = 42; + const updateDisciplineDto = { discipline: DISCIPLINE_VALUES.RN }; + const updatedApplication: Application = { + ...mockApplication, + appId, + discipline: DISCIPLINE_VALUES.RN, + }; + + jest + .spyOn(mockApplicationsService, 'update') + .mockResolvedValue(updatedApplication); + + await controller.updateApplicationDiscipline( + appId, + updateDisciplineDto, + {}, + ); + + expect(mockApplicationsService.update).toHaveBeenCalledWith(appId, { + discipline: DISCIPLINE_VALUES.RN, + }); + }); + }); +}); diff --git a/apps/backend/src/applications/applications.controller.ts b/apps/backend/src/applications/applications.controller.ts new file mode 100644 index 000000000..e755004a5 --- /dev/null +++ b/apps/backend/src/applications/applications.controller.ts @@ -0,0 +1,165 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + Request, +} from '@nestjs/common'; +import { ApplicationsService } from './applications.service'; +import { Application } from './application.entity'; +import { CreateApplicationDto } from './dto/create-application.request.dto'; +import { ApiTags } from '@nestjs/swagger'; +import { UpdateApplicationStatusDto } from './dto/update-application-status.request.dto'; +import { UpdateApplicationDisciplineDto } from './dto/update-application-discipline.request.dto'; +import { UpdateApplicationAvailabilityDto } from './dto/update-application-availability.request.dto'; + +/** + * Controller to expose HTTP endpoints to interface, extract, and change information about the app's applications. + */ +@ApiTags('Applications') +@Controller('applications') +export class ApplicationsController { + constructor(private applicationsService: ApplicationsService) {} + + /** + * Exposes an endpoint to return all applications. + * @param req The request object from the caller (frontend). Currently not used. + * @returns A promise of the list of all available applications. + * @throws {Error} which is unchanged from what repository throws. + */ + @Get() + async getAllApplications(@Request() req): Promise { + return await this.applicationsService.findAll(); + } + + /** + * Exposes an endpoint to return all applications filtered by discipline. + * @param discipline The discipline to filter applications by. + * @param req The request object from the caller (frontend). Currently not used. + * @returns A promise of the list of applications with the specified discipline. + * Returns an empty array if no applications match the discipline. + * @throws {BadRequestException} if the discipline is not a valid DISCIPLINE_VALUES enum value. + * @throws {Error} which is unchanged from what repository throws. + */ + @Get('by-discipline') + async getApplicationsByDiscipline( + @Query('discipline') discipline: string, + @Request() req, + ): Promise { + return await this.applicationsService.findByDiscipline(discipline); + } + + /** + * Exposes an endpoint to return an application by id. + * @param appId The desired application id to search for. + * @param req The request object from the caller (frontend). Currently not used. + * @returns A promise of the application with that id. + * @throws {NotFoundException} with message 'Application with ID not found' + * if an application with that id does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + @Get('/:appId') + async getApplicationById( + @Param('appId', ParseIntPipe) appId: number, + @Request() req, + ): Promise { + return await this.applicationsService.findById(appId); + } + + /** + * Exposes an endpoint to create an application. + * @param createApplicationDto The expected data required to create an application (applicant's info). + * @param req The request object from the caller (frontend). Currently not used. + * @returns The newly created application. + * @throws {Error} which is unchanged from what repository throws. + */ + @Post() + async createApplication( + @Body() createApplicationDto: CreateApplicationDto, + @Request() req, + ): Promise { + return await this.applicationsService.create(createApplicationDto); + } + + /** + * Exposes an endpoint to update the status of the application. + * @param appId The id of the application to update. + * @param updateStatusDto Object containing the desired new application status. + * @param req The request object from the caller (frontend). Currently not used. + * @returns The updated application object. + * @throws {NotFoundException} with message 'Application with ID not found' + * if the application does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + @Patch('/:appId/status') + async updateApplicationStatus( + @Param('appId', ParseIntPipe) appId: number, + @Body() updateStatusDto: UpdateApplicationStatusDto, + @Request() req, + ): Promise { + return await this.applicationsService.update(appId, { + appStatus: updateStatusDto.appStatus, + }); + } + + /** + * Exposes an endpoint to update the application's discipline. + * @param appId The id of the application to modify. + * @param updateDisciplineDto Object containing the desired new discipline (must be a valid DISCIPLINE_VALUES enum value). + * @param req The request object from the caller (frontend). Currently not used. + * @returns The updated application object. + * @throws {NotFoundException} with message 'Application with ID not found' + * if the application does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + @Patch('/:appId/discipline') + async updateApplicationDiscipline( + @Param('appId', ParseIntPipe) appId: number, + @Body() updateDisciplineDto: UpdateApplicationDisciplineDto, + @Request() req, + ): Promise { + return await this.applicationsService.update(appId, { + discipline: updateDisciplineDto.discipline, + }); + } + + /** + * Exposes an endpoint to update the availability fields of an application. + * @param appId The id of the application to update. + * @param updateAvailabilityDto Object containing one or more day availability strings. + * @param req The request object from the caller (frontend). Currently not used. + * @returns The updated application object. + * @throws {NotFoundException} if the application does not exist. + */ + @Patch('/:appId/availability') + async updateApplicationAvailability( + @Param('appId', ParseIntPipe) appId: number, + @Body() updateAvailabilityDto: UpdateApplicationAvailabilityDto, + @Request() req, + ): Promise { + return await this.applicationsService.update(appId, updateAvailabilityDto); + } + + /** + * Exposes an endpoint to delete an application from the system. + * @param appId The id of the application to delete. + * @param req The request object from the caller (frontend). Currently not used. + * @throws {NotFoundException} with message 'Application with ID not found' + * if the application does not exist. + * @throws {Error} which is unchanged from what repository throws. + * + * Does not return a value. + */ + @Delete('/:appId') + async deleteApplication( + @Param('appId', ParseIntPipe) appId: number, + @Request() req, + ): Promise { + return await this.applicationsService.delete(appId); + } +} diff --git a/apps/backend/src/applications/applications.module.ts b/apps/backend/src/applications/applications.module.ts new file mode 100644 index 000000000..723a51c71 --- /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 000000000..b8143a558 --- /dev/null +++ b/apps/backend/src/applications/applications.service.ts @@ -0,0 +1,150 @@ +import { + Injectable, + NotFoundException, + BadRequestException, +} 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 { PHONE_REGEX } from './types'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; + +/** + * Service for applications that interfaces with the application repository. + */ +@Injectable() +export class ApplicationsService { + constructor( + @InjectRepository(Application) + private applicationRepository: Repository, + ) {} + + /** + * Validates the fields of a CreateApplicationDto. + * @param dto The DTO to validate. + * @throws {BadRequestException} if any field fails validation. + */ + private validateApplicationDto(dto: CreateApplicationDto): void { + // Validate phone number format + if (!PHONE_REGEX.test(dto.phone)) { + throw new BadRequestException( + 'Phone number must be in ###-###-#### format', + ); + } + + // Validate weeklyHours is positive + if (dto.weeklyHours <= 0 || dto.weeklyHours > 7 * 24) { + throw new BadRequestException( + 'Weekly hours must be greater than 0 and less than 7 * 24 hours', + ); + } + } + + /** + * Returns all applications in the repository. + * @returns A promise resolving to all applications in the repository. + * @throws {Error} which is unchanged from what repository throws. + */ + async findAll(): Promise { + return await this.applicationRepository.find(); + } + + /** + * Returns an application by id from the repository. + * @param appId The desired application id to search for. + * @returns A promise resolving to the application with that id. + * @throws {NotFoundException} with message 'Application with ID not found' + * if an application with that id does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + 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; + } + + /** + * Validates that the provided discipline is a valid DISCIPLINE_VALUES enum value. + * @param discipline The discipline value to validate. + * @throws {BadRequestException} if the discipline is not a valid DISCIPLINE_VALUES enum value. + */ + private validateDiscipline(discipline: string): void { + if ( + !Object.values(DISCIPLINE_VALUES).includes( + discipline as DISCIPLINE_VALUES, + ) + ) { + throw new BadRequestException( + `Invalid discipline: ${discipline}. Valid disciplines are: ${Object.values( + DISCIPLINE_VALUES, + ).join(', ')}`, + ); + } + } + + /** + * Returns all applications that have the specified discipline. + * @param discipline The discipline to filter applications by. + * @returns A promise resolving to an array of applications with the specified discipline. + * Returns an empty array if no applications match the discipline. + * @throws {BadRequestException} if the discipline is not a valid DISCIPLINE_VALUES enum value. + * @throws {Error} which is unchanged from what repository throws. + */ + async findByDiscipline(discipline: string): Promise { + this.validateDiscipline(discipline); + return await this.applicationRepository.find({ + where: { discipline: discipline as DISCIPLINE_VALUES }, + }); + } + + /** + * Creates an application in the repository. + * @param createApplicationDto The expected data required to create an application (applicant's info). + * @returns The newly created application. + * @throws {BadRequestException} if validation fails. + * @throws {Error} which is unchanged from what repository throws. + */ + async create( + createApplicationDto: CreateApplicationDto, + ): Promise { + this.validateApplicationDto(createApplicationDto); + const application = this.applicationRepository.create(createApplicationDto); + return await this.applicationRepository.save(application); + } + + /** + * Updates the status of the application in the repository. + * @param appId The id of the application to update. + * @param updateData Object containing the desired new application status. + * @returns The updated application object. + * @throws {NotFoundException} with message 'Application with ID not found' + * if the application does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + async update( + appId: number, + updateData: Partial, + ): Promise { + const application = await this.findById(appId); + if (!application) { + throw new NotFoundException(`Application with ID ${appId} not found`); + } + Object.assign(application, updateData); + return await this.applicationRepository.save(application); + } + + async delete(appId: number): Promise { + const application = await this.findById(appId); + if (!application) { + throw new NotFoundException(`Application with ID ${appId} not found`); + } + await this.applicationRepository.remove(application); + } +} 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 000000000..179f1e06c --- /dev/null +++ b/apps/backend/src/applications/dto/create-application.request.dto.ts @@ -0,0 +1,280 @@ +import { + IsBoolean, + IsEnum, + IsNumber, + IsString, + IsOptional, + IsNotEmpty, + IsDefined, + Matches, + Min, + Max, + IsEmail, + IsArray, +} from 'class-validator'; +import { + AppStatus, + ExperienceType, + InterestArea, + School, + ApplicantType, + HeardAboutFrom, +} from '../types'; +import { DISCIPLINE_VALUES } from '../../disciplines/disciplines.constants'; + +/** + * Defines the expected shape of data for creating an application. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class CreateApplicationDto { + /** + * Status of the application in the review process. + * + * Example: AppStatus.APP_SUBMITTED. + */ + @IsEnum(AppStatus) + @IsDefined() + appStatus: AppStatus; + + /** + * Applicant's Monday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @IsString() + mondayAvailability: string; + + /** + * Applicant's Tuesday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @IsString() + tuesdayAvailability: string; + + /** + * Applicant's Wednesday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @IsString() + wednesdayAvailability: string; + + /** + * Applicant's Thursday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @IsString() + thursdayAvailability: string; + + /** + * Applicant's Friday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @IsString() + fridayAvailability: string; + + /** + * Applicant's Saturday availability as a free text string. + * + * Example: 12pm and on every other week + */ + @IsString() + saturdayAvailability: string; + + /** + * Type of applicant, currently either a learner or a volunteer. + * + * Example: ApplicantType.LEARNER. + */ + @IsEnum(ApplicantType) + @IsDefined() + applicantType: ApplicantType; + + /** + * Experience type/ level of the applicant, generally in terms of medical experience/ degree. + * + * Example: ExperienceType.BS. + */ + @IsEnum(ExperienceType) + @IsDefined() + experienceType: ExperienceType; + + /** + * Applicant's areas of interest for the commitment (multiple select). + * + * Example: [InterestArea.NURSING, InterestArea.HARM_REDUCTION]. + */ + @IsArray() + @IsEnum(InterestArea, { each: true }) + @IsDefined() + interest: InterestArea[]; + + /** + * Any licenses that the applicant holds + * + * Example: PHYSICIAN LICENSE + */ + @IsString() + @IsNotEmpty() + license: string; + + /** + * Phone number of the applicant in ###-###-#### format. + * + * Example: "123-456-7890". + */ + @IsString() + @IsDefined() + @Matches(/^\d{3}-\d{3}-\d{4}$/, { + message: 'Phone number must be in ###-###-#### format', + }) + phone: string; + + /** + * School of the applicant, includes well-known medical schools, or an other option. + * + * Example: School.STANFORD_MEDICINE. + */ + @IsEnum(School) + @IsDefined() + school: School; + + /** + * Name of school if chose other + * + * Example: Northeastern University + */ + @IsString() + otherSchool?: string; + + /** + * Email of the applicant. + * + * Example: bob.ross@example.com + */ + @IsEmail() + @IsDefined() + email: string; + + /** + * Discipline of the applicant. + * + * Example: "Nursing" + */ + @IsEnum(DISCIPLINE_VALUES) + @IsDefined() + discipline: DISCIPLINE_VALUES; + + /** + * Discipline or area of interest description of applicant clicked other + */ + @IsString() + otherDisciplineDescription?: string; + + /** + * Whether or not the applicant was referred by someone else. + * + * Example: false. + */ + @IsBoolean() + @IsOptional() + referred?: boolean; + + /** + * The email of the person who referred this applicant, if applicable. + * + * Example: jane.doe@example.com. + */ + @IsString() + @IsOptional() + referredEmail?: string; + + /** + * Applicant's desired commitment in amount of hours per week. + * + * Example: 20. + */ + @IsNumber() + @IsDefined() + @Min(1) + @Max(168) // 168 hours in a week, can change later if there's a business limit + weeklyHours: number; + + /** + * Applicant's pronouns + * + * Example: they/them + */ + @IsString() + @IsDefined() + pronouns: string; + + /** + * Languages that the applicant speaks other than English + * + * Example: I speak some cantonese + */ + @IsString() + @IsOptional() + nonEnglishLangs?: string; + + /** + * Description of the type of experience the applicant is looking for + * + * Example: I want to give back to the boston community and learn to talk better with patients + */ + @IsString() + @IsDefined() + desiredExperience: string; + + /** + * Field for someone to elaborate on their discipline if they chose other for discipline dropdown + * + * Example: + */ + @IsString() + @IsOptional() + elaborateOtherDiscipline?: string; + + /** + * Name of the applicant's emergency contact + * + * Example: Jane Doe + */ + @IsString() + @IsNotEmpty() + emergencyContactName: string; + + /** + * Phone number of the applicant's emergency contact + * + * Example: Jane Doe + */ + @IsString() + @IsDefined() + @Matches(/^\d{3}-\d{3}-\d{4}$/, { + message: 'Phone number must be in ###-###-#### format', + }) + emergencyContactPhone: string; + + /** + * Relationship between the applicant and their emergency contact + * + * Example: Mother + */ + @IsString() + @IsNotEmpty() + emergencyContactRelationship: string; + + /** + * List of sources that the applicant heard about BHCHP from + * + * Example: HeardAboutFrom.OTHER, HeardAboutFrom.SCHOOL + */ + @IsEnum(HeardAboutFrom) + heardAboutFrom: HeardAboutFrom[]; +} diff --git a/apps/backend/src/applications/dto/update-application-availability.request.dto.ts b/apps/backend/src/applications/dto/update-application-availability.request.dto.ts new file mode 100644 index 000000000..599a56571 --- /dev/null +++ b/apps/backend/src/applications/dto/update-application-availability.request.dto.ts @@ -0,0 +1,31 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateApplicationAvailabilityDto { + @IsOptional() + @IsString() + sundayAvailability?: string; + + @IsOptional() + @IsString() + mondayAvailability?: string; + + @IsOptional() + @IsString() + tuesdayAvailability?: string; + + @IsOptional() + @IsString() + wednesdayAvailability?: string; + + @IsOptional() + @IsString() + thursdayAvailability?: string; + + @IsOptional() + @IsString() + fridayAvailability?: string; + + @IsOptional() + @IsString() + saturdayAvailability?: string; +} diff --git a/apps/backend/src/applications/dto/update-application-discipline.request.dto.ts b/apps/backend/src/applications/dto/update-application-discipline.request.dto.ts new file mode 100644 index 000000000..1abfcd05c --- /dev/null +++ b/apps/backend/src/applications/dto/update-application-discipline.request.dto.ts @@ -0,0 +1,22 @@ +import { IsDefined, IsEnum } from 'class-validator'; +import { DISCIPLINE_VALUES } from '../../disciplines/disciplines.constants'; + +/** + * Defines the expected shape of data for updating an application's discipline. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class UpdateApplicationDisciplineDto { + /** + * Application's new discipline. + * + * Example: DISCIPLINE_VALUES.Nursing. + */ + @IsEnum(DISCIPLINE_VALUES, { + message: `Discipline must be one of: ${Object.values( + DISCIPLINE_VALUES, + ).join(', ')}`, + }) + @IsDefined() + discipline: DISCIPLINE_VALUES; +} diff --git a/apps/backend/src/applications/dto/update-application-interest.request.dto.ts b/apps/backend/src/applications/dto/update-application-interest.request.dto.ts new file mode 100644 index 000000000..a671ba2e9 --- /dev/null +++ b/apps/backend/src/applications/dto/update-application-interest.request.dto.ts @@ -0,0 +1,24 @@ +import { IsArray, IsDefined, IsEnum } from 'class-validator'; +import { InterestArea } from '../types'; + +/** + * Defines the expected shape of data for updating an applicant's application interest areas. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class UpdateApplicationInterestDto { + /** + * Applicant's new areas of interest (multiple select). + * + * Example: [InterestArea.NURSING, InterestArea.HARM_REDUCTION]. + */ + @IsArray() + @IsEnum(InterestArea, { + each: true, + message: `Each interest must be one of: ${Object.values(InterestArea).join( + ', ', + )}`, + }) + @IsDefined() + interest: InterestArea[]; +} diff --git a/apps/backend/src/applications/dto/update-application-status.request.dto.ts b/apps/backend/src/applications/dto/update-application-status.request.dto.ts new file mode 100644 index 000000000..d7ddff6aa --- /dev/null +++ b/apps/backend/src/applications/dto/update-application-status.request.dto.ts @@ -0,0 +1,20 @@ +import { IsDefined, IsEnum } from 'class-validator'; +import { AppStatus } from '../types'; + +/** + * Defines the expected shape of data for updating an applicant's application status. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class UpdateApplicationStatusDto { + /** + * The applicant's new stage or status in the application process. + * + * Example: AppStatus.IN_REVIEW. + */ + @IsEnum(AppStatus, { + message: `Status must be one of: ${Object.values(AppStatus).join(', ')}`, + }) + @IsDefined() + appStatus: AppStatus; +} diff --git a/apps/backend/src/applications/types.ts b/apps/backend/src/applications/types.ts new file mode 100644 index 000000000..edc4482f4 --- /dev/null +++ b/apps/backend/src/applications/types.ts @@ -0,0 +1,81 @@ +/** + * Status of the application in the system/ review process + */ +export enum AppStatus { + APP_SUBMITTED = 'App submitted', + IN_REVIEW = 'In review', + FORMS_SENT = 'Forms sent', + ACCEPTED = 'Accepted', + NO_AVAILABILITY = 'No Availability', + DECLINED = 'Declined', + ACTIVE = 'Active', + INACTIVE = 'Inactive', +} + +/** + * Experience type/ level of the applicant, generally in terms of medical experience/ degree + */ +export enum ExperienceType { + BS = 'BS', + MS = 'MS', + PHD = 'PhD', + MD = 'MD', + MD_PHD = 'MD PhD', + RN = 'RN', + NP = 'NP', + PA = 'PA', + OTHER = 'Other', +} + +/** + * Applicant's area of interest for the commitment + */ +export enum InterestArea { + WOMENS_HEALTH = "Women's Health", + MEDICAL_RESPITE_INPATIENT = 'Medical Respite/Inpatient', + STREET_MEDICINE = 'Street Medicine', + ADDICTION_MEDICINE = 'Addiction Medicine', + PRIMARY_CARE = 'Primary Care', + BEHAVIORAL_HEALTH = 'Behavioral Health', + VETERANS_SERVICES = 'Veterans Services', + FAMILY_AND_YOUTH_SERVICES = 'Family and Youth Services', + HEP_C_CARE = 'Hep C Care', + HIV_SERVICES = 'HIV Services', + CASE_MANAGEMENT = 'Case Management', + DENTAL = 'Dental', +} + +/** + * School of the applicant, includes well-known medical schools, or an other option + */ +export enum School { + HARVARD_MEDICAL_SCHOOL = 'Harvard Medical School', + JOHNS_HOPKINS = 'Johns Hopkins', + STANFORD_MEDICINE = 'Stanford Medicine', + MAYO_CLINIC = 'Mayo Clinic', + OTHER = 'Other', +} + +export enum ApplicantType { + LEARNER = 'Learner', + VOLUNTEER = 'Volunteer', +} + +/** + * Phone number regex pattern for ###-###-#### format validation + * @see https://stackoverflow.com/questions/16699007/regular-expression-to-match-standard-10-digit-phone-number + */ +export const PHONE_REGEX = /^(\+\d{1,2}\s)?\(?\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}$/; + +/** + * How the applicant heard about BHCHP + */ +export enum HeardAboutFrom { + ONLINE_SEARCH = 'Online Search', + BHCHP_WEBSITE = 'BHCHP Website', + SCHOOL = 'School', + FROM_A_BHCHP_STAFF_MEMBER = 'From a BHCHP Staff Member', + OTHER = 'Other', + FRIEND_FAMILY = 'Friend/Family', + CURRENT_OR_FORMER_STAFF = 'I am a current/former BHCHP staff member', +} diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index 27a31e618..cfece9a46 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -1,12 +1,44 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { UsersService } from '../users/users.service'; +import { BadRequestException } from '@nestjs/common'; describe('AuthController', () => { let controller: AuthController; + // Create proper mocks with all methods + const mockAuthService = { + signup: jest.fn(), + verifyUser: jest.fn(), + signin: jest.fn(), + refreshToken: jest.fn(), + forgotPassword: jest.fn(), + confirmForgotPassword: jest.fn(), + deleteUser: jest.fn(), + }; + + const mockUsersService = { + create: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], + providers: [ + { + provide: AuthService, // No quotes! Use the actual class + useValue: mockAuthService, + }, + { + provide: UsersService, // No quotes! Use the actual class + useValue: mockUsersService, + }, + ], }).compile(); controller = module.get(AuthController); @@ -15,4 +47,257 @@ describe('AuthController', () => { it('should be defined', () => { expect(controller).toBeDefined(); }); + + describe('POST /signup', () => { + it('should create a new user successfully', async () => { + const signUpDto = { + firstName: 'c4c', + lastName: 'neu', + email: 'c4c.neu@northestern.edu', + password: 'Password1!', + }; + + const mockUser = { + appId: 1, + email: 'c4c.neu@northestern.edu', + firstName: 'c4c', + lastName: 'neu', + }; + + // Setup mocks + mockAuthService.signup.mockResolvedValue(false); // Returns false for unconfirmed + mockUsersService.create.mockResolvedValue(mockUser); + + // Call controller method + const result = await controller.createUser(signUpDto); + + // Verify results + expect(result).toEqual(mockUser); + expect(mockAuthService.signup).toHaveBeenCalledWith(signUpDto); + expect(mockUsersService.create).toHaveBeenCalledWith( + 'c4c.neu@northestern.edu', + 'c4c', + 'neu', + ); + }); + + it('should throw BadRequestException when Cognito signup fails', async () => { + const signUpDto = { + firstName: 'Test', + lastName: 'User', + email: 'test@northeastern.edu', + password: 'Pass123!', + }; + + // Mock Cognito error + mockAuthService.signup.mockRejectedValue( + new Error('UsernameExistsException'), + ); + + // Verify error is caught and wrapped + await expect(controller.createUser(signUpDto)).rejects.toThrow( + BadRequestException, + ); + + // Verify database user was NOT created + expect(mockUsersService.create).not.toHaveBeenCalled(); + }); + + it('should handle database creation failure after successful Cognito signup', async () => { + const signUpDto = { + firstName: 'Test', + lastName: 'User', + email: 'test@husky.neu.edu', + password: 'Pass123!', + }; + + mockAuthService.signup.mockResolvedValue(false); + mockUsersService.create.mockRejectedValue( + new Error('Database connection error'), + ); + + await expect(controller.createUser(signUpDto)).rejects.toThrow( + 'Database connection error', + ); + }); + }); + + describe('POST /signin', () => { + it('should return tokens on successful signin', async () => { + const signInDto = { + email: 'neu.nich@northeastern.edu', + password: 'Password1!', + }; + + const mockTokens = { + accessToken: 'access-token-123', + refreshToken: 'refresh-token-456', + idToken: 'id-token-789', + }; + + mockAuthService.signin.mockResolvedValue(mockTokens); + + const result = await controller.signin(signInDto); + + expect(result).toEqual(mockTokens); + expect(mockAuthService.signin).toHaveBeenCalledWith(signInDto); + }); + + it('should propagate authentication errors', async () => { + const signInDto = { + email: 'test@northeastern.edu', + password: 'WrongPassword', + }; + + mockAuthService.signin.mockRejectedValue( + new Error('NotAuthorizedException'), + ); + + await expect(controller.signin(signInDto)).rejects.toThrow( + 'NotAuthorizedException', + ); + }); + }); + + describe('POST /verify', () => { + it('should verify user email with confirmation code', async () => { + const verifyDto = { + email: 'test@northeastern.edu', + verificationCode: '123456', + }; + + mockAuthService.verifyUser.mockResolvedValue(undefined); + + // The controller method is not async, so we don't await it + expect(() => controller.verifyUser(verifyDto)).not.toThrow(); + + expect(mockAuthService.verifyUser).toHaveBeenCalledWith( + 'test@northeastern.edu', + '123456', + ); + }); + + it('should throw BadRequestException for invalid verification code', () => { + const verifyDto = { + email: 'test@northeastern.edu', + verificationCode: 'wrong', + }; + + mockAuthService.verifyUser.mockImplementation(() => { + throw new Error('CodeMismatchException'); + }); + + expect(() => controller.verifyUser(verifyDto)).toThrow( + BadRequestException, + ); + }); + }); + + describe('POST /refresh', () => { + it('should refresh access token successfully', async () => { + const refreshDto = { + refreshToken: 'old-refresh-token', + userSub: 'user-sub-123', + }; + + const mockNewTokens = { + accessToken: 'new-access-token', + refreshToken: 'old-refresh-token', + idToken: 'new-id-token', + }; + + mockAuthService.refreshToken.mockResolvedValue(mockNewTokens); + + const result = await controller.refresh(refreshDto); + + expect(result).toEqual(mockNewTokens); + }); + }); + + describe('POST /forgotPassword', () => { + it('should initiate password reset', async () => { + const forgotDto = { + email: 'test@northeastern.edu', + }; + + mockAuthService.forgotPassword.mockResolvedValue(undefined); + + await expect(controller.forgotPassword(forgotDto)).resolves.not.toThrow(); + + expect(mockAuthService.forgotPassword).toHaveBeenCalledWith( + 'test@northeastern.edu', + ); + }); + }); + + describe('POST /confirmPassword', () => { + it('should reset password with confirmation code', async () => { + const confirmDto = { + email: 'test@northeastern.edu', + confirmationCode: '123456', + newPassword: 'NewPassword123!', + }; + + mockAuthService.confirmForgotPassword.mockResolvedValue(undefined); + + await expect( + controller.confirmPassword(confirmDto), + ).resolves.not.toThrow(); + + expect(mockAuthService.confirmForgotPassword).toHaveBeenCalledWith( + confirmDto, + ); + }); + }); + + describe('POST /delete', () => { + it('should delete user from Cognito and database', async () => { + const deleteDto = { + appId: 1, + }; + + const mockUser = { + appId: 1, + email: 'test@northeastern.edu', + firstName: 'Test', + lastName: 'User', + }; + + mockUsersService.findOne.mockResolvedValue(mockUser); + mockAuthService.deleteUser.mockResolvedValue(undefined); + mockUsersService.remove.mockResolvedValue(undefined); + + await controller.delete(deleteDto); + + // Verify correct order: find user, delete from Cognito, then database + expect(mockUsersService.findOne).toHaveBeenCalledWith(1); + expect(mockAuthService.deleteUser).toHaveBeenCalledWith( + 'test@northeastern.edu', + ); + expect(mockUsersService.remove).toHaveBeenCalledWith(1); + }); + + it('should throw BadRequestException if Cognito deletion fails', async () => { + const deleteDto = { + appId: 1, + }; + + const mockUser = { + appId: 1, + email: 'test@northeastern.edu', + }; + + mockUsersService.findOne.mockResolvedValue(mockUser); + mockAuthService.deleteUser.mockRejectedValue( + new Error('UserNotFoundException'), + ); + + await expect(controller.delete(deleteDto)).rejects.toThrow( + BadRequestException, + ); + + // Database deletion should NOT happen if Cognito fails + expect(mockUsersService.remove).not.toHaveBeenCalled(); + }); + }); }); diff --git a/apps/backend/src/auth/auth.controller.ts b/apps/backend/src/auth/auth.controller.ts index bb04b6b7c..be29f0f5e 100644 --- a/apps/backend/src/auth/auth.controller.ts +++ b/apps/backend/src/auth/auth.controller.ts @@ -5,6 +5,7 @@ import { Post, Request, UseGuards, + UseInterceptors, } from '@nestjs/common'; import { SignInDto } from './dtos/sign-in.dto'; @@ -20,15 +21,27 @@ import { AuthGuard } from '@nestjs/passport'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; import { ForgotPasswordDto } from './dtos/forgot-password.dto'; import { ApiTags } from '@nestjs/swagger'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +/** + * Controller to expose HTTP endpoints to handle user authentication, including signup and login. + */ @ApiTags('Auth') @Controller('auth') +@UseInterceptors(CurrentUserInterceptor) export class AuthController { constructor( private authService: AuthService, private usersService: UsersService, ) {} + /** + * Exposes an endpoint to create a user. + * @param signUpDto Object containing the necessary fields to create a new user. + * @returns The newly created user. + * @throws {BadRequestException} if signup fails in the external auth client (AWS Cognito). + * @throws {Error} which is unchanged from what repository throws. + */ @Post('/signup') async createUser(@Body() signUpDto: SignUpDto): Promise { // By default, creates a standard user @@ -47,6 +60,13 @@ export class AuthController { return user; } + /** + * Exposes an endpoint to verify the user in the external auth service (AWS Cognito). + * @param body Object containing the necessary fields to verify a user. + * @throws {BadRequestException} with message thrown from the external auth service. + * + * Does not return a value. + */ // TODO deprecated if verification code is replaced by link @Post('/verify') verifyUser(@Body() body: VerifyUserDto): void { @@ -57,29 +77,65 @@ export class AuthController { } } + /** + * Exposes an endpoint to sign an existing user into the application. + * @param signInDto Object containing the necessary fields to sign in a user. + * @returns SignInResponseDto with session tokens for the user. + * @throws {Error} If the external auth provider throws an error. + */ @Post('/signin') signin(@Body() signInDto: SignInDto): Promise { return this.authService.signin(signInDto); } + /** + * Exposes an endpoint to refresh a user's session token with the external auth provider. + * @param refreshDto Object containing the necessary fields to refresh the token. + * @returns SignInResponseDto with the new (refreshed) session tokens for the user. + * @throws {Error} If the external auth provider throws an error. + */ @Post('/refresh') refresh(@Body() refreshDto: RefreshTokenDto): Promise { return this.authService.refreshToken(refreshDto); } + /** + * Exposes an endpoint to initiate the process with the external auth provider + * when the user forgets their password. + * @param body Object containing the necessary fields to identify which user forgot their password. + * @throws {Error} If the external auth provider throws an error. + * + * Does not return a value. + */ @Post('/forgotPassword') forgotPassword(@Body() body: ForgotPasswordDto): Promise { return this.authService.forgotPassword(body.email); } + /** + * Exposes an endpoint to confirm a forgotten password with the external auth provider. + * @param body Object containing the necessary fields + * (email, confirmation code, new password) to confirm. + * @throws {Error} If the external auth provider throws an error. + * + * Does not return a value. + */ @Post('/confirmPassword') confirmPassword(@Body() body: ConfirmPasswordDto): Promise { return this.authService.confirmForgotPassword(body); } + /** + * Exposes an endpoint to delete a user by id. + * @param body Object containing the necessary fields to delete a user, including id. + * @throws {Error} If the repository or external auth provider throws an error. + * @throws {BadRequestException} with a message from the external auth provider. + * + * Does not return a value. + */ @Post('/delete') async delete(@Body() body: DeleteUserDto): Promise { - const user = await this.usersService.findOne(body.userId); + const user = await this.usersService.findOne(body.appId); try { await this.authService.deleteUser(user.email); @@ -87,6 +143,6 @@ export class AuthController { throw new BadRequestException(e.message); } - this.usersService.remove(user.id); + this.usersService.remove(user.appId); } } diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index 09f5965cb..4decaf658 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { PassportModule } from '@nestjs/passport'; - import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/user.entity'; import { JwtStrategy } from './jwt.strategy'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; @Module({ imports: [ @@ -14,6 +14,7 @@ import { JwtStrategy } from './jwt.strategy'; PassportModule.register({ defaultStrategy: 'jwt' }), ], controllers: [AuthController], - providers: [AuthService, UsersService, JwtStrategy], + providers: [AuthService, UsersService, JwtStrategy, CurrentUserInterceptor], + exports: [AuthService, UsersService], }) export class AuthModule {} diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 800ab6626..bbbad7013 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -1,10 +1,32 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AuthService } from './auth.service'; +import { CognitoIdentityProviderClient } from '@aws-sdk/client-cognito-identity-provider'; +import { Status } from '../users/types'; + +// Mock the entire AWS SDK v3 module +jest.mock('@aws-sdk/client-cognito-identity-provider'); describe('AuthService', () => { let service: AuthService; + // Create a mock for the send method + const mockSend = jest.fn(); + + beforeAll(() => { + // Set environment variables + process.env.NX_AWS_ACCESS_KEY = 'test-access-key'; + process.env.NX_AWS_SECRET_ACCESS_KEY = 'test-secret-key'; + process.env.COGNITO_CLIENT_SECRET = 'test-client-secret'; + }); + beforeEach(async () => { + jest.clearAllMocks(); + + // Mock the CognitoIdentityProviderClient constructor + (CognitoIdentityProviderClient as jest.Mock).mockImplementation(() => ({ + send: mockSend, + })); + const module: TestingModule = await Test.createTestingModule({ providers: [AuthService], }).compile(); @@ -12,7 +34,309 @@ describe('AuthService', () => { service = module.get(AuthService); }); + afterAll(() => { + jest.restoreAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('calculateHash', () => { + it('should generate a valid HMAC hash', () => { + const email = 'test@northeastern.edu'; + const hash = service.calculateHash(email); + + expect(hash).toBeDefined(); + expect(typeof hash).toBe('string'); + // Base64 encoded string pattern + expect(hash).toMatch(/^[A-Za-z0-9+/]+=*$/); + }); + + it('should generate consistent hashes for same input', () => { + const email = 'test@northeastern.edu'; + const hash1 = service.calculateHash(email); + const hash2 = service.calculateHash(email); + + expect(hash1).toBe(hash2); + }); + + it('should generate different hashes for different inputs', () => { + const hash1 = service.calculateHash('user1@northeastern.edu'); + const hash2 = service.calculateHash('user2@northeastern.edu'); + + expect(hash1).not.toBe(hash2); + }); + }); + + describe('signup', () => { + it('should successfully sign up a new user', async () => { + const signUpDto = { + firstName: 'c4c', + lastName: 'neu', + email: 'c4c.neu@northestern.edu', + password: 'SecurePass123!', + }; + + // Mock Cognito response + mockSend.mockResolvedValueOnce({ + UserConfirmed: false, + UserSub: 'user-sub-123', + }); + + const result = await service.signup(signUpDto); + + expect(result).toBe(false); // User needs email confirmation + expect(mockSend).toHaveBeenCalledTimes(1); + // Just verify that send was called with a command object + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should sign up user with admin status', async () => { + const signUpDto = { + firstName: 'Admin', + lastName: 'User', + email: 'admin@northeastern.edu', + password: 'AdminPass123!', + }; + + mockSend.mockResolvedValueOnce({ + UserConfirmed: false, + UserSub: 'admin-sub-123', + }); + + const result = await service.signup(signUpDto, Status.ADMIN); + + expect(result).toBe(false); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should handle username already exists error', async () => { + const signUpDto = { + firstName: 'Test', + lastName: 'User', + email: 'existing@northeastern.edu', + password: 'Pass123!', + }; + + mockSend.mockRejectedValueOnce(new Error('UsernameExistsException')); + + await expect(service.signup(signUpDto)).rejects.toThrow( + 'UsernameExistsException', + ); + }); + }); + + describe('verifyUser', () => { + it('should verify user with confirmation code', async () => { + mockSend.mockResolvedValueOnce({}); + + await service.verifyUser('test@northeastern.edu', '123456'); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should handle invalid confirmation code', async () => { + mockSend.mockRejectedValueOnce(new Error('CodeMismatchException')); + + await expect( + service.verifyUser('test@northeastern.edu', 'wrong'), + ).rejects.toThrow('CodeMismatchException'); + }); + }); + + describe('signin', () => { + it('should authenticate user and return tokens', async () => { + const signInDto = { + email: 'c4c.neu@northestern.edu', + password: 'Password123!', + }; + + mockSend.mockResolvedValueOnce({ + AuthenticationResult: { + AccessToken: 'access-token-123', + RefreshToken: 'refresh-token-456', + IdToken: 'id-token-789', + }, + }); + + const result = await service.signin(signInDto); + + expect(result).toEqual({ + accessToken: 'access-token-123', + refreshToken: 'refresh-token-456', + idToken: 'id-token-789', + }); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should handle incorrect password', async () => { + const signInDto = { + email: 'test@northeastern.edu', + password: 'WrongPassword', + }; + + mockSend.mockRejectedValueOnce(new Error('NotAuthorizedException')); + + await expect(service.signin(signInDto)).rejects.toThrow( + 'NotAuthorizedException', + ); + }); + + it('should handle unconfirmed user', async () => { + const signInDto = { + email: 'unconfirmed@northeastern.edu', + password: 'Password123!', + }; + + mockSend.mockRejectedValueOnce(new Error('UserNotConfirmedException')); + + await expect(service.signin(signInDto)).rejects.toThrow( + 'UserNotConfirmedException', + ); + }); + }); + + describe('refreshToken', () => { + it('should refresh access token', async () => { + const refreshDto = { + refreshToken: 'old-refresh-token', + userSub: 'user-sub-123', + }; + + mockSend.mockResolvedValueOnce({ + AuthenticationResult: { + AccessToken: 'new-access-token', + IdToken: 'new-id-token', + }, + }); + + const result = await service.refreshToken(refreshDto); + + expect(result).toEqual({ + accessToken: 'new-access-token', + refreshToken: 'old-refresh-token', // Stays the same + idToken: 'new-id-token', + }); + }); + + it('should handle expired refresh token', async () => { + const refreshDto = { + refreshToken: 'expired-token', + userSub: 'user-sub-123', + }; + + mockSend.mockRejectedValueOnce( + new Error('NotAuthorizedException: Refresh token expired'), + ); + + await expect(service.refreshToken(refreshDto)).rejects.toThrow( + 'NotAuthorizedException', + ); + }); + }); + + describe('getUser', () => { + it('should retrieve user attributes by sub', async () => { + mockSend.mockResolvedValueOnce({ + Users: [ + { + Attributes: [ + { Name: 'email', Value: 'test@northeastern.edu' }, + { Name: 'name', Value: 'Test User' }, + { Name: 'sub', Value: 'user-sub-123' }, + ], + }, + ], + }); + + const result = await service.getUser('user-sub-123'); + + expect(result).toEqual([ + { Name: 'email', Value: 'test@northeastern.edu' }, + { Name: 'name', Value: 'Test User' }, + { Name: 'sub', Value: 'user-sub-123' }, + ]); + }); + + it('should handle user not found', async () => { + mockSend.mockResolvedValueOnce({ + Users: [], + }); + + // This will throw because Users[0] is undefined + await expect(service.getUser('non-existent')).rejects.toThrow(); + }); + }); + + describe('forgotPassword', () => { + it('should initiate password reset', async () => { + mockSend.mockResolvedValueOnce({ + CodeDeliveryDetails: { + AttributeName: 'email', + DeliveryMedium: 'EMAIL', + Destination: 't***@northeastern.edu', + }, + }); + + await service.forgotPassword('test@northeastern.edu'); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + }); + + describe('confirmForgotPassword', () => { + it('should reset password with confirmation code', async () => { + const confirmDto = { + email: 'test@northeastern.edu', + confirmationCode: '123456', + newPassword: 'NewSecurePass123!', + }; + + mockSend.mockResolvedValueOnce({}); + + await service.confirmForgotPassword(confirmDto); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should handle invalid code', async () => { + const confirmDto = { + email: 'test@northeastern.edu', + confirmationCode: 'wrong', + newPassword: 'NewPass123!', + }; + + mockSend.mockRejectedValueOnce(new Error('CodeMismatchException')); + + await expect(service.confirmForgotPassword(confirmDto)).rejects.toThrow( + 'CodeMismatchException', + ); + }); + }); + + describe('deleteUser', () => { + it('should delete user from Cognito', async () => { + mockSend.mockResolvedValueOnce({}); + + await service.deleteUser('test@northeastern.edu'); + + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith(expect.any(Object)); + }); + + it('should handle user not found', async () => { + mockSend.mockRejectedValueOnce(new Error('UserNotFoundException')); + + await expect( + service.deleteUser('nonexistent@northeastern.edu'), + ).rejects.toThrow('UserNotFoundException'); + }); + }); }); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index d78a12dd4..44bd580d9 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -20,6 +20,9 @@ import { RefreshTokenDto } from './dtos/refresh-token.dto'; import { Status } from '../users/types'; import { ConfirmPasswordDto } from './dtos/confirm-password.dto'; +/** + * Service to interface with the external auth provider (AWS Cognito). + */ @Injectable() export class AuthService { private readonly providerClient: CognitoIdentityProviderClient; @@ -37,10 +40,14 @@ export class AuthService { this.clientSecret = process.env.COGNITO_CLIENT_SECRET; } - // Computes secret hash to authenticate this backend to Cognito - // Hash key is the Cognito client secret, message is username + client ID - // Username value depends on the command - // (see https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash) + /** + * Computes the secret hash to authenticate this backend to Cognito. + * The hash key is the Cognito client secret; the message is username + client ID. + * @param username Value which depends on the command. + * See: https://docs.aws.amazon.com/cognito/latest/developerguide/signing-up-users-in-your-app.html#cognito-user-pools-computing-secret-hash + * @returns The HMAC digest for the given username. + * @throws {Error} If the HMAC handling interface throws an error. + */ calculateHash(username: string): string { const hmac = createHmac('sha256', this.clientSecret); hmac.update(username + CognitoAuthConfig.clientId); @@ -58,6 +65,12 @@ export class AuthService { return Users[0].Attributes; } + /** + * Creates a user in the external auth provider (AWS Cognito). + * @param signUpDto Object containing the necessary fields to create a new user. + * @returns Whether the user was confirmed as created in the external auth provider. + * @throws {Error} If the external auth client throws an error. + */ async signup( { firstName, lastName, email, password }: SignUpDto, status: Status = Status.STANDARD, @@ -86,6 +99,12 @@ export class AuthService { return response.UserConfirmed; } + /** + * Verifies a user by email and verification code with the external auth provider (AWS Cognito). + * @param email The email of the user to verify. + * @param verificationCode The code required to verify the user with the external auth provider. + * @throws {Error} If the external auth provider throws an error. + */ async verifyUser(email: string, verificationCode: string): Promise { const confirmCommand = new ConfirmSignUpCommand({ ClientId: CognitoAuthConfig.clientId, @@ -97,6 +116,12 @@ export class AuthService { await this.providerClient.send(confirmCommand); } + /** + * Signs an existing user into the application using the external auth provider. + * @param signInDto Object containing the necessary fields to sign in a user. + * @returns SignInResponseDto with session tokens for the user. + * @throws {Error} If the external auth provider throws an error. + */ async signin({ email, password }: SignInDto): Promise { const signInCommand = new AdminInitiateAuthCommand({ AuthFlow: 'ADMIN_USER_PASSWORD_AUTH', @@ -118,7 +143,14 @@ export class AuthService { }; } - // Refresh token hash uses a user's sub (unique ID), not their username (typically their email) + /** + * Refreshes a user's session token with the external auth provider. + * @param refreshDto Object containing the necessary fields to refresh the token. + * @returns SignInResponseDto with the new (refreshed) session tokens for the user. + * @throws {Error} If the external auth provider throws an error. + * + * Note: Refresh token hash uses a user's sub (unique ID), not their username (typically their email). + */ async refreshToken({ refreshToken, userSub, @@ -142,6 +174,13 @@ export class AuthService { }; } + /** + * Initiates the forgot-password flow with the external auth provider. + * @param body The email address of the user who forgot their password. + * @throws {Error} If the external auth provider throws an error. + * + * Does not return a value. + */ async forgotPassword(email: string) { const forgotCommand = new ForgotPasswordCommand({ ClientId: CognitoAuthConfig.clientId, @@ -152,6 +191,13 @@ export class AuthService { await this.providerClient.send(forgotCommand); } + /** + * Confirms a forgotten password with the external auth provider. + * @param body Object containing the necessary fields (email, confirmation code, new password) to confirm. + * @throws {Error} If the external auth provider throws an error. + * + * Does not return a value. + */ async confirmForgotPassword({ email, confirmationCode, @@ -168,6 +214,13 @@ export class AuthService { await this.providerClient.send(confirmComamnd); } + /** + * Deletes a user by email in the external auth provider. + * @param body The email address of the user to delete. + * @throws {Error} If the repository or external auth provider throws an error. + * + * Does not return a value. + */ async deleteUser(email: string): Promise { const adminDeleteUserCommand = new AdminDeleteUserCommand({ Username: email, diff --git a/apps/backend/src/auth/aws-exports.ts b/apps/backend/src/auth/aws-exports.ts index 5c3a2deca..4930c1380 100644 --- a/apps/backend/src/auth/aws-exports.ts +++ b/apps/backend/src/auth/aws-exports.ts @@ -1,3 +1,5 @@ +// TODO: Modify so that it uses env vars instead of hardcoded values + const CognitoAuthConfig = { userPoolId: 'USER POOL ID HERE', clientId: 'CLIENT ID HERE', diff --git a/apps/backend/src/auth/dtos/confirm-password.dto.ts b/apps/backend/src/auth/dtos/confirm-password.dto.ts index ec1d63bb0..8e452b9f2 100644 --- a/apps/backend/src/auth/dtos/confirm-password.dto.ts +++ b/apps/backend/src/auth/dtos/confirm-password.dto.ts @@ -1,12 +1,28 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString, Matches } from 'class-validator'; +/** + * Defines the expected shape of data for confirming a user's new password + */ export class ConfirmPasswordDto { + /** + * Pre-existing user's email in the system + * + * Example: 'jane.doe@northeastern.edu' + */ @IsEmail() email: string; + /** + * The user's new password for the system + * + * TODO: clarify whether this password is encrypted or in plain format + */ @IsString() + @IsNotEmpty() newPassword: string; + // TODO: clarify where this code comes from @IsString() + @IsNotEmpty() confirmationCode: string; } diff --git a/apps/backend/src/auth/dtos/delete-user.dto.ts b/apps/backend/src/auth/dtos/delete-user.dto.ts index 1a6163763..bbdc9bfcd 100644 --- a/apps/backend/src/auth/dtos/delete-user.dto.ts +++ b/apps/backend/src/auth/dtos/delete-user.dto.ts @@ -1,6 +1,15 @@ -import { IsPositive } from 'class-validator'; +import { IsNumber, IsPositive } from 'class-validator'; +/** + * Defines the expected shape of data for deleting a user. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class DeleteUserDto { + /** + * The id of the user within the repository to delete. + */ @IsPositive() - userId: number; + @IsNumber({ maxDecimalPlaces: 0 }) + appId: number; } diff --git a/apps/backend/src/auth/dtos/forgot-password.dto.ts b/apps/backend/src/auth/dtos/forgot-password.dto.ts index bbedf0832..34d395f9d 100644 --- a/apps/backend/src/auth/dtos/forgot-password.dto.ts +++ b/apps/backend/src/auth/dtos/forgot-password.dto.ts @@ -1,6 +1,18 @@ -import { IsEmail } from 'class-validator'; +import { IsEmail, IsNotEmpty } from 'class-validator'; +/** + * Defines the expected shape of data for initiating a process for when the user + * forgets their password. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class ForgotPasswordDto { + /** + * The email of the user to initiate the forgot password process for. + * + * Example: 'Jane.Doe@northeastern.edu'. + */ @IsEmail() + @IsNotEmpty() email: string; } diff --git a/apps/backend/src/auth/dtos/refresh-token.dto.ts b/apps/backend/src/auth/dtos/refresh-token.dto.ts index f67905d32..7c62c9e91 100644 --- a/apps/backend/src/auth/dtos/refresh-token.dto.ts +++ b/apps/backend/src/auth/dtos/refresh-token.dto.ts @@ -1,9 +1,19 @@ -import { IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; +/** + * Defines the expected shape of data for initiating a process for when the user + * needs to refresh their session token. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class RefreshTokenDto { + // TODO: Clarify what this is. @IsString() + @IsNotEmpty() refreshToken: string; + // TODO: Clarify what this is @IsString() + @IsNotEmpty() userSub: string; } diff --git a/apps/backend/src/auth/dtos/sign-in-response.dto.ts b/apps/backend/src/auth/dtos/sign-in-response.dto.ts index 571a02f8c..22fb8f0f8 100644 --- a/apps/backend/src/auth/dtos/sign-in-response.dto.ts +++ b/apps/backend/src/auth/dtos/sign-in-response.dto.ts @@ -1,19 +1,34 @@ +// TODO: Add validators from 'class-validator' package or find out why there aren't validators here? + +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * Defines the expected shape of data to be passed into API requests. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class SignInResponseDto { /** - * The JWT access token to be passed in API requests + * The JWT access token to be passed in API requests. * @example eyJ... */ + @IsString() + @IsNotEmpty() accessToken: string; /** - * The JWT refresh token to maintain user sessions by requesting new access tokens + * The JWT refresh token to maintain user sessions by requesting new access tokens. * @example eyJ... */ + @IsString() + @IsNotEmpty() refreshToken: string; /** - * The JWT ID token that carries the user's information + * The JWT ID token that carries the user's information. * @example eyJ... */ + @IsString() + @IsNotEmpty() idToken: string; } diff --git a/apps/backend/src/auth/dtos/sign-in.dto.ts b/apps/backend/src/auth/dtos/sign-in.dto.ts index 51cd9c95d..197c60866 100644 --- a/apps/backend/src/auth/dtos/sign-in.dto.ts +++ b/apps/backend/src/auth/dtos/sign-in.dto.ts @@ -1,9 +1,26 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +/** + * Defines the expected shape of data when a user desires to sign in. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class SignInDto { + /** + * The email of the user that wants to sign in. + * + * Example: 'jane.doe@northeastern.edu'. + */ @IsEmail() + @IsNotEmpty() email: string; + /** + * The attempted password of the user that wants to sign in. + * + * TODO: find out if this is in plain text or encrypted. + */ @IsString() + @IsNotEmpty() password: string; } diff --git a/apps/backend/src/auth/dtos/sign-up.dto.ts b/apps/backend/src/auth/dtos/sign-up.dto.ts index 5756f1863..ceda6348a 100644 --- a/apps/backend/src/auth/dtos/sign-up.dto.ts +++ b/apps/backend/src/auth/dtos/sign-up.dto.ts @@ -1,15 +1,44 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +/** + * Defines the expected shape of data when a user desires to sign up to the system. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class SignUpDto { + /** + * The first name of the user. + * + * Example: 'Jane'. + */ @IsString() + @IsNotEmpty() firstName: string; + /** + * The last name of the user. + * + * Example: 'Doe'. + */ @IsString() + @IsNotEmpty() lastName: string; + /** + * The email of the user. + * + * Example: 'Jane.doe@northeastern.edu'. + */ @IsEmail() + @IsNotEmpty() email: string; + /** + * The password of the user. + * + * TODO: Clarify whether this is in plain text or in an encrypted format. + */ @IsString() + @IsNotEmpty() password: string; } diff --git a/apps/backend/src/auth/dtos/verify-user.dto.ts b/apps/backend/src/auth/dtos/verify-user.dto.ts index 663916056..61077f409 100644 --- a/apps/backend/src/auth/dtos/verify-user.dto.ts +++ b/apps/backend/src/auth/dtos/verify-user.dto.ts @@ -1,9 +1,26 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; +/** + * Defines the expected shape of data when verifying a user. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ export class VerifyUserDto { + /** + * The email of the user to verify. + * + * Example: 'Jane.doe@northeastern.edu'. + */ @IsEmail() + @IsNotEmpty() email: string; + /** + * The verification code to confirm with the external auth provider. + * + * TODO: Clarify how this code is obtained and if there is a standard format. + */ @IsString() + @IsNotEmpty() verificationCode: string; } diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index 44d8789d4..cdec2b1db 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -5,6 +5,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import CognitoAuthConfig from './aws-exports'; +// TODO: Clarify what this is @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor() { diff --git a/apps/backend/src/aws-s3/aws-s3.module.ts b/apps/backend/src/aws-s3/aws-s3.module.ts new file mode 100644 index 000000000..bcb05aca9 --- /dev/null +++ b/apps/backend/src/aws-s3/aws-s3.module.ts @@ -0,0 +1,10 @@ +import { Global, Module } from '@nestjs/common'; +import { AWSS3Service } from './aws-s3.service'; + +@Global() +@Module({ + imports: [], + providers: [AWSS3Service], + exports: [AWSS3Service], +}) +export class AWSS3Module {} diff --git a/apps/backend/src/aws-s3/aws-s3.service.spec.ts b/apps/backend/src/aws-s3/aws-s3.service.spec.ts new file mode 100644 index 000000000..4dab88ca1 --- /dev/null +++ b/apps/backend/src/aws-s3/aws-s3.service.spec.ts @@ -0,0 +1,198 @@ +import * as dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.join(__dirname, '../../../../.env') }); + +import { + DeleteObjectsCommand, + S3Client, + S3ServiceException, + waitUntilObjectNotExists, + PutObjectCommand, + GetObjectCommand, + NoSuchKey, +} from '@aws-sdk/client-s3'; +import { AWSS3Service } from './aws-s3.service'; +import { mockClient } from 'aws-sdk-client-mock'; +import axios from 'axios'; + +const s3Mock = mockClient(S3Client); + +describe('AWSS3Service', () => { + let service: AWSS3Service; + const bucketName = process.env.AWS_BUCKET_NAME; + const bucketRegion = process.env.AWS_REGION; + + beforeEach(() => { + s3Mock.reset(); + service = new AWSS3Service(); + }); + + it('should throw error if AWS_BUCKET_NAME is not defined', () => { + delete process.env.AWS_BUCKET_NAME; + expect(() => new AWSS3Service()).toThrow( + 'AWS_BUCKET_NAME is not defined in environment variables', + ); + process.env.AWS_BUCKET_NAME = bucketName; // restore for other tests + }); + + it('should create correct S3 link', () => { + const link = service.createLink('JohnDoe', 'Resume'); + expect(link).toBe( + `https://${bucketName}.s3.${bucketRegion}.amazonaws.com/JohnDoe-Resume.pdf`, + ); + }); + + it('should upload file and return correct URL', async () => { + s3Mock.on(PutObjectCommand).resolves({}); + + const buffer = Buffer.from('test'); + const fileName = 'file.pdf'; + const mimeType = 'application/pdf'; + + const url = await service.upload(buffer, fileName, mimeType); + + expect(s3Mock.calls()).toHaveLength(1); + expect(url).toBe( + `https://${bucketName}.s3.${bucketRegion}.amazonaws.com/${fileName}`, + ); + }); + + it('should throw error on upload failure', async () => { + s3Mock.on(PutObjectCommand).rejects(new Error('fail')); + + const buffer = Buffer.from('test'); + await expect( + service.upload(buffer, 'file.pdf', 'application/pdf'), + ).rejects.toThrow('File upload to AWS failed: Error: fail'); + }); + + // take off ".skip" to run this test. It does cleanup automatically but READ/WRITES can still pile up + it.skip('should actually upload a file to S3 (integration)', async () => { + s3Mock.restore(); + const fileContent = `integration-test-content-${Date.now()}`; + const buffer = Buffer.from(fileContent); + const fileName = `integration-test-${Date.now()}.txt`; + const mimeType = 'text/plain'; + const integrationService = new AWSS3Service(); + + const url = await integrationService.upload(buffer, fileName, mimeType); + console.log('Uploaded file URL:', url); + expect(url).toContain(fileName); + + try { + const response = await axios.get(url); + expect(response.status).toBe(200); + expect(response.data).toBe(fileContent); + } catch (error) { + throw new Error( + `Failed to fetch the uploaded file from S3. Error: ${error.message}.`, + ); + } finally { + // cleanup uploaded object(s) from S3 using helper + try { + await deleteObjects({ + bucketName: process.env.AWS_BUCKET_NAME, + keys: [fileName], + }); + } catch (cleanupErr) { + // don't mask test failure — log cleanup issues + // eslint-disable-next-line no-unsafe-finally + throw new Error( + 'Failed to clean up S3 objects after integration test:' + cleanupErr, + ); + } + + try { + await getObjectFromS3({ + bucketName: process.env.AWS_BUCKET_NAME, + key: fileName, + }); + } catch (err) { + if (err instanceof NoSuchKey) { + console.log( + 'object with filename ' + + fileName + + ' is successfully no longer in the bucket. No manual steps required', + ); + } else { + // eslint-disable-next-line no-unsafe-finally + throw new Error( + 'There was an error trying to check if the object is no longer in the bucket. Please check the bucket manually to ensure proper cleanup.', + ); + } + } + } + }, 15000); + + // MAKE SURE TO CLEAN UP THE FILES FROM OUR S3 BUCKET AFTER RUNNING THE INTEGRATION TEST +}); + +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 +// For the following code to the end of the file, which has been modified: +/** + * Delete multiple objects from an S3 bucket. + * @param {{ bucketName: string, keys: string[] }} + */ +const deleteObjects = async ({ bucketName, keys }) => { + const client = new S3Client({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + + try { + const { Deleted } = await client.send( + new DeleteObjectsCommand({ + Bucket: bucketName, + Delete: { + Objects: keys.map((k) => ({ Key: k })), + }, + }), + ); + for (const k of keys) { + await waitUntilObjectNotExists( + { client, maxWaitTime: 30 }, + { Bucket: bucketName, Key: k }, + ); + } + console.log( + `Successfully deleted ${Deleted.length} objects from S3 bucket. Deleted objects:`, + ); + console.log(Deleted.map((d) => ` • ${d.Key}`).join('\n')); + } catch (caught) { + if ( + caught instanceof S3ServiceException && + caught.name === 'NoSuchBucket' + ) { + throw new Error( + `Error from S3 while deleting objects from ${bucketName}. The bucket doesn't exist.`, + ); + } else if (caught instanceof S3ServiceException) { + throw new Error( + `Error from S3 while deleting objects from ${bucketName}. ${caught.name}: ${caught.message}`, + ); + } else { + throw caught; + } + } +}; + +/** + * Get a single object from a specified S3 bucket. + * @param {{ bucketName: string, key: string }} + */ +const getObjectFromS3 = async ({ bucketName, key }) => { + const client = new S3Client({}); + const response = await client.send( + new GetObjectCommand({ + Bucket: bucketName, + Key: key, + }), + ); + // The Body object also has 'transformToByteArray' and 'transformToWebStream' methods. + const str = await response.Body.transformToString(); + console.log(str); +}; diff --git a/apps/backend/src/aws-s3/aws-s3.service.ts b/apps/backend/src/aws-s3/aws-s3.service.ts new file mode 100644 index 000000000..ec7105c7f --- /dev/null +++ b/apps/backend/src/aws-s3/aws-s3.service.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; + +/** + * Service to interface with the external object storage service (AWS S3). + */ +@Injectable() +export class AWSS3Service { + private client: S3Client; + private readonly bucketName = process.env.AWS_BUCKET_NAME; + private readonly region: string; + + constructor() { + this.region = process.env.AWS_REGION || 'us-east-2'; + this.bucketName = process.env.AWS_BUCKET_NAME; + + if (!this.bucketName) { + throw new Error( + 'AWS_BUCKET_NAME is not defined in environment variables', + ); + } + + if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) { + throw new Error( + 'AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY is not defined in environment variables', + ); + } + + this.client = new S3Client({ + region: this.region, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + } + + /** + * Creates an URL within the S3 bucket corresponding to a person and a document type. + * @param person the person that the S3 object relates to. + * @param type the document type that the S3 object relates to. + * @returns the expected URL of the object of specified person and type, + * with filename in the form of 'JohnDoe-Resume.pdf' + * or 'janedoe-application.pdf'; the file extension is always a pdf. + * + * Does not throw beyond TypeScript errors. + * + * TODO: Remove hard-coded region in the url + */ + createLink(person: string, type: string): string { + const fileName = `${person}-${type}.pdf`; + return `https://${this.bucketName}.s3.us-east-2.amazonaws.com/${fileName}`; + } + + /** + * Method to upload a file to the S3 bucket specified in the environmental variable. + * @param fileBuffer in-memory representation of the file's data. + * @param fileName desired name of the file in the destination (AWS S3). + * @param mimeType the desired MIME type to store the file as in S3, + * MIME type indicates how a file should be processed + * by a browser or email client (e.g., text/html, image/jpeg). + * @throws Error with message 'File upload to AWS failed: ' with + * any error message from the S3 client appended to the end. + * + * @returns the S3 URL of the new object + * @throws {Error} containing the error message from the external object storage provider S3 + */ + async upload( + fileBuffer: Buffer, + fileName: string, + mimeType: string, + ): Promise { + try { + const command = new PutObjectCommand({ + Bucket: this.bucketName, + Key: fileName, + Body: fileBuffer, + ContentType: mimeType, + }); + + await this.client.send(command); + + return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${fileName}`; + } catch (error) { + throw new Error('File upload to AWS failed: ' + error); + } + } +} diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 4cd06624c..a63b74cef 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -1,7 +1,13 @@ import { DataSource } from 'typeorm'; +import { Admin } from './users/admin.entity'; +import { User } from './users/user.entity'; 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 { Applicant } from './applicants/applicant.entity'; +import { Discipline } from './disciplines/disciplines.entity'; +import { LearnerInfo } from './learner-info/learner-info.entity'; +import { VolunteerInfo } from './volunteer-info/volunteer-info.entity'; dotenv.config(); @@ -12,8 +18,17 @@ const AppDataSource = new DataSource({ username: process.env.NX_DB_USERNAME, password: process.env.NX_DB_PASSWORD, database: process.env.NX_DB_DATABASE, - entities: [Task], - migrations: ['apps/backend/src/migrations/*.js'], + entities: [ + Application, + Applicant, + Admin, + Discipline, + VolunteerInfo, + LearnerInfo, + User, + ], + migrations: ['apps/backend/src/migrations/*.js'], // use this line before pushing to github so that it works on the deployment server + // migrations: ['apps/backend/src/migrations/*.ts'], // use this line when running migrations locally // 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.constants.ts b/apps/backend/src/disciplines/disciplines.constants.ts new file mode 100644 index 000000000..6c2759128 --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.constants.ts @@ -0,0 +1,12 @@ +/** + * The applicant's discipline of expertise. + */ +export enum DISCIPLINE_VALUES { + MD_MedicalStudent_PreMed = 'MD/Medical Student/Pre-Med', + Medical_NP_PA = 'Medical NP/PA', + Psychiatry_or_Psychiatric_NP_PA = 'Psychiatry or Psychiatric NP/PA', + PublicHealth = 'Public Health', + RN = 'RN', + SocialWork = 'Social Work', + Other = 'Other', +} 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 000000000..61e39d40b --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.controller.spec.ts @@ -0,0 +1,370 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { DisciplinesController } from './disciplines.controller'; +import { DisciplinesService } from './disciplines.service'; +import { Discipline } from './disciplines.entity'; +import { AuthService } from '../auth/auth.service'; +import { UsersService } from '../users/users.service'; +import { DISCIPLINE_VALUES } from './disciplines.constants'; +import { CreateDisciplineRequestDto } from './dto/create-discipline.request.dto'; + +const mockDisciplinesService: Partial = { + findAll: jest.fn(), + findOne: jest.fn(), + create: jest.fn(), + remove: jest.fn(), + addAdmin: jest.fn(), + removeAdmin: jest.fn(), +}; + +const mockAuthService = { + getUser: jest.fn(), +}; + +const mockUsersService = { + find: jest.fn(), +}; + +const defaultDiscipline: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], +}; + +describe('DisciplinesController', () => { + let controller: DisciplinesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [DisciplinesController], + providers: [ + { + provide: DisciplinesService, + useValue: mockDisciplinesService, + }, + { + provide: getRepositoryToken(Discipline), + useValue: {}, + }, + { + provide: AuthService, + useValue: mockAuthService, + }, + { + provide: UsersService, + useValue: mockUsersService, + }, + ], + }).compile(); + + controller = module.get(DisciplinesController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('getAll', () => { + it('should return all disciplines', async () => { + const disciplines = [ + defaultDiscipline, + { + id: 2, + name: DISCIPLINE_VALUES.RN, + admin_ids: [3], + }, + ]; + jest + .spyOn(mockDisciplinesService, 'findAll') + .mockResolvedValue(disciplines); + + const result = await controller.getAll(); + + expect(result).toEqual(disciplines); + expect(mockDisciplinesService.findAll).toHaveBeenCalledTimes(1); + }); + + it('should return empty array when no disciplines exist', async () => { + jest.spyOn(mockDisciplinesService, 'findAll').mockResolvedValue([]); + + const result = await controller.getAll(); + + expect(result).toEqual([]); + expect(mockDisciplinesService.findAll).toHaveBeenCalledTimes(1); + }); + + it('should error out without information loss if the service throws an error', async () => { + jest + .spyOn(mockDisciplinesService, 'findAll') + .mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(controller.getAll()).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + }); + + describe('getOne', () => { + it('should return a specific discipline', async () => { + jest + .spyOn(mockDisciplinesService, 'findOne') + .mockResolvedValue(defaultDiscipline); + + const result = await controller.getOne('1'); + + expect(result).toEqual(defaultDiscipline); + expect(mockDisciplinesService.findOne).toHaveBeenCalledWith(1); + }); + + it('should return null if discipline is not found', async () => { + jest.spyOn(mockDisciplinesService, 'findOne').mockResolvedValue(null); + + const result = await controller.getOne('999'); + + expect(result).toBeNull(); + expect(mockDisciplinesService.findOne).toHaveBeenCalledWith(999); + }); + + it('should handle service errors when retrieving discipline', async () => { + const errorMessage = 'There was a problem retrieving the info'; + jest + .spyOn(mockDisciplinesService, 'findOne') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.getOne('1')).rejects.toThrow(errorMessage); + }); + + it('should convert string id to number correctly', async () => { + jest + .spyOn(mockDisciplinesService, 'findOne') + .mockResolvedValue(defaultDiscipline); + + const result = await controller.getOne('42'); + + expect(result).toEqual(defaultDiscipline); + expect(mockDisciplinesService.findOne).toHaveBeenCalledWith(42); + }); + }); + + describe('create', () => { + it('should create a new discipline', async () => { + const createDisciplineDto: CreateDisciplineRequestDto = { + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + + jest + .spyOn(mockDisciplinesService, 'create') + .mockResolvedValue(defaultDiscipline); + + const result = await controller.create(createDisciplineDto); + + expect(result).toEqual(defaultDiscipline); + expect(mockDisciplinesService.create).toHaveBeenCalledWith( + createDisciplineDto, + ); + }); + + it('should create a discipline with empty admin_ids array', async () => { + const createDisciplineDto: CreateDisciplineRequestDto = { + name: DISCIPLINE_VALUES.RN, + admin_ids: [], + }; + + const disciplineWithEmptyAdmins: Discipline = { + id: 3, + name: DISCIPLINE_VALUES.RN, + admin_ids: [], + }; + + jest + .spyOn(mockDisciplinesService, 'create') + .mockResolvedValue(disciplineWithEmptyAdmins); + + const result = await controller.create(createDisciplineDto); + + expect(result).toEqual(disciplineWithEmptyAdmins); + expect(mockDisciplinesService.create).toHaveBeenCalledWith( + createDisciplineDto, + ); + }); + + it('should handle service errors when creating discipline', async () => { + const createDisciplineDto: CreateDisciplineRequestDto = { + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + + const errorMessage = 'Failed to create discipline'; + jest + .spyOn(mockDisciplinesService, 'create') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.create(createDisciplineDto)).rejects.toThrow( + errorMessage, + ); + }); + + it('should create discipline with different discipline values', async () => { + const createDisciplineDto: CreateDisciplineRequestDto = { + name: DISCIPLINE_VALUES.RN, + admin_ids: [5], + }; + + const RNDiscipline: Discipline = { + id: 4, + name: DISCIPLINE_VALUES.RN, + admin_ids: [5], + }; + + jest + .spyOn(mockDisciplinesService, 'create') + .mockResolvedValue(RNDiscipline); + + const result = await controller.create(createDisciplineDto); + + expect(result).toEqual(RNDiscipline); + expect(mockDisciplinesService.create).toHaveBeenCalledWith( + createDisciplineDto, + ); + }); + }); + + describe('remove', () => { + it('should delete and return a discipline', async () => { + jest + .spyOn(mockDisciplinesService, 'remove') + .mockResolvedValue(defaultDiscipline); + + const result = await controller.remove(1); + + expect(result).toEqual(defaultDiscipline); + expect(mockDisciplinesService.remove).toHaveBeenCalledWith(1); + }); + + it('should throw NotFoundException when discipline does not exist', async () => { + jest + .spyOn(mockDisciplinesService, 'remove') + .mockRejectedValue(new Error('Discipline with ID 999 not found')); + + await expect(controller.remove(999)).rejects.toThrow( + 'Discipline with ID 999 not found', + ); + }); + + it('should handle service errors when deleting discipline', async () => { + const errorMessage = 'Failed to delete discipline'; + jest + .spyOn(mockDisciplinesService, 'remove') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.remove(1)).rejects.toThrow(errorMessage); + }); + }); + + describe('addAdmin', () => { + it('should add an admin to a discipline', async () => { + const updatedDiscipline: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2, 3], + }; + + jest + .spyOn(mockDisciplinesService, 'addAdmin') + .mockResolvedValue(updatedDiscipline); + + const result = await controller.addAdmin(1, 3); + + expect(result).toEqual(updatedDiscipline); + expect(mockDisciplinesService.addAdmin).toHaveBeenCalledWith(1, 3); + }); + + it('should throw NotFoundException when discipline does not exist', async () => { + jest + .spyOn(mockDisciplinesService, 'addAdmin') + .mockRejectedValue(new Error('Discipline with ID 999 not found')); + + await expect(controller.addAdmin(999, 1)).rejects.toThrow( + 'Discipline with ID 999 not found', + ); + }); + + it('should handle service errors when adding admin', async () => { + const errorMessage = 'Failed to add admin'; + jest + .spyOn(mockDisciplinesService, 'addAdmin') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.addAdmin(1, 5)).rejects.toThrow(errorMessage); + }); + + it('should handle adding duplicate admin gracefully', async () => { + // Service doesn't throw for duplicates, just returns unchanged + jest + .spyOn(mockDisciplinesService, 'addAdmin') + .mockResolvedValue(defaultDiscipline); + + const result = await controller.addAdmin(1, 2); // 2 already in defaultDiscipline + + expect(result).toEqual(defaultDiscipline); + expect(mockDisciplinesService.addAdmin).toHaveBeenCalledWith(1, 2); + }); + }); + + describe('removeAdmin', () => { + it('should remove an admin from a discipline', async () => { + const updatedDiscipline: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1], // 2 was removed + }; + + jest + .spyOn(mockDisciplinesService, 'removeAdmin') + .mockResolvedValue(updatedDiscipline); + + const result = await controller.removeAdmin(1, 2); + + expect(result).toEqual(updatedDiscipline); + expect(mockDisciplinesService.removeAdmin).toHaveBeenCalledWith(1, 2); + }); + + it('should throw NotFoundException when discipline does not exist', async () => { + jest + .spyOn(mockDisciplinesService, 'removeAdmin') + .mockRejectedValue(new Error('Discipline with ID 999 not found')); + + await expect(controller.removeAdmin(999, 1)).rejects.toThrow( + 'Discipline with ID 999 not found', + ); + }); + + it('should handle service errors when removing admin', async () => { + const errorMessage = 'Failed to remove admin'; + jest + .spyOn(mockDisciplinesService, 'removeAdmin') + .mockRejectedValue(new Error(errorMessage)); + + await expect(controller.removeAdmin(1, 1)).rejects.toThrow(errorMessage); + }); + + it('should handle removing non-existent admin gracefully', async () => { + // Service doesn't throw, just returns unchanged array + jest + .spyOn(mockDisciplinesService, 'removeAdmin') + .mockResolvedValue(defaultDiscipline); + + const result = await controller.removeAdmin(1, 999); + + expect(result).toEqual(defaultDiscipline); + expect(mockDisciplinesService.removeAdmin).toHaveBeenCalledWith(1, 999); + }); + }); +}); diff --git a/apps/backend/src/disciplines/disciplines.controller.ts b/apps/backend/src/disciplines/disciplines.controller.ts new file mode 100644 index 000000000..acc4cef2f --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.controller.ts @@ -0,0 +1,102 @@ +import { + Controller, + Get, + Post, + Param, + Body, + UseInterceptors, + ParseIntPipe, + Delete, +} from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { DisciplinesService } from './disciplines.service'; +import { CreateDisciplineRequestDto } from './dto/create-discipline.request.dto'; +import { Discipline } from './disciplines.entity'; + +/** + * Controller to expose callable HTTP endpoints to interface, + * extract, and change information about the app's disciplines. + */ +@Controller('disciplines') +@UseInterceptors(CurrentUserInterceptor) +// TODO: Uncomment after JWT authentication is setup with Cognito +// @UseGuards(AuthGuard('jwt')) +export class DisciplinesController { + constructor(private disciplinesService: DisciplinesService) {} + + /** + * Exposes an endpoint to return a list of all disciplines + * @returns a list of all disciplines + */ + @Get() + async getAll(): Promise { + return this.disciplinesService.findAll(); + } + + /** + * Exposes an endpoint to return a discipline by id + * @param id the id of the discipline to return + * @returns the discipline with the corresponding id + */ + @Get(':id') + async getOne(@Param('id') id: string): Promise { + return this.disciplinesService.findOne(Number(id)); + } + + /** + * Exposes an endpoint to create a new discipline + * @param createDto object containing necessary info to create an new discipline, like the name + * @returns the new discipline + */ + @Post() + async create( + @Body() createDto: CreateDisciplineRequestDto, + ): Promise { + return this.disciplinesService.create(createDto); + } + + /** + * Deletes a discipline by id + * @param id the id of the discipline to delete + * @returns the deleted discipline + * @throws {NotFoundException} if a discipline of the specified id doesn't exist in the repository. + * @throws {Error} if the repository throws an error. + */ + @Delete(':id') + async remove(@Param('id', ParseIntPipe) id: number): Promise { + return this.disciplinesService.remove(id); + } + + /** + * Adds an admin ID to a discipline + * @param id the discipline id + * @param adminId the admin id to add + * @returns the updated discipline + * @throws {NotFoundException} if a discipline of the specified id doesn't exist in the repository. + * @throws {Error} if the repository throws an error. + */ + @Post(':id/admins/:adminId') + async addAdmin( + @Param('id', ParseIntPipe) id: number, + @Param('adminId', ParseIntPipe) adminId: number, + ): Promise { + return this.disciplinesService.addAdmin(id, adminId); + } + + /** + * Removes an admin ID from a discipline + * @param id the discipline id + * @param adminId the admin id to remove + * @returns the updated discipline + * @throws {NotFoundException} if a discipline of the specified id doesn't exist in the repository. + * @throws {Error} if the repository throws an error. + */ + @Delete(':id/admins/:adminId') + async removeAdmin( + @Param('id', ParseIntPipe) id: number, + @Param('adminId', ParseIntPipe) adminId: number, + ): Promise { + return this.disciplinesService.removeAdmin(id, adminId); + } +} diff --git a/apps/backend/src/disciplines/disciplines.entity.ts b/apps/backend/src/disciplines/disciplines.entity.ts new file mode 100644 index 000000000..63ad0f3de --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.entity.ts @@ -0,0 +1,38 @@ +import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; +import { DISCIPLINE_VALUES } from './disciplines.constants'; + +/** + * Represents the desired columns for the database table + * in the repository for the system's medical disciplines + * (BHCHP medical discipline) and their admin Ids. + * + * e.g.: Volunteers, Nursing, Public Health, MD, PA, NP, + * Research, Social work, Psychiatry, Pharmacy, IT. + */ +@Entity('discipline') +export class Discipline { + @PrimaryGeneratedColumn() + id: number; + + /** + * Predefined discipline values present in the table. + * + * e.g.: Volunteers, Nursing, Public Health, MD, PA, NP, + * Research, Social work, Psychiatry, Pharmacy, IT. + */ + @Column({ + type: 'enum', + enum: DISCIPLINE_VALUES, + nullable: false, + }) + name: DISCIPLINE_VALUES; + + /** + * Ids of admins in charge of reviewing the discipline, + * in no particular order. + * + * E.g. [4, 1] + */ + @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 000000000..c2c2dcdc7 --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.module.ts @@ -0,0 +1,15 @@ +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'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [TypeOrmModule.forFeature([Discipline]), AuthModule], + controllers: [DisciplinesController], + providers: [DisciplinesService, JwtStrategy, CurrentUserInterceptor], +}) +export class DisciplinesModule {} 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 000000000..0a4e03620 --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.service.spec.ts @@ -0,0 +1,350 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DisciplinesService } from './disciplines.service'; +import { Discipline } from './disciplines.entity'; +import { DISCIPLINE_VALUES } from './disciplines.constants'; +import { CreateDisciplineRequestDto } from './dto/create-discipline.request.dto'; + +describe('DisciplinesService', () => { + let service: DisciplinesService; + let repository: Repository; + + const mockRepository = { + find: jest.fn(), + findOneBy: jest.fn(), + create: jest.fn(), + save: jest.fn(), + remove: jest.fn(), + }; + + const mockDiscipline1: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + + const mockDiscipline2: Discipline = { + id: 2, + name: DISCIPLINE_VALUES.MD_MedicalStudent_PreMed, + admin_ids: [3], + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DisciplinesService, + { + provide: getRepositoryToken(Discipline), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(DisciplinesService); + repository = module.get>( + getRepositoryToken(Discipline), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('findAll', () => { + it('should return an array of disciplines', async () => { + const mockDisciplines = [mockDiscipline1, mockDiscipline2]; + mockRepository.find.mockResolvedValue(mockDisciplines); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual(mockDisciplines); + }); + + it('should return empty array when no disciplines exist', async () => { + mockRepository.find.mockResolvedValue([]); + + const result = await service.findAll(); + + expect(repository.find).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.find.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findAll()).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + }); + + describe('findOne', () => { + it('should return a single discipline', async () => { + mockRepository.findOneBy.mockResolvedValue(mockDiscipline1); + + const result = await service.findOne(1); + + expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 }); + expect(result).toEqual(mockDiscipline1); + }); + + it('should return null when discipline is not found', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + const result = await service.findOne(999); + + expect(repository.findOneBy).toHaveBeenCalledWith({ id: 999 }); + expect(result).toBeNull(); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOneBy.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findOne(1)).rejects.toThrow( + 'There was a problem retrieving the info', + ); + }); + }); + + describe('create', () => { + it('should create and save a new discipline', async () => { + const createDisciplineDto: CreateDisciplineRequestDto = { + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + + mockRepository.create.mockReturnValue(mockDiscipline1); + mockRepository.save.mockResolvedValue(mockDiscipline1); + + const result = await service.create(createDisciplineDto); + + expect(repository.create).toHaveBeenCalledWith(createDisciplineDto); + expect(repository.save).toHaveBeenCalledWith(mockDiscipline1); + expect(result).toEqual(mockDiscipline1); + }); + + it('should create a discipline with empty admin_ids array', async () => { + const createDisciplineDto: CreateDisciplineRequestDto = { + name: DISCIPLINE_VALUES.RN, + admin_ids: [], + }; + + const disciplineWithEmptyAdmins: Discipline = { + id: 3, + name: DISCIPLINE_VALUES.RN, + admin_ids: [], + }; + + mockRepository.create.mockReturnValue(disciplineWithEmptyAdmins); + mockRepository.save.mockResolvedValue(disciplineWithEmptyAdmins); + + const result = await service.create(createDisciplineDto); + + expect(repository.create).toHaveBeenCalledWith(createDisciplineDto); + expect(repository.save).toHaveBeenCalledWith(disciplineWithEmptyAdmins); + expect(result).toEqual(disciplineWithEmptyAdmins); + }); + }); + + describe('remove', () => { + it('should remove and return the discipline', async () => { + mockRepository.findOneBy.mockResolvedValue(mockDiscipline1); + mockRepository.remove.mockResolvedValue(mockDiscipline1); + + const result = await service.remove(1); + + expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 }); + expect(repository.remove).toHaveBeenCalledWith(mockDiscipline1); + expect(result).toEqual(mockDiscipline1); + }); + + it('should throw NotFoundException when discipline does not exist', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow( + 'Discipline with ID 999 not found', + ); + expect(repository.remove).not.toHaveBeenCalled(); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOneBy.mockResolvedValue(mockDiscipline1); + mockRepository.remove.mockRejectedValue( + new Error('Database connection failed'), + ); + + await expect(service.remove(1)).rejects.toThrow( + 'Database connection failed', + ); + }); + }); + + describe('addAdmin', () => { + it('should add an admin id to the discipline', async () => { + const disciplineBeforeAdd: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + const disciplineAfterAdd: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2, 3], + }; + + mockRepository.findOneBy.mockResolvedValue({ ...disciplineBeforeAdd }); + mockRepository.save.mockResolvedValue(disciplineAfterAdd); + + const result = await service.addAdmin(1, 3); + + expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 }); + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ admin_ids: [1, 2, 3] }), + ); + expect(result).toEqual(disciplineAfterAdd); + }); + + it('should not add duplicate admin id', async () => { + const discipline: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + + mockRepository.findOneBy.mockResolvedValue({ ...discipline }); + mockRepository.save.mockResolvedValue(discipline); + + const result = await service.addAdmin(1, 2); // 2 already exists + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ admin_ids: [1, 2] }), + ); + expect(result.admin_ids).toEqual([1, 2]); + }); + + it('should add admin to discipline with empty admin_ids', async () => { + const disciplineEmpty: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [], + }; + const disciplineAfterAdd: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [5], + }; + + mockRepository.findOneBy.mockResolvedValue({ ...disciplineEmpty }); + mockRepository.save.mockResolvedValue(disciplineAfterAdd); + + const result = await service.addAdmin(1, 5); + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ admin_ids: [5] }), + ); + expect(result).toEqual(disciplineAfterAdd); + }); + + it('should throw NotFoundException when discipline does not exist', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.addAdmin(999, 1)).rejects.toThrow( + 'Discipline with ID 999 not found', + ); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOneBy.mockResolvedValue(mockDiscipline1); + mockRepository.save.mockRejectedValue(new Error('Save failed')); + + await expect(service.addAdmin(1, 5)).rejects.toThrow('Save failed'); + }); + }); + + describe('removeAdmin', () => { + it('should remove an admin id from the discipline', async () => { + const disciplineBeforeRemove: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2, 3], + }; + const disciplineAfterRemove: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 3], + }; + + mockRepository.findOneBy.mockResolvedValue({ ...disciplineBeforeRemove }); + mockRepository.save.mockResolvedValue(disciplineAfterRemove); + + const result = await service.removeAdmin(1, 2); + + expect(repository.findOneBy).toHaveBeenCalledWith({ id: 1 }); + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ admin_ids: [1, 3] }), + ); + expect(result).toEqual(disciplineAfterRemove); + }); + + it('should handle removing non-existent admin id gracefully', async () => { + const discipline: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [1, 2], + }; + + mockRepository.findOneBy.mockResolvedValue({ ...discipline }); + mockRepository.save.mockResolvedValue(discipline); + + const result = await service.removeAdmin(1, 999); // 999 doesn't exist + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ admin_ids: [1, 2] }), + ); + }); + + it('should handle removing from empty admin_ids array', async () => { + const disciplineEmpty: Discipline = { + id: 1, + name: DISCIPLINE_VALUES.RN, + admin_ids: [], + }; + + mockRepository.findOneBy.mockResolvedValue({ ...disciplineEmpty }); + mockRepository.save.mockResolvedValue(disciplineEmpty); + + const result = await service.removeAdmin(1, 5); + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ admin_ids: [] }), + ); + }); + + it('should throw NotFoundException when discipline does not exist', async () => { + mockRepository.findOneBy.mockResolvedValue(null); + + await expect(service.removeAdmin(999, 1)).rejects.toThrow( + 'Discipline with ID 999 not found', + ); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOneBy.mockResolvedValue(mockDiscipline1); + mockRepository.save.mockRejectedValue(new Error('Save failed')); + + await expect(service.removeAdmin(1, 1)).rejects.toThrow('Save failed'); + }); + }); +}); diff --git a/apps/backend/src/disciplines/disciplines.service.ts b/apps/backend/src/disciplines/disciplines.service.ts new file mode 100644 index 000000000..d197a8c56 --- /dev/null +++ b/apps/backend/src/disciplines/disciplines.service.ts @@ -0,0 +1,99 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Discipline } from './disciplines.entity'; +import { CreateDisciplineRequestDto } from './dto/create-discipline.request.dto'; + +/** + * Service to interface with the disciplines repository. + */ +@Injectable() +export class DisciplinesService { + constructor( + @InjectRepository(Discipline) + private disciplinesRepository: Repository, + ) {} + + /** + * Returns a list of all disciplines in the repository + * @returns a list of all disciplines in the repository + */ + async findAll(): Promise { + return this.disciplinesRepository.find(); + } + + /** + * Returns a discipline from the repository with the respective id + * @param id the id corresponding to the desired discipline + * @returns a discipline from the repository with the respective id + */ + async findOne(id: number): Promise { + return this.disciplinesRepository.findOneBy({ id }); + } + + /** + * Creates a discipline with the requested fields + * @param createDto the requested fields for the new discipline to have + * @returns the new discipline + */ + async create(createDto: CreateDisciplineRequestDto): Promise { + const discipline = this.disciplinesRepository.create(createDto); + return this.disciplinesRepository.save(discipline); + } + + /** + * Deletes a discipline by id + * @param id the id of the discipline to delete + * @returns the deleted discipline + * @throws {NotFoundException} if a discipline of the specified id doesn't exist in the repository. + * @throws {Error} if the repository throws an error. + */ + async remove(id: number): Promise { + const discipline = await this.findOne(id); + if (!discipline) { + throw new NotFoundException(`Discipline with ID ${id} not found`); + } + return this.disciplinesRepository.remove(discipline); + } + /** + * Adds an admin ID to a discipline's admin_ids array + * @param id the discipline id + * @param adminId the admin id to add + * @throws {NotFoundException} if a discipline of the specified id doesn't exist in the repository. + * @throws {Error} if the repository throws an error. + * @returns the updated discipline + */ + async addAdmin(id: number, adminId: number): Promise { + const discipline = await this.findOne(id); + if (!discipline) { + throw new NotFoundException(`Discipline with ID ${id} not found`); + } + + if (!discipline.admin_ids.includes(adminId)) { + discipline.admin_ids = [...discipline.admin_ids, adminId]; + } + + return this.disciplinesRepository.save(discipline); + } + + /** + * Removes an admin ID from a discipline's admin_ids array + * @param id the discipline id + * @param adminId the admin id to remove + * @returns the updated discipline + * @throws {NotFoundException} if a discipline of the specified id doesn't exist in the repository. + * @throws {Error} if the repository throws an error. + */ + async removeAdmin(id: number, adminId: number): Promise { + const discipline = await this.findOne(id); + if (!discipline) { + throw new NotFoundException(`Discipline with ID ${id} not found`); + } + + discipline.admin_ids = discipline.admin_ids.filter( + (aid) => aid !== adminId, + ); + + return this.disciplinesRepository.save(discipline); + } +} diff --git a/apps/backend/src/disciplines/dto/create-discipline.request.dto.ts b/apps/backend/src/disciplines/dto/create-discipline.request.dto.ts new file mode 100644 index 000000000..7d87185ed --- /dev/null +++ b/apps/backend/src/disciplines/dto/create-discipline.request.dto.ts @@ -0,0 +1,11 @@ +import { DISCIPLINE_VALUES } from '../disciplines.constants'; +import { IsEnum, IsNotEmpty, IsArray } from 'class-validator'; + +export class CreateDisciplineRequestDto { + @IsEnum(DISCIPLINE_VALUES) + @IsNotEmpty() + name: DISCIPLINE_VALUES; + + @IsArray() + admin_ids: number[]; +} diff --git a/apps/backend/src/interceptors/current-user.interceptor.ts b/apps/backend/src/interceptors/current-user.interceptor.ts index 3d5d8297b..065681e71 100644 --- a/apps/backend/src/interceptors/current-user.interceptor.ts +++ b/apps/backend/src/interceptors/current-user.interceptor.ts @@ -16,6 +16,11 @@ export class CurrentUserInterceptor implements NestInterceptor { async intercept(context: ExecutionContext, handler: CallHandler) { const request = context.switchToHttp().getRequest(); + + if (!request.user || !request.user.idUser) { + return handler.handle(); + } + const cognitoUserAttributes = await this.authService.getUser( request.user.idUser, ); diff --git a/apps/backend/src/learner-info/dto/create-learner-info.request.dto.ts b/apps/backend/src/learner-info/dto/create-learner-info.request.dto.ts new file mode 100644 index 000000000..20b99c8b8 --- /dev/null +++ b/apps/backend/src/learner-info/dto/create-learner-info.request.dto.ts @@ -0,0 +1,94 @@ +import { + IsBoolean, + IsEnum, + IsNumber, + IsDefined, + Min, + IsString, + IsOptional, + Matches, +} from 'class-validator'; +import { ExperienceType, InterestArea, School } from '../types'; + +/** + * Defines the expected shape of data for creating a learner info + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class CreateLearnerInfoDto { + /** + * The id corresponding to the application this information belongs to + */ + @IsNumber() + @Min(0) + @IsDefined() + appId!: number; + + /** + * School of the applicant; includes well-known medical schools or an 'other' option. + * + * Example: School.STANFORD_MEDICINE. + */ + @IsEnum(School) + @IsDefined() + school!: School; + + /** + * Name of the department in the school studied in if relevent + * + * Example: Infectious Diseases + */ + @IsString() + @IsOptional() + schoolDepartment?: string; + + /** + * Applying as themselves or applying as a supervisor + * + * Example: true + */ + @IsBoolean() + @IsDefined() + isSupervisorApplying: boolean; + + /** + * Whether the applicant is over 18 years old + * + * Example: true + */ + @IsBoolean() + @IsDefined() + isLegalAdult: boolean; + + /** + * The birthdate of the applicant, only required if they are under 18 + * + * Example: '2000-01-01'. + */ + @IsString() + @IsDefined() + @Matches(/^\d{4}-\d{2}-\d{2}$/, { + message: 'Date must be in YYYY-MM-DD format', + }) + dateOfBirth?: Date; + + /** + * Course requirements if volunteering fulfills some course requirement + * + * Example: 15 hours of patient facing work per week + */ + @IsString() + courseRequirements?: string; + + /** + * Instructor's information if needed. + * + * Example: Jane Doe at khoury college of computer sciences, contact: doe.ja@northeastern.edu + */ + @IsString() + instructorInfo?: string; + + /** + * Course syllabus if relevant to volunteering + */ +} diff --git a/apps/backend/src/learner-info/learner-info.controller.spec.ts b/apps/backend/src/learner-info/learner-info.controller.spec.ts new file mode 100644 index 000000000..f5158d47b --- /dev/null +++ b/apps/backend/src/learner-info/learner-info.controller.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { LearnerInfoController } from './learner-info.controller'; +import { LearnerInfoService } from './learner-info.service'; +import { LearnerInfo } from './learner-info.entity'; +import { CreateLearnerInfoDto } from './dto/create-learner-info.request.dto'; +import { School } from './types'; +import { BadRequestException } from '@nestjs/common'; + +describe('LearnerInfoController', () => { + let controller: LearnerInfoController; + + const mockLearnerInfoService = { + create: jest.fn(), + update: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [LearnerInfoController], + providers: [ + { + provide: LearnerInfoService, + useValue: mockLearnerInfoService, + }, + { + provide: getRepositoryToken(LearnerInfo), + useValue: {}, + }, + ], + }).compile(); + + controller = module.get(LearnerInfoController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('POST /', () => { + it('should create a new learner info', async () => { + const createLearnerInfo: CreateLearnerInfoDto = { + appId: 0, + school: School.HARVARD_MEDICAL_SCHOOL, + schoolDepartment: 'Infectious Diseases', + isSupervisorApplying: false, + isLegalAdult: true, + }; + + mockLearnerInfoService.create.mockResolvedValue( + createLearnerInfo as LearnerInfo, + ); + + // Call controller method + const result = await controller.createLearnerInfo(createLearnerInfo); + + // Verify results + expect(result).toEqual(createLearnerInfo as LearnerInfo); + expect(mockLearnerInfoService.create).toHaveBeenCalledWith( + createLearnerInfo, + ); + }); + + it('should pass along any service errors without information loss', async () => { + mockLearnerInfoService.create.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + const createLearnerInfoDto: CreateLearnerInfoDto = { + appId: 0, + school: School.HARVARD_MEDICAL_SCHOOL, + isSupervisorApplying: false, + isLegalAdult: true, + }; + + await expect( + controller.createLearnerInfo(createLearnerInfoDto), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should not accept negative appId', async () => { + const createLearnerInfoDto: CreateLearnerInfoDto = { + appId: -1, + school: School.HARVARD_MEDICAL_SCHOOL, + isSupervisorApplying: false, + isLegalAdult: true, + }; + + mockLearnerInfoService.create.mockRejectedValue( + new BadRequestException('appId must not be negative'), + ); + + await expect( + controller.createLearnerInfo(createLearnerInfoDto), + ).rejects.toThrow(new BadRequestException(`appId must not be negative`)); + }); + }); +}); diff --git a/apps/backend/src/learner-info/learner-info.controller.ts b/apps/backend/src/learner-info/learner-info.controller.ts new file mode 100644 index 000000000..ed795619d --- /dev/null +++ b/apps/backend/src/learner-info/learner-info.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Post } 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'; + +/** + * Controller to expose HTTP endpoints to interface, extract, and change information about learner-specific application info. + */ +@ApiTags('LearnerInfo') +@Controller('learner_info') +export class LearnerInfoController { + constructor(private learnerInfoService: LearnerInfoService) {} + + /** + * Exposes an endpoint to create a learner info. + * @param createLearnerInfoDto The expected data required to create a learner specific info object + * @returns The newly created application. + * @throws {Error} which is unchanged from what repository throws. + */ + @Post() + async createLearnerInfo( + @Body() createLearnerInfoDto: CreateLearnerInfoDto, + ): Promise { + return await this.learnerInfoService.create(createLearnerInfoDto); + } +} diff --git a/apps/backend/src/learner-info/learner-info.entity.ts b/apps/backend/src/learner-info/learner-info.entity.ts new file mode 100644 index 000000000..ffb3f7726 --- /dev/null +++ b/apps/backend/src/learner-info/learner-info.entity.ts @@ -0,0 +1,84 @@ +import { Entity, Column, PrimaryColumn } from 'typeorm'; +import { School } from './types'; + +/** + * Represents the desired columns for the database table in the repository for the system's learner info. + */ +@Entity('learner_info') +export class LearnerInfo { + /** + * The id corresponding to the application this information belongs to + */ + @PrimaryColumn() + appId!: number; + + /** + * School of the applicant; includes well-known medical schools or an 'other' option. + * + * Example: School.STANFORD_MEDICINE. + */ + @Column({ type: 'enum', enum: School }) + school!: School; + + /** + * Name of the department in the school studied in if relevent + * + * Example: Infectious Diseases + */ + @Column({ type: 'varchar', nullable: true }) + schoolDepartment?: string; + + /** + * Applying as themselves or applying as a supervisor + * + * Example: true + */ + @Column({ type: 'boolean' }) + isSupervisorApplying: boolean; + + /** + * Whether the applicant is over 18 years old + * + * Example: true + */ + @Column({ type: 'boolean' }) + isLegalAdult: boolean; + + /** + * The birthdate of the applicant, only required if they are under 18 + * + * Example: new Date('2024-06-30'). + */ + @Column({ type: 'date', nullable: true }) + dateOfBirth?: Date; + + /** + * Course requirements if volunteering fulfills some course requirement + * + * Example: 15 hours of patient facing work per week + */ + @Column({ type: 'varchar', nullable: true }) + courseRequirements?: string; + + /** + * Instructor's information if needed. + * + * Example: Jane Doe at khoury college of computer sciences, contact: doe.ja@northeastern.edu + */ + @Column({ type: 'varchar', nullable: true }) + instructorInfo?: string; + + /** + * S3 file name of course syllabus if relevant to volunteering + * + * Example: cs_3500_2_7_2026.pdf + * + * Note: In the code when accessing the files we would prepend the s3 address, e.g. + * a full link looks like this: + * https://shelter-link-shelters.s3.us-east-2.amazonaws.com/test_photo.webp + * But since "https://shelter-link-shelters.s3.us-east-2.amazonaws.com/" would look the same + * for every single file we can just store the file with its extension e.g. "test_photo.webp" + */ + @Column({ type: 'varchar', nullable: true }) + syllabus?: string; +} diff --git a/apps/backend/src/learner-info/learner-info.module.ts b/apps/backend/src/learner-info/learner-info.module.ts new file mode 100644 index 000000000..536019062 --- /dev/null +++ b/apps/backend/src/learner-info/learner-info.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { LearnerInfoController } from './learner-info.controller'; +import { LearnerInfoService } from './learner-info.service'; +import { LearnerInfo } from './learner-info.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([LearnerInfo])], + controllers: [LearnerInfoController], + providers: [LearnerInfoService], +}) +export class LearnerInfoModule {} diff --git a/apps/backend/src/learner-info/learner-info.service.spec.ts b/apps/backend/src/learner-info/learner-info.service.spec.ts new file mode 100644 index 000000000..c844fc82c --- /dev/null +++ b/apps/backend/src/learner-info/learner-info.service.spec.ts @@ -0,0 +1,136 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LearnerInfoService } from './learner-info.service'; +import { LearnerInfo } from './learner-info.entity'; +import { School } from './types'; +import { NotFoundException } from '@nestjs/common'; + +describe('LearnerInfoService', () => { + let service: LearnerInfoService; + let repository: Repository; + + const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LearnerInfoService, + { + provide: getRepositoryToken(LearnerInfo), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(LearnerInfoService); + repository = module.get>( + getRepositoryToken(LearnerInfo), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create and save a new learner info', async () => { + const LearnerInfo: LearnerInfo = { + appId: 0, + school: School.HARVARD_MEDICAL_SCHOOL, + isSupervisorApplying: false, + isLegalAdult: true, + }; + + mockRepository.save.mockResolvedValue(LearnerInfo); + + const result = await service.create(LearnerInfo); + + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(LearnerInfo); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + const LearnerInfo: LearnerInfo = { + appId: 0, + school: School.HARVARD_MEDICAL_SCHOOL, + isSupervisorApplying: false, + isLegalAdult: true, + }; + + await expect(service.create(LearnerInfo)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); + + it('should not accept negative appId', async () => { + const LearnerInfo: LearnerInfo = { + appId: -1, + school: School.HARVARD_MEDICAL_SCHOOL, + isSupervisorApplying: false, + isLegalAdult: true, + }; + + mockRepository.save.mockResolvedValue(LearnerInfo); + await expect(service.create(LearnerInfo)).rejects.toThrow(); + }); + }); + + describe('findById', () => { + it('should return a single application', async () => { + const LearnerInfo: LearnerInfo = { + appId: 1, + school: School.HARVARD_MEDICAL_SCHOOL, + isSupervisorApplying: false, + isLegalAdult: true, + }; + + mockRepository.findOne.mockResolvedValue(LearnerInfo); + + const result = await service.findById(1); + + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(result).toEqual(LearnerInfo); + }); + + 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( + `Learner Info with AppId ${nonExistentId} not found`, + ), + ); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { appId: nonExistentId }, + }); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.findOne.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + + await expect(service.findById(1)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); + }); +}); diff --git a/apps/backend/src/learner-info/learner-info.service.ts b/apps/backend/src/learner-info/learner-info.service.ts new file mode 100644 index 000000000..e324acac3 --- /dev/null +++ b/apps/backend/src/learner-info/learner-info.service.ts @@ -0,0 +1,57 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { LearnerInfo } from './learner-info.entity'; +import { CreateLearnerInfoDto } from './dto/create-learner-info.request.dto'; + +/** + * Service for applications that interfaces with the application repository. + */ +@Injectable() +export class LearnerInfoService { + constructor( + @InjectRepository(LearnerInfo) + private learnerInfoRepository: Repository, + ) {} + + /** + * Returns a learner info by appId from the repository. + * @param appId The desired learner info appId to search for. + * @returns A promise resolving to the learner info object with that appId. + * @throws {NotFoundException} with message 'Learner Info with AppId not found' + * if an application with that id does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + async findById(appId: number): Promise { + const learnerInfo: LearnerInfo = await this.learnerInfoRepository.findOne({ + where: { appId }, + }); + + if (!learnerInfo) { + throw new NotFoundException(`Learner Info with AppId ${appId} not found`); + } + + return learnerInfo; + } + + /** + * Creates a learner info in the repository. + * @param createLearnerInfoDto The expected data required to create a learner specific info object + * @returns The newly created learner-specific information object. + * @throws {Error} which is unchanged from what repository throws. + * @throws {BadRequestException} if any fields are invalid + */ + async create( + createLearnerInfoDto: CreateLearnerInfoDto, + ): Promise { + if (createLearnerInfoDto.appId < 0) { + throw new BadRequestException('appId must not be negative'); + } + const learnerInfo = this.learnerInfoRepository.create(createLearnerInfoDto); + return await this.learnerInfoRepository.save(learnerInfo); + } +} diff --git a/apps/backend/src/learner-info/types.ts b/apps/backend/src/learner-info/types.ts new file mode 100644 index 000000000..96588d965 --- /dev/null +++ b/apps/backend/src/learner-info/types.ts @@ -0,0 +1,34 @@ +/** + * Experience type/ level of the applicant, generally in terms of medical experience/ degree + */ +export enum ExperienceType { + BS = 'BS', + MS = 'MS', + PHD = 'PhD', + MD = 'MD', + MD_PHD = 'MD PhD', + RN = 'RN', + NP = 'NP', + PA = 'PA', + OTHER = 'Other', +} + +/** + * Applicant's area of interest for the commitment + */ +export enum InterestArea { + NURSING = 'Nursing', + HARM_REDUCTION = 'HarmReduction', + WOMENS_HEALTH = 'WomensHealth', +} + +/** + * School of the applicant, includes well-known medical schools, or an other option + */ +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/migrations/1754254886189-add_task.ts b/apps/backend/src/migrations/1754254886189-add_task.ts deleted file mode 100644 index 450a64155..000000000 --- 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 000000000..713ba0fbc --- /dev/null +++ b/apps/backend/src/migrations/1754254886189-init.ts @@ -0,0 +1,84 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class Init1754254886189 implements MigrationInterface { + name = 'Init1754254886189'; + + public async up(queryRunner: QueryRunner): Promise { + const disciplineEnum = + `('MD/Medical Student/Pre-Med', 'Medical NP/PA', ` + + `'Psychiatry or Psychiatric NP/PA', 'Public Health', 'RN', 'Social Work', 'Other')`; + + // DISCIPLINE_VALUES - used by application, admins, discipline + await queryRunner.query( + `CREATE TYPE "public"."application_discipline_enum" AS ENUM${disciplineEnum}`, + ); + await queryRunner.query( + `CREATE TYPE "public"."admins_discipline_enum" AS ENUM${disciplineEnum}`, + ); + await queryRunner.query( + `CREATE TYPE "public"."discipline_name_enum" AS ENUM${disciplineEnum}`, + ); + + // AppStatus + await queryRunner.query( + `CREATE TYPE "public"."application_appstatus_enum" AS ENUM(` + + `'App submitted', 'In review', 'Forms sent', 'Accepted', ` + + `'No Availability', 'Declined', 'Active', 'Inactive')`, + ); + + // ExperienceType + await queryRunner.query( + `CREATE TYPE "public"."application_experiencetype_enum" AS ENUM(` + + `'BS', 'MS', 'PhD', 'MD', 'MD PhD', 'RN', 'NP', 'PA', 'Other')`, + ); + + // InterestArea + await queryRunner.query( + `CREATE TYPE "public"."application_interest_enum" AS ENUM(` + + `'Women''s Health', 'Medical Respite/Inpatient', 'Street Medicine', ` + + `'Addiction Medicine', 'Primary Care', 'Behavioral Health', ` + + `'Veterans Services', 'Family and Youth Services', ` + + `'Hep C Care', 'HIV Services', 'Case Management', 'Dental')`, + ); + + // ApplicantType + await queryRunner.query( + `CREATE TYPE "public"."application_applicanttype_enum" AS ENUM(` + + `'Learner', 'Volunteer')`, + ); + + // School + await queryRunner.query( + `CREATE TYPE "public"."application_school_enum" AS ENUM(` + + `'Harvard Medical School', 'Johns Hopkins', 'Stanford Medicine', ` + + `'Mayo Clinic', 'Other')`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."application_school_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."application_applicanttype_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."application_interest_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."application_experiencetype_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."application_appstatus_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."discipline_name_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."admins_discipline_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."application_discipline_enum"`, + ); + } +} diff --git a/apps/backend/src/migrations/1770300403706-CreateApplicationTable.ts b/apps/backend/src/migrations/1770300403706-CreateApplicationTable.ts new file mode 100644 index 000000000..1fef577c1 --- /dev/null +++ b/apps/backend/src/migrations/1770300403706-CreateApplicationTable.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateApplicationTable1770300403706 implements MigrationInterface { + name = 'CreateApplicationTable1770300403706'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "application" ( + "appId" SERIAL NOT NULL, + "email" character varying NOT NULL, + "discipline" "public"."application_discipline_enum" NOT NULL, + "otherDisciplineDescription" character varying, + "appStatus" "public"."application_appstatus_enum" NOT NULL DEFAULT 'App submitted', + "mondayAvailability" character varying NOT NULL, + "tuesdayAvailability" character varying NOT NULL, + "wednesdayAvailability" character varying NOT NULL, + "thursdayAvailability" character varying NOT NULL, + "fridayAvailability" character varying NOT NULL, + "saturdayAvailability" character varying NOT NULL, + "experienceType" "public"."application_experiencetype_enum" NOT NULL, + "interest" "public"."application_interest_enum"[] NOT NULL, + "license" character varying NOT NULL, + "phone" character varying NOT NULL, + "applicantType" "public"."application_applicanttype_enum" NOT NULL, + "school" "public"."application_school_enum" NOT NULL, + "otherSchool" character varying, + "referred" boolean DEFAULT false, + "referredEmail" character varying, + "weeklyHours" integer NOT NULL, + "pronouns" character varying NOT NULL, + "nonEnglishLangs" character varying, + "desiredExperience" character varying NOT NULL, + "elaborateOtherDiscipline" character varying, + "resume" character varying NOT NULL, + "coverLetter" character varying NOT NULL, + "emergencyContactName" character varying NOT NULL, + "emergencyContactPhone" character varying NOT NULL, + "emergencyContactRelationship" character varying NOT NULL, + CONSTRAINT "PK_application_appId" PRIMARY KEY ("appId"));`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "application"`); + } +} diff --git a/apps/backend/src/migrations/1770490892570-CreateLearnerInfoTable.ts b/apps/backend/src/migrations/1770490892570-CreateLearnerInfoTable.ts new file mode 100644 index 000000000..235213550 --- /dev/null +++ b/apps/backend/src/migrations/1770490892570-CreateLearnerInfoTable.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateLearnerInfoTable1770490892570 implements MigrationInterface { + name = 'CreateLearnerInfoTable1770490892570'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "learner_info" ( + "appId" integer NOT NULL, + "school" "public"."application_school_enum" NOT NULL, + CONSTRAINT "PK_learner_info_appId" PRIMARY KEY ("appId"), + CONSTRAINT "FK_learner_info_appId" FOREIGN KEY ("appId") REFERENCES "application"("appId") ON DELETE CASCADE + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "learner_info"`); + } +} diff --git a/apps/backend/src/migrations/1770490892571-CreateVolunteerInfoTable.ts b/apps/backend/src/migrations/1770490892571-CreateVolunteerInfoTable.ts new file mode 100644 index 000000000..0852a2b96 --- /dev/null +++ b/apps/backend/src/migrations/1770490892571-CreateVolunteerInfoTable.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVolunteerInfoTable1770490892571 + implements MigrationInterface +{ + name = 'CreateVolunteerInfoTable1770490892571'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "volunteer_info" ( + "appId" integer NOT NULL, + "license" character varying NOT NULL, + CONSTRAINT "PK_volunteer_info_appId" PRIMARY KEY ("appId"), + CONSTRAINT "FK_volunteer_info_appId" FOREIGN KEY ("appId") REFERENCES "application"("appId") ON DELETE CASCADE + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "volunteer_info"`); + } +} diff --git a/apps/backend/src/migrations/1770490892573-CreateApplicantTable.ts b/apps/backend/src/migrations/1770490892573-CreateApplicantTable.ts new file mode 100644 index 000000000..0c95066c0 --- /dev/null +++ b/apps/backend/src/migrations/1770490892573-CreateApplicantTable.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateApplicantTable1770490892573 implements MigrationInterface { + name = 'CreateApplicantTable1770490892573'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "applicants" ( + "appId" integer NOT NULL, + "firstName" character varying NOT NULL, + "lastName" character varying NOT NULL, + "startDate" date NOT NULL, + "endDate" date NOT NULL, + CONSTRAINT "PK_applicants_appId" PRIMARY KEY ("appId"), + CONSTRAINT "FK_applicants_appId" FOREIGN KEY ("appId") REFERENCES "application"("appId") ON DELETE CASCADE + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "applicants"`); + } +} diff --git a/apps/backend/src/migrations/1771176005881-CreateAdminsTable.ts b/apps/backend/src/migrations/1771176005881-CreateAdminsTable.ts new file mode 100644 index 000000000..c9b1f2e35 --- /dev/null +++ b/apps/backend/src/migrations/1771176005881-CreateAdminsTable.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner, Table } from 'typeorm'; + +export class CreateAdminsTable1771176005881 implements MigrationInterface { + name = 'CreateAdminsTable1771176005881'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DO $$ BEGIN CREATE TYPE "admins_discipline_enum" AS ENUM ( + 'MD/Medical Student/Pre-Med', + 'Medical NP/PA', + 'Psychiatry or Psychiatric NP/PA', + 'Public Health', + 'RN', + 'Social Work', + 'Other' + ); EXCEPTION WHEN duplicate_object THEN null; END $$ + `); + + await queryRunner.createTable( + new Table({ + name: 'admins', + columns: [ + { + name: 'id', + type: 'int', + isPrimary: true, + isGenerated: true, + generationStrategy: 'increment', + }, + { + name: 'firstName', + type: 'varchar', + }, + { + name: 'lastName', + type: 'varchar', + }, + { + name: 'email', + type: 'varchar', + isUnique: true, + }, + { + name: 'discipline', + type: 'admins_discipline_enum', + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'now()', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'now()', + }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('admins'); + await queryRunner.query('DROP TYPE "admins_discipline_enum"'); + } +} diff --git a/apps/backend/src/migrations/1771176422893-CreateUserTable.ts b/apps/backend/src/migrations/1771176422893-CreateUserTable.ts new file mode 100644 index 000000000..2c12fb767 --- /dev/null +++ b/apps/backend/src/migrations/1771176422893-CreateUserTable.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateUserTable1771176422893 implements MigrationInterface { + name = 'CreateUserTable1771176422893'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DO $$ BEGIN CREATE TYPE "user_status_enum" AS ENUM ('ADMIN', 'STANDARD'); EXCEPTION WHEN duplicate_object THEN null; END $$`, + ); + + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "users" ( + "appId" integer NOT NULL, + "status" "user_status_enum" NOT NULL, + "firstName" character varying NOT NULL, + "lastName" character varying NOT NULL, + "email" character varying NOT NULL, + CONSTRAINT "PK_users_appId" PRIMARY KEY ("appId") + )`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "users"`); + await queryRunner.query(`DROP TYPE "user_status_enum"`); + } +} diff --git a/apps/backend/src/migrations/1771191788744-CreateDisciplineTable.ts b/apps/backend/src/migrations/1771191788744-CreateDisciplineTable.ts new file mode 100644 index 000000000..d4bdedb61 --- /dev/null +++ b/apps/backend/src/migrations/1771191788744-CreateDisciplineTable.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateDisciplineTable1771191788744 implements MigrationInterface { + name = 'CreateDisciplineTable1771191788744'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DO $$ BEGIN + CREATE TYPE "public"."discipline_name_enum" AS ENUM( + 'MD/Medical Student/Pre-Med', + 'Medical NP/PA', + 'Psychiatry or Psychiatric NP/PA', + 'Public Health', + 'RN', + 'Social Work', + 'Other' + ); + EXCEPTION WHEN duplicate_object THEN null; + END $$`, + ); + await queryRunner.query( + `CREATE TABLE "discipline" ("id" SERIAL NOT NULL, "name" "public"."discipline_name_enum" NOT NULL, "admin_ids" integer array NOT NULL DEFAULT '{}', CONSTRAINT "PK_139512aefbb11a5b2fa92696828" PRIMARY KEY ("id"))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "discipline"`); + await queryRunner.query(`DROP TYPE "public"."discipline_name_enum"`); + } +} diff --git a/apps/backend/src/migrations/1772059627598-AddSundayAvailability.ts b/apps/backend/src/migrations/1772059627598-AddSundayAvailability.ts new file mode 100644 index 000000000..1f044702a --- /dev/null +++ b/apps/backend/src/migrations/1772059627598-AddSundayAvailability.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSundayAvailability1772059627598 implements MigrationInterface { + name = 'AddSundayAvailability1772059627598'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" ADD "sundayAvailability" character varying NOT NULL DEFAULT ''`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" DROP COLUMN "sundayAvailability"`, + ); + } +} diff --git a/apps/backend/src/seeds/learner-volunteer-seed.txt b/apps/backend/src/seeds/learner-volunteer-seed.txt new file mode 100644 index 000000000..d08b6588b --- /dev/null +++ b/apps/backend/src/seeds/learner-volunteer-seed.txt @@ -0,0 +1,57 @@ +## Add this after + console.log(`✅ Created ${disciplines.length} disciplines`); +## And before + console.log('🎉 Database seed completed successfully!'); + +IMPORTS: +""" +import { LearnerInfo } from '../learner-info/learner-info.entity'; +import { VolunteerInfo } from '../volunteer-info/volunteer-info.entity'; +import { School } from '../learner-info/types'; +""" + +ADD THIS +""" + +const learnerRepo = dataSource.getRepository(LearnerInfo); +const volunteerRepo = dataSource.getRepository(VolunteerInfo); + +// Clear existing data (optional - be careful with this!) +await learnerRepo.clear(); +await volunteerRepo.clear(); + +// Seed Learner Info +const learners = await learnerRepo.save([ + { + appId: 1, + school: School.STANFORD_MEDICINE, + }, + { + appId: 2, + school: School.JOHNS_HOPKINS, + }, + { + appId: 3, + school: School.MAYO_CLINIC, + }, +]); +console.log(`✅ Seeded ${learners.length} learners`); + +// Seed Volunteer Info +const volunteers = await volunteerRepo.save([ + { + appId: 3, + license: '1234567890', + }, + { + appId: 4, + license: '1234567890', + }, + { + appId: 5, + license: '1234567890', + }, +]); +console.log(`✅ Seeded ${volunteers.length} volunteers`); + +""" diff --git a/apps/backend/src/seeds/seed.ts b/apps/backend/src/seeds/seed.ts new file mode 100644 index 000000000..3f1a6047f --- /dev/null +++ b/apps/backend/src/seeds/seed.ts @@ -0,0 +1,299 @@ +import { DeepPartial, Repository } from 'typeorm'; +import dataSource from '../data-source'; +import { Discipline } from '../disciplines/disciplines.entity'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; +import { Application } from '../applications/application.entity'; +import { + AppStatus, + ExperienceType, + InterestArea, + School, + ApplicantType, +} from '../applications/types'; +import { LearnerInfo } from '../learner-info/learner-info.entity'; +import { VolunteerInfo } from '../volunteer-info/volunteer-info.entity'; +import { Applicant } from '../applicants/applicant.entity'; + +const APPLICANT_SEED = [ + { + appId: 1, + firstName: 'Jane', + lastName: 'Doe', + startDate: '2024-01-01', + endDate: '2024-06-30', + }, + { + appId: 2, + firstName: 'John', + lastName: 'Smith', + startDate: '2026-01-01', + endDate: '2026-06-30', + }, +]; + +const APPLICATION_SEED = [ + { + appId: 1, + appStatus: AppStatus.APP_SUBMITTED, + sundayAvailability: 'no availability', + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.MEDICAL_RESPITE_INPATIENT], + license: 'n/a', + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + nonEnglishLangs: 'spoken chinese only', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }, + { + appId: 2, + appStatus: AppStatus.APP_SUBMITTED, + sundayAvailability: '10am-2pm', + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.MEDICAL_RESPITE_INPATIENT], + license: 'n/a', + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + nonEnglishLangs: 'spoken chinese only', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }, + { + appId: 3, + appStatus: AppStatus.APP_SUBMITTED, + sundayAvailability: 'All day', + mondayAvailability: '12pm and on every other week', + tuesdayAvailability: 'approximately 10am-3pm', + wednesdayAvailability: 'no availability', + thursdayAvailability: 'maybe before 10am', + fridayAvailability: 'Sometime between 4-6', + saturdayAvailability: 'no availability', + experienceType: ExperienceType.BS, + interest: [InterestArea.MEDICAL_RESPITE_INPATIENT], + license: 'n/a', + applicantType: ApplicantType.LEARNER, + phone: '123-456-7890', + school: School.HARVARD_MEDICAL_SCHOOL, + email: 'test@example.com', + discipline: DISCIPLINE_VALUES.RN, + referred: false, + weeklyHours: 20, + pronouns: 'she/her', + nonEnglishLangs: 'spoken chinese only', + desiredExperience: + 'I want to give back to the boston community and learn to talk better with patients', + resume: 'janedoe_resume_2_6_2026.pdf', + coverLetter: 'janedoe_coverLetter_2_6_2026.pdf', + emergencyContactName: 'Jane Doe', + emergencyContactPhone: '111-111-1111', + emergencyContactRelationship: 'Mother', + }, +]; + +async function seed() { + try { + console.log('🌱 Starting database seed...'); + + // Initialize the data source + await dataSource.initialize(); + console.log('✅ Database connection established'); + + // Note: We're NOT dropping the schema here since we want to keep migrations + // Just ensure migrations are run first before seeding + + // Create disciplines + console.log('📚 Creating disciplines...'); + await dataSource.getRepository(Discipline).save( + Object.values(DISCIPLINE_VALUES).map((name) => ({ + name, + admin_ids: [], + })), + ); + console.log('✅ Disciplines created'); + + // Create application test data + console.log('📋 Creating applications...'); + const applicationRepo: Repository = + dataSource.getRepository(Application); + const applications = await applicationRepo.save( + APPLICATION_SEED as DeepPartial[], + ); + console.log(`✅ Created ${applications.length} applications`); + + // Create learner_info for the learner application (john.smith) + console.log('📋 Creating learner infos...'); + const learnerApp = applications.find( + (a) => a.email === 'john.smith@example.com', + ); + if (learnerApp) { + await dataSource.getRepository(LearnerInfo).save({ + appId: learnerApp.appId, + school: School.HARVARD_MEDICAL_SCHOOL, + }); + console.log('✅ Created learner_info for 1 application'); + } + + // Create volunteer_info for volunteer applications + console.log('📋 Creating volunteer infos...'); + const volunteerAppIds = applications + .filter((a) => a.applicantType === ApplicantType.VOLUNTEER) + .map((a) => a.appId); + if (volunteerAppIds.length > 0) { + await dataSource.getRepository(VolunteerInfo).save( + volunteerAppIds.map((appId) => ({ + appId, + license: 'Volunteer-License', + })), + ); + console.log( + `✅ Created volunteer_info for ${volunteerAppIds.length} applications`, + ); + } + + // Create applicant test data + console.log('📋 Creating applicants...'); + const applicantRepo: Repository = + dataSource.getRepository(Applicant); + const applicants = await applicantRepo.save( + APPLICANT_SEED as DeepPartial[], + ); + console.log(`✅ Created ${applicants.length} applicants`); + + // TODO: UNcomment after the migration shape is updated. + // Create test applications + // console.log('📝 Creating test applications...'); + // const applications = await dataSource.getRepository(Application).save([ + // { + // email: 'john.doe@example.com', + // discipline: DISCIPLINE_VALUES.RN, + // appStatus: AppStatus.APP_SUBMITTED, + // mondayAvailability: '9am-5pm', + // tuesdayAvailability: 'Not available', + // wednesdayAvailability: '9am-5pm', + // thursdayAvailability: 'Not available', + // fridayAvailability: '9am-5pm', + // saturdayAvailability: 'Not available', + // experienceType: ExperienceType.BS, + // resume: 'john_doe_resume.pdf', + // coverLetter: 'john_doe_cover_letter.pdf', + // interest: InterestArea.PRIMARY_CARE, + // license: 'RN-12345', + // phone: '555-123-4567', + // applicantType: ApplicantType.LEARNER, + // school: School.HARVARD_MEDICAL_SCHOOL, + // referred: false, + // weeklyHours: 20, + // pronouns: 'he/him', + // desiredExperience: 'I want to gain clinical experience in primary care settings', + // emergencyContactName: 'Jane Doe', + // emergencyContactPhone: '555-123-4568', + // emergencyContactRelationship: 'Spouse', + // }, + // { + // email: 'jane.smith@example.com', + // discipline: DISCIPLINE_VALUES.MD_MedicalStudent_PreMed, + // appStatus: AppStatus.IN_REVIEW, + // mondayAvailability: 'Not available', + // tuesdayAvailability: '10am-2pm', + // wednesdayAvailability: 'Not available', + // thursdayAvailability: '10am-2pm', + // fridayAvailability: 'Not available', + // saturdayAvailability: 'Not available', + // experienceType: ExperienceType.MD, + // resume: 'jane_smith_resume.pdf', + // coverLetter: 'jane_smith_cover_letter.pdf', + // interest: InterestArea.MEDICAL_RESPITE_INPATIENT, + // license: 'MD-67890', + // phone: '555-987-6543', + // applicantType: ApplicantType.VOLUNTEER, + // school: School.STANFORD_MEDICINE, + // referred: true, + // referredEmail: 'referrer@example.com', + // weeklyHours: 15, + // pronouns: 'she/her', + // desiredExperience: 'I want to provide healthcare services to underserved women', + // emergencyContactName: 'John Smith', + // emergencyContactPhone: '555-987-6544', + // emergencyContactRelationship: 'Partner', + // }, + // { + // email: 'bob.wilson@example.com', + // discipline: DISCIPLINE_VALUES.PublicHealth, + // appStatus: AppStatus.ACCEPTED, + // mondayAvailability: '8am-6pm', + // tuesdayAvailability: '8am-6pm', + // wednesdayAvailability: '8am-6pm', + // thursdayAvailability: '8am-6pm', + // fridayAvailability: '8am-6pm', + // saturdayAvailability: 'Not available', + // experienceType: ExperienceType.MS, + // resume: 'bob_wilson_resume.pdf', + // coverLetter: 'bob_wilson_cover_letter.pdf', + // interest: InterestArea.ADDICTION_MEDICINE, + // license: 'PH-11111', + // phone: '555-555-5555', + // applicantType: ApplicantType.VOLUNTEER, + // school: School.OTHER, + // otherSchool: 'Boston University', + // referred: false, + // weeklyHours: 25, + // pronouns: 'they/them', + // desiredExperience: 'I want to work in harm reduction and addiction medicine', + // emergencyContactName: 'Alice Wilson', + // emergencyContactPhone: '555-555-5556', + // emergencyContactRelationship: 'Sibling', + // }, + // ]); + // console.log(`✅ Created ${applications.length} test applications`); + + // 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/apps/backend/src/seeds/user-admin-seed-info.txt b/apps/backend/src/seeds/user-admin-seed-info.txt new file mode 100644 index 000000000..6d8160fd1 --- /dev/null +++ b/apps/backend/src/seeds/user-admin-seed-info.txt @@ -0,0 +1,66 @@ +IMPORT THIS: + +''' +import { Admin } from '../users/admin.entity'; +import { User } from '../users/user.entity'; +import { Status } from '../users/types'; +''' + +Put this after where the applications are created/seeded +''' +const adminRepo = dataSource.getRepository(Admin); + const userRepo = dataSource.getRepository(User); + + // Clear existing data + await adminRepo.clear(); + await userRepo.clear(); + + // Seed Admins + const admins = await adminRepo.save([ + { + firstName: 'Jane', + lastName: 'Doe', + email: 'jane.doe@northeastern.edu', + discipline: DISCIPLINE_VALUES.RN, + }, + { + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@northeastern.edu', + discipline: DISCIPLINE_VALUES.PublicHealth, + }, + { + firstName: 'Alice', + lastName: 'Johnson', + email: 'alice.johnson@northeastern.edu', + discipline: DISCIPLINE_VALUES.SocialWork, + }, + ]); + console.log(`✅ Seeded ${admins.length} admins`); + + // Seed Users + const users = await userRepo.save([ + { + appId: 1, + status: Status.ADMIN, + firstName: 'Jane', + lastName: 'Doe', + email: 'jane.doe@northeastern.edu', + }, + { + appId: 2, + status: Status.STANDARD, + firstName: 'John', + lastName: 'Smith', + email: 'john.smith@northeastern.edu', + }, + { + appId: 3, + status: Status.STANDARD, + firstName: 'Bob', + lastName: 'Wilson', + email: 'bob.wilson@northeastern.edu', + }, + ]); + console.log(`✅ Seeded ${users.length} users`); +''' \ No newline at end of file diff --git a/apps/backend/src/testing/factories/applicant.factory.ts b/apps/backend/src/testing/factories/applicant.factory.ts new file mode 100644 index 000000000..f07f5c03d --- /dev/null +++ b/apps/backend/src/testing/factories/applicant.factory.ts @@ -0,0 +1,17 @@ +import merge from 'lodash/merge'; + +import { Applicant } from '../../applicants/applicant.entity'; + +export const defaultApplicant: Applicant = { + appId: 1, + firstName: 'Jane', + lastName: 'Doe', + startDate: new Date('2024-01-01'), + endDate: new Date('2024-06-30'), +}; + +export const applicantFactory = ( + applicant: Partial = {}, +): Applicant => merge({}, defaultApplicant, applicant); + +export default applicantFactory; diff --git a/apps/backend/src/users/admin.entity.ts b/apps/backend/src/users/admin.entity.ts new file mode 100644 index 000000000..306408a11 --- /dev/null +++ b/apps/backend/src/users/admin.entity.ts @@ -0,0 +1,63 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; + +/** + * Represents the desired columns for the database table + * in the repository for the system's admins. + */ +@Entity('admins') +export class Admin { + /** + * Autogenerated admin Id. + */ + @PrimaryGeneratedColumn() + id: number; + + /** + * First name of the admin. + */ + @Column() + firstName: string; + + /** + * Last name of the admin. + */ + @Column() + lastName: string; + + /** + * Email of the admin. + * + * Example: 'jane.doe@northeastern.edu'. + */ + @Column({ unique: true }) + email: string; + + /** + * Discipline of the admin. + */ + @Column({ type: 'enum', enum: DISCIPLINE_VALUES }) + discipline: DISCIPLINE_VALUES; + + /** + * When the admin was created stored in YYYY-MM-DD format. + * + * Example: new Date('2025-01-30'). + */ + @CreateDateColumn({ type: 'timestamp' }) + createdAt: Date; + + /** + * When the admin was last updated stored in YYYY-MM-DD format. + * + * Example: new Date('2025-01-30'). + */ + @UpdateDateColumn({ type: 'timestamp' }) + updatedAt: Date; +} diff --git a/apps/backend/src/users/admins.controller.ts b/apps/backend/src/users/admins.controller.ts new file mode 100644 index 000000000..b25af941f --- /dev/null +++ b/apps/backend/src/users/admins.controller.ts @@ -0,0 +1,92 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + ParseIntPipe, + UseInterceptors, +} from '@nestjs/common'; +import { AdminsService } from './admins.service'; +import { Admin } from './admin.entity'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { CreateAdminDto } from './dtos/create-admin.dto'; +import { UpdateAdminEmailDto } from './dtos/update-admin-email.dto'; + +/** + * Controller to expose callable HTTP endpoints to interface + * extract, and change information about the app's admins. + */ +@Controller('admins') +@UseInterceptors(CurrentUserInterceptor) // Apply authentication to all routes +export class AdminsController { + constructor(private readonly adminsService: AdminsService) {} + + /** + * Exposes an endpoint to create an admin in the system. + * @param createAdminDto object containing all of the necessary fields to create an admin. + * @returns the new admin object. + * @throws {Error} anything that the repository throws. + */ + @Post() + async create(@Body() createAdminDto: CreateAdminDto): Promise { + return await this.adminsService.create(createAdminDto); + } + + /** + * Exposes an endpoint to return an admin's information by their Id. + * @param id the id of the desired admin. + * @returns the admin with the desired Id. + * @throws {Error} anything that the repository throws. + * @throws {NotFoundException} if an admin with the + * desired id does not exist in the system. + */ + @Get(':id') + async findOne(@Param('id', ParseIntPipe) id: number): Promise { + return await this.adminsService.findOne(id); + } + + /** + * Exposes an endpoint to return an admin's information by their email. + * @param email the email of the desired admin. + * @returns the admin with the desired email, + * or null if an admin with the specified email does not exist in the system. + * @throws {Error} anything that the repository throws. + */ + @Get('email/:email') + async findByEmail(@Param('email') email: string): Promise { + return await this.adminsService.findByEmail(email); + } + + /** + * Exposes an endpoint to update an admin's email. + * @param id the id fo the desired admin to update. + * @param updateEmailDto object containing the new email to update to. + * @returns the new admin object. + * @throws {Error} anything that the repository throws. + */ + @Patch(':id/email') + async updateEmail( + @Param('id', ParseIntPipe) id: number, + @Body() updateEmailDto: UpdateAdminEmailDto, + ): Promise { + return await this.adminsService.updateEmail(id, updateEmailDto); + } + + /** + * Exposes an endpoint to delete an admin by id + * @param id the id of the admin to be deleted + * @returns object with a message containing 'Admin with ID has been deleted' + * @throws {Error} anything that the repository throws + */ + @Delete(':id') + async remove( + @Param('id', ParseIntPipe) id: number, + ): Promise<{ message: string }> { + await this.adminsService.remove(id); + return { message: `Admin with ID ${id} has been deleted` }; + } +} diff --git a/apps/backend/src/users/admins.module.ts b/apps/backend/src/users/admins.module.ts new file mode 100644 index 000000000..66da5efbd --- /dev/null +++ b/apps/backend/src/users/admins.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AdminsService } from './admins.service'; +import { AdminsController } from './admins.controller'; +import { Admin } from './admin.entity'; +import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [AuthModule, TypeOrmModule.forFeature([Admin])], + controllers: [AdminsController], + providers: [AdminsService], + exports: [AdminsService], +}) +export class AdminsModule {} diff --git a/apps/backend/src/users/admins.service.spec.ts b/apps/backend/src/users/admins.service.spec.ts new file mode 100644 index 000000000..e289b6f9e --- /dev/null +++ b/apps/backend/src/users/admins.service.spec.ts @@ -0,0 +1,360 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotFoundException } from '@nestjs/common'; +import { AdminsService } from './admins.service'; +import { CreateAdminDto } from './dtos/create-admin.dto'; +import { UpdateAdminEmailDto } from './dtos/update-admin-email.dto'; +import { Admin } from './admin.entity'; +import { DISCIPLINE_VALUES } from '../disciplines/disciplines.constants'; + +describe('AdminsService', () => { + let service: AdminsService; + let repository: Repository; + + const mockRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + remove: jest.fn(), + }; + + const mockAdmin: Admin = { + id: 1, + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + discipline: DISCIPLINE_VALUES.RN, + createdAt: new Date('2025-01-01'), + updatedAt: new Date('2025-01-01'), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminsService, + { + provide: getRepositoryToken(Admin), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(AdminsService); + repository = module.get>(getRepositoryToken(Admin)); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('create', () => { + it('should create and save a new admin', async () => { + const createAdminDto: CreateAdminDto = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + discipline: DISCIPLINE_VALUES.RN, + }; + + mockRepository.create.mockReturnValue(mockAdmin); + mockRepository.save.mockResolvedValue(mockAdmin); + + const result = await service.create(createAdminDto); + + expect(mockRepository.create).toHaveBeenCalledWith(createAdminDto); + expect(mockRepository.save).toHaveBeenCalledWith(mockAdmin); + expect(result).toEqual(mockAdmin); + }); + + it('should handle repository errors during creation', async () => { + const createAdminDto: CreateAdminDto = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + discipline: DISCIPLINE_VALUES.RN, + }; + + mockRepository.create.mockReturnValue(mockAdmin); + mockRepository.save.mockRejectedValueOnce(new Error('Database error')); + + await expect(service.create(createAdminDto)).rejects.toThrow( + 'Database error', + ); + }); + it('should pass along any repo errors without information loss during create', async () => { + const createAdminDto: CreateAdminDto = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + discipline: DISCIPLINE_VALUES.RN, + }; + + mockRepository.create.mockImplementationOnce(() => { + throw new Error('There was a problem creating the entry'); + }); + + await expect(service.create(createAdminDto)).rejects.toThrow( + 'There was a problem creating the entry', + ); + }); + + it('should pass along any repo errors without information loss during save', async () => { + const createAdminDto: CreateAdminDto = { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + discipline: DISCIPLINE_VALUES.RN, + }; + + mockRepository.create.mockReturnValue(mockAdmin); + mockRepository.save.mockRejectedValueOnce( + new Error('There was a problem saving the entry'), + ); + + await expect(service.create(createAdminDto)).rejects.toThrow( + 'There was a problem saving the entry', + ); + }); + }); + + describe('findAll', () => { + it('should return an array of admins', async () => { + const mockAdmins = [ + mockAdmin, + { ...mockAdmin, id: 2, email: 'jane@example.com' }, + ]; + mockRepository.find.mockResolvedValueOnce(mockAdmins); + + const result = await service.findAll(); + + expect(mockRepository.find).toHaveBeenCalled(); + expect(result).toEqual(mockAdmins); + }); + + it('should return empty array when no admins exist', async () => { + mockRepository.find.mockResolvedValueOnce([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.find.mockRejectedValueOnce( + new Error('There was a problem retrieving the entries'), + ); + + await expect(service.findAll()).rejects.toThrow( + 'There was a problem retrieving the entries', + ); + }); + }); + + describe('findOne', () => { + it('should return an admin by id', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + + const result = await service.findOne(1); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(result).toEqual(mockAdmin); + }); + + it('should throw NotFoundException when admin not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.findOne(999)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.findOne.mockRejectedValueOnce( + new Error('There was a problem retrieving the entry'), + ); + + await expect(service.findOne(1)).rejects.toThrow( + 'There was a problem retrieving the entry', + ); + }); + }); + + describe('findByEmail', () => { + it('should return an admin by email', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + + const result = await service.findByEmail('john@example.com'); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { email: 'john@example.com' }, + }); + expect(result).toEqual(mockAdmin); + }); + + it('should return null when admin not found by email', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.findByEmail('notfound@example.com'); + + expect(result).toBeNull(); + }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.findOne.mockImplementationOnce(() => { + throw new Error('There was a problem retrieving the entries'); + }); + + await expect(service.findByEmail('n')).rejects.toThrow( + 'There was a problem retrieving the entries', + ); + }); + }); + + describe('updateEmail', () => { + it('should update admin email and return the admin', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'newemail@example.com', + }; + + const updatedAdmin = { ...mockAdmin, email: 'newemail@example.com' }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(updatedAdmin); + + const result = await service.updateEmail(1, updateEmailDto); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(mockRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ email: 'newemail@example.com' }), + ); + expect(result.email).toBe('newemail@example.com'); + expect(result.firstName).toBe(mockAdmin.firstName); // Should remain unchanged + expect(result.lastName).toBe(mockAdmin.lastName); // Should remain unchanged + }); + + it('should throw NotFoundException when admin not found for email update', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'newemail@example.com', + }; + + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.updateEmail(999, updateEmailDto)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + + it('should handle email validation', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', + }; + + const updatedAdmin = { ...mockAdmin, email: 'valid@example.com' }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(updatedAdmin); + + const result = await service.updateEmail(1, updateEmailDto); + + expect(result.email).toBe('valid@example.com'); + }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', + }; + + mockRepository.findOne.mockRejectedValueOnce( + new Error('There was a problem retrieving the entry'), + ); + + await expect(service.updateEmail(1, updateEmailDto)).rejects.toThrow( + 'There was a problem retrieving the entry', + ); + }); + + it('should pass along any repo errors without information loss during saving', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'valid@example.com', + }; + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockRejectedValueOnce( + new Error('There was a problem saving the entry'), + ); + await expect(service.updateEmail(1, updateEmailDto)).rejects.toThrow( + 'There was a problem saving the entry', + ); + }); + }); + + describe('remove', () => { + it('should remove an admin', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.remove.mockResolvedValue(mockAdmin); + + await service.remove(1); + + expect(mockRepository.findOne).toHaveBeenCalledWith({ where: { id: 1 } }); + expect(mockRepository.remove).toHaveBeenCalledWith(mockAdmin); + }); + + it('should throw NotFoundException when admin not found for removal', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect(service.remove(999)).rejects.toThrow( + new NotFoundException('Admin with ID 999 not found'), + ); + }); + + it('should pass along any repo errors without information loss during retrieval', async () => { + mockRepository.findOne.mockRejectedValueOnce( + new Error('There was a problem retrieving the entry'), + ); + + await expect(service.remove(1)).rejects.toThrow( + 'There was a problem retrieving the entry', + ); + }); + + it('should pass along any repo errors without information loss during saving', async () => { + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.remove.mockRejectedValueOnce( + new Error('There was a problem saving the entry'), + ); + await expect(service.remove(1)).rejects.toThrow( + 'There was a problem saving the entry', + ); + }); + }); + + describe('edge cases', () => { + it('should handle database connection errors', async () => { + mockRepository.find.mockRejectedValue(new Error('Connection failed')); + + await expect(service.findAll()).rejects.toThrow('Connection failed'); + }); + + it('should handle invalid email format in findByEmail', async () => { + mockRepository.findOne.mockResolvedValue(null); + + const result = await service.findByEmail('invalid-email'); + + expect(result).toBeNull(); + }); + + it('should handle email update with same email', async () => { + const updateEmailDto: UpdateAdminEmailDto = { + email: 'john@example.com', + }; + + mockRepository.findOne.mockResolvedValue(mockAdmin); + mockRepository.save.mockResolvedValue(mockAdmin); + + const result = await service.updateEmail(1, updateEmailDto); + + expect(result.email).toBe('john@example.com'); + }); + }); +}); diff --git a/apps/backend/src/users/admins.service.ts b/apps/backend/src/users/admins.service.ts new file mode 100644 index 000000000..069149942 --- /dev/null +++ b/apps/backend/src/users/admins.service.ts @@ -0,0 +1,92 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Admin } from './admin.entity'; +import { CreateAdminDto } from './dtos/create-admin.dto'; +import { UpdateAdminEmailDto } from './dtos/update-admin-email.dto'; + +/** + * Service to interface with the admin repository. + */ +@Injectable() +export class AdminsService { + constructor( + @InjectRepository(Admin) + private readonly adminRepository: Repository, + ) {} + + /** + * Creates an admin in the system. + * @param createAdminDto object containing all of the necessary fields to create an admin. + * @returns the new admin object. + * @throws {Error} anything that the repository throws. + */ + async create(createAdminDto: CreateAdminDto): Promise { + const admin = this.adminRepository.create(createAdminDto); + return await this.adminRepository.save(admin); + } + + /** + * Returns all admins in the system. + * @returns a list of admin objects. + * @throws {Error} anything that the repository throws. + */ + async findAll(): Promise { + return await this.adminRepository.find(); + } + + /** + * Returns an admin's information by their id. + * @param id the id of the desired admin. + * @returns the admin with the desired id. + * @throws {Error} anything that the repository throws. + * @throws {NotFoundException} if an admin with the + * desired id does not exist in the system. + */ + async findOne(id: number): Promise { + const admin = await this.adminRepository.findOne({ where: { id } }); + if (!admin) { + throw new NotFoundException(`Admin with ID ${id} not found`); + } + return admin; + } + + /** + * Returns an admin's information by their email. + * @param email the email of the desired admin. + * @returns the admin with the desired email, + * or null if an admin with the specified email does not exist in the system. + * @throws {Error} anything that the repository throws. + */ + async findByEmail(email: string): Promise { + return await this.adminRepository.findOne({ where: { email } }); + } + + /** + * Updates admin's email. + * @param id the id fo the desired admin to update. + * @param updateEmailDto object containing the new email to update to. + * @returns the new admin object. + * @throws {Error} anything that the repository throws. + */ + async updateEmail( + id: number, + updateEmailDto: UpdateAdminEmailDto, + ): Promise { + const admin = await this.findOne(id); + admin.email = updateEmailDto.email; + return await this.adminRepository.save(admin); + } + + /** + * Deletes an admin by Id. + * @param id the id of the admin to be deleted. + * @throws {Error} anything that the repository throws. + * + * Does not return a value. + */ + async remove(id: number): Promise { + const admin = await this.findOne(id); + await this.adminRepository.remove(admin); + } +} diff --git a/apps/backend/src/users/dtos/create-admin.dto.ts b/apps/backend/src/users/dtos/create-admin.dto.ts new file mode 100644 index 000000000..b1cdb7abc --- /dev/null +++ b/apps/backend/src/users/dtos/create-admin.dto.ts @@ -0,0 +1,51 @@ +import { + IsDefined, + IsEmail, + IsEnum, + IsNotEmpty, + IsString, +} from 'class-validator'; +import { DISCIPLINE_VALUES } from '../../disciplines/disciplines.constants'; + +// TODO: Add class validators + +/** + * Defines the expected shape of data for creating a new admin. + */ +export class CreateAdminDto { + /** + * The first name of the admin to create. + * + * Example: 'Jane Doe'. + */ + @IsString() + @IsNotEmpty() + firstName: string; + + /** + * The last name of the admin to create. + * + * Example: 'Doe'. + */ + @IsString() + @IsNotEmpty() + lastName: string; + + /** + * The email of the admin to create. + * + * Example: 'jane.doe@northeastern.edu'. + */ + @IsEmail() + @IsNotEmpty() + email: string; + + /** + * The discipline of the admin to create. + * + * Example: DISCIPLINE_VALUES.Nursing. + */ + @IsEnum(DISCIPLINE_VALUES) + @IsNotEmpty() + discipline: DISCIPLINE_VALUES; +} diff --git a/apps/backend/src/users/dtos/update-admin-email.dto.ts b/apps/backend/src/users/dtos/update-admin-email.dto.ts new file mode 100644 index 000000000..f456a051d --- /dev/null +++ b/apps/backend/src/users/dtos/update-admin-email.dto.ts @@ -0,0 +1,15 @@ +import { IsEmail, IsNotEmpty } from 'class-validator'; + +/** + * Defines the expected shape of data for updating an admin's email. + */ +export class UpdateAdminEmailDto { + /** + * The new email to change to. + * + * Example: 'jane.doe@northeastern.edu'. + */ + @IsEmail() + @IsNotEmpty() + email: string; +} diff --git a/apps/backend/src/users/types.ts b/apps/backend/src/users/types.ts index dd9a359b9..825a0a658 100644 --- a/apps/backend/src/users/types.ts +++ b/apps/backend/src/users/types.ts @@ -1,4 +1,18 @@ +/** + * Status or level of the user (currently admin or standard). + */ export enum Status { ADMIN = 'ADMIN', STANDARD = 'STANDARD', } + +/** + * Sites the user belongs to (and for admins, sites they administrate). + */ +export enum Site { + FENWAY = 'fenway', + SITE_A = 'site_a', + // Add more sites as needed + // SITE_B = 'site_b', + // SITE_C = 'site_c', +} diff --git a/apps/backend/src/users/user.entity.ts b/apps/backend/src/users/user.entity.ts index 3224019c2..b99a7c8f8 100644 --- a/apps/backend/src/users/user.entity.ts +++ b/apps/backend/src/users/user.entity.ts @@ -2,20 +2,47 @@ import { Entity, Column, ObjectIdColumn, ObjectId } from 'typeorm'; import type { Status } from './types'; +/** + * Represents the desired columns for the database table + * in the repository for the system's users. + */ @Entity() export class User { + /** + * Not autogenerated id of the user. + */ @Column({ primary: true }) - id: number; + appId: number; + /** + * Status or level of the user, currently either standard or admin. + * + * Example: Status.Admin. + */ @Column() status: Status; + /** + * First name of the user. + * + * Example: 'Jane'. + */ @Column() firstName: string; + /** + * Last name of the user. + * + * Example: 'Doe'. + */ @Column() lastName: string; + /** + * Email of the user. + * + * Example: 'jane.doe@northeastern.edu'. + */ @Column() email: string; } diff --git a/apps/backend/src/users/users.controller.ts b/apps/backend/src/users/users.controller.ts index 4d0f9e827..6a9a9f6d9 100644 --- a/apps/backend/src/users/users.controller.ts +++ b/apps/backend/src/users/users.controller.ts @@ -13,6 +13,10 @@ import { User } from './user.entity'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +/** + * Controller to expose callable HTTP endpoints to + * extract information about the app's users or delete them. + */ @ApiTags('Users') @ApiBearerAuth() @Controller('users') @@ -21,13 +25,27 @@ import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; export class UsersController { constructor(private usersService: UsersService) {} + /** + * Exposes an endpoint to get a user's information by their id. + * @param userId the id of the desired user to get information about. + * @returns the user with the corresponding id or null if the user was not found. + * @throws {Error} anything that the repository throws. + */ @Get('/:userId') async getUser(@Param('userId', ParseIntPipe) userId: number): Promise { return this.usersService.findOne(userId); } - @Delete('/:id') - removeUser(@Param('id') id: string) { - return this.usersService.remove(parseInt(id)); + /** + * Exposes an endpoint to delete a user by their id. + * @param appId the id of the user to delete. + * @throws {Error} anything that the repository throws. + * @throws {NotFoundException} if a user with the specified id does not exist. + * + * Does not return a value. + */ + @Delete('/:appId') + removeUser(@Param('appId', ParseIntPipe) appId: number) { + return this.usersService.remove(appId); } } diff --git a/apps/backend/src/users/users.module.ts b/apps/backend/src/users/users.module.ts index 577c23718..87c07dd31 100644 --- a/apps/backend/src/users/users.module.ts +++ b/apps/backend/src/users/users.module.ts @@ -6,10 +6,12 @@ import { User } from './user.entity'; import { JwtStrategy } from '../auth/jwt.strategy'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthService } from '../auth/auth.service'; +import { AuthModule } from '../auth/auth.module'; // Add this import @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [AuthModule, TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService, AuthService, JwtStrategy, CurrentUserInterceptor], + exports: [UsersService], }) export class UsersModule {} diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index 018a76785..e296fb1e3 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -1,14 +1,25 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; - import { User } from './user.entity'; import { Status } from './types'; +/** + * Service to interface with the user repository. + */ @Injectable() export class UsersService { constructor(@InjectRepository(User) private repo: Repository) {} + /** + * Creates a user in the repository. + * @param email Email of the user to create. + * @param firstName First name of the user to create. + * @param lastName Last name of the user to create. + * @param status Status or position of the user to create (e.g. standard or admin). + * @returns The created user. + * @throws {Error} If the repository throws an error. + */ async create( email: string, firstName: string, @@ -17,7 +28,7 @@ export class UsersService { ) { const userId = (await this.repo.count()) + 1; const user = this.repo.create({ - id: userId, + appId: userId, status, firstName, lastName, @@ -27,20 +38,40 @@ export class UsersService { return this.repo.save(user); } - findOne(id: number) { - if (!id) { + /** + * Returns the data of a user from the repository by id. + * @param appId The application id of the user to find. + * @returns The desired user if they exist. + * @throws {Error} If the repository throws an error. + */ + findOne(appId: number) { + if (!appId) { return null; } - return this.repo.findOneBy({ id }); + return this.repo.findOneBy({ appId }); } + /** + * Returns the data of a user from the repository by email. + * @param email The email of the user to find. + * @returns The desired user if they exist. + * @throws {Error} If the repository throws an error. + */ find(email: string) { return this.repo.find({ where: { email } }); } - async update(id: number, attrs: Partial) { - const user = await this.findOne(id); + /** + * Updates a user by id with any desired new field values. + * @param appId The id of the user to update. + * @param attrs Any desired new field values to apply. + * @returns The updated user. + * @throws {NotFoundException} if a user of the specified id doesn't exist in the repository. + * @throws {Error} Also throws any error the repository throws. + */ + async update(appId: number, attrs: Partial) { + const user = await this.findOne(appId); if (!user) { throw new NotFoundException('User not found'); @@ -51,8 +82,16 @@ export class UsersService { return this.repo.save(user); } - async remove(id: number) { - const user = await this.findOne(id); + /** + * Removes a user by id. + * @param appId The id of the user to delete. + * @throws {NotFoundException} if a user of the specified id doesn't exist in the repository. + * @throws {Error} Also throws any error the repository throws. + * + * Does not return a value. + */ + async remove(appId: number) { + const user = await this.findOne(appId); if (!user) { throw new NotFoundException('User not found'); diff --git a/apps/backend/src/util/email/amazon-ses-client.factory.ts b/apps/backend/src/util/email/amazon-ses-client.factory.ts new file mode 100644 index 000000000..0222f3b63 --- /dev/null +++ b/apps/backend/src/util/email/amazon-ses-client.factory.ts @@ -0,0 +1,34 @@ +import { Provider } from '@nestjs/common'; +import { SESClient } from '@aws-sdk/client-ses'; +import { assert } from 'console'; +import * as dotenv from 'dotenv'; +dotenv.config(); + +export const AMAZON_SES_CLIENT = 'AMAZON_SES_CLIENT'; + +/** + * Factory that produces a new instance of the Amazon SES client. + * Used to send emails via Amazon SES. + */ +export const amazonSESClientFactory: Provider = { + provide: AMAZON_SES_CLIENT, + useFactory: () => { + assert( + process.env.AWS_ACCESS_KEY_ID !== undefined, + 'AWS_ACCESS_KEY_ID is not defined', + ); + assert( + process.env.AWS_SECRET_ACCESS_KEY !== undefined, + 'AWS_SECRET_ACCESS_KEY is not defined', + ); + assert(process.env.AWS_REGION !== undefined, 'AWS_REGION is not defined'); + + return new SESClient({ + region: process.env.AWS_REGION, + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + }, + }); + }, +}; diff --git a/apps/backend/src/util/email/amazon-ses.wrapper.ts b/apps/backend/src/util/email/amazon-ses.wrapper.ts new file mode 100644 index 000000000..a173c63ee --- /dev/null +++ b/apps/backend/src/util/email/amazon-ses.wrapper.ts @@ -0,0 +1,85 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + SESClient, + SendRawEmailCommand, + SendRawEmailCommandInput, +} from '@aws-sdk/client-ses'; +import { AMAZON_SES_CLIENT } from './amazon-ses-client.factory'; +import MailComposer = require('nodemailer/lib/mail-composer'); +import * as dotenv from 'dotenv'; +import Mail from 'nodemailer/lib/mailer'; +dotenv.config(); + +/** + * Defines an email attachment. + */ +export interface EmailAttachment { + /** + * Name of the file. + * + * TODO: Clarify whether this includes the file extension + */ + filename: string; + + /** + * Buffer or in-memory data representation of the file. + */ + content: Buffer; +} + +/** + * Interfaces with Amazon SES to manage email sending. + */ +@Injectable() +export class AmazonSESWrapper { + private client: SESClient; + + /** + * @param client injected from `amazon-ses-client.factory.ts` + */ + constructor(@Inject(AMAZON_SES_CLIENT) client: SESClient) { + this.client = client; + } + + /** + * Sends an email via Amazon SES. + * + * @param recipientEmails the email addresses of the recipients. + * @param subject the subject of the email. + * @param bodyHtml the HTML body of the email. + * @param attachments any base64 encoded attachments to inlude in the email. + * @resolves if the email was sent successfully. + * @rejects if the email was not sent successfully. + */ + async sendEmails( + recipientEmails: string[], + subject: string, + bodyHtml: string, + attachments?: EmailAttachment[], + ) { + const mailOptions: Mail.Options = { + from: process.env.AWS_SES_SENDER_EMAIL, + to: recipientEmails, + subject: subject, + html: bodyHtml, + }; + + if (attachments) { + mailOptions.attachments = attachments.map((a) => ({ + filename: a.filename, + content: a.content, + encoding: 'base64', + })); + } + + const messageData = await new MailComposer(mailOptions).compile().build(); + + const params: SendRawEmailCommandInput = { + Destinations: recipientEmails, + Source: process.env.AWS_SES_SENDER_EMAIL, + RawMessage: { Data: messageData }, + }; + + return await this.client.send(new SendRawEmailCommand(params)); + } +} diff --git a/apps/backend/src/util/email/dto/send-email.dto.ts b/apps/backend/src/util/email/dto/send-email.dto.ts new file mode 100644 index 000000000..439f2ff71 --- /dev/null +++ b/apps/backend/src/util/email/dto/send-email.dto.ts @@ -0,0 +1,33 @@ +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { EmailAttachment } from '../amazon-ses.wrapper'; + +export class SendEmailDto { + /** + * The email address of the recipient. + */ + @IsString() + @IsNotEmpty() + @MaxLength(255) + to: string; + + /** + * The subject of the email. + */ + @IsString() + @IsNotEmpty() + @MaxLength(255) + subject: string; + + /** + * The body content of the email. + */ + @IsString() + @IsNotEmpty() + body: string; + + /** + * Optional attachments for the email. + */ + @IsOptional() + attachments?: EmailAttachment[]; +} diff --git a/apps/backend/src/util/email/email.controller.ts b/apps/backend/src/util/email/email.controller.ts new file mode 100644 index 000000000..338ea16d8 --- /dev/null +++ b/apps/backend/src/util/email/email.controller.ts @@ -0,0 +1,25 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { EmailService } from './email.service'; +import { SendEmailDto } from './dto/send-email.dto'; + +/** + * Controller to expose callable HTTP endpoints to + * manage email communications. + */ +@Controller('email') +export class EmailController { + constructor(private emailService: EmailService) {} + + /** + * Exposes an endpoint to send an email to a recipient. + * @param body object optionally containing information about the + * contents of the email, and the recipient. + * @returns object containing message 'Email queued' + */ + @Post('send') + async sendEmail(@Body() sendEmailDTO: SendEmailDto) { + const { to, subject, body, attachments } = sendEmailDTO; + await this.emailService.queueEmail(to, subject, body, attachments); + return { message: 'Email queued' }; + } +} diff --git a/apps/backend/src/util/email/email.service.spec.ts b/apps/backend/src/util/email/email.service.spec.ts new file mode 100644 index 000000000..e84b508af --- /dev/null +++ b/apps/backend/src/util/email/email.service.spec.ts @@ -0,0 +1,46 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AmazonSESWrapper } from './amazon-ses.wrapper'; +import { EmailService } from './email.service'; +import { mock } from 'jest-mock-extended'; + +const mockAmazonSESWrapper = mock(); + +describe('EmailService', () => { + let service: EmailService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + EmailService, + { + provide: AmazonSESWrapper, + useValue: mockAmazonSESWrapper, + }, + ], + }).compile(); + + service = module.get(EmailService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should send an email by calling SES', async () => { + mockAmazonSESWrapper.sendEmails.mockResolvedValue({ + MessageId: 'test', + $metadata: {}, + }); + await service.queueEmail('recipient@email.com', 'Subject', '

body

'); + expect(mockAmazonSESWrapper.sendEmails).toHaveBeenCalled(); + }); + + it('should throw an error and pass on information with no loss if the SESWrapper throws', async () => { + mockAmazonSESWrapper.sendEmails.mockRejectedValueOnce( + new Error('Error in sending email.'), + ); + await expect( + service.queueEmail('recipient@email.com', 'Subject', '

body

'), + ).rejects.toThrow('Error in sending email.'); + }); +}); diff --git a/apps/backend/src/util/email/email.service.ts b/apps/backend/src/util/email/email.service.ts new file mode 100644 index 000000000..0fc7aac08 --- /dev/null +++ b/apps/backend/src/util/email/email.service.ts @@ -0,0 +1,68 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { AmazonSESWrapper, EmailAttachment } from './amazon-ses.wrapper'; + +/** + * Interfaces with a service that interfaces with Amazon SES to manage email sending. + */ +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + constructor(private amazonSESWrapper: AmazonSESWrapper) {} + + /** + * Queues the email to be sent using the external email management service (AWS SES). + * + * @param recipientEmail the email address of the recipient. + * @param subject the subject of the email. + * @param bodyHTML the HTML body of the email. + * @param attachments optional attachments for the email. + */ + public async queueEmail( + recipientEmail: string, + subject: string, + bodyHTML: string, + attachments?: EmailAttachment[], + ): Promise { + try { + await this.sendEmail(recipientEmail, subject, bodyHTML, attachments); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to queue/send email to ${recipientEmail} (subject: ${subject})`, + err?.stack, + ); + throw error; + } + } + + /** + * Sends an email using the external email management service (AWS SES). + * + * @param recipientEmail the email address of the recipients. + * @param subject the subject of the email. + * @param bodyHtml the HTML body of the email. + * @param attachments optional attachments for the email. + */ + private async sendEmail( + recipientEmail: string, + subject: string, + bodyHTML: string, + attachments?: EmailAttachment[], + ): Promise { + try { + return await this.amazonSESWrapper.sendEmails( + [recipientEmail], + subject, + bodyHTML, + attachments, + ); + } catch (error) { + const err = error as Error; + this.logger.error( + `Failed to send email to ${recipientEmail} (subject: ${subject})`, + err?.stack, + ); + throw error; + } + } +} diff --git a/apps/backend/src/util/util.module.ts b/apps/backend/src/util/util.module.ts new file mode 100644 index 000000000..c7316b5cd --- /dev/null +++ b/apps/backend/src/util/util.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { amazonSESClientFactory } from './email/amazon-ses-client.factory'; +import { AmazonSESWrapper } from './email/amazon-ses.wrapper'; +import { EmailService } from './email/email.service'; +import { EmailController } from './email/email.controller'; + +@Module({ + providers: [EmailService, amazonSESClientFactory, AmazonSESWrapper], + exports: [EmailService], + controllers: [EmailController], +}) +export class UtilModule {} diff --git a/apps/backend/src/volunteer-info/dto/create-volunteer-info.request.dto.ts b/apps/backend/src/volunteer-info/dto/create-volunteer-info.request.dto.ts new file mode 100644 index 000000000..aaac91824 --- /dev/null +++ b/apps/backend/src/volunteer-info/dto/create-volunteer-info.request.dto.ts @@ -0,0 +1,28 @@ +import { + IsNumber, + IsDefined, + Min, + IsString, + IsNotEmpty, +} from 'class-validator'; + +/** + * Defines the expected shape of data for creating a volunter info + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class CreateVolunteerInfoDto { + /** + * The id corresponding to the application this information belongs to + */ + @IsNumber() + @Min(0) + @IsDefined() + appId!: number; + + // TODO: clarify what format this string is in, and why it's not an array + // if people can hold multiple licenses in real life + @IsString() + @IsNotEmpty() + license!: string; +} diff --git a/apps/backend/src/volunteer-info/volunteer-info.controller.spec.ts b/apps/backend/src/volunteer-info/volunteer-info.controller.spec.ts new file mode 100644 index 000000000..709b45b76 --- /dev/null +++ b/apps/backend/src/volunteer-info/volunteer-info.controller.spec.ts @@ -0,0 +1,92 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { VolunteerInfoController } from './volunteer-info.controller'; +import { VolunteerInfoService } from './volunteer-info.service'; +import { VolunteerInfo } from './volunteer-info.entity'; +import { CreateVolunteerInfoDto } from './dto/create-volunteer-info.request.dto'; +import { BadRequestException } from '@nestjs/common'; + +describe('VolunteerInfoController', () => { + let controller: VolunteerInfoController; + + const mockVolunteerInfoService = { + create: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [VolunteerInfoController], + providers: [ + { + provide: VolunteerInfoService, + useValue: mockVolunteerInfoService, + }, + { + provide: getRepositoryToken(VolunteerInfo), + useValue: {}, + }, + ], + }).compile(); + + controller = module.get(VolunteerInfoController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('POST /', () => { + it('should create a new volunteer info', async () => { + const createVolunteerInfo: CreateVolunteerInfoDto = { + appId: 0, + license: 'example', + }; + + mockVolunteerInfoService.create.mockResolvedValue( + createVolunteerInfo as VolunteerInfo, + ); + + // Call controller method + const result = await controller.createVolunteerInfo(createVolunteerInfo); + + // Verify results + expect(result).toEqual(createVolunteerInfo as VolunteerInfo); + expect(mockVolunteerInfoService.create).toHaveBeenCalledWith( + createVolunteerInfo, + ); + }); + + it('should pass along any service errors without information loss', async () => { + mockVolunteerInfoService.create.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + const createVolunteerInfoDto: CreateVolunteerInfoDto = { + appId: 0, + license: 'example', + }; + + await expect( + controller.createVolunteerInfo(createVolunteerInfoDto), + ).rejects.toThrow(new Error(`There was a problem retrieving the info`)); + }); + + it('should not accept negative appId', async () => { + const createVolunteerInfoDto: CreateVolunteerInfoDto = { + appId: 0, + license: 'example', + }; + + mockVolunteerInfoService.create.mockRejectedValue( + new BadRequestException('appId must not be negative'), + ); + + await expect( + controller.createVolunteerInfo(createVolunteerInfoDto), + ).rejects.toThrow(new BadRequestException(`appId must not be negative`)); + }); + }); +}); diff --git a/apps/backend/src/volunteer-info/volunteer-info.controller.ts b/apps/backend/src/volunteer-info/volunteer-info.controller.ts new file mode 100644 index 000000000..2e86904f4 --- /dev/null +++ b/apps/backend/src/volunteer-info/volunteer-info.controller.ts @@ -0,0 +1,27 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { VolunteerInfo } from './volunteer-info.entity'; +import { ApiTags } from '@nestjs/swagger'; +import { VolunteerInfoService } from './volunteer-info.service'; +import { CreateVolunteerInfoDto } from './dto/create-volunteer-info.request.dto'; + +/** + * Controller to expose HTTP endpoints to interface, extract, and change information about volunteer-specific application info. + */ +@ApiTags('volunteerInfo') +@Controller('volunteer_info') +export class VolunteerInfoController { + constructor(private volunteerInfoService: VolunteerInfoService) {} + + /** + * Exposes an endpoint to create a volunteer info. + * @param createvolunteerInfoDto The expected data required to create a volunteer specific info object + * @returns The newly created application. + * @throws {Error} which is unchanged from what repository throws. + */ + @Post() + async createVolunteerInfo( + @Body() createvolunteerInfoDto: CreateVolunteerInfoDto, + ): Promise { + return await this.volunteerInfoService.create(createvolunteerInfoDto); + } +} diff --git a/apps/backend/src/volunteer-info/volunteer-info.entity.ts b/apps/backend/src/volunteer-info/volunteer-info.entity.ts new file mode 100644 index 000000000..5e41624df --- /dev/null +++ b/apps/backend/src/volunteer-info/volunteer-info.entity.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryColumn } from 'typeorm'; + +/** + * Represents the desired columns for the database table in the repository for the system's volunteer info. + */ +@Entity('volunteer_info') +export class VolunteerInfo { + /** + * The id corresponding to the application this information belongs to + */ + @PrimaryColumn() + appId!: number; + + // TODO: clarify what format this string is in, and why it's not an array + // if people can hold multiple licenses in real life + @Column({ type: 'varchar' }) + license!: string; +} diff --git a/apps/backend/src/volunteer-info/volunteer-info.module.ts b/apps/backend/src/volunteer-info/volunteer-info.module.ts new file mode 100644 index 000000000..59475e2da --- /dev/null +++ b/apps/backend/src/volunteer-info/volunteer-info.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VolunteerInfoController } from './volunteer-info.controller'; +import { VolunteerInfoService } from './volunteer-info.service'; +import { VolunteerInfo } from './volunteer-info.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([VolunteerInfo])], + controllers: [VolunteerInfoController], + providers: [VolunteerInfoService], +}) +export class VolunteerInfoModule {} diff --git a/apps/backend/src/volunteer-info/volunteer-info.service.spec.ts b/apps/backend/src/volunteer-info/volunteer-info.service.spec.ts new file mode 100644 index 000000000..fc193a689 --- /dev/null +++ b/apps/backend/src/volunteer-info/volunteer-info.service.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { VolunteerInfoService } from './volunteer-info.service'; +import { VolunteerInfo } from './volunteer-info.entity'; + +describe('volunteerInfoService', () => { + let service: VolunteerInfoService; + let repository: Repository; + + const mockRepository = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + remove: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + VolunteerInfoService, + { + provide: getRepositoryToken(VolunteerInfo), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(VolunteerInfoService); + repository = module.get>( + getRepositoryToken(VolunteerInfo), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('create', () => { + it('should create and save a new volunteer info', async () => { + const volunteerInfo: VolunteerInfo = { + appId: 0, + license: 'example', + }; + + mockRepository.save.mockResolvedValue(volunteerInfo); + + const result = await service.create(volunteerInfo); + + expect(repository.save).toHaveBeenCalled(); + expect(result).toEqual(volunteerInfo); + }); + + it('should pass along any repo errors without information loss', async () => { + mockRepository.save.mockRejectedValue( + new Error('There was a problem retrieving the info'), + ); + const volunteerInfo: VolunteerInfo = { + appId: 0, + license: 'example', + }; + + await expect(service.create(volunteerInfo)).rejects.toThrow( + new Error(`There was a problem retrieving the info`), + ); + }); + + it('should not accept negative appId', async () => { + const volunteerInfo: VolunteerInfo = { + appId: -1, + license: 'example', + }; + + mockRepository.save.mockResolvedValue(volunteerInfo); + await expect(service.create(volunteerInfo)).rejects.toThrow(); + }); + }); +}); diff --git a/apps/backend/src/volunteer-info/volunteer-info.service.ts b/apps/backend/src/volunteer-info/volunteer-info.service.ts new file mode 100644 index 000000000..815f1fab6 --- /dev/null +++ b/apps/backend/src/volunteer-info/volunteer-info.service.ts @@ -0,0 +1,62 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { VolunteerInfo } from './volunteer-info.entity'; +import { CreateVolunteerInfoDto } from './dto/create-volunteer-info.request.dto'; + +/** + * Service for applications that interfaces with the application repository. + */ +@Injectable() +export class VolunteerInfoService { + constructor( + @InjectRepository(VolunteerInfo) + private volunteerInfoRepository: Repository, + ) {} + + /** + * Returns a volunteer info by appId from the repository. + * @param appId The desired volunteer info appId to search for. + * @returns A promise resolving to the volunteer info object with that appId. + * @throws {NotFoundException} with message 'volunteer Info with AppId not found' + * if an application with that id does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + async findById(appId: number): Promise { + const volunteerInfo: VolunteerInfo = + await this.volunteerInfoRepository.findOne({ + where: { appId }, + }); + + if (!volunteerInfo) { + throw new NotFoundException( + `volunteer Info with AppId ${appId} not found`, + ); + } + + return volunteerInfo; + } + + /** + * Creates a volunteer info in the repository. + * @param createvolunteerInfoDto The expected data required to create a volunteer specific info object + * @returns The newly created volunteer-specific information object. + * @throws {Error} which is unchanged from what repository throws. + * @throws {BadRequestException} if any fields are invalid + */ + async create( + createvolunteerInfoDto: CreateVolunteerInfoDto, + ): Promise { + if (createvolunteerInfoDto.appId < 0) { + throw new BadRequestException('appId must not be negative'); + } + const volunteerInfo = this.volunteerInfoRepository.create( + createvolunteerInfoDto, + ); + return await this.volunteerInfoRepository.save(volunteerInfo); + } +} diff --git a/apps/frontend/index.html b/apps/frontend/index.html index 47f54c98b..ee858c714 100644 --- a/apps/frontend/index.html +++ b/apps/frontend/index.html @@ -7,6 +7,9 @@ + + + diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index cfaf52dce..1a2c300ca 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -1,5 +1,35 @@ import axios, { type AxiosInstance } from 'axios'; +export interface AvailabilityFields { + sundayAvailability: string; + mondayAvailability: string; + tuesdayAvailability: string; + wednesdayAvailability: string; + thursdayAvailability: string; + fridayAvailability: string; + saturdayAvailability: string; +} + +export interface Application extends AvailabilityFields { + appId: number; + email: string; + discipline: string; + appStatus: string; + experienceType: string; + interest: string[]; + license: string; + phone: string; + applicantType: string; + school: string; + weeklyHours: number; + pronouns: string; + resume: string; + coverLetter: string; + emergencyContactName: string; + emergencyContactPhone: string; + emergencyContactRelationship: string; +} + const defaultBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000'; @@ -14,6 +44,20 @@ export class ApiClient { return this.get('/api') as Promise; } + public async getApplication(appId: number): Promise { + return this.get(`/api/applications/${appId}`) as Promise; + } + + public async updateAvailability( + appId: number, + availability: Partial, + ): Promise { + return this.patch( + `/api/applications/${appId}/availability`, + availability, + ) as Promise; + } + private async get(path: string): Promise { return this.axiosInstance.get(path).then((response) => response.data); } diff --git a/apps/frontend/src/app.spec.tsx b/apps/frontend/src/app.spec.tsx deleted file mode 100644 index 95caf44df..000000000 --- a/apps/frontend/src/app.spec.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render } from '@testing-library/react'; - -import App from './app'; - -describe('App', () => { - it('should render successfully', () => { - const { baseElement } = render(); - expect(baseElement).toBeTruthy(); - }); - - it('should have a greeting as the title', () => { - const { getByText } = render(); - expect(getByText(/Welcome frontend/gi)).toBeTruthy(); - }); -}); diff --git a/apps/frontend/src/app.tsx b/apps/frontend/src/app.tsx index a51df65b5..b5b8f5608 100644 --- a/apps/frontend/src/app.tsx +++ b/apps/frontend/src/app.tsx @@ -1,10 +1,11 @@ import { useEffect } from 'react'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { ChakraProvider, defaultSystem } from '@chakra-ui/react'; import apiClient from '@api/apiClient'; import Root from '@containers/root'; +import ApplicantView from '@containers/applicant'; import NotFound from '@containers/404'; -import Test from '@containers/test'; const router = createBrowserRouter([ { @@ -13,8 +14,9 @@ const router = createBrowserRouter([ errorElement: , }, { - path: '/test', - element: , + path: '/applications/:appId', + element: , + errorElement: , }, ]); @@ -23,7 +25,11 @@ export const App: React.FC = () => { apiClient.getHello().then((res) => console.log(res)); }, []); - return ; + return ( + + + + ); }; export default App; diff --git a/apps/frontend/src/assets/icons/Vector.svg b/apps/frontend/src/assets/icons/Vector.svg new file mode 100644 index 000000000..f44086f70 --- /dev/null +++ b/apps/frontend/src/assets/icons/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/icons/checkmark.svg b/apps/frontend/src/assets/icons/checkmark.svg new file mode 100644 index 000000000..276fcc2c4 --- /dev/null +++ b/apps/frontend/src/assets/icons/checkmark.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/icons/clock.svg b/apps/frontend/src/assets/icons/clock.svg new file mode 100644 index 000000000..0ea942592 --- /dev/null +++ b/apps/frontend/src/assets/icons/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/icons/cross.svg b/apps/frontend/src/assets/icons/cross.svg new file mode 100644 index 000000000..534b1b71d --- /dev/null +++ b/apps/frontend/src/assets/icons/cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/assets/icons/users.svg b/apps/frontend/src/assets/icons/users.svg new file mode 100644 index 000000000..cb06f60f7 --- /dev/null +++ b/apps/frontend/src/assets/icons/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/frontend/src/components/ApplicationTable.tsx b/apps/frontend/src/components/ApplicationTable.tsx new file mode 100644 index 000000000..182a197c9 --- /dev/null +++ b/apps/frontend/src/components/ApplicationTable.tsx @@ -0,0 +1,92 @@ +import { Table } from '@chakra-ui/react'; + +const COLUMNS = [ + 'Name', + 'Start Date', + 'Experience Type', + 'Discipline', + 'Discipline Admin Name', + 'Status', +]; + +const APPLICATIONS = [ + { + id: '1', + name: 'Firstname Lastname', + startDate: '01-01-2026', + experienceType: 'Volunteer', + discipline: 'Nursing', + disciplineAdminName: 'Firstname Lastname', + status: 'Approved', + }, + { + id: '2', + name: 'Firstname Lastname', + startDate: '01-01-2026', + experienceType: 'Volunteer', + discipline: 'Nursing', + disciplineAdminName: 'Firstname Lastname', + status: 'Approved', + }, + { + id: '3', + name: 'Firstname Lastname', + startDate: '01-01-2026', + experienceType: 'Volunteer', + discipline: 'Nursing', + disciplineAdminName: 'Firstname Lastname', + status: 'Approved', + }, + { + id: '4', + name: 'Firstname Lastname', + startDate: '01-01-2026', + experienceType: 'Volunteer', + discipline: 'Nursing', + disciplineAdminName: 'Firstname Lastname', + status: 'Approved', + }, + { + id: '5', + name: 'Firstname Lastname', + startDate: '01-01-2026', + experienceType: 'Volunteer', + discipline: 'Nursing', + disciplineAdminName: 'Firstname Lastname', + status: 'Approved', + }, +]; + +export const ApplicationTable: React.FC = () => { + return ( + + + + {COLUMNS.map((column) => ( + + {column} + + ))} + + + + {APPLICATIONS.map((application) => ( + + {application.name} + {application.startDate} + {application.experienceType} + {application.discipline} + {application.disciplineAdminName} + {application.status} + + ))} + + + ); +}; + +export default ApplicationTable; diff --git a/apps/frontend/src/components/AvailabilityTable.tsx b/apps/frontend/src/components/AvailabilityTable.tsx new file mode 100644 index 000000000..665e5b6ae --- /dev/null +++ b/apps/frontend/src/components/AvailabilityTable.tsx @@ -0,0 +1,204 @@ +import { useState } from 'react'; +import { + Box, + Button, + Dialog, + Heading, + IconButton, + Menu, + Textarea, + Portal, +} from '@chakra-ui/react'; +import { BsThreeDots } from 'react-icons/bs'; +import apiClient, { type AvailabilityFields } from '@api/apiClient'; + +type DayKey = keyof AvailabilityFields; + +const DAYS: { label: string; key: DayKey }[] = [ + { label: 'Sunday', key: 'sundayAvailability' }, + { label: 'Monday', key: 'mondayAvailability' }, + { label: 'Tuesday', key: 'tuesdayAvailability' }, + { label: 'Wednesday', key: 'wednesdayAvailability' }, + { label: 'Thursday', key: 'thursdayAvailability' }, + { label: 'Friday', key: 'fridayAvailability' }, + { label: 'Saturday', key: 'saturdayAvailability' }, +]; + +interface AvailabilityTableProps { + appId: number; + availability: AvailabilityFields; + isAdmin: boolean; + onUpdate?: (updated: AvailabilityFields) => void; +} + +export const AvailabilityTable: React.FC = ({ + appId, + availability, + isAdmin, + onUpdate, +}) => { + const [editingDay, setEditingDay] = useState(null); + const [editValue, setEditValue] = useState(''); + const [saving, setSaving] = useState(false); + + const handleEditOpen = (dayKey: DayKey) => { + setEditValue(availability[dayKey] ?? ''); + setEditingDay(dayKey); + }; + + const handleEditClose = () => { + setEditingDay(null); + setEditValue(''); + }; + + const handleSave = async () => { + if (!editingDay) return; + setSaving(true); + try { + const updated = await apiClient.updateAvailability(appId, { + [editingDay]: editValue, + }); + onUpdate?.({ + sundayAvailability: updated.sundayAvailability, + mondayAvailability: updated.mondayAvailability, + tuesdayAvailability: updated.tuesdayAvailability, + wednesdayAvailability: updated.wednesdayAvailability, + thursdayAvailability: updated.thursdayAvailability, + fridayAvailability: updated.fridayAvailability, + saturdayAvailability: updated.saturdayAvailability, + }); + handleEditClose(); + } catch (err) { + console.error('Failed to update availability:', err); + } finally { + setSaving(false); + } + }; + + const editingDayLabel = DAYS.find((d) => d.key === editingDay)?.label ?? ''; + + return ( + + + Availability + + + + {/* Header */} + + + Day + + Times Available + {isAdmin && } + + + {/* Rows */} + {DAYS.map(({ label, key }, index) => ( + + + {label} + + + {availability[key] || '—'} + + {isAdmin && ( + + + + + + + + + + + handleEditOpen(key)} + > + Edit + + + + + + + )} + + ))} + + + { + if (!details.open) handleEditClose(); + }} + > + + + + + + Edit {editingDayLabel} Availability + + +