@@ -128,34 +128,7 @@ export class PipelineOrchestratorService {
128128 if ( input . courseId ) scope . course = input . courseId ;
129129
130130 const coverage = await this . ComputeCoverageStats ( fork , scope ) ;
131-
132- // Generate warnings
133- const warnings : string [ ] = [ ] ;
134- if ( coverage . responseRate < COVERAGE_WARNINGS . MIN_RESPONSE_RATE ) {
135- warnings . push (
136- `Response rate is ${ ( coverage . responseRate * 100 ) . toFixed ( 1 ) } % (below ${ COVERAGE_WARNINGS . MIN_RESPONSE_RATE * 100 } % threshold).` ,
137- ) ;
138- }
139- if ( coverage . submissionCount < COVERAGE_WARNINGS . MIN_SUBMISSIONS ) {
140- warnings . push (
141- `Only ${ coverage . submissionCount } submissions (minimum recommended: ${ COVERAGE_WARNINGS . MIN_SUBMISSIONS } ).` ,
142- ) ;
143- }
144- if ( coverage . commentCount < COVERAGE_WARNINGS . MIN_COMMENTS ) {
145- warnings . push (
146- `Only ${ coverage . commentCount } qualitative comments (minimum recommended: ${ COVERAGE_WARNINGS . MIN_COMMENTS } ).` ,
147- ) ;
148- }
149- if ( coverage . lastEnrollmentSyncAt ) {
150- const hoursSinceSync =
151- ( Date . now ( ) - coverage . lastEnrollmentSyncAt . getTime ( ) ) / 3_600_000 ;
152- if ( hoursSinceSync > COVERAGE_WARNINGS . STALE_SYNC_HOURS ) {
153- const daysStale = Math . floor ( hoursSinceSync / 24 ) ;
154- warnings . push (
155- `Enrollment data may be stale (last synced ${ daysStale } day${ daysStale !== 1 ? 's' : '' } ago).` ,
156- ) ;
157- }
158- }
131+ const warnings = this . BuildCoverageWarnings ( coverage ) ;
159132
160133 const pipeline = fork . create ( AnalysisPipeline , {
161134 semester : fork . getReference ( Semester , input . semesterId ) ,
@@ -478,30 +451,63 @@ export class PipelineOrchestratorService {
478451 } ) ;
479452 }
480453
481- // Compute lastEnrollmentSyncAt by scoping through courses in submission scope
482- const scope = buildSubmissionScope ( pipeline ) ;
454+ // Coverage stats are cached on the pipeline entity at creation time. For
455+ // pipelines still awaiting confirmation, recompute on every status fetch
456+ // so the user sees the latest submission/enrollment counts before they
457+ // lock in the snapshot. After confirmation, the stored values represent
458+ // what was actually analyzed and must not drift.
459+ const scope = this . BuildScopeFromPipeline ( pipeline ) ;
460+ let totalEnrolled = pipeline . totalEnrolled ;
461+ let submissionCount = pipeline . submissionCount ;
462+ let commentCount = pipeline . commentCount ;
463+ let responseRate = Number ( pipeline . responseRate ) ;
464+ let warnings = pipeline . warnings ;
483465 let lastEnrollmentSyncAt : Date | null = null ;
484- const scopedSubs = await fork . find (
485- QuestionnaireSubmission ,
486- {
487- ...scope ,
488- qualitativeComment : { $ne : null } ,
489- } ,
490- { fields : [ 'course' ] } ,
491- ) ;
492- const courseIds = [
493- ...new Set (
494- scopedSubs . map ( ( s ) => s . course ?. id ) . filter ( ( id ) : id is string => ! ! id ) ,
495- ) ,
496- ] ;
497- if ( courseIds . length > 0 ) {
498- const latestEnrollment = await fork . findOne (
499- Enrollment ,
500- { isActive : true , course : { $in : courseIds } } ,
501- { orderBy : { updatedAt : 'DESC' } } ,
466+
467+ if ( pipeline . status === PipelineStatus . AWAITING_CONFIRMATION ) {
468+ const freshCoverage = await this . ComputeCoverageStats ( fork , scope ) ;
469+ totalEnrolled = freshCoverage . totalEnrolled ;
470+ submissionCount = freshCoverage . submissionCount ;
471+ commentCount = freshCoverage . commentCount ;
472+ responseRate = freshCoverage . responseRate ;
473+ lastEnrollmentSyncAt = freshCoverage . lastEnrollmentSyncAt ;
474+ warnings = this . BuildCoverageWarnings ( freshCoverage ) ;
475+
476+ // Persist refreshed snapshot so the values shown here match what will
477+ // be locked in at confirmation time.
478+ pipeline . totalEnrolled = freshCoverage . totalEnrolled ;
479+ pipeline . submissionCount = freshCoverage . submissionCount ;
480+ pipeline . commentCount = freshCoverage . commentCount ;
481+ pipeline . responseRate = freshCoverage . responseRate ;
482+ pipeline . warnings = warnings ;
483+ await fork . flush ( ) ;
484+ } else {
485+ // For confirmed/terminal pipelines, derive lastEnrollmentSyncAt from
486+ // courses in the original submission scope (snapshot view).
487+ const scopedSubs = await fork . find (
488+ QuestionnaireSubmission ,
489+ {
490+ ...scope ,
491+ qualitativeComment : { $ne : null } ,
492+ } ,
493+ { fields : [ 'course' ] } ,
502494 ) ;
503- if ( latestEnrollment ) {
504- lastEnrollmentSyncAt = latestEnrollment . updatedAt ;
495+ const courseIds = [
496+ ...new Set (
497+ scopedSubs
498+ . map ( ( s ) => s . course ?. id )
499+ . filter ( ( id ) : id is string => ! ! id ) ,
500+ ) ,
501+ ] ;
502+ if ( courseIds . length > 0 ) {
503+ const latestEnrollment = await fork . findOne (
504+ Enrollment ,
505+ { isActive : true , course : { $in : courseIds } } ,
506+ { orderBy : { updatedAt : 'DESC' } } ,
507+ ) ;
508+ if ( latestEnrollment ) {
509+ lastEnrollmentSyncAt = latestEnrollment . updatedAt ;
510+ }
505511 }
506512 }
507513
@@ -554,10 +560,10 @@ export class PipelineOrchestratorService {
554560 course : pipeline . course ?. shortname || null ,
555561 } ,
556562 coverage : {
557- totalEnrolled : pipeline . totalEnrolled ,
558- submissionCount : pipeline . submissionCount ,
559- commentCount : pipeline . commentCount ,
560- responseRate : Number ( pipeline . responseRate ) ,
563+ totalEnrolled,
564+ submissionCount,
565+ commentCount,
566+ responseRate,
561567 lastEnrollmentSyncAt : lastEnrollmentSyncAt ?. toISOString ( ) || null ,
562568 } ,
563569 stages : {
@@ -585,7 +591,7 @@ export class PipelineOrchestratorService {
585591 recommendationRun ,
586592 ) ,
587593 } ,
588- warnings : pipeline . warnings ,
594+ warnings,
589595 errorMessage : pipeline . errorMessage ?? null ,
590596 // Intent signal for future error categorization — currently equivalent to status === FAILED
591597 retryable : pipeline . status === PipelineStatus . FAILED ,
@@ -636,6 +642,48 @@ export class PipelineOrchestratorService {
636642
637643 // --- Private Helpers ---
638644
645+ private BuildScopeFromPipeline ( pipeline : AnalysisPipeline ) : ScopeFilter {
646+ const scope : ScopeFilter = { semester : pipeline . semester . id } ;
647+ if ( pipeline . faculty ) scope . faculty = pipeline . faculty . id ;
648+ if ( pipeline . questionnaireVersion )
649+ scope . questionnaireVersion = pipeline . questionnaireVersion . id ;
650+ if ( pipeline . department ) scope . department = pipeline . department . id ;
651+ if ( pipeline . program ) scope . program = pipeline . program . id ;
652+ if ( pipeline . campus ) scope . campus = pipeline . campus . id ;
653+ if ( pipeline . course ) scope . course = pipeline . course . id ;
654+ return scope ;
655+ }
656+
657+ private BuildCoverageWarnings ( coverage : CoverageStats ) : string [ ] {
658+ const warnings : string [ ] = [ ] ;
659+ if ( coverage . responseRate < COVERAGE_WARNINGS . MIN_RESPONSE_RATE ) {
660+ warnings . push (
661+ `Response rate is ${ ( coverage . responseRate * 100 ) . toFixed ( 1 ) } % (below ${ COVERAGE_WARNINGS . MIN_RESPONSE_RATE * 100 } % threshold).` ,
662+ ) ;
663+ }
664+ if ( coverage . submissionCount < COVERAGE_WARNINGS . MIN_SUBMISSIONS ) {
665+ warnings . push (
666+ `Only ${ coverage . submissionCount } submissions (minimum recommended: ${ COVERAGE_WARNINGS . MIN_SUBMISSIONS } ).` ,
667+ ) ;
668+ }
669+ if ( coverage . commentCount < COVERAGE_WARNINGS . MIN_COMMENTS ) {
670+ warnings . push (
671+ `Only ${ coverage . commentCount } qualitative comments (minimum recommended: ${ COVERAGE_WARNINGS . MIN_COMMENTS } ).` ,
672+ ) ;
673+ }
674+ if ( coverage . lastEnrollmentSyncAt ) {
675+ const hoursSinceSync =
676+ ( Date . now ( ) - coverage . lastEnrollmentSyncAt . getTime ( ) ) / 3_600_000 ;
677+ if ( hoursSinceSync > COVERAGE_WARNINGS . STALE_SYNC_HOURS ) {
678+ const daysStale = Math . floor ( hoursSinceSync / 24 ) ;
679+ warnings . push (
680+ `Enrollment data may be stale (last synced ${ daysStale } day${ daysStale !== 1 ? 's' : '' } ago).` ,
681+ ) ;
682+ }
683+ }
684+ return warnings ;
685+ }
686+
639687 private async ComputeCoverageStats (
640688 em : EntityManager ,
641689 scope : ScopeFilter ,
0 commit comments