diff --git a/SLACK_HUDDLE_ATTENDANCE.md b/SLACK_HUDDLE_ATTENDANCE.md new file mode 100644 index 0000000..e235b8d --- /dev/null +++ b/SLACK_HUDDLE_ATTENDANCE.md @@ -0,0 +1,386 @@ +# Slack Huddle Attendance Tracking + +Automatic attendance tracking for student meetings using Slack huddles. + +## Overview + +This system automatically tracks student attendance at meetings by monitoring Slack huddle participation. When a mentor joins a Slack huddle in a project channel, it creates a meeting record. When students join, their attendance is automatically recorded. + +### Key Features + +- **Zero-configuration**: No setup required by mentors or students +- **Mentor-initiated**: Meeting is created when a mentor joins a huddle +- **Automatic attendance**: Students who join the huddle are marked as present +- **Automatic absences**: Students who don't join are marked as absent when the meeting ends +- **Flexible scheduling**: Meetings can happen any day/time - no pre-scheduling required +- **High confidence**: All records have confidence score of 1.0 (100% accurate) +- **Opt-out aware**: Teams which do not meet on Slack are excluded rather than reported as absent + +## How It Works + +### 1. Mentor Starts Meeting + +``` +Mentor Jane joins huddle in #labs-project-channel + โ†“ +System creates Meeting record with: + - slackHuddleId: unique huddle identifier + - scheduledStartAt: when mentor joined + - scheduledEndAt: when mentor joined (updated later) + - projectId: linked to the project + - source: SLACK_HUDDLE +``` + +### 2. Students Join + +``` +Student Sarah joins huddle + โ†“ +System creates MeetingAttendance record: + - attended: true + - source: SLACK_HUDDLE + - confidence: 1.0 + - metadata: { huddleId, joinedAt } +``` + +### 3. Meeting Ends + +``` +Last person leaves huddle + โ†“ +System updates Meeting: + - scheduledEndAt: time last person left + +Daily task runs (2 AM): + - Finds meetings that ended in last 24 hours + - Marks students who didn't join as absent +``` + +## Architecture + +### Database Models + +**SlackHuddleParticipation** - Tracks every join/leave event +```prisma +model SlackHuddleParticipation { + id String @id + huddleId String + userId String // Slack user ID + joinedAt DateTime + leftAt DateTime? + studentId String? + mentorId String? + meetingId String? + projectId String? +} +``` + +**Meeting** - Extended with Slack huddle tracking +```prisma +model Meeting { + slackHuddleId String? // Links to huddle + scheduledStartAt DateTime? + scheduledEndAt DateTime? + projectId String? +} +``` + +**MeetingAttendance** - Attendance records with source tracking +```prisma +model MeetingAttendance { + attended Boolean + source AttendanceSource // SLACK_HUDDLE + confidence Float // 1.0 for huddle data + metadata Json? // { huddleId, joinedAt } +} +``` + +**Project / Event** - Opt-out configuration +```prisma +model Project { + attendanceTracking AttendanceTrackingMode? // null = inherit from event +} + +model Event { + defaultAttendanceTracking AttendanceTrackingMode @default(SLACK_HUDDLE) +} +``` + +## Opting a Team Out + +Not every team meets on Slack. Because this system infers absence from the *lack* +of a huddle join, an untracked team would otherwise appear to have 0% attendance. + +Set `attendanceTracking` to `NOT_TRACKED` on the project to exclude it: + +```graphql +mutation { + editProject( + project: "" + data: { attendanceTracking: NOT_TRACKED } + ) { id attendanceTracking } +} +``` + +To opt out an entire cohort, set `defaultAttendanceTracking: NOT_TRACKED` on the +event; individual projects can still opt back in by setting `SLACK_HUDDLE`. + +A project with no explicit value inherits the event default, which is +`SLACK_HUDDLE` (tracked) unless changed. + +### What opting out changes + +| Component | Behaviour when `NOT_TRACKED` | +| --- | --- | +| `huddleHandler` | No meetings or attendance are created for the channel | +| `markAbsentStudents` | Project is skipped, so no student is marked absent | +| `statStudentAttendance` | Returns `trackingMode: NOT_TRACKED` and never sets `isFlagged` | +| `flaggedStudents` | Project's students are never flagged | +| `sendAttendanceAlerts` | Project excluded; count reported as a footnote | + +The `trackingMode` field lets the dashboard distinguish "this student missed +meetings" from "we do not measure this team", so 0% is never shown for an +untracked project. + +### Components + +1. **Webhook Handler** (`src/slack/webhooks.ts`) + - Receives `user_huddle_changed` events from Slack + - Validates event type and data + - Routes to huddle handler + +2. **Huddle Event Handler** (`src/slack/events/huddleHandler.ts`) + - Processes join/leave events + - Creates meetings when mentors join + - Records attendance when students join + - Updates meeting end time when huddle ends + +3. **Absent Marker Task** (`src/automation/tasks/markAbsentStudents.ts`) + - Runs daily at 2 AM + - Finds meetings that ended in last 24 hours + - Marks students who didn't attend as absent + + + + +## Configuration + +### Slack App Setup + +1. **Event Subscriptions**: + - Enable Events: ON + - Request URL: `https://labs.codeday.org/{WEBHOOK_KEY}/slack` + - Subscribe to bot events: `user_huddle_changed` + +2. **OAuth & Permissions**: + - Bot Token Scopes: + - `channels:read` (read channel info) + - `users:read` (map Slack users to students/mentors) + +3. **Reinstall to Workspace** after changing scopes + +### Environment Variables + +No additional environment variables needed - uses existing `WEBHOOK_KEY`. + +## User Experience + +### For Mentors + +- Start a huddle in the project channel; attendance is tracked automatically +- Nothing to submit or remember +- If the team meets outside Slack, ask for the project to be set to `NOT_TRACKED` + +### For Students + +- Join the huddle as normal +- Attendance is recorded automatically, with no extra steps + +### For Program Managers (Akif) + +**Query attendance:** +```graphql +query { + statStudentAttendance(eventId: "spring-2025") { + student { givenName surname } + project { description } + meetingsTotal + meetingsAttended + attendancePercentage + dataSources + trackingMode + lastAttendedAt + isFlagged + } +} +``` + +**Example result:** +```json +{ + "student": { "givenName": "Sarah", "surname": "Johnson" }, + "project": { "description": "React Contribution" }, + "meetingsTotal": 8, + "meetingsAttended": 8, + "attendancePercentage": 1.0, + "dataSources": ["SLACK_HUDDLE"], + "trackingMode": "SLACK_HUDDLE", + "lastAttendedAt": "2025-02-15T14:05:00Z", + "isFlagged": false +} +``` + +## Edge Cases + +### Student Joins Before Mentor + +**Scenario:** Students start huddle before mentor arrives + +**Behavior:** +- Student participation is logged in `SlackHuddleParticipation` +- No meeting created yet (mentor hasn't joined) +- When mentor joins: + - Meeting is created + - All existing participations are linked to meeting + - Attendance records created for students already in huddle + +### Late Arrival + +**Scenario:** Student joins 30 minutes late + +**Behavior:** +- Still marked as attended +- `joinedAt` timestamp stored in metadata +- Can review actual join time if needed + +### Early Departure + +**Scenario:** Student leaves before meeting ends + +**Behavior:** +- Still marked as attended (they joined) +- `leftAt` timestamp recorded +- Can review participation duration if needed + +### Non-Project Members Join + +**Scenario:** Someone not in the project joins the huddle + +**Behavior:** +- Participation logged but ignored +- No attendance record created +- Doesn't affect project meeting + +### Multiple Huddles Same Day + +**Scenario:** Mentor runs two huddles in one day + +**Behavior:** +- Each huddle creates separate meeting +- Attendance tracked separately for each +- Dashboard shows all meetings + +## Deployment + +### Database Migration + +```bash +# Apply schema changes +yarn prisma migrate deploy + +# Generate Prisma client +yarn prisma generate +``` + +### Deploy Application + +```bash +# Build +yarn build + +# Deploy (Fly.io) +fly deploy +``` + +### Configure Slack Webhook + +1. Go to https://api.slack.com/apps +2. Select your app +3. Navigate to Event Subscriptions +4. Set Request URL: `https://labs.codeday.org/{WEBHOOK_KEY}/slack` +5. Slack will send verification challenge - server responds automatically +6. Subscribe to `user_huddle_changed` event +7. Save changes + +### Verify Setup + +1. Have a mentor join a test huddle in a project channel +2. Check server logs for: `Created meeting {id} from mentor joining huddle` +3. Have a student join the huddle +4. Check logs for: `Recorded attendance for student {id} at meeting {id}` +5. Everyone leave the huddle +6. Check logs for: `Huddle {id} ended, updated meeting end time` +7. Wait until next day (2 AM) or manually run the task +8. Check that absent students are marked + +## Monitoring + +### Logs to Watch + +```bash +# Huddle events +DEBUG=slack:events:huddle + +# Webhook processing +DEBUG=slack:webhooks + +# Absent marking +DEBUG=automation:tasks:markAbsentStudents +``` + +### Metrics to Track + +- **Huddle events received** - Should match number of join/leave actions +- **Meetings created** - Should match number of mentor huddle joins +- **Attendance records** - Should match number of student huddle joins +- **Absences marked** - Runs daily at 2 AM, should process previous day's meetings + +## Troubleshooting + +### Mentor joins but no meeting created + +**Check:** +- Is project status `MATCHED`? +- Does project have `slackChannelId` set? +- Is mentor status `ACCEPTED`? +- Check logs for errors + +### Student joins but attendance not recorded + +**Check:** +- Did mentor join first? (if not, wait for mentor) +- Is student status `ACCEPTED`? +- Is student's `slackId` set correctly? +- Check logs for errors + +### Absent students not marked + +**Check:** +- Has the 2 AM task run yet today? +- Is the daily task running? (check logs at 2 AM) +- Did the meeting have a `slackHuddleId`? +- Did the meeting end in the last 24 hours? + +## Future Enhancements + +1. **Backfill historical huddles** - Process old huddle data before system was deployed +2. **Participation duration** - Calculate how long each student stayed in huddle +3. **Conflict resolution** - Handle disagreements between huddle data and mentor reports +4. **Real-time notifications** - Alert mentors if student is late/absent +5. **Dashboard widgets** - Show live huddle status for active meetings + +## Related Documentation + +- [MEETING_ATTENDANCE_TRACKING.md](./MEETING_ATTENDANCE_TRACKING.md) - Overall attendance system (Phase 1-4) +- Slack Events API: https://api.slack.com/events-api +- Prisma migrations: https://www.prisma.io/docs/concepts/components/prisma-migrate diff --git a/package.json b/package.json index 4c7c5ae..1a57d17 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,9 @@ "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 scripts/testAttendanceTracking.ts && ts-node --transpile-only scripts/testSendAttendanceAlerts.ts", + "test:attendance-slack": "ts-node --transpile-only scripts/testAttendanceSlack.ts", "send-event-recommendations": "ts-node scripts/sendEventRecommendations.ts", - "attio-setup": "ts-node scripts/attio-setup.ts", - "sync-alumni-interactions": "ts-node scripts/sync-alumni-interactions.ts", - "test": "ts-node src/automation/tasks/syncAlumniInteractions.test.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" }, "dependencies": { 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..157e84b --- /dev/null +++ b/prisma/migrations/20260714193312_add_meeting_attendance_tracking/migration.sql @@ -0,0 +1,61 @@ +-- CreateEnum: Add attendance source tracking +CREATE TYPE "AttendanceSource" AS ENUM ('SLACK_HUDDLE', 'MANUAL'); + +-- CreateEnum: Per-project/event attendance tracking mode, allowing teams which do +-- not meet on Slack to opt out of automated tracking. +CREATE TYPE "AttendanceTrackingMode" AS ENUM ('SLACK_HUDDLE', 'NOT_TRACKED'); + +-- AlterTable: Opt-out configuration. Project value is nullable and falls back to the event default. +ALTER TABLE "Project" ADD COLUMN "attendanceTracking" "AttendanceTrackingMode"; +ALTER TABLE "Event" ADD COLUMN "defaultAttendanceTracking" "AttendanceTrackingMode" NOT NULL DEFAULT 'SLACK_HUDDLE'; + +-- 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"); + +-- CreateTable: Raw Slack huddle join/leave events, used to derive meeting attendance. +CREATE TABLE "SlackHuddleParticipation" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "huddleId" TEXT NOT NULL, + "channelId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + "leftAt" TIMESTAMP(3), + "studentId" TEXT, + "mentorId" TEXT, + "meetingId" TEXT, + "projectId" TEXT, + + CONSTRAINT "SlackHuddleParticipation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "SlackHuddleParticipation_huddleId_userId_key" ON "SlackHuddleParticipation"("huddleId", "userId"); +CREATE INDEX "SlackHuddleParticipation_huddleId_idx" ON "SlackHuddleParticipation"("huddleId"); +CREATE INDEX "SlackHuddleParticipation_studentId_joinedAt_idx" ON "SlackHuddleParticipation"("studentId", "joinedAt"); +CREATE INDEX "SlackHuddleParticipation_mentorId_joinedAt_idx" ON "SlackHuddleParticipation"("mentorId", "joinedAt"); +CREATE INDEX "SlackHuddleParticipation_channelId_joinedAt_idx" ON "SlackHuddleParticipation"("channelId", "joinedAt"); + +-- AddForeignKey +ALTER TABLE "SlackHuddleParticipation" ADD CONSTRAINT "SlackHuddleParticipation_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SlackHuddleParticipation" ADD CONSTRAINT "SlackHuddleParticipation_mentorId_fkey" FOREIGN KEY ("mentorId") REFERENCES "Mentor"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SlackHuddleParticipation" ADD CONSTRAINT "SlackHuddleParticipation_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "Meeting"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "SlackHuddleParticipation" ADD CONSTRAINT "SlackHuddleParticipation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 62a873a..22d8255 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -117,6 +117,8 @@ model Event { matchComplete Boolean @default(false) partnersOnly Boolean @default(false) + defaultAttendanceTracking AttendanceTrackingMode @default(SLACK_HUDDLE) + slackWorkspaceId String? slackUserGroupId String? slackAnnouncementChannelId String? @@ -222,12 +224,23 @@ model Meeting { notesStudentSchema Json? notesStudentUi Json? + // Slack Integration + slackHuddleId String? + scheduledStartAt DateTime? + scheduledEndAt DateTime? + // Relations event Event @relation(fields: [eventId], references: [id]) eventId String - responses MeetingResponse[] - attendance MeetingAttendance[] + project Project? @relation(fields: [projectId], references: [id]) + projectId String? + + responses MeetingResponse[] + attendance MeetingAttendance[] + huddleParticipations SlackHuddleParticipation[] + + @@index([projectId]) } model MeetingAttendance { @@ -239,12 +252,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 { @@ -264,6 +284,40 @@ model MeetingResponse { authorStudentId String? } +model SlackHuddleParticipation { + // Metadata + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Slack data + huddleId String + channelId String + userId String // Slack user ID + joinedAt DateTime + leftAt DateTime? + + // Relations - who joined (student or mentor) + student Student? @relation(fields: [studentId], references: [id]) + studentId String? + + mentor Mentor? @relation(fields: [mentorId], references: [id]) + mentorId String? + + // What meeting this was part of + meeting Meeting? @relation(fields: [meetingId], references: [id]) + meetingId String? + + project Project? @relation(fields: [projectId], references: [id]) + projectId String? + + @@unique([huddleId, userId]) + @@index([huddleId]) + @@index([studentId, joinedAt]) + @@index([mentorId, joinedAt]) + @@index([channelId, joinedAt]) +} + model Survey { // Metadata id String @id @default(cuid()) @@ -383,11 +437,12 @@ model Mentor { // Relations projects Project[] emailsSent EmailSent[] - authoredSurveyResponses SurveyResponse[] @relation("AuthorMentor") - targetSurveyResponses SurveyResponse[] @relation("TargetMentor") + authoredSurveyResponses SurveyResponse[] @relation("AuthorMentor") + targetSurveyResponses SurveyResponse[] @relation("TargetMentor") projectEmails ProjectEmail[] artifacts Artifact[] files File[] + huddleParticipations SlackHuddleParticipation[] event Event @relation(fields: [eventId], references: [id]) eventId String @default("codeday-labs-2022") @@ -434,14 +489,15 @@ model Student { emailsSent EmailSent[] projectPreferences ProjectPreference[] tagTrainingSubmissions TagTrainingSubmission[] - authoredSurveyResponses SurveyResponse[] @relation("AuthorStudent") - targetSurveyResponses SurveyResponse[] @relation("TargetStudent") + authoredSurveyResponses SurveyResponse[] @relation("AuthorStudent") + targetSurveyResponses SurveyResponse[] @relation("TargetStudent") meetingResponses MeetingResponse[] meetingAttendance MeetingAttendance[] trainingEntryResponses TrainingEntryResponse[] projectEmails ProjectEmail[] artifacts Artifact[] files File[] + huddleParticipations SlackHuddleParticipation[] event Event @relation(fields: [eventId], references: [id]) eventId String @default("codeday-labs-2022") @@ -490,6 +546,16 @@ enum PrStatus { CLOSED_MERGED } +enum AttendanceSource { + SLACK_HUDDLE + MANUAL +} + +enum AttendanceTrackingMode { + SLACK_HUDDLE + NOT_TRACKED +} + model Project { // Metadata id String @id @default(cuid()) @@ -515,6 +581,8 @@ model Project { slackChannelId String? standupId String? + attendanceTracking AttendanceTrackingMode? + // Relations tags Tag[] mentors Mentor[] @@ -530,12 +598,14 @@ model Project { repository Repository? @relation(fields: [repositoryId], references: [id]) repositoryId String? - event Event? @relation(fields: [eventId], references: [id]) - eventId String? - standupThreads StandupThread[] - standupResults StandupResult[] - artifacts Artifact[] - files File[] + event Event? @relation(fields: [eventId], references: [id]) + eventId String? + standupThreads StandupThread[] + standupResults StandupResult[] + artifacts Artifact[] + files File[] + meetings Meeting[] + huddleParticipations SlackHuddleParticipation[] } model ArtifactType { diff --git a/scripts/testAttendanceSlack.ts b/scripts/testAttendanceSlack.ts new file mode 100644 index 0000000..c9afb73 --- /dev/null +++ b/scripts/testAttendanceSlack.ts @@ -0,0 +1,428 @@ +/** + * 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 } from '../src/automation/tasks/sendAttendanceAlerts'; +import { getSlackClientForEvent } from '../src/slack'; +import { isAttendanceTracked } from '../src/utils'; +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(), + }, +]; + +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[]; untrackedProjectCount: number }> { + const projects = await prisma.project.findMany({ + where: { + eventId: event.id, + status: 'MATCHED', + }, + include: { + students: { where: { status: 'ACCEPTED' } }, + mentors: { where: { status: 'ACCEPTED' } }, + event: { select: { defaultAttendanceTracking: true } }, + }, + }); + + const meetings = await prisma.meeting.findMany({ + where: { + eventId: event.id, + }, + include: { + attendance: true, + }, + }); + + const students: AttendanceIssue[] = []; + let untrackedProjectCount = 0; + + for (const project of projects) { + if (!isAttendanceTracked(project)) { + untrackedProjectCount += 1; + continue; + } + + 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, + }); + } + } + + } + + return { students, untrackedProjectCount }; +} + +async function postTestMessage( + slack: WebClient | null, + channelNameToUse: string, + eventName: string, + students: AttendanceIssue[], + untrackedProjectCount: number, +): Promise { + const message = { + channel: channelNameToUse, + text: buildWeeklyAttendanceAlertMessage(eventName, students, untrackedProjectCount), + }; + + 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 untrackedProjectCount: number; + 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; + untrackedProjectCount = issues.untrackedProjectCount; + eventName = sourceEvent.name; + + console.log(`Using event data: ${sourceEvent.name} (${sourceEvent.id})`); + console.log(`Found ${students.length} flagged students (${untrackedProjectCount} untracked project(s)) in local DB\n`); + + if (students.length === 0) { + console.error('โŒ Seeded test data did not produce any student attendance issues.'); + process.exit(1); + } + } else { + students = FAKE_STUDENT_ISSUES; + untrackedProjectCount = 0; + eventName = TEST_EVENT_NAME; + } + + if (isDryRun) { + await postTestMessage(null, channelName, eventName, students, untrackedProjectCount); + 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, untrackedProjectCount); + } 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/scripts/testAttendanceTracking.ts b/scripts/testAttendanceTracking.ts new file mode 100644 index 0000000..44a47fc --- /dev/null +++ b/scripts/testAttendanceTracking.ts @@ -0,0 +1,62 @@ +import 'reflect-metadata'; +import assert from 'assert'; +import { AttendanceTrackingMode } from '@prisma/client'; +import { isAttendanceTracked, resolveAttendanceTracking } from '../src/utils/attendanceTracking'; + +function run(): void { + // Project setting takes precedence over the event default. + assert.strictEqual( + resolveAttendanceTracking({ + attendanceTracking: AttendanceTrackingMode.NOT_TRACKED, + event: { defaultAttendanceTracking: AttendanceTrackingMode.SLACK_HUDDLE }, + }), + AttendanceTrackingMode.NOT_TRACKED, + ); + + // Unset project falls back to the event default. + assert.strictEqual( + resolveAttendanceTracking({ + attendanceTracking: null, + event: { defaultAttendanceTracking: AttendanceTrackingMode.NOT_TRACKED }, + }), + AttendanceTrackingMode.NOT_TRACKED, + ); + + // A project may opt back in even when its event opts out by default. + assert.strictEqual( + resolveAttendanceTracking({ + attendanceTracking: AttendanceTrackingMode.SLACK_HUDDLE, + event: { defaultAttendanceTracking: AttendanceTrackingMode.NOT_TRACKED }, + }), + AttendanceTrackingMode.SLACK_HUDDLE, + ); + + // With neither set, tracking defaults on. + assert.strictEqual( + resolveAttendanceTracking({ attendanceTracking: null }), + AttendanceTrackingMode.SLACK_HUDDLE, + ); + assert.strictEqual( + resolveAttendanceTracking({ attendanceTracking: null, event: null }), + AttendanceTrackingMode.SLACK_HUDDLE, + ); + + // isAttendanceTracked mirrors the resolved mode. + assert.strictEqual(isAttendanceTracked({ attendanceTracking: null }), true); + assert.strictEqual( + isAttendanceTracked({ attendanceTracking: AttendanceTrackingMode.NOT_TRACKED }), + false, + ); + assert.strictEqual( + isAttendanceTracked({ + attendanceTracking: null, + event: { defaultAttendanceTracking: AttendanceTrackingMode.NOT_TRACKED }, + }), + false, + ); + + // eslint-disable-next-line no-console + console.log('attendanceTracking tests passed'); +} + +run(); diff --git a/scripts/testSendAttendanceAlerts.ts b/scripts/testSendAttendanceAlerts.ts new file mode 100644 index 0000000..4c265e1 --- /dev/null +++ b/scripts/testSendAttendanceAlerts.ts @@ -0,0 +1,36 @@ +import 'reflect-metadata'; +import assert from 'assert'; +import { buildWeeklyAttendanceAlertMessage } from '../src/automation/tasks/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, + }, + ], + 2, + ); + + 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('2 project(s) are not tracked')); + + const noUntracked = buildWeeklyAttendanceAlertMessage('CodeDay Labs', [], 0); + assert.ok(!noUntracked.includes('not tracked')); + + // eslint-disable-next-line no-console + console.log('sendAttendanceAlerts tests passed'); +} + +run(); diff --git a/src/automation/tasks/markAbsentStudents.ts b/src/automation/tasks/markAbsentStudents.ts new file mode 100644 index 0000000..6a9b1df --- /dev/null +++ b/src/automation/tasks/markAbsentStudents.ts @@ -0,0 +1,96 @@ +import { PrismaClient } from '@prisma/client'; +import Container from 'typedi'; +import { isAttendanceTracked, makeDebug } from '../../utils'; +import { DateTime } from 'luxon'; + +const DEBUG = makeDebug('automation:tasks:markAbsentStudents'); + +export const JOBSPEC = '0 2 * * *'; // Run daily at 2 AM + +/** + * For completed meetings (from Slack huddles), mark students who didn't + * join the huddle as absent. This runs daily to check meetings that + * ended in the last 24 hours. + */ +export default async function markAbsentStudents(): Promise { + const prisma = Container.get(PrismaClient); + + // Find meetings that ended in the last 24 hours + const oneDayAgo = DateTime.now().minus({ hours: 24 }).toJSDate(); + const now = new Date(); + + const recentMeetings = await prisma.meeting.findMany({ + where: { + scheduledEndAt: { gte: oneDayAgo, lte: now }, + slackHuddleId: { not: null }, + }, + include: { + project: { + include: { + students: { where: { status: 'ACCEPTED' } }, + event: { select: { defaultAttendanceTracking: true } }, + }, + }, + attendance: { where: { source: 'SLACK_HUDDLE' } }, + }, + }); + + DEBUG(`Checking ${recentMeetings.length} meetings from last 24 hours for absent students`); + + let totalAbsentMarked = 0; + + for (const meeting of recentMeetings) { + if (!meeting.project) { + DEBUG(`Meeting ${meeting.id} has no project, skipping`); + continue; + } + + // Never infer absence for projects which do not meet on Slack; they have no + // huddle data, so every student would be wrongly marked absent. + if (!isAttendanceTracked(meeting.project)) { + DEBUG(`Meeting ${meeting.id} project is not tracked via Slack, skipping`); + continue; + } + + // Get IDs of students who attended + const attendedStudentIds = new Set( + meeting.attendance.map((a) => a.studentId).filter((id): id is string => id !== null) + ); + + // Mark students who didn't attend as absent + for (const student of meeting.project.students) { + if (!attendedStudentIds.has(student.id)) { + // Check if attendance record already exists + const existing = await prisma.meetingAttendance.findFirst({ + where: { + meetingId: meeting.id, + studentId: student.id, + source: 'SLACK_HUDDLE', + }, + }); + + if (!existing) { + // Create absent record + await prisma.meetingAttendance.create({ + data: { + meetingId: meeting.id, + studentId: student.id, + attended: false, + source: 'SLACK_HUDDLE', + confidence: 1.0, + metadata: { + reason: 'Did not join Slack huddle', + meetingEndTime: meeting.scheduledEndAt?.toISOString(), + }, + }, + }); + + totalAbsentMarked++; + DEBUG(`Marked student ${student.id} as absent from meeting ${meeting.id}`); + } + } + } + } + + DEBUG(`Completed: marked ${totalAbsentMarked} students as absent`); +} diff --git a/src/automation/tasks/sendAttendanceAlerts.ts b/src/automation/tasks/sendAttendanceAlerts.ts new file mode 100644 index 0000000..0f9db7a --- /dev/null +++ b/src/automation/tasks/sendAttendanceAlerts.ts @@ -0,0 +1,191 @@ +import { PrismaClient, Event } from '@prisma/client'; +import Container from 'typedi'; +import { getSlackClientForEvent } from '../../slack'; +import { isAttendanceTracked, makeDebug } from '../../utils'; + +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; +} + +function slackMention(slackId?: string): string | null { + return slackId ? `<@${slackId}>` : null; +} + +export function buildWeeklyAttendanceAlertMessage( + eventName: string, + students: AttendanceIssue[], + untrackedProjectCount = 0, +): 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 (untrackedProjectCount > 0) { + message += `_${untrackedProjectCount} project(s) are not tracked via Slack huddles and were excluded._\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[]; untrackedProjectCount: number }> { + + DEBUG(`Processing attendance alerts for event: ${event.name}`); + + const lowAttendanceStudents: AttendanceIssue[] = []; + let untrackedProjectCount = 0; + + // 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' } }, + event: { select: { defaultAttendanceTracking: true } }, + meetings: { + include: { + attendance: true, + }, + }, + }, + }); + + for (const project of projects) { + // Projects which do not meet on Slack have no huddle data, so reporting on + // them would falsely show every student as absent. + if (!isAttendanceTracked(project)) { + untrackedProjectCount += 1; + DEBUG(`Skipping project ${project.id}: attendance not tracked via Slack`); + continue; + } + + 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, + }); + } + } + + } + + return { + students: lowAttendanceStudents, + untrackedProjectCount, + }; +} + +async function processEventAlerts(event: Event): Promise { + const prisma = Container.get(PrismaClient); + const { students, untrackedProjectCount } = await getAttendanceIssuesForEvent(prisma, event); + + // Send alerts if there are any issues + if (students.length > 0) { + await sendSlackAlert(event, students, untrackedProjectCount); + } else { + DEBUG(`No attendance issues found for ${event.name}`); + } +} + +async function sendSlackAlert( + event: Event, + students: AttendanceIssue[], + untrackedProjectCount: number, +): 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, untrackedProjectCount); + + DEBUG(`Sending Slack alert to channel ${ATTENDANCE_ALERT_CHANNEL}`); + + await slack.chat.postMessage({ + channel: ATTENDANCE_ALERT_CHANNEL, + text: message, + }); +} diff --git a/src/email/templates/weeklyAttendanceAlert.md b/src/email/templates/weeklyAttendanceAlert.md new file mode 100644 index 0000000..6e378e3 --- /dev/null +++ b/src/email/templates/weeklyAttendanceAlert.md @@ -0,0 +1,42 @@ +--- +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}} + +--- + +## Summary + +- **Total flagged students:** {{ lowAttendanceStudents.length }} +{{#if untrackedProjectCount}} +- **Projects not tracked via Slack (excluded):** {{ untrackedProjectCount }} +{{/if}} + +_Attendance is measured from Slack huddles. Teams which meet elsewhere are excluded from this report._ + +_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..88c90b7 100644 --- a/src/enums/index.ts +++ b/src/enums/index.ts @@ -10,6 +10,8 @@ import { FileTypeType, FileTypeGenerationCondition, FileTypeGenerationTarget, + AttendanceSource, + AttendanceTrackingMode, } from '@prisma/client'; import { registerEnumType } from 'type-graphql'; @@ -35,6 +37,8 @@ registerEnumType(RejectionReason, { name: 'RejectionReason' }); registerEnumType(TagType, { name: 'TagType' }); registerEnumType(PersonType, { name: 'PersonType' }); registerEnumType(PrStatus, { name: 'PrStatus' }); +registerEnumType(AttendanceSource, { name: 'AttendanceSource' }); +registerEnumType(AttendanceTrackingMode, { name: 'AttendanceTrackingMode' }); export { Track, @@ -49,4 +53,6 @@ export { FileTypeType, FileTypeGenerationCondition, FileTypeGenerationTarget, + AttendanceSource, + AttendanceTrackingMode, }; 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/ProjectEditInput.ts b/src/inputs/ProjectEditInput.ts index f0585c5..9fdc095 100644 --- a/src/inputs/ProjectEditInput.ts +++ b/src/inputs/ProjectEditInput.ts @@ -1,6 +1,6 @@ import { InputType, Field, Int } from 'type-graphql'; import { Prisma } from '@prisma/client'; -import { ProjectStatus, Track } from '../enums'; +import { AttendanceTrackingMode, ProjectStatus, Track } from '../enums'; @InputType() export class ProjectEditInput { @@ -34,6 +34,9 @@ export class ProjectEditInput { @Field(() => String, { nullable: true }) repositoryId?: string | null + @Field(() => AttendanceTrackingMode, { nullable: true }) + attendanceTracking?: AttendanceTrackingMode | null + toQuery(): Prisma.ProjectUpdateInput { return { description: this.description, @@ -50,6 +53,7 @@ export class ProjectEditInput { tags: this.tags ? { set: this.tags.map((id): Prisma.TagWhereUniqueInput => ({ id })) } : undefined, issueUrl: this.issueUrl ?? undefined, complete: this.complete ?? undefined, + attendanceTracking: this.attendanceTracking ?? undefined, repository: typeof this.repositoryId !== 'undefined' ? (this.repositoryId ? { connect: { id: this.repositoryId } } 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..726f1a3 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,8 @@ import { DateTime } from 'luxon'; import { Context, AuthRole } from '../context'; import { Track, StudentStatus } from '../enums'; import { Stat } from '../types/Stat'; +import { StudentAttendanceStat, FlaggedStudent } from '../types/AttendanceStats'; +import { isAttendanceTracked, resolveAttendanceTracking } from '../utils'; // 2012: 24 students, 400 hours = 9,600 hours // 2013: 16 students, 400 hours = 6,400 hours @@ -107,4 +109,120 @@ 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: { + event: { select: { defaultAttendanceTracking: true } }, + 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)) + ); + + // Untracked projects produce no huddle data, so they must never be flagged + // on the basis of missing attendance records. + const tracked = isAttendanceTracked(project); + + stats.push({ + student: student as any, + project: project as any, + meetingsTotal, + meetingsAttended, + attendancePercentage, + lastAttendedAt: lastAttended?.createdAt, + lastMeetingAt: lastMeeting?.scheduledStartAt || undefined, + isFlagged: tracked && attendancePercentage < minAttendanceThreshold && meetingsTotal > 0, + dataSources, + trackingMode: resolveAttendanceTracking(project), + }); + } + + return stats.sort((a, b) => a.attendancePercentage - b.attendancePercentage); + } + + @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/server.ts b/src/server.ts index 253653a..1452dd9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import { createContext as context } from './context'; import config from './config'; import { processPostmarkInboundEmail } from './email'; import { getPrometheusMetrics } from './metrics'; +import { processSlackEvent } from './slack/webhooks'; const DEBUG = makeDebug('server'); @@ -51,6 +52,11 @@ export async function startServer(): Promise { bodyParser.json(), processPostmarkInboundEmail ); + restServer.post( + `/${config.webhook.key}/slack`, + bodyParser.json(), + processSlackEvent + ); restServer.get('/metrics', basicAuth({ users: { 'metrics': config.metrics.key } }), async (_, res) => { res.setHeader('Content-Type', 'text/plain'); res.send(await getPrometheusMetrics()); diff --git a/src/slack/events/huddleHandler.ts b/src/slack/events/huddleHandler.ts new file mode 100644 index 0000000..dc53f05 --- /dev/null +++ b/src/slack/events/huddleHandler.ts @@ -0,0 +1,257 @@ +import { PrismaClient, Student, Mentor } from '@prisma/client'; +import Container from 'typedi'; +import { isAttendanceTracked, makeDebug } from '../../utils'; +import { DateTime } from 'luxon'; + +const DEBUG = makeDebug('slack:events:huddle'); + +interface HuddleEvent { + user: { id: string; team_id: string }; + channel: { id: string }; + huddle: { id: string }; + huddle_client?: 'desktop' | 'mobile' | null; // null = left huddle +} + +/** + * Handle Slack huddle event (user_huddle_changed) + * When a user joins or leaves a huddle in a project channel + */ +export async function handleHuddleEvent(event: HuddleEvent): Promise { + const prisma = Container.get(PrismaClient); + const { user, channel, huddle, huddle_client } = event; + + DEBUG(`Huddle event: user=${user.id}, channel=${channel.id}, huddle=${huddle.id}, joined=${!!huddle_client}`); + + // Find project by Slack channel + const project = await prisma.project.findFirst({ + where: { slackChannelId: channel.id, status: 'MATCHED' }, + include: { + students: { where: { status: 'ACCEPTED' } }, + mentors: { where: { status: 'ACCEPTED' } }, + event: { select: { id: true, defaultAttendanceTracking: true } }, + }, + }); + + if (!project || !project.event) { + DEBUG(`No matched project found for channel ${channel.id}`); + return; + } + + if (!isAttendanceTracked(project)) { + DEBUG(`Project ${project.id} has opted out of Slack attendance tracking`); + return; + } + + const now = new Date(); + const mentor = project.mentors.find((m) => m.slackId === user.id); + const student = project.students.find((s) => s.slackId === user.id); + + if (!mentor && !student) { + DEBUG(`User ${user.id} is neither mentor nor student in project ${project.id}`); + return; + } + + if (huddle_client) { + // User JOINED huddle + await handleHuddleJoin({ + huddleId: huddle.id, + channelId: channel.id, + userId: user.id, + mentor, + student, + projectId: project.id, + eventId: project.event.id, + joinedAt: now, + }); + } else { + // User LEFT huddle + await handleHuddleLeave({ + huddleId: huddle.id, + userId: user.id, + leftAt: now, + }); + } +} + +interface HuddleJoinData { + huddleId: string; + channelId: string; + userId: string; + mentor?: Mentor; + student?: Student; + projectId: string; + eventId: string; + joinedAt: Date; +} + +async function handleHuddleJoin(data: HuddleJoinData): Promise { + const prisma = Container.get(PrismaClient); + const { mentor, student } = data; + + // Record participation + await prisma.slackHuddleParticipation.upsert({ + where: { huddleId_userId: { huddleId: data.huddleId, userId: data.userId } }, + create: { + huddleId: data.huddleId, + channelId: data.channelId, + userId: data.userId, + projectId: data.projectId, + mentorId: mentor?.id, + studentId: student?.id, + joinedAt: data.joinedAt, + }, + update: { + joinedAt: data.joinedAt, + leftAt: null, + }, + }); + + DEBUG(`Recorded huddle participation for user ${data.userId}`); + + // If MENTOR joined โ†’ create/find meeting + if (mentor) { + await handleMentorJoinedHuddle({ + huddleId: data.huddleId, + projectId: data.projectId, + eventId: data.eventId, + mentorId: mentor.id, + startedAt: data.joinedAt, + }); + } + + // If STUDENT joined โ†’ record attendance + if (student) { + await handleStudentJoinedHuddle({ + huddleId: data.huddleId, + studentId: student.id, + joinedAt: data.joinedAt, + }); + } +} + +interface MentorJoinData { + huddleId: string; + projectId: string; + eventId: string; + mentorId: string; + startedAt: Date; +} + +async function handleMentorJoinedHuddle(data: MentorJoinData): Promise { + const prisma = Container.get(PrismaClient); + + // Check if meeting already exists for this huddle + const existing = await prisma.meeting.findFirst({ + where: { slackHuddleId: data.huddleId }, + }); + + if (existing) { + DEBUG(`Meeting already exists for huddle ${data.huddleId}`); + return; + } + + // Create new meeting + const meeting = await prisma.meeting.create({ + data: { + eventId: data.eventId, + projectId: data.projectId, + slackHuddleId: data.huddleId, + scheduledStartAt: data.startedAt, + scheduledEndAt: data.startedAt, // Will update when huddle ends + visibleAt: data.startedAt, + dueAt: DateTime.fromJSDate(data.startedAt).plus({ days: 1 }).toJSDate(), + }, + }); + + DEBUG(`Created meeting ${meeting.id} from mentor joining huddle ${data.huddleId}`); + + // Link all existing huddle participations to this meeting + await prisma.slackHuddleParticipation.updateMany({ + where: { huddleId: data.huddleId, meetingId: null }, + data: { meetingId: meeting.id }, + }); +} + +interface StudentJoinData { + huddleId: string; + studentId: string; + joinedAt: Date; +} + +async function handleStudentJoinedHuddle(data: StudentJoinData): Promise { + const prisma = Container.get(PrismaClient); + + // Find meeting for this huddle + const meeting = await prisma.meeting.findFirst({ + where: { slackHuddleId: data.huddleId }, + }); + + if (!meeting) { + DEBUG(`No meeting yet for huddle ${data.huddleId} - mentor hasn't joined?`); + return; + } + + // Check if attendance already recorded + const existing = await prisma.meetingAttendance.findFirst({ + where: { + meetingId: meeting.id, + studentId: data.studentId, + source: 'SLACK_HUDDLE', + }, + }); + + if (existing) { + DEBUG(`Attendance already recorded for student ${data.studentId}`); + return; + } + + // Record attendance + await prisma.meetingAttendance.create({ + data: { + meetingId: meeting.id, + studentId: data.studentId, + attended: true, + source: 'SLACK_HUDDLE', + confidence: 1.0, + metadata: { + huddleId: data.huddleId, + joinedAt: data.joinedAt.toISOString(), + }, + }, + }); + + DEBUG(`Recorded attendance for student ${data.studentId} at meeting ${meeting.id}`); +} + +interface HuddleLeaveData { + huddleId: string; + userId: string; + leftAt: Date; +} + +async function handleHuddleLeave(data: HuddleLeaveData): Promise { + const prisma = Container.get(PrismaClient); + + // Update participation record + await prisma.slackHuddleParticipation.updateMany({ + where: { huddleId: data.huddleId, userId: data.userId, leftAt: null }, + data: { leftAt: data.leftAt }, + }); + + DEBUG(`Updated leave time for user ${data.userId} in huddle ${data.huddleId}`); + + // Check if this was the last person to leave + const remaining = await prisma.slackHuddleParticipation.count({ + where: { huddleId: data.huddleId, leftAt: null }, + }); + + if (remaining === 0) { + // Huddle ended - update meeting end time + await prisma.meeting.updateMany({ + where: { slackHuddleId: data.huddleId }, + data: { scheduledEndAt: data.leftAt }, + }); + + DEBUG(`Huddle ${data.huddleId} ended, updated meeting end time`); + } +} diff --git a/src/slack/webhooks.ts b/src/slack/webhooks.ts new file mode 100644 index 0000000..f2ca288 --- /dev/null +++ b/src/slack/webhooks.ts @@ -0,0 +1,60 @@ +import { Request, Response } from 'express'; +import { makeDebug } from '../utils'; +import { handleHuddleEvent } from './events/huddleHandler'; + +const DEBUG = makeDebug('slack:webhooks'); + +interface SlackEvent { + type: string; + challenge?: string; + event?: { + type: string; + user: { id: string; team_id: string }; + channel: { id: string }; + huddle: { id: string }; + huddle_client?: 'desktop' | 'mobile' | null; + [key: string]: unknown; + }; +} + +/** + * Handle Slack webhook events + * https://api.slack.com/events-api + */ +export async function processSlackEvent(req: Request, res: Response): Promise { + const body = req.body as SlackEvent; + const { type, challenge, event } = body; + + DEBUG('Received Slack event:', { type, eventType: event?.type }); + + // URL verification (Slack sends this once during setup) + if (type === 'url_verification' && challenge) { + DEBUG('URL verification request received'); + res.json({ challenge }); + return; + } + + // Event callback + if (type === 'event_callback' && event) { + // Acknowledge immediately (Slack requires response <3 seconds) + res.status(200).send('OK'); + + // Process event asynchronously + setImmediate(async () => { + try { + if (event.type === 'user_huddle_changed' && event.huddle) { + await handleHuddleEvent(event as any); + } else { + DEBUG(`Unhandled event type: ${event.type}`); + } + } catch (err) { + DEBUG('Error processing Slack event:', err); + } + }); + + return; + } + + DEBUG('Unknown event type or missing data'); + res.status(400).send('Unknown event type'); +} diff --git a/src/types/AttendanceStats.ts b/src/types/AttendanceStats.ts new file mode 100644 index 0000000..04a4036 --- /dev/null +++ b/src/types/AttendanceStats.ts @@ -0,0 +1,64 @@ +import { ObjectType, Field, Int, Float } from 'type-graphql'; +import { Student } from './Student'; +import { Mentor } from './Mentor'; +import { Project } from './Project'; +import { AttendanceSource, AttendanceTrackingMode } 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[] + + // Distinguishes "did not attend" from "this team is not measured", so untracked + // teams are not read as 0% attendance. + @Field(() => AttendanceTrackingMode) + trackingMode: AttendanceTrackingMode +} + +@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/Project.ts b/src/types/Project.ts index 9ff0855..4d9078d 100644 --- a/src/types/Project.ts +++ b/src/types/Project.ts @@ -16,7 +16,7 @@ import { Container } from 'typedi'; import { ObjectType, Field, Int, Authorized, Ctx, } from 'type-graphql'; -import { Track, ProjectStatus, PrStatus } from '../enums'; +import { Track, ProjectStatus, PrStatus, AttendanceTrackingMode } from '../enums'; import { Tag } from './Tag'; import { Mentor } from './Mentor'; import { Student } from './Student'; @@ -72,6 +72,9 @@ export class Project implements PrismaProject { @Field(() => String, { nullable: true }) standupId: string | null + @Field(() => AttendanceTrackingMode, { nullable: true }) + attendanceTracking: AttendanceTrackingMode | null + @Field(() => [Tag], { name: 'tags' }) async fetchTags(): Promise { if (!this.tags) { 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 diff --git a/src/utils/attendanceTracking.ts b/src/utils/attendanceTracking.ts new file mode 100644 index 0000000..48dd805 --- /dev/null +++ b/src/utils/attendanceTracking.ts @@ -0,0 +1,25 @@ +import { AttendanceTrackingMode } from '@prisma/client'; + +export interface AttendanceTrackingConfig { + attendanceTracking: AttendanceTrackingMode | null; + event?: { defaultAttendanceTracking: AttendanceTrackingMode } | null; +} + +/** + * Resolves the effective attendance tracking mode for a project, falling back to + * the event default when the project has no explicit setting. + */ +export function resolveAttendanceTracking(project: AttendanceTrackingConfig): AttendanceTrackingMode { + return project.attendanceTracking + ?? project.event?.defaultAttendanceTracking + ?? AttendanceTrackingMode.SLACK_HUDDLE; +} + +/** + * Whether attendance for a project is derived from Slack huddles. Projects which + * meet elsewhere have no huddle data, so their absence of records must not be + * reported as missed meetings. + */ +export function isAttendanceTracked(project: AttendanceTrackingConfig): boolean { + return resolveAttendanceTracking(project) === AttendanceTrackingMode.SLACK_HUDDLE; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 7996211..90e61bb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -29,4 +29,5 @@ export * from './minMaxFunction'; export * from './math'; export * from './array'; export * from './fullName'; -export * from './notNullable'; \ No newline at end of file +export * from './notNullable'; +export * from './attendanceTracking'; \ No newline at end of file