diff --git a/apps/backend/src/applications/application.entity.ts b/apps/backend/src/applications/application.entity.ts index a6159d401..2e536d901 100644 --- a/apps/backend/src/applications/application.entity.ts +++ b/apps/backend/src/applications/application.entity.ts @@ -297,4 +297,12 @@ export class Application { */ @UpdateDateColumn({ type: 'timestamp' }) updatedAt!: Date; + + /** + * Internal notes about the application, only visible to admins. + * + * Example: "Applicant is potentially a good fit." + */ + @Column({ type: 'varchar', nullable: true }) + internalNotes?: string; } diff --git a/apps/backend/src/applications/application.service.spec.ts b/apps/backend/src/applications/application.service.spec.ts index e99940223..88792cd53 100644 --- a/apps/backend/src/applications/application.service.spec.ts +++ b/apps/backend/src/applications/application.service.spec.ts @@ -70,6 +70,7 @@ const dummyApplication: Application = { emergencyContactPhone: '111-111-1111', emergencyContactRelationship: 'Mother', heardAboutFrom: [], + internalNotes: undefined, }; const dummyCreateApplicationDto: CreateApplicationDto = { @@ -1067,6 +1068,86 @@ describe('ApplicationsService', () => { }); }); + describe('updateInternalNotes', () => { + it('should update internal notes', async () => { + const updatedApplication: Application = { + ...dummyApplication, + internalNotes: 'Applicant is potentially a good fit.', + }; + + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue(updatedApplication); + + const result = await service.updateInternalNotes( + 1, + 'Applicant is potentially a good fit.', + ); + + expect(result).toEqual(updatedApplication); + expect(repository.findOne).toHaveBeenCalledWith({ where: { appId: 1 } }); + expect(repository.save).toHaveBeenCalledWith({ + ...dummyApplication, + internalNotes: 'Applicant is potentially a good fit.', + }); + }); + + it('should clear internal notes when undefined is passed', async () => { + const updatedApplication: Application = { + ...dummyApplication, + internalNotes: undefined, + }; + + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockResolvedValue(updatedApplication); + + const result = await service.updateInternalNotes(1, undefined); + + expect(result).toEqual(updatedApplication); + expect(repository.save).toHaveBeenCalledWith({ + ...dummyApplication, + internalNotes: undefined, + }); + }); + + it('should throw NotFoundException if application is not found', async () => { + mockRepository.findOne.mockResolvedValue(null); + + await expect( + service.updateInternalNotes( + 999, + 'Applicant is potentially a good fit.', + ), + ).rejects.toThrow('Application with ID 999 not found'); + }); + + it('should throw BadRequestException if application id is missing', async () => { + await expect( + service.updateInternalNotes(0, 'Applicant is potentially a good fit.'), + ).rejects.toThrow('Application ID is required'); + }); + + it('should error out without information loss if the repository throws an error during retrieval', async () => { + mockRepository.findOne.mockRejectedValueOnce( + new Error('There was a problem retrieving the info'), + ); + + await expect( + service.updateInternalNotes(1, 'Applicant is potentially a good fit.'), + ).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 () => { + mockRepository.findOne.mockResolvedValue(dummyApplication); + mockRepository.save.mockRejectedValueOnce( + new Error('There was a problem saving the info'), + ); + + await expect( + service.updateInternalNotes(1, 'Applicant is potentially a good fit.'), + ).rejects.toThrow('There was a problem saving the info'); + }); + }); + describe('delete', () => { it('should delete an application', async () => { mockRepository.findOne.mockResolvedValue(dummyApplication); diff --git a/apps/backend/src/applications/applications.controller.ts b/apps/backend/src/applications/applications.controller.ts index 2aa7b3b97..a7011d06f 100644 --- a/apps/backend/src/applications/applications.controller.ts +++ b/apps/backend/src/applications/applications.controller.ts @@ -28,6 +28,7 @@ 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'; +import { UpdateApplicationInternalNotesDto } from './dto/update-application-internal-notes.request.dto'; import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthGuard } from '@nestjs/passport'; import { UserType } from '../users/types'; @@ -363,6 +364,26 @@ export class ApplicationsController { ); } + /** + * Exposes an endpoint to update the internal notes of an application. + * @param appId The id of the application to update. + * @param updateInternalNotesDto Object containing the new internal notes. + * @returns The updated application object. + * @throws {NotFoundException} with message 'Application with ID not found' + * if the application does not exist. + */ + @Patch('/:appId/internal-notes') + @Roles(UserType.ADMIN) + async updateApplicationInternalNotes( + @Param('appId', ParseIntPipe) appId: number, + @Body() updateInternalNotesDto: UpdateApplicationInternalNotesDto, + ): Promise { + return await this.applicationsService.updateInternalNotes( + appId, + updateInternalNotesDto.internalNotes, + ); + } + /** * Exposes an endpoint to get a signed URL for the confidentiality-form template. * @returns object containing the template URL. diff --git a/apps/backend/src/applications/applications.service.ts b/apps/backend/src/applications/applications.service.ts index bd4e649db..d1fe750ce 100644 --- a/apps/backend/src/applications/applications.service.ts +++ b/apps/backend/src/applications/applications.service.ts @@ -882,6 +882,28 @@ export class ApplicationsService { return await this.applicationRepository.save(application); } + /** + * Updates the internal notes of an application. + * @param appId The id of the application to update. + * @param internalNotes The new internal notes string. + * @returns The updated application object. + * @throws {NotFoundException} if the application does not exist. + * @throws {Error} which is unchanged from what repository throws. + */ + async updateInternalNotes( + appId: number, + internalNotes: string | undefined, + ): Promise { + if (!appId) { + throw new BadRequestException('Application ID is required'); + } + + const application = await this.findById(appId); + + application.internalNotes = internalNotes; + return await this.applicationRepository.save(application); + } + /** * Deletes an application from the repository by id. * @param appId the id of the application to delete. diff --git a/apps/backend/src/applications/dto/update-application-internal-notes.request.dto.ts b/apps/backend/src/applications/dto/update-application-internal-notes.request.dto.ts new file mode 100644 index 000000000..3f64ea587 --- /dev/null +++ b/apps/backend/src/applications/dto/update-application-internal-notes.request.dto.ts @@ -0,0 +1,17 @@ +import { IsOptional, IsString } from 'class-validator'; + +/** + * Defines the expected shape of data for updating an application's internal notes. + * + * DTO - data transfer object (defines and validates the structure of data sent over the network). + */ +export class UpdateApplicationInternalNotesDto { + /** + * Internal notes about the application, only visible to admins. + * + * Example: "Applicant is potentially a good fit." + */ + @IsString() + @IsOptional() + internalNotes?: string; +} diff --git a/apps/backend/src/migrations/1781306552405-AddInternalNotesToApplications.ts b/apps/backend/src/migrations/1781306552405-AddInternalNotesToApplications.ts new file mode 100644 index 000000000..3bf50c599 --- /dev/null +++ b/apps/backend/src/migrations/1781306552405-AddInternalNotesToApplications.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddInternalNotesToApplications1781306552405 + implements MigrationInterface +{ + name = 'AddInternalNotesToApplications1781306552405'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" ADD "internalNotes" character varying`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" DROP COLUMN "internalNotes"`, + ); + } +} diff --git a/apps/frontend/src/api/apiClient.ts b/apps/frontend/src/api/apiClient.ts index 696866d8f..6d4298899 100644 --- a/apps/frontend/src/api/apiClient.ts +++ b/apps/frontend/src/api/apiClient.ts @@ -221,6 +221,15 @@ export class ApiClient { }) as Promise; } + public async updateApplicationInternalNotes( + appId: number, + internalNotes: string | undefined, + ): Promise { + return this.patch(`/api/applications/${appId}/internal-notes`, { + internalNotes, + }) as Promise; + } + public async getConfidentialityTemplateUrl(): Promise { return this.get( '/api/applications/forms/confidentiality/template', diff --git a/apps/frontend/src/api/types.ts b/apps/frontend/src/api/types.ts index c08a8f11c..784275e57 100644 --- a/apps/frontend/src/api/types.ts +++ b/apps/frontend/src/api/types.ts @@ -113,6 +113,7 @@ export interface Application extends AvailabilityFields { emergencyContactRelationship: string; heardAboutFrom: HeardAboutFrom[]; endDate?: string; + internalNotes?: string; } /** diff --git a/apps/frontend/src/containers/AdminViewApplication.tsx b/apps/frontend/src/containers/AdminViewApplication.tsx index d9a3aac5e..64fea650e 100644 --- a/apps/frontend/src/containers/AdminViewApplication.tsx +++ b/apps/frontend/src/containers/AdminViewApplication.tsx @@ -4,10 +4,12 @@ import apiClient from '@api/apiClient'; import { Badge, Box, + Button, Flex, Heading, Spinner, Text, + Textarea, VStack, } from '@chakra-ui/react'; import AvailabilityTable from '@components/AvailabilityTable'; @@ -189,6 +191,26 @@ const AdminViewApplication: React.FC = () => { setApplication(updatedApplication); }; + const [internalNotes, setInternalNotes] = useState( + application?.internalNotes ?? '', + ); + const [notesSaving, setNotesSaving] = useState(false); + + useEffect(() => { + setInternalNotes(application?.internalNotes ?? ''); + }, [application]); + + const handleInternalNotesUpdate = async () => { + if (!application) return; + setNotesSaving(true); + const updatedApplication = await apiClient.updateApplicationInternalNotes( + application.appId, + internalNotes, + ); + setApplication(updatedApplication); + setNotesSaving(false); + }; + if (loading) { return ( @@ -445,6 +467,30 @@ const AdminViewApplication: React.FC = () => { phone={application.emergencyContactPhone} relationship={application.emergencyContactRelationship} /> + + + + Internal Notes + + + Admin only — not visible to applicants. + +