@@ -480,6 +480,14 @@ async function handleAssignmentExport(ctx: MCPAuthContext, rawParams: Record<str
480480 testOutputMax
481481 ) ;
482482 const hintCount = await streamHints ( supabase , graderTestIds , mode , tokenizer , writer ) ;
483+ const errorPinEngagementCount = await streamErrorPinEngagement (
484+ supabase ,
485+ classData . id ,
486+ submissionIds ,
487+ mode ,
488+ tokenizer ,
489+ writer
490+ ) ;
483491
484492 await writer . write ( {
485493 kind : "end" ,
@@ -490,7 +498,8 @@ async function handleAssignmentExport(ctx: MCPAuthContext, rawParams: Record<str
490498 submissions : submissionCount ,
491499 scores : scoreCount ,
492500 grader_tests : testCount ,
493- hints : hintCount
501+ hints : hintCount ,
502+ error_pin_engagement : errorPinEngagementCount
494503 }
495504 } ) ;
496505 } ) ;
@@ -1119,6 +1128,260 @@ async function streamHints(
11191128 return total ;
11201129}
11211130
1131+ /**
1132+ * Student engagement with discussion posts pinned to errors on exported
1133+ * submissions. Emits one row per submission × pinned discussion post × student
1134+ * participant so group submissions retain per-student read/like state.
1135+ */
1136+ async function streamErrorPinEngagement (
1137+ supabase : ReturnType < typeof getAdminClient > ,
1138+ classId : number ,
1139+ submissionIds : number [ ] ,
1140+ mode : IdentityMode ,
1141+ tokenizer : Tokenizer | null ,
1142+ writer : { write : ( record : Record < string , unknown > ) => Promise < void > }
1143+ ) : Promise < number > {
1144+ if ( submissionIds . length === 0 ) return 0 ;
1145+
1146+ const participants = await loadSubmissionParticipants ( supabase , classId , submissionIds ) ;
1147+ const matches = await loadErrorPinMatches ( supabase , submissionIds ) ;
1148+ if ( matches . length === 0 ) return 0 ;
1149+
1150+ const discussionThreadIds = unique ( matches . map ( ( m ) => m . discussion_thread_id ) ) ;
1151+ const profileIds = unique ( Array . from ( participants . values ( ) ) . flatMap ( ( profiles ) => Array . from ( profiles ) ) ) ;
1152+ const userIdByProfileId = await loadUserIdsByProfileId ( supabase , classId , profileIds ) ;
1153+ const readAtByUserAndThread = await loadReadStatusByUserAndThread (
1154+ supabase ,
1155+ discussionThreadIds ,
1156+ unique ( Array . from ( userIdByProfileId . values ( ) ) )
1157+ ) ;
1158+ const likedByProfileAndThread = await loadLikesByProfileAndThread ( supabase , discussionThreadIds , profileIds ) ;
1159+
1160+ let total = 0 ;
1161+ for ( const match of matches ) {
1162+ const submissionParticipants = participants . get ( match . submission_id ) ?? new Set < string > ( ) ;
1163+ for ( const profileId of submissionParticipants ) {
1164+ const userId = userIdByProfileId . get ( profileId ) ?? null ;
1165+ const submissionRef =
1166+ tokenizer === null
1167+ ? { id : match . submission_id }
1168+ : { token : await tokenizer . token ( "submission" , match . submission_id ) } ;
1169+ const subjectRef =
1170+ tokenizer === null ? { id : profileId } : { token : await tokenizer . token ( "subject" , profileId ) } ;
1171+ const graderTestRef =
1172+ match . grader_result_test_id === null
1173+ ? null
1174+ : tokenizer === null
1175+ ? { id : match . grader_result_test_id }
1176+ : { token : await tokenizer . token ( "grader_test" , match . grader_result_test_id ) } ;
1177+
1178+ await writer . write ( {
1179+ kind : "error_pin_engagement" ,
1180+ submission : submissionRef ,
1181+ subject : subjectRef ,
1182+ discussion_thread_id : match . discussion_thread_id ,
1183+ error_pin_id : match . error_pin_id ,
1184+ grader_test : graderTestRef ,
1185+ read_at :
1186+ userId === null ? null : ( readAtByUserAndThread . get ( compoundKey ( userId , match . discussion_thread_id ) ) ?? null ) ,
1187+ liked : likedByProfileAndThread . has ( compoundKey ( profileId , match . discussion_thread_id ) )
1188+ } ) ;
1189+ total += 1 ;
1190+ }
1191+ }
1192+ return total ;
1193+ }
1194+
1195+ async function loadSubmissionParticipants (
1196+ supabase : ReturnType < typeof getAdminClient > ,
1197+ classId : number ,
1198+ submissionIds : number [ ]
1199+ ) : Promise < Map < number , Set < string > > > {
1200+ const participants = new Map < number , Set < string > > ( ) ;
1201+ const groupIdsBySubmissionId = new Map < number , number > ( ) ;
1202+
1203+ for ( const batch of chunked ( submissionIds , 500 ) ) {
1204+ const { data, error } = await supabase
1205+ . from ( "submissions" )
1206+ . select ( "id, profile_id, assignment_group_id" )
1207+ . in ( "id" , batch ) ;
1208+ if ( error ) throw new CLICommandError ( `Failed to load submission participants: ${ error . message } ` , 500 ) ;
1209+
1210+ for ( const row of data ?? [ ] ) {
1211+ const profiles = participants . get ( row . id ) ?? new Set < string > ( ) ;
1212+ if ( row . profile_id !== null ) profiles . add ( row . profile_id ) ;
1213+ if ( row . assignment_group_id !== null ) groupIdsBySubmissionId . set ( row . id , row . assignment_group_id ) ;
1214+ participants . set ( row . id , profiles ) ;
1215+ }
1216+ }
1217+
1218+ const groupIds = unique ( Array . from ( groupIdsBySubmissionId . values ( ) ) ) ;
1219+ const membersByGroupId = new Map < number , string [ ] > ( ) ;
1220+ for ( const batch of chunked ( groupIds , 500 ) ) {
1221+ const { data, error } = await supabase
1222+ . from ( "assignment_groups_members" )
1223+ . select ( "assignment_group_id, profile_id" )
1224+ . in ( "assignment_group_id" , batch ) ;
1225+ if ( error ) throw new CLICommandError ( `Failed to load assignment group members: ${ error . message } ` , 500 ) ;
1226+ for ( const row of data ?? [ ] ) {
1227+ const profiles = membersByGroupId . get ( row . assignment_group_id ) ?? [ ] ;
1228+ profiles . push ( row . profile_id ) ;
1229+ membersByGroupId . set ( row . assignment_group_id , profiles ) ;
1230+ }
1231+ }
1232+
1233+ for ( const [ submissionId , groupId ] of groupIdsBySubmissionId . entries ( ) ) {
1234+ const profiles = participants . get ( submissionId ) ?? new Set < string > ( ) ;
1235+ for ( const profileId of membersByGroupId . get ( groupId ) ?? [ ] ) profiles . add ( profileId ) ;
1236+ participants . set ( submissionId , profiles ) ;
1237+ }
1238+
1239+ const profileIds = unique ( Array . from ( participants . values ( ) ) . flatMap ( ( profiles ) => Array . from ( profiles ) ) ) ;
1240+ const enrolledProfiles = new Set ( ( await loadUserIdsByProfileId ( supabase , classId , profileIds ) ) . keys ( ) ) ;
1241+ for ( const [ submissionId , profiles ] of participants . entries ( ) ) {
1242+ participants . set (
1243+ submissionId ,
1244+ new Set ( Array . from ( profiles ) . filter ( ( profileId ) => enrolledProfiles . has ( profileId ) ) )
1245+ ) ;
1246+ }
1247+
1248+ return participants ;
1249+ }
1250+
1251+ type ErrorPinMatchForExport = {
1252+ error_pin_id : number ;
1253+ submission_id : number ;
1254+ grader_result_test_id : number | null ;
1255+ discussion_thread_id : number ;
1256+ } ;
1257+
1258+ async function loadErrorPinMatches (
1259+ supabase : ReturnType < typeof getAdminClient > ,
1260+ submissionIds : number [ ]
1261+ ) : Promise < ErrorPinMatchForExport [ ] > {
1262+ const rawMatches : Array < Omit < ErrorPinMatchForExport , "discussion_thread_id" > > = [ ] ;
1263+
1264+ for ( const batch of chunked ( submissionIds , 500 ) ) {
1265+ let cursor = 0 ;
1266+ while ( true ) {
1267+ const { data, error } = await supabase
1268+ . from ( "error_pin_submission_matches" )
1269+ . select ( "id, error_pin_id, submission_id, grader_result_test_id" )
1270+ . in ( "submission_id" , batch )
1271+ . gt ( "id" , cursor )
1272+ . order ( "id" , { ascending : true } )
1273+ . limit ( FACT_PAGE_SIZE ) ;
1274+ if ( error ) throw new CLICommandError ( `Failed to load error pin matches: ${ error . message } ` , 500 ) ;
1275+ if ( ! data || data . length === 0 ) break ;
1276+
1277+ for ( const row of data ) {
1278+ rawMatches . push ( {
1279+ error_pin_id : row . error_pin_id ,
1280+ submission_id : row . submission_id ,
1281+ grader_result_test_id : row . grader_result_test_id
1282+ } ) ;
1283+ }
1284+
1285+ if ( data . length < FACT_PAGE_SIZE ) break ;
1286+ cursor = data [ data . length - 1 ] ! . id ;
1287+ }
1288+ }
1289+
1290+ if ( rawMatches . length === 0 ) return [ ] ;
1291+
1292+ const pinById = new Map < number , { discussion_thread_id : number } > ( ) ;
1293+ for ( const batch of chunked ( unique ( rawMatches . map ( ( m ) => m . error_pin_id ) ) , 500 ) ) {
1294+ const { data, error } = await supabase
1295+ . from ( "error_pins" )
1296+ . select ( "id, discussion_thread_id" )
1297+ . eq ( "enabled" , true )
1298+ . in ( "id" , batch ) ;
1299+ if ( error ) throw new CLICommandError ( `Failed to load error pins: ${ error . message } ` , 500 ) ;
1300+ for ( const pin of data ?? [ ] ) {
1301+ pinById . set ( pin . id , { discussion_thread_id : pin . discussion_thread_id } ) ;
1302+ }
1303+ }
1304+
1305+ return rawMatches . flatMap ( ( match ) => {
1306+ const pin = pinById . get ( match . error_pin_id ) ;
1307+ return pin ? [ { ...match , discussion_thread_id : pin . discussion_thread_id } ] : [ ] ;
1308+ } ) ;
1309+ }
1310+
1311+ async function loadUserIdsByProfileId (
1312+ supabase : ReturnType < typeof getAdminClient > ,
1313+ classId : number ,
1314+ profileIds : string [ ]
1315+ ) : Promise < Map < string , string > > {
1316+ const userIdByProfileId = new Map < string , string > ( ) ;
1317+ for ( const batch of chunked ( profileIds , 500 ) ) {
1318+ const { data, error } = await supabase
1319+ . from ( "user_roles" )
1320+ . select ( "private_profile_id, user_id" )
1321+ . eq ( "class_id" , classId )
1322+ . eq ( "role" , "student" )
1323+ . eq ( "disabled" , false )
1324+ . in ( "private_profile_id" , batch ) ;
1325+ if ( error ) throw new CLICommandError ( `Failed to load student user ids: ${ error . message } ` , 500 ) ;
1326+ for ( const row of data ?? [ ] ) {
1327+ if ( row . private_profile_id !== null ) userIdByProfileId . set ( row . private_profile_id , row . user_id ) ;
1328+ }
1329+ }
1330+ return userIdByProfileId ;
1331+ }
1332+
1333+ async function loadReadStatusByUserAndThread (
1334+ supabase : ReturnType < typeof getAdminClient > ,
1335+ discussionThreadIds : number [ ] ,
1336+ userIds : string [ ]
1337+ ) : Promise < Map < string , string | null > > {
1338+ const readAtByUserAndThread = new Map < string , string | null > ( ) ;
1339+ for ( const threadBatch of chunked ( discussionThreadIds , 200 ) ) {
1340+ for ( const userBatch of chunked ( userIds , 200 ) ) {
1341+ const { data, error } = await supabase
1342+ . from ( "discussion_thread_read_status" )
1343+ . select ( "user_id, discussion_thread_id, read_at" )
1344+ . in ( "discussion_thread_id" , threadBatch )
1345+ . in ( "user_id" , userBatch ) ;
1346+ if ( error ) throw new CLICommandError ( `Failed to load discussion read status: ${ error . message } ` , 500 ) ;
1347+ for ( const row of data ?? [ ] ) {
1348+ readAtByUserAndThread . set ( compoundKey ( row . user_id , row . discussion_thread_id ) , row . read_at ) ;
1349+ }
1350+ }
1351+ }
1352+ return readAtByUserAndThread ;
1353+ }
1354+
1355+ async function loadLikesByProfileAndThread (
1356+ supabase : ReturnType < typeof getAdminClient > ,
1357+ discussionThreadIds : number [ ] ,
1358+ profileIds : string [ ]
1359+ ) : Promise < Set < string > > {
1360+ const likedByProfileAndThread = new Set < string > ( ) ;
1361+ for ( const threadBatch of chunked ( discussionThreadIds , 200 ) ) {
1362+ for ( const profileBatch of chunked ( profileIds , 200 ) ) {
1363+ const { data, error } = await supabase
1364+ . from ( "discussion_thread_likes" )
1365+ . select ( "creator, discussion_thread" )
1366+ . in ( "discussion_thread" , threadBatch )
1367+ . in ( "creator" , profileBatch ) ;
1368+ if ( error ) throw new CLICommandError ( `Failed to load discussion likes: ${ error . message } ` , 500 ) ;
1369+ for ( const row of data ?? [ ] ) {
1370+ likedByProfileAndThread . add ( compoundKey ( row . creator , row . discussion_thread ) ) ;
1371+ }
1372+ }
1373+ }
1374+ return likedByProfileAndThread ;
1375+ }
1376+
1377+ function unique < T > ( values : T [ ] ) : T [ ] {
1378+ return Array . from ( new Set ( values ) ) ;
1379+ }
1380+
1381+ function compoundKey ( left : string | number , right : string | number ) : string {
1382+ return `${ left } :${ right } ` ;
1383+ }
1384+
11221385function * chunked < T > ( arr : T [ ] , size : number ) : Generator < T [ ] > {
11231386 for ( let i = 0 ; i < arr . length ; i += size ) yield arr . slice ( i , i + size ) ;
11241387}
0 commit comments