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
This commit is contained in:
2026-09-17 22:52:51 +05:30
parent 64140c7add
commit dca184289e
17 changed files with 1624 additions and 94 deletions

View File

@@ -48,41 +48,19 @@ export function buildHires({ staff = [], applications = [], postings = [] }) {
});
}
/**
* Demo fill, carried over from the page this module was extracted from.
/*
* There is deliberately no demo fill here.
*
* The store seeds three Staff records; Hired History has always padded that to
* eight so the page reads as a hiring history rather than as three rows. That
* padding is pre-existing product behaviour, not something derived — it is kept
* here, named for what it is, so both pages show what the page has always shown
* and there is one list to delete when the deployment has real volume.
* This module used to pad the hires list with five invented people so Hired
* History read as a history rather than as three rows. The padding was applied
* to Analytics too, so "total hires" counted eight where the database held
* three, and no page said which figure was which. A dashboard that quietly
* inflates a count is worse than a sparse one: the sparse page is merely
* disappointing, and the inflated page is wrong in a way nobody can see.
*
* It only ever *adds* people the store does not already have, matched on email,
* so a real hire is never shadowed by a demo one.
* Hires now come only from `staff` — the rows the hire workflow actually
* writes. If a deployment has three hires, every page says three.
*/
const DEMO_FILL = [
{ id: 's4', name: 'Sophia Chen', email: 'sophia.chen@email.com', role: 'Guest Relations Lead', department: 'Front Desk', profile_tier: 'expert', score: 90, timeToHire: 2, hire_date: '2026-07-22', status: 'hired' },
{ id: 's5', name: 'Oliver Bennett', email: 'oliver.b@email.com', role: 'Event Coordinator', department: 'Event Manager', profile_tier: 'skilled', score: 91, timeToHire: 3, hire_date: '2026-07-20', status: 'hired' },
{ id: 's6', name: 'Aaliyah Patel', email: 'aaliyah.p@email.com', role: 'Operations Supervisor', department: 'Housekeeping', profile_tier: 'solid', score: 87, timeToHire: 2, hire_date: '2026-07-18', status: 'hired' },
{ id: 's7', name: 'Lucas Wright', email: 'lucas.w@email.com', role: 'Concierge Lead', department: 'Front Desk', profile_tier: 'solid', score: 88, timeToHire: 2, hire_date: '2026-07-15', status: 'hired' },
{ id: 's8', name: 'Elena Rostova', email: 'elena.r@email.com', role: 'Lead Security Officer', department: 'Security', profile_tier: 'expert', score: 95, timeToHire: 1, hire_date: '2026-07-12', status: 'hired' },
];
/** The joined hires, with the demo fill applied for anyone not already on file. */
export function hiresWithFill(sources) {
const live = buildHires(sources);
const seen = new Set(live.map((h) => String(h.email || h.name).toLowerCase()));
const filled = [...live];
for (const person of DEMO_FILL) {
const key = String(person.email || person.name).toLowerCase();
if (!seen.has(key)) {
filled.push({ ...person, company: person.company || '—' });
seen.add(key);
}
}
return filled;
}
/** Headline figures: volume, speed, quality, and how many are still on. */
export function summarise(hires) {
@@ -116,6 +94,86 @@ export const APPLICATION_STATUSES = [
/** Counted as hired. `assigned` is hired and then rostered, not a separate fate. */
export const HIRED_STATUSES = ['hired', 'assigned'];
/** Terminal fates. Neither is awaiting anybody's decision. */
export const DECIDED_STATUSES = [...HIRED_STATUSES, 'rejected'];
/**
* The applications that belong to one talent-pool person.
*
* Two ways in, in order of trust. `worker_profile_id` is the foreign key and is
* set whenever an application is filed from the pool. Email is the fallback,
* because rows written before that link existed carry only an address — and
* within an organization an address already identifies a person, which is what
* `worker_profiles`' own `UNIQUE (org_id, email)` asserts. It is a weaker claim
* than a key, so it is second, and it is here rather than in two pages so both
* agree about who somebody is.
*/
export const applicationsForProfile = (applications = [], profile) => {
if (!profile) return [];
const email = String(profile.email || '').toLowerCase();
return applications.filter((a) => (
(a.worker_profile_id && a.worker_profile_id === profile.id)
|| (email && String(a.email || '').toLowerCase() === email)
));
};
/**
* Whether an interview actually took place for this application.
*
* Read from the interview rows rather than from `job_applications.interview_id`,
* for two reasons. `ai_interviews.application_id` is a real NOT NULL foreign
* key, while `interview_id` is a deliberately unconstrained soft reference the
* schema's own migration notes can dangle. And `status = 'interview'` is set
* both when an interview is *arranged* and when one is *completed* — only the
* interview row distinguishes the two.
*/
export const hasInterview = (application, interviews = []) =>
interviews.some((i) => i.application_id === application?.id);
/**
* Final selection: this person did the process for this position, and a human
* has not yet decided.
*
* Derived rather than stored. `application_status` has no `final_selection`
* value and this deliberately does not add one: "the interview happened" is
* already a fact in the database, so a status flag would be a second copy of it
* that somebody has to remember to write. Deriving also means the rule can
* change without a migration, which matters while the funnel is still settling.
*
* The interview verdict is deliberately NOT a gate here — a `no` verdict still
* belongs in front of a person, because the decision is theirs to make.
*/
export const isFinalSelection = (application, interviews = []) =>
Boolean(application)
&& !DECIDED_STATUSES.includes(application.status)
&& hasInterview(application, interviews);
/** Everyone awaiting a decision, newest-scored first. */
export const finalSelection = (applications = [], interviews = []) =>
applications
.filter((a) => isFinalSelection(a, interviews))
.sort((a, b) => (b.ai_score || 0) - (a.ai_score || 0));
/**
* Seats filled on a position — counted from the employment records, never
* stored.
*
* There is no `filled` column on `job_postings` and this does not invent one: a
* counter is a second source of truth, and the day it disagrees with the `staff`
* rows there is no way to tell which is lying. Someone who has left
* (`status: 'inactive'`) is not occupying a seat, so the role is open again.
*/
export const filledFor = (postingId, staff = []) =>
staff.filter((s) => s.job_posting_id === postingId && s.status !== 'inactive').length;
/** Seats still open. Never negative — over-hiring is a fact, not a negative. */
export const remainingFor = (posting, staff = []) =>
Math.max(0, (posting?.headcount ?? 1) - filledFor(posting?.id, staff));
/** Whether this position has every seat taken. */
export const isFullyStaffed = (posting, staff = []) =>
Boolean(posting) && remainingFor(posting, staff) === 0;
/**
* How far a candidate got.
*

102
src/lib/humanInterviews.js Normal file
View File

@@ -0,0 +1,102 @@
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';
};

View File

@@ -6,6 +6,7 @@ import { request } from '@/api/httpClient';
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
import { recalcProfilePatch } from './krowScore';
import { logActivity } from './userTracking';
import { CANCELLED, COMPLETED, NO_SHOW, SCHEDULED } from './humanInterviews';
import { evaluateChallenge } from './provingGround';
/**
@@ -705,6 +706,11 @@ export function useAssignWorkers() {
entry.application_id = application.id;
} else {
entry.application = {
/* Same link the position page files: without it a worker assigned
straight from the pool arrives as an application belonging to
nobody, and the hire it may later become cannot be traced back
to the profile it came from. */
worker_profile_id: worker.profile?.id,
applicant_name: worker.name,
email: worker.email || '',
phone: worker.profile?.phone || '',
@@ -787,6 +793,179 @@ export function useMarkInterviewReady() {
});
}
/**
* Schedule a human interview.
*
* Two writes, and the order matters. First the status transition, through
* `useMarkInterviewReady` — the existing mechanism, unchanged, which is what
* makes a candidate interview-ready everywhere in the product. Then the booking
* itself as a `user_activity` row carrying type, date, time and notes.
*
* WHAT THIS DELIBERATELY DOES NOT DO
*
* It does not create an `ai_interviews` row. That row is the durable evidence
* an interview was COMPLETED, and writing one here would mean every candidate
* merely booked in appeared in the final-selection queue — a hiring decision
* put in front of a recruiter for someone nobody has met yet. Scheduling is not
* completion, and the two writes are kept apart so nothing can confuse them.
*
* The booking goes through `base44.entities.UserActivity.create` rather than
* `logActivity`, which swallows its own failures on purpose so that tracking
* cannot break a user flow. That is the right call for tracking and the wrong
* one here: the operator typed a date and needs to be told if it did not save.
*/
export function useScheduleHumanInterview() {
const queryClient = useQueryClient();
const markInterviewReady = useMarkInterviewReady();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position, type, date, time, notes }) => {
if (!application?.id) throw new Error('No application to schedule against');
if (!date || !time) throw new Error('An interview needs a date and a time');
await markInterviewReady.mutateAsync({ application, position });
const when = `${date} ${time}`;
return base44.entities.UserActivity.create({
event_type: SCHEDULED,
details: `${application.applicant_name} booked for a ${type || 'video'} interview on ${when}`,
application_id: application.id,
position_id: position?.id || application.job_posting_id,
metadata: {
interview_type: type || 'video',
scheduled_date: date,
scheduled_time: time,
notes: notes || '',
},
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
/**
* The line that says a human interview actually happened.
*
* The record goes in `ai_interviews` because that is where KROW keeps completed
* interviews, and it is currently reused as the durable completed-interview
* record for BOTH the AI flow and this one. The name is wrong for half of what
* it now holds; renaming it is a migration and deliberately out of scope, so
* this comment stands in for it. What matters is the invariant the table
* enforces either way: a row exists if and only if an interview was completed,
* which is what `isFinalSelection` reads and why it needed no change.
*
* NOTHING ABOUT AI IS INVENTED HERE. No transcript, no AI score, no AI verdict,
* no hire recommendation, no integrity score, no category scores, no flags —
* every one of those columns is left to its schema default rather than filled
* with a plausible-looking number that no machine produced. `messages` stays
* the empty array it defaults to, which is also the structural tell that no AI
* conducted this: an AI interview always carries its transcript.
*
* `overall_interview_score` is omitted rather than sent as 0, and that is not a
* cosmetic choice. `WorkflowService.CreateInterview` copies the score onto the
* application only when the request mentions it, so omitting it leaves a real
* screening score intact instead of overwriting it with a zero this interview
* never measured.
*
* The interviewer's own assessment is optional, human-entered, and recorded as
* what it is. It sorts and informs; it decides nothing. Hiring stays an
* explicit recruiter action on a separate control.
*/
const HUMAN_INTERVIEW_NOTE = 'Human interview — recorded by the hiring team, not conducted by AI.';
export function useCompleteHumanInterview() {
const queryClient = useQueryClient();
const createInterview = useCreateInterview();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position, assessment, notes }) => {
if (!application?.id) throw new Error('No application to record an interview against');
if (!application.job_posting_id) throw new Error('That application is not attached to a position');
/** @type {any} */
const record = {
application_id: application.id,
job_posting_id: application.job_posting_id,
/* Denormalised the same way the AI path denormalises them, and factual
either way: this is who was interviewed, for what. */
job_title: application.job_title || position?.title || '',
candidate_name: application.applicant_name || '',
summary: notes?.trim() ? `${HUMAN_INTERVIEW_NOTE} ${notes.trim()}` : HUMAN_INTERVIEW_NOTE,
};
/* Only when a person actually chose one. Left unsent, the column takes
its schema default — which is the database's word, not ours. */
if (assessment) record.verdict = assessment;
/* The existing hook, so there is one interview client rather than two.
It transactionally sets the application to `interview` and links
`interview_id`, and it logs its own `start_interview` event — noise on
this path, and not worth changing a shared write to silence. */
const interview = await createInterview.mutateAsync(record);
await base44.entities.UserActivity.create({
event_type: COMPLETED,
details: `${application.applicant_name} completed a human interview`,
application_id: application.id,
position_id: position?.id || application.job_posting_id,
interview_id: interview?.id,
metadata: { conducted_by: 'human', assessment: assessment || '' },
});
return interview;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
/**
* An interview that was booked and did not happen.
*
* Cancelled, or nobody came. Both write an event and NOTHING else: no interview
* record, because no interview took place; no new application status, because
* the enum has no value for this and inventing one would put a second funnel
* beside the real one. The application stays where it was, which is honest —
* it is still at interview stage, still waiting for an interview to happen.
*
* The consequence is the correct one and worth saying out loud: with no
* interview row, the candidate cannot reach final selection. A no-show is not a
* decision, and this does not make one.
*/
export function useRecordInterviewNotHeld() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, position, outcome, notes }) => {
if (!application?.id) throw new Error('No application to record against');
if (outcome !== CANCELLED && outcome !== NO_SHOW) {
throw new Error(`Not an outcome this records: ${outcome}`);
}
return base44.entities.UserActivity.create({
event_type: outcome,
details: outcome === NO_SHOW
? `${application.applicant_name} did not attend the interview`
: `Interview with ${application.applicant_name} was cancelled`,
application_id: application.id,
position_id: position?.id || application.job_posting_id,
metadata: { notes: notes || '' },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
queryClient.invalidateQueries({ queryKey: owliverContextKey });
},
});
}
export function useLearningPaths() {
return useQuery({ queryKey: ['learningPaths'], queryFn: () => base44.entities.LearningPath.list('-created_date', 100) });
}