import type { JobPosting } from '@/types/entities'; /** * Two shapes reach this module, and they are not the same. * * `PositionRecord` is a posting the API has returned. Fields carry the types * the backend's column registry declares. `Partial`, because every function * takes `= {}` and guards each field — those defaults render labels on a page * that mounts before the posting query resolves, so requiring the whole record * would force them out, which is a behaviour change. * * `PositionDraft` is a form in progress. Its numbers are still strings, which * is not a guess: `toPositionPayload` coerces all five with `Number(...)` and * treats `duration_months === ''` as "not stated". Only that function takes a * draft; the label functions below are given saved postings. * * `certifications_required` is a real `text[]` column and arrives as * `string[]`. `skill_requirements` and `vetting_criteria` are `jsonb`, typed * `unknown` by the registry; this module reads through neither. */ type PositionRecord = Partial; type PositionDraft = Omit & { pay_range_min?: number | string; pay_range_max?: number | string; min_experience_years?: number | string; headcount?: number | string; duration_months?: number | string | null; }; /** * The position record, as one definition. * * Create Position (the form) and Owliver (the conversation) are two ways into * the same record, and the field names, option lists and defaults have to be the * same in both — a category the form offers and the conversation does not is a * position that can only be created one way. So the shape lives here and both * read it, rather than each carrying its own copy. * * Nothing in this file talks to the store. Creating a position is still * `useCreateJobPosting`, in both paths. */ /** * Which certifications matter for a role, from what this organization has * actually asked for. * * THE SOURCE OF TRUTH, and it is worth being explicit about why it is this one. * The schema has no role-to-certification relationship at all: `role_categories` * and `certifications` are both bare `(id, org_id, name)` lists with nothing * joining them. So relevance cannot be looked up — but it can be OBSERVED, and * the observation is real organizational behaviour rather than a guess: * `job_postings` carries `role_category` and `certifications_required` on the * same row, so every posting this organization has written is a statement that * these certifications matter for that role. * * That makes the answer tenant-specific for free. A staffing company that puts * `Guard Card` on its Security postings gets Guard Card; one that does not, * does not. Nothing here carries a list of roles or a list of certifications, * and adding either to the product changes this function's output without * changing this function. * * `postings` is the caller's own already-loaded set, so this widens nobody's * view: the API scoped it before it reached the browser. * * Returns `null` when the postings have not loaded, and `[]` when they have and * the role genuinely has none. Those are different answers — "we do not know * yet" must not render as "there are none" — and the caller is expected to tell * them apart rather than treating both as empty. */ export function certificationsForRole(role, postings) { if (!Array.isArray(postings)) return null; const want = String(role || '').trim().toLowerCase(); if (!want) return []; const found = new Set(); for (const posting of postings) { if (String(posting?.role_category || '').trim().toLowerCase() !== want) continue; for (const cert of posting.certifications_required || []) { const name = String(cert || '').trim(); if (name) found.add(name); } } return [...found]; } /** * Certifications the CREATE POSITION FORM offers, in the order they are offered. * * A fixed list, and it should not be one — the organization keeps its own in * the `certifications` table, which `useCertifications()` already reads and * which nothing in this product currently consults. Against the live tenant * this list is wrong twice over: it offers `ABC License`, which that * organization does not use, and spells `CPR/First Aid` where the record says * `CPR / First Aid`, so the two can never match. * * Left in place deliberately rather than quietly rewired: the form is a * multi-select over a fixed vocabulary and changing its source is a change to * how positions are authored, which is a product decision and not a bug fix. * The conversational flows no longer read it — see `certificationsForRole`. */ export const CERT_OPTIONS = [ 'Food Handler Card', 'ServSafe', 'ABC License', 'TIPS Certified', 'CPR/First Aid', 'Background Check Cleared', ]; /** English levels, stored as the value and shown as the label. */ export const ENGLISH_LEVELS = [ { value: 'basic', label: 'Basic' }, { value: 'conversational', label: 'Conversational' }, { value: 'fluent', label: 'Fluent' }, { value: 'native', label: 'Native' }, ]; /** How each vetting weight reads when it is labelled. */ export const CRITERIA_LABELS = { experience: 'Experience', english: 'English', reliability: 'Reliability', certifications: 'Certifications', availability: 'Availability', }; /** * A blank position. * * A function rather than a constant, so two callers cannot end up sharing — and * mutating — the same `vetting_criteria` or certifications array. */ /** How long a position needs people for, in months. `null` is open-ended. */ export const DURATION_OPTIONS = [ { value: '0.5', label: '2 weeks' }, { value: '1', label: '1 month' }, { value: '3', label: '3 months' }, { value: '6', label: '6 months' }, { value: '12', label: '1 year' }, { value: '', label: '1+ year / ongoing' }, ]; /** Urgency, as the employer states it. Drives allocation priority, not display. */ export const PRIORITY_OPTIONS = [ { value: 'urgent', label: 'Urgent' }, { value: 'high', label: 'High' }, { value: 'normal', label: 'Normal' }, ]; export function defaultPosition() { return { title: '', role_category: 'Server', /* ── Workforce demand ──────────────────────────────────────────────── What the employer is actually asking for. Everything the allocation engine reasons about — who needs people first, whether one person can cover a role for its whole run, whether they are free when it starts — comes from these five fields, so they live on the position record rather than being inferred from prose. */ company: '', /* How many people. One is the common case and the old implicit one, so a position created before this existed still means what it meant. */ headcount: 1, /* ISO date, or '' for "as soon as possible" — which is not the same as today, and the engine treats it as an immediate start. */ start_date: '', /* Months. '' means open-ended, which is a real answer, not a missing one. */ duration_months: '', priority: 'normal', custom_requirements: '', physical_requirements: '', leadership_expectations: '', attendance_expectations: '', min_experience_years: 0, english_required: 'basic', location: '', pay_range_min: '', pay_range_max: '', certifications_required: [], /* What the work requires in verified skill levels — the field the matching engine reads (lib/skillGraph.js). Empty means "no skill requirements defined", and a position with none shows no match score rather than an invented one. */ skill_requirements: [], vetting_criteria: { experience: 25, english: 20, reliability: 20, certifications: 20, availability: 15 }, }; } /** * A draft — from either path — as the record the store is given. * * The two numeric fields are text inputs in the form, so they are coerced here * rather than at each call site. Everything absent falls back to the same * defaults the form starts from. */ export function toPositionPayload(draft: PositionDraft = {}, { status = 'active' }: { status?: string } = {}) { const record = { ...defaultPosition(), ...draft }; return { ...record, status, pay_range_min: Number(record.pay_range_min) || 0, pay_range_max: Number(record.pay_range_max) || 0, min_experience_years: Number(record.min_experience_years) || 0, /* At least one person is being asked for, whatever the field says. */ headcount: Math.max(1, Number(record.headcount) || 1), /* Kept as a number or null rather than a string, because the allocation engine compares it against assignment windows. Null is open-ended. */ duration_months: record.duration_months === '' || record.duration_months == null ? null : (Number(record.duration_months) || null), start_date: record.start_date && String(record.start_date).trim() !== '' ? String(record.start_date).trim() : null, company: String(record.company || '').trim(), }; } /** * "30 people · starts 4 Sep · 1 month" — the demand, in one line. * * Only the parts the record actually states. A position that declares no * headcount, no start and no duration returns `null` and the caller renders * nothing, rather than "1 person · starts immediately · ongoing" — which is * three schema defaults wearing the appearance of an employer's answer. */ export function demandLabel(position: PositionRecord = {}) { const parts = []; const count = Number(position.headcount); if (Number.isFinite(count) && count > 0) { parts.push(`${count} ${count === 1 ? 'person' : 'people'}`); } if (position.start_date) { parts.push(`starts ${new Date(position.start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`); } else if (position.start_date === '') { /* An explicit empty start means "as soon as possible" — a stated answer. An absent field means the question was never asked. */ parts.push('starts immediately'); } const months = position.duration_months; if (months != null) { parts.push( months < 1 ? `${Math.round(months * 4)} weeks` : months === 1 ? '1 month' : months < 12 ? `${months} months` : months === 12 ? '1 year' : `${Math.round(months / 12)}+ years` ); } else if ('duration_months' in position) { parts.push('ongoing'); } return parts.length ? parts.join(' · ') : null; } /** `$30–$40/hr`, or a single rate, or nothing when no pay is set. */ export function payLabel({ pay_range_min: min, pay_range_max: max }: PositionRecord = {}) { const low = Number(min) || 0; const high = Number(max) || 0; if (!low && !high) return null; if (!high || high === low) return `$${low}/hr`; return `$${low}–$${high}/hr`; } /** `fluent` → `Fluent`. Returns null for a level the record does not state. */ export function englishLabel(value) { if (!value) return null; return ENGLISH_LEVELS.find((l) => l.value === value)?.label ?? String(value).replace(/\b\w/, (c) => c.toUpperCase()); } /** * "0 years", "1 year", "3 years" — the number the employer actually entered. * * Zero is an answer, not an absence: a position open to people with no * experience says so, and reading it back as "No minimum" would be this code * rewording the employer rather than reporting them. `null` is returned only * when the field carries nothing at all. */ export function experienceLabel(position: PositionRecord = {}) { const years = position.min_experience_years; /* `=== ''` is cast because the registry types this column `number`, so TypeScript calls the comparison impossible — and against a saved record it is. The guard stays because the value has also arrived from a form, where an untouched numeric input yields ''. Deleting a live guard to satisfy a type would be the type rewriting the code. */ if (years === undefined || years === null || (years as unknown) === '') return null; const n = Number(years); if (!Number.isFinite(n)) return null; return `${n} ${n === 1 ? 'year' : 'years'}`; } /** A single rate as its own value: `$24/hr`, or null when it was not set. */ export function rateLabel(value) { if (value === undefined || value === null || value === '') return null; const n = Number(value); if (!Number.isFinite(n) || n <= 0) return null; return `$${n}/hr`; } /** * The prose expectations the employer wrote, in the order the form asks for them. * * `custom_requirements` is deliberately not in this list: it is a paragraph * rather than a value, and it gets a block of its own — see * `PositionCustomRequirements`. */ export const REQUIREMENT_FIELDS = [ { key: 'physical_requirements', label: 'Physical Requirements' }, { key: 'leadership_expectations', label: 'Leadership Expectations' }, { key: 'attendance_expectations', label: 'Attendance Expectations' }, ]; /** * The requirements this position actually states. * * Only the ones with something in them. A requirement is role-specific by * nature — a warehouse role states what it needs lifted, a server role does not * — so a field left blank is not an omission to be reported, it is that role * saying the question does not apply to it. Rendering an empty "Physical * requirements" card on every position turns a blank answer into a demand, and * makes every role look like the same template. * * The universal fields (title, category, location, pay, experience, English) * behave the opposite way and are handled separately: those are questions every * position answers, so an unanswered one is worth showing as unanswered. */ export function statedRequirements(position: PositionRecord = {}) { return REQUIREMENT_FIELDS .map((field) => ({ ...field, value: String(position[field.key] ?? '').trim() })) .filter((field) => field.value); } /** The custom requirement text, or `''` when none was written. */ export const customRequirementsText = (position: PositionRecord = {}) => String(position.custom_requirements ?? '').trim(); /** * Does this position state anything under Requirements at all? * * The gate a surface uses before drawing the heading — a section title standing * over nothing is worse than no section. */ export const hasRequirements = (position: PositionRecord = {}) => statedRequirements(position).length > 0 || (position.certifications_required || []).length > 0; /** * "Server · San Jose, CA · $24–$34/hr" — the terms of the role in one line. * * Company is deliberately absent: it belongs above the title, not beside the * category. Parts the record does not carry are omitted rather than padded. */ export function roleMetaLine(position: PositionRecord = {}) { return [position.role_category, position.location, payLabel(position)] .filter(Boolean) .join(' · '); }