Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/backend/src/applications/application.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
81 changes: 81 additions & 0 deletions apps/backend/src/applications/application.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ const dummyApplication: Application = {
emergencyContactPhone: '111-111-1111',
emergencyContactRelationship: 'Mother',
heardAboutFrom: [],
internalNotes: undefined,
};

const dummyCreateApplicationDto: CreateApplicationDto = {
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions apps/backend/src/applications/applications.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <appId> 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<Application> {
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.
Expand Down
22 changes: 22 additions & 0 deletions apps/backend/src/applications/applications.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Application> {
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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddInternalNotesToApplications1781306552405
implements MigrationInterface
{
name = 'AddInternalNotesToApplications1781306552405';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "application" ADD "internalNotes" character varying`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove everything else from this migration but this query please - we only want to put in the migration what is relevant to the goal you are trying to achieve

);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "application" DROP COLUMN "internalNotes"`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above but just specifically for the down one ^

);
}
}
9 changes: 9 additions & 0 deletions apps/frontend/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,15 @@ export class ApiClient {
}) as Promise<Application>;
}

public async updateApplicationInternalNotes(
appId: number,
internalNotes: string | undefined,
): Promise<Application> {
return this.patch(`/api/applications/${appId}/internal-notes`, {
internalNotes,
}) as Promise<Application>;
}

public async getConfidentialityTemplateUrl(): Promise<ConfidentialityTemplateResponse> {
return this.get(
'/api/applications/forms/confidentiality/template',
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface Application extends AvailabilityFields {
emergencyContactRelationship: string;
heardAboutFrom: HeardAboutFrom[];
endDate?: string;
internalNotes?: string;
}

/**
Expand Down
46 changes: 46 additions & 0 deletions apps/frontend/src/containers/AdminViewApplication.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -189,6 +191,26 @@ const AdminViewApplication: React.FC = () => {
setApplication(updatedApplication);
};

const [internalNotes, setInternalNotes] = useState<string>(
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 (
<Flex direction="row">
Expand Down Expand Up @@ -445,6 +467,30 @@ const AdminViewApplication: React.FC = () => {
phone={application.emergencyContactPhone}
relationship={application.emergencyContactRelationship}
/>

<Box borderWidth="1px" borderRadius="lg" p={6} bg="white">
<Heading as="h2" size="md" mb={1}>
Internal Notes
</Heading>
<Text fontSize="sm" color="orange.700" mb={3}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do think this text being this color is good though.

Admin only — not visible to applicants.
</Text>
<Textarea
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
placeholder="Add internal notes about this applicant..."
bg="white"
rows={4}
/>
<Button
mt={3}
colorScheme="orange"
loading={notesSaving}
onClick={handleInternalNotesUpdate}
>
Save Notes
</Button>
</Box>
</Box>
</Flex>
);
Expand Down
Loading