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.
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
| Status | Description |
|---|---|
PENDING | Awaiting confirmation |
CONFIRMED | Interview confirmed with time |
RESCHEDULED | Time changed |
IN_PROGRESS | Currently happening |
COMPLETED | Finished |
CANCELLED | Cancelled |
NO_SHOW | Participant did not attend |
Interview Status
| Status | Description |
|---|---|
PENDING | Created, not yet scheduled |
SCHEDULED | Time confirmed |
IN_PROGRESS | Currently happening |
COMPLETED | Finished |
EVALUATED | Results scored |
CANCELLED | Cancelled |
NO_SHOW | No-show recorded |
Slot Offer Status
| Status | Description |
|---|---|
PENDING | Awaiting candidate selection |
ACCEPTED | Candidate selected a slot |
DECLINED | Candidate declined |
EXPIRED | Offer 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, orRESCHEDULED
This provides internal conflict detection based on interviews already booked through the platform.
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
Meeting Link Security
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.
| Context | How the link is generated |
|---|---|
| In-app "Join Meeting" button | Frontend 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 & reminders | actionUrl points to the interview page (/interviews/{id}), not a raw Jitsi URL — the user clicks "Join" from the app to get a personalized link |
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:
| Window | Tolerance |
|---|---|
| 24 hours before | +/- 30 minutes |
| 6 hours before | +/- 30 minutes |
| 1 hour before | +/- 30 minutes |
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: REQUESTfor scheduled + rescheduled invitations,METHOD: CANCELfor cancellations- a stable
UIDderived from recipient +scheduledAtso Google Calendar / Outlook / Apple Calendar import updates as the same event SEQUENCE: 0for the first invite,>=1for reschedules so calendars treat them as updatesATTENDEElines for every meeting attendee (role REQ-PARTICIPANT, RSVP TRUE)URL+DESCRIPTIONcarrying 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.
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:
- The interview has a linked
InterviewBooking(no booking → no attendees → nothing to notify). - The new
timestampis actually different from the stored value — re-saving the same date is a no-op. - The candidate is active (
statusis notWITHDRAWNorREJECTED). 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.
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 e4363a8 → 64125ee → 650dba7 for the progression).
The filter lives in two places in InterviewNotificationListener:
handleInterviewRescheduled— no gate on source (removed in650dba7)sendInterviewEmails— gate is nowinput.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 MailService — sendInterviewInvitationEmail, 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/Stockholmwith weekday, duration, job title, company name, candidate/interviewer name - Organizer block (first
INTERVIEWERattendee, falling back to first HR, then first non-candidate, thenAiQlick 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
previousScheduledAtis set - "Updated" / "Cancelled" badge at the top
Per-attendee meeting links
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.
RSVP deep links
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:
| Route | Purpose |
|---|---|
GET /rsvp/interview/:token/accept | Mark response ACCEPTED, redirect to confirmation page |
GET /rsvp/interview/:token/decline | Mark response DECLINED, redirect to confirmation page |
GET /rsvp/interview/:token/tentative | Mark 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.
| Variable | Purpose | Fallback |
|---|---|---|
FRONTEND_URL | Base 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_URL | Base URL for RSVP endpoints (/rsvp/interview/…) — must point at the backend that owns the rsvpToken row | https://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 receiveForbiddenExceptionwhen 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:
| Query | Layer |
|---|---|
myInterviews | WHERE Candidate.status NOT IN inactive |
jobSeekerUpcomingInterviews | WHERE Candidate.status NOT IN inactive |
listUserMeetings | NOT { InterviewBooking.Interview.Candidate.status IN inactive } |
listUpcomingMeetings | NOT { InterviewBooking.Interview.Candidate.status IN inactive } |
getJobSeekerUpcomingMeetings | NOT { 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:
| Method | Path | Purpose |
|---|---|---|
| GET | /interview-scheduling/slot-offer/:token | View slot offer details |
| GET | /interview-scheduling/select-slot?token=X&slotId=Y | Select slot (redirects to confirmation) |
| POST | /interview-scheduling/slot-selection | Select slot (JSON response) |
| GET | /interview-scheduling/slot-offer-view/:token | Redirect 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:
| Event | Trigger |
|---|---|
interview.scheduled | Time confirmed, Jitsi link generated |
interview.rescheduled | Time changed |
interview.cancelled | Interview cancelled |
interview.reminder | Cron-triggered at 24h/6h/1h |
interview.slot_offer.created | Slot offer sent to candidate |
interview.slot_offer.selected | Candidate picked a slot |
interview.slot_offer.expired | Offer past expiry (hourly cron) |