149 lines
5.2 KiB
JavaScript
149 lines
5.2 KiB
JavaScript
import { request } from '../lib/apiClient'
|
|
import { toDate } from '../lib/format'
|
|
|
|
/* ============================================================
|
|
interviews.js — backend/job/app.py `/interview/*`.
|
|
|
|
THREE READ MODES on one endpoint, selected by which params are present
|
|
(backend/job/app.py::fetch_interview):
|
|
interview_id -> one row, bare object
|
|
inbox_id -> every interview on one application, list
|
|
range -> from_date / to_date / status / top
|
|
|
|
Range mode only engages when at least ONE of from_date, to_date, status or
|
|
top is set. With none of them the route raises 400 "interview_id or inbox_id
|
|
is required" — so `list()` always sends `top`, and the screens never call it
|
|
bare.
|
|
|
|
Permissions are interviews.* OR candidates.* (either tag is enough). A custom
|
|
role with only the interviews_tab bundle can list/schedule here without
|
|
candidates.view. Recruiter / hiring_manager still pass via candidates.*.
|
|
============================================================ */
|
|
|
|
/** Status vocabulary. `interview_status` is a free-text column, so this file is
|
|
the only place the spelling is decided; writes and filters share it. */
|
|
export const INTERVIEW_STATUSES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
|
|
|
|
/** Round vocabulary — same story: free text, pinned here. */
|
|
export const INTERVIEW_TYPES = [
|
|
'Phone Screen',
|
|
'Technical',
|
|
'System Design',
|
|
'Onsite Loop',
|
|
'Hiring Manager',
|
|
'Culture Fit',
|
|
'Final Round',
|
|
]
|
|
|
|
/** Range read. `top` is always sent so the route takes the range branch. */
|
|
export function listRange({ fromDate, toDate, status, recruiterId, top = 200, skip } = {}) {
|
|
return request('/interview/fetch', {
|
|
params: {
|
|
from_date: fromDate,
|
|
to_date: toDate,
|
|
status,
|
|
recruiter_id: recruiterId,
|
|
top,
|
|
skip,
|
|
},
|
|
})
|
|
}
|
|
|
|
export function listByInbox(inboxId) {
|
|
return request('/interview/fetch', { params: { inbox_id: inboxId } })
|
|
}
|
|
|
|
/**
|
|
* Schedule one interview against an APPLICATION (inbox.id), not a candidate:
|
|
* `inbox_id` is the only link the table has, so a candidate with no inbox row
|
|
* (a manual upload) cannot be scheduled through this endpoint at all.
|
|
*
|
|
* interview_date and interview_time are both `datetime` columns, so the same
|
|
* instant goes to each rather than inventing a second one — the same rule the
|
|
* candidate profile's Interview tab already follows.
|
|
*/
|
|
export function create({ inboxId, instant, type, status }) {
|
|
return request('/interview/create', {
|
|
method: 'POST',
|
|
body: {
|
|
inbox_id: inboxId,
|
|
interview_date: instant,
|
|
interview_time: instant,
|
|
interview_type: type,
|
|
interview_status: status,
|
|
},
|
|
})
|
|
}
|
|
|
|
/** Partial update. Only the keys present are written (exclude_unset server-side). */
|
|
export function update(interviewId, { instant, type, status } = {}) {
|
|
const body = {}
|
|
if (instant != null) {
|
|
body.interview_date = instant
|
|
body.interview_time = instant
|
|
}
|
|
if (type != null) body.interview_type = type
|
|
if (status != null) body.interview_status = status
|
|
return request('/interview/update', {
|
|
method: 'PATCH',
|
|
params: { interview_id: interviewId },
|
|
body,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* API row -> what the Interviews table, the Calendar grid and the Up Next rail
|
|
* render.
|
|
*
|
|
* serialize_interview returns the interview columns plus optional calendar sync
|
|
* fields (`graph_event_id`, `web_link`), `job_title`, and `user_id` (inbox.user
|
|
* id, falling back to the denorm column). Meeting mode, duration, interviewer
|
|
* list and feedback verdict still have no source — they stay absent rather than
|
|
* defaulted. Screens that already hydrate `jobTitle` from the application row
|
|
* keep doing so as a fallback; `userId` prefers the interview row so unassigned
|
|
* applications still open a profile.
|
|
*/
|
|
export function toInterviewView(row) {
|
|
const whenRaw = row.interview_date || row.interview_time
|
|
const when = toDate(whenRaw)
|
|
return {
|
|
id: row.id,
|
|
inboxId: row.inbox_id,
|
|
candidate: row.candidate_name || 'Unknown candidate',
|
|
type: row.interview_type || 'Interview',
|
|
status: row.interview_status || 'Scheduled',
|
|
when: when && !Number.isNaN(when.getTime()) ? when : null,
|
|
jobTitle: row.job_title || null,
|
|
userId: row.user_id ?? null,
|
|
graphEventId: row.graph_event_id || null,
|
|
webLink: row.web_link || null,
|
|
}
|
|
}
|
|
|
|
/** Create an Outlook event for an existing interview row (30 min default). */
|
|
export function createCalendarEvent(interviewId, { durationMinutes = 30 } = {}) {
|
|
return request(`/interview/${interviewId}/calendar-event`, {
|
|
method: 'POST',
|
|
body: { duration_minutes: durationMinutes },
|
|
})
|
|
}
|
|
|
|
/** Move the Outlook event; also updates the interview row times server-side. */
|
|
export function rescheduleCalendarEvent(interviewId, { instant, durationMinutes = 30 }) {
|
|
return request(`/interview/${interviewId}/calendar-event/reschedule`, {
|
|
method: 'PATCH',
|
|
body: {
|
|
instant,
|
|
duration_minutes: durationMinutes,
|
|
},
|
|
})
|
|
}
|
|
|
|
/** Cancel the Outlook event and clear graph_event_id / web_link on the row. */
|
|
export function cancelCalendarEvent(interviewId, { comment } = {}) {
|
|
return request(`/interview/${interviewId}/calendar-event/cancel`, {
|
|
method: 'POST',
|
|
body: comment != null ? { comment } : {},
|
|
})
|
|
}
|