From 724e8258b40c0bb31051232d9a911e97d44767d3 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Sat, 21 Mar 2026 12:25:33 -0700 Subject: [PATCH 01/10] Initial changes for SDPR api --- ...ernal.controller.getActiveSINs.e2e-spec.ts | 267 ++++++++++++++++++ .../models/student-external-search.dto.ts | 8 + .../student/student.external.controller.ts | 20 ++ .../student/student-information.service.ts | 83 +++++- 4 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts 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..672c6d48e5 --- /dev/null +++ b/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts @@ -0,0 +1,267 @@ +import { HttpStatus, INestApplication } from "@nestjs/common"; +import * as request from "supertest"; +import { + createE2EDataSources, + createFakeSFASApplication, + createFakeSFASApplicationDisbursement, + 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"; +import { ActiveSINsAPIOutDTO } from "../../models/student-external-search.dto"; + +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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.sins).toContain(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, + offeringInitialValues: { + studyStartDate: getISODateOnlyString(addDays(-30)), + studyEndDate: getISODateOnlyString(addDays(30)), + }, + }, + ); + const token = await getExternalUserToken(); + // Act + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.sins).toContain(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. + const [disbursement] = application.currentAssessment.disbursementSchedules; + disbursement.disbursementScheduleStatus = DisbursementScheduleStatus.Sent; + disbursement.dateSent = addDays(-60); + await db.disbursementSchedule.save(disbursement); + const token = await getExternalUserToken(); + // Act + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.sins).toContain(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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.sins).not.toContain(student.sinValidation.sin); + }); + + 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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.sins).not.toContain(individual.sin); + }); + + it("Should return HTTP 401 when request is not authenticated.", async () => { + await request(app.getHttpServer()) + .get(endpoint) + .expect(HttpStatus.UNAUTHORIZED); + }); + + 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..8cd690db6c 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,6 +17,7 @@ import { import BaseController from "../BaseController"; import { StudentInformationService } from "../../services"; import { + ActiveSINsAPIOutDTO, StudentSearchAPIInDTO, StudentSearchResultAPIOutDTO, } from "./models/student-external-search.dto"; @@ -105,4 +107,22 @@ 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 90 days in the future. + * - A FT application within a current study period. + * - FT disbursements sent in the last 90 days. + * Only the most recent validated SIN is returned for SIMS students. + * @returns active SINs from both SIMS and SFAS. + */ + @Get("active-sins") + async getActiveSINs(): Promise { + const [simsSINs, sfasSINs] = await Promise.all([ + this.studentInformationService.getSIMSSINs(), + this.studentInformationService.getSFASSINs(), + ]); + return { sins: [...new Set([...simsSINs, ...sfasSINs])] }; + } } 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..c6c09624df 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 @@ -9,8 +9,9 @@ import { Application, OfferingIntensity, SFASApplication, + DisbursementScheduleStatus, } 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,84 @@ export class StudentInformationService { .getMany() ); } + + /** + * Get SIMS student SINs for students who have full-time applications + * meeting at least one of the following criteria: + * - A FT application with a start date between now and 90 days in the future (no cancelled apps). + * - A FT application within a study period (no cancelled apps). + * - FT disbursements sent in the last 90 days (no cancelled apps). + * Only the most recent validated SIN for each student is returned. + * @returns distinct SIMS student SINs. + */ + async getSIMSSINs(): Promise { + const results = await this.studentRepo + .createQueryBuilder("student") + .select("DISTINCT sinValidation.sin", "sin") + .innerJoin( + "student.sinValidation", + "sinValidation", + "sinValidation.isValidSIN = true", + ) + .innerJoin("student.applications", "application") + .innerJoin("application.currentAssessment", "assessment") + .innerJoin("assessment.offering", "offering") + .leftJoin("assessment.disbursementSchedules", "disbursement") + .where("application.applicationStatus != :cancelled") + .andWhere("application.offeringIntensity = :fullTime") + .andWhere( + new Brackets((qb) => + qb + .where( + "offering.studyStartDate > CURRENT_DATE AND offering.studyStartDate <= CURRENT_DATE + INTERVAL '90 days'", + ) + .orWhere( + "offering.studyStartDate <= CURRENT_DATE AND offering.studyEndDate >= CURRENT_DATE", + ) + .orWhere( + "disbursement.disbursementScheduleStatus = :sent AND disbursement.dateSent >= CURRENT_DATE - INTERVAL '90 days'", + ), + ), + ) + .setParameters({ + cancelled: ApplicationStatus.Cancelled, + fullTime: OfferingIntensity.fullTime, + sent: DisbursementScheduleStatus.Sent, + }) + .getRawMany<{ sin: string }>(); + return [...new Set(results.map((result) => result.sin))]; + } + + /** + * Get SFAS (legacy) student SINs for students who have full-time applications + * meeting at least one of the following criteria: + * - A FT application with a start date between now and 90 days in the future (no cancelled apps). + * - A FT application within a study period (no cancelled apps). + * - FT disbursements issued in the last 90 days (no cancelled apps). + * @returns distinct SFAS student SINs. + */ + async getSFASSINs(): Promise { + const results = await this.sfasApplicationRepo + .createQueryBuilder("sfasApp") + .select("DISTINCT sfasIndividual.sin", "sin") + .innerJoin("sfasApp.individual", "sfasIndividual") + .leftJoin("sfasApp.disbursements", "disbursement") + .where("sfasApp.applicationCancelDate IS NULL") + .andWhere( + new Brackets((qb) => + qb + .where( + "sfasApp.startDate > CURRENT_DATE AND sfasApp.startDate <= CURRENT_DATE + INTERVAL '90 days'", + ) + .orWhere( + "sfasApp.startDate <= CURRENT_DATE AND sfasApp.endDate >= CURRENT_DATE", + ) + .orWhere( + "disbursement.dateIssued >= CURRENT_DATE - INTERVAL '90 days'", + ), + ), + ) + .getRawMany<{ sin: string }>(); + return [...new Set(results.map((result) => result.sin))]; + } } From f098271dd166423bf20ed4365cd7d06e5c9102f0 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Mon, 23 Mar 2026 12:37:56 -0700 Subject: [PATCH 02/10] Update query to get last valid SIN --- ...ernal.controller.getActiveSINs.e2e-spec.ts | 38 +++++++++++++++++++ .../student/student.external.controller.ts | 4 +- .../student/student-information.service.ts | 12 ++++-- 3 files changed, 48 insertions(+), 6 deletions(-) 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 index 672c6d48e5..b628740177 100644 --- 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 @@ -4,6 +4,7 @@ import { createE2EDataSources, createFakeSFASApplication, createFakeSFASApplicationDisbursement, + createFakeSINValidation, E2EDataSources, saveFakeApplicationDisbursements, saveFakeSFASIndividual, @@ -143,6 +144,43 @@ describe("StudentExternalController(e2e)-getActiveSINs", () => { expect(result.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 } }, + ); + await db.sinValidation.save(newerSINValidation); + // 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 + const response = await request(app.getHttpServer()) + .get(endpoint) + .auth(token, BEARER_AUTH_TYPE) + .expect(HttpStatus.OK); + // Assert + const result = response.body as ActiveSINsAPIOutDTO; + expect(result.sins).toContain(newerSINValidation.sin); + expect(result.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); 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 8cd690db6c..69138779c2 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 @@ -120,8 +120,8 @@ export class StudentExternalController extends BaseController { @Get("active-sins") async getActiveSINs(): Promise { const [simsSINs, sfasSINs] = await Promise.all([ - this.studentInformationService.getSIMSSINs(), - this.studentInformationService.getSFASSINs(), + this.studentInformationService.getFullTimeActiveSINs(), + this.studentInformationService.getFullTimeActiveLegacySINs(), ]); return { sins: [...new Set([...simsSINs, ...sfasSINs])] }; } 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 c6c09624df..e091e1be86 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 @@ -279,14 +279,18 @@ export class StudentInformationService { * Only the most recent validated SIN for each student is returned. * @returns distinct SIMS student SINs. */ - async getSIMSSINs(): Promise { + async getFullTimeActiveSINs(): Promise { const results = await this.studentRepo .createQueryBuilder("student") .select("DISTINCT sinValidation.sin", "sin") .innerJoin( - "student.sinValidation", + "student.sinValidations", "sinValidation", - "sinValidation.isValidSIN = true", + // Joining the most recent valid SIN for each student. + "sinValidation.id = (" + + "SELECT sv.id FROM sims.sin_validations sv " + + "WHERE sv.student_id = student.id AND sv.valid_sin = true " + + "ORDER BY sv.id DESC LIMIT 1)", ) .innerJoin("student.applications", "application") .innerJoin("application.currentAssessment", "assessment") @@ -325,7 +329,7 @@ export class StudentInformationService { * - FT disbursements issued in the last 90 days (no cancelled apps). * @returns distinct SFAS student SINs. */ - async getSFASSINs(): Promise { + async getFullTimeActiveLegacySINs(): Promise { const results = await this.sfasApplicationRepo .createQueryBuilder("sfasApp") .select("DISTINCT sfasIndividual.sin", "sin") From 8b9d6b352d9739a44a371e807fb8c6bd203a8515 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Mon, 23 Mar 2026 13:36:36 -0700 Subject: [PATCH 03/10] Update endpoint name --- ...ent.external.controller.getFullTimeActiveSINs.e2e-spec.ts} | 4 ++-- .../route-controllers/student/student.external.controller.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/{student.external.controller.getActiveSINs.e2e-spec.ts => student.external.controller.getFullTimeActiveSINs.e2e-spec.ts} (98%) 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.getFullTimeActiveSINs.e2e-spec.ts similarity index 98% rename from sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts rename to sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getFullTimeActiveSINs.e2e-spec.ts index b628740177..eb616d9b52 100644 --- 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.getFullTimeActiveSINs.e2e-spec.ts @@ -23,10 +23,10 @@ import { import { addDays, getISODateOnlyString } from "@sims/utilities"; import { ActiveSINsAPIOutDTO } from "../../models/student-external-search.dto"; -describe("StudentExternalController(e2e)-getActiveSINs", () => { +describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { let app: INestApplication; let db: E2EDataSources; - const endpoint = "/external/student/active-sins"; + const endpoint = "/external/student/full-time-active-sins"; beforeAll(async () => { const { nestApplication, dataSource } = await createTestingAppModule(); 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 69138779c2..6583b7d3ec 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 @@ -117,8 +117,8 @@ export class StudentExternalController extends BaseController { * Only the most recent validated SIN is returned for SIMS students. * @returns active SINs from both SIMS and SFAS. */ - @Get("active-sins") - async getActiveSINs(): Promise { + @Get("full-time-active-sins") + async getFullTimeActiveSINs(): Promise { const [simsSINs, sfasSINs] = await Promise.all([ this.studentInformationService.getFullTimeActiveSINs(), this.studentInformationService.getFullTimeActiveLegacySINs(), From 946f39c1c649112f6730eec705c0c1b98b2f4e23 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Mon, 23 Mar 2026 13:57:35 -0700 Subject: [PATCH 04/10] Update sources/packages/backend/apps/api/src/route-controllers/student/student.external.controller.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../route-controllers/student/student.external.controller.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 6583b7d3ec..40ae293aaf 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 @@ -123,6 +123,7 @@ export class StudentExternalController extends BaseController { this.studentInformationService.getFullTimeActiveSINs(), this.studentInformationService.getFullTimeActiveLegacySINs(), ]); - return { sins: [...new Set([...simsSINs, ...sfasSINs])] }; + const mergedSINs = [...new Set([...simsSINs, ...sfasSINs])].sort(); + return { sins: mergedSINs }; } } From e303724cd76a87225b26f00ff65a0d790df89e5b Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Mon, 23 Mar 2026 14:02:14 -0700 Subject: [PATCH 05/10] Update student-information.service.ts --- .../api/src/services/student/student-information.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e091e1be86..d02176e356 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 @@ -318,7 +318,7 @@ export class StudentInformationService { sent: DisbursementScheduleStatus.Sent, }) .getRawMany<{ sin: string }>(); - return [...new Set(results.map((result) => result.sin))]; + return results.map((result) => result.sin); } /** @@ -351,6 +351,6 @@ export class StudentInformationService { ), ) .getRawMany<{ sin: string }>(); - return [...new Set(results.map((result) => result.sin))]; + return results.map((result) => result.sin); } } From 526c5b4226ba3be686f61019da7af080419e0853 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Mon, 23 Mar 2026 14:03:01 -0700 Subject: [PATCH 06/10] Update student.external.controller.ts --- .../route-controllers/student/student.external.controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 40ae293aaf..b41edc88c6 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 @@ -123,7 +123,9 @@ export class StudentExternalController extends BaseController { this.studentInformationService.getFullTimeActiveSINs(), this.studentInformationService.getFullTimeActiveLegacySINs(), ]); - const mergedSINs = [...new Set([...simsSINs, ...sfasSINs])].sort(); + const mergedSINs = [...new Set([...simsSINs, ...sfasSINs])].sort((a, b) => + a.localeCompare(b), + ); return { sins: mergedSINs }; } } From de1083509e4106a51f895bd24755add1f66a0840 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Wed, 25 Mar 2026 15:56:13 -0700 Subject: [PATCH 07/10] Comments updated --- ...rnal.controller.getActiveSINs.e2e-spec.ts} | 134 +++++++++--------- .../student/student.external.controller.ts | 18 +-- .../student/student-information.service.ts | 72 ++++++---- .../system-configurations-constants.ts | 5 + 4 files changed, 123 insertions(+), 106 deletions(-) rename sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/{student.external.controller.getFullTimeActiveSINs.e2e-spec.ts => student.external.controller.getActiveSINs.e2e-spec.ts} (76%) diff --git a/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getFullTimeActiveSINs.e2e-spec.ts b/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts similarity index 76% rename from sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getFullTimeActiveSINs.e2e-spec.ts rename to sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts index eb616d9b52..a18737060d 100644 --- a/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getFullTimeActiveSINs.e2e-spec.ts +++ b/sources/packages/backend/apps/api/src/route-controllers/student/_tests_/e2e/student.external.controller.getActiveSINs.e2e-spec.ts @@ -21,12 +21,11 @@ import { getExternalUserToken, } from "../../../../testHelpers"; import { addDays, getISODateOnlyString } from "@sims/utilities"; -import { ActiveSINsAPIOutDTO } from "../../models/student-external-search.dto"; -describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { +describe("StudentExternalController(e2e)-getActiveSINs", () => { let app: INestApplication; let db: E2EDataSources; - const endpoint = "/external/student/full-time-active-sins"; + const endpoint = "/external/student/active-sins"; beforeAll(async () => { const { nestApplication, dataSource } = await createTestingAppModule(); @@ -50,14 +49,16 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { }, ); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(student.sinValidation.sin); + .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 () => { @@ -69,23 +70,20 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { { student }, { offeringIntensity: OfferingIntensity.fullTime, - offeringInitialValues: { - studyStartDate: getISODateOnlyString(addDays(-30)), - studyEndDate: getISODateOnlyString(addDays(30)), - }, }, ); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(student.sinValidation.sin); + .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); @@ -99,22 +97,26 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { 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), + }, }, ); - // Set the disbursement as Sent with dateSent within the last 90 days. const [disbursement] = application.currentAssessment.disbursementSchedules; - disbursement.disbursementScheduleStatus = DisbursementScheduleStatus.Sent; - disbursement.dateSent = addDays(-60); await db.disbursementSchedule.save(disbursement); const token = await getExternalUserToken(); // Act - const response = await request(app.getHttpServer()) + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(student.sinValidation.sin); + .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 () => { @@ -134,14 +136,14 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { }, ); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).not.toContain(student.sinValidation.sin); + .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 () => { @@ -170,17 +172,16 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { }, ); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(newerSINValidation.sin); - expect(result.sins).not.toContain(olderValidSIN); + .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); @@ -196,14 +197,14 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { ); await db.sfasApplication.save(sfasApplication); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(individual.sin); + .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 () => { @@ -221,16 +222,15 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { ); await db.sfasApplication.save(sfasApplication); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(individual.sin); + .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); @@ -257,14 +257,14 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { ); await db.sfasApplicationDisbursement.save(disbursement); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).toContain(individual.sin); + .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 () => { @@ -283,14 +283,14 @@ describe("StudentExternalController(e2e)-getFullTimeActiveSINs", () => { ); await db.sfasApplication.save(sfasApplication); const token = await getExternalUserToken(); - // Act - const response = await request(app.getHttpServer()) + // Act/Assert + await request(app.getHttpServer()) .get(endpoint) .auth(token, BEARER_AUTH_TYPE) - .expect(HttpStatus.OK); - // Assert - const result = response.body as ActiveSINsAPIOutDTO; - expect(result.sins).not.toContain(individual.sin); + .expect(HttpStatus.OK) + .expect(({ body }) => { + expect(body.sins).not.toContain(individual.sin); + }); }); it("Should return HTTP 401 when request is not authenticated.", async () => { 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 b41edc88c6..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 @@ -24,6 +24,7 @@ import { 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. @@ -111,17 +112,18 @@ export class StudentExternalController extends BaseController { /** * 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 90 days in the future. - * - A FT application within a current study period. - * - FT disbursements sent in the last 90 days. - * Only the most recent validated SIN is returned for SIMS students. + * - 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("full-time-active-sins") - async getFullTimeActiveSINs(): Promise { + @Get("active-sins") + async getActiveSINs(): Promise { const [simsSINs, sfasSINs] = await Promise.all([ - this.studentInformationService.getFullTimeActiveSINs(), - this.studentInformationService.getFullTimeActiveLegacySINs(), + this.studentInformationService.getFullTimeActiveSINs(ACTIVE_SINS_DAYS), + this.studentInformationService.getFullTimeActiveLegacySINs( + ACTIVE_SINS_DAYS, + ), ]); const mergedSINs = [...new Set([...simsSINs, ...sfasSINs])].sort((a, b) => a.localeCompare(b), 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 d02176e356..9a69948aba 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 @@ -273,16 +273,18 @@ export class StudentInformationService { /** * Get SIMS student SINs for students who have full-time applications * meeting at least one of the following criteria: - * - A FT application with a start date between now and 90 days in the future (no cancelled apps). - * - A FT application within a study period (no cancelled apps). - * - FT disbursements sent in the last 90 days (no cancelled apps). - * Only the most recent validated SIN for each student is returned. + * - A FT application with a start date between now and the specified future window (no cancelled or edited apps). + * - FT disbursements sent within the specified past window (no cancelled or edited apps). + * Only valid SINs are returned, and duplicate SINs are excluded. + * @param activeSinsDays number of days in the past and future to consider for active SINs. * @returns distinct SIMS student SINs. */ - async getFullTimeActiveSINs(): Promise { - const results = await this.studentRepo - .createQueryBuilder("student") - .select("DISTINCT sinValidation.sin", "sin") + 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.sinValidations", "sinValidation", @@ -292,47 +294,54 @@ export class StudentInformationService { "WHERE sv.student_id = student.id AND sv.valid_sin = true " + "ORDER BY sv.id DESC LIMIT 1)", ) - .innerJoin("student.applications", "application") .innerJoin("application.currentAssessment", "assessment") .innerJoin("assessment.offering", "offering") - .leftJoin("assessment.disbursementSchedules", "disbursement") - .where("application.applicationStatus != :cancelled") - .andWhere("application.offeringIntensity = :fullTime") + .leftJoin( + "assessment.disbursementSchedules", + "disbursement", + "disbursement.disbursementScheduleStatus = :sentDisbursementStatus", + ) + .where( + "application.applicationStatus NOT IN (:cancelledApplicationStatus, :editedApplicationStatus)", + {}, + ) + .andWhere("application.offeringIntensity = :fullTimeOfferingIntensity") .andWhere( new Brackets((qb) => qb .where( - "offering.studyStartDate > CURRENT_DATE AND offering.studyStartDate <= CURRENT_DATE + INTERVAL '90 days'", - ) - .orWhere( - "offering.studyStartDate <= CURRENT_DATE AND offering.studyEndDate >= CURRENT_DATE", + "offering.studyStartDate BETWEEN CURRENT_DATE AND (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day')", ) .orWhere( - "disbursement.disbursementScheduleStatus = :sent AND disbursement.dateSent >= CURRENT_DATE - INTERVAL '90 days'", + "disbursement.dateSent BETWEEN (CURRENT_DATE - :activeSinsDays * INTERVAL '1 day') AND CURRENT_DATE", ), ), ) .setParameters({ + cancelledApplicationStatus: ApplicationStatus.Cancelled, + editedApplicationStatus: ApplicationStatus.Edited, + fullTimeOfferingIntensity: OfferingIntensity.fullTime, + sentDisbursementStatus: DisbursementScheduleStatus.Sent, cancelled: ApplicationStatus.Cancelled, - fullTime: OfferingIntensity.fullTime, - sent: DisbursementScheduleStatus.Sent, + activeSinsDays, }) - .getRawMany<{ sin: string }>(); - return results.map((result) => result.sin); + .getMany(); + return applications.map((app) => app.student.sinValidations[0].sin); } /** * Get SFAS (legacy) student SINs for students who have full-time applications * meeting at least one of the following criteria: - * - A FT application with a start date between now and 90 days in the future (no cancelled apps). - * - A FT application within a study period (no cancelled apps). - * - FT disbursements issued in the last 90 days (no cancelled apps). + * - A FT application with a start date between now and the specified future window (no cancelled apps). + * - FT disbursements issued within the specified past window (no cancelled apps). + * @param activeSinsDays number of days in the past and future to consider for active SINs. * @returns distinct SFAS student SINs. */ - async getFullTimeActiveLegacySINs(): Promise { - const results = await this.sfasApplicationRepo + async getFullTimeActiveLegacySINs(activeSinsDays: number): Promise { + const sfasApplications = await this.sfasApplicationRepo .createQueryBuilder("sfasApp") - .select("DISTINCT sfasIndividual.sin", "sin") + .select(["sfasApp.id", "sfasIndividual.sin"]) + .distinctOn(["sfasIndividual.sin"]) .innerJoin("sfasApp.individual", "sfasIndividual") .leftJoin("sfasApp.disbursements", "disbursement") .where("sfasApp.applicationCancelDate IS NULL") @@ -340,17 +349,18 @@ export class StudentInformationService { new Brackets((qb) => qb .where( - "sfasApp.startDate > CURRENT_DATE AND sfasApp.startDate <= CURRENT_DATE + INTERVAL '90 days'", + "sfasApp.startDate BETWEEN CURRENT_DATE AND (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day')", ) .orWhere( "sfasApp.startDate <= CURRENT_DATE AND sfasApp.endDate >= CURRENT_DATE", ) .orWhere( - "disbursement.dateIssued >= CURRENT_DATE - INTERVAL '90 days'", + "disbursement.dateIssued BETWEEN (CURRENT_DATE - :activeSinsDays * INTERVAL '1 day') AND CURRENT_DATE", ), ), ) - .getRawMany<{ sin: string }>(); - return results.map((result) => result.sin); + .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; From 23897f81d8cee33bf558808102c1847a3f5336f6 Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Thu, 26 Mar 2026 09:02:20 -0700 Subject: [PATCH 08/10] Update student.external.controller.getActiveSINs.e2e-spec.ts --- ...ernal.controller.getActiveSINs.e2e-spec.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) 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 index a18737060d..cd6882eed4 100644 --- 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 @@ -155,7 +155,6 @@ describe("StudentExternalController(e2e)-getActiveSINs", () => { { student }, { initialValue: { isValidSIN: true } }, ); - await db.sinValidation.save(newerSINValidation); // Update the student to point to the newer SIN validation as the current one. student.sinValidation = newerSINValidation; await db.student.save(student); @@ -182,6 +181,7 @@ describe("StudentExternalController(e2e)-getActiveSINs", () => { 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); @@ -293,10 +293,44 @@ describe("StudentExternalController(e2e)-getActiveSINs", () => { }); }); - it("Should return HTTP 401 when request is not authenticated.", async () => { + 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) - .expect(HttpStatus.UNAUTHORIZED); + .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 () => { From 52c9db320ca6215aaa18d043af8204e5359ccceb Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Sat, 28 Mar 2026 09:18:28 -0700 Subject: [PATCH 09/10] Update SIN query --- .../student/student-information.service.ts | 75 ++++++------------- 1 file changed, 21 insertions(+), 54 deletions(-) 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 9a69948aba..ba130ec1a5 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 @@ -9,9 +9,8 @@ import { Application, OfferingIntensity, SFASApplication, - DisbursementScheduleStatus, } from "@sims/sims-db"; -import { Brackets, Repository } from "typeorm"; +import { Repository } from "typeorm"; import { InjectRepository } from "@nestjs/typeorm"; const MAX_PAST_PROGRAM_YEARS_INCLUDING_CURRENT = 3; @@ -272,11 +271,12 @@ export class StudentInformationService { /** * Get SIMS student SINs for students who have full-time applications - * meeting at least one of the following criteria: - * - A FT application with a start date between now and the specified future window (no cancelled or edited apps). - * - FT disbursements sent within the specified past window (no cancelled or edited apps). - * Only valid SINs are returned, and duplicate SINs are excluded. - * @param activeSinsDays number of days in the past and future to consider for active SINs. + * where 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. + * 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 in the future to consider for upcoming study start dates. * @returns distinct SIMS student SINs. */ async getFullTimeActiveSINs(activeSinsDays: number): Promise { @@ -285,56 +285,35 @@ export class StudentInformationService { .select(["application.id", "student.id", "sinValidation.sin"]) .distinctOn(["sinValidation.sin"]) .innerJoin("application.student", "student") - .innerJoin( - "student.sinValidations", - "sinValidation", - // Joining the most recent valid SIN for each student. - "sinValidation.id = (" + - "SELECT sv.id FROM sims.sin_validations sv " + - "WHERE sv.student_id = student.id AND sv.valid_sin = true " + - "ORDER BY sv.id DESC LIMIT 1)", - ) + .innerJoin("student.sinValidation", "sinValidation") .innerJoin("application.currentAssessment", "assessment") .innerJoin("assessment.offering", "offering") - .leftJoin( - "assessment.disbursementSchedules", - "disbursement", - "disbursement.disbursementScheduleStatus = :sentDisbursementStatus", - ) .where( - "application.applicationStatus NOT IN (:cancelledApplicationStatus, :editedApplicationStatus)", - {}, + "application.applicationStatus NOT IN (:...inEligibleApplicationStatuses)", ) .andWhere("application.offeringIntensity = :fullTimeOfferingIntensity") .andWhere( - new Brackets((qb) => - qb - .where( - "offering.studyStartDate BETWEEN CURRENT_DATE AND (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day')", - ) - .orWhere( - "disbursement.dateSent BETWEEN (CURRENT_DATE - :activeSinsDays * INTERVAL '1 day') AND CURRENT_DATE", - ), - ), + "offering.studyStartDate <= (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day') AND offering.studyEndDate >= CURRENT_DATE", ) .setParameters({ - cancelledApplicationStatus: ApplicationStatus.Cancelled, - editedApplicationStatus: ApplicationStatus.Edited, + inEligibleApplicationStatuses: [ + ApplicationStatus.Cancelled, + ApplicationStatus.Edited, + ], fullTimeOfferingIntensity: OfferingIntensity.fullTime, - sentDisbursementStatus: DisbursementScheduleStatus.Sent, - cancelled: ApplicationStatus.Cancelled, activeSinsDays, }) .getMany(); - return applications.map((app) => app.student.sinValidations[0].sin); + 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: - * - A FT application with a start date between now and the specified future window (no cancelled apps). - * - FT disbursements issued within the specified past window (no cancelled apps). - * @param activeSinsDays number of days in the past and future to consider for active SINs. + * where 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. + * Cancelled applications are excluded. + * @param activeSinsDays number of days in the future to consider for upcoming study start dates. * @returns distinct SFAS student SINs. */ async getFullTimeActiveLegacySINs(activeSinsDays: number): Promise { @@ -343,21 +322,9 @@ export class StudentInformationService { .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 BETWEEN CURRENT_DATE AND (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day')", - ) - .orWhere( - "sfasApp.startDate <= CURRENT_DATE AND sfasApp.endDate >= CURRENT_DATE", - ) - .orWhere( - "disbursement.dateIssued BETWEEN (CURRENT_DATE - :activeSinsDays * INTERVAL '1 day') AND CURRENT_DATE", - ), - ), + "sfasApp.startDate <= (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day') AND sfasApp.endDate >= CURRENT_DATE", ) .setParameters({ activeSinsDays }) .getMany(); From a66944d228f55a0b9ca83c0e647ec2400b813edc Mon Sep 17 00:00:00 2001 From: Tiago Graf Date: Sat, 28 Mar 2026 20:56:28 -0700 Subject: [PATCH 10/10] Update student-information.service.ts --- .../student/student-information.service.ts | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) 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 ba130ec1a5..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; @@ -271,12 +272,14 @@ export class StudentInformationService { /** * Get SIMS student SINs for students who have full-time applications - * where 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. + * 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 in the future to consider for upcoming study start dates. + * @param activeSinsDays number of days used for the lookahead and lookback windows. * @returns distinct SIMS student SINs. */ async getFullTimeActiveSINs(activeSinsDays: number): Promise { @@ -288,12 +291,19 @@ export class StudentInformationService { .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( - "offering.studyStartDate <= (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day') AND offering.studyEndDate >= CURRENT_DATE", + 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: [ @@ -302,6 +312,7 @@ export class StudentInformationService { ], fullTimeOfferingIntensity: OfferingIntensity.fullTime, activeSinsDays, + sentStatus: DisbursementScheduleStatus.Sent, }) .getMany(); return applications.map((app) => app.student.sinValidation.sin); @@ -309,11 +320,13 @@ export class StudentInformationService { /** * Get SFAS (legacy) student SINs for students who have full-time applications - * where 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. + * 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 in the future to consider for upcoming study start dates. + * @param activeSinsDays number of days used for the lookahead and lookback windows. * @returns distinct SFAS student SINs. */ async getFullTimeActiveLegacySINs(activeSinsDays: number): Promise { @@ -322,9 +335,16 @@ export class StudentInformationService { .select(["sfasApp.id", "sfasIndividual.sin"]) .distinctOn(["sfasIndividual.sin"]) .innerJoin("sfasApp.individual", "sfasIndividual") + .leftJoin("sfasApp.disbursements", "disbursement") .where("sfasApp.applicationCancelDate IS NULL") .andWhere( - "sfasApp.startDate <= (CURRENT_DATE + :activeSinsDays * INTERVAL '1 day') AND sfasApp.endDate >= CURRENT_DATE", + 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();