diff --git a/MEETING_ATTENDANCE_TRACKING.md b/MEETING_ATTENDANCE_TRACKING.md new file mode 100644 index 0000000..f524fe7 --- /dev/null +++ b/MEETING_ATTENDANCE_TRACKING.md @@ -0,0 +1,203 @@ +# Meeting Attendance Tracking System + +This document describes the meeting attendance tracking system implemented in the Labs GraphQL API. + +## Overview + +The system provides a **two-pronged approach** to tracking student meeting attendance: + +1. **Immediate (Backup):** Capture attendance via mentor reflections/surveys +2. **Future (Primary):** Automatically track attendance from Slack meetings (prepared for future implementation) + +## Architecture + +### Database Schema + +**New Enum: `AttendanceSource`** +- `SLACK_HUDDLE` - Direct tracking from Slack huddle API (future) +- `MESSAGE_ACTIVITY` - Inferred from message activity during meeting time (future) +- `MENTOR_REPORT` - From mentor reflection surveys (active) +- `MANUAL` - Manually entered by admin + +**Extended Models:** + +**`Meeting`** +- `projectId` - Links meetings to specific projects +- `slackHuddleId` - Slack huddle identifier for automatic tracking +- `scheduledStartAt` - Scheduled meeting start time +- `scheduledEndAt` - Scheduled meeting end time + +**`MeetingAttendance`** +- `source` - Tracks where attendance data came from (AttendanceSource) +- `confidence` - Quality score (0.0-1.0) for inferred data +- `metadata` - Additional context (e.g., reflection ID, message count) + +**`Project`** +- `meetings` - One-to-many relation to meetings + +### GraphQL API + +**Types:** +- `Meeting` - Represents a scheduled meeting +- `MeetingAttendance` - Attendance record for a student at a meeting +- `MeetingResponse` - Student's agenda/notes for a meeting +- `StudentAttendanceStat` - Attendance statistics for a student +- `MentorReflectionStat` - Reflection completion statistics for a mentor +- `FlaggedStudent` - Students needing attention + +**Queries:** +- `meetings(eventId, projectId)` - List meetings +- `meeting(id)` - Get single meeting +- `meetingAttendance(meetingId)` - Get attendance for a meeting +- `statStudentAttendance(eventId, projectId, minAttendance)` - Get attendance stats +- `statMentorReflectionCompletion(eventId)` - Track mentor reflection submissions +- `flaggedStudents(eventId, minAttendance)` - Get students needing attention + +**Mutations:** +- `createMeeting(data)` - Create new meeting +- `recordMeetingAttendance(data)` - Record/update attendance (upsert logic) + +### Automation Tasks + +**`processMentorReflections`** (runs every Monday at 6 AM) +- Processes mentor reflection survey responses +- Extracts attendance data from response JSON +- Automatically creates Meeting records for the week if needed +- Records MeetingAttendance with source=MENTOR_REPORT +- Supports multiple response formats: + - `response.meetingHeld` - whether meeting occurred + - `response.studentAttendance` - array of attending student IDs + - `response.studentsPresent` - alternative format + +**`sendAttendanceAlerts`** (runs every Monday at 9 AM) +- Checks all active events for attendance issues +- Identifies students with <75% attendance (minimum 2 meetings) +- Identifies mentors behind on reflections +- Sends Slack alerts to the `#stats` channel with summary +- Email template available for weekly reports + +## Usage + +### For Admins: Adding Attendance Question to Mentor Surveys + +To enable attendance tracking via mentor reflections, add this question to your mentor reflection survey: + +```json +{ + "type": "object", + "properties": { + "meetingHeld": { + "type": "boolean", + "title": "Did you hold a meeting with your student(s) this week?" + }, + "studentAttendance": { + "type": "array", + "title": "Which students attended your meeting?", + "items": { + "type": "string", + "enum": ["student-id-1", "student-id-2", "student-id-3"] + }, + "uniqueItems": true + } + } +} +``` + +The automation task will automatically process responses and create attendance records. + +### For Developers: Querying Attendance Data + +**Get attendance stats for an event:** +```graphql +query { + statStudentAttendance(eventId: "event-id") { + student { givenName surname email } + project { description } + meetingsTotal + meetingsAttended + attendancePercentage + isFlagged + dataSources + lastAttendedAt + } +} +``` + +**Get flagged students:** +```graphql +query { + flaggedStudents(eventId: "event-id", minAttendance: 0.75) { + student { givenName surname } + mentor { givenName surname } + reason + attendancePercentage + missedMeetings + } +} +``` + +**Record manual attendance:** +```graphql +mutation { + recordMeetingAttendance(data: { + meetingId: "meeting-id" + studentId: "student-id" + attended: true + source: MANUAL + }) { + id attended source + } +} +``` + +## Future Enhancements (Phase 5+) + +### Slack Message Activity Tracking +- Infer attendance from message activity during meeting times +- Source: `MESSAGE_ACTIVITY`, Confidence: 0.7 + +### Slack Huddle API Integration +- Direct tracking from Slack huddle events +- Real-time attendance capture +- Source: `SLACK_HUDDLE`, Confidence: 1.0 + +### Hybrid Attendance Resolution +- Merge attendance from multiple sources +- Priority system: SLACK_HUDDLE > MENTOR_REPORT > MESSAGE_ACTIVITY > MANUAL +- Flag conflicts for review + +## Migration + +To apply the database changes: + +```bash +# Run the migration +npx prisma migrate deploy + +# Or for development +npx prisma migrate dev +``` + +## Testing + +1. Create a test meeting: +```graphql +mutation { + createMeeting(data: { + eventId: "test-event" + projectId: "test-project" + visibleAt: "2025-01-01T00:00:00Z" + dueAt: "2025-01-08T00:00:00Z" + scheduledStartAt: "2025-01-03T14:00:00Z" + scheduledEndAt: "2025-01-03T15:00:00Z" + }) { id } +} +``` + +2. Record attendance +3. Check stats with `statStudentAttendance` +4. Verify alert in Slack mentor channel (Mondays at 9 AM) + +## Support + +For questions or issues, contact the engineering team or open an issue in the repository. diff --git a/package.json b/package.json index b52f2f6..325a2da 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "prisma": "prisma format && prisma generate", "dev": "ts-node-dev --no-notify --respawn --transpile-only src", "debug": "ts-node-dev --no-notify --respawn src", + "test:attendance": "ts-node --transpile-only src/automation/tasks/testProcessMentorReflections.ts && ts-node --transpile-only src/automation/tasks/testSendAttendanceAlerts.ts", + "test:attendance-slack": "ts-node --transpile-only scripts/testAttendanceSlack.ts", "send-event-recommendations": "ts-node scripts/sendEventRecommendations.ts", "swagger": "rm src/badgr/Api.ts; swagger-typescript-api -p badgr-api-v2.yaml -n Api2.ts -o ./src/badgr; echo 'type json = JSON;' | cat - src/badgr/Api2.ts > src/badgr/Api.ts; rm src/badgr/Api2.ts" }, @@ -88,4 +90,4 @@ "ts-node-dev": "^1.1.6", "typescript": "^5.2.2" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20260714193312_add_meeting_attendance_tracking/migration.sql b/prisma/migrations/20260714193312_add_meeting_attendance_tracking/migration.sql new file mode 100644 index 0000000..3c1c464 --- /dev/null +++ b/prisma/migrations/20260714193312_add_meeting_attendance_tracking/migration.sql @@ -0,0 +1,22 @@ +-- CreateEnum: Add attendance source tracking +CREATE TYPE "AttendanceSource" AS ENUM ('SLACK_HUDDLE', 'MESSAGE_ACTIVITY', 'MENTOR_REPORT', 'MANUAL'); + +-- AlterTable: Add attendance tracking fields to MeetingAttendance +ALTER TABLE "MeetingAttendance" ADD COLUMN "source" "AttendanceSource" NOT NULL DEFAULT 'MANUAL'; +ALTER TABLE "MeetingAttendance" ADD COLUMN "confidence" DOUBLE PRECISION NOT NULL DEFAULT 1.0; +ALTER TABLE "MeetingAttendance" ADD COLUMN "metadata" JSONB; + +-- AlterTable: Add Slack and project fields to Meeting +ALTER TABLE "Meeting" ADD COLUMN "slackHuddleId" TEXT; +ALTER TABLE "Meeting" ADD COLUMN "scheduledStartAt" TIMESTAMP(3); +ALTER TABLE "Meeting" ADD COLUMN "scheduledEndAt" TIMESTAMP(3); +ALTER TABLE "Meeting" ADD COLUMN "projectId" TEXT; + +-- AddForeignKey: Link meetings to projects +ALTER TABLE "Meeting" ADD CONSTRAINT "Meeting_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- CreateIndex: Add index for source-based queries +CREATE INDEX "MeetingAttendance_source_idx" ON "MeetingAttendance"("source"); + +-- CreateIndex: Add index for project-based meeting queries +CREATE INDEX "Meeting_projectId_idx" ON "Meeting"("projectId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62a873a..65fada1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -222,12 +222,22 @@ model Meeting { notesStudentSchema Json? notesStudentUi Json? + // Slack Integration + slackHuddleId String? + scheduledStartAt DateTime? + scheduledEndAt DateTime? + // Relations event Event @relation(fields: [eventId], references: [id]) eventId String + project Project? @relation(fields: [projectId], references: [id]) + projectId String? + responses MeetingResponse[] attendance MeetingAttendance[] + + @@index([projectId]) } model MeetingAttendance { @@ -239,12 +249,19 @@ model MeetingAttendance { attended Boolean @default(false) prepared Boolean @default(false) + // Attendance tracking + source AttendanceSource @default(MANUAL) + confidence Float @default(1.0) + metadata Json? + // Relations meeting Meeting @relation(fields: [meetingId], references: [id]) meetingId String student Student? @relation(fields: [studentId], references: [id]) studentId String? + + @@index([source]) } model MeetingResponse { @@ -490,6 +507,13 @@ enum PrStatus { CLOSED_MERGED } +enum AttendanceSource { + SLACK_HUDDLE + MESSAGE_ACTIVITY + MENTOR_REPORT + MANUAL +} + model Project { // Metadata id String @id @default(cuid()) @@ -536,6 +560,7 @@ model Project { standupResults StandupResult[] artifacts Artifact[] files File[] + meetings Meeting[] } model ArtifactType { diff --git a/scripts/testAttendanceSlack.ts b/scripts/testAttendanceSlack.ts new file mode 100644 index 0000000..e6a696c --- /dev/null +++ b/scripts/testAttendanceSlack.ts @@ -0,0 +1,460 @@ +/** + * Manual test script for the attendance Slack integration + * + * This script allows you to seed local test data, preview the attendance alert + * message, and optionally post it to a test Slack channel without affecting + * real events. + * + * Usage: + * # Dry run - preview message without posting + * npx ts-node scripts/testAttendanceSlack.ts --dry-run + * + * # Seed local DB and preview the real alert message from seeded data + * npx ts-node scripts/testAttendanceSlack.ts --seed-test-data --use-real-data --dry-run + * + * # Seed local DB and post to a test channel + * npx ts-node scripts/testAttendanceSlack.ts --seed-test-data --use-real-data --channel=test-notifications + * + * # Post fake data to #stats + * npx ts-node scripts/testAttendanceSlack.ts --channel=stats + */ + +import 'reflect-metadata'; +import { PrismaClient, MentorStatus, ProjectStatus, StudentStatus, Track } from '@prisma/client'; +import Container from 'typedi'; +import { WebClient } from '@slack/web-api'; +import { DateTime } from 'luxon'; +import { buildWeeklyAttendanceAlertMessage, AttendanceIssue, MentorIssue } from '../src/automation/tasks/sendAttendanceAlerts'; +import { getSlackClientForEvent } from '../src/slack'; +import { registerDi } from '../src/di'; + +const args = process.argv.slice(2); +const isDryRun = args.includes('--dry-run'); +const useRealData = args.includes('--use-real-data'); +const seedTestData = args.includes('--seed-test-data'); +const channelArg = args.find((arg) => arg.startsWith('--channel=')); +const channelName = channelArg ? channelArg.split('=')[1] : 'attendance-test'; + +const TEST_EVENT_ID = 'attendance-slack-test-event'; +const TEST_EVENT_NAME = 'Attendance Slack Test Event'; +const TEST_PROJECT_ID = 'attendance-slack-test-project'; +const TEST_MENTOR_ID = 'attendance-slack-test-mentor'; +const TEST_STUDENT_ID = 'attendance-slack-test-student'; +const TEST_MEETING_ONE_ID = 'attendance-slack-test-meeting-1'; +const TEST_MEETING_TWO_ID = 'attendance-slack-test-meeting-2'; +const TEST_ATTENDANCE_ONE_ID = 'attendance-slack-test-attendance-1'; +const TEST_ATTENDANCE_TWO_ID = 'attendance-slack-test-attendance-2'; +const TEST_SLACK_BOT_TOKEN = process.env.TEST_SLACK_BOT_TOKEN || process.env.SLACK_BOT_TOKEN || null; + +interface TestEventInfo { + eventId: string; + eventName: string; + hasSlackToken: boolean; +} + +const FAKE_STUDENT_ISSUES: AttendanceIssue[] = [ + { + studentName: 'Alice TestStudent', + studentEmail: 'alice.teststudent@example.test', + studentSlackId: null, + projectName: 'Attendance Slack Test Project', + mentorName: 'Test Mentor', + mentorSlackId: null, + attendancePercentage: 0.5, + meetingsAttended: 1, + meetingsTotal: 2, + lastAttendedAt: new Date(), + }, +]; + +const FAKE_MENTOR_ISSUES: MentorIssue[] = [ + { + mentorName: 'Test Mentor', + mentorEmail: 'test.mentor@example.test', + mentorSlackId: null, + projectName: 'Attendance Slack Test Project', + missedReflections: 2, + expectedReflections: 4, + }, +]; + +async function resolveSlackChannelId(slack: WebClient, channelInput: string): Promise { + const normalizedChannel = channelInput.replace(/^#/, ''); + + if (/^[CGD][A-Z0-9]+$/i.test(normalizedChannel)) { + return normalizedChannel; + } + + const channelsList = await slack.conversations.list({ + exclude_archived: true, + types: 'public_channel,private_channel', + limit: 100, + }); + + const channel = channelsList.channels?.find((item: any) => item.name === normalizedChannel); + + if (!channel?.id) { + throw new Error(`Channel #${normalizedChannel} was not found in the test Slack workspace.`); + } + + return channel.id; +} + +async function seedLocalTestData(prisma: PrismaClient): Promise { + const now = DateTime.now(); + let slackWorkspaceId: string | null = null; + + if (TEST_SLACK_BOT_TOKEN) { + const slack = new WebClient(TEST_SLACK_BOT_TOKEN); + const auth = await slack.auth.test(); + slackWorkspaceId = auth.team_id || null; + } + + await prisma.meetingAttendance.deleteMany({ where: { id: { in: [TEST_ATTENDANCE_ONE_ID, TEST_ATTENDANCE_TWO_ID] } } }); + await prisma.meeting.deleteMany({ where: { id: { in: [TEST_MEETING_ONE_ID, TEST_MEETING_TWO_ID] } } }); + await prisma.project.deleteMany({ where: { id: TEST_PROJECT_ID } }); + await prisma.student.deleteMany({ where: { id: TEST_STUDENT_ID } }); + await prisma.mentor.deleteMany({ where: { id: TEST_MENTOR_ID } }); + await prisma.event.deleteMany({ where: { id: TEST_EVENT_ID } }); + + await prisma.event.create({ + data: { + id: TEST_EVENT_ID, + name: TEST_EVENT_NAME, + title: TEST_EVENT_NAME, + certificationStatements: [], + studentApplicationsStartAt: now.minus({ days: 60 }).toJSDate(), + mentorApplicationsStartAt: now.minus({ days: 60 }).toJSDate(), + studentApplicationsEndAt: now.minus({ days: 45 }).toJSDate(), + mentorApplicationsEndAt: now.minus({ days: 45 }).toJSDate(), + startsAt: now.minus({ days: 35 }).toJSDate(), + projectWorkStartsAt: now.minus({ days: 30 }).toJSDate(), + studentApplicationSchema: {}, + studentApplicationUi: {}, + studentApplicationPostprocess: {}, + mentorApplicationSchema: {}, + mentorApplicationUi: {}, + mentorApplicationPostprocess: {}, + isActive: true, + defaultWeeks: 4, + slackWorkspaceAccessToken: TEST_SLACK_BOT_TOKEN, + slackWorkspaceId, + slackMentorChannelId: null, + }, + }); + + await prisma.mentor.create({ + data: { + id: TEST_MENTOR_ID, + eventId: TEST_EVENT_ID, + givenName: 'Test', + surname: 'Mentor', + email: 'test.mentor@example.test', + profile: {}, + status: MentorStatus.ACCEPTED, + slackId: 'U_TEST_MENTOR', + }, + }); + + await prisma.student.create({ + data: { + id: TEST_STUDENT_ID, + eventId: TEST_EVENT_ID, + givenName: 'Test', + surname: 'Student', + email: 'test.student@example.test', + profile: {}, + track: Track.BEGINNER, + status: StudentStatus.ACCEPTED, + minHours: 5, + slackId: 'U_TEST_STUDENT', + }, + }); + + await prisma.project.create({ + data: { + id: TEST_PROJECT_ID, + eventId: TEST_EVENT_ID, + description: 'Attendance Slack test project', + deliverables: 'Weekly meeting attendance mock data', + track: Track.BEGINNER, + status: ProjectStatus.MATCHED, + mentors: { + connect: [{ id: TEST_MENTOR_ID }], + }, + students: { + connect: [{ id: TEST_STUDENT_ID }], + }, + }, + }); + + await prisma.meeting.createMany({ + data: [ + { + id: TEST_MEETING_ONE_ID, + eventId: TEST_EVENT_ID, + visibleAt: now.minus({ days: 14 }).toJSDate(), + dueAt: now.minus({ days: 13 }).toJSDate(), + }, + { + id: TEST_MEETING_TWO_ID, + eventId: TEST_EVENT_ID, + visibleAt: now.minus({ days: 7 }).toJSDate(), + dueAt: now.minus({ days: 6 }).toJSDate(), + }, + ], + }); + + await prisma.meetingAttendance.createMany({ + data: [ + { + id: TEST_ATTENDANCE_ONE_ID, + meetingId: TEST_MEETING_ONE_ID, + studentId: TEST_STUDENT_ID, + attended: true, + }, + { + id: TEST_ATTENDANCE_TWO_ID, + meetingId: TEST_MEETING_TWO_ID, + studentId: TEST_STUDENT_ID, + attended: false, + }, + ], + }); + + return { + eventId: TEST_EVENT_ID, + eventName: TEST_EVENT_NAME, + hasSlackToken: Boolean(TEST_SLACK_BOT_TOKEN && slackWorkspaceId), + }; +} + +async function collectAttendanceIssuesFromEvent( + prisma: PrismaClient, + event: { id: string; name: string; startsAt: Date; defaultWeeks: number }, +): Promise<{ students: AttendanceIssue[]; mentors: MentorIssue[] }> { + const projects = await prisma.project.findMany({ + where: { + eventId: event.id, + status: 'MATCHED', + }, + include: { + students: { where: { status: 'ACCEPTED' } }, + mentors: { where: { status: 'ACCEPTED' } }, + }, + }); + + const meetings = await prisma.meeting.findMany({ + where: { + eventId: event.id, + }, + include: { + attendance: true, + }, + }); + + const students: AttendanceIssue[] = []; + const mentors: MentorIssue[] = []; + + for (const project of projects) { + const mentor = project.mentors[0]; + if (!mentor) continue; + + for (const student of project.students) { + const studentAttendance = meetings.flatMap((meeting) => + meeting.attendance.filter((attendance) => attendance.studentId === student.id) + ); + + const meetingsTotal = meetings.length; + const meetingsAttended = studentAttendance.filter((attendance) => attendance.attended).length; + const attendancePercentage = meetingsTotal > 0 ? meetingsAttended / meetingsTotal : 1; + + if (attendancePercentage < 0.75 && meetingsTotal >= 2) { + const lastAttended = studentAttendance + .filter((attendance) => attendance.attended) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; + + students.push({ + studentName: `${student.givenName} ${student.surname}`, + studentEmail: student.email, + studentSlackId: student.slackId || undefined, + projectName: project.description?.slice(0, 50) || 'Untitled Project', + mentorName: `${mentor.givenName} ${mentor.surname}`, + mentorSlackId: mentor.slackId || undefined, + attendancePercentage, + meetingsAttended, + meetingsTotal, + lastAttendedAt: lastAttended?.createdAt, + }); + } + } + + const mentorReflections = await prisma.surveyResponse.count({ + where: { + authorMentorId: mentor.id, + surveyOccurence: { + survey: { + personType: 'MENTOR', + eventId: event.id, + }, + }, + }, + }); + + const weeksSinceStart = Math.max( + 1, + Math.floor(DateTime.now().diff(DateTime.fromJSDate(event.startsAt), 'weeks').weeks) + ); + const expectedReflections = Math.min(weeksSinceStart, event.defaultWeeks); + + if (mentorReflections < expectedReflections * 0.75 && expectedReflections >= 2) { + mentors.push({ + mentorName: `${mentor.givenName} ${mentor.surname}`, + mentorEmail: mentor.email, + mentorSlackId: mentor.slackId || undefined, + projectName: project.description?.slice(0, 50) || 'Untitled Project', + missedReflections: expectedReflections - mentorReflections, + expectedReflections, + }); + } + } + + return { students, mentors }; +} + +async function postTestMessage( + slack: WebClient | null, + channelNameToUse: string, + eventName: string, + students: AttendanceIssue[], + mentors: MentorIssue[], +): Promise { + const message = { + channel: channelNameToUse, + text: buildWeeklyAttendanceAlertMessage(eventName, students, mentors), + }; + + if (isDryRun) { + console.log('\n๐Ÿ“‹ DRY RUN - Message preview:'); + console.log(JSON.stringify(message, null, 2)); + console.log('\nโœ… Dry run complete - no message posted'); + return; + } + + if (!slack) { + throw new Error('Slack client is required for live posting.'); + } + + const channelId = await resolveSlackChannelId(slack, channelNameToUse); + + await slack.chat.postMessage({ + ...message, + channel: channelId, + }); + + console.log(`\nโœ… Test message posted to #${channelNameToUse}`); +} + +async function main() { + console.log('๐Ÿงช Attendance Slack - Test Script\n'); + console.log(`Mode: ${isDryRun ? 'DRY RUN' : 'LIVE'}`); + console.log(`Channel: #${channelName}`); + console.log(`Data: ${useRealData ? 'Real from database' : 'Fake test data'}\n`); + + registerDi(); + const prisma = Container.get(PrismaClient); + + try { + let seededEventInfo: TestEventInfo | null = null; + if (seedTestData) { + console.log('Seeding local attendance test data...'); + seededEventInfo = await seedLocalTestData(prisma); + console.log(`โœ… Seeded event: ${seededEventInfo.eventName} (${seededEventInfo.eventId})`); + if (!seededEventInfo.hasSlackToken) { + console.log('โš ๏ธ Seeded DB data, but no test Slack bot token was found. Live posting will still require TEST_SLACK_BOT_TOKEN or SLACK_BOT_TOKEN.'); + } + } + + let students: AttendanceIssue[]; + let mentors: MentorIssue[]; + let eventName: string; + + if (useRealData) { + const sourceEvent = seededEventInfo + ? await prisma.event.findUnique({ where: { id: seededEventInfo.eventId } }) + : await prisma.event.findFirst({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + if (!sourceEvent) { + console.error('โŒ No event found to pull attendance data from. Seed data first with --seed-test-data.'); + process.exit(1); + } + + const issues = await collectAttendanceIssuesFromEvent(prisma, sourceEvent); + students = issues.students; + mentors = issues.mentors; + eventName = sourceEvent.name; + + console.log(`Using event data: ${sourceEvent.name} (${sourceEvent.id})`); + console.log(`Found ${students.length} flagged students and ${mentors.length} flagged mentors in local DB\n`); + + if (students.length === 0 || mentors.length === 0) { + console.error('โŒ Seeded test data did not produce both student and mentor issues.'); + process.exit(1); + } + } else { + students = FAKE_STUDENT_ISSUES; + mentors = FAKE_MENTOR_ISSUES; + eventName = TEST_EVENT_NAME; + } + + if (isDryRun) { + await postTestMessage(null, channelName, eventName, students, mentors); + return; + } + + const event = seededEventInfo + ? await prisma.event.findUnique({ where: { id: seededEventInfo.eventId } }) + : await prisma.event.findFirst({ + where: { + isActive: true, + slackWorkspaceAccessToken: { not: null }, + slackWorkspaceId: { not: null }, + }, + orderBy: { + updatedAt: 'desc', + }, + }); + + if (!event?.slackWorkspaceAccessToken || !event.slackWorkspaceId) { + console.error('โŒ No active event with Slack workspace token found. Seed a test event with TEST_SLACK_BOT_TOKEN or SLACK_BOT_TOKEN.'); + process.exit(1); + } + + console.log(`Using Slack event: ${event.name} (${event.id})\n`); + + const slack = getSlackClientForEvent(event); + + await postTestMessage(slack, channelName, eventName, students, mentors); + } finally { + await prisma.$disconnect(); + } +} + +main() + .then(() => { + console.log('\nโœจ Test complete'); + process.exit(0); + }) + .catch((error) => { + console.error('\nโŒ Error:', error); + process.exit(1); + }); \ No newline at end of file diff --git a/src/automation/tasks/processMentorReflections.ts b/src/automation/tasks/processMentorReflections.ts new file mode 100644 index 0000000..8c36fb8 --- /dev/null +++ b/src/automation/tasks/processMentorReflections.ts @@ -0,0 +1,197 @@ +import { PrismaClient, SurveyResponse, Meeting } from '@prisma/client'; +import Container from 'typedi'; +import { makeDebug } from '../../utils'; +import { DateTime } from 'luxon'; + +const DEBUG = makeDebug('automation:tasks:processMentorReflections'); + +export const JOBSPEC = '0 6 * * MON'; // Run every Monday at 6 AM + +export function extractAttendedStudentIds( + response: Record, + projectStudentIds: string[] +): Set { + const validStudentIds = new Set(projectStudentIds); + const attendedStudentIds = new Set(); + + const fromStudentAttendance = Array.isArray(response.studentAttendance) + ? response.studentAttendance + : []; + const fromStudentsPresent = Array.isArray(response.studentsPresent) + ? response.studentsPresent + : []; + + for (const id of [...fromStudentAttendance, ...fromStudentsPresent]) { + if (typeof id === 'string' && validStudentIds.has(id)) attendedStudentIds.add(id); + } + + const fromStudentsAbsent = Array.isArray(response.studentsAbsent) + ? response.studentsAbsent.filter((id): id is string => typeof id === 'string' && validStudentIds.has(id)) + : []; + + if (fromStudentsAbsent.length > 0 && attendedStudentIds.size === 0) { + const absentIds = new Set(fromStudentsAbsent); + projectStudentIds.forEach((id) => { + if (!absentIds.has(id)) attendedStudentIds.add(id); + }); + } else { + fromStudentsAbsent.forEach((id) => attendedStudentIds.delete(id)); + } + + return attendedStudentIds; +} + +/** + * This task processes mentor reflection survey responses and extracts + * attendance data to create MeetingAttendance records. + * + * Looks for survey responses with: + * - response.meetingHeld (boolean) - whether a meeting occurred + * - response.studentAttendance (array of student IDs) - which students attended + * - response.studentsAbsent (array of student IDs) - alternative format + */ +export default async function processMentorReflections(): Promise { + const prisma = Container.get(PrismaClient); + + // Weekly schedule: look back a full week plus a small buffer. + const lastProcessed = DateTime.now().minus({ days: 8 }).toJSDate(); + + DEBUG(`Processing mentor reflections since ${lastProcessed.toISOString()}`); + + // Find recent mentor-authored survey responses + const mentorReflections = await prisma.surveyResponse.findMany({ + where: { + authorMentorId: { not: null }, + createdAt: { gte: lastProcessed }, + surveyOccurence: { + survey: { + personType: 'MENTOR', + }, + }, + }, + include: { + authorMentor: { + include: { + projects: { + where: { status: 'MATCHED' }, + include: { + students: { where: { status: 'ACCEPTED' } }, + }, + }, + }, + }, + surveyOccurence: { + include: { survey: true }, + }, + }, + }); + + DEBUG(`Found ${mentorReflections.length} mentor reflections to process`); + + for (const reflection of mentorReflections) { + try { + await processReflection(reflection); + } catch (err) { + DEBUG(`Error processing reflection ${reflection.id}: ${err}`); + } + } +} + +async function processReflection(reflection: any): Promise { + const prisma = Container.get(PrismaClient); + const response = reflection.response as any; + + // Check if this reflection contains meeting attendance data + if (!response || typeof response !== 'object') { + DEBUG(`Skipping reflection ${reflection.id}: invalid response format`); + return; + } + + // Get the mentor's project (assume first project for now) + const project = reflection.authorMentor?.projects?.[0]; + if (!project) { + DEBUG(`Skipping reflection ${reflection.id}: no project found for mentor`); + return; + } + + // Determine if a meeting was held + const meetingHeld = response.meetingHeld === true || response.meetingHeld === 'true'; + + if (!meetingHeld && !response.studentAttendance && !response.studentsAbsent && !response.studentsPresent) { + DEBUG(`Skipping reflection ${reflection.id}: no attendance data found`); + return; + } + + DEBUG(`Processing attendance for project ${project.id} from reflection ${reflection.id}`); + + // Find or create a meeting for this week + const weekStart = DateTime.fromJSDate(reflection.createdAt).startOf('week').toJSDate(); + const weekEnd = DateTime.fromJSDate(reflection.createdAt).endOf('week').toJSDate(); + + let meeting = await prisma.meeting.findFirst({ + where: { + projectId: project.id, + scheduledStartAt: { gte: weekStart, lte: weekEnd }, + }, + }); + + if (!meeting) { + // Create a meeting for this week + DEBUG(`Creating meeting for project ${project.id}, week of ${weekStart.toISOString()}`); + meeting = await prisma.meeting.create({ + data: { + projectId: project.id, + eventId: project.eventId!, + visibleAt: weekStart, + dueAt: weekEnd, + scheduledStartAt: weekStart, + scheduledEndAt: weekStart, + }, + }); + } + + // Extract attendance information from supported reflection response formats. + const attendedStudentIds = extractAttendedStudentIds( + response, + project.students.map((s: { id: string }) => s.id) + ); + + // Record attendance for all students in the project + for (const student of project.students) { + const attended = attendedStudentIds.has(student.id); + + // Check if attendance record already exists + const existing = await prisma.meetingAttendance.findFirst({ + where: { + meetingId: meeting.id, + studentId: student.id, + source: 'MENTOR_REPORT', + }, + }); + + if (existing) { + DEBUG(`Updating attendance for student ${student.id} in meeting ${meeting.id}: ${attended}`); + await prisma.meetingAttendance.update({ + where: { id: existing.id }, + data: { + attended, + metadata: { reflectionId: reflection.id, processedAt: new Date().toISOString() }, + }, + }); + } else { + DEBUG(`Creating attendance for student ${student.id} in meeting ${meeting.id}: ${attended}`); + await prisma.meetingAttendance.create({ + data: { + meetingId: meeting.id, + studentId: student.id, + attended, + source: 'MENTOR_REPORT', + confidence: 1.0, + metadata: { reflectionId: reflection.id, processedAt: new Date().toISOString() }, + }, + }); + } + } + + DEBUG(`Successfully processed reflection ${reflection.id} for ${project.students.length} students`); +} diff --git a/src/automation/tasks/sendAttendanceAlerts.ts b/src/automation/tasks/sendAttendanceAlerts.ts new file mode 100644 index 0000000..ed27d30 --- /dev/null +++ b/src/automation/tasks/sendAttendanceAlerts.ts @@ -0,0 +1,231 @@ +import { PrismaClient, Event } from '@prisma/client'; +import Container from 'typedi'; +import { getSlackClientForEvent } from '../../slack'; +import { makeDebug } from '../../utils'; +import { DateTime } from 'luxon'; + +const DEBUG = makeDebug('automation:tasks:sendAttendanceAlerts'); +const ATTENDANCE_ALERT_CHANNEL = 'stats'; + +export const JOBSPEC = '0 9 * * MON'; // Every Monday at 9 AM + +export interface AttendanceIssue { + studentName: string; + studentEmail: string; + studentSlackId?: string; + projectName: string; + mentorName: string; + mentorSlackId?: string; + attendancePercentage: number; + meetingsAttended: number; + meetingsTotal: number; + lastAttendedAt?: Date; +} + +export interface MentorIssue { + mentorName: string; + mentorEmail: string; + mentorSlackId?: string; + projectName: string; + missedReflections: number; + expectedReflections: number; +} + +function slackMention(slackId?: string): string | null { + return slackId ? `<@${slackId}>` : null; +} + +export function buildWeeklyAttendanceAlertMessage( + eventName: string, + students: AttendanceIssue[], + mentors: MentorIssue[] +): string { + let message = `๐Ÿšจ *Weekly Attendance Alert for ${eventName}*\n\n`; + + if (students.length > 0) { + message += '*Students with Low Attendance (<75%):*\n'; + students.slice(0, 10).forEach((s) => { + const pct = Math.round(s.attendancePercentage * 100); + message += `โ€ข ${s.studentName} - ${pct}% (${s.meetingsAttended}/${s.meetingsTotal} meetings)\n`; + message += ` Project: ${s.projectName}\n`; + message += ` Mentor: ${s.mentorName}\n`; + + const studentMention = slackMention(s.studentSlackId); + const mentorMention = slackMention(s.mentorSlackId); + const mentions = [studentMention, mentorMention].filter(Boolean).join(' '); + if (mentions) message += ` Notify: ${mentions}\n`; + }); + if (students.length > 10) { + message += `\n_... and ${students.length - 10} more students_\n`; + } + message += '\n'; + } + + if (mentors.length > 0) { + message += '*Mentors Behind on Reflections:*\n'; + mentors.slice(0, 10).forEach((m) => { + message += `โ€ข ${m.mentorName} - ${m.missedReflections} reflections behind\n`; + message += ` Project: ${m.projectName}\n`; + + const mention = slackMention(m.mentorSlackId); + if (mention) message += ` Notify: ${mention}\n`; + }); + if (mentors.length > 10) { + message += `\n_... and ${mentors.length - 10} more mentors_\n`; + } + } + + return message; +} + +export default async function sendAttendanceAlerts(): Promise { + const prisma = Container.get(PrismaClient); + + // Get all active events + const activeEvents = await prisma.event.findMany({ + where: { isActive: true }, + }); + + DEBUG(`Checking ${activeEvents.length} active events for attendance issues`); + + for (const event of activeEvents) { + try { + await processEventAlerts(event); + } catch (err) { + DEBUG(`Error processing alerts for event ${event.id}: ${err}`); + } + } +} + +export async function getAttendanceIssuesForEvent( + prisma: PrismaClient, + event: Event, +): Promise<{ students: AttendanceIssue[]; mentors: MentorIssue[] }> { + + DEBUG(`Processing attendance alerts for event: ${event.name}`); + + const lowAttendanceStudents: AttendanceIssue[] = []; + const mentorReflectionIssues: MentorIssue[] = []; + + // Get all matched projects with their students and attendance + const projects = await prisma.project.findMany({ + where: { + eventId: event.id, + status: 'MATCHED', + }, + include: { + students: { where: { status: 'ACCEPTED' } }, + mentors: { where: { status: 'ACCEPTED' } }, + meetings: { + include: { + attendance: true, + }, + }, + }, + }); + + for (const project of projects) { + const mentor = project.mentors[0]; + if (!mentor) continue; + + // Check student attendance + for (const student of project.students) { + const allMeetings = project.meetings; + const studentAttendance = allMeetings.flatMap((m) => + m.attendance.filter((a) => a.studentId === student.id) + ); + + const meetingsTotal = allMeetings.length; + const meetingsAttended = studentAttendance.filter((a) => a.attended).length; + const attendancePercentage = meetingsTotal > 0 ? meetingsAttended / meetingsTotal : 1; + + // Flag students with <75% attendance and at least 2 meetings + if (attendancePercentage < 0.75 && meetingsTotal >= 2) { + const lastAttended = studentAttendance + .filter((a) => a.attended) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; + + lowAttendanceStudents.push({ + studentName: `${student.givenName} ${student.surname}`, + studentEmail: student.email, + studentSlackId: student.slackId || undefined, + projectName: project.description?.slice(0, 50) || 'Untitled Project', + mentorName: `${mentor.givenName} ${mentor.surname}`, + mentorSlackId: mentor.slackId || undefined, + attendancePercentage, + meetingsAttended, + meetingsTotal, + lastAttendedAt: lastAttended?.createdAt, + }); + } + } + + // Check mentor reflection completion + const mentorReflections = await prisma.surveyResponse.count({ + where: { + authorMentorId: mentor.id, + surveyOccurence: { + survey: { + personType: 'MENTOR', + eventId: event.id, + }, + }, + }, + }); + + const weeksSinceStart = Math.max( + 1, + Math.floor(DateTime.now().diff(DateTime.fromJSDate(event.startsAt), 'weeks').weeks) + ); + const expectedReflections = Math.min(weeksSinceStart, event.defaultWeeks); + + if (mentorReflections < expectedReflections * 0.75 && expectedReflections >= 2) { + mentorReflectionIssues.push({ + mentorName: `${mentor.givenName} ${mentor.surname}`, + mentorEmail: mentor.email, + mentorSlackId: mentor.slackId || undefined, + projectName: project.description?.slice(0, 50) || 'Untitled Project', + missedReflections: expectedReflections - mentorReflections, + expectedReflections, + }); + } + } + + return { + students: lowAttendanceStudents, + mentors: mentorReflectionIssues, + }; +} + +async function processEventAlerts(event: Event): Promise { + const prisma = Container.get(PrismaClient); + const { students, mentors } = await getAttendanceIssuesForEvent(prisma, event); + + // Send alerts if there are any issues + if (students.length > 0 || mentors.length > 0) { + await sendSlackAlert(event, students, mentors); + } else { + DEBUG(`No attendance issues found for ${event.name}`); + } +} + +async function sendSlackAlert( + event: Event, + students: AttendanceIssue[], + mentors: MentorIssue[] +): Promise { + if (!event.slackWorkspaceAccessToken || !event.slackWorkspaceId) { + DEBUG(`Event ${event.id} does not have Slack configured, skipping Slack alert`); + return; + } + + const slack = getSlackClientForEvent(event as any); + const message = buildWeeklyAttendanceAlertMessage(event.name, students, mentors); + + DEBUG(`Sending Slack alert to channel ${ATTENDANCE_ALERT_CHANNEL}`); + + await slack.chat.postMessage({ + channel: ATTENDANCE_ALERT_CHANNEL, + text: message, + }); +} diff --git a/src/automation/tasks/testProcessMentorReflections.ts b/src/automation/tasks/testProcessMentorReflections.ts new file mode 100644 index 0000000..df2a889 --- /dev/null +++ b/src/automation/tasks/testProcessMentorReflections.ts @@ -0,0 +1,53 @@ +import 'reflect-metadata'; +import assert from 'assert'; +import { extractAttendedStudentIds } from './processMentorReflections'; + +function sorted(values: Set): string[] { + return [...values].sort(); +} + +function run(): void { + const studentIds = ['s1', 's2', 's3']; + + { + const result = extractAttendedStudentIds({ studentAttendance: ['s1', 's3'] }, studentIds); + assert.deepStrictEqual(sorted(result), ['s1', 's3']); + } + + { + const result = extractAttendedStudentIds({ studentsPresent: ['s2'] }, studentIds); + assert.deepStrictEqual(sorted(result), ['s2']); + } + + { + const result = extractAttendedStudentIds({ studentsAbsent: ['s2'] }, studentIds); + assert.deepStrictEqual(sorted(result), ['s1', 's3']); + } + + { + const result = extractAttendedStudentIds( + { + studentAttendance: ['s1', 's2'], + studentsAbsent: ['s2'], + }, + studentIds + ); + assert.deepStrictEqual(sorted(result), ['s1']); + } + + { + const result = extractAttendedStudentIds( + { + studentAttendance: ['s1', 'unknown-student'], + studentsAbsent: ['another-unknown'], + }, + studentIds + ); + assert.deepStrictEqual(sorted(result), ['s1']); + } + + // eslint-disable-next-line no-console + console.log('processMentorReflections tests passed'); +} + +run(); diff --git a/src/automation/tasks/testSendAttendanceAlerts.ts b/src/automation/tasks/testSendAttendanceAlerts.ts new file mode 100644 index 0000000..7915226 --- /dev/null +++ b/src/automation/tasks/testSendAttendanceAlerts.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata'; +import assert from 'assert'; +import { buildWeeklyAttendanceAlertMessage } from './sendAttendanceAlerts'; + +function run(): void { + const message = buildWeeklyAttendanceAlertMessage( + 'CodeDay Labs', + [ + { + studentName: 'Student One', + studentEmail: 'student@example.com', + studentSlackId: 'U_STUDENT', + projectName: 'Project Alpha', + mentorName: 'Mentor One', + mentorSlackId: 'U_MENTOR', + attendancePercentage: 0.5, + meetingsAttended: 1, + meetingsTotal: 2, + }, + ], + [ + { + mentorName: 'Mentor One', + mentorEmail: 'mentor@example.com', + mentorSlackId: 'U_MENTOR', + projectName: 'Project Alpha', + missedReflections: 2, + expectedReflections: 4, + }, + ] + ); + + assert.ok(message.includes('Weekly Attendance Alert for CodeDay Labs')); + assert.ok(message.includes('Students with Low Attendance (<75%)')); + assert.ok(message.includes('Notify: <@U_STUDENT> <@U_MENTOR>')); + assert.ok(message.includes('Mentors Behind on Reflections')); + assert.ok(message.includes('Notify: <@U_MENTOR>')); + + // eslint-disable-next-line no-console + console.log('sendAttendanceAlerts tests passed'); +} + +run(); diff --git a/src/email/templates/weeklyAttendanceAlert.md b/src/email/templates/weeklyAttendanceAlert.md new file mode 100644 index 0000000..4752457 --- /dev/null +++ b/src/email/templates/weeklyAttendanceAlert.md @@ -0,0 +1,53 @@ +--- +to: "akif@codeday.org" +subject: "Weekly Attendance Report for {{ event.name }}" +--- + +# Weekly Attendance Report + +**Event:** {{ event.name }} +**Week of:** {{ prettyDate weekStart }} + +--- + +## ๐Ÿšจ Students with Low Attendance (<75%) + +{{#if lowAttendanceStudents}} +{{#each lowAttendanceStudents}} +**{{ studentName }}** ({{ studentEmail }}) +- **Attendance:** {{ attendancePercentage }}% ({{ meetingsAttended }}/{{ meetingsTotal }} meetings) +- **Project:** {{ projectName }} +- **Mentor:** {{ mentorName }} +{{#if lastAttendedAt}}- **Last Attended:** {{ prettyDate lastAttendedAt }}{{/if}} + +{{/each}} +{{else}} +_No students with low attendance this week._ โœ… +{{/if}} + +--- + +## ๐Ÿ“ Mentors Behind on Reflections + +{{#if mentorReflectionIssues}} +{{#each mentorReflectionIssues}} +**{{ mentorName }}** ({{ mentorEmail }}) +- **Status:** {{ missedReflections }} reflections behind (expected: {{ expectedReflections }}) +- **Project:** {{ projectName }} + +{{/each}} +{{else}} +_All mentors are up to date on reflections._ โœ… +{{/if}} + +--- + +## Summary + +- **Total flagged students:** {{ lowAttendanceStudents.length }} +- **Total mentors behind:** {{ mentorReflectionIssues.length }} + +_This is an automated report sent every Monday. To adjust the attendance threshold or frequency, contact the engineering team._ + +Best, +CodeDay Labs Attendance System diff --git a/src/enums/index.ts b/src/enums/index.ts index 89f6447..98d752b 100644 --- a/src/enums/index.ts +++ b/src/enums/index.ts @@ -10,6 +10,7 @@ import { FileTypeType, FileTypeGenerationCondition, FileTypeGenerationTarget, + AttendanceSource, } from '@prisma/client'; import { registerEnumType } from 'type-graphql'; @@ -35,6 +36,7 @@ registerEnumType(RejectionReason, { name: 'RejectionReason' }); registerEnumType(TagType, { name: 'TagType' }); registerEnumType(PersonType, { name: 'PersonType' }); registerEnumType(PrStatus, { name: 'PrStatus' }); +registerEnumType(AttendanceSource, { name: 'AttendanceSource' }); export { Track, @@ -49,4 +51,5 @@ export { FileTypeType, FileTypeGenerationCondition, FileTypeGenerationTarget, + AttendanceSource, }; diff --git a/src/inputs/MeetingAttendanceInput.ts b/src/inputs/MeetingAttendanceInput.ts new file mode 100644 index 0000000..3d22af2 --- /dev/null +++ b/src/inputs/MeetingAttendanceInput.ts @@ -0,0 +1,27 @@ +import { InputType, Field } from 'type-graphql'; +import { GraphQLJSONObject } from 'graphql-type-json'; +import { AttendanceSource } from '../enums'; + +@InputType() +export class MeetingAttendanceInput { + @Field(() => String) + meetingId: string + + @Field(() => String) + studentId: string + + @Field(() => Boolean) + attended: boolean + + @Field(() => Boolean, { nullable: true }) + prepared?: boolean + + @Field(() => AttendanceSource, { nullable: true }) + source?: AttendanceSource + + @Field(() => Number, { nullable: true }) + confidence?: number + + @Field(() => GraphQLJSONObject, { nullable: true }) + metadata?: Record +} diff --git a/src/inputs/MeetingCreateInput.ts b/src/inputs/MeetingCreateInput.ts new file mode 100644 index 0000000..e357642 --- /dev/null +++ b/src/inputs/MeetingCreateInput.ts @@ -0,0 +1,38 @@ +import { InputType, Field } from 'type-graphql'; +import { GraphQLJSONObject } from 'graphql-type-json'; + +@InputType() +export class MeetingCreateInput { + @Field(() => String) + eventId: string + + @Field(() => String, { nullable: true }) + projectId?: string + + @Field(() => Date) + visibleAt: Date + + @Field(() => Date) + dueAt: Date + + @Field(() => Date, { nullable: true }) + scheduledStartAt?: Date + + @Field(() => Date, { nullable: true }) + scheduledEndAt?: Date + + @Field(() => GraphQLJSONObject, { nullable: true }) + agendaStudentSchema?: Record + + @Field(() => GraphQLJSONObject, { nullable: true }) + agendaStudentUi?: Record + + @Field(() => GraphQLJSONObject, { nullable: true }) + notesStudentSchema?: Record + + @Field(() => GraphQLJSONObject, { nullable: true }) + notesStudentUi?: Record + + @Field(() => String, { nullable: true }) + slackHuddleId?: string +} diff --git a/src/inputs/index.ts b/src/inputs/index.ts index 770c451..15f7dd5 100644 --- a/src/inputs/index.ts +++ b/src/inputs/index.ts @@ -28,4 +28,6 @@ export * from './ProjectFilterInput'; export * from './FileTypeCreateInput'; export * from './FileTypeEditInput'; export * from './ScheduledAnnouncementCreateInput'; -export * from './ScheduledAnnouncementEditInput'; \ No newline at end of file +export * from './ScheduledAnnouncementEditInput'; +export * from './MeetingCreateInput'; +export * from './MeetingAttendanceInput'; \ No newline at end of file diff --git a/src/resolvers/Meeting.ts b/src/resolvers/Meeting.ts new file mode 100644 index 0000000..47cbee5 --- /dev/null +++ b/src/resolvers/Meeting.ts @@ -0,0 +1,145 @@ +import { + Resolver, Authorized, Query, Mutation, Arg, Ctx, +} from 'type-graphql'; +import { + PrismaClient, + Meeting as PrismaMeeting, + MeetingAttendance as PrismaMeetingAttendance, +} from '@prisma/client'; +import { Inject, Service } from 'typedi'; +import { Context, AuthRole } from '../context'; +import { Meeting, MeetingAttendance } from '../types'; +import { MeetingCreateInput, MeetingAttendanceInput } from '../inputs'; +import { makeDebug } from '../utils'; + +const DEBUG = makeDebug('resolvers:Meeting'); + +@Service() +@Resolver(Meeting) +export class MeetingResolver { + @Inject(() => PrismaClient) + private readonly prisma: PrismaClient; + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER) + @Query(() => [Meeting]) + async meetings( + @Ctx() { auth }: Context, + @Arg('eventId', () => String, { nullable: true }) eventId?: string, + @Arg('projectId', () => String, { nullable: true }) projectId?: string, + ): Promise { + return this.prisma.meeting.findMany({ + where: { + eventId: eventId || auth.eventId, + ...(projectId ? { projectId } : {}), + }, + orderBy: { scheduledStartAt: 'desc' }, + }); + } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR, AuthRole.STUDENT) + @Query(() => Meeting, { nullable: true }) + async meeting( + @Ctx() { auth }: Context, + @Arg('id', () => String) id: string, + ): Promise { + const meeting = await this.prisma.meeting.findUnique({ + where: { id }, + include: { project: true }, + }); + + if (!meeting) return null; + + // Verify access + if (!auth.isAdmin && !auth.isManager) { + if (auth.isMentor || auth.isStudent) { + if (meeting.eventId !== auth.eventId) { + throw new Error('No permission to view this meeting.'); + } + } + } + + return meeting; + } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER) + @Mutation(() => Meeting) + async createMeeting( + @Ctx() { auth }: Context, + @Arg('data', () => MeetingCreateInput) data: MeetingCreateInput, + ): Promise { + DEBUG(`Creating meeting for event ${data.eventId}, project ${data.projectId || 'none'}`); + + return this.prisma.meeting.create({ + data: { + eventId: data.eventId, + projectId: data.projectId, + visibleAt: data.visibleAt, + dueAt: data.dueAt, + scheduledStartAt: data.scheduledStartAt, + scheduledEndAt: data.scheduledEndAt, + agendaStudentSchema: data.agendaStudentSchema as any, + agendaStudentUi: data.agendaStudentUi as any, + notesStudentSchema: data.notesStudentSchema as any, + notesStudentUi: data.notesStudentUi as any, + slackHuddleId: data.slackHuddleId, + }, + }); + } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR) + @Mutation(() => MeetingAttendance) + async recordMeetingAttendance( + @Ctx() { auth }: Context, + @Arg('data', () => MeetingAttendanceInput) data: MeetingAttendanceInput, + ): Promise { + DEBUG(`Recording attendance for meeting ${data.meetingId}, student ${data.studentId}: ${data.attended}`); + + // Check for existing attendance record + const existing = await this.prisma.meetingAttendance.findFirst({ + where: { + meetingId: data.meetingId, + studentId: data.studentId, + }, + }); + + if (existing) { + // Update existing record + return this.prisma.meetingAttendance.update({ + where: { id: existing.id }, + data: { + attended: data.attended, + prepared: data.prepared ?? existing.prepared, + source: data.source ?? existing.source, + confidence: data.confidence ?? existing.confidence, + metadata: data.metadata as any ?? existing.metadata, + }, + }); + } + + // Create new record + return this.prisma.meetingAttendance.create({ + data: { + meetingId: data.meetingId, + studentId: data.studentId, + attended: data.attended, + prepared: data.prepared ?? false, + source: data.source ?? 'MANUAL', + confidence: data.confidence ?? 1.0, + metadata: data.metadata as any, + }, + }); + } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR) + @Query(() => [MeetingAttendance]) + async meetingAttendance( + @Ctx() { auth }: Context, + @Arg('meetingId', () => String) meetingId: string, + ): Promise { + return this.prisma.meetingAttendance.findMany({ + where: { meetingId }, + include: { student: true }, + orderBy: { createdAt: 'asc' }, + }); + } +} diff --git a/src/resolvers/Stats.ts b/src/resolvers/Stats.ts index 1a965f7..899b861 100644 --- a/src/resolvers/Stats.ts +++ b/src/resolvers/Stats.ts @@ -1,5 +1,5 @@ import { - Resolver, Authorized, Query, Arg, Ctx, + Resolver, Authorized, Query, Arg, Ctx, Int, Float, } from 'type-graphql'; import { PrismaClient } from '@prisma/client'; import { Inject, Service } from 'typedi'; @@ -7,6 +7,7 @@ import { DateTime } from 'luxon'; import { Context, AuthRole } from '../context'; import { Track, StudentStatus } from '../enums'; import { Stat } from '../types/Stat'; +import { StudentAttendanceStat, MentorReflectionStat, FlaggedStudent } from '../types/AttendanceStats'; // 2012: 24 students, 400 hours = 9,600 hours // 2013: 16 students, 400 hours = 6,400 hours @@ -107,4 +108,184 @@ export class StatsResolver { })), ]; } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER) + @Query(() => [StudentAttendanceStat]) + async statStudentAttendance( + @Ctx() { auth }: Context, + @Arg('eventId', () => String, { nullable: true }) eventId?: string, + @Arg('projectId', () => String, { nullable: true }) projectId?: string, + @Arg('minAttendance', () => Float, { nullable: true }) minAttendance?: number, + ): Promise { + const targetEventId = eventId || auth.eventId!; + const minAttendanceThreshold = minAttendance ?? 0.75; // Default 75% + + // Get all students in the event + const students = await this.prisma.student.findMany({ + where: { + eventId: targetEventId, + status: 'ACCEPTED', + ...(projectId ? { projects: { some: { id: projectId } } } : {}), + }, + include: { + projects: { + where: { status: 'MATCHED' }, + include: { + meetings: { + include: { + attendance: { + where: { studentId: { not: null } }, + }, + }, + }, + }, + }, + }, + }); + + const stats: StudentAttendanceStat[] = []; + + for (const student of students) { + const project = student.projects[0]; // Assume one project per student + if (!project) continue; + + const allMeetings = project.meetings; + const studentAttendance = allMeetings.flatMap((m) => + m.attendance.filter((a) => a.studentId === student.id) + ); + + const meetingsTotal = allMeetings.length; + const meetingsAttended = studentAttendance.filter((a) => a.attended).length; + const attendancePercentage = meetingsTotal > 0 ? meetingsAttended / meetingsTotal : 0; + + const lastAttended = studentAttendance + .filter((a) => a.attended) + .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; + + const lastMeeting = allMeetings.sort( + (a, b) => (b.scheduledStartAt?.getTime() || 0) - (a.scheduledStartAt?.getTime() || 0) + )[0]; + + const dataSources = Array.from( + new Set(studentAttendance.map((a) => a.source)) + ); + + stats.push({ + student: student as any, + project: project as any, + meetingsTotal, + meetingsAttended, + attendancePercentage, + lastAttendedAt: lastAttended?.createdAt, + lastMeetingAt: lastMeeting?.scheduledStartAt || undefined, + isFlagged: attendancePercentage < minAttendanceThreshold && meetingsTotal > 0, + dataSources, + }); + } + + return stats.sort((a, b) => a.attendancePercentage - b.attendancePercentage); + } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER) + @Query(() => [MentorReflectionStat]) + async statMentorReflectionCompletion( + @Ctx() { auth }: Context, + @Arg('eventId', () => String, { nullable: true }) eventId?: string, + ): Promise { + const targetEventId = eventId || auth.eventId!; + + // Get all mentors in the event + const mentors = await this.prisma.mentor.findMany({ + where: { + eventId: targetEventId, + status: 'ACCEPTED', + }, + include: { + projects: { + where: { status: 'MATCHED' }, + }, + authoredSurveyResponses: { + where: { + surveyOccurence: { + survey: { + personType: 'MENTOR', + eventId: targetEventId, + }, + }, + }, + }, + }, + }); + + const stats: MentorReflectionStat[] = []; + + // Calculate expected reflections based on weeks since event start + const event = await this.prisma.event.findUnique({ + where: { id: targetEventId }, + }); + + if (!event) return stats; + + const weeksSinceStart = Math.max( + 1, + Math.floor(DateTime.now().diff(DateTime.fromJSDate(event.startsAt), 'weeks').weeks) + ); + + for (const mentor of mentors) { + const project = mentor.projects[0]; + const submittedReflections = mentor.authoredSurveyResponses.length; + const expectedReflections = Math.min(weeksSinceStart, event.defaultWeeks); + const completionPercentage = + expectedReflections > 0 ? submittedReflections / expectedReflections : 0; + + const lastSubmitted = mentor.authoredSurveyResponses.sort( + (a, b) => b.createdAt.getTime() - a.createdAt.getTime() + )[0]; + + stats.push({ + mentor: mentor as any, + project: project as any, + expectedReflections, + submittedReflections, + completionPercentage, + lastSubmittedAt: lastSubmitted?.createdAt, + isFlagged: completionPercentage < 0.75 && expectedReflections > 0, + }); + } + + return stats.sort((a, b) => a.completionPercentage - b.completionPercentage); + } + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER) + @Query(() => [FlaggedStudent]) + async flaggedStudents( + @Ctx() { auth }: Context, + @Arg('eventId', () => String, { nullable: true }) eventId?: string, + @Arg('minAttendance', () => Float, { nullable: true }) minAttendance?: number, + ): Promise { + const attendanceStats = await this.statStudentAttendance( + { auth } as Context, + eventId, + undefined, + minAttendance + ); + + const flagged: FlaggedStudent[] = []; + + for (const stat of attendanceStats.filter((s) => s.isFlagged)) { + const mentor = stat.project?.mentors?.[0]; + + flagged.push({ + student: stat.student, + mentor: mentor as any, + project: stat.project, + reason: `Low attendance: ${Math.round(stat.attendancePercentage * 100)}%`, + attendancePercentage: stat.attendancePercentage, + missedMeetings: stat.meetingsTotal - stat.meetingsAttended, + lastAttendedAt: stat.lastAttendedAt, + }); + } + + return flagged; + } } diff --git a/src/types/AttendanceStats.ts b/src/types/AttendanceStats.ts new file mode 100644 index 0000000..507424c --- /dev/null +++ b/src/types/AttendanceStats.ts @@ -0,0 +1,83 @@ +import { ObjectType, Field, Int, Float } from 'type-graphql'; +import { Student } from './Student'; +import { Mentor } from './Mentor'; +import { Project } from './Project'; +import { AttendanceSource } from '../enums'; + +@ObjectType() +export class StudentAttendanceStat { + @Field(() => Student) + student: Student + + @Field(() => Project, { nullable: true }) + project?: Project + + @Field(() => Int) + meetingsTotal: number + + @Field(() => Int) + meetingsAttended: number + + @Field(() => Float) + attendancePercentage: number + + @Field(() => Date, { nullable: true }) + lastAttendedAt?: Date + + @Field(() => Date, { nullable: true }) + lastMeetingAt?: Date + + @Field(() => Boolean) + isFlagged: boolean + + @Field(() => [AttendanceSource]) + dataSources: AttendanceSource[] +} + +@ObjectType() +export class MentorReflectionStat { + @Field(() => Mentor) + mentor: Mentor + + @Field(() => Project, { nullable: true }) + project?: Project + + @Field(() => Int) + expectedReflections: number + + @Field(() => Int) + submittedReflections: number + + @Field(() => Float) + completionPercentage: number + + @Field(() => Date, { nullable: true }) + lastSubmittedAt?: Date + + @Field(() => Boolean) + isFlagged: boolean +} + +@ObjectType() +export class FlaggedStudent { + @Field(() => Student) + student: Student + + @Field(() => Mentor, { nullable: true }) + mentor?: Mentor + + @Field(() => Project, { nullable: true }) + project?: Project + + @Field(() => String) + reason: string + + @Field(() => Float) + attendancePercentage: number + + @Field(() => Int) + missedMeetings: number + + @Field(() => Date, { nullable: true }) + lastAttendedAt?: Date +} diff --git a/src/types/Meeting.ts b/src/types/Meeting.ts new file mode 100644 index 0000000..7e8d6a9 --- /dev/null +++ b/src/types/Meeting.ts @@ -0,0 +1,131 @@ +import { + Prisma, + Meeting as PrismaMeeting, + MeetingResponse as PrismaMeetingResponse, + MeetingAttendance as PrismaMeetingAttendance, + Event as PrismaEvent, + Project as PrismaProject, + PrismaClient, +} from '@prisma/client'; +import { + ObjectType, Field, Authorized, Ctx, +} from 'type-graphql'; +import { Container } from 'typedi'; +import { GraphQLJSONObject } from 'graphql-type-json'; +import { AuthRole, Context } from '../context'; +import { Event } from './Event'; +import { Project } from './Project'; +import { MeetingResponse } from './MeetingResponse'; +import { MeetingAttendance } from './MeetingAttendance'; + +@ObjectType() +export class Meeting implements PrismaMeeting { + // Metadata + @Field(() => String) + id: string + + @Field(() => Date) + createdAt: Date + + @Field(() => Date) + updatedAt: Date + + // Data + @Field(() => Date) + visibleAt: Date + + @Field(() => Date) + dueAt: Date + + @Field(() => Boolean) + sentAgendaVisibleReminder: boolean + + @Field(() => Boolean) + sentAgendaOverdueReminder: boolean + + @Field(() => Boolean) + sentMeetingReminder: boolean + + @Field(() => GraphQLJSONObject, { nullable: true }) + agendaStudentSchema: Prisma.JsonValue | null + + @Field(() => GraphQLJSONObject, { nullable: true }) + agendaStudentUi: Prisma.JsonValue | null + + @Field(() => GraphQLJSONObject, { nullable: true }) + notesStudentSchema: Prisma.JsonValue | null + + @Field(() => GraphQLJSONObject, { nullable: true }) + notesStudentUi: Prisma.JsonValue | null + + // Slack Integration + @Field(() => String, { nullable: true }) + slackHuddleId: string | null + + @Field(() => Date, { nullable: true }) + scheduledStartAt: Date | null + + @Field(() => Date, { nullable: true }) + scheduledEndAt: Date | null + + // Relations + @Field(() => String) + eventId: string + + event?: PrismaEvent + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER) + @Field(() => Event, { name: 'event' }) + async fetchEvent(): Promise { + if (!this.event) { + this.event = (await Container.get(PrismaClient).event.findUnique({ + where: { id: this.eventId }, + }))!; + } + return this.event; + } + + @Field(() => String, { nullable: true }) + projectId: string | null + + project?: PrismaProject + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR, AuthRole.STUDENT) + @Field(() => Project, { nullable: true, name: 'project' }) + async fetchProject(): Promise { + if (!this.projectId) return null; + if (!this.project) { + this.project = (await Container.get(PrismaClient).project.findUnique({ + where: { id: this.projectId }, + })) || undefined; + } + return this.project || null; + } + + responses?: PrismaMeetingResponse[] + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR, AuthRole.STUDENT) + @Field(() => [MeetingResponse], { name: 'responses' }) + async fetchResponses(): Promise { + if (!this.responses) { + this.responses = await Container.get(PrismaClient).meetingResponse.findMany({ + where: { meetingId: this.id }, + }); + } + return this.responses; + } + + attendance?: PrismaMeetingAttendance[] + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR) + @Field(() => [MeetingAttendance], { name: 'attendance' }) + async fetchAttendance(): Promise { + if (!this.attendance) { + this.attendance = await Container.get(PrismaClient).meetingAttendance.findMany({ + where: { meetingId: this.id }, + include: { student: true }, + }); + } + return this.attendance; + } +} diff --git a/src/types/MeetingAttendance.ts b/src/types/MeetingAttendance.ts new file mode 100644 index 0000000..89e2674 --- /dev/null +++ b/src/types/MeetingAttendance.ts @@ -0,0 +1,80 @@ +import { + Prisma, + MeetingAttendance as PrismaMeetingAttendance, + Meeting as PrismaMeeting, + Student as PrismaStudent, + PrismaClient, + AttendanceSource, +} from '@prisma/client'; +import { + ObjectType, Field, Authorized, Ctx, +} from 'type-graphql'; +import { Container } from 'typedi'; +import { GraphQLJSONObject } from 'graphql-type-json'; +import { AuthRole, Context } from '../context'; +import { Meeting } from './Meeting'; +import { Student } from './Student'; + +@ObjectType() +export class MeetingAttendance implements PrismaMeetingAttendance { + // Metadata + @Field(() => String) + id: string + + @Field(() => Date) + createdAt: Date + + @Field(() => Date) + updatedAt: Date + + // Data + @Field(() => Boolean) + attended: boolean + + @Field(() => Boolean) + prepared: boolean + + // Attendance tracking + @Field(() => AttendanceSource) + source: AttendanceSource + + @Field(() => Number) + confidence: number + + @Field(() => GraphQLJSONObject, { nullable: true }) + metadata: Prisma.JsonValue | null + + // Relations + @Field(() => String) + meetingId: string + + meeting?: PrismaMeeting + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR) + @Field(() => Meeting, { name: 'meeting' }) + async fetchMeeting(): Promise { + if (!this.meeting) { + this.meeting = (await Container.get(PrismaClient).meeting.findUnique({ + where: { id: this.meetingId }, + }))!; + } + return this.meeting; + } + + @Field(() => String, { nullable: true }) + studentId: string | null + + student?: PrismaStudent + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR) + @Field(() => Student, { nullable: true, name: 'student' }) + async fetchStudent(): Promise { + if (!this.studentId) return null; + if (!this.student) { + this.student = (await Container.get(PrismaClient).student.findUnique({ + where: { id: this.studentId }, + })) || undefined; + } + return this.student || null; + } +} diff --git a/src/types/MeetingResponse.ts b/src/types/MeetingResponse.ts new file mode 100644 index 0000000..159d6b5 --- /dev/null +++ b/src/types/MeetingResponse.ts @@ -0,0 +1,69 @@ +import { + Prisma, + MeetingResponse as PrismaMeetingResponse, + Meeting as PrismaMeeting, + Student as PrismaStudent, + PrismaClient, +} from '@prisma/client'; +import { + ObjectType, Field, Authorized, +} from 'type-graphql'; +import { Container } from 'typedi'; +import { GraphQLJSONObject } from 'graphql-type-json'; +import { AuthRole } from '../context'; +import { Meeting } from './Meeting'; +import { Student } from './Student'; + +@ObjectType() +export class MeetingResponse implements PrismaMeetingResponse { + // Metadata + @Field(() => String) + id: string + + @Field(() => Date) + createdAt: Date + + @Field(() => Date) + updatedAt: Date + + // Data + @Field(() => GraphQLJSONObject, { nullable: true }) + agenda: Prisma.JsonValue | null + + @Field(() => GraphQLJSONObject, { nullable: true }) + notes: Prisma.JsonValue | null + + // Relations + @Field(() => String) + meetingId: string + + meeting?: PrismaMeeting + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR, AuthRole.STUDENT) + @Field(() => Meeting, { name: 'meeting' }) + async fetchMeeting(): Promise { + if (!this.meeting) { + this.meeting = (await Container.get(PrismaClient).meeting.findUnique({ + where: { id: this.meetingId }, + }))!; + } + return this.meeting; + } + + @Field(() => String, { nullable: true }) + authorStudentId: string | null + + authorStudent?: PrismaStudent + + @Authorized(AuthRole.ADMIN, AuthRole.MANAGER, AuthRole.MENTOR, AuthRole.STUDENT) + @Field(() => Student, { nullable: true, name: 'authorStudent' }) + async fetchAuthorStudent(): Promise { + if (!this.authorStudentId) return null; + if (!this.authorStudent) { + this.authorStudent = (await Container.get(PrismaClient).student.findUnique({ + where: { id: this.authorStudentId }, + })) || undefined; + } + return this.authorStudent || null; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 20b4ee1..1146ff9 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -16,4 +16,8 @@ export * from './Artifact'; export * from './ArtifactType'; export * from './File'; export * from './FileType'; -export * from './ScheduledAnnouncement'; \ No newline at end of file +export * from './ScheduledAnnouncement'; +export * from './Meeting'; +export * from './MeetingAttendance'; +export * from './MeetingResponse'; +export * from './AttendanceStats'; \ No newline at end of file