Skip to main content

Interview Scheduling

The interview scheduling system handles the full booking lifecycle -- from slot offers through video conferencing to post-interview scoring. It integrates with Jitsi Meet for video rooms and supports automated reminders.

Architecture Overview

Booking Methods

1. Slot Offer Flow (Candidate Self-Scheduling)

The recruiter creates a slot offer with multiple time options. The candidate receives an email and selects a slot via a public (no-auth) page.

# Step 1: Recruiter creates slot offer
mutation {
createInterviewSlotOffer(input: {
candidateId: "candidate-uuid"
jobId: "job-uuid"
interviewerId: "interviewer-uuid"
duration: 60
expirationHours: 72
slots: [
{ startTime: "2026-02-25T09:00:00Z", endTime: "2026-02-25T10:00:00Z" }
{ startTime: "2026-02-25T14:00:00Z", endTime: "2026-02-25T15:00:00Z" }
{ startTime: "2026-02-26T10:00:00Z", endTime: "2026-02-26T11:00:00Z" }
]
customMessage: "Please select a convenient time."
}) {
id token expiresAt
slots { id startTime endTime }
}
}

Candidate views the offer (no auth required):

query {
slotOfferByToken(token: "offer-token") {
jobTitle companyName recruiterName duration
slots { id startTime endTime isAvailable }
}
}

Slot selection happens via REST endpoint: GET /interview-scheduling/select-slot?token=X&slotId=Y, which auto-creates the interview, booking, and Jitsi room, then redirects to a confirmation page.

Expiration is clamped to the earliest slot

The effective expiresAt on the offer is min(now + expirationHours, earliestSlotStart). If the recruiter offers a slot that starts in 2 hours and passes the default expirationHours: 72, the offer still expires at the slot start time — accepting an offer for a slot that has already begun is meaningless, and the candidate-facing email's "This invitation expires on …" line must reflect that. expirationHours therefore behaves as an upper bound, not a fixed window.

2. Direct Scheduling

Bypass the slot offer flow and confirm an interview immediately:

mutation {
confirmInterviewManually(input: {
candidateId: "candidate-uuid"
jobId: "job-uuid"
scheduledAt: "2026-02-25T14:00:00Z"
interviewerId: "interviewer-uuid"
duration: 60
attendees: [
{ email: "tech-lead@company.com", name: "Tech Lead", role: INTERVIEWER }
]
}) {
id meetingUrl scheduledAt status
}
}

Status Enums

Booking Status

StatusDescription
PENDINGAwaiting confirmation
CONFIRMEDInterview confirmed with time
RESCHEDULEDTime changed
IN_PROGRESSCurrently happening
COMPLETEDFinished
CANCELLEDCancelled
NO_SHOWParticipant did not attend

Interview Status

StatusDescription
PENDINGCreated, not yet scheduled
SCHEDULEDTime confirmed
IN_PROGRESSCurrently happening
COMPLETEDFinished
EVALUATEDResults scored
CANCELLEDCancelled
NO_SHOWNo-show recorded

Slot Offer Status

StatusDescription
PENDINGAwaiting candidate selection
ACCEPTEDCandidate selected a slot
DECLINEDCandidate declined
EXPIREDOffer expired (hourly cron)

Busy Time Detection

When generating available interview slots, the system checks for scheduling conflicts by querying existing InterviewBooking records in the database. A time slot is marked as unavailable if it overlaps with any existing booking where:

  • The user is an attendee or the interviewer
  • The booking status is PENDING, CONFIRMED, or RESCHEDULED

This provides internal conflict detection based on interviews already booked through the platform.

Google Calendar

Google Calendar integration for external busy time detection is not yet active (requires OAuth token storage). Currently, only interviews booked within the AIQLick platform are checked for conflicts.

Jitsi Meeting Integration

When an interview is confirmed, a Jitsi room is auto-created at book.aiqlick.com with a unique room name. The meetingUrl stored in the database is a generic room URL without any user-specific JWT — it does not contain anyone's identity.

Attendee roles: INTERVIEWER (moderator), HR_PANEL, OBSERVER, CANDIDATE, RECRUITER

Per-user JWTs (containing the participant's name, email, and moderator status) are generated on-demand rather than stored with the booking. This prevents one participant's identity from leaking to another if the link is shared.

ContextHow the link is generated
In-app "Join Meeting" buttonFrontend calls getMyMeetingLink(meetingId) which calls MeetingService.getAttendeeLink — mints a fresh JWT for the authenticated user
Email confirmations (slot selection)SlotOfferNotificationListener generates a per-attendee JWT via JitsiMeetService.generateAttendeeLink for each recipient
In-app notifications & remindersactionUrl points to the interview page (/interviews/{id}), not a raw Jitsi URL — the user clicks "Join" from the app to get a personalized link
warning

The stored meetingUrl on InterviewBooking and Meeting records is a bare room URL (e.g. https://book.aiqlick.com/aiqlick-interview-abc-xyz-123). Opening it directly without a JWT will either prompt for a display name or be rejected by Jitsi's secure domain — this is by design. Always use getMyMeetingLink for authenticated access.

Interview Reminders

A cron job runs hourly and sends notifications at three intervals before the scheduled time:

WindowTolerance
24 hours before+/- 30 minutes
6 hours before+/- 30 minutes
1 hour before+/- 30 minutes
info

Separate reminder crons exist for interviews and general meetings, each with the same 24h/6h/1h schedule.

Interview Prep Notifications

During the 24-hour reminder window, an additional INTERVIEW_PREP notification is automatically sent to the candidate. This notification:

  • Reminds the candidate to prepare for the interview
  • Includes the job title, company name, and interview date/time
  • Links to the interview page in the app (not a raw Jitsi URL)
  • Passes the candidate's active CV ID (cvId) in the notification metadata

The metadata enables the background-tasks AI agent to generate personalized prep content using:

  • The company profile
  • The job description
  • The candidate's CV

The prep notification respects the user's notifyOnInterviews preference setting.

RSVP System

Attendees can respond to interview invitations:

# Accept
mutation { acceptInterviewInvitation(interviewId: "uuid") }

# Decline with reason
mutation { declineInterviewInvitation(interviewId: "uuid", reason: "Schedule conflict") }

# Tentative
mutation { tentativeInterviewInvitation(interviewId: "uuid", message: "Checking availability") }

Rescheduling and Cancellation

Two mutation paths trigger a reschedule. Both emit interview.rescheduled, which InterviewNotificationListener consumes to notify attendees in-app and by email.

# Explicit reschedule (dedicated UI button — captures reason, increments rescheduleCount)
# SUP-00135 (2026-04-21): duration is now optional on reschedule so 45 → 15/30 min moves persist
mutation {
rescheduleInterviewBooking(input: {
interviewId: "uuid"
newScheduledAt: "2026-02-27T10:00:00Z"
duration: 30 # optional, 5–480 min; omitted ⇒ keeps existing duration
reason: "Interviewer has a conflict"
notifyAttendees: true
}) { id scheduledAt duration rescheduleCount }
}

# Generic interview edit (any form that lets the user pick a new date)
mutation {
updateInterview(updateInterviewInput: {
id: "uuid"
timestamp: "2026-02-27T10:00:00Z"
}) { id timestamp status }
}

# Cancel
mutation {
cancelInterviewBooking(interviewId: "uuid", reason: "Position filled") {
id status cancelReason
}
}

Reschedule notification flow

Both paths pass the same InterviewRescheduledPayload shape (interviewId, bookingId, newScheduledAt, newEndTime, duration, previousScheduledAt, rescheduledBy, meetingUrl, attendees). When duration is supplied, InterviewBookingService.rescheduleBooking() writes it back to both InterviewBooking.duration and the linked Meeting.duration so the notification email, ICS attachment, and UI all reflect the new length (SUP-00135).

ICS calendar attachments (SUP-00136, 2026-04-21)

Every interview email — scheduled, rescheduled, or cancelled — now carries a .ics attachment built by src/common/mail/utils/ics.util.ts. The file is a minimal RFC 5545 VCALENDAR with:

  • METHOD: REQUEST for scheduled + rescheduled invitations, METHOD: CANCEL for cancellations
  • a stable UID derived from recipient + scheduledAt so Google Calendar / Outlook / Apple Calendar import updates as the same event
  • SEQUENCE: 0 for the first invite, >=1 for reschedules so calendars treat them as updates
  • ATTENDEE lines for every meeting attendee (role REQ-PARTICIPANT, RSVP TRUE)
  • URL + DESCRIPTION carrying the Jitsi join link and any reschedule reason

Attachment plumbing: MailService.queueMail(..., attachments) accepts an array of { filename, contentBase64, contentType? }, stored on the new Email.attachments JSON? column. The Bull mail processor decodes them and passes nodemailer.attachments on the SMTP send.

Known gap — double emails

The handler currently drives both the in-app notification (which auto-queues an email via NotificationDeliveryService when the INTERVIEW_SCHEDULED template has EMAIL in defaultChannels) and the rich calendar email via MailService.sendInterview*Email. Attendees whose userId matches a notification recipient receive two emails per event. Tracked for follow-up; the fix is to either (a) drop EMAIL from the INTERVIEW_SCHEDULED template's defaultChannels or (b) override channels: ['IN_APP'] at the createFromTemplate/createNotification call sites.

Emit guards on updateInterview

InterviewService.update() only emits interview.rescheduled when:

  1. The interview has a linked InterviewBooking (no booking → no attendees → nothing to notify).
  2. The new timestamp is actually different from the stored value — re-saving the same date is a no-op.
  3. The candidate is active (status is not WITHDRAWN or REJECTED). Inactive candidates have their interviews cancelled by the withdrawal cascade instead.

Editing non-timestamp fields (interviewer name, notes, score, etc.) never triggers a reschedule notification.

Listener attendee resolution

InterviewBookingAttendee rows for the candidate are often created with userId = null even when the candidate is a platform user — the attendee list is populated from the booking email invite, not from the linked Talent.User. The listener falls back to Candidate.Talent.User.id for the candidate role so the candidate still receives an in-app notification and email. It also:

  • Skips the rescheduler themselves (no self-notification).
  • Deduplicates recipients so a user who appears both as an attendee and as the linked candidate is notified once.
Candidate-source filter (SUP-00135, 2026-04-17)

The Candidate.source !== 'INVITED' gate is applied only on INTERVIEW_EVENTS.SCHEDULED (initial booking). On RESCHEDULED and CANCELLED the candidate is notified regardless of source (INVITED, DIRECT_APPLICATION, TALENT_POOL, TALENT_POOL_MATCH, MANUAL_ENTRY).

Why: on the initial SCHEDULED event a recruiter may be exploring a candidate without having contacted them yet, so we don't want to surface an unsolicited "you have an interview" notification. Once an InterviewBooking exists and we're changing it, the candidate has already engaged and must know. The earlier listener applied the filter to every interview event, which silently dropped reschedule notifications for anyone who applied directly (see SUP-00135 commits e4363a864125ee650dba7 for the progression).

The filter lives in two places in InterviewNotificationListener:

  • handleInterviewRescheduled — no gate on source (removed in 650dba7)
  • sendInterviewEmails — gate is now input.mode === 'SCHEDULED' && attendee.role === 'CANDIDATE' && candidateSource !== 'INVITED', so the rich calendar email also reaches RESCHEDULED/CANCELLED candidates regardless of source

InterviewBookingService.rescheduleBooking() carries an additional notifyAttendees boolean on the mutation input; updateInterview has no such flag and always notifies when the guards above pass.

Rich calendar-style emails

InterviewNotificationListener delegates email content generation to three helpers on MailServicesendInterviewInvitationEmail, sendInterviewRescheduledEmail, sendInterviewCancelledEmail — all backed by the shared src/common/mail/templates/calendar-invitation.template.ts (the same template used by MeetingNotificationListener). Each email includes:

  • Subject: Rescheduled: <job> at <company> / Interview scheduled: … / Cancelled: …
  • Body: start time formatted in Europe/Stockholm with weekday, duration, job title, company name, candidate/interviewer name
  • Organizer block (first INTERVIEWER attendee, falling back to first HR, then first non-candidate, then AiQlick Team <no-reply@aiqlick.com>)
  • Attendees list
  • Meeting link (see below) as a prominent "Join Interview" button
  • RSVP buttons — Accept / Decline / Tentative (omitted on cancellation)
  • "Previously scheduled for …" line on reschedule emails when previousScheduledAt is set
  • "Updated" / "Cancelled" badge at the top

Jitsi rooms on book.aiqlick.com are JWT-gated per user, so the listener calls JitsiMeetService.generateAttendeeLink(roomName, attendee, { moderator }) to mint a per-recipient token. Candidates are issued non-moderator JWTs; everyone else (INTERVIEWER / HR / OBSERVER / RECRUITER) is minted a moderator JWT. If the JitsiService cannot build a per-attendee link (missing email or room name), the listener falls back to the booking's bare meetingUrl, which will prompt for a display name at Jitsi instead of auto-joining.

The listener lazily calls InterviewBookingService.generateRsvpTokensForBooking(bookingId) before sending. The generator is idempotent — it only creates new tokens for attendees where rsvpToken is null, so existing links in previously-sent emails keep working. Each attendee's email embeds their own token (not a booking-scoped token), so a forwarded email lets the forwardee RSVP only as the original recipient.

The backend routes live at:

RoutePurpose
GET /rsvp/interview/:token/acceptMark response ACCEPTED, redirect to confirmation page
GET /rsvp/interview/:token/declineMark response DECLINED, redirect to confirmation page
GET /rsvp/interview/:token/tentativeMark response TENTATIVE, redirect to confirmation page

The controller looks up InterviewBookingAttendee by the unique rsvpToken field and updates that attendee's responseStatus — the token cannot be used to respond on behalf of a different attendee.

Required environment variables

The listener builds absolute URLs from two env vars and warns (does not fail) when they are missing, falling back to the hardcoded production URLs below. Always set these explicitly in non-prod environments to avoid emails linking from dev/staging into prod.

VariablePurposeFallback
FRONTEND_URLBase URL used for actionUrl (e.g. https://dev.aiqlick.com) — otherwise NotificationDeliveryService strips relative paths with the warning Invalid URL detected and removed: /interviews/…https://aiqlick.com
BACKEND_URLBase URL for RSVP endpoints (/rsvp/interview/…) — must point at the backend that owns the rsvpToken rowhttps://api.aiqlick.com

System-Triggered Cancellation

InterviewBookingService.cancelBooking(userId, interviewId, reason) is the public, authorization-checked entry point for cancellations triggered by a resolver. It calls verifyInterviewAccess and then delegates to a private cascade method.

A second method, InterviewBookingService.cancelBookingFromSystemEvent, performs the same cascade without running the auth check. It exists exclusively so domain event listeners can react to upstream events (e.g., a candidate withdrawal) and tear down related state when there is no recruiter user in scope to satisfy verifyInterviewAccess. The only legitimate caller today is CandidateWithdrawalListener — its JSDoc carries an explicit ⚠️ SYSTEM-ONLY warning so future contributors do not invoke it from a resolver.

Automatic Cancellation on Candidate Withdrawal

When a candidate withdraws their application, every open interview attached to that candidate is cancelled automatically. The cascade is event-driven:

The cascade is best-effort: a failure on one booking is logged but does not stop the others. See Candidate Management → Withdrawal Lifecycle for the full mutation contract and idempotency guarantees.

Access Boundary for Withdrawn / Rejected Candidates

MeetingService.getAttendeeLink refuses to issue a Jitsi join link for any interview-linked meeting whose underlying candidate is WITHDRAWN or REJECTED. This is the actual security boundary:

  • getMeeting (metadata read) — open. Recruiters can still review notes, transcripts, and audit history of cancelled interviews.
  • getAttendeeLink (join-link generation) — gated. Both candidate and recruiter receive ForbiddenException when the candidate is no longer active.

The same active-status filter is applied to every "list interviews / list meetings" query so withdrawn-candidate rows disappear from job-seeker UI feeds even if the cascade listener has not yet flipped the underlying meeting status:

QueryLayer
myInterviewsWHERE Candidate.status NOT IN inactive
jobSeekerUpcomingInterviewsWHERE Candidate.status NOT IN inactive
listUserMeetingsNOT { InterviewBooking.Interview.Candidate.status IN inactive }
listUpcomingMeetingsNOT { InterviewBooking.Interview.Candidate.status IN inactive }
getJobSeekerUpcomingMeetingsNOT { InterviewBooking.Interview.Candidate.status IN inactive }

All five filters import the shared Prisma fragments from candidate-status.constants, so the active/inactive definition stays in one place.

Querying Interviews

# Company upcoming interviews
query {
upcomingInterviews(companyId: "company-uuid", limit: 10) {
id scheduledAt meetingUrl
interview {
candidate { name email }
job { title }
}
}
}

# Job seeker's own interviews (no companyId needed)
query {
myInterviews(input: {
status: [CONFIRMED, RESCHEDULED]
first: 20
}) {
edges {
node {
id scheduledAt status meetingUrl
interview { job { title hiringCompany { companyName } } }
}
}
totalCount
}
}

Public Endpoints (No Auth)

These REST endpoints support the candidate self-scheduling flow:

MethodPathPurpose
GET/interview-scheduling/slot-offer/:tokenView slot offer details
GET/interview-scheduling/select-slot?token=X&slotId=YSelect slot (redirects to confirmation)
POST/interview-scheduling/slot-selectionSelect slot (JSON response)
GET/interview-scheduling/slot-offer-view/:tokenRedirect to frontend slot selection UI

Error reasons on failed slot selection: expired, cancelled, already_selected, not_found, invalid_request

Transcription Access

Interviews conducted via Jitsi can have real-time transcriptions (via Jigasi + Whisper). Access the transcript after the meeting:

query {
transcriptText(meetingId: "jitsi-room-name") {
transcript # Formatted: "[HH:MM:SS] Speaker: text"
segmentCount
}
}

Event System

Key events emitted during the interview lifecycle:

EventTrigger
interview.scheduledTime confirmed, Jitsi link generated
interview.rescheduledTime changed
interview.cancelledInterview cancelled
interview.reminderCron-triggered at 24h/6h/1h
interview.slot_offer.createdSlot offer sent to candidate
interview.slot_offer.selectedCandidate picked a slot
interview.slot_offer.expiredOffer past expiry (hourly cron)