Files
krow_talent_app/src/lib/humanInterviews.js
Aravind dca184289e feat(hiring): final-selection queue, honest seat counts, and human interviews
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
2026-09-17 22:52:51 +05:30

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';
};