Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,4 @@ NOTIFY_TG_VERBOSE=
# Google Calendar / Reports
GOOGLE_CALENDAR_ID=primary
INTERVIEW_REPORTS_FOLDER_ID=
INTERVIEW_REPORT_TEMPLATE_ID=
2 changes: 2 additions & 0 deletions src/inngest/functions/index.ts
Original file line number Diff line number Diff line change
@@ -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,
];
156 changes: 156 additions & 0 deletions src/inngest/functions/stage-d.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
);
176 changes: 176 additions & 0 deletions src/lib/automation/interviewAssets.ts
Original file line number Diff line number Diff line change
@@ -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<Applicant, 'name' | 'surname'>) {
return `${applicant.name} ${applicant.surname}`;
}

function getReportTitle(params: {
applicant: Pick<Applicant, 'name' | 'surname'>;
interview: Pick<Interview, 'id'>;
}) {
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<Applicant, 'name' | 'surname'>;
interview: Pick<Interview, 'id'>;
}): Promise<string> {
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<Applicant, 'name' | 'surname' | 'email'>;
interview: Pick<Interview, 'id'>;
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,
};
}
28 changes: 28 additions & 0 deletions src/lib/automation/stageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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));
}