chore(ts-migration): migrate domain logic to TypeScript
Phase 5. Twelve modules under `src/lib` and `src/lib/admin`. All twelve emit
byte-identical JavaScript; eight needed no annotation at all.
Where the generated entity types fit, they are used. `positionModel` is typed
against `JobPosting` — and that is where TypeScript earned its keep. Annotating
the label functions made `experienceLabel`'s `years === ''` guard a comparison
the compiler called impossible, because the registry types
`min_experience_years` as `number`, which is correct for a record the API has
returned. The guard is not dead: the same functions are handed drafts, and an
untouched numeric form input yields `''` — which is why `toPositionPayload`
coerces all five numerics with `Number(...)`.
So the module now has two types rather than one. `PositionRecord` is a saved
posting with the registry's column types; `PositionDraft` widens the five
numerics to `number | string` and is taken by `toPositionPayload` alone. The
one comparison the split cannot express keeps its guard and carries a cast with
the reason written next to it. Deleting a live guard to satisfy a type would be
the type rewriting the code.
`workforce` keeps its records as `any`: 574 lines of demand and availability
arithmetic over profiles, postings, assignments and staff read largely through
jsonb columns the registry does not describe. What IS described is the module's
own contract — the `WorkforceContext` option bag and the `Availability` result,
whose two shapes differ by whether a worker's commitments are known.
Two of my own type declarations were too narrow and were caught by the
set-difference rather than by inspection. `activitySignals`' accumulator seeds
`{ email, name, count, privileged }` and I had named only the two counters;
`WorkforceContext` omitted `profiles` and `courses`, which `PositionDetail`
passes in a single call with three more. The bag now carries an index signature,
because that is what the call site assumes: callers hand the whole thing over
and each function picks what it needs.
`skillGraph` gains a `SkillLevel` interface with an optional `earned`, set in a
second pass that stops at the first incomplete rung — so the levels above the
gap never receive it, and optional is the honest description.
Verified: tsc 40 -> 37, zero introduced; all twelve emitted outputs
byte-identical; npm test 1684/1691 with the same seven failures; lint 0 errors;
build succeeds with the API origin inlined; baseline artifacts untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
361
src/lib/positionModel.ts
Normal file
361
src/lib/positionModel.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
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<JobPosting>;
|
||||
|
||||
type PositionDraft = Omit<PositionRecord,
|
||||
'pay_range_min' | 'pay_range_max' | 'min_experience_years' | 'headcount' | 'duration_months'> & {
|
||||
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(' · ');
|
||||
}
|
||||
Reference in New Issue
Block a user