This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS } from '@/lib/positionModel';
|
||||
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
|
||||
import {
|
||||
AVAILABILITY_OPTIONS, defaultEmployeeRole, desiredPayLabel,
|
||||
AVAILABILITY_OPTIONS, desiredPayLabel,
|
||||
} from '@/lib/employeeRoleModel';
|
||||
import {
|
||||
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
|
||||
@@ -31,37 +31,54 @@ const fields = {
|
||||
* and what survives a worker profile being removed. A name with no email
|
||||
* behind it is not an answer, so `parse` refuses one it cannot resolve.
|
||||
*/
|
||||
worker: {
|
||||
label: 'Worker',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Name the worker, or type their email address.',
|
||||
parse: (answer, { workers = [] }) => {
|
||||
const said = String(answer).trim();
|
||||
if (!said) return null;
|
||||
|
||||
/* An email typed outright is the worker, whether or not a profile
|
||||
exists — a role can be recorded before the profile is created. */
|
||||
const email = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.exec(said)?.[0];
|
||||
const lower = said.toLowerCase();
|
||||
|
||||
const match = workers.find((w) => (email
|
||||
? String(w.email || '').toLowerCase() === email.toLowerCase()
|
||||
: String(w.name || '').toLowerCase() === lower))
|
||||
/* A chip carries the full name; a typed answer may be part of one. */
|
||||
|| (!email && workers.find((w) => String(w.name || '').toLowerCase().includes(lower)));
|
||||
|
||||
if (match) {
|
||||
return {
|
||||
worker_profile_id: match.id || null,
|
||||
worker_email: match.email,
|
||||
worker_name: match.name || '',
|
||||
};
|
||||
}
|
||||
return email ? { worker_profile_id: null, worker_email: email, worker_name: '' } : null;
|
||||
/**
|
||||
* The new employee's name.
|
||||
*
|
||||
* A name and nothing more. It is NOT looked up, because a name cannot select
|
||||
* anybody: an organization may employ any number of people who share one, and
|
||||
* the previous version of this field searched the existing workers for a name
|
||||
* match and attached the role to the first hit. With several people of the
|
||||
* same name that silently filed the role against the wrong person; with a
|
||||
* name nobody had, it understood nothing and asked the same question again,
|
||||
* which is the loop this flow was stuck in.
|
||||
*/
|
||||
worker_name: {
|
||||
label: 'Name',
|
||||
settled: (draft) => Boolean(String(draft.worker_name || '').trim()),
|
||||
retry: 'Type the new employee\u2019s full name.',
|
||||
parse: (answer) => {
|
||||
const name = String(answer).trim().replace(/\s+/g, ' ');
|
||||
return name.length >= 2 ? { worker_name: name } : null;
|
||||
},
|
||||
summary: (draft) => (draft.worker_name
|
||||
? `Worker: ${draft.worker_name} (${draft.worker_email})`
|
||||
: `Worker: ${draft.worker_email}`),
|
||||
summary: (draft) => `Name: ${draft.worker_name}`,
|
||||
},
|
||||
|
||||
/**
|
||||
* The new employee's email, which is their identity.
|
||||
*
|
||||
* Asked outright and never derived. There is no rule anywhere that turns a
|
||||
* name into an address, no fallback to the operator's own account, and no
|
||||
* reuse of anything an earlier conversation collected — an invented address
|
||||
* is a real person's record filed under something they do not own.
|
||||
*
|
||||
* `worker_profiles` carries UNIQUE (org_id, email) over a `citext` column, so
|
||||
* this value is what decides whether the person already exists. The check is
|
||||
* the database's, not this field's: two operators recording the same person
|
||||
* at the same moment cannot both win, whatever either browser believed.
|
||||
*/
|
||||
worker_email: {
|
||||
label: 'Email',
|
||||
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
|
||||
retry: 'Type the employee\u2019s email address — it is how the record is identified.',
|
||||
parse: (answer) => {
|
||||
const said = String(answer).trim();
|
||||
/* The whole answer must be the address. Pulling one out of a sentence
|
||||
would accept "I don't know, maybe bob@x.com" as a considered answer. */
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)
|
||||
? { worker_email: said }
|
||||
: null;
|
||||
},
|
||||
summary: (draft) => `Email: ${draft.worker_email}`,
|
||||
},
|
||||
|
||||
role_category: {
|
||||
@@ -174,12 +191,25 @@ const fields = {
|
||||
* `@workers` is the worker profiles the panel has already loaded for this
|
||||
* caller — org-scoped by the API, and nothing here widens that view.
|
||||
*/
|
||||
const resolve = (token, { roles = [], workers = [] }) => {
|
||||
const resolve = (token, { roles = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@workers': return workers.map((w) => w.name).filter(Boolean);
|
||||
case '@roles': return roles;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
/**
|
||||
* Only the certifications that matter for the role just chosen.
|
||||
*
|
||||
* Never the global list. A worker declaring themselves a Chef is asked
|
||||
* about the certifications this organization puts on its Chef postings, and
|
||||
* about nothing else — offering `Guard Card` there is the assistant
|
||||
* inventing a requirement, and a chip is a suggestion the reader is
|
||||
* entitled to read as informed.
|
||||
*
|
||||
* `null` back from the helper means the postings have not arrived; `[]`
|
||||
* means this role genuinely has none. Both produce no chips here, and
|
||||
* neither falls back to a global list — the step is optional, so `Skip` is
|
||||
* offered by the engine either way and the reader can still type one.
|
||||
*/
|
||||
case '@certifications': return certificationsForRole(draft.role_category, postings) || [];
|
||||
case '@availability': return AVAILABILITY_OPTIONS;
|
||||
default: return [];
|
||||
}
|
||||
@@ -296,14 +326,28 @@ export const employeeRoleRegistry = {
|
||||
extract,
|
||||
|
||||
/**
|
||||
* No prefill from the opening request.
|
||||
* No prefill from the opening request, and an EMPTY draft rather than a
|
||||
* defaulted one.
|
||||
*
|
||||
* "Create an employee role" names nobody, and the posting flow's habit of
|
||||
* reading a role out of the request would settle `role_category` from the
|
||||
* word "role" in the phrase that started the conversation. The first question
|
||||
* is who this is about, and it is asked.
|
||||
*
|
||||
* Returning `defaultEmployeeRole()` here — the record's write-time defaults —
|
||||
* was worse than it looks. Every `settled` test asks whether a field HAS a
|
||||
* value, and the defaults give all of them one: `certifications: []` is an
|
||||
* array, `notes: ''` is a string, `experience_years: 0` is a number. So five
|
||||
* of the eight questions were answered before they were asked, and the
|
||||
* conversation went worker → role → pay and stopped. The certification step
|
||||
* could not be reached at all.
|
||||
*
|
||||
* The two are different things wearing the same shape: write-time defaults
|
||||
* are what a MISSING answer becomes, and a conversation must be able to tell
|
||||
* missing from answered. `toEmployeeRolePayload` still applies them at the
|
||||
* write, so nothing is lost by starting empty.
|
||||
*/
|
||||
prefill: () => defaultEmployeeRole(),
|
||||
prefill: () => ({}),
|
||||
|
||||
/**
|
||||
* One verb, because there is one outcome. A declared role has no draft state:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
|
||||
import {
|
||||
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
|
||||
extractLocation, extractPay, extractRole,
|
||||
@@ -143,12 +143,16 @@ const fields = {
|
||||
* provision a TENANT the operator cannot then see, because every read is
|
||||
* predicated on the session's own org_id.
|
||||
*/
|
||||
const resolve = (token, { roles = [], companies = [] }) => {
|
||||
const resolve = (token, { roles = [], companies = [], postings = null }, draft = {}) => {
|
||||
switch (token) {
|
||||
case '@roles': return roles;
|
||||
case '@companies': return companies;
|
||||
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
|
||||
case '@certifications': return CERT_OPTIONS;
|
||||
/* The same rule as the role flow, from the same helper: what this
|
||||
organization already asks for on postings of this category. A position
|
||||
being written for a role it has never posted before offers none, which is
|
||||
honest — there is nothing to go on yet. */
|
||||
case '@certifications': return certificationsForRole(draft.role_category || draft.title, postings) || [];
|
||||
default: return [];
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user