diff --git a/projects/gamilit/apps/backend/src/modules/teacher/services/analytics.service.ts b/projects/gamilit/apps/backend/src/modules/teacher/services/analytics.service.ts index 54a5a7b..29ac071 100644 --- a/projects/gamilit/apps/backend/src/modules/teacher/services/analytics.service.ts +++ b/projects/gamilit/apps/backend/src/modules/teacher/services/analytics.service.ts @@ -578,6 +578,158 @@ export class AnalyticsService { } } + // ========================================================================= + // P1-08: CACHE INVALIDATION METHODS (2025-12-18) + // ========================================================================= + + /** + * Invalidate economy analytics cache for a teacher + * + * Call this when: + * - Student earns/spends ML Coins + * - New student joins classroom + * - Student removed from classroom + * + * @param teacherId - Teacher's user ID + * @param classroomId - Optional specific classroom ID + */ + async invalidateEconomyAnalyticsCache(teacherId: string, classroomId?: string): Promise { + const cacheKeys = [ + `economy-analytics:${teacherId}:all`, + `students-economy:${teacherId}:all`, + ]; + + if (classroomId) { + cacheKeys.push(`economy-analytics:${teacherId}:${classroomId}`); + cacheKeys.push(`students-economy:${teacherId}:${classroomId}`); + } + + try { + await Promise.all(cacheKeys.map(key => this.cacheManager.del(key))); + this.logger.debug(`Invalidated economy cache for teacher ${teacherId}${classroomId ? `, classroom ${classroomId}` : ''}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Error invalidating economy cache: ${errorMessage}`); + } + } + + /** + * Invalidate achievements stats cache for a teacher + * + * Call this when: + * - Student unlocks achievement + * - Achievement configuration changes + * - Student joins/leaves classroom + * + * @param teacherId - Teacher's user ID + * @param classroomId - Optional specific classroom ID + */ + async invalidateAchievementsStatsCache(teacherId: string, classroomId?: string): Promise { + const cacheKeys = [ + `achievements-stats:${teacherId}:all`, + ]; + + if (classroomId) { + cacheKeys.push(`achievements-stats:${teacherId}:${classroomId}`); + } + + try { + await Promise.all(cacheKeys.map(key => this.cacheManager.del(key))); + this.logger.debug(`Invalidated achievements cache for teacher ${teacherId}${classroomId ? `, classroom ${classroomId}` : ''}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Error invalidating achievements cache: ${errorMessage}`); + } + } + + /** + * Invalidate all analytics caches for a teacher + * + * Call this on major data changes that affect multiple analytics: + * - New submission completed + * - Score/grade updated + * - Bulk student changes + * + * @param teacherId - Teacher's user ID + * @param studentId - Optional student whose insights should be invalidated + * @param classroomId - Optional specific classroom ID + */ + async invalidateAllAnalyticsCache( + teacherId: string, + studentId?: string, + classroomId?: string, + ): Promise { + try { + const invalidations = [ + this.invalidateEconomyAnalyticsCache(teacherId, classroomId), + this.invalidateAchievementsStatsCache(teacherId, classroomId), + ]; + + if (studentId) { + invalidations.push(this.invalidateStudentInsightsCache(studentId)); + } + + await Promise.all(invalidations); + this.logger.debug( + `Invalidated all analytics caches for teacher ${teacherId}` + + (studentId ? `, student ${studentId}` : '') + + (classroomId ? `, classroom ${classroomId}` : ''), + ); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Error invalidating all analytics cache: ${errorMessage}`); + } + } + + /** + * Hook for ExerciseSubmissionService to call when a submission is created/updated + * + * @param studentId - Student who made the submission + * @param teacherIds - Array of teacher IDs who teach this student + */ + async onSubmissionChange(studentId: string, teacherIds: string[]): Promise { + try { + // Invalidate student's insights + await this.invalidateStudentInsightsCache(studentId); + + // Invalidate each teacher's analytics + await Promise.all( + teacherIds.map(teacherId => + this.invalidateAllAnalyticsCache(teacherId, studentId), + ), + ); + + this.logger.debug(`Invalidated caches after submission change for student ${studentId}`); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Error in onSubmissionChange cache invalidation: ${errorMessage}`); + } + } + + /** + * Hook for ClassroomMemberService to call when membership changes + * + * @param classroomId - Classroom where change occurred + * @param teacherId - Teacher who owns the classroom + * @param studentId - Student whose membership changed + */ + async onMembershipChange(classroomId: string, teacherId: string, studentId: string): Promise { + try { + await Promise.all([ + this.invalidateStudentInsightsCache(studentId), + this.invalidateEconomyAnalyticsCache(teacherId, classroomId), + this.invalidateAchievementsStatsCache(teacherId, classroomId), + ]); + + this.logger.debug( + `Invalidated caches after membership change in classroom ${classroomId} for student ${studentId}`, + ); + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn(`Error in onMembershipChange cache invalidation: ${errorMessage}`); + } + } + // ========================================================================= // ECONOMY ANALYTICS (GAP-ST-005) // ========================================================================= diff --git a/projects/gamilit/apps/backend/src/modules/teacher/services/student-progress.service.ts b/projects/gamilit/apps/backend/src/modules/teacher/services/student-progress.service.ts index c3e8b57..199070e 100644 --- a/projects/gamilit/apps/backend/src/modules/teacher/services/student-progress.service.ts +++ b/projects/gamilit/apps/backend/src/modules/teacher/services/student-progress.service.ts @@ -14,6 +14,9 @@ import { ClassroomMember } from '@/modules/social/entities/classroom-member.enti import { Classroom } from '@/modules/social/entities/classroom.entity'; import { User } from '@/modules/auth/entities/user.entity'; import { UserStats } from '@/modules/gamification/entities/user-stats.entity'; +// P1-05: Added 2025-12-18 - Educational entities for data enrichment +import { Module as EducationalModule } from '@/modules/educational/entities/module.entity'; +import { Exercise } from '@/modules/educational/entities/exercise.entity'; import { GetStudentProgressQueryDto, AddTeacherNoteDto, StudentNoteResponseDto } from '../dto'; export interface StudentOverview { @@ -101,6 +104,11 @@ export class StudentProgressService { private readonly userRepository: Repository, @InjectRepository(UserStats, 'gamification') private readonly userStatsRepository: Repository, + // P1-05: Added 2025-12-18 - Educational repositories for data enrichment + @InjectRepository(EducationalModule, 'educational') + private readonly moduleRepository: Repository, + @InjectRepository(Exercise, 'educational') + private readonly exerciseRepository: Repository, ) {} /** @@ -255,20 +263,45 @@ export class StudentProgressService { where: { user_id: profile.id }, }); - // TODO: Join with actual module data to get names and details - return moduleProgresses.map((mp, index) => ({ - module_id: mp.module_id, - module_name: `Módulo ${index + 1}`, // TODO: Get from modules table - module_order: index + 1, - total_activities: 15, // TODO: Get from module - completed_activities: Math.round( - (mp.progress_percentage / 100) * 15, - ), - average_score: Math.round(mp.progress_percentage * 0.8), // Estimate - time_spent_minutes: 0, // TODO: Calculate from submissions - last_activity_date: mp.updated_at, // Using updated_at as proxy for last_activity - status: this.calculateModuleStatus(mp.progress_percentage), - })); + // P1-05: Get module data for enrichment + const moduleIds = moduleProgresses.map(mp => mp.module_id); + const modules = moduleIds.length > 0 + ? await this.moduleRepository.find({ where: { id: In(moduleIds) } }) + : []; + const moduleMap = new Map(modules.map(m => [m.id, m])); + + // P1-05: Get submissions for time calculation + const submissions = await this.submissionRepository.find({ + where: { user_id: profile.id }, + }); + const timeByModule = new Map(); + for (const sub of submissions) { + // Get exercise to find module + const exercise = await this.exerciseRepository.findOne({ where: { id: sub.exercise_id } }); + if (exercise) { + const currentTime = timeByModule.get(exercise.module_id) || 0; + timeByModule.set(exercise.module_id, currentTime + (sub.time_spent_seconds || 0)); + } + } + + // P1-05: Enriched response with real module data + return moduleProgresses.map((mp) => { + const moduleData = moduleMap.get(mp.module_id); + const timeSpentSeconds = timeByModule.get(mp.module_id) || 0; + const totalActivities = moduleData?.total_exercises || 15; + + return { + module_id: mp.module_id, + module_name: moduleData?.title || `Módulo ${moduleData?.order_index || 1}`, + module_order: moduleData?.order_index || 1, + total_activities: totalActivities, + completed_activities: Math.round((mp.progress_percentage / 100) * totalActivities), + average_score: Math.round(mp.score || mp.progress_percentage * 0.8), + time_spent_minutes: Math.round(timeSpentSeconds / 60), + last_activity_date: mp.updated_at, + status: this.calculateModuleStatus(mp.progress_percentage), + }; + }); } /** @@ -316,19 +349,37 @@ export class StudentProgressService { order: { submitted_at: 'DESC' }, }); - // TODO: Join with exercise data to get titles and types - return submissions.map((sub) => ({ - id: sub.id, - exercise_title: 'Ejercicio', // TODO: Get from exercises table - module_name: 'Módulo', // TODO: Get from modules table - exercise_type: 'multiple_choice', // TODO: Get from exercises table - is_correct: sub.is_correct || false, - // Protect against division by zero - score_percentage: Math.round((sub.score / (sub.max_score || 1)) * 100), - time_spent_seconds: sub.time_spent_seconds || 0, - hints_used: sub.hints_count || 0, - submitted_at: sub.submitted_at, - })); + // P1-05: Get exercise and module data for enrichment + const exerciseIds = [...new Set(submissions.map(s => s.exercise_id))]; + const exercises = exerciseIds.length > 0 + ? await this.exerciseRepository.find({ where: { id: In(exerciseIds) } }) + : []; + const exerciseMap = new Map(exercises.map(e => [e.id, e])); + + // Get module data for exercise modules + const moduleIds = [...new Set(exercises.map(e => e.module_id))]; + const modules = moduleIds.length > 0 + ? await this.moduleRepository.find({ where: { id: In(moduleIds) } }) + : []; + const moduleMap = new Map(modules.map(m => [m.id, m])); + + // P1-05: Enriched response with real exercise/module data + return submissions.map((sub) => { + const exercise = exerciseMap.get(sub.exercise_id); + const moduleData = exercise ? moduleMap.get(exercise.module_id) : undefined; + + return { + id: sub.id, + exercise_title: exercise?.title || 'Ejercicio', + module_name: moduleData?.title || 'Módulo', + exercise_type: exercise?.exercise_type || 'multiple_choice', + is_correct: sub.is_correct || false, + score_percentage: Math.round((sub.score / (sub.max_score || 1)) * 100), + time_spent_seconds: sub.time_spent_seconds || 0, + hints_used: sub.hints_count || 0, + submitted_at: sub.submitted_at, + }; + }); } /** @@ -351,32 +402,48 @@ export class StudentProgressService { }); // Group by exercise to find struggles - const exerciseMap = new Map(); + const submissionsByExercise = new Map(); submissions.forEach((sub) => { const key = sub.exercise_id; - if (!exerciseMap.has(key)) { - exerciseMap.set(key, []); + if (!submissionsByExercise.has(key)) { + submissionsByExercise.set(key, []); } - exerciseMap.get(key)!.push(sub); + submissionsByExercise.get(key)!.push(sub); }); + // P1-05: Get exercise and module data for enrichment + const exerciseIds = [...submissionsByExercise.keys()]; + const exercises = exerciseIds.length > 0 + ? await this.exerciseRepository.find({ where: { id: In(exerciseIds) } }) + : []; + const exerciseDataMap = new Map(exercises.map(e => [e.id, e])); + + const moduleIds = [...new Set(exercises.map(e => e.module_id))]; + const modules = moduleIds.length > 0 + ? await this.moduleRepository.find({ where: { id: In(moduleIds) } }) + : []; + const moduleDataMap = new Map(modules.map(m => [m.id, m])); + const struggles: StruggleArea[] = []; - exerciseMap.forEach((subs) => { + submissionsByExercise.forEach((subs, exerciseId) => { const attempts = subs.length; const correctAttempts = subs.filter((s) => s.is_correct).length; const successRate = (correctAttempts / attempts) * 100; // Consider it a struggle if success rate < 70% and multiple attempts if (successRate < 70 && attempts >= 2) { - // Protect against division by zero in score calculation const avgScore = subs.reduce((sum, s) => sum + (s.score / (s.max_score || 1)) * 100, 0) / attempts; + // P1-05: Get real exercise/module names + const exercise = exerciseDataMap.get(exerciseId); + const moduleData = exercise ? moduleDataMap.get(exercise.module_id) : undefined; + struggles.push({ - topic: 'Tema del ejercicio', // TODO: Get from exercise data - module_name: 'Módulo', // TODO: Get from module data + topic: exercise?.title || 'Tema del ejercicio', + module_name: moduleData?.title || 'Módulo', attempts, success_rate: Math.round(successRate), average_score: Math.round(avgScore), @@ -390,6 +457,7 @@ export class StudentProgressService { /** * Compare student with class averages + * P1-05: Updated 2025-12-18 - Calculate real class averages */ async getClassComparison(studentId: string): Promise { const studentStats = await this.getStudentStats(studentId); @@ -398,12 +466,14 @@ export class StudentProgressService { const allProfiles = await this.profileRepository.find(); const allSubmissions = await this.submissionRepository.find(); + // P1-05: Get all user stats for real averages + const allUserStats = await this.userStatsRepository.find(); + // Calculate class averages (with division by zero protection) const classAvgScore = allSubmissions.length > 0 ? Math.round( allSubmissions.reduce( (sum, sub) => { - // Protect against division by zero in score calculation const maxScore = sub.max_score || 1; return sum + (sub.score / maxScore) * 100; }, @@ -417,6 +487,24 @@ export class StudentProgressService { ? allSubmissions.length / allProfiles.length : 0; + // P1-05: Calculate real time average from submissions + const totalTimeAllStudents = allSubmissions.reduce( + (sum, sub) => sum + (sub.time_spent_seconds || 0), + 0, + ); + const classAvgTimeMinutes = allProfiles.length > 0 + ? Math.round(totalTimeAllStudents / 60 / allProfiles.length) + : 0; + + // P1-05: Calculate real streak average from user_stats + const totalStreaks = allUserStats.reduce( + (sum, stats) => sum + (stats.current_streak || 0), + 0, + ); + const classAvgStreak = allUserStats.length > 0 + ? Math.round(totalStreaks / allUserStats.length) + : 0; + return [ { metric: 'Puntuación Promedio', @@ -439,19 +527,19 @@ export class StudentProgressService { { metric: 'Tiempo de Estudio (min)', student_value: studentStats.total_time_spent_minutes, - class_average: 1100, // TODO: Calculate actual class average + class_average: classAvgTimeMinutes, percentile: this.calculatePercentile( studentStats.total_time_spent_minutes, - 1100, + classAvgTimeMinutes, ), }, { metric: 'Racha Actual (días)', student_value: studentStats.current_streak_days, - class_average: 5, // TODO: Calculate actual class average + class_average: classAvgStreak, percentile: this.calculatePercentile( studentStats.current_streak_days, - 5, + classAvgStreak, ), }, ]; diff --git a/projects/gamilit/apps/backend/src/modules/teacher/services/student-risk-alert.service.ts b/projects/gamilit/apps/backend/src/modules/teacher/services/student-risk-alert.service.ts index 6cda16c..a62fa81 100644 --- a/projects/gamilit/apps/backend/src/modules/teacher/services/student-risk-alert.service.ts +++ b/projects/gamilit/apps/backend/src/modules/teacher/services/student-risk-alert.service.ts @@ -13,7 +13,9 @@ import { Profile } from '@/modules/auth/entities/profile.entity'; import { Classroom } from '@/modules/social/entities/classroom.entity'; import { ClassroomMember } from '@/modules/social/entities/classroom-member.entity'; import { AnalyticsService } from './analytics.service'; -import { GamilityRoleEnum } from '@/shared/constants/enums.constants'; +// P0-04: Added 2025-12-18 - NotificationsService integration +import { NotificationsService } from '@/modules/notifications/services/notifications.service'; +import { GamilityRoleEnum, NotificationTypeEnum } from '@/shared/constants/enums.constants'; export interface RiskAlert { student_id: string; @@ -49,6 +51,8 @@ export class StudentRiskAlertService { @InjectRepository(ClassroomMember, 'social') private readonly classroomMemberRepository: Repository, private readonly analyticsService: AnalyticsService, + // P0-04: Added 2025-12-18 - NotificationsService injection + private readonly notificationsService: NotificationsService, ) {} /** @@ -205,29 +209,45 @@ export class StudentRiskAlertService { /** * Send alert notification to teacher about at-risk students * - * @TODO: Replace with actual notification service call + * P0-04: Implemented 2025-12-18 - NotificationsService integration */ private async sendTeacherAlert(teacherId: string, alerts: RiskAlert[]): Promise { const highRiskCount = alerts.filter(a => a.risk_level === 'high').length; const mediumRiskCount = alerts.filter(a => a.risk_level === 'medium').length; + const totalAlerts = highRiskCount + mediumRiskCount; this.logger.log( `[NOTIFICATION] Teacher ${teacherId}: ${highRiskCount} high-risk, ${mediumRiskCount} medium-risk students`, ); - // TODO: Integrate with NotificationService - // Example: - // await this.notificationService.create({ - // recipient_id: teacherId, - // type: 'student_risk_alert', - // title: `${highRiskCount + mediumRiskCount} estudiantes requieren atención`, - // message: this.formatAlertMessage(alerts), - // priority: highRiskCount > 0 ? 'high' : 'medium', - // action_url: '/teacher/alerts', - // metadata: { alerts } - // }); + try { + // P0-04: Send notification via NotificationsService + await this.notificationsService.sendNotification({ + userId: teacherId, + type: NotificationTypeEnum.SYSTEM_ANNOUNCEMENT, + title: highRiskCount > 0 + ? `⚠️ Alerta: ${totalAlerts} estudiantes requieren atención urgente` + : `📊 ${totalAlerts} estudiantes requieren seguimiento`, + message: this.formatAlertMessage(alerts), + data: { + alertType: 'student_risk', + highRiskCount, + mediumRiskCount, + studentIds: alerts.map(a => a.student_id), + action: { + type: 'navigate', + url: '/teacher/alerts', + }, + }, + }); - // For now, just log detailed info + this.logger.log(`✅ Notification sent to teacher ${teacherId}`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to send notification to teacher ${teacherId}: ${errorMessage}`); + } + + // Log detailed info for debugging for (const alert of alerts) { this.logger.debug( ` - ${alert.student_name}: ${alert.risk_level} risk, ${alert.overall_score}% score, ${alert.dropout_risk * 100}% dropout risk`, @@ -237,21 +257,70 @@ export class StudentRiskAlertService { /** * Send summary to admins about high-risk students across the platform + * + * P0-04: Implemented 2025-12-18 - NotificationsService integration */ private async sendAdminSummary(highRiskAlerts: RiskAlert[]): Promise { this.logger.log( `[ADMIN SUMMARY] ${highRiskAlerts.length} high-risk students detected across platform`, ); - // TODO: Integrate with NotificationService for admins - // Example: - // await this.notificationService.createForRole({ - // role: GamilityRoleEnum.SUPER_ADMIN, - // type: 'platform_risk_summary', - // title: `Alerta: ${highRiskAlerts.length} estudiantes en alto riesgo`, - // message: this.formatAdminSummary(highRiskAlerts), - // priority: 'high' - // }); + try { + // Get all super admins + const admins = await this.profileRepository.find({ + where: { role: GamilityRoleEnum.SUPER_ADMIN }, + }); + + if (admins.length === 0) { + this.logger.warn('No super admins found to receive risk summary'); + return; + } + + // Prepare summary message + const summaryLines = [ + `🚨 Resumen de Riesgo de Plataforma`, + ``, + `Se han detectado ${highRiskAlerts.length} estudiantes en ALTO RIESGO:`, + ``, + ]; + + for (const alert of highRiskAlerts.slice(0, 10)) { // Limit to first 10 + summaryLines.push(`• ${alert.student_name} - Riesgo abandono: ${Math.round(alert.dropout_risk * 100)}%`); + } + + if (highRiskAlerts.length > 10) { + summaryLines.push(`... y ${highRiskAlerts.length - 10} más`); + } + + summaryLines.push(``); + summaryLines.push(`Revisa el panel de administración para acciones.`); + + const summaryMessage = summaryLines.join('\n'); + + // P0-04: Send notification to each admin + const notifications = admins.map(admin => ({ + userId: admin.id, + type: NotificationTypeEnum.SYSTEM_ANNOUNCEMENT, + title: `🚨 Alerta Crítica: ${highRiskAlerts.length} estudiantes en alto riesgo`, + message: summaryMessage, + data: { + alertType: 'platform_risk_summary', + highRiskCount: highRiskAlerts.length, + studentIds: highRiskAlerts.map(a => a.student_id), + action: { + type: 'navigate', + url: '/admin/alerts', + }, + }, + })); + + await this.notificationsService.sendBulkNotifications(notifications); + + this.logger.log(`✅ Admin summary sent to ${admins.length} administrators`); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error(`Failed to send admin summary: ${errorMessage}`); + } } /** diff --git a/projects/gamilit/apps/backend/src/modules/teacher/teacher.module.ts b/projects/gamilit/apps/backend/src/modules/teacher/teacher.module.ts index 7538c4e..eddb74d 100644 --- a/projects/gamilit/apps/backend/src/modules/teacher/teacher.module.ts +++ b/projects/gamilit/apps/backend/src/modules/teacher/teacher.module.ts @@ -72,6 +72,8 @@ import { TeacherGuard, ClassroomOwnershipGuard } from './guards'; // External modules import { ProgressModule } from '@modules/progress/progress.module'; +// P0-04: Added 2025-12-18 - NotificationsModule for StudentRiskAlertService +import { NotificationsModule } from '@modules/notifications/notifications.module'; /** * TeacherModule @@ -124,6 +126,9 @@ import { ProgressModule } from '@modules/progress/progress.module'; // Import ProgressModule for ExerciseSubmissionService (needed for reward distribution) ProgressModule, + // P0-04: Import NotificationsModule for StudentRiskAlertService + NotificationsModule, + // Entities from 'auth' datasource TypeOrmModule.forFeature([Profile, User], 'auth'), diff --git a/projects/gamilit/apps/backend/src/modules/websocket/notifications.gateway.ts b/projects/gamilit/apps/backend/src/modules/websocket/notifications.gateway.ts index 5d6a318..6b482ba 100644 --- a/projects/gamilit/apps/backend/src/modules/websocket/notifications.gateway.ts +++ b/projects/gamilit/apps/backend/src/modules/websocket/notifications.gateway.ts @@ -17,7 +17,14 @@ import { import { Logger, UseGuards } from '@nestjs/common'; import { Server } from 'socket.io'; import { WsJwtGuard, AuthenticatedSocket } from './guards/ws-jwt.guard'; -import { SocketEvent } from './types/websocket.types'; +import { + SocketEvent, + StudentActivityPayload, + ClassroomUpdatePayload, + NewSubmissionPayload, + AlertTriggeredPayload, + StudentOnlineStatusPayload, +} from './types/websocket.types'; @WebSocketGateway({ cors: { @@ -175,4 +182,166 @@ implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { getUserSocketCount(userId: string): number { return this.userSockets.get(userId)?.size || 0; } + + // ========================================================================== + // TEACHER PORTAL METHODS (P2-01: 2025-12-18) + // ========================================================================== + + /** + * Subscribe teacher to classroom updates + */ + @UseGuards(WsJwtGuard) + @SubscribeMessage('teacher:subscribe_classroom') + async handleSubscribeClassroom( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { classroomId: string }, + ) { + try { + const userId = client.userData!.userId; + const { classroomId } = data; + const room = `classroom:${classroomId}`; + + await client.join(room); + this.logger.debug(`Teacher ${userId} subscribed to classroom ${classroomId}`); + + return { success: true, room }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Error subscribing to classroom:', error); + return { success: false, error: errorMessage }; + } + } + + /** + * Unsubscribe teacher from classroom updates + */ + @UseGuards(WsJwtGuard) + @SubscribeMessage('teacher:unsubscribe_classroom') + async handleUnsubscribeClassroom( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { classroomId: string }, + ) { + try { + const userId = client.userData!.userId; + const { classroomId } = data; + const room = `classroom:${classroomId}`; + + await client.leave(room); + this.logger.debug(`Teacher ${userId} unsubscribed from classroom ${classroomId}`); + + return { success: true }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + this.logger.error('Error unsubscribing from classroom:', error); + return { success: false, error: errorMessage }; + } + } + + /** + * Emit student activity to classroom teachers + */ + emitStudentActivity(classroomId: string, payload: Omit) { + const room = `classroom:${classroomId}`; + this.server.to(room).emit(SocketEvent.STUDENT_ACTIVITY, { + ...payload, + timestamp: new Date().toISOString(), + }); + this.logger.debug(`Student activity emitted to classroom ${classroomId}`); + } + + /** + * Emit classroom update to subscribed teachers + */ + emitClassroomUpdate(classroomId: string, payload: Omit) { + const room = `classroom:${classroomId}`; + this.server.to(room).emit(SocketEvent.CLASSROOM_UPDATE, { + ...payload, + timestamp: new Date().toISOString(), + }); + this.logger.debug(`Classroom update emitted to ${classroomId}`); + } + + /** + * Emit new submission notification to classroom teachers + */ + emitNewSubmission(classroomId: string, payload: Omit) { + const room = `classroom:${classroomId}`; + this.server.to(room).emit(SocketEvent.NEW_SUBMISSION, { + ...payload, + timestamp: new Date().toISOString(), + }); + this.logger.debug(`New submission emitted to classroom ${classroomId}`); + } + + /** + * Emit alert to specific teacher and classroom + */ + emitAlertTriggered( + teacherId: string, + classroomId: string, + payload: Omit, + ) { + // Emit to teacher's personal room + this.emitToUser(teacherId, SocketEvent.ALERT_TRIGGERED, payload); + // Also emit to classroom room for other subscribed teachers + const room = `classroom:${classroomId}`; + this.server.to(room).emit(SocketEvent.ALERT_TRIGGERED, { + ...payload, + timestamp: new Date().toISOString(), + }); + this.logger.debug(`Alert triggered for teacher ${teacherId} in classroom ${classroomId}`); + } + + /** + * Emit student online/offline status to classroom + */ + emitStudentOnlineStatus(classroomId: string, payload: Omit) { + const room = `classroom:${classroomId}`; + const event = payload.isOnline ? SocketEvent.STUDENT_ONLINE : SocketEvent.STUDENT_OFFLINE; + this.server.to(room).emit(event, { + ...payload, + timestamp: new Date().toISOString(), + }); + this.logger.debug(`Student ${payload.studentId} is now ${payload.isOnline ? 'online' : 'offline'}`); + } + + /** + * Emit progress update for a student + */ + emitProgressUpdate( + teacherIds: string[], + classroomId: string, + data: { + studentId: string; + studentName: string; + progressType: 'module_complete' | 'exercise_complete' | 'level_up' | 'achievement'; + details: Record; + }, + ) { + const payload = { + ...data, + classroomId, + timestamp: new Date().toISOString(), + }; + + // Emit to all relevant teachers + teacherIds.forEach((teacherId) => { + this.emitToUser(teacherId, SocketEvent.PROGRESS_UPDATE, payload); + }); + + // Also emit to classroom room + const room = `classroom:${classroomId}`; + this.server.to(room).emit(SocketEvent.PROGRESS_UPDATE, payload); + + this.logger.debug(`Progress update emitted for student ${data.studentId}`); + } + + /** + * Get count of teachers subscribed to a classroom + */ + async getClassroomSubscriberCount(classroomId: string): Promise { + const room = `classroom:${classroomId}`; + const sockets = await this.server.in(room).fetchSockets(); + return sockets.length; + } } diff --git a/projects/gamilit/apps/backend/src/modules/websocket/types/websocket.types.ts b/projects/gamilit/apps/backend/src/modules/websocket/types/websocket.types.ts index 42fc391..e2f5901 100644 --- a/projects/gamilit/apps/backend/src/modules/websocket/types/websocket.types.ts +++ b/projects/gamilit/apps/backend/src/modules/websocket/types/websocket.types.ts @@ -25,6 +25,15 @@ export enum SocketEvent { // Missions MISSION_COMPLETED = 'mission:completed', MISSION_PROGRESS = 'mission:progress', + + // Teacher Portal (P2-01: 2025-12-18) + STUDENT_ACTIVITY = 'teacher:student_activity', + CLASSROOM_UPDATE = 'teacher:classroom_update', + NEW_SUBMISSION = 'teacher:new_submission', + ALERT_TRIGGERED = 'teacher:alert_triggered', + STUDENT_ONLINE = 'teacher:student_online', + STUDENT_OFFLINE = 'teacher:student_offline', + PROGRESS_UPDATE = 'teacher:progress_update', } export interface SocketUserData { @@ -51,3 +60,60 @@ export interface LeaderboardPayload { leaderboard: any[]; // Will be typed from gamification module timestamp: string; } + +// Teacher Portal Payloads (P2-01: 2025-12-18) + +export interface StudentActivityPayload { + studentId: string; + studentName: string; + classroomId: string; + activityType: 'exercise_start' | 'exercise_complete' | 'hint_used' | 'comodin_used' | 'module_start'; + exerciseId?: string; + exerciseTitle?: string; + moduleId?: string; + moduleTitle?: string; + metadata?: Record; + timestamp: string; +} + +export interface ClassroomUpdatePayload { + classroomId: string; + classroomName: string; + updateType: 'student_joined' | 'student_left' | 'stats_changed'; + data: Record; + timestamp: string; +} + +export interface NewSubmissionPayload { + submissionId: string; + studentId: string; + studentName: string; + exerciseId: string; + exerciseTitle: string; + classroomId: string; + score: number; + maxScore: number; + requiresReview: boolean; + timestamp: string; +} + +export interface AlertTriggeredPayload { + alertId: string; + studentId: string; + studentName: string; + classroomId: string; + alertType: 'at_risk' | 'low_performance' | 'inactive' | 'struggling'; + severity: 'low' | 'medium' | 'high'; + title: string; + description: string; + timestamp: string; +} + +export interface StudentOnlineStatusPayload { + studentId: string; + studentName: string; + classroomId: string; + isOnline: boolean; + lastActivity?: string; + timestamp: string; +} diff --git a/projects/gamilit/apps/database/ddl/schemas/progress_tracking/indexes/03-teacher-portal-indexes.sql b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/indexes/03-teacher-portal-indexes.sql new file mode 100644 index 0000000..a5360dd --- /dev/null +++ b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/indexes/03-teacher-portal-indexes.sql @@ -0,0 +1,39 @@ +-- ===================================================== +-- Indexes for Teacher Portal Optimization +-- Schema: progress_tracking +-- Created: 2025-12-18 (P1-02 - FASE 5 Implementation) +-- ===================================================== + +-- Index: module_progress by classroom and status +-- Purpose: Fast lookup of progress data for classroom analytics +CREATE INDEX IF NOT EXISTS idx_module_progress_classroom_status + ON progress_tracking.module_progress(classroom_id, status); + +COMMENT ON INDEX progress_tracking.idx_module_progress_classroom_status IS + 'P1-02: Optimizes classroom progress overview queries'; + +-- Index: intervention_alerts by teacher and status +-- Purpose: Fast lookup of pending alerts for teacher dashboard +CREATE INDEX IF NOT EXISTS idx_intervention_alerts_teacher_status + ON progress_tracking.student_intervention_alerts(teacher_id, status) + WHERE status IN ('pending', 'acknowledged'); + +COMMENT ON INDEX progress_tracking.idx_intervention_alerts_teacher_status IS + 'P1-02: Optimizes teacher alerts panel queries'; + +-- Index: exercise_submissions by student and date +-- Purpose: Fast lookup of recent submissions for progress tracking +CREATE INDEX IF NOT EXISTS idx_exercise_submissions_student_date + ON progress_tracking.exercise_submissions(student_id, submitted_at DESC); + +COMMENT ON INDEX progress_tracking.idx_exercise_submissions_student_date IS + 'P1-02: Optimizes student timeline and recent activity queries'; + +-- Index: exercise_submissions needing review +-- Purpose: Fast lookup of submissions pending manual review +CREATE INDEX IF NOT EXISTS idx_exercise_submissions_needs_review + ON progress_tracking.exercise_submissions(needs_review, submitted_at DESC) + WHERE needs_review = true; + +COMMENT ON INDEX progress_tracking.idx_exercise_submissions_needs_review IS + 'P1-02: Optimizes teacher review queue'; diff --git a/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/01-enable-rls.sql b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/01-enable-rls.sql index 93410dd..da2ab48 100644 --- a/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/01-enable-rls.sql +++ b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/01-enable-rls.sql @@ -14,9 +14,12 @@ ALTER TABLE progress_tracking.module_progress ENABLE ROW LEVEL SECURITY; ALTER TABLE progress_tracking.exercise_attempts ENABLE ROW LEVEL SECURITY; ALTER TABLE progress_tracking.exercise_submissions ENABLE ROW LEVEL SECURITY; ALTER TABLE progress_tracking.learning_sessions ENABLE ROW LEVEL SECURITY; +-- P1-01: Added 2025-12-18 - Teacher notes RLS +ALTER TABLE progress_tracking.teacher_notes ENABLE ROW LEVEL SECURITY; -- Comentarios COMMENT ON TABLE progress_tracking.module_progress IS 'RLS enabled: Progreso de módulos - lectura propia + teacher + admin'; COMMENT ON TABLE progress_tracking.exercise_attempts IS 'RLS enabled: Intentos de ejercicios - lectura propia + teacher'; COMMENT ON TABLE progress_tracking.exercise_submissions IS 'RLS enabled: Entregas de ejercicios - gestión propia + calificación teacher'; COMMENT ON TABLE progress_tracking.learning_sessions IS 'RLS enabled: Sesiones de aprendizaje de usuarios'; +COMMENT ON TABLE progress_tracking.teacher_notes IS 'RLS enabled: Notas de profesores - lectura/escritura propia'; diff --git a/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/03-teacher-notes-policies.sql b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/03-teacher-notes-policies.sql new file mode 100644 index 0000000..d6e07d8 --- /dev/null +++ b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/rls-policies/03-teacher-notes-policies.sql @@ -0,0 +1,88 @@ +-- ===================================================== +-- RLS Policies for: progress_tracking.teacher_notes +-- Description: Teacher notes with self-access only +-- Created: 2025-12-18 (P1-01 - FASE 5 Implementation) +-- Policies: 4 (SELECT, INSERT, UPDATE, DELETE) +-- ===================================================== +-- +-- Security Strategy: +-- - Self-service: Teachers can CRUD their own notes +-- - No student access: Notes are private to teachers +-- - No cross-teacher access: Teachers cannot see other teachers' notes +-- ===================================================== + +-- ===================================================== +-- TABLE: progress_tracking.teacher_notes +-- Policies: 4 (SELECT: 1, INSERT: 1, UPDATE: 1, DELETE: 1) +-- ===================================================== + +DROP POLICY IF EXISTS teacher_notes_select_own ON progress_tracking.teacher_notes; +DROP POLICY IF EXISTS teacher_notes_insert_own ON progress_tracking.teacher_notes; +DROP POLICY IF EXISTS teacher_notes_update_own ON progress_tracking.teacher_notes; +DROP POLICY IF EXISTS teacher_notes_delete_own ON progress_tracking.teacher_notes; + +-- Policy: teacher_notes_select_own +-- Purpose: Teachers can read their own notes +CREATE POLICY teacher_notes_select_own + ON progress_tracking.teacher_notes + AS PERMISSIVE + FOR SELECT + TO public + USING (teacher_id = current_setting('app.current_user_id', true)::uuid); + +COMMENT ON POLICY teacher_notes_select_own ON progress_tracking.teacher_notes IS + 'Teachers can see their own notes about students'; + +-- Policy: teacher_notes_insert_own +-- Purpose: Teachers can create notes for any student +CREATE POLICY teacher_notes_insert_own + ON progress_tracking.teacher_notes + AS PERMISSIVE + FOR INSERT + TO public + WITH CHECK ( + teacher_id = current_setting('app.current_user_id', true)::uuid + AND EXISTS ( + SELECT 1 FROM auth_management.user_roles ur + WHERE ur.user_id = current_setting('app.current_user_id', true)::uuid + AND ur.role = 'admin_teacher' + ) + ); + +COMMENT ON POLICY teacher_notes_insert_own ON progress_tracking.teacher_notes IS + 'Teachers can create notes about students (teacher role required)'; + +-- Policy: teacher_notes_update_own +-- Purpose: Teachers can update their own notes +CREATE POLICY teacher_notes_update_own + ON progress_tracking.teacher_notes + AS PERMISSIVE + FOR UPDATE + TO public + USING (teacher_id = current_setting('app.current_user_id', true)::uuid) + WITH CHECK (teacher_id = current_setting('app.current_user_id', true)::uuid); + +COMMENT ON POLICY teacher_notes_update_own ON progress_tracking.teacher_notes IS + 'Teachers can update their own notes'; + +-- Policy: teacher_notes_delete_own +-- Purpose: Teachers can delete their own notes +CREATE POLICY teacher_notes_delete_own + ON progress_tracking.teacher_notes + AS PERMISSIVE + FOR DELETE + TO public + USING (teacher_id = current_setting('app.current_user_id', true)::uuid); + +COMMENT ON POLICY teacher_notes_delete_own ON progress_tracking.teacher_notes IS + 'Teachers can delete their own notes'; + +-- ===================================================== +-- SUMMARY: teacher_notes RLS +-- ===================================================== +-- Total policies: 4 +-- - SELECT: 1 (own notes only) +-- - INSERT: 1 (teacher role required) +-- - UPDATE: 1 (own notes only) +-- - DELETE: 1 (own notes only) +-- ===================================================== diff --git a/projects/gamilit/apps/database/ddl/schemas/progress_tracking/tables/17-teacher_interventions.sql b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/tables/17-teacher_interventions.sql new file mode 100644 index 0000000..58d427e --- /dev/null +++ b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/tables/17-teacher_interventions.sql @@ -0,0 +1,204 @@ +-- ============================================================================= +-- TABLE: progress_tracking.teacher_interventions +-- ============================================================================= +-- Purpose: Track teacher actions/interventions in response to student alerts +-- Priority: P2-04 - Teacher Portal intervention tracking +-- Created: 2025-12-18 +-- +-- DESCRIPTION: +-- This table records the specific actions teachers take when responding to +-- student intervention alerts. Unlike the resolution in student_intervention_alerts +-- which only captures the final resolution, this table captures the full history +-- of all interventions taken, including follow-ups and multi-step interventions. +-- +-- USE CASES: +-- - Parent contact records +-- - One-on-one sessions scheduled/completed +-- - Resource assignments +-- - Peer tutoring arrangements +-- - Accommodation adjustments +-- - Follow-up tracking +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS progress_tracking.teacher_interventions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + + -- Alert reference (optional - can also be standalone intervention) + alert_id uuid, + + -- Core identifiers + student_id uuid NOT NULL, + teacher_id uuid NOT NULL, + classroom_id uuid, + + -- Intervention details + intervention_type text NOT NULL, + title text NOT NULL, + description text, + + -- Action tracking + action_taken text NOT NULL, + outcome text, + + -- Scheduling + scheduled_date timestamp with time zone, + completed_date timestamp with time zone, + + -- Status tracking + status text DEFAULT 'planned'::text NOT NULL, + priority text DEFAULT 'medium'::text NOT NULL, + + -- Follow-up + follow_up_required boolean DEFAULT false, + follow_up_date timestamp with time zone, + follow_up_notes text, + + -- Communication records + parent_contacted boolean DEFAULT false, + parent_contact_date timestamp with time zone, + parent_contact_notes text, + + -- Effectiveness tracking + effectiveness_rating integer, + student_response text, + + -- Metadata + notes text, + metadata jsonb DEFAULT '{}'::jsonb, + + -- Multi-tenant + tenant_id uuid NOT NULL, + + -- Audit + created_at timestamp with time zone DEFAULT gamilit.now_mexico() NOT NULL, + updated_at timestamp with time zone DEFAULT gamilit.now_mexico() NOT NULL, + + -- Constraints + CONSTRAINT teacher_interventions_pkey PRIMARY KEY (id), + CONSTRAINT teacher_interventions_type_check CHECK (intervention_type IN ( + 'one_on_one_session', -- Individual tutoring session + 'parent_contact', -- Parent/guardian communication + 'resource_assignment', -- Additional materials assigned + 'peer_tutoring', -- Paired with another student + 'accommodation', -- Learning accommodations + 'referral', -- Referral to specialist + 'behavior_plan', -- Behavioral intervention + 'progress_check', -- Scheduled progress review + 'encouragement', -- Motivational intervention + 'schedule_adjustment', -- Modified schedule/deadlines + 'other' -- Custom intervention + )), + CONSTRAINT teacher_interventions_status_check CHECK (status IN ( + 'planned', -- Scheduled but not started + 'in_progress', -- Currently ongoing + 'completed', -- Successfully completed + 'cancelled', -- Cancelled before completion + 'rescheduled' -- Moved to new date + )), + CONSTRAINT teacher_interventions_priority_check CHECK (priority IN ( + 'low', + 'medium', + 'high', + 'urgent' + )), + CONSTRAINT teacher_interventions_effectiveness_check CHECK ( + effectiveness_rating IS NULL OR + (effectiveness_rating >= 1 AND effectiveness_rating <= 5) + ) +); + +-- Set ownership +ALTER TABLE progress_tracking.teacher_interventions OWNER TO gamilit_user; + +-- Comments +COMMENT ON TABLE progress_tracking.teacher_interventions IS +'Records teacher intervention actions for at-risk students. Tracks full intervention history including scheduling, outcomes, parent contact, and effectiveness.'; + +COMMENT ON COLUMN progress_tracking.teacher_interventions.intervention_type IS +'Type of intervention: one_on_one_session, parent_contact, resource_assignment, peer_tutoring, accommodation, referral, behavior_plan, progress_check, encouragement, schedule_adjustment, other'; + +COMMENT ON COLUMN progress_tracking.teacher_interventions.status IS +'Current status: planned, in_progress, completed, cancelled, rescheduled'; + +COMMENT ON COLUMN progress_tracking.teacher_interventions.effectiveness_rating IS +'Teacher rating of intervention effectiveness (1-5 scale). NULL if not yet rated.'; + +-- Indexes +CREATE INDEX idx_teacher_interventions_alert ON progress_tracking.teacher_interventions(alert_id); +CREATE INDEX idx_teacher_interventions_student ON progress_tracking.teacher_interventions(student_id); +CREATE INDEX idx_teacher_interventions_teacher ON progress_tracking.teacher_interventions(teacher_id); +CREATE INDEX idx_teacher_interventions_classroom ON progress_tracking.teacher_interventions(classroom_id); +CREATE INDEX idx_teacher_interventions_status ON progress_tracking.teacher_interventions(status); +CREATE INDEX idx_teacher_interventions_type ON progress_tracking.teacher_interventions(intervention_type); +CREATE INDEX idx_teacher_interventions_tenant ON progress_tracking.teacher_interventions(tenant_id); +CREATE INDEX idx_teacher_interventions_scheduled ON progress_tracking.teacher_interventions(scheduled_date) + WHERE status IN ('planned', 'in_progress'); +CREATE INDEX idx_teacher_interventions_follow_up ON progress_tracking.teacher_interventions(follow_up_date) + WHERE follow_up_required = true; + +-- Foreign keys +ALTER TABLE progress_tracking.teacher_interventions + ADD CONSTRAINT teacher_interventions_alert_fkey + FOREIGN KEY (alert_id) REFERENCES progress_tracking.student_intervention_alerts(id) ON DELETE SET NULL; + +ALTER TABLE progress_tracking.teacher_interventions + ADD CONSTRAINT teacher_interventions_student_fkey + FOREIGN KEY (student_id) REFERENCES auth_management.profiles(id) ON DELETE CASCADE; + +ALTER TABLE progress_tracking.teacher_interventions + ADD CONSTRAINT teacher_interventions_teacher_fkey + FOREIGN KEY (teacher_id) REFERENCES auth_management.profiles(id) ON DELETE CASCADE; + +ALTER TABLE progress_tracking.teacher_interventions + ADD CONSTRAINT teacher_interventions_classroom_fkey + FOREIGN KEY (classroom_id) REFERENCES social_features.classrooms(id) ON DELETE SET NULL; + +ALTER TABLE progress_tracking.teacher_interventions + ADD CONSTRAINT teacher_interventions_tenant_fkey + FOREIGN KEY (tenant_id) REFERENCES auth_management.tenants(id) ON DELETE CASCADE; + +-- Trigger for updated_at +CREATE TRIGGER trg_teacher_interventions_updated_at + BEFORE UPDATE ON progress_tracking.teacher_interventions + FOR EACH ROW EXECUTE FUNCTION gamilit.update_updated_at_column(); + +-- ============================================================================= +-- ROW LEVEL SECURITY +-- ============================================================================= + +ALTER TABLE progress_tracking.teacher_interventions ENABLE ROW LEVEL SECURITY; + +-- Teachers can view and manage their own interventions +CREATE POLICY teacher_manage_own_interventions ON progress_tracking.teacher_interventions + FOR ALL + USING (teacher_id = gamilit.get_current_user_id()) + WITH CHECK (teacher_id = gamilit.get_current_user_id()); + +-- Teachers can view interventions for students in their classrooms +CREATE POLICY teacher_view_classroom_interventions ON progress_tracking.teacher_interventions + FOR SELECT + USING (EXISTS ( + SELECT 1 FROM social_features.teacher_classrooms tc + WHERE tc.classroom_id = teacher_interventions.classroom_id + AND tc.teacher_id = gamilit.get_current_user_id() + AND tc.tenant_id = teacher_interventions.tenant_id + )); + +-- Admins can view all interventions in their tenant +CREATE POLICY admin_view_tenant_interventions ON progress_tracking.teacher_interventions + FOR SELECT + USING ( + tenant_id IN ( + SELECT p.tenant_id FROM auth_management.profiles p + WHERE p.id = gamilit.get_current_user_id() + ) + AND EXISTS ( + SELECT 1 FROM auth_management.profiles p + WHERE p.id = gamilit.get_current_user_id() + AND p.role IN ('SUPER_ADMIN', 'ADMIN_TEACHER') + ) + ); + +-- Grants +GRANT ALL ON TABLE progress_tracking.teacher_interventions TO gamilit_user; +GRANT SELECT ON TABLE progress_tracking.teacher_interventions TO authenticated; diff --git a/projects/gamilit/apps/database/ddl/schemas/progress_tracking/views/02-teacher_pending_reviews.sql b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/views/02-teacher_pending_reviews.sql new file mode 100644 index 0000000..5bccbf2 --- /dev/null +++ b/projects/gamilit/apps/database/ddl/schemas/progress_tracking/views/02-teacher_pending_reviews.sql @@ -0,0 +1,182 @@ +-- ============================================================================= +-- VIEW: progress_tracking.teacher_pending_reviews +-- ============================================================================= +-- Purpose: Consolidated view of student submissions requiring teacher review +-- Priority: P2-05 - Teacher Portal pending reviews dashboard +-- Created: 2025-12-18 +-- +-- DESCRIPTION: +-- This view aggregates exercise submissions that need manual grading or review, +-- providing teachers with a prioritized queue of work items. It includes +-- student info, exercise details, submission timestamps, and urgency indicators. +-- +-- USE CASES: +-- - Teacher dashboard pending review count +-- - Grading queue interface +-- - Priority-based review workflow +-- - Bulk grading operations +-- ============================================================================= + +DROP VIEW IF EXISTS progress_tracking.teacher_pending_reviews CASCADE; + +CREATE VIEW progress_tracking.teacher_pending_reviews AS +SELECT + -- Submission identifiers + es.id AS submission_id, + es.exercise_id, + es.user_id AS student_id, + + -- Student info + p.full_name AS student_name, + p.username AS student_username, + + -- Exercise info + e.title AS exercise_title, + e.mechanic_type, + e.exercise_type, + m.title AS module_title, + m.module_order, + + -- Classroom info + cm.classroom_id, + c.name AS classroom_name, + + -- Submission details + es.score, + es.time_spent, + es.attempts, + es.answers, + es.feedback, + es.submitted_at, + es.created_at AS submission_date, + + -- Review status + CASE + WHEN es.graded_at IS NOT NULL THEN 'graded' + WHEN es.submitted_at IS NOT NULL THEN 'pending' + ELSE 'in_progress' + END AS review_status, + es.graded_at, + es.graded_by, + + -- Priority calculation + CASE + WHEN es.submitted_at < NOW() - INTERVAL '7 days' THEN 'urgent' + WHEN es.submitted_at < NOW() - INTERVAL '3 days' THEN 'high' + WHEN es.submitted_at < NOW() - INTERVAL '1 day' THEN 'medium' + ELSE 'normal' + END AS priority, + + -- Days waiting + EXTRACT(DAY FROM (NOW() - es.submitted_at))::integer AS days_waiting, + + -- Metadata + es.metadata, + es.tenant_id + +FROM progress_tracking.exercise_submissions es +-- Join to get student profile +INNER JOIN auth_management.profiles p ON es.user_id = p.id +-- Join to get exercise details +INNER JOIN educational_content.exercises e ON es.exercise_id = e.id +-- Join to get module info +INNER JOIN educational_content.modules m ON e.module_id = m.id +-- Join to find student's classroom(s) +LEFT JOIN social_features.classroom_members cm ON es.user_id = cm.student_id + AND cm.is_active = true +-- Join to get classroom name +LEFT JOIN social_features.classrooms c ON cm.classroom_id = c.id + +WHERE + -- Only submissions that need review + es.graded_at IS NULL + AND es.submitted_at IS NOT NULL + -- Only exercises that require manual grading + AND ( + e.requires_manual_grading = true + OR e.mechanic_type IN ( + 'respuesta_abierta', + 'escritura_creativa', + 'debate_guiado', + 'mapa_mental', + 'proyecto_multimedia', + 'reflexion_metacognitiva', + 'podcast_educativo', + 'infografia_interactiva', + 'creacion_storyboard', + 'argumentacion_estructurada' + ) + ) + +ORDER BY + -- Urgent items first + CASE + WHEN es.submitted_at < NOW() - INTERVAL '7 days' THEN 1 + WHEN es.submitted_at < NOW() - INTERVAL '3 days' THEN 2 + WHEN es.submitted_at < NOW() - INTERVAL '1 day' THEN 3 + ELSE 4 + END, + -- Then by submission date (oldest first) + es.submitted_at ASC; + +-- Set ownership +ALTER VIEW progress_tracking.teacher_pending_reviews OWNER TO gamilit_user; + +-- Comments +COMMENT ON VIEW progress_tracking.teacher_pending_reviews IS +'Consolidated view of student submissions requiring teacher review. Shows pending items with priority based on wait time.'; + +COMMENT ON COLUMN progress_tracking.teacher_pending_reviews.priority IS +'Priority based on wait time: urgent (>7 days), high (3-7 days), medium (1-3 days), normal (<1 day)'; + +COMMENT ON COLUMN progress_tracking.teacher_pending_reviews.days_waiting IS +'Number of days the submission has been waiting for review'; + +-- Grants +GRANT SELECT ON progress_tracking.teacher_pending_reviews TO gamilit_user; +GRANT SELECT ON progress_tracking.teacher_pending_reviews TO authenticated; + +-- ============================================================================= +-- HELPER FUNCTION: Get pending reviews count for a teacher +-- ============================================================================= + +CREATE OR REPLACE FUNCTION progress_tracking.get_teacher_pending_reviews_count( + p_teacher_id uuid, + p_classroom_id uuid DEFAULT NULL +) +RETURNS TABLE ( + total_pending bigint, + urgent_count bigint, + high_count bigint, + medium_count bigint, + normal_count bigint +) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + RETURN QUERY + SELECT + COUNT(*)::bigint AS total_pending, + COUNT(*) FILTER (WHERE pr.priority = 'urgent')::bigint AS urgent_count, + COUNT(*) FILTER (WHERE pr.priority = 'high')::bigint AS high_count, + COUNT(*) FILTER (WHERE pr.priority = 'medium')::bigint AS medium_count, + COUNT(*) FILTER (WHERE pr.priority = 'normal')::bigint AS normal_count + FROM progress_tracking.teacher_pending_reviews pr + WHERE EXISTS ( + SELECT 1 FROM social_features.teacher_classrooms tc + WHERE tc.teacher_id = p_teacher_id + AND tc.classroom_id = pr.classroom_id + AND tc.is_active = true + ) + AND (p_classroom_id IS NULL OR pr.classroom_id = p_classroom_id); +END; +$$; + +ALTER FUNCTION progress_tracking.get_teacher_pending_reviews_count(uuid, uuid) OWNER TO gamilit_user; + +COMMENT ON FUNCTION progress_tracking.get_teacher_pending_reviews_count IS +'Returns count of pending reviews for a teacher, optionally filtered by classroom. Includes breakdown by priority level.'; + +GRANT EXECUTE ON FUNCTION progress_tracking.get_teacher_pending_reviews_count(uuid, uuid) TO gamilit_user; +GRANT EXECUTE ON FUNCTION progress_tracking.get_teacher_pending_reviews_count(uuid, uuid) TO authenticated; diff --git a/projects/gamilit/apps/database/ddl/schemas/social_features/indexes/01-teacher-portal-indexes.sql b/projects/gamilit/apps/database/ddl/schemas/social_features/indexes/01-teacher-portal-indexes.sql new file mode 100644 index 0000000..1e7c3a5 --- /dev/null +++ b/projects/gamilit/apps/database/ddl/schemas/social_features/indexes/01-teacher-portal-indexes.sql @@ -0,0 +1,23 @@ +-- ===================================================== +-- Indexes for Teacher Portal Optimization +-- Schema: social_features +-- Created: 2025-12-18 (P1-02 - FASE 5 Implementation) +-- ===================================================== + +-- Index: classroom_members active by classroom +-- Purpose: Fast lookup of active students in a classroom (Teacher Portal monitoring) +CREATE INDEX IF NOT EXISTS idx_classroom_members_classroom_active + ON social_features.classroom_members(classroom_id, status) + WHERE status = 'active'; + +COMMENT ON INDEX social_features.idx_classroom_members_classroom_active IS + 'P1-02: Optimizes teacher queries for active students in classrooms'; + +-- Index: classrooms by teacher +-- Purpose: Fast lookup of classrooms owned by a teacher +CREATE INDEX IF NOT EXISTS idx_classrooms_teacher_active + ON social_features.classrooms(teacher_id, is_active) + WHERE is_active = true; + +COMMENT ON INDEX social_features.idx_classrooms_teacher_active IS + 'P1-02: Optimizes teacher dashboard classroom listing'; diff --git a/projects/gamilit/apps/database/ddl/schemas/social_features/views/01-classroom_progress_overview.sql b/projects/gamilit/apps/database/ddl/schemas/social_features/views/01-classroom_progress_overview.sql new file mode 100644 index 0000000..a4a65d9 --- /dev/null +++ b/projects/gamilit/apps/database/ddl/schemas/social_features/views/01-classroom_progress_overview.sql @@ -0,0 +1,87 @@ +-- ============================================================================= +-- VIEW: social_features.classroom_progress_overview +-- ============================================================================= +-- Purpose: Aggregated progress view for Teacher Portal classroom monitoring +-- Priority: P1-03 - Teacher Portal optimization +-- Created: 2025-12-18 +-- ============================================================================= + +CREATE OR REPLACE VIEW social_features.classroom_progress_overview AS +SELECT + c.id AS classroom_id, + c.name AS classroom_name, + c.teacher_id, + + -- Student counts + COUNT(DISTINCT cm.student_id) FILTER (WHERE cm.status = 'active') AS total_students, + COUNT(DISTINCT mp.student_id) FILTER (WHERE mp.status = 'completed') AS students_completed, + + -- Progress metrics + COALESCE(ROUND(AVG(mp.progress_percentage)::numeric, 2), 0) AS avg_progress, + COALESCE(ROUND(AVG(mp.score)::numeric, 2), 0) AS avg_score, + + -- Alert counts + COUNT(DISTINCT sia.id) FILTER (WHERE sia.status = 'pending') AS pending_alerts, + COUNT(DISTINCT sia.id) FILTER (WHERE sia.status = 'acknowledged') AS acknowledged_alerts, + + -- Review counts + COUNT(DISTINCT es.id) FILTER (WHERE es.needs_review = true) AS pending_reviews, + + -- Activity metrics + COUNT(DISTINCT es.id) AS total_submissions, + MAX(es.submitted_at) AS last_activity, + + -- Module completion + COUNT(DISTINCT mp.module_id) FILTER (WHERE mp.status = 'completed') AS modules_completed, + COUNT(DISTINCT mp.module_id) AS modules_started, + + -- Timestamps + c.created_at AS classroom_created_at, + c.updated_at AS classroom_updated_at + +FROM social_features.classrooms c +LEFT JOIN social_features.classroom_members cm + ON c.id = cm.classroom_id AND cm.status = 'active' +LEFT JOIN progress_tracking.module_progress mp + ON cm.student_id = mp.student_id +LEFT JOIN progress_tracking.student_intervention_alerts sia + ON cm.student_id = sia.student_id AND sia.teacher_id = c.teacher_id +LEFT JOIN progress_tracking.exercise_submissions es + ON cm.student_id = es.student_id +WHERE + c.is_deleted = FALSE +GROUP BY + c.id, c.name, c.teacher_id, c.created_at, c.updated_at; + +-- Grant permissions +GRANT SELECT ON social_features.classroom_progress_overview TO authenticated; + +-- Documentation +COMMENT ON VIEW social_features.classroom_progress_overview IS +'Teacher Portal view for classroom progress monitoring. + +Columns: + - classroom_id/name: Classroom identification + - teacher_id: Owner teacher + - total_students: Active students in classroom + - students_completed: Students who completed at least one module + - avg_progress: Average progress percentage (0-100) + - avg_score: Average score across all modules + - pending_alerts: Count of pending intervention alerts + - acknowledged_alerts: Count of acknowledged alerts + - pending_reviews: Submissions needing manual review + - total_submissions: Total exercise submissions + - last_activity: Most recent submission timestamp + - modules_completed/started: Module completion stats + +Usage: + -- Get all classrooms for a teacher + SELECT * FROM social_features.classroom_progress_overview + WHERE teacher_id = :teacher_id; + + -- Find classrooms needing attention + SELECT * FROM social_features.classroom_progress_overview + WHERE pending_alerts > 0 OR pending_reviews > 5 + ORDER BY pending_alerts DESC; + +Created: 2025-12-18 (P1-03 Teacher Portal Analysis)'; diff --git a/projects/gamilit/apps/frontend/src/apps/teacher/components/grading/RubricEvaluator.tsx b/projects/gamilit/apps/frontend/src/apps/teacher/components/grading/RubricEvaluator.tsx new file mode 100644 index 0000000..6a6d5a7 --- /dev/null +++ b/projects/gamilit/apps/frontend/src/apps/teacher/components/grading/RubricEvaluator.tsx @@ -0,0 +1,733 @@ +/** + * RubricEvaluator Component + * + * P2-02: Created 2025-12-18 + * Standardized rubric-based evaluation component for manual grading mechanics. + * + * Features: + * - Configurable rubric criteria with weight support + * - Visual scoring interface with level descriptors + * - Automatic weighted score calculation + * - Feedback templates per criterion + * - Support for 10 manual grading mechanics + * + * @component + */ + +import { useState, useMemo, useCallback } from 'react'; +import { motion } from 'framer-motion'; +import { + Star, + Info, + CheckCircle, + AlertCircle, + MessageSquare, + Save, + RotateCcw, +} from 'lucide-react'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface RubricLevel { + score: number; + label: string; + description: string; +} + +export interface RubricCriterion { + id: string; + name: string; + description: string; + weight: number; // Percentage weight (0-100) + levels: RubricLevel[]; + feedbackTemplates?: string[]; +} + +export interface RubricConfig { + id: string; + name: string; + description: string; + mechanicType: string; + maxScore: number; + criteria: RubricCriterion[]; +} + +export interface RubricScore { + criterionId: string; + selectedLevel: number; + feedback?: string; +} + +export interface RubricEvaluatorProps { + rubric: RubricConfig; + initialScores?: RubricScore[]; + onScoreChange?: (scores: RubricScore[], totalScore: number, percentage: number) => void; + onSubmit?: (scores: RubricScore[], totalScore: number, feedback: string) => Promise; + readOnly?: boolean; +} + +export interface RubricEvaluatorResult { + scores: RubricScore[]; + totalScore: number; + percentage: number; + feedback: string; +} + +// ============================================================================ +// DEFAULT RUBRICS BY MECHANIC TYPE +// ============================================================================ + +export const DEFAULT_RUBRICS: Record = { + prediccion_narrativa: { + id: 'rubric-prediccion', + name: 'Evaluaci\u00f3n de Predicci\u00f3n Narrativa', + description: 'R\u00fabrica para evaluar predicciones narrativas basadas en el texto', + mechanicType: 'prediccion_narrativa', + maxScore: 100, + criteria: [ + { + id: 'coherencia', + name: 'Coherencia con el texto', + description: '\u00bfLa predicci\u00f3n es coherente con la informaci\u00f3n presentada en el texto?', + weight: 30, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin relaci\u00f3n con el texto' }, + { score: 2, label: 'B\u00e1sico', description: 'Relaci\u00f3n m\u00ednima' }, + { score: 3, label: 'Competente', description: 'Relaci\u00f3n adecuada' }, + { score: 4, label: 'Avanzado', description: 'Relaci\u00f3n s\u00f3lida con evidencia' }, + { score: 5, label: 'Excelente', description: 'Fundamentaci\u00f3n excepcional' }, + ], + }, + { + id: 'creatividad', + name: 'Creatividad', + description: '\u00bfLa predicci\u00f3n muestra pensamiento original?', + weight: 25, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Respuesta literal/copiada' }, + { score: 2, label: 'B\u00e1sico', description: 'Poca originalidad' }, + { score: 3, label: 'Competente', description: 'Algo de creatividad' }, + { score: 4, label: 'Avanzado', description: 'Ideas originales' }, + { score: 5, label: 'Excelente', description: 'Muy creativo e innovador' }, + ], + }, + { + id: 'justificacion', + name: 'Justificaci\u00f3n', + description: '\u00bfEl estudiante justifica su predicci\u00f3n adecuadamente?', + weight: 30, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin justificaci\u00f3n' }, + { score: 2, label: 'B\u00e1sico', description: 'Justificaci\u00f3n vaga' }, + { score: 3, label: 'Competente', description: 'Justificaci\u00f3n aceptable' }, + { score: 4, label: 'Avanzado', description: 'Bien justificado con ejemplos' }, + { score: 5, label: 'Excelente', description: 'Argumentaci\u00f3n excepcional' }, + ], + }, + { + id: 'expresion', + name: 'Expresi\u00f3n escrita', + description: 'Claridad, gram\u00e1tica y ortograf\u00eda', + weight: 15, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Muchos errores' }, + { score: 2, label: 'B\u00e1sico', description: 'Errores frecuentes' }, + { score: 3, label: 'Competente', description: 'Algunos errores' }, + { score: 4, label: 'Avanzado', description: 'Pocos errores' }, + { score: 5, label: 'Excelente', description: 'Escritura impecable' }, + ], + }, + ], + }, + tribunal_opiniones: { + id: 'rubric-tribunal', + name: 'Evaluaci\u00f3n de Tribunal de Opiniones', + description: 'R\u00fabrica para evaluar argumentos en debates', + mechanicType: 'tribunal_opiniones', + maxScore: 100, + criteria: [ + { + id: 'argumentacion', + name: 'Calidad argumentativa', + description: 'Solidez y l\u00f3gica de los argumentos', + weight: 35, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin argumentos claros' }, + { score: 2, label: 'B\u00e1sico', description: 'Argumentos d\u00e9biles' }, + { score: 3, label: 'Competente', description: 'Argumentos aceptables' }, + { score: 4, label: 'Avanzado', description: 'Argumentos s\u00f3lidos' }, + { score: 5, label: 'Excelente', description: 'Argumentaci\u00f3n excepcional' }, + ], + }, + { + id: 'evidencia', + name: 'Uso de evidencia', + description: 'Respaldo con datos o ejemplos del texto', + weight: 30, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin evidencia' }, + { score: 2, label: 'B\u00e1sico', description: 'Evidencia irrelevante' }, + { score: 3, label: 'Competente', description: 'Alguna evidencia' }, + { score: 4, label: 'Avanzado', description: 'Buena evidencia' }, + { score: 5, label: 'Excelente', description: 'Evidencia excepcional' }, + ], + }, + { + id: 'contraargumentos', + name: 'Manejo de contraargumentos', + description: 'Capacidad de anticipar y responder objeciones', + weight: 20, + levels: [ + { score: 1, label: 'Insuficiente', description: 'No considera otras perspectivas' }, + { score: 2, label: 'B\u00e1sico', description: 'Reconocimiento superficial' }, + { score: 3, label: 'Competente', description: 'Considera algunas objeciones' }, + { score: 4, label: 'Avanzado', description: 'Buen manejo de objeciones' }, + { score: 5, label: 'Excelente', description: 'Anticipaci\u00f3n magistral' }, + ], + }, + { + id: 'respeto', + name: 'Respeto y \u00e9tica', + description: 'Tono respetuoso y \u00e9tico en el debate', + weight: 15, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Irrespetuoso' }, + { score: 2, label: 'B\u00e1sico', description: 'Poco respetuoso' }, + { score: 3, label: 'Competente', description: 'Generalmente respetuoso' }, + { score: 4, label: 'Avanzado', description: 'Respetuoso' }, + { score: 5, label: 'Excelente', description: 'Ejemplar' }, + ], + }, + ], + }, + comic_digital: { + id: 'rubric-comic', + name: 'Evaluaci\u00f3n de C\u00f3mic Digital', + description: 'R\u00fabrica para evaluar creaciones de c\u00f3mic digital', + mechanicType: 'comic_digital', + maxScore: 100, + criteria: [ + { + id: 'narrativa', + name: 'Narrativa visual', + description: 'Secuencia l\u00f3gica y fluidez de la historia', + weight: 30, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin secuencia clara' }, + { score: 2, label: 'B\u00e1sico', description: 'Secuencia confusa' }, + { score: 3, label: 'Competente', description: 'Secuencia aceptable' }, + { score: 4, label: 'Avanzado', description: 'Buena secuencia' }, + { score: 5, label: 'Excelente', description: 'Narrativa excepcional' }, + ], + }, + { + id: 'creatividad', + name: 'Creatividad visual', + description: 'Originalidad en dise\u00f1o y presentaci\u00f3n', + weight: 25, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin esfuerzo creativo' }, + { score: 2, label: 'B\u00e1sico', description: 'Poca creatividad' }, + { score: 3, label: 'Competente', description: 'Algo de creatividad' }, + { score: 4, label: 'Avanzado', description: 'Creativo' }, + { score: 5, label: 'Excelente', description: 'Muy creativo' }, + ], + }, + { + id: 'comprension', + name: 'Comprensi\u00f3n del tema', + description: 'Demuestra entendimiento del contenido', + weight: 30, + levels: [ + { score: 1, label: 'Insuficiente', description: 'No demuestra comprensi\u00f3n' }, + { score: 2, label: 'B\u00e1sico', description: 'Comprensi\u00f3n limitada' }, + { score: 3, label: 'Competente', description: 'Comprensi\u00f3n adecuada' }, + { score: 4, label: 'Avanzado', description: 'Buena comprensi\u00f3n' }, + { score: 5, label: 'Excelente', description: 'Comprensi\u00f3n profunda' }, + ], + }, + { + id: 'presentacion', + name: 'Presentaci\u00f3n t\u00e9cnica', + description: 'Calidad t\u00e9cnica del c\u00f3mic', + weight: 15, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Ilegible/incompleto' }, + { score: 2, label: 'B\u00e1sico', description: 'Dif\u00edcil de leer' }, + { score: 3, label: 'Competente', description: 'Legible' }, + { score: 4, label: 'Avanzado', description: 'Bien presentado' }, + { score: 5, label: 'Excelente', description: 'Presentaci\u00f3n profesional' }, + ], + }, + ], + }, + // Generic rubric for other manual mechanics + generic_creative: { + id: 'rubric-generic', + name: 'Evaluaci\u00f3n de Trabajo Creativo', + description: 'R\u00fabrica general para trabajos creativos', + mechanicType: 'generic', + maxScore: 100, + criteria: [ + { + id: 'contenido', + name: 'Contenido', + description: 'Calidad y relevancia del contenido', + weight: 35, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Contenido inadecuado' }, + { score: 2, label: 'B\u00e1sico', description: 'Contenido m\u00ednimo' }, + { score: 3, label: 'Competente', description: 'Contenido adecuado' }, + { score: 4, label: 'Avanzado', description: 'Buen contenido' }, + { score: 5, label: 'Excelente', description: 'Contenido excepcional' }, + ], + }, + { + id: 'creatividad', + name: 'Creatividad', + description: 'Originalidad y esfuerzo creativo', + weight: 25, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Sin creatividad' }, + { score: 2, label: 'B\u00e1sico', description: 'Poca creatividad' }, + { score: 3, label: 'Competente', description: 'Algo de creatividad' }, + { score: 4, label: 'Avanzado', description: 'Creativo' }, + { score: 5, label: 'Excelente', description: 'Muy creativo' }, + ], + }, + { + id: 'esfuerzo', + name: 'Esfuerzo', + description: 'Dedicaci\u00f3n y completitud del trabajo', + weight: 25, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Trabajo incompleto' }, + { score: 2, label: 'B\u00e1sico', description: 'Esfuerzo m\u00ednimo' }, + { score: 3, label: 'Competente', description: 'Esfuerzo aceptable' }, + { score: 4, label: 'Avanzado', description: 'Buen esfuerzo' }, + { score: 5, label: 'Excelente', description: 'Esfuerzo sobresaliente' }, + ], + }, + { + id: 'presentacion', + name: 'Presentaci\u00f3n', + description: 'Claridad y organizaci\u00f3n', + weight: 15, + levels: [ + { score: 1, label: 'Insuficiente', description: 'Desorganizado' }, + { score: 2, label: 'B\u00e1sico', description: 'Poco organizado' }, + { score: 3, label: 'Competente', description: 'Organizado' }, + { score: 4, label: 'Avanzado', description: 'Bien organizado' }, + { score: 5, label: 'Excelente', description: 'Excelente presentaci\u00f3n' }, + ], + }, + ], + }, +}; + +/** + * Get rubric for a specific mechanic type + */ +export const getRubricForMechanic = (mechanicType: string): RubricConfig => { + return DEFAULT_RUBRICS[mechanicType] || DEFAULT_RUBRICS.generic_creative; +}; + +// ============================================================================ +// SUB-COMPONENTS +// ============================================================================ + +interface CriterionCardProps { + criterion: RubricCriterion; + selectedLevel: number | undefined; + feedback: string; + onLevelSelect: (level: number) => void; + onFeedbackChange: (feedback: string) => void; + readOnly?: boolean; +} + +const CriterionCard: React.FC = ({ + criterion, + selectedLevel, + feedback, + onLevelSelect, + onFeedbackChange, + readOnly = false, +}) => { + const [showFeedback, setShowFeedback] = useState(false); + + return ( +
+ {/* Header */} +
+
+
+

{criterion.name}

+ + {criterion.weight}% + +
+

{criterion.description}

+
+ {selectedLevel !== undefined && ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ )} +
+ + {/* Level Selection */} +
+ {criterion.levels.map((level) => ( + + ))} +
+ + {/* Feedback Toggle */} + {!readOnly && ( +
+ + + {showFeedback && ( + +