import { hasInterview } from '@/lib/hiringRecords'; /** * Human interviews, read back from the record KROW already keeps. * * A human interview has three moments — booked, held, and whatever happened * instead — and until now none of them survived the modal that collected them. * This module is the read half of fixing that. The write half is in * `krowHooks.js`; what it writes are `user_activity` rows, which is the * organization's append-only log and already carries `application_id`, * `interview_id` and a `metadata` object per event. * * WHAT COUNTS AS A COMPLETED INTERVIEW, AND WHAT DOES NOT * * Nothing in this file decides that. Completion is `hasInterview` — the * existence of an `ai_interviews` row whose `application_id` is a NOT NULL * foreign key — exactly as it was before Step 7, and `isFinalSelection` is * untouched. The events below record a booking and its outcome; they are never * the proof that an interview happened. * * That split is the whole safety property. `logActivity` is fire-and-forget by * design, the log has no uniqueness constraint, and its list endpoint is capped * at 500 rows — none of which is a foundation for a hiring decision. So a * booking may be lost without a candidate being wrongly advanced, because a * booking never advances anyone. Only the interview row does, and that is * written transactionally. */ export const SCHEDULED = 'interview_scheduled'; export const COMPLETED = 'interview_completed'; export const CANCELLED = 'interview_cancelled'; export const NO_SHOW = 'interview_no_show'; /** Every event type this module writes or reads. */ export const INTERVIEW_EVENTS = [SCHEDULED, COMPLETED, CANCELLED, NO_SHOW]; /** The outcomes an interviewer can record against a booking. */ export const OUTCOMES = [COMPLETED, NO_SHOW, CANCELLED]; const time = (event) => { const at = Date.parse(event?.created_date ?? ''); return Number.isNaN(at) ? 0 : at; }; /** * This application's interview events, newest first. * * The list arrives sorted by the server, but it is re-sorted here rather than * trusted: a caller may hand over a filtered or merged array, and "newest" * decides which booking is the standing one. */ export const interviewEventsFor = (application, activity = []) => { if (!application?.id) return []; return activity .filter((e) => e?.application_id === application.id && INTERVIEW_EVENTS.includes(e.event_type)) .slice() .sort((a, b) => time(b) - time(a)); }; /** * The booking that currently stands, or null. * * A booking stands until something later supersedes it — held, cancelled, or * not attended. Reading the newest event first and stopping at the first one * that is not a booking is what makes rescheduling work without a second * record to keep in step. */ export const scheduledInterview = (application, activity = []) => { const [latest] = interviewEventsFor(application, activity); return latest?.event_type === SCHEDULED ? latest : null; }; /** What the booking said: type, date, time, notes. */ export const scheduleDetails = (event) => { const m = event?.metadata || {}; return { type: m.interview_type || '', date: m.scheduled_date || '', time: m.scheduled_time || '', notes: m.notes || '', }; }; /** * Where this application's human interview has got to. * * `completed` is answered by the interview row and nothing else, so it stays * true even if the log is trimmed, lost, or never written. The other three are * log-derived and advisory — none of them can put a candidate in front of a * hiring decision, and none of them can keep one out of it. */ export const humanInterviewState = (application, activity = [], interviews = []) => { if (hasInterview(application, interviews)) return 'completed'; const [latest] = interviewEventsFor(application, activity); if (!latest) return 'none'; if (latest.event_type === SCHEDULED) return 'scheduled'; if (latest.event_type === CANCELLED) return 'cancelled'; if (latest.event_type === NO_SHOW) return 'no_show'; /* A completion event with no interview row behind it. The row is the fact; the event is a note about it. Treated as not completed, deliberately. */ return 'none'; };