From f8aae7bf05adb97c64ee40bd7554b4e0c4abf888 Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:40:04 +0000 Subject: [PATCH 1/3] Add meeting attendance schema, types, and resolvers Introduces the Meeting/MeetingAttendance data model used to record whether students attend their team meetings: - AttendanceSource enum (SLACK_HUDDLE, MANUAL) plus source/confidence/metadata on MeetingAttendance, so records carry their provenance. - AttendanceTrackingMode enum with Project.attendanceTracking and Event.defaultAttendanceTracking, letting teams which do not meet on Slack opt out of automated tracking. - Meeting gains projectId, slackHuddleId, and scheduled start/end times. - SlackHuddleParticipation records raw huddle join/leave events. - GraphQL types, inputs, and a Meeting resolver for reading meetings and recording attendance manually. --- .../migration.sql | 61 ++++++++ prisma/schema.prisma | 94 ++++++++++-- src/enums/index.ts | 6 + src/inputs/MeetingAttendanceInput.ts | 27 ++++ src/inputs/MeetingCreateInput.ts | 38 +++++ src/inputs/index.ts | 4 +- src/resolvers/Meeting.ts | 145 ++++++++++++++++++ src/types/AttendanceStats.ts | 64 ++++++++ src/types/Meeting.ts | 131 ++++++++++++++++ src/types/MeetingAttendance.ts | 80 ++++++++++ src/types/MeetingResponse.ts | 69 +++++++++ src/types/index.ts | 6 +- 12 files changed, 711 insertions(+), 14 deletions(-) create mode 100644 prisma/migrations/20260714193312_add_meeting_attendance_tracking/migration.sql create mode 100644 src/inputs/MeetingAttendanceInput.ts create mode 100644 src/inputs/MeetingCreateInput.ts create mode 100644 src/resolvers/Meeting.ts create mode 100644 src/types/AttendanceStats.ts create mode 100644 src/types/Meeting.ts create mode 100644 src/types/MeetingAttendance.ts create mode 100644 src/types/MeetingResponse.ts 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/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/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/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/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 From cf16d1790af4dfa389d4266229088e3938810198 Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:40:10 +0000 Subject: [PATCH 2/3] Track meeting attendance automatically from Slack huddles Attendance is derived from Slack huddle activity rather than asked for: - A Slack events webhook receives user_huddle_changed and acknowledges within Slack's 3 second budget, processing the event asynchronously. - A mentor joining a huddle in a project channel opens a Meeting; students joining are recorded as present with source=SLACK_HUDDLE. The meeting end time is set when the last participant leaves. - markAbsentStudents runs daily and marks students who never joined, so absence is recorded without anyone submitting a form. --- src/automation/tasks/markAbsentStudents.ts | 96 ++++++++ src/server.ts | 6 + src/slack/events/huddleHandler.ts | 257 +++++++++++++++++++++ src/slack/webhooks.ts | 60 +++++ 4 files changed, 419 insertions(+) create mode 100644 src/automation/tasks/markAbsentStudents.ts create mode 100644 src/slack/events/huddleHandler.ts create mode 100644 src/slack/webhooks.ts 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/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'); +} From a4484c4d784ef2e5c4ecfd131bf0310fd177d130 Mon Sep 17 00:00:00 2001 From: "augmentcode[bot]" <185243770+augmentcode[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 20:40:20 +0000 Subject: [PATCH 3/3] Report attendance, excluding teams which opt out of Slack tracking Surfaces attendance to program managers while ensuring teams that do not meet on Slack are not misreported. Because absence is inferred from the absence of a huddle, an untracked team would otherwise appear to have 0% attendance and every student would be flagged. - resolveAttendanceTracking/isAttendanceTracked resolve a project's mode, falling back to its event default. - statStudentAttendance exposes trackingMode and never flags untracked projects; flaggedStudents and the weekly Slack alert exclude them, reporting only a count. - attendanceTracking is settable via editProject and readable on Project. - Documents setup, the opt-out, and its effect on each component. --- SLACK_HUDDLE_ATTENDANCE.md | 386 +++++++++++++++++ package.json | 5 +- scripts/testAttendanceSlack.ts | 428 +++++++++++++++++++ scripts/testAttendanceTracking.ts | 62 +++ scripts/testSendAttendanceAlerts.ts | 36 ++ src/automation/tasks/sendAttendanceAlerts.ts | 191 +++++++++ src/email/templates/weeklyAttendanceAlert.md | 42 ++ src/inputs/ProjectEditInput.ts | 6 +- src/resolvers/Stats.ts | 120 +++++- src/types/Project.ts | 5 +- src/utils/attendanceTracking.ts | 25 ++ src/utils/index.ts | 3 +- 12 files changed, 1302 insertions(+), 7 deletions(-) create mode 100644 SLACK_HUDDLE_ATTENDANCE.md create mode 100644 scripts/testAttendanceSlack.ts create mode 100644 scripts/testAttendanceTracking.ts create mode 100644 scripts/testSendAttendanceAlerts.ts create mode 100644 src/automation/tasks/sendAttendanceAlerts.ts create mode 100644 src/email/templates/weeklyAttendanceAlert.md create mode 100644 src/utils/attendanceTracking.ts 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/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/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/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/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/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/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