From 91dd53194e1548343423495c7fde1cccc50ede1c Mon Sep 17 00:00:00 2001 From: Stefano Falco Date: Wed, 13 May 2026 10:29:38 +0200 Subject: [PATCH] feat: add Inngest stage D interview assets workflow --- .env.example | 1 + src/inngest/functions/index.ts | 2 + src/inngest/functions/stage-d.ts | 156 +++++++++++++++++++++++ src/lib/automation/interviewAssets.ts | 176 ++++++++++++++++++++++++++ src/lib/automation/stageData.ts | 28 ++++ 5 files changed, 363 insertions(+) create mode 100644 src/inngest/functions/stage-d.ts create mode 100644 src/lib/automation/interviewAssets.ts diff --git a/.env.example b/.env.example index 098bff6..effb539 100644 --- a/.env.example +++ b/.env.example @@ -65,3 +65,4 @@ NOTIFY_TG_VERBOSE= # Google Calendar / Reports GOOGLE_CALENDAR_ID=primary INTERVIEW_REPORTS_FOLDER_ID= +INTERVIEW_REPORT_TEMPLATE_ID= diff --git a/src/inngest/functions/index.ts b/src/inngest/functions/index.ts index 93fca51..99af7df 100644 --- a/src/inngest/functions/index.ts +++ b/src/inngest/functions/index.ts @@ -1,9 +1,11 @@ import { stageAApplicationReceipt } from './stage-a'; import { stageBInterviewBookingEmail } from './stage-b'; import { stageCSlotSelectedNotification } from './stage-c'; +import { stageDCreateInterviewAssets } from './stage-d'; export const functions = [ stageAApplicationReceipt, stageBInterviewBookingEmail, stageCSlotSelectedNotification, + stageDCreateInterviewAssets, ]; diff --git a/src/inngest/functions/stage-d.ts b/src/inngest/functions/stage-d.ts new file mode 100644 index 0000000..97552a0 --- /dev/null +++ b/src/inngest/functions/stage-d.ts @@ -0,0 +1,156 @@ +import { inngest } from '@/inngest/client'; +import { STAGE_CANCELLED_EVENT, STAGE_CHANGED_EVENT } from '@/inngest/events'; +import { + createInterviewCalendarEvent, + createInterviewReportDocument, + hasRealExternalId, +} from '@/lib/automation/interviewAssets'; +import { notifyTelegram } from '@/lib/automation/notifications'; +import { + loadActiveStageContext, + loadInterviewers, + loadInterviewSlotForApplicant, + markStageStatusProcessed, + updateInterviewGeneratedData, +} from '@/lib/automation/stageData'; + +type StageEventData = { + stageStatusId: string; + scheduledAt: string; +}; + +export const stageDCreateInterviewAssets = inngest.createFunction( + { + id: 'stage-d-create-interview-assets', + triggers: { event: STAGE_CHANGED_EVENT, if: "event.data.stage == 'd'" }, + cancelOn: [{ event: STAGE_CANCELLED_EVENT, match: 'data.stageStatusId' }], + }, + async ({ event, step }) => { + const data = event.data as StageEventData; + const dryRun = process.env.AUTOMATION_DRY_RUN === '1'; + + await step.sleepUntil( + 'wait-until-scheduled-time', + new Date(data.scheduledAt) + ); + + const context = await step.run('load-and-validate-stage', async () => { + return await loadActiveStageContext({ + stageStatusId: data.stageStatusId, + expectedStage: 'd', + }); + }); + + if (!context) { + return { skipped: true, reason: 'stage-status-no-longer-active' }; + } + + const interviewData = await step.run('load-interview-data', async () => { + return await loadInterviewSlotForApplicant(context.applicant.id); + }); + + if (!interviewData) { + throw new Error( + `No interview data found for applicant ${context.applicant.id}` + ); + } + + if (!interviewData.interview.confirmed) { + throw new Error( + `Interview ${interviewData.interview.id} is not confirmed yet` + ); + } + + const interviewers = await step.run('load-interviewers', async () => { + return await loadInterviewers(interviewData.interview.id); + }); + + const reportDocId = await step.run('ensure-report-document', async () => { + const currentReportDocId = interviewData.interview.reportDocId; + + if (hasRealExternalId(currentReportDocId)) { + return currentReportDocId; + } + + const createdReportDocId = await createInterviewReportDocument({ + applicant: context.applicant, + interview: interviewData.interview, + }); + + if (!dryRun) { + await updateInterviewGeneratedData({ + interviewId: interviewData.interview.id, + reportDocId: createdReportDocId, + }); + } else { + console.log( + `[DRY RUN][stage-d] Would save reportDocId=${createdReportDocId} on interview ${interviewData.interview.id}` + ); + } + + return createdReportDocId; + }); + + const meetingId = await step.run( + 'ensure-calendar-event-and-meet', + async () => { + const currentMeetingId = interviewData.interview.meetingId; + + if (hasRealExternalId(currentMeetingId)) { + return currentMeetingId; + } + + const calendarResult = await createInterviewCalendarEvent({ + applicant: context.applicant, + interview: interviewData.interview, + timeslot: interviewData.timeslot, + interviewers, + }); + + if (!dryRun) { + await updateInterviewGeneratedData({ + interviewId: interviewData.interview.id, + meetingId: calendarResult.meetingId, + }); + } else { + console.log( + `[DRY RUN][stage-d] Would save meetingId=${calendarResult.meetingId} on interview ${interviewData.interview.id}` + ); + } + + return calendarResult.meetingId; + } + ); + + await step.run('notify-hr', async () => { + const candidate = `${context.applicant.name} ${context.applicant.surname}`; + + await notifyTelegram({ + channel: 'hr', + text: `Interview assets created for ${candidate}: https://meet.google.com/${meetingId}`, + }); + + await notifyTelegram({ + channel: 'verbose', + text: `[Stage D] Created assets for ${candidate}. meetingId=${meetingId}, reportDocId=${reportDocId}`, + }); + }); + + await step.run('mark-stage-status-processed', async () => { + if (!dryRun) { + await markStageStatusProcessed(context.stageStatus.id); + } else { + console.log( + `[DRY RUN][stage-d] Would mark stage_status ${context.stageStatus.id} as processed` + ); + } + }); + + return { + success: true, + meetingId, + reportDocId, + dryRun, + }; + } +); diff --git a/src/lib/automation/interviewAssets.ts b/src/lib/automation/interviewAssets.ts new file mode 100644 index 0000000..40da88c --- /dev/null +++ b/src/lib/automation/interviewAssets.ts @@ -0,0 +1,176 @@ +import { google } from 'googleapis'; +import { nanoid } from 'nanoid'; +import { DateTime } from 'luxon'; +import type { Applicant, Interview, Timeslot } from '@/db/types'; +import { service } from '@/lib/google/service'; +import { ROME_TIMEZONE } from './scheduling'; + +const PLACEHOLDER_VALUE = 'placeholder'; + +export function hasRealExternalId( + value: string | null | undefined +): value is string { + return Boolean(value && value.trim() !== '' && value !== PLACEHOLDER_VALUE); +} + +function getCandidateFullName(applicant: Pick) { + return `${applicant.name} ${applicant.surname}`; +} + +function getReportTitle(params: { + applicant: Pick; + interview: Pick; +}) { + return `${getCandidateFullName(params.applicant)} (${params.interview.id})`; +} + +function toRomeDateTime(value: Date | string) { + const date = value instanceof Date ? value : new Date(value); + + return DateTime.fromJSDate(date, { zone: 'utc' }).setZone(ROME_TIMEZONE); +} + +export async function createInterviewReportDocument(params: { + applicant: Pick; + interview: Pick; +}): Promise { + const title = getReportTitle(params); + + if (process.env.AUTOMATION_DRY_RUN === '1') { + const dryRunId = `dry-run-report-doc-${params.interview.id}`; + console.log( + `[DRY RUN][stage-d] Would copy Google Doc template as "${title}" into INTERVIEW_REPORTS_FOLDER_ID -> ${dryRunId}` + ); + return dryRunId; + } + + const templateId = process.env.INTERVIEW_REPORT_TEMPLATE_ID; + const folderId = process.env.INTERVIEW_REPORTS_FOLDER_ID; + + if (!templateId) { + throw new Error('Missing INTERVIEW_REPORT_TEMPLATE_ID env variable'); + } + + if (!folderId) { + throw new Error('Missing INTERVIEW_REPORTS_FOLDER_ID env variable'); + } + + const authResult = await service.getAuth(); + if (authResult.isErr()) { + throw authResult.error; + } + + const drive = google.drive({ version: 'v3', auth: authResult.value }); + + const response = await drive.files.copy({ + fileId: templateId, + requestBody: { + name: title, + parents: [folderId], + }, + fields: 'id, name, mimeType', + }); + + if (!response.data.id) { + throw new Error('Google Doc copy failed: missing copied document id'); + } + + return response.data.id; +} + +export async function createInterviewCalendarEvent(params: { + applicant: Pick; + interview: Pick; + timeslot: { startingFrom: Date | string }; + interviewers: Array<{ + name: string | null; + email: string | null; + }>; +}): Promise<{ meetingId: string; eventId: string | null }> { + const candidate = getCandidateFullName(params.applicant); + const start = toRomeDateTime(params.timeslot.startingFrom); + const end = start.plus({ hours: 1 }); + + const interviewerEmails = params.interviewers + .map((interviewer) => interviewer.email) + .filter((email): email is string => Boolean(email)); + + const attendees = [params.applicant.email, ...interviewerEmails] + .filter(Boolean) + .map((email) => ({ email })); + + if (process.env.AUTOMATION_DRY_RUN === '1') { + const dryRunMeetingId = `dry-run-meet-${params.interview.id}`; + console.log( + `[DRY RUN][stage-d] Would create Calendar event for ${candidate} at ${start.toISO()} with attendees: ${attendees + .map((attendee) => attendee.email) + .join(', ')} -> ${dryRunMeetingId}` + ); + + return { + meetingId: dryRunMeetingId, + eventId: `dry-run-calendar-event-${params.interview.id}`, + }; + } + + const authResult = await service.getAuth(); + if (authResult.isErr()) { + throw authResult.error; + } + + const calendar = google.calendar({ version: 'v3', auth: authResult.value }); + const calendarId = process.env.GOOGLE_CALENDAR_ID || 'applyhkn@hknpolito.org'; + + const response = await calendar.events.insert({ + calendarId, + conferenceDataVersion: 1, + sendUpdates: 'all', + requestBody: { + summary: `[IEEE-HKN] Application Interview - ${candidate}`, + description: 'Prepare appropriately for the interview.', + start: { + dateTime: start.toISO({ suppressMilliseconds: true })!, + timeZone: ROME_TIMEZONE, + }, + end: { + dateTime: end.toISO({ suppressMilliseconds: true })!, + timeZone: ROME_TIMEZONE, + }, + attendees, + guestsCanInviteOthers: false, + guestsCanModify: false, + guestsCanSeeOtherGuests: true, + reminders: { + useDefault: false, + overrides: [ + { method: 'email', minutes: 1440 }, + { method: 'popup', minutes: 60 }, + ], + }, + conferenceData: { + createRequest: { + requestId: `hkn-${params.interview.id}-${nanoid()}`, + conferenceSolutionKey: { + type: 'hangoutsMeet', + }, + }, + }, + }, + }); + + const conferenceId = response.data.conferenceData?.conferenceId; + const hangoutLink = response.data.hangoutLink; + const parsedMeetingId = hangoutLink ? hangoutLink.split('/').pop() : null; + const meetingId = conferenceId || parsedMeetingId; + + if (!meetingId) { + throw new Error( + 'Calendar event created but no Google Meet id was returned' + ); + } + + return { + meetingId, + eventId: response.data.id ?? null, + }; +} diff --git a/src/lib/automation/stageData.ts b/src/lib/automation/stageData.ts index ee0d58e..0452365 100644 --- a/src/lib/automation/stageData.ts +++ b/src/lib/automation/stageData.ts @@ -84,3 +84,31 @@ export async function loadInterviewers(interviewId: string) { .innerJoin(schema.user, eq(schema.usersToInterviews.userId, schema.user.id)) .where(eq(schema.usersToInterviews.interviewId, interviewId)); } + +export async function updateInterviewGeneratedData(params: { + interviewId: string; + meetingId?: string | null; + reportDocId?: string | null; +}): Promise { + const values: { + meetingId?: string | null; + reportDocId?: string | null; + } = {}; + + if (params.meetingId !== undefined) { + values.meetingId = params.meetingId; + } + + if (params.reportDocId !== undefined) { + values.reportDocId = params.reportDocId; + } + + if (Object.keys(values).length === 0) { + return; + } + + await db + .update(schema.interview) + .set(values) + .where(eq(schema.interview.id, params.interviewId)); +}