diff --git a/common/achievement/evaluate.spec.ts b/common/achievement/evaluate.spec.ts index 01eb52c22..77cb56224 100644 --- a/common/achievement/evaluate.spec.ts +++ b/common/achievement/evaluate.spec.ts @@ -296,6 +296,7 @@ function createLecture({ start }: { start: Date }): lecture { zoomMeetingReport: [], instructorId: null, override_meeting_link: null, + actualDuration: null, }; } diff --git a/common/appointment/create.ts b/common/appointment/create.ts index d25c41f57..5db28d141 100644 --- a/common/appointment/create.ts +++ b/common/appointment/create.ts @@ -1,8 +1,7 @@ import { Field, InputType, Int } from 'type-graphql'; import { prisma } from '../prisma'; -import assert from 'assert'; import { Lecture, lecture_appointmenttype_enum, Subcourse } from '../../graphql/generated'; -import { createZoomMeeting, getZoomMeetingReport } from '../zoom/scheduled-meeting'; +import { createZoomMeeting } from '../zoom/scheduled-meeting'; import { getOrCreateZoomUser, getZoomUrl, ZoomUser } from '../zoom/user'; import { lecture as Appointment, lecture_appointmenttype_enum as AppointmentType, student as Student } from '@prisma/client'; import moment from 'moment'; @@ -242,19 +241,33 @@ const createZoomMeetingForAppointmentWithHosts = async ( } }; -export const saveZoomMeetingReport = async (appointment: Lecture) => { - const result = await getZoomMeetingReport(appointment.zoomMeetingId); +type ZoomMeetingReport = { + participants: { + id: string; + name: string; + join_time: string; + leave_time: string; + }[]; +}; - if (!result) { - logger.info(`Meeting report could not be saved for appointment (${appointment.id})`); - return; - } +export const saveAppointmentStats = async (report: ZoomMeetingReport, appointment: Pick) => { + const earliestJoinTime = report.participants.reduce((acc, p) => { + const joinTime = new Date(p.join_time); + return joinTime < acc ? joinTime : acc; + }, new Date('2100-01-01')); + const latestLeaveTime = report.participants.reduce((acc, p) => { + const leaveTime = new Date(p.leave_time); + return leaveTime > acc ? leaveTime : acc; + }, new Date(0)); + const duration = Math.max(0, latestLeaveTime.getTime() - earliestJoinTime.getTime()); await prisma.lecture.update({ where: { id: appointment.id }, - data: { zoomMeetingReport: { push: result } }, + data: { + actualDuration: duration, + }, }); - logger.info(`Zoom meeting report was saved for appointment (${appointment.id})`); + logger.info(`Stats saved for Lecture (${appointment.id})`); }; export async function createAdHocMeeting(matchId: number, user: User) { diff --git a/graphql/appointment/mutations.ts b/graphql/appointment/mutations.ts index 2fe741394..b23fc2759 100644 --- a/graphql/appointment/mutations.ts +++ b/graphql/appointment/mutations.ts @@ -8,7 +8,7 @@ import { createMatchAppointments, createZoomMeetingForAppointment, isAppointmentOneWeekLater, - saveZoomMeetingReport, + saveAppointmentStats, } from '../../common/appointment/create'; import { GraphQLContext } from '../context'; import { AuthorizedDeferred, hasAccess } from '../authorizations'; @@ -16,7 +16,7 @@ import { prisma } from '../../common/prisma'; import { LectureWhereInput } from '../generated'; import { Doc, getLecture, getStudent } from '../util'; import { getLogger } from '../../common/logger/logger'; -import { deleteZoomMeeting, getZoomMeeting } from '../../common/zoom/scheduled-meeting'; +import { deleteZoomMeeting, getZoomMeeting, getZoomMeetingReport } from '../../common/zoom/scheduled-meeting'; import { declineAppointment } from '../../common/appointment/decline'; import { updateAppointment } from '../../common/appointment/update'; import { cancelAppointment } from '../../common/appointment/cancel'; @@ -134,7 +134,14 @@ export class MutateAppointmentResolver { async appointmentSaveMeetingReport(@Ctx() context: GraphQLContext, @Arg('appointmentId') appointmentId: number) { const appointment = await getLecture(appointmentId); await hasAccess(context, 'Lecture', appointment); - await saveZoomMeetingReport(appointment); + + const report = await getZoomMeetingReport(appointment.zoomMeetingId); + if (!report) { + logger.info(`Meeting report could not be saved for appointment (${appointment.id})`); + return; + } + + await saveAppointmentStats(report, appointment); return true; } diff --git a/graphql/authorizations.ts b/graphql/authorizations.ts index 166736240..36555fc61 100644 --- a/graphql/authorizations.ts +++ b/graphql/authorizations.ts @@ -596,6 +596,7 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = { zoomMeetingId: participantOrOwnerOrAdmin, zoomMeetingReport: adminOrOwner, override_meeting_link: participantOrOwnerOrAdmin, + actualDuration: adminOrOwner, }), }, Participation_certificate: { diff --git a/jobs/list.ts b/jobs/list.ts index 5562d257d..c94f87839 100644 --- a/jobs/list.ts +++ b/jobs/list.ts @@ -12,6 +12,7 @@ import deleteUnreachableAchievements from './periodic/delete-unreachable-achieve import { postStatisticsToSlack } from './slack-statistics'; import notificationsEndedYesterday from './periodic/notification-courses-ended-yesterday'; import { assignOriginalAchievement } from './manual/assign_original_achievement'; +import { populateAppointmentStats } from './manual/populate_appointment_stats'; export const allJobs = { cleanupSecrets, @@ -29,6 +30,7 @@ export const allJobs = { deleteUnreachableAchievements, assignOriginalAchievement, + populateAppointmentStats, // For Integration Tests only: NOTHING_DO_NOT_USE: async () => { diff --git a/jobs/manual/populate_appointment_stats.ts b/jobs/manual/populate_appointment_stats.ts new file mode 100644 index 000000000..8014031e3 --- /dev/null +++ b/jobs/manual/populate_appointment_stats.ts @@ -0,0 +1,25 @@ +import { getLogger } from '../../common/logger/logger'; +import { prisma } from '../../common/prisma'; +import { saveAppointmentStats } from '../../common/appointment/create'; + +const logger = getLogger(); + +export async function populateAppointmentStats() { + logger.info('Populating appointment stats'); + const zoomMeetingReports = await prisma.lecture.findMany({ + where: { + zoomMeetingReport: { hasSome: true }, + }, + select: { + zoomMeetingReport: true, + id: true, + }, + }); + + for (const lecture of zoomMeetingReports) { + const report = lecture.zoomMeetingReport as any; + await saveAppointmentStats(report, lecture); + } + + logger.info('Appointment stats populated'); +} diff --git a/prisma/migrations/20250326143431_add_actual_duration/migration.sql b/prisma/migrations/20250326143431_add_actual_duration/migration.sql new file mode 100644 index 000000000..5c35a7c3a --- /dev/null +++ b/prisma/migrations/20250326143431_add_actual_duration/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "lecture" ADD COLUMN "actualDuration" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ceb45779d..010e6247d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -370,35 +370,37 @@ model learning_note { // Then we evolved "Course Lectures" into "Appointments", were any group of users could meet together. // In the codebase we call this 'appointment' and also still 'lecture' model lecture { - id Int @id(map: "PK_2abef7c1e52b7b58a9f905c9643") @default(autoincrement()) - createdAt DateTime @default(now()) @db.Timestamp(6) - updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) - start DateTime @db.Timestamp(6) - duration Int // in minutes - title String? - description String? - appointmentType lecture_appointmenttype_enum @default(legacy) - isCanceled Boolean? @default(false) + id Int @id(map: "PK_2abef7c1e52b7b58a9f905c9643") @default(autoincrement()) + createdAt DateTime @default(now()) @db.Timestamp(6) + updatedAt DateTime @default(now()) @updatedAt @db.Timestamp(6) + start DateTime @db.Timestamp(6) + duration Int // in minutes + title String? + description String? + appointmentType lecture_appointmenttype_enum @default(legacy) + isCanceled Boolean? @default(false) // A set of UserIDs of users that can modify the appointment: - organizerIds String[] @default([]) + organizerIds String[] @default([]) // A set of UserIDs of users that only participate: - participantIds String[] @default([]) + participantIds String[] @default([]) // A subset of organizerIds and participantIds that have declined the appointment: declinedBy String[] @default([]) // A subset of UserIDs of users that joined the meeting joinedBy String[] @default([]) // A Zoom Meeting might be attached to an Appointment (unless Zoom is turned off): - zoomMeetingId String? @db.VarChar + zoomMeetingId String? @db.VarChar // When an organizer leaves a Zoom Meeting, we collect a Zoom Meeting Report from the API: - zoomMeetingReport Json[] @default([]) @db.Json + zoomMeetingReport Json[] @default([]) @db.Json + + actualDuration Int? // actual duration of Zoom meeting, in milliseconds // DEPRECATED: This was used for Course Lectures before the evolution to Appointments. Use organizerIds instead instructorId Int? // An Appointment might be related to a Subcourse or a Match - but it can also be a standalone meeting: subcourseId Int? matchId Int? - subcourse subcourse? @relation(fields: [subcourseId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_087916363d2c5b483701d505a07") - student student? @relation(fields: [instructorId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_2ca61c8451b53ad2da3c5f6432a") - match match? @relation(fields: [matchId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_5829da504d003d9aa252856574e") + subcourse subcourse? @relation(fields: [subcourseId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_087916363d2c5b483701d505a07") + student student? @relation(fields: [instructorId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_2ca61c8451b53ad2da3c5f6432a") + match match? @relation(fields: [matchId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_5829da504d003d9aa252856574e") course_attendance_log course_attendance_log[] // Support can set this if organizer prefers a different meeting platform over zoom; if this is set, don't create zoom meeting override_meeting_link String?