diff --git a/AGENTS.md b/AGENTS.md index 1c7c33c6..6e94e9fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -178,6 +178,16 @@ flowchart TD Cross-year-matched rows are never sent to the ODS — they're only made available through the Runway app's API, which EDU and other external consumers query. +### Roster sources & no-ODS year selectability + +A roster is the student lookup the executor matches input rows against. Source precedence: + +1. **ODS** — for `sendToOds` years, the executor fetches the roster from the ODS API. +2. **EDU** — the cross-year roster from EDU (Snowflake), pulled via `appUrls.roster` as NDJSON when `crossYearMatchAvailable`. Two roles: for ODS years, it's the second-pass match for IDs that didn't match the ODS roster (see Cross-Year Matching Flow — those rows are never sent to the ODS); for no-ODS (`sendToOds=false`) years, it's the roster source, preferred over the S3 file (the executor handles this preference). +3. **S3 roster file** — the fallback for no-ODS years when cross-year matching is unavailable (`__rosters/...jsonl`). The app omits `rosterFilePath` from the payload when `crossYearMatchAvailable` is true (it would be a dangling pointer). + +A no-ODS year is **selectable** at job creation, and shows **green** ("roster available") on the ODS-config page, when a roster file exists **OR** the partner has cross-year matching enabled. The executor payload's `crossYearMatchAvailable` is the same partner setting (`crossYearMatchingEnabled`) — there is no creds/connection check at run-prep time. The admin enable endpoint requires working EDU creds to turn the toggle on; once on, the EDU connection is an assumed dependency like postgres or S3: if EDU is unavailable mid-run, the run fails loudly at roster-fetch time rather than silently degrading to weaker matching. (In practice a tenant has either a roster file or an EDU connection, not both, so there is no fallback to preserve.) + ### S3 Path Structure ``` diff --git a/app/api/integration/tests/earthbeam-api.spec.ts b/app/api/integration/tests/earthbeam-api.spec.ts index e9c7454f..26ac1c05 100644 --- a/app/api/integration/tests/earthbeam-api.spec.ts +++ b/app/api/integration/tests/earthbeam-api.spec.ts @@ -107,31 +107,17 @@ describe('Earthbeam API', () => { }); describe('cross-year ID matching', () => { - // Default state per test: both gates ON (toggle enabled + creds present) - // so the happy path requires no overrides and each negative test reads - // as "remove one condition, expect the flag to flip false." - let getInfoSpy: jest.SpyInstance; - + // crossYearMatchAvailable mirrors the partner toggle alone — there is no + // creds/connection check at run-prep time. EDU problems surface as loud + // run failures at roster-fetch time instead of silent degradation. beforeEach(async () => { await global.prisma.partner.update({ where: { id: partnerA.id }, data: { crossYearMatchingEnabled: true }, }); - const configService = app.get(AppConfigService); - getInfoSpy = jest.spyOn(configService, 'getEduConnectionInfo').mockResolvedValue({ - username: 'snowflake-user', - account: 'example', - database: 'edu_stg', - schema: 'public', - privateKey: Buffer.from('priv'), - }); - }); - - afterEach(() => { - getInfoSpy.mockRestore(); }); - it('sets crossYearMatchAvailable=true and emits appUrls.roster when toggle on and creds exist', async () => { + it('sets crossYearMatchAvailable=true and emits appUrls.roster when the partner toggle is on', async () => { const res = await request(app.getHttpServer()) .get(endpointA) .set('Authorization', `Bearer ${tokenA}`); @@ -157,16 +143,25 @@ describe('Earthbeam API', () => { expect(res.body.appUrls.roster).toBeUndefined(); }); - it('sets crossYearMatchAvailable=false and omits appUrls.roster when creds are missing', async () => { - getInfoSpy.mockResolvedValue(null); + it('omits rosterFilePath on a no-ODS job when cross-year matching is available (EDU is the source)', async () => { + const authService = app.get(EarthbeamApiAuthService); + const noOdsJob = await seedJob({ + sendToOds: false, + schoolYearId: '2324', + bundle: bundleA, + tenant: tenantA, + }); + const noOdsRun = noOdsJob.runs[0]; + const noOdsToken = await authService.createAccessToken({ runId: noOdsRun.id }); const res = await request(app.getHttpServer()) - .get(endpointA) - .set('Authorization', `Bearer ${tokenA}`); + .get(`/earthbeam/jobs/${noOdsRun.id}`) + .set('Authorization', `Bearer ${noOdsToken}`); expect(res.status).toBe(200); - expect(res.body.crossYearMatchAvailable).toBe(false); - expect(res.body.appUrls.roster).toBeUndefined(); + expect(res.body.crossYearMatchAvailable).toBe(true); + expect(res.body.appUrls.roster).toBeDefined(); + expect(res.body.rosterFilePath).toBeUndefined(); }); }); diff --git a/app/api/integration/tests/external-api.v1.spec.ts b/app/api/integration/tests/external-api.v1.spec.ts index 9449c1ee..f2ce9cdf 100644 --- a/app/api/integration/tests/external-api.v1.spec.ts +++ b/app/api/integration/tests/external-api.v1.spec.ts @@ -275,6 +275,43 @@ describe('ExternalApiV1', () => { expect(job?.odsId).toBeNull(); expect(job?.sendToOds).toBe(false); }); + + it('should create a no-ODS job without a roster file when cross-year matching is enabled', async () => { + await prisma.schoolYearConfig.create({ + data: { + partnerId: partnerA.id, + schoolYearId: '2324', + isEnabled: true, + sendToOds: false, + }, + }); + await prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + + const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock; + doesFileExistMock.mockResolvedValue(false); + + try { + const res = await request(app.getHttpServer()) + .post(endpoint) + .set('Authorization', `Bearer ${token}`) + .send({ + ...jobInput, + schoolYear: '2024', + }); + + expect(res.status).toBe(201); + + const job = await prisma.job.findUnique({ where: { uid: res.body.uid } }); + expect(job?.odsId).toBeNull(); + expect(job?.sendToOds).toBe(false); + } finally { + // No partner reset needed — seed data is refreshed before each test + doesFileExistMock.mockResolvedValue(true); + } + }); }); describe('API Client Info', () => { @@ -537,7 +574,9 @@ describe('ExternalApiV1', () => { }); expect(res.status).toBe(400); - expect(res.body.message).toContain('No roster file found'); + expect(res.body.message).toContain( + 'No roster file found and cross-year matching not enabled' + ); } finally { doesFileExistMock.mockResolvedValue(true); } diff --git a/app/api/integration/tests/jobs.spec.ts b/app/api/integration/tests/jobs.spec.ts index cabdf206..fe04abd2 100644 --- a/app/api/integration/tests/jobs.spec.ts +++ b/app/api/integration/tests/jobs.spec.ts @@ -343,6 +343,8 @@ describe('POST /jobs', () => { expect(job?.sendToOds).toBe(false); }); + // Partner A defaults to crossYearMatchingEnabled=false, so this also + // guards the "cross-year matching disabled" rejection path. it('should reject no-ODS jobs when the roster file does not exist', async () => { await prisma.schoolYearConfig.create({ data: { @@ -366,7 +368,9 @@ describe('POST /jobs', () => { }); expect(res.status).toBe(400); - expect(res.body.message).toContain('No roster file found'); + expect(res.body.message).toContain( + 'No roster file found and cross-year matching not enabled' + ); } finally { doesFileExistMock.mockResolvedValue(true); } @@ -400,6 +404,43 @@ describe('POST /jobs', () => { } }); + it('should create a no-ODS job without a roster file when cross-year matching is enabled', async () => { + await prisma.schoolYearConfig.create({ + data: { + partnerId: tenantA.partnerId, + schoolYearId: '2324', + isEnabled: true, + sendToOds: false, + }, + }); + await prisma.partner.update({ + where: { id: tenantA.partnerId }, + data: { crossYearMatchingEnabled: true }, + }); + + const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock; + doesFileExistMock.mockResolvedValue(false); + + try { + const res = await request(app.getHttpServer()) + .post(endpoint) + .set('Cookie', [sessionA.cookie]) + .send({ + ...postJobDto, + schoolYearId: '2324', + }); + + expect(res.status).toBe(201); + + const job = await prisma.job.findUnique({ where: { id: res.body.id } }); + expect(job?.odsId).toBeNull(); + expect(job?.sendToOds).toBe(false); + } finally { + // No partner reset needed — seed data is refreshed before each test + doesFileExistMock.mockResolvedValue(true); + } + }); + it('should reject requests when the school year is not enabled', async () => { const res = await request(app.getHttpServer()) .post(endpoint) diff --git a/app/api/integration/tests/school-year-config.spec.ts b/app/api/integration/tests/school-year-config.spec.ts index 6e6656d4..32fa460a 100644 --- a/app/api/integration/tests/school-year-config.spec.ts +++ b/app/api/integration/tests/school-year-config.spec.ts @@ -286,22 +286,22 @@ describe('GET /school-year-config/tenant', () => { expect(yearIds).toContain('2526'); expect(yearIds).not.toContain('2324'); - // 2425: sendToOds=true → hasRoster is null (no S3 check), hasOds from tenant's ODS config + // 2425: sendToOds=true → hasNonOdsRoster is null (no S3 check), hasOds from tenant's ODS config const row2425 = res.body.find((r: any) => r.schoolYearId === '2425'); expect(row2425).toMatchObject({ sendToOds: true, hasOds: true, - hasRoster: null, + hasNonOdsRoster: null, startYear: 2024, endYear: 2025, }); - // 2526: sendToOds=false → hasRoster checked via S3, hasOds still reflects ODS config existence + // 2526: sendToOds=false → hasNonOdsRoster checked via S3, hasOds still reflects ODS config existence const row2526 = res.body.find((r: any) => r.schoolYearId === '2526'); expect(row2526).toMatchObject({ sendToOds: false, hasOds: true, - hasRoster: true, + hasNonOdsRoster: true, startYear: 2025, endYear: 2026, }); @@ -340,19 +340,44 @@ describe('GET /school-year-config/tenant', () => { expect(row2526.hasOds).toBe(true); }); - it('should return hasRoster=false when roster file does not exist', async () => { + it('should return hasNonOdsRoster=false when roster file does not exist and cross-year matching is not enabled', async () => { const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock; doesFileExistMock.mockResolvedValue(false); try { const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [cookieA]); - // 2526 is seeded as sendToOds=false, so hasRoster reflects the S3 check + // 2526 is seeded as sendToOds=false and partner A defaults to + // crossYearMatchingEnabled=false, so hasNonOdsRoster reflects the S3 check const row2526 = res.body.find((r: any) => r.schoolYearId === '2526'); - expect(row2526.hasRoster).toBe(false); + expect(row2526.hasNonOdsRoster).toBe(false); } finally { doesFileExistMock.mockResolvedValue(true); } }); + + it('should return hasNonOdsRoster=true for a no-ODS year when cross-year matching is enabled, even with no roster file', async () => { + const doesFileExistMock = app.get(FileService).doesFileExist as jest.Mock; + doesFileExistMock.mockResolvedValue(false); + await global.prisma.partner.update({ + where: { id: partnerA.id }, + data: { crossYearMatchingEnabled: true }, + }); + + try { + const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [cookieA]); + + // 2526 is sendToOds=false; toggle on → hasNonOdsRoster true even with no file + const row2526 = res.body.find((r: any) => r.schoolYearId === '2526'); + expect(row2526.hasNonOdsRoster).toBe(true); + + // ODS years stay null regardless of the toggle + const row2425 = res.body.find((r: any) => r.schoolYearId === '2425'); + expect(row2425.hasNonOdsRoster).toBeNull(); + } finally { + // No partner reset needed — seed data is refreshed before each test + doesFileExistMock.mockResolvedValue(true); + } + }); }); }); diff --git a/app/api/src/earthbeam/api/earthbeam-api.service.ts b/app/api/src/earthbeam/api/earthbeam-api.service.ts index 9c2e9ba1..400d73df 100644 --- a/app/api/src/earthbeam/api/earthbeam-api.service.ts +++ b/app/api/src/earthbeam/api/earthbeam-api.service.ts @@ -30,7 +30,6 @@ import { EventEmitterService, EVENT_EMITTER_SERVICE, } from 'api/src/event-emitter/event-emitter.service'; -import { EduSnowflakePoolService } from './edu-snowflake-pool.service'; @Injectable() export class EarthbeamApiService { @@ -41,8 +40,7 @@ export class EarthbeamApiService { private readonly encryptionService: EncryptionService, private readonly fileService: FileService, private readonly configService: AppConfigService, - @Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService, - private readonly eduPool: EduSnowflakePoolService + @Inject(EVENT_EMITTER_SERVICE) private readonly eventEmitter: EventEmitterService ) {} async earthbeamInputForRun(runId: Run['id']) { @@ -143,9 +141,12 @@ export class EarthbeamApiService { const executorBaseUrl = this.configService.executorCallbackBaseUrl(); - const partnerId = job.tenant.partnerId; - const crossYearEnabled = job.tenant.partner.crossYearMatchingEnabled; - const crossYearMatchAvailable = crossYearEnabled && (await this.eduPool.canConnect(partnerId)); + // The partner toggle alone — no creds/connection check. Once the toggle is + // on (the admin enable endpoint requires working creds to turn it on), the + // EDU connection is an assumed dependency like postgres or S3: if EDU is + // unavailable mid-run, the run fails loudly rather than silently degrading + // to weaker matching. + const crossYearMatchAvailable = job.tenant.partner.crossYearMatchingEnabled; const payload: EarthbeamApiJobResponseDto = { appDataBasePath: `${job.fileProtocol}://${job.fileBucketOrHost}/${job.fileBasePath}`, @@ -171,9 +172,14 @@ export class EarthbeamApiService { }, crossYearMatchAvailable, sendToOds: job.sendToOds, - rosterFilePath: job.sendToOds - ? undefined - : `s3://${this.configService.rosterBucket()}/${rosterFileKey(job, job.schoolYear)}`, + // When cross-year matching is available, the executor pulls the roster + // from EDU via appUrls.roster, so the S3 file path would be a dangling + // (often nonexistent) pointer — omit it. The executor only reads + // rosterFilePath in its non-cross-year branch. + rosterFilePath: + job.sendToOds || crossYearMatchAvailable + ? undefined + : `s3://${this.configService.rosterBucket()}/${rosterFileKey(job, job.schoolYear)}`, // odsConnection check narrows the type — the early guard ensures it's present when sendToOds assessmentDatastore: odsConnection && job.sendToOds diff --git a/app/api/src/external-api/v1/jobs.v1.controller.ts b/app/api/src/external-api/v1/jobs.v1.controller.ts index cefe9a2b..214adc2c 100644 --- a/app/api/src/external-api/v1/jobs.v1.controller.ts +++ b/app/api/src/external-api/v1/jobs.v1.controller.ts @@ -112,7 +112,7 @@ export class ExternalApiV1JobsController { school_year_config_missing: `School year is not enabled: ${year}`, school_year_disabled: `School year is not enabled: ${year}`, ods_not_found: `No ODS found for school year: ${year}`, - roster_file_missing: `No roster file found for school year: ${year}`, + roster_unavailable: `No roster file found and cross-year matching not enabled for school year: ${year}`, }; throw new BadRequestException(messages[destination.code]); } diff --git a/app/api/src/jobs/jobs.controller.ts b/app/api/src/jobs/jobs.controller.ts index 0cdc6b27..979ded1b 100644 --- a/app/api/src/jobs/jobs.controller.ts +++ b/app/api/src/jobs/jobs.controller.ts @@ -173,7 +173,7 @@ export class JobsController { school_year_config_missing: `School year is not enabled: ${year}`, school_year_disabled: `School year is not enabled: ${year}`, ods_not_found: `No ODS found for school year: ${year}`, - roster_file_missing: `No roster file found for school year: ${year}`, + roster_unavailable: `No roster file found and cross-year matching not enabled for school year: ${year}`, }; throw new BadRequestException(messages[destination.code]); } diff --git a/app/api/src/jobs/jobs.service.ts b/app/api/src/jobs/jobs.service.ts index ef2e5354..0eb040ad 100644 --- a/app/api/src/jobs/jobs.service.ts +++ b/app/api/src/jobs/jobs.service.ts @@ -59,7 +59,7 @@ export class JobsService { | 'school_year_config_missing' | 'school_year_disabled' | 'ods_not_found' - | 'roster_file_missing'; + | 'roster_unavailable'; } > { const config = await this.prisma.schoolYearConfig.findUnique({ @@ -95,10 +95,31 @@ export class JobsService { } if (!config.sendToOds) { - const rosterKey = rosterFileKey({ partnerId: input.tenant.partnerId, tenantCode: input.tenant.code }, config.schoolYear); - const rosterExists = await this.fileService.doesFileExist(rosterKey, this.appConfig.rosterBucket()); - if (!rosterExists) { - return { status: 'error', code: 'roster_file_missing' }; + // A no-ODS year is valid if a roster file exists OR the partner has + // cross-year matching enabled (EDU can supply the roster). + // Short-circuit the S3 check when the toggle is on; we don't need the file. + const partner = await this.prisma.partner.findUnique({ + where: { id: input.tenant.partnerId }, + select: { crossYearMatchingEnabled: true }, + }); + if (!partner) { + // Every tenant has a partner (FK) — a missing one is an invariant + // violation, not a "cross-year disabled" case. Don't proceed. + throw new Error(`Partner not found: ${input.tenant.partnerId}`); + } + + if (!partner.crossYearMatchingEnabled) { + const rosterKey = rosterFileKey( + { partnerId: input.tenant.partnerId, tenantCode: input.tenant.code }, + config.schoolYear + ); + const rosterExists = await this.fileService.doesFileExist( + rosterKey, + this.appConfig.rosterBucket() + ); + if (!rosterExists) { + return { status: 'error', code: 'roster_unavailable' }; + } } return { @@ -412,5 +433,4 @@ export class JobsService { return { result: 'JOB_STARTED', job, run }; } - } diff --git a/app/api/src/school-year-config/school-year-config.controller.ts b/app/api/src/school-year-config/school-year-config.controller.ts index 38f9136a..d855c221 100644 --- a/app/api/src/school-year-config/school-year-config.controller.ts +++ b/app/api/src/school-year-config/school-year-config.controller.ts @@ -6,6 +6,7 @@ import { Get, Headers, Inject, + InternalServerErrorException, ParseArrayPipe, Put, Res, @@ -37,6 +38,20 @@ export class SchoolYearConfigController { @Authorize('school-year-config.read') @Get('tenant') async getTenantConfig(@TenantDecorator() tenant: Tenant) { + // When cross-year matching is enabled, EDU can supply the roster, so a + // no-ODS year has a roster regardless of any S3 file. Partner setting only + // — no creds/connection check. + const partner = await this.prisma.partner.findUnique({ + where: { id: tenant.partnerId }, + select: { crossYearMatchingEnabled: true }, + }); + if (!partner) { + // Every tenant has a partner (FK) — a missing one is an invariant + // violation, not a "cross-year disabled" case. Don't proceed. + throw new InternalServerErrorException(`Partner not found: ${tenant.partnerId}`); + } + const crossYearMatchingEnabled = partner.crossYearMatchingEnabled; + const schoolYears = await this.prisma.schoolYear.findMany({ where: { schoolYearConfig: { @@ -73,8 +88,13 @@ export class SchoolYearConfigController { ); } - const hasRoster = config.sendToOds + // ODS years use an ODS-fetched roster, so this is null for them. For + // no-ODS years, a roster is available from an S3 file or, when + // cross-year matching is enabled, from EDU. + const hasNonOdsRoster = config.sendToOds ? null + : crossYearMatchingEnabled + ? true : await this.fileService.doesFileExist( rosterFileKey({ partnerId: tenant.partnerId, tenantCode: tenant.code }, schoolYear), this.appConfig.rosterBucket(), @@ -86,7 +106,7 @@ export class SchoolYearConfigController { endYear: schoolYear.endYear, sendToOds: config.sendToOds, hasOds: schoolYear.odsConfig.length > 0, - hasRoster, + hasNonOdsRoster, }; }) ); diff --git a/app/fe/src/app/Pages/Home/SetupRequiredPage.tsx b/app/fe/src/app/Pages/Home/SetupRequiredPage.tsx index 422c5bbb..9b4ccefc 100644 --- a/app/fe/src/app/Pages/Home/SetupRequiredPage.tsx +++ b/app/fe/src/app/Pages/Home/SetupRequiredPage.tsx @@ -10,8 +10,8 @@ import { tenantSchoolYearConfigQuery } from '../../api'; // user at whatever will unblock them: // - If any year is configured to send to an ODS, guide them to configure // one — that's the only step they can take themselves. -// - Otherwise, they need an admin to either load a roster file or enable -// school years, so point them at support. +// - Otherwise, they need an admin to either configure a roster source or +// enable school years, so point them at support. export const SetupRequiredPage = () => { const { data: yearConfigs } = useSuspenseQuery(tenantSchoolYearConfigQuery); @@ -30,12 +30,12 @@ export const SetupRequiredPage = () => { const doesAnyYearSendToOds = yearConfigs.some((y) => y.sendToOds); if (!doesAnyYearSendToOds) { return ( - + - Before you can start uploading assessments, a roster file must be loaded for your + Before you can start uploading assessments, a roster source must be configured for your district. Please contact support for assistance. - + ); } diff --git a/app/fe/src/app/Pages/Jobs/JobCreatePage.tsx b/app/fe/src/app/Pages/Jobs/JobCreatePage.tsx index b859b885..c89d7cae 100644 --- a/app/fe/src/app/Pages/Jobs/JobCreatePage.tsx +++ b/app/fe/src/app/Pages/Jobs/JobCreatePage.tsx @@ -209,8 +209,8 @@ export const JobCreatePage = () => { label: `${year.startYear} - ${year.endYear} school year${ year.sendToOds && !year.hasOds ? ' (no ODS configured)' - : !year.sendToOds && year.hasRoster !== true - ? ' (no roster file loaded)' + : !year.sendToOds && year.hasNonOdsRoster !== true + ? ' (no roster available)' : '' }`, value: year.schoolYearId, @@ -219,7 +219,7 @@ export const JobCreatePage = () => { const year = selectableYears.find((row) => row.schoolYearId === option.value); if (!year) return true; // narrow .find() return type if (year.sendToOds && !year.hasOds) return true; - if (!year.sendToOds && year.hasRoster !== true) return true; + if (!year.sendToOds && year.hasNonOdsRoster !== true) return true; return false; }} > diff --git a/app/fe/src/app/Pages/Jobs/JobViewComponents/UnmatchedStudents.tsx b/app/fe/src/app/Pages/Jobs/JobViewComponents/UnmatchedStudents.tsx index 79ef83ac..6911cf99 100644 --- a/app/fe/src/app/Pages/Jobs/JobViewComponents/UnmatchedStudents.tsx +++ b/app/fe/src/app/Pages/Jobs/JobViewComponents/UnmatchedStudents.tsx @@ -57,7 +57,7 @@ export const UnmatchedStudents = ({ job }: { job: GetJobDto }) => { )} If the file already contains the correct ID, then the student likely does not exist in - {job.sendToOds ? ' the ODS' : ' the roster file'}. Contact your district administrator. + {job.sendToOds ? ' the ODS' : ' the roster'}. Contact your district administrator. diff --git a/app/fe/src/app/Pages/Ods/OdsConfigsPage.tsx b/app/fe/src/app/Pages/Ods/OdsConfigsPage.tsx index 1e9f1f64..4a4693a8 100644 --- a/app/fe/src/app/Pages/Ods/OdsConfigsPage.tsx +++ b/app/fe/src/app/Pages/Ods/OdsConfigsPage.tsx @@ -103,7 +103,7 @@ export const OdsConfigsPage = () => { onDelete={() => odsConfig && confirmDelete(yearConfig, odsConfig)} /> ) : ( - + )} ); @@ -228,8 +228,8 @@ const RosterYearContent = ({ hasRoster }: { hasRoster: boolean }) => { {!hasRoster && ( - A roster file is required to match student IDs. Contact support to have a roster file - loaded. + A roster is required to match student IDs. Contact support to have a roster source + configured. )} @@ -243,10 +243,10 @@ const RosterYearContent = ({ hasRoster }: { hasRoster: boolean }) => { {hasRoster ? : } - {hasRoster ? 'roster file loaded' : 'roster file not loaded'} + {hasRoster ? 'roster available' : 'roster not available'} - {!hasRoster && } + {!hasRoster && } ); diff --git a/app/fe/src/app/routes/index.tsx b/app/fe/src/app/routes/index.tsx index e41d5b1e..3ae622fd 100644 --- a/app/fe/src/app/routes/index.tsx +++ b/app/fe/src/app/routes/index.tsx @@ -15,7 +15,7 @@ export const Route = createFileRoute('/')({ // the admin who needs to enable years in the first place and define // whether jobs for those years are sent to an ODS. const isAnyYearReadyForJobs = yearConfigs.some((y) => - y.sendToOds ? y.hasOds : y.hasRoster === true + y.sendToOds ? y.hasOds : y.hasNonOdsRoster === true ); const isPartnerAdmin = me?.roles?.includes('PartnerAdmin') ?? false; if (isAnyYearReadyForJobs || isPartnerAdmin) { diff --git a/app/models/src/dtos/school-year-config.dto.ts b/app/models/src/dtos/school-year-config.dto.ts index 621d5f6e..d2b4a9d7 100644 --- a/app/models/src/dtos/school-year-config.dto.ts +++ b/app/models/src/dtos/school-year-config.dto.ts @@ -42,8 +42,11 @@ export class GetTenantSchoolYearConfigDto { @Expose() hasOds: boolean; + // Null for ODS years (they use an ODS-fetched roster). For no-ODS years, + // true when a roster is available — from an S3 file or, when cross-year + // matching is enabled, from EDU. @Expose() - hasRoster: boolean | null; + hasNonOdsRoster: boolean | null; } export const toGetTenantSchoolYearConfigDto = makeSerializer(