diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts new file mode 100644 index 0000000000..cd6882eed4 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts @@ -0,0 +1,339 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + createE2EDataSources, + createFakeSFASApplication, + createFakeSFASApplicationDisbursement, + createFakeSINValidation, + E2EDataSources, + saveFakeApplicationDisbursements, + saveFakeSFASIndividual, + saveFakeStudent, +} from "@sims/test-utils"; +import { + ApplicationStatus, + DisbursementScheduleStatus, + OfferingIntensity, +} from "@sims/sims-db"; +import { + createTestingAppModule, + BEARER_AUTH_TYPE, + getExternalUserToken, +} from "../../../../testHelpers"; +import { addDays, getISODateOnlyString } from "@sims/utilities"; + +describe("StudentExternalController(e2e)-getActiveSINs", () => { + let app: INestApplication; + let db: E2EDataSources; + const endpoint = "/external/student/active-sins"; + + beforeAll(async () => { + const { nestApplication, dataSource } = await createTestingAppModule(); + app = nestApplication; + db = createE2EDataSources(dataSource); + }); + + it("Should include SIMS student SIN when student has a FT application with start date between now and 90 days in the future and no cancelled status.", async () => { + // Arrange + const student = await saveFakeStudent(db.dataSource); + // Create a FT application with a study start date 45 days in the future. + await saveFakeApplicationDisbursements( + db.dataSource, + { student }, + { + offeringIntensity: OfferingIntensity.fullTime, + offeringInitialValues: { + studyStartDate: getISODateOnlyString(addDays(45)), + studyEndDate: getISODateOnlyString(addDays(120)), + }, + }, + ); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body.sins).toEqual( + expect.arrayContaining([student.sinValidation.sin]), + ), + ); + }); + + it("Should include SIMS student SIN when student has a FT application currently within the study period and no cancelled status.", async () => { + // Arrange + const student = await saveFakeStudent(db.dataSource); + // Create a FT application with a study start date in the past and end date in the future. + await saveFakeApplicationDisbursements( + db.dataSource, + { student }, + { + offeringIntensity: OfferingIntensity.fullTime, + }, + ); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body.sins).toEqual( + expect.arrayContaining([student.sinValidation.sin]), + ), + ); + }); + it("Should include SIMS student SIN when student has a FT application with a disbursement sent in the last 90 days and no cancelled status.", async () => { + // Arrange + const student = await saveFakeStudent(db.dataSource); + // Create a FT application with a disbursement sent 60 days ago. + const application = await saveFakeApplicationDisbursements( + db.dataSource, + { student }, + { + offeringIntensity: OfferingIntensity.fullTime, + offeringInitialValues: { + studyStartDate: getISODateOnlyString(addDays(-120)), + studyEndDate: getISODateOnlyString(addDays(-60)), + }, + // Set the disbursement as Sent with dateSent within the last 90 days. + firstDisbursementInitialValues: { + disbursementScheduleStatus: DisbursementScheduleStatus.Sent, + dateSent: addDays(-60), + }, + }, + ); + const [disbursement] = application.currentAssessment.disbursementSchedules; + await db.disbursementSchedule.save(disbursement); + const token = await getExternalUserToken(); + // Act + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body.sins).toEqual( + expect.arrayContaining([student.sinValidation.sin]), + ), + ); + }); + + it("Should not include SIMS student SIN when student has only a cancelled FT application that would otherwise match the study start date criteria.", async () => { + // Arrange + const student = await saveFakeStudent(db.dataSource); + // Create a cancelled FT application with a study start date 45 days in the future. + await saveFakeApplicationDisbursements( + db.dataSource, + { student }, + { + applicationStatus: ApplicationStatus.Cancelled, + offeringIntensity: OfferingIntensity.fullTime, + offeringInitialValues: { + studyStartDate: getISODateOnlyString(addDays(45)), + studyEndDate: getISODateOnlyString(addDays(120)), + }, + }, + ); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => + expect(body.sins).not.toContain(student.sinValidation.sin), + ); + }); + + it("Should return only the most recent valid SIMS student SIN when a student has multiple valid SIN records.", async () => { + // Arrange + const student = await saveFakeStudent(db.dataSource); + const olderValidSIN = student.sinValidation.sin; + // Create a newer SIN validation with a different SIN also marked as valid. + const newerSINValidation = createFakeSINValidation( + { student }, + { initialValue: { isValidSIN: true } }, + ); + // Update the student to point to the newer SIN validation as the current one. + student.sinValidation = newerSINValidation; + await db.student.save(student); + // Create a FT application with a study start date 45 days in the future. + await saveFakeApplicationDisbursements( + db.dataSource, + { student }, + { + offeringIntensity: OfferingIntensity.fullTime, + offeringInitialValues: { + studyStartDate: getISODateOnlyString(addDays(45)), + studyEndDate: getISODateOnlyString(addDays(120)), + }, + }, + ); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).toContain(newerSINValidation.sin); + expect(body.sins).not.toContain(olderValidSIN); + }); + }); + + it("Should include SFAS student SIN when student has a FT application with start date between now and 90 days in the future and no cancelled status.", async () => { + // Arrange + const individual = await saveFakeSFASIndividual(db.dataSource); + const sfasApplication = createFakeSFASApplication( + { individual }, + { + initialValues: { + startDate: getISODateOnlyString(addDays(45)), + endDate: getISODateOnlyString(addDays(120)), + applicationCancelDate: null, + }, + }, + ); + await db.sfasApplication.save(sfasApplication); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).toContain(individual.sin); + }); + }); + + it("Should include SFAS student SIN when student has a FT application currently within the study period and no cancelled status.", async () => { + // Arrange + const individual = await saveFakeSFASIndividual(db.dataSource); + const sfasApplication = createFakeSFASApplication( + { individual }, + { + initialValues: { + startDate: getISODateOnlyString(addDays(-30)), + endDate: getISODateOnlyString(addDays(30)), + applicationCancelDate: null, + }, + }, + ); + await db.sfasApplication.save(sfasApplication); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).toContain(individual.sin); + }); + }); + it("Should include SFAS student SIN when student has a FT application with disbursement date issued in the last 90 days and no cancelled status.", async () => { + // Arrange + const individual = await saveFakeSFASIndividual(db.dataSource); + // Create a SFAS application with a past study period. + const sfasApplication = createFakeSFASApplication( + { individual }, + { + initialValues: { + startDate: getISODateOnlyString(addDays(-120)), + endDate: getISODateOnlyString(addDays(-60)), + applicationCancelDate: null, + }, + }, + ); + await db.sfasApplication.save(sfasApplication); + // Create a disbursement with dateIssued within the last 90 days. + const disbursement = createFakeSFASApplicationDisbursement( + { sfasApplication }, + { + initialValues: { + dateIssued: getISODateOnlyString(addDays(-60)), + }, + }, + ); + await db.sfasApplicationDisbursement.save(disbursement); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).toContain(individual.sin); + }); + }); + + it("Should not include SFAS student SIN when student has only a cancelled FT application that would otherwise match the study start date criteria.", async () => { + // Arrange + const individual = await saveFakeSFASIndividual(db.dataSource); + // Create a cancelled SFAS application with a start date in the next 90 days. + const sfasApplication = createFakeSFASApplication( + { individual }, + { + initialValues: { + startDate: getISODateOnlyString(addDays(45)), + endDate: getISODateOnlyString(addDays(120)), + applicationCancelDate: getISODateOnlyString(addDays(-5)), + }, + }, + ); + await db.sfasApplication.save(sfasApplication); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).not.toContain(individual.sin); + }); + }); + + it("Should include both a SIMS and a SFAS student SIN when each has a qualifying FT application with start date between now and 90 days in the future.", async () => { + // Arrange + // Create a SIMS student with a FT application starting 45 days in the future. + const student = await saveFakeStudent(db.dataSource); + await saveFakeApplicationDisbursements( + db.dataSource, + { student }, + { + offeringIntensity: OfferingIntensity.fullTime, + offeringInitialValues: { + studyStartDate: getISODateOnlyString(addDays(45)), + studyEndDate: getISODateOnlyString(addDays(120)), + }, + }, + ); + // Create a SFAS individual with a FT application starting 45 days in the future. + const individual = await saveFakeSFASIndividual(db.dataSource); + const sfasApplication = createFakeSFASApplication( + { individual }, + { + initialValues: { + startDate: getISODateOnlyString(addDays(45)), + endDate: getISODateOnlyString(addDays(120)), + applicationCancelDate: null, + }, + }, + ); + await db.sfasApplication.save(sfasApplication); + const token = await getExternalUserToken(); + // Act/Assert + await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).toContain(student.sinValidation.sin); + expect(body.sins).toContain(individual.sin); + }); + }); + + afterAll(async () => { + await app?.close(); + }); +}); diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/models/student-external-search.dto.ts b/sources/packages/backend/apps/api/src/route-controllers/student/models/student-external-search.dto.ts index 4f65a0d69a..88958defe3 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student/models/student-external-search.dto.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student/models/student-external-search.dto.ts @@ -112,3 +112,11 @@ export type StudentSearchDetails = Omit< StudentSearchResultAPIOutDTO, "applications" >; + +/** + * Active SINs result containing all SIMS and legacy SINs for students + * with active full-time applications or recent disbursements. + */ +export class ActiveSINsAPIOutDTO { + sins: string[]; +} diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/student.external.controller.ts b/sources/packages/backend/apps/api/src/route-controllers/student/student.external.controller.ts index aace9e269c..3631da2936 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student/student.external.controller.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student/student.external.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, + Get, HttpCode, HttpStatus, NotFoundException, @@ -16,12 +17,14 @@ import { import BaseController from "../BaseController"; import { StudentInformationService } from "../../services"; import { + ActiveSINsAPIOutDTO, StudentSearchAPIInDTO, StudentSearchResultAPIOutDTO, } from "./models/student-external-search.dto"; import { StudentExternalControllerService } from "./student.external.controller.service"; import { SFASApplication } from "@sims/sims-db"; import { LoggerService } from "@sims/utilities/logger"; +import { ACTIVE_SINS_DAYS } from "../../utilities"; /** * Student controller for external client. @@ -105,4 +108,26 @@ export class StudentExternalController extends BaseController { applications, }; } + + /** + * Returns all SIMS and legacy (SFAS) student SINs for full-time applications + * meeting at least one of the following criteria: + * - A FT application with a start date between now and the lookback/lookahead window. + * - FT disbursements sent or issued within the lookback/lookahead window. + * Only valid SINs are returned for SIMS students, and duplicate SINs are excluded. + * @returns active SINs from both SIMS and SFAS. + */ + @Get("active-sins") + async getActiveSINs(): Promise { + const [simsSINs, sfasSINs] = await Promise.all([ + this.studentInformationService.getFullTimeActiveSINs(ACTIVE_SINS_DAYS), + this.studentInformationService.getFullTimeActiveLegacySINs( + ACTIVE_SINS_DAYS, + ), + ]); + const mergedSINs = [...new Set([...simsSINs, ...sfasSINs])].sort((a, b) => + a.localeCompare(b), + ); + return { sins: mergedSINs }; + } } diff --git a/sources/packages/backend/apps/api/src/services/student/student-information.service.ts b/sources/packages/backend/apps/api/src/services/student/student-information.service.ts index d701797f57..1dc39dbf41 100644 --- a/sources/packages/backend/apps/api/src/services/student/student-information.service.ts +++ b/sources/packages/backend/apps/api/src/services/student/student-information.service.ts @@ -3,6 +3,7 @@ import { Student, SFASIndividual, ApplicationStatus, + DisbursementScheduleStatus, StudentAssessmentStatus, ProgramYear, StudentScholasticStandingChangeType, @@ -10,7 +11,7 @@ import { OfferingIntensity, SFASApplication, } from "@sims/sims-db"; -import { Repository } from "typeorm"; +import { Brackets, Repository } from "typeorm"; import { InjectRepository } from "@nestjs/typeorm"; const MAX_PAST_PROGRAM_YEARS_INCLUDING_CURRENT = 3; @@ -268,4 +269,85 @@ export class StudentInformationService { .getMany() ); } + + /** + * Get SIMS student SINs for students who have full-time applications + * meeting at least one of the following criteria: + * - The study period is active or starts within the specified future window. + * Active is defined as: study start date is on or before (today + activeSinsDays) + * AND study end date is on or after today. + * - A disbursement with status Sent was sent within the last activeSinsDays days. + * Cancelled and edited applications are excluded. + * Only the most recent SIN (student.sinValidation) is returned, and duplicate SINs are excluded. + * @param activeSinsDays number of days used for the lookahead and lookback windows. + * @returns distinct SIMS student SINs. + */ + async getFullTimeActiveSINs(activeSinsDays: number): Promise { + const applications = await this.applicationRepo + .createQueryBuilder("application") + .select(["application.id", "student.id", "sinValidation.sin"]) + .distinctOn(["sinValidation.sin"]) + .innerJoin("application.student", "student") + .innerJoin("student.sinValidation", "sinValidation") + .innerJoin("application.currentAssessment", "assessment") + .innerJoin("assessment.offering", "offering") + .leftJoin("assessment.disbursementSchedules", "disbursement") + .where( + "application.applicationStatus NOT IN (:...inEligibleApplicationStatuses)", + ) + .andWhere("application.offeringIntensity = :fullTimeOfferingIntensity") + .andWhere( + new Brackets((qb) => { + qb.where( + "offering.studyStartDate <= (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day') AND offering.studyEndDate >= CURRENT_DATE", + ).orWhere( + "disbursement.dateSent >= (CURRENT_DATE - :activeSinsDays * INTERVAL '1 day') AND disbursement.disbursementScheduleStatus = :sentStatus", + ); + }), + ) + .setParameters({ + inEligibleApplicationStatuses: [ + ApplicationStatus.Cancelled, + ApplicationStatus.Edited, + ], + fullTimeOfferingIntensity: OfferingIntensity.fullTime, + activeSinsDays, + sentStatus: DisbursementScheduleStatus.Sent, + }) + .getMany(); + return applications.map((app) => app.student.sinValidation.sin); + } + + /** + * Get SFAS (legacy) student SINs for students who have full-time applications + * meeting at least one of the following criteria: + * - The study period is active or starts within the specified future window. + * Active is defined as: study start date is on or before (today + activeSinsDays) + * AND study end date is on or after today. + * - A disbursement was issued within the last activeSinsDays days. + * Cancelled applications are excluded. + * @param activeSinsDays number of days used for the lookahead and lookback windows. + * @returns distinct SFAS student SINs. + */ + async getFullTimeActiveLegacySINs(activeSinsDays: number): Promise { + const sfasApplications = await this.sfasApplicationRepo + .createQueryBuilder("sfasApp") + .select(["sfasApp.id", "sfasIndividual.sin"]) + .distinctOn(["sfasIndividual.sin"]) + .innerJoin("sfasApp.individual", "sfasIndividual") + .leftJoin("sfasApp.disbursements", "disbursement") + .where("sfasApp.applicationCancelDate IS NULL") + .andWhere( + new Brackets((qb) => { + qb.where( + "sfasApp.startDate <= (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day') AND sfasApp.endDate >= CURRENT_DATE", + ).orWhere( + "disbursement.dateIssued >= (CURRENT_DATE - :activeSinsDays * INTERVAL '1 day')", + ); + }), + ) + .setParameters({ activeSinsDays }) + .getMany(); + return sfasApplications.map((app) => app.individual.sin); + } } diff --git a/sources/packages/backend/apps/api/src/utilities/system-configurations-constants.ts b/sources/packages/backend/apps/api/src/utilities/system-configurations-constants.ts index 3ad18d3eb3..b79f6e03b8 100644 --- a/sources/packages/backend/apps/api/src/utilities/system-configurations-constants.ts +++ b/sources/packages/backend/apps/api/src/utilities/system-configurations-constants.ts @@ -146,3 +146,8 @@ export const AVIATION_RESTRICTION_CODES: RestrictionCode[] = [ RestrictionCode.AVIR, RestrictionCode.SFAS_AV, ]; +/** + * Number of days used for determining + * active student SINs for the external API. + */ +export const ACTIVE_SINS_DAYS = 90;