Authored in a parallel session alongside the TypeScript migration; committed separately so the two never share a commit. No TypeScript migration file is included here. Candidates becomes the queue of hiring decisions waiting on a person, rather than a second Talent Pool listing every application the org ever took. Final selection is DERIVED - there is no `final_selection` value in the `application_status` enum and none is added. The fact it reads is the existence of an interview row, a NOT NULL foreign key, rather than `job_applications.interview_id`, which the schema keeps as an unconstrained soft reference precisely so it may dangle. `status = 'interview'` is set both when an interview is arranged and when one is completed, so status alone cannot tell a queue of people who have been interviewed from a queue of people merely booked in. Seats on a position are counted from the employment records instead of a stored column. A `filled` counter would be a second source of truth, and the day it disagreed with `staff` nothing could say which was lying. Someone who has left frees their seat, and over-hiring floors at zero rather than going negative. `DEMO_FILL` is gone. `hiringRecords.js` padded the hires list with five invented people so Hired History read as a history rather than as three rows; the padding reached Analytics too, where "total hires" counted eight against a database holding three. Hires now come only from `staff`. Both paths that file an application on somebody's behalf now carry `worker_profile_id`, the link back to the talent-pool record. The column is nullable, so omitting it saved cleanly and failed silently: the application belonged to an email address rather than to a person, and the hire it became could not be traced back to the profile it came from. `HiredChronology` used to `return null` with no hires, taking the `chronology` node identity out of the DOM with it - so on an honest empty dataset the section could not be addressed by Owliver or the layout editor at all. It now renders an empty state inside the section it keeps. Nine new checks cover the above; `npm test` reports 1684/1691. SIX SSR PARITY CHECKS FAIL ON PURPOSE, and `scripts/__baseline__/README.md` documents each with verified tag counts. Five are the `DEMO_FILL` removal: the Hired History and Analytics baselines were captured while the padding was in effect and, because they render with queries disabled, the padding is all they contain. The sixth is this change to what Candidates says. Do not regenerate those baselines to clear them - two of the checks exist to prove the UI node tree migration added exactly two `<div>`s, and that proof needs the baseline to be pre-migration markup. Recapturing now would write post-migration markup into a file named `pre-migration` and the check would compare the current render against itself forever. The debt is held until the migration work lands, when both files are recaptured together. The seventh failure, the stale backend seed fixture, predates all of this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
103 lines
4.1 KiB
JavaScript
103 lines
4.1 KiB
JavaScript
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';
|
|
};
|