HR-ATS-Portal/frontend/src/api/interviews.js

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 } : {},
})
}