@ttctl/core 0.1.0-rc.3 → 0.1.0-rc.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -1
- package/dist/__tests__/fixtures/profile/builders.js +2 -0
- package/dist/__tests__/fixtures/profile/builders.js.map +1 -1
- package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -1
- package/dist/__tests__/fixtures/profile/data.js +4 -0
- package/dist/__tests__/fixtures/profile/data.js.map +1 -1
- package/dist/services/applications/index.d.ts +281 -7
- package/dist/services/applications/index.d.ts.map +1 -1
- package/dist/services/applications/index.js +464 -4
- package/dist/services/applications/index.js.map +1 -1
- package/dist/services/jobs/index.d.ts +21 -0
- package/dist/services/jobs/index.d.ts.map +1 -1
- package/dist/services/jobs/index.js +56 -0
- package/dist/services/jobs/index.js.map +1 -1
- package/dist/services/profile/basic/index.d.ts +113 -27
- package/dist/services/profile/basic/index.d.ts.map +1 -1
- package/dist/services/profile/basic/index.js +176 -66
- package/dist/services/profile/basic/index.js.map +1 -1
- package/dist/services/profile/employment/index.d.ts +192 -3
- package/dist/services/profile/employment/index.d.ts.map +1 -1
- package/dist/services/profile/employment/index.js +311 -15
- package/dist/services/profile/employment/index.js.map +1 -1
- package/dist/services/profile/skills/index.d.ts +136 -13
- package/dist/services/profile/skills/index.d.ts.map +1 -1
- package/dist/services/profile/skills/index.js +69 -15
- package/dist/services/profile/skills/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DryRunPreview } from "../../../transport.js";
|
|
1
2
|
/**
|
|
2
3
|
* `Employment` row as ttctl exposes it. Trimmed read-side projection of
|
|
3
4
|
* the `Employment` GraphQL fragment (see
|
|
@@ -38,6 +39,22 @@ export interface Employment {
|
|
|
38
39
|
code: string | null;
|
|
39
40
|
name: string | null;
|
|
40
41
|
} | null;
|
|
42
|
+
/**
|
|
43
|
+
* The catalog id of the resolved employer (#394 — surfaced read-side so
|
|
44
|
+
* `update()` can echo it back through the merge; the wire's
|
|
45
|
+
* `UpdateEmploymentInput` requires `employerId` even when the caller
|
|
46
|
+
* only supplies `position`, and the read-side previously hid the id).
|
|
47
|
+
*/
|
|
48
|
+
employerId: string | null;
|
|
49
|
+
/**
|
|
50
|
+
* Catalog skills attached to the row (#394 — surfaced read-side so
|
|
51
|
+
* `update()` can preserve them through the merge; the wire requires
|
|
52
|
+
* `skills` to be non-empty on update).
|
|
53
|
+
*/
|
|
54
|
+
skills: {
|
|
55
|
+
id: string;
|
|
56
|
+
name: string;
|
|
57
|
+
}[];
|
|
41
58
|
}
|
|
42
59
|
/**
|
|
43
60
|
* Fields editable on an Employment row. Mirrors `EmploymentInput` per the
|
|
@@ -51,9 +68,21 @@ export interface Employment {
|
|
|
51
68
|
* fields (employerId, engagementId, industryIds, managementExperience,
|
|
52
69
|
* primaryGeographyId, reportingTo, skills, …) are exposed at the type
|
|
53
70
|
* level so future leaves can grow without churning callers.
|
|
71
|
+
*
|
|
72
|
+
* `employerId` is the server-side catalog identifier for the employer
|
|
73
|
+
* record (e.g. "V1-Employer-1234"). `add()` requires EITHER an explicit
|
|
74
|
+
* `employerId`, a `company` that resolves to exactly one autocomplete
|
|
75
|
+
* match (see {@link add} for the resolution policy), OR the
|
|
76
|
+
* {@link EmploymentFields.noEmployer} signal for a custom (non-catalog)
|
|
77
|
+
* workplace — which sends `employerId: null` with the free-text
|
|
78
|
+
* `company` verbatim. `employerId` and `noWebsite` are ORTHOGONAL axes
|
|
79
|
+
* (#401): a custom workplace may still carry a website. The earlier
|
|
80
|
+
* "nullable only when noWebsite" note was a single-capture
|
|
81
|
+
* over-inference, not the wire contract.
|
|
54
82
|
*/
|
|
55
83
|
export interface EmploymentFields {
|
|
56
84
|
company?: string;
|
|
85
|
+
employerId?: string;
|
|
57
86
|
position?: string;
|
|
58
87
|
companyWebsite?: string | null;
|
|
59
88
|
noWebsite?: boolean;
|
|
@@ -67,8 +96,63 @@ export interface EmploymentFields {
|
|
|
67
96
|
industryIds?: string[];
|
|
68
97
|
primaryGeographyId?: string | null;
|
|
69
98
|
reportingTo?: string | null;
|
|
70
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Catalog skill refs (wire shape: `SkillRefInput[]` = `{ id, name }[]`,
|
|
101
|
+
* not the `string[]` originally declared — corrected #394 after the
|
|
102
|
+
* live capture showed the live mutation accepts the object form and
|
|
103
|
+
* rejects empty arrays on update).
|
|
104
|
+
*/
|
|
105
|
+
skills?: {
|
|
106
|
+
id: string;
|
|
107
|
+
name: string;
|
|
108
|
+
}[];
|
|
109
|
+
noEmployer?: boolean;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Options accepted by {@link add}. `dryRun` mirrors the option-shape
|
|
113
|
+
* established by `basic.set` (#393 / SetOptions) so the cross-service
|
|
114
|
+
* surface stays uniform — callers that branch on the outcome's `kind`
|
|
115
|
+
* discriminator can use the same code path regardless of which
|
|
116
|
+
* mutation they're invoking.
|
|
117
|
+
*
|
|
118
|
+
* The `dryRun` path fires the employer autocomplete read so the preview
|
|
119
|
+
* shows the resolved `employerId` (not the raw `company` string). This
|
|
120
|
+
* departs from `basic.set`'s zero-network dry-run by design (#395):
|
|
121
|
+
* the alternative — placeholder employerId — would misrepresent the
|
|
122
|
+
* wire shape, since the server-side input requires the resolved id, not
|
|
123
|
+
* the company name. EXCEPTION (#401): the custom-workplace path
|
|
124
|
+
* (`fields.noEmployer === true`) skips resolution entirely, so dry-run
|
|
125
|
+
* there fires ZERO network — `employerId: null` needs no lookup. The
|
|
126
|
+
* mutation transport is still NEVER fired in `dryRun` mode.
|
|
127
|
+
*/
|
|
128
|
+
export interface AddOptions {
|
|
129
|
+
dryRun?: boolean;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Discriminated outcome of an {@link add} call when the apply-path
|
|
133
|
+
* succeeded — the newly created {@link Employment} row.
|
|
134
|
+
*/
|
|
135
|
+
export interface AddOutcomeCreated {
|
|
136
|
+
kind: "created";
|
|
137
|
+
result: Employment;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Discriminated outcome of an {@link add} call invoked with
|
|
141
|
+
* `dryRun: true` — the structured preview of the request that WOULD
|
|
142
|
+
* have been sent. The `employersAutocomplete` read query MAY have been
|
|
143
|
+
* fired during dry-run to resolve `employerId`; the `CreateEmployment`
|
|
144
|
+
* mutation transport was NOT fired.
|
|
145
|
+
*/
|
|
146
|
+
export interface AddOutcomePreview {
|
|
147
|
+
kind: "preview";
|
|
148
|
+
preview: DryRunPreview;
|
|
71
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* Discriminated-union return type for {@link add}. Apply-path callers
|
|
152
|
+
* branch on `outcome.kind === "created"`; dry-run callers branch on
|
|
153
|
+
* `"preview"`. Symmetric with `basic.set`'s {@link SetOutcome} (#393).
|
|
154
|
+
*/
|
|
155
|
+
export type AddOutcome = AddOutcomeCreated | AddOutcomePreview;
|
|
72
156
|
/**
|
|
73
157
|
* Lightweight `Employer` reference returned by
|
|
74
158
|
* `employer-autocomplete`. Mirrors the read-side `Employer` fragment
|
|
@@ -97,12 +181,117 @@ export declare function list(token: string): Promise<Employment[]>;
|
|
|
97
181
|
export declare function show(token: string, id: string): Promise<Employment>;
|
|
98
182
|
/**
|
|
99
183
|
* Create a new employment row. Wire format per Pattern 2: `{ profileId,
|
|
100
|
-
* employment: EmploymentInput }`.
|
|
184
|
+
* employment: EmploymentInput }`.
|
|
185
|
+
*
|
|
186
|
+
* **employerId resolution (#395)**: the live `talent_profile/graphql`
|
|
187
|
+
* server requires `employment.employerId` (the catalog identifier),
|
|
188
|
+
* NOT the free-text `company` string. Pre-#395, `add({company, role,
|
|
189
|
+
* …})` sent only the company string and was rejected with
|
|
190
|
+
* `USER_ERROR: employment add rejected (employerId): You can't leave
|
|
191
|
+
* this empty`. The fix:
|
|
192
|
+
*
|
|
193
|
+
* 1. If `fields.employerId` is supplied → use it verbatim. This is
|
|
194
|
+
* the explicit-bypass path (`--employer-id` on CLI,
|
|
195
|
+
* `employerId` on MCP) — useful for replay scripts, the
|
|
196
|
+
* disambiguation fallback, or known-good ids.
|
|
197
|
+
* 2. Otherwise, fire {@link employerAutocomplete} against
|
|
198
|
+
* `fields.company`. Toptal's autocomplete is fuzzy / prefix-
|
|
199
|
+
* search (typing "Anthropic" returns 10 partial matches), so the
|
|
200
|
+
* practical cardinality is on EXACT NAME MATCH (case-insensitive
|
|
201
|
+
* trim):
|
|
202
|
+
* - exactly 1 exact match → use its id transparently
|
|
203
|
+
* - 2+ exact matches (catalog duplicates — e.g. multiple
|
|
204
|
+
* regional subsidiaries with the same display name) →
|
|
205
|
+
* `VALIDATION_ERROR` listing the duplicates + `--employer-id`
|
|
206
|
+
* nudge
|
|
207
|
+
* - 0 exact, 0 fuzzy → `VALIDATION_ERROR` nudging to the
|
|
208
|
+
* autocomplete CLI command or `--employer-id` bypass
|
|
209
|
+
* - 0 exact, ≥1 fuzzy → `VALIDATION_ERROR` listing the top-N
|
|
210
|
+
* closest candidates with `--employer-id` nudge (the user
|
|
211
|
+
* typed a prefix; surface the catalog's actual names)
|
|
212
|
+
*
|
|
213
|
+
* The static defaults at `experienceItems: []`, `skills: []`,
|
|
214
|
+
* `showViaToptal: true` are retained pre-#395 — they were established
|
|
215
|
+
* empirically via the #344 E2E to satisfy "Expected value to not be
|
|
216
|
+
* null" on those required fields. Reviewing them in this PR would
|
|
217
|
+
* be premature without a fresh live capture; they may be revisited in
|
|
218
|
+
* a follow-up that mirrors the basic.set #393 read-merge pattern for
|
|
219
|
+
* employment.add.
|
|
220
|
+
*
|
|
221
|
+
* **Dry-run path (#395)**: when `options.dryRun === true`, the
|
|
222
|
+
* employer resolution still runs (fires `employersAutocomplete` if
|
|
223
|
+
* `employerId` is absent — except on the #401 custom-workplace path;
|
|
224
|
+
* see **Custom-workplace path (#401)** below) so the preview's
|
|
225
|
+
* `variables.input.employment`
|
|
226
|
+
* carries the resolved `employerId`, matching the wire shape the live
|
|
227
|
+
* mutation would transmit. The `CreateEmployment` mutation transport
|
|
228
|
+
* is NOT invoked. The placeholder
|
|
229
|
+
* {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} stands in for `profileId`
|
|
230
|
+
* (which the apply-path resolves via `extractProfileId`).
|
|
231
|
+
*
|
|
232
|
+
* **Custom-workplace path (#401)**: when `fields.noEmployer === true`,
|
|
233
|
+
* `resolveEmployerId()` is skipped entirely — NO `employersAutocomplete`
|
|
234
|
+
* call in EITHER the apply or dry-run path — and `employerId: null` is
|
|
235
|
+
* sent with the free-text `company`. Mutually exclusive with an explicit
|
|
236
|
+
* `fields.employerId` (→ `VALIDATION_ERROR`). Orthogonal to `noWebsite`.
|
|
237
|
+
*/
|
|
238
|
+
export declare function add(token: string, fields: EmploymentFields, options?: AddOptions): Promise<AddOutcome>;
|
|
239
|
+
/**
|
|
240
|
+
* Placeholder string substituted into a dry-run `UpdateEmployment`
|
|
241
|
+
* preview's variables payload for fields that the apply-path resolves by
|
|
242
|
+
* reading the current row (`experienceItems`, `position`, `skills`,
|
|
243
|
+
* `showViaToptal`, `startDate` — the five required-non-null fields
|
|
244
|
+
* injected by the read-current+merge logic, #394 + #407 for `position`).
|
|
245
|
+
* Surfaced verbatim so MCP consumers can
|
|
246
|
+
* see the structural shape of what will be sent without TTCtl having
|
|
247
|
+
* fired the read transport. Same posture as `basic.set`'s
|
|
248
|
+
* {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} — preserves the zero-transport-
|
|
249
|
+
* in-dry-run invariant (#165 / #379) while honoring #394's AC that the
|
|
250
|
+
* preview shows the full merged shape.
|
|
251
|
+
*/
|
|
252
|
+
export declare const DRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDER: "<resolved at send-time by reading current state>";
|
|
253
|
+
/**
|
|
254
|
+
* Build the merged `EmploymentInput` to send for an `UpdateEmployment`
|
|
255
|
+
* mutation by reading the current row and overlaying user-supplied fields.
|
|
256
|
+
*
|
|
257
|
+
* The `talent_profile/graphql` server treats five `EmploymentInput` fields
|
|
258
|
+
* as required non-null on `UpdateEmployment` and rejects the whole
|
|
259
|
+
* variables payload with `"Expected value to not be null"` when they are
|
|
260
|
+
* absent (#394 + #407 for `position` — wire-broke meta-class #392). The
|
|
261
|
+
* five fields are `experienceItems`, `position`, `showViaToptal`,
|
|
262
|
+
* `startDate`, and `skills`. This helper injects them from the current
|
|
263
|
+
* state where the EMPLOYMENT_FRAGMENT surfaces them (`experienceItems`,
|
|
264
|
+
* `position`, `showViaToptal`, `startDate`) and defaults `skills: []`
|
|
265
|
+
* because the fragment does not currently select the read-side `skills`
|
|
266
|
+
* connection. Other fields are left undefined and omitted from the wire
|
|
267
|
+
* payload — the server keeps the existing value for any field absent
|
|
268
|
+
* from the input (the omission-is-preservation half of the merge
|
|
269
|
+
* contract; only the five required-non-null fields force-echo).
|
|
270
|
+
*
|
|
271
|
+
* **Known limitation (#394)**: `skills` defaults to `[]` because the
|
|
272
|
+
* current read fragment does not surface skills. Calling `update()` on a
|
|
273
|
+
* row that has skills will reset them to empty. A follow-up will extend
|
|
274
|
+
* the fragment to read skills and preserve them through the merge; for
|
|
275
|
+
* the test-account this is acceptable, and the bug fix here is for the
|
|
276
|
+
* minimal `{id, role}` repro that previously failed at the wire layer
|
|
277
|
+
* regardless of skills state.
|
|
278
|
+
*
|
|
279
|
+
* Exported so the MCP layer can build the same merged input for the
|
|
280
|
+
* dry-run preview (AC: "Dry-run preview shows the full merged input").
|
|
281
|
+
*
|
|
282
|
+
* @throws `ProfileError("VALIDATION_ERROR")` when `fields` is empty.
|
|
283
|
+
* @throws `ProfileError("VALIDATION_ERROR")` when `current.startDate` is
|
|
284
|
+
* `null` and the caller did not supply a `startDate` override — the
|
|
285
|
+
* wire requires a non-null `startDate` and we have nothing to send.
|
|
101
286
|
*/
|
|
102
|
-
export declare function
|
|
287
|
+
export declare function buildUpdateEmploymentInput(current: Employment, fields: EmploymentFields): EmploymentFields;
|
|
103
288
|
/**
|
|
104
289
|
* Update an existing employment row. Wire format per Pattern 1:
|
|
105
290
|
* `{ employmentId, employment: EmploymentInput }`.
|
|
291
|
+
*
|
|
292
|
+
* Reads the current row first and merges the five required-non-null
|
|
293
|
+
* fields onto the wire input (see {@link buildUpdateEmploymentInput} for
|
|
294
|
+
* the merge contract and #394 + #407 for the originating wire-broke incidents).
|
|
106
295
|
*/
|
|
107
296
|
export declare function update(token: string, id: string, fields: EmploymentFields): Promise<Employment>;
|
|
108
297
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/services/profile/employment/index.ts"],"names":[],"mappings":"AAOA;;;;;;;;;;;;GAYG;AACH,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,EAAE,OAAO,CAAC;IACnB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,eAAe,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;IACjC,SAAS,EAAE,OAAO,CAAC;IACnB,aAAa,EAAE,OAAO,CAAC;IACvB,aAAa,EAAE,OAAO,CAAC;IACvB,uEAAuE;IACvE,iBAAiB,EAAE,OAAO,GAAG,IAAI,CAAC;IAClC,mDAAmD;IACnD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,sDAAsD;IACtD,UAAU,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC3C,8DAA8D;IAC9D,gBAAgB,EAAE;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/services/profile/employment/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (C) 2026 Oleksii PELYKH
|
|
3
|
-
import { ProfileError } from "../basic/index.js";
|
|
3
|
+
import { DRY_RUN_PROFILE_ID_PLACEHOLDER, ProfileError } from "../basic/index.js";
|
|
4
4
|
import { applyUserErrorsAndSuccess, callTalentProfile, ensureNoTopLevelErrors, extractProfileId } from "../shared.js";
|
|
5
|
+
import { buildDryRunPreview } from "../../../transport.js";
|
|
5
6
|
const EMPLOYMENT_FRAGMENT = `fragment Employment on Employment {
|
|
6
7
|
id
|
|
7
8
|
company
|
|
@@ -18,6 +19,8 @@ const EMPLOYMENT_FRAGMENT = `fragment Employment on Employment {
|
|
|
18
19
|
reportingTo
|
|
19
20
|
industries { nodes { id name } }
|
|
20
21
|
primaryGeography { id code name }
|
|
22
|
+
employer { id }
|
|
23
|
+
skills { nodes { id name } }
|
|
21
24
|
}`;
|
|
22
25
|
const GET_WORK_EXPERIENCE_QUERY = `query GET_WORK_EXPERIENCE($profileId: ID!) {
|
|
23
26
|
profile(id: $profileId) {
|
|
@@ -92,6 +95,12 @@ function mapEmploymentNode(node) {
|
|
|
92
95
|
name: typeof geoRaw.name === "string" ? geoRaw.name : null,
|
|
93
96
|
}
|
|
94
97
|
: null;
|
|
98
|
+
const employerRaw = node["employer"];
|
|
99
|
+
const employerId = employerRaw && typeof employerRaw.id === "string" ? employerRaw.id : null;
|
|
100
|
+
const skillsConn = node["skills"];
|
|
101
|
+
const skills = Array.isArray(skillsConn?.nodes)
|
|
102
|
+
? skillsConn.nodes.flatMap((s) => typeof s.id === "string" && typeof s.name === "string" ? [{ id: s.id, name: s.name }] : [])
|
|
103
|
+
: [];
|
|
95
104
|
const rawItems = node["experienceItems"];
|
|
96
105
|
return {
|
|
97
106
|
id: typeof node["id"] === "string" ? node["id"] : "",
|
|
@@ -111,6 +120,8 @@ function mapEmploymentNode(node) {
|
|
|
111
120
|
reportingTo: node["reportingTo"] ?? null,
|
|
112
121
|
industries,
|
|
113
122
|
primaryGeography,
|
|
123
|
+
employerId,
|
|
124
|
+
skills,
|
|
114
125
|
};
|
|
115
126
|
}
|
|
116
127
|
/**
|
|
@@ -151,27 +162,125 @@ export async function show(token, id) {
|
|
|
151
162
|
}
|
|
152
163
|
/**
|
|
153
164
|
* Create a new employment row. Wire format per Pattern 2: `{ profileId,
|
|
154
|
-
* employment: EmploymentInput }`.
|
|
165
|
+
* employment: EmploymentInput }`.
|
|
166
|
+
*
|
|
167
|
+
* **employerId resolution (#395)**: the live `talent_profile/graphql`
|
|
168
|
+
* server requires `employment.employerId` (the catalog identifier),
|
|
169
|
+
* NOT the free-text `company` string. Pre-#395, `add({company, role,
|
|
170
|
+
* …})` sent only the company string and was rejected with
|
|
171
|
+
* `USER_ERROR: employment add rejected (employerId): You can't leave
|
|
172
|
+
* this empty`. The fix:
|
|
173
|
+
*
|
|
174
|
+
* 1. If `fields.employerId` is supplied → use it verbatim. This is
|
|
175
|
+
* the explicit-bypass path (`--employer-id` on CLI,
|
|
176
|
+
* `employerId` on MCP) — useful for replay scripts, the
|
|
177
|
+
* disambiguation fallback, or known-good ids.
|
|
178
|
+
* 2. Otherwise, fire {@link employerAutocomplete} against
|
|
179
|
+
* `fields.company`. Toptal's autocomplete is fuzzy / prefix-
|
|
180
|
+
* search (typing "Anthropic" returns 10 partial matches), so the
|
|
181
|
+
* practical cardinality is on EXACT NAME MATCH (case-insensitive
|
|
182
|
+
* trim):
|
|
183
|
+
* - exactly 1 exact match → use its id transparently
|
|
184
|
+
* - 2+ exact matches (catalog duplicates — e.g. multiple
|
|
185
|
+
* regional subsidiaries with the same display name) →
|
|
186
|
+
* `VALIDATION_ERROR` listing the duplicates + `--employer-id`
|
|
187
|
+
* nudge
|
|
188
|
+
* - 0 exact, 0 fuzzy → `VALIDATION_ERROR` nudging to the
|
|
189
|
+
* autocomplete CLI command or `--employer-id` bypass
|
|
190
|
+
* - 0 exact, ≥1 fuzzy → `VALIDATION_ERROR` listing the top-N
|
|
191
|
+
* closest candidates with `--employer-id` nudge (the user
|
|
192
|
+
* typed a prefix; surface the catalog's actual names)
|
|
193
|
+
*
|
|
194
|
+
* The static defaults at `experienceItems: []`, `skills: []`,
|
|
195
|
+
* `showViaToptal: true` are retained pre-#395 — they were established
|
|
196
|
+
* empirically via the #344 E2E to satisfy "Expected value to not be
|
|
197
|
+
* null" on those required fields. Reviewing them in this PR would
|
|
198
|
+
* be premature without a fresh live capture; they may be revisited in
|
|
199
|
+
* a follow-up that mirrors the basic.set #393 read-merge pattern for
|
|
200
|
+
* employment.add.
|
|
201
|
+
*
|
|
202
|
+
* **Dry-run path (#395)**: when `options.dryRun === true`, the
|
|
203
|
+
* employer resolution still runs (fires `employersAutocomplete` if
|
|
204
|
+
* `employerId` is absent — except on the #401 custom-workplace path;
|
|
205
|
+
* see **Custom-workplace path (#401)** below) so the preview's
|
|
206
|
+
* `variables.input.employment`
|
|
207
|
+
* carries the resolved `employerId`, matching the wire shape the live
|
|
208
|
+
* mutation would transmit. The `CreateEmployment` mutation transport
|
|
209
|
+
* is NOT invoked. The placeholder
|
|
210
|
+
* {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} stands in for `profileId`
|
|
211
|
+
* (which the apply-path resolves via `extractProfileId`).
|
|
212
|
+
*
|
|
213
|
+
* **Custom-workplace path (#401)**: when `fields.noEmployer === true`,
|
|
214
|
+
* `resolveEmployerId()` is skipped entirely — NO `employersAutocomplete`
|
|
215
|
+
* call in EITHER the apply or dry-run path — and `employerId: null` is
|
|
216
|
+
* sent with the free-text `company`. Mutually exclusive with an explicit
|
|
217
|
+
* `fields.employerId` (→ `VALIDATION_ERROR`). Orthogonal to `noWebsite`.
|
|
155
218
|
*/
|
|
156
|
-
export async function add(token, fields) {
|
|
219
|
+
export async function add(token, fields, options = {}) {
|
|
157
220
|
if (!fields.company || !fields.position) {
|
|
158
221
|
throw new ProfileError("VALIDATION_ERROR", "employment add requires --company and --role.");
|
|
159
222
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
223
|
+
// Custom (non-catalog) workplace path (#401): when `noEmployer` is
|
|
224
|
+
// set, the Toptal "Add as new: <name>" behaviour applies — the wire
|
|
225
|
+
// takes `employerId: null` with the free-text `company` verbatim
|
|
226
|
+
// (there is no `CreateEmployer` mutation anywhere in the schema). It
|
|
227
|
+
// is orthogonal to `noWebsite` (a custom workplace may still have a
|
|
228
|
+
// website) and mutually exclusive with an explicit `employerId` (a
|
|
229
|
+
// catalog id and "not in the catalog" are contradictory).
|
|
230
|
+
if (fields.noEmployer === true && fields.employerId !== undefined && fields.employerId !== "") {
|
|
231
|
+
throw new ProfileError("VALIDATION_ERROR", "employment add: a custom workplace (--no-employer) cannot also pass --employer-id (a catalog id). Use one or the other.");
|
|
232
|
+
}
|
|
233
|
+
// Resolve employerId BEFORE branching on dryRun so the preview's wire
|
|
234
|
+
// shape matches what the live mutation would transmit (#395 explicit
|
|
235
|
+
// AC). The autocomplete query is a read, not a mutation — it fires in
|
|
236
|
+
// both dry-run and apply paths. The custom-workplace path (#401) skips
|
|
237
|
+
// resolution entirely: NO autocomplete network call in EITHER path
|
|
238
|
+
// (apply or dry-run), and `employerId: null` goes on the wire.
|
|
239
|
+
const { noEmployer, ...wireFields } = fields;
|
|
240
|
+
const employerId = noEmployer === true ? null : await resolveEmployerId(token, wireFields);
|
|
241
|
+
// The wire requires several non-null fields on `CreateEmployment`
|
|
242
|
+
// (live API rejects with "Expected value to not be null" / "You can't
|
|
243
|
+
// leave this empty" otherwise). The defaults below were established
|
|
244
|
+
// empirically through E2E iteration — DO NOT add pre-emptive defaults
|
|
245
|
+
// for fields the server hasn't explicitly demanded, since
|
|
246
|
+
// `CreateEmploymentInput` rejects unknown fields with
|
|
247
|
+
// "Field is not defined on EmploymentInput" (e.g. `toptalRelated`,
|
|
248
|
+
// `highlight` are valid on `UpdateEmploymentInput` but NOT on
|
|
249
|
+
// `CreateEmploymentInput`). The request-shaping `noEmployer` signal is
|
|
250
|
+
// destructured out above for the same reason — it is not an
|
|
251
|
+
// `EmploymentInput` field.
|
|
252
|
+
// - `experienceItems`, `skills`, `showViaToptal` — via the #344 E2E
|
|
253
|
+
// - `publicationPermit` — server treats Boolean `false` as blank
|
|
254
|
+
// (USER_ERROR "publicationPermit: You can't leave this empty");
|
|
255
|
+
// default to `true` to satisfy the Rails `.blank?` gate. Mirrors the
|
|
256
|
+
// `buildUpdateEmploymentInput` fallback (`current.publicationPermit
|
|
257
|
+
// ?? true`) so add/update agree on the no-caller-input semantics.
|
|
258
|
+
// Callers may still override.
|
|
169
259
|
const employment = {
|
|
170
260
|
experienceItems: [],
|
|
171
261
|
skills: [],
|
|
172
262
|
showViaToptal: true,
|
|
173
|
-
|
|
263
|
+
publicationPermit: true,
|
|
264
|
+
...wireFields,
|
|
265
|
+
employerId,
|
|
174
266
|
};
|
|
267
|
+
if (options.dryRun === true) {
|
|
268
|
+
return {
|
|
269
|
+
kind: "preview",
|
|
270
|
+
preview: buildDryRunPreview({
|
|
271
|
+
surface: "talent-profile",
|
|
272
|
+
authToken: token,
|
|
273
|
+
body: {
|
|
274
|
+
operationName: "CreateEmployment",
|
|
275
|
+
query: CREATE_EMPLOYMENT_MUTATION,
|
|
276
|
+
variables: { input: { profileId: DRY_RUN_PROFILE_ID_PLACEHOLDER, employment } },
|
|
277
|
+
},
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
const profileId = await extractProfileId(token);
|
|
282
|
+
const before = await listByProfileId(token, profileId);
|
|
283
|
+
const beforeIds = new Set(before.map((e) => e.id));
|
|
175
284
|
const res = await callTalentProfile(token, "CreateEmployment", CREATE_EMPLOYMENT_MUTATION, { input: { profileId, employment } }, "employment add");
|
|
176
285
|
const payload = unwrapMutation(res, "createEmployment", "employment add");
|
|
177
286
|
const after = payload.profile?.employments.nodes.filter((n) => n !== null).map(mapEmploymentNode) ??
|
|
@@ -180,17 +289,204 @@ export async function add(token, fields) {
|
|
|
180
289
|
if (!created) {
|
|
181
290
|
throw new ProfileError("UNKNOWN", "employment add returned success but no new row was found in the response.");
|
|
182
291
|
}
|
|
183
|
-
return created;
|
|
292
|
+
return { kind: "created", result: created };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Resolve `fields.employerId` for the create-employment flow (#395).
|
|
296
|
+
*
|
|
297
|
+
* - Explicit `fields.employerId` → returned verbatim (bypass).
|
|
298
|
+
* - Otherwise call {@link employerAutocomplete}; Toptal's autocomplete
|
|
299
|
+
* is fuzzy / prefix-search, so the practical cardinality is on
|
|
300
|
+
* **exact name match** (case-insensitive, trimmed):
|
|
301
|
+
* - 1 exact match → transparent use
|
|
302
|
+
* - 0 exact, 0 fuzzy → "No employer matched" nudge
|
|
303
|
+
* - 0 exact, ≥1 fuzzy → "No exact match; closest candidates"
|
|
304
|
+
* listing + `--employer-id` nudge (the user typed a prefix /
|
|
305
|
+
* substring; we surface the catalog's actual names so they can
|
|
306
|
+
* pick an id)
|
|
307
|
+
* - 2+ exact → disambiguation listing of the duplicates (the
|
|
308
|
+
* catalog has multiple records with the same display name —
|
|
309
|
+
* common for companies with city subsidiaries; the user must
|
|
310
|
+
* pick one)
|
|
311
|
+
*
|
|
312
|
+
* Errors all surface as `VALIDATION_ERROR` with actionable recovery
|
|
313
|
+
* text so the CLI / MCP layer can render them as user-facing
|
|
314
|
+
* messages without further classification.
|
|
315
|
+
*/
|
|
316
|
+
async function resolveEmployerId(token, fields) {
|
|
317
|
+
if (fields.employerId !== undefined && fields.employerId !== "") {
|
|
318
|
+
return fields.employerId;
|
|
319
|
+
}
|
|
320
|
+
// `fields.company` is asserted non-empty by the caller (the add()
|
|
321
|
+
// pre-flight rejects an empty company / position).
|
|
322
|
+
const company = fields.company ?? "";
|
|
323
|
+
const matches = await employerAutocomplete(token, company);
|
|
324
|
+
const norm = company.trim().toLowerCase();
|
|
325
|
+
const exact = matches.filter((m) => m.name.trim().toLowerCase() === norm);
|
|
326
|
+
if (exact.length === 1) {
|
|
327
|
+
const only = exact[0];
|
|
328
|
+
if (only === undefined) {
|
|
329
|
+
// Defensive: exact.length === 1 but indexed read is undefined —
|
|
330
|
+
// a TypeScript noUncheckedIndexedAccess guard. Unreachable at
|
|
331
|
+
// runtime.
|
|
332
|
+
throw new ProfileError("UNKNOWN", "employer-autocomplete returned 1 exact match but indexing it yielded undefined.");
|
|
333
|
+
}
|
|
334
|
+
return only.id;
|
|
335
|
+
}
|
|
336
|
+
if (exact.length >= 2) {
|
|
337
|
+
// Multiple catalog records share the user-supplied exact name
|
|
338
|
+
// (common for global companies with regional subsidiaries listed
|
|
339
|
+
// separately). Surface only the exact-name duplicates — the fuzzy
|
|
340
|
+
// siblings would just add noise.
|
|
341
|
+
const list = exact.map(formatCandidate).join("\n");
|
|
342
|
+
throw new ProfileError("VALIDATION_ERROR", `Multiple employers matched "${company}" exactly (${exact.length.toString()} duplicates in the catalog):\n` +
|
|
343
|
+
`${list}\n` +
|
|
344
|
+
`Pass \`--employer-id <id>\` to disambiguate.`);
|
|
345
|
+
}
|
|
346
|
+
// exact.length === 0
|
|
347
|
+
if (matches.length === 0) {
|
|
348
|
+
throw new ProfileError("VALIDATION_ERROR", `No employer matched "${company}". Use ` +
|
|
349
|
+
`\`ttctl profile employment employer-autocomplete <query>\` to search the catalog, ` +
|
|
350
|
+
`or pass \`--employer-id <id>\` to bypass autocomplete.`);
|
|
351
|
+
}
|
|
352
|
+
// 0 exact, ≥1 fuzzy. Surface the catalog's actual names so the user
|
|
353
|
+
// can refine (or pick an id directly).
|
|
354
|
+
const top = matches.slice(0, 5);
|
|
355
|
+
const list = top.map(formatCandidate).join("\n");
|
|
356
|
+
throw new ProfileError("VALIDATION_ERROR", `No exact match for "${company}" in the employer catalog ` +
|
|
357
|
+
`(${matches.length.toString()} fuzzy match${matches.length === 1 ? "" : "es"}; showing top ${top.length.toString()}):\n` +
|
|
358
|
+
`${list}\n` +
|
|
359
|
+
`Refine the company string to the exact catalog name, or pass \`--employer-id <id>\` to bypass autocomplete.`);
|
|
360
|
+
}
|
|
361
|
+
function formatCandidate(m) {
|
|
362
|
+
const loc = [m.city, m.country].filter((v) => v !== null && v !== "").join(", ");
|
|
363
|
+
return ` - ${m.id} ${m.name}${loc ? ` (${loc})` : ""}`;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Placeholder string substituted into a dry-run `UpdateEmployment`
|
|
367
|
+
* preview's variables payload for fields that the apply-path resolves by
|
|
368
|
+
* reading the current row (`experienceItems`, `position`, `skills`,
|
|
369
|
+
* `showViaToptal`, `startDate` — the five required-non-null fields
|
|
370
|
+
* injected by the read-current+merge logic, #394 + #407 for `position`).
|
|
371
|
+
* Surfaced verbatim so MCP consumers can
|
|
372
|
+
* see the structural shape of what will be sent without TTCtl having
|
|
373
|
+
* fired the read transport. Same posture as `basic.set`'s
|
|
374
|
+
* {@link DRY_RUN_PROFILE_ID_PLACEHOLDER} — preserves the zero-transport-
|
|
375
|
+
* in-dry-run invariant (#165 / #379) while honoring #394's AC that the
|
|
376
|
+
* preview shows the full merged shape.
|
|
377
|
+
*/
|
|
378
|
+
export const DRY_RUN_EMPLOYMENT_MERGE_PLACEHOLDER = "<resolved at send-time by reading current state>";
|
|
379
|
+
/**
|
|
380
|
+
* Build the merged `EmploymentInput` to send for an `UpdateEmployment`
|
|
381
|
+
* mutation by reading the current row and overlaying user-supplied fields.
|
|
382
|
+
*
|
|
383
|
+
* The `talent_profile/graphql` server treats five `EmploymentInput` fields
|
|
384
|
+
* as required non-null on `UpdateEmployment` and rejects the whole
|
|
385
|
+
* variables payload with `"Expected value to not be null"` when they are
|
|
386
|
+
* absent (#394 + #407 for `position` — wire-broke meta-class #392). The
|
|
387
|
+
* five fields are `experienceItems`, `position`, `showViaToptal`,
|
|
388
|
+
* `startDate`, and `skills`. This helper injects them from the current
|
|
389
|
+
* state where the EMPLOYMENT_FRAGMENT surfaces them (`experienceItems`,
|
|
390
|
+
* `position`, `showViaToptal`, `startDate`) and defaults `skills: []`
|
|
391
|
+
* because the fragment does not currently select the read-side `skills`
|
|
392
|
+
* connection. Other fields are left undefined and omitted from the wire
|
|
393
|
+
* payload — the server keeps the existing value for any field absent
|
|
394
|
+
* from the input (the omission-is-preservation half of the merge
|
|
395
|
+
* contract; only the five required-non-null fields force-echo).
|
|
396
|
+
*
|
|
397
|
+
* **Known limitation (#394)**: `skills` defaults to `[]` because the
|
|
398
|
+
* current read fragment does not surface skills. Calling `update()` on a
|
|
399
|
+
* row that has skills will reset them to empty. A follow-up will extend
|
|
400
|
+
* the fragment to read skills and preserve them through the merge; for
|
|
401
|
+
* the test-account this is acceptable, and the bug fix here is for the
|
|
402
|
+
* minimal `{id, role}` repro that previously failed at the wire layer
|
|
403
|
+
* regardless of skills state.
|
|
404
|
+
*
|
|
405
|
+
* Exported so the MCP layer can build the same merged input for the
|
|
406
|
+
* dry-run preview (AC: "Dry-run preview shows the full merged input").
|
|
407
|
+
*
|
|
408
|
+
* @throws `ProfileError("VALIDATION_ERROR")` when `fields` is empty.
|
|
409
|
+
* @throws `ProfileError("VALIDATION_ERROR")` when `current.startDate` is
|
|
410
|
+
* `null` and the caller did not supply a `startDate` override — the
|
|
411
|
+
* wire requires a non-null `startDate` and we have nothing to send.
|
|
412
|
+
*/
|
|
413
|
+
export function buildUpdateEmploymentInput(current, fields) {
|
|
414
|
+
if (Object.keys(fields).length === 0) {
|
|
415
|
+
throw new ProfileError("VALIDATION_ERROR", "employment update requires at least one field flag.");
|
|
416
|
+
}
|
|
417
|
+
const startDate = fields.startDate ?? current.startDate;
|
|
418
|
+
if (startDate === null) {
|
|
419
|
+
throw new ProfileError("VALIDATION_ERROR", `Cannot update employment "${current.id}": startDate is required and current value is null. Supply --from to set a year.`);
|
|
420
|
+
}
|
|
421
|
+
// Server-side Rails `.blank?` gates (USER_ERROR "You can't leave this
|
|
422
|
+
// empty") — surfaced by the #394 live capture (2026-05-19): when the
|
|
423
|
+
// caller omits these, the wire layer accepts the partial input but
|
|
424
|
+
// the Rails apply path rejects it. Inject from the current row so
|
|
425
|
+
// user-supplied fields can still override. Optional pass-throughs
|
|
426
|
+
// (employerId / primaryGeographyId / reportingTo) are only set when
|
|
427
|
+
// the current row has a non-null value — sending an explicit null
|
|
428
|
+
// would change the row's state, which would defeat "merge".
|
|
429
|
+
//
|
|
430
|
+
// #401 WORM limitation (2026-05-19 live capture): the UpdateEmployment
|
|
431
|
+
// wire treats BOTH absence AND explicit null of `employerId` as Rails
|
|
432
|
+
// `.blank?` and rejects with "employerId: You can't leave this
|
|
433
|
+
// empty". Custom workplaces (CreateEmployment with `employerId: null`)
|
|
434
|
+
// therefore CANNOT be updated via this surface — they are write-once-
|
|
435
|
+
// read-many on Toptal. The "omit when null" branch below reflects
|
|
436
|
+
// this honestly: there is no employerId payload we can send that
|
|
437
|
+
// satisfies the wire for a null-employerId row. update() on such a
|
|
438
|
+
// row will surface the USER_ERROR verbatim. See
|
|
439
|
+
// `research/notes/15-employment-custom-workplace-worm.md` and #401.
|
|
440
|
+
const merged = {
|
|
441
|
+
// Wire-required non-null (GraphQL `Expected value to not be null`):
|
|
442
|
+
experienceItems: current.experienceItems ?? [],
|
|
443
|
+
// #407 — same wire-required non-null class: server rejects with
|
|
444
|
+
// `Expected value to not be null` for `employment.position` on any
|
|
445
|
+
// partial update that omits it. EMPLOYMENT_FRAGMENT selects `position`
|
|
446
|
+
// so `current.position` is always available to thread through.
|
|
447
|
+
position: current.position,
|
|
448
|
+
// Preserve current row's skills through the merge — server rejects
|
|
449
|
+
// `skills: []` with "is too short (minimum is 1 character)" on
|
|
450
|
+
// update (#394 live-capture finding 2026-05-19). The EMPLOYMENT_FRAGMENT
|
|
451
|
+
// now selects `skills { nodes { id name } }` so `current.skills` is
|
|
452
|
+
// populated; pre-#394 it was always `[]` and update() defaulted to
|
|
453
|
+
// empty, which is what the live wire was rejecting.
|
|
454
|
+
skills: current.skills,
|
|
455
|
+
showViaToptal: current.showViaToptal,
|
|
456
|
+
startDate,
|
|
457
|
+
// Rails `.blank?` gates:
|
|
458
|
+
company: current.company,
|
|
459
|
+
publicationPermit: current.publicationPermit ?? true,
|
|
460
|
+
// industryIds: catalog refs the wire requires present and non-empty
|
|
461
|
+
// on the apply path.
|
|
462
|
+
industryIds: current.industries.map((i) => i.id),
|
|
463
|
+
};
|
|
464
|
+
if (current.employerId !== null) {
|
|
465
|
+
merged.employerId = current.employerId;
|
|
466
|
+
}
|
|
467
|
+
if (current.primaryGeography !== null) {
|
|
468
|
+
merged.primaryGeographyId = current.primaryGeography.id;
|
|
469
|
+
}
|
|
470
|
+
if (current.reportingTo !== null) {
|
|
471
|
+
merged.reportingTo = current.reportingTo;
|
|
472
|
+
}
|
|
473
|
+
return { ...merged, ...fields };
|
|
184
474
|
}
|
|
185
475
|
/**
|
|
186
476
|
* Update an existing employment row. Wire format per Pattern 1:
|
|
187
477
|
* `{ employmentId, employment: EmploymentInput }`.
|
|
478
|
+
*
|
|
479
|
+
* Reads the current row first and merges the five required-non-null
|
|
480
|
+
* fields onto the wire input (see {@link buildUpdateEmploymentInput} for
|
|
481
|
+
* the merge contract and #394 + #407 for the originating wire-broke incidents).
|
|
188
482
|
*/
|
|
189
483
|
export async function update(token, id, fields) {
|
|
190
484
|
if (Object.keys(fields).length === 0) {
|
|
191
485
|
throw new ProfileError("VALIDATION_ERROR", "employment update requires at least one field flag.");
|
|
192
486
|
}
|
|
193
|
-
const
|
|
487
|
+
const current = await show(token, id);
|
|
488
|
+
const employment = buildUpdateEmploymentInput(current, fields);
|
|
489
|
+
const res = await callTalentProfile(token, "UpdateEmployment", UPDATE_EMPLOYMENT_MUTATION, { input: { employmentId: id, employment } }, "employment update");
|
|
194
490
|
const payload = unwrapMutation(res, "updateEmployment", "employment update");
|
|
195
491
|
const updated = payload.profile?.employments.nodes
|
|
196
492
|
.filter((n) => n !== null)
|