@ttctl/core 0.0.0 → 0.1.0-rc.1
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/README.md +49 -9
- package/dist/__generated__/gateway.d.ts +4546 -0
- package/dist/__generated__/gateway.d.ts.map +1 -0
- package/dist/__generated__/gateway.js +9 -0
- package/dist/__generated__/gateway.js.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts +1187 -0
- package/dist/__generated__/talent-profile-zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/talent-profile-zod-schemas.js +1136 -0
- package/dist/__generated__/talent-profile-zod-schemas.js.map +1 -0
- package/dist/__generated__/talent-profile.d.ts +1397 -0
- package/dist/__generated__/talent-profile.d.ts.map +1 -0
- package/dist/__generated__/talent-profile.js +9 -0
- package/dist/__generated__/talent-profile.js.map +1 -0
- package/dist/__generated__/zod-schemas.d.ts +2895 -0
- package/dist/__generated__/zod-schemas.d.ts.map +1 -0
- package/dist/__generated__/zod-schemas.js +3121 -0
- package/dist/__generated__/zod-schemas.js.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts +74 -0
- package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/builders.js +196 -0
- package/dist/__tests__/fixtures/profile/builders.js.map +1 -0
- package/dist/__tests__/fixtures/profile/data.d.ts +39 -0
- package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/data.js +230 -0
- package/dist/__tests__/fixtures/profile/data.js.map +1 -0
- package/dist/__tests__/fixtures/profile/index.d.ts +9 -0
- package/dist/__tests__/fixtures/profile/index.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/index.js +10 -0
- package/dist/__tests__/fixtures/profile/index.js.map +1 -0
- package/dist/__tests__/fixtures/profile/types.d.ts +53 -0
- package/dist/__tests__/fixtures/profile/types.d.ts.map +1 -0
- package/dist/__tests__/fixtures/profile/types.js +4 -0
- package/dist/__tests__/fixtures/profile/types.js.map +1 -0
- package/dist/auth/errors.d.ts +82 -0
- package/dist/auth/errors.d.ts.map +1 -0
- package/dist/auth/errors.js +68 -0
- package/dist/auth/errors.js.map +1 -0
- package/dist/auth.d.ts +192 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +294 -0
- package/dist/auth.js.map +1 -0
- package/dist/config.d.ts +212 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +349 -0
- package/dist/config.js.map +1 -0
- package/dist/configLock.d.ts +50 -0
- package/dist/configLock.d.ts.map +1 -0
- package/dist/configLock.js +88 -0
- package/dist/configLock.js.map +1 -0
- package/dist/configWriter.d.ts +97 -0
- package/dist/configWriter.d.ts.map +1 -0
- package/dist/configWriter.js +687 -0
- package/dist/configWriter.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/kill-switch.d.ts +161 -0
- package/dist/kill-switch.d.ts.map +1 -0
- package/dist/kill-switch.js +235 -0
- package/dist/kill-switch.js.map +1 -0
- package/dist/lib/date.d.ts +58 -0
- package/dist/lib/date.d.ts.map +1 -0
- package/dist/lib/date.js +104 -0
- package/dist/lib/date.js.map +1 -0
- package/dist/lib/diagnostic-log.d.ts +159 -0
- package/dist/lib/diagnostic-log.d.ts.map +1 -0
- package/dist/lib/diagnostic-log.js +186 -0
- package/dist/lib/diagnostic-log.js.map +1 -0
- package/dist/lib/package-version.d.ts +19 -0
- package/dist/lib/package-version.d.ts.map +1 -0
- package/dist/lib/package-version.js +38 -0
- package/dist/lib/package-version.js.map +1 -0
- package/dist/lib/redact.d.ts +153 -0
- package/dist/lib/redact.d.ts.map +1 -0
- package/dist/lib/redact.js +207 -0
- package/dist/lib/redact.js.map +1 -0
- package/dist/lib/text.d.ts +14 -0
- package/dist/lib/text.d.ts.map +1 -0
- package/dist/lib/text.js +21 -0
- package/dist/lib/text.js.map +1 -0
- package/dist/lib/wire-shape.d.ts +131 -0
- package/dist/lib/wire-shape.d.ts.map +1 -0
- package/dist/lib/wire-shape.js +376 -0
- package/dist/lib/wire-shape.js.map +1 -0
- package/dist/onepassword.d.ts +29 -0
- package/dist/onepassword.d.ts.map +1 -0
- package/dist/onepassword.js +112 -0
- package/dist/onepassword.js.map +1 -0
- package/dist/services/_shared/transport.d.ts +148 -0
- package/dist/services/_shared/transport.d.ts.map +1 -0
- package/dist/services/_shared/transport.js +102 -0
- package/dist/services/_shared/transport.js.map +1 -0
- package/dist/services/applications/index.d.ts +210 -0
- package/dist/services/applications/index.d.ts.map +1 -0
- package/dist/services/applications/index.js +240 -0
- package/dist/services/applications/index.js.map +1 -0
- package/dist/services/availability/index.d.ts +254 -0
- package/dist/services/availability/index.d.ts.map +1 -0
- package/dist/services/availability/index.js +310 -0
- package/dist/services/availability/index.js.map +1 -0
- package/dist/services/contracts/index.d.ts +132 -0
- package/dist/services/contracts/index.d.ts.map +1 -0
- package/dist/services/contracts/index.js +211 -0
- package/dist/services/contracts/index.js.map +1 -0
- package/dist/services/engagements/index.d.ts +504 -0
- package/dist/services/engagements/index.d.ts.map +1 -0
- package/dist/services/engagements/index.js +613 -0
- package/dist/services/engagements/index.js.map +1 -0
- package/dist/services/jobs/index.d.ts +490 -0
- package/dist/services/jobs/index.d.ts.map +1 -0
- package/dist/services/jobs/index.js +753 -0
- package/dist/services/jobs/index.js.map +1 -0
- package/dist/services/payments/index.d.ts +415 -0
- package/dist/services/payments/index.d.ts.map +1 -0
- package/dist/services/payments/index.js +636 -0
- package/dist/services/payments/index.js.map +1 -0
- package/dist/services/profile/__tests__/fixtures.d.ts +214 -0
- package/dist/services/profile/__tests__/fixtures.d.ts.map +1 -0
- package/dist/services/profile/__tests__/fixtures.js +176 -0
- package/dist/services/profile/__tests__/fixtures.js.map +1 -0
- package/dist/services/profile/basic/index.d.ts +390 -0
- package/dist/services/profile/basic/index.d.ts.map +1 -0
- package/dist/services/profile/basic/index.js +1007 -0
- package/dist/services/profile/basic/index.js.map +1 -0
- package/dist/services/profile/certifications/index.d.ts +74 -0
- package/dist/services/profile/certifications/index.d.ts.map +1 -0
- package/dist/services/profile/certifications/index.js +169 -0
- package/dist/services/profile/certifications/index.js.map +1 -0
- package/dist/services/profile/education/index.d.ts +73 -0
- package/dist/services/profile/education/index.d.ts.map +1 -0
- package/dist/services/profile/education/index.js +168 -0
- package/dist/services/profile/education/index.js.map +1 -0
- package/dist/services/profile/employment/index.d.ts +111 -0
- package/dist/services/profile/employment/index.d.ts.map +1 -0
- package/dist/services/profile/employment/index.js +202 -0
- package/dist/services/profile/employment/index.js.map +1 -0
- package/dist/services/profile/external/index.d.ts +219 -0
- package/dist/services/profile/external/index.d.ts.map +1 -0
- package/dist/services/profile/external/index.js +560 -0
- package/dist/services/profile/external/index.js.map +1 -0
- package/dist/services/profile/index.d.ts +24 -0
- package/dist/services/profile/index.d.ts.map +1 -0
- package/dist/services/profile/index.js +26 -0
- package/dist/services/profile/index.js.map +1 -0
- package/dist/services/profile/industries/index.d.ts +130 -0
- package/dist/services/profile/industries/index.d.ts.map +1 -0
- package/dist/services/profile/industries/index.js +292 -0
- package/dist/services/profile/industries/index.js.map +1 -0
- package/dist/services/profile/portfolio/index.d.ts +352 -0
- package/dist/services/profile/portfolio/index.d.ts.map +1 -0
- package/dist/services/profile/portfolio/index.js +833 -0
- package/dist/services/profile/portfolio/index.js.map +1 -0
- package/dist/services/profile/resume/index.d.ts +60 -0
- package/dist/services/profile/resume/index.d.ts.map +1 -0
- package/dist/services/profile/resume/index.js +212 -0
- package/dist/services/profile/resume/index.js.map +1 -0
- package/dist/services/profile/reviews/index.d.ts +137 -0
- package/dist/services/profile/reviews/index.d.ts.map +1 -0
- package/dist/services/profile/reviews/index.js +431 -0
- package/dist/services/profile/reviews/index.js.map +1 -0
- package/dist/services/profile/shared.d.ts +127 -0
- package/dist/services/profile/shared.d.ts.map +1 -0
- package/dist/services/profile/shared.js +155 -0
- package/dist/services/profile/shared.js.map +1 -0
- package/dist/services/profile/skills/index.d.ts +212 -0
- package/dist/services/profile/skills/index.d.ts.map +1 -0
- package/dist/services/profile/skills/index.js +461 -0
- package/dist/services/profile/skills/index.js.map +1 -0
- package/dist/services/profile/visas/index.d.ts +74 -0
- package/dist/services/profile/visas/index.d.ts.map +1 -0
- package/dist/services/profile/visas/index.js +306 -0
- package/dist/services/profile/visas/index.js.map +1 -0
- package/dist/services/timesheet/index.d.ts +326 -0
- package/dist/services/timesheet/index.d.ts.map +1 -0
- package/dist/services/timesheet/index.js +324 -0
- package/dist/services/timesheet/index.js.map +1 -0
- package/dist/services/translations.d.ts +79 -0
- package/dist/services/translations.d.ts.map +1 -0
- package/dist/services/translations.js +136 -0
- package/dist/services/translations.js.map +1 -0
- package/dist/transport-resilience.d.ts +136 -0
- package/dist/transport-resilience.d.ts.map +1 -0
- package/dist/transport-resilience.js +247 -0
- package/dist/transport-resilience.js.map +1 -0
- package/dist/transport.d.ts +408 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +691 -0
- package/dist/transport.js.map +1 -0
- package/dist/types.d.ts +41 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/package.json +40 -12
- package/index.js +0 -7
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 Oleksii PELYKH
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { AuthRevokedError, TtctlError } from "../../../auth/errors.js";
|
|
6
|
+
import { impersonatedMultipartTransport, impersonatedTransport } from "../../../transport.js";
|
|
7
|
+
import { extractProfileId, isAuthRevokedExtensionCode } from "../shared.js";
|
|
8
|
+
import { list as listSkills } from "../skills/index.js";
|
|
9
|
+
export class PortfolioError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
name = "PortfolioError";
|
|
12
|
+
constructor(code, message, options) {
|
|
13
|
+
super(message, options);
|
|
14
|
+
this.code = code;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Portfolio item kind enumeration. Empirically the server requires this
|
|
19
|
+
* as a non-null field on `CreatePortfolioItemInput.portfolioItem`. The
|
|
20
|
+
* WRITE-side enum uses **lowercase snake_case** values — the
|
|
21
|
+
* SCREAMING_SNAKE forms returned by the read-side `PortfolioItemKindEnum`
|
|
22
|
+
* in `research/graphql/gateway/schema.graphql` are REJECTED on create
|
|
23
|
+
* with `"kind: is not included in the list"` (verified live 2026-05-16
|
|
24
|
+
* during issue #314 investigation, see `.tmp/probe-portfolio-kind.mjs`).
|
|
25
|
+
*
|
|
26
|
+
* Use the {@link PORTFOLIO_ITEM_KIND} constant for autocomplete-friendly
|
|
27
|
+
* references. The {@link PortfolioItemKind} type is derived so adding a
|
|
28
|
+
* member here automatically extends the type.
|
|
29
|
+
*/
|
|
30
|
+
export const PORTFOLIO_ITEM_KIND = {
|
|
31
|
+
ACCOMPLISHMENT: "accomplishment",
|
|
32
|
+
BASIC: "basic",
|
|
33
|
+
CLASSIC: "classic",
|
|
34
|
+
CODE_BASE: "code_base",
|
|
35
|
+
OTHER_AMAZING_THINGS: "other_amazing_things",
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Map a portfolio fragment node from the wire shape to the typed
|
|
39
|
+
* {@link PortfolioItem}. The wire shape carries many more fields (skills,
|
|
40
|
+
* industries, files, kpis, quotes, details unions) — those are out of
|
|
41
|
+
* scope for the v0 read surface and dropped here. Callers that need the
|
|
42
|
+
* extras can issue a richer query in a follow-up.
|
|
43
|
+
*/
|
|
44
|
+
function mapPortfolioNode(node) {
|
|
45
|
+
const id = node["id"];
|
|
46
|
+
const skillsConn = node["skills"];
|
|
47
|
+
const industriesConn = node["industries"];
|
|
48
|
+
const skills = Array.isArray(skillsConn?.nodes)
|
|
49
|
+
? skillsConn.nodes.flatMap((s) => typeof s.id === "string" && typeof s.name === "string" ? [{ id: s.id, name: s.name }] : [])
|
|
50
|
+
: [];
|
|
51
|
+
const industries = Array.isArray(industriesConn?.nodes)
|
|
52
|
+
? industriesConn.nodes.flatMap((i) => typeof i.id === "string" && typeof i.name === "string" ? [{ id: i.id, name: i.name }] : [])
|
|
53
|
+
: [];
|
|
54
|
+
const rawKind = node["kind"];
|
|
55
|
+
const kind = typeof rawKind === "string" && Object.values(PORTFOLIO_ITEM_KIND).includes(rawKind)
|
|
56
|
+
? rawKind
|
|
57
|
+
: null;
|
|
58
|
+
return {
|
|
59
|
+
id: typeof id === "string" ? id : "",
|
|
60
|
+
title: node["title"] ?? null,
|
|
61
|
+
description: node["description"] ?? null,
|
|
62
|
+
link: node["link"] ?? null,
|
|
63
|
+
highlight: Boolean(node["highlight"]),
|
|
64
|
+
coverImage: node["coverImage"] ?? null,
|
|
65
|
+
accomplishment: node["accomplishment"] ?? null,
|
|
66
|
+
publicationPermit: node["publicationPermit"] ?? null,
|
|
67
|
+
clientOrCompanyName: node["clientOrCompanyName"] ?? null,
|
|
68
|
+
websiteUrl: node["websiteUrl"] ?? null,
|
|
69
|
+
toptalRelated: node["toptalRelated"] ?? null,
|
|
70
|
+
showViaToptal: node["showViaToptal"] ?? null,
|
|
71
|
+
kind,
|
|
72
|
+
skills,
|
|
73
|
+
industries,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Common "200 with errors" shape handler. Returns the unwrapped payload
|
|
78
|
+
* (the value of the single root data field) as `unknown`; callers
|
|
79
|
+
* narrow at the call site to their per-operation payload shape. The
|
|
80
|
+
* helper centralizes the auth-revoked / GraphQL-error / null-data paths
|
|
81
|
+
* so each operation's domain code stays focused on its own payload
|
|
82
|
+
* shape.
|
|
83
|
+
*/
|
|
84
|
+
function unwrapResponse(res, operationName) {
|
|
85
|
+
if (res.status === 401) {
|
|
86
|
+
throw new AuthRevokedError("Session is invalid or expired.");
|
|
87
|
+
}
|
|
88
|
+
if (res.status < 200 || res.status >= 300) {
|
|
89
|
+
throw new PortfolioError("UNKNOWN", `${operationName} returned HTTP ${res.status.toString()}`);
|
|
90
|
+
}
|
|
91
|
+
const body = res.body;
|
|
92
|
+
if (body && Array.isArray(body.errors) && body.errors.length > 0) {
|
|
93
|
+
const first = body.errors[0];
|
|
94
|
+
if (isAuthRevokedExtensionCode(first?.extensions?.code)) {
|
|
95
|
+
throw new AuthRevokedError("Session is invalid or expired.");
|
|
96
|
+
}
|
|
97
|
+
throw new PortfolioError("GRAPHQL_ERROR", `${operationName} failed: ${first?.message ?? "GraphQL error"}`);
|
|
98
|
+
}
|
|
99
|
+
if (!body?.data) {
|
|
100
|
+
throw new PortfolioError("UNKNOWN", `${operationName} response had no \`data\` field`);
|
|
101
|
+
}
|
|
102
|
+
// The mutation/query operations in this module all use a single root
|
|
103
|
+
// field whose name matches the operation (createPortfolioItem,
|
|
104
|
+
// updatePortfolioItem, getPortfolioItems, profile, …). We extract the
|
|
105
|
+
// single value from `data` here.
|
|
106
|
+
const keys = Object.keys(body.data);
|
|
107
|
+
if (keys.length === 0) {
|
|
108
|
+
throw new PortfolioError("UNKNOWN", `${operationName} response had empty \`data\``);
|
|
109
|
+
}
|
|
110
|
+
const firstKey = keys[0];
|
|
111
|
+
const payload = body.data[firstKey];
|
|
112
|
+
if (payload === null || payload === undefined) {
|
|
113
|
+
throw new PortfolioError("UNKNOWN", `${operationName} response had \`null\` payload`);
|
|
114
|
+
}
|
|
115
|
+
return payload;
|
|
116
|
+
}
|
|
117
|
+
/** Translate `userErrors[]` into a `USER_ERROR` `PortfolioError`. */
|
|
118
|
+
function rejectIfUserErrors(errors, operationName) {
|
|
119
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
120
|
+
const first = errors[0];
|
|
121
|
+
const fieldHint = first?.key ? ` (${first.key})` : "";
|
|
122
|
+
throw new PortfolioError("USER_ERROR", `${operationName} rejected${fieldHint}: ${first?.message ?? "unknown error"}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Wrap a transport call, narrowing transport-level errors to
|
|
127
|
+
* `PortfolioError("NETWORK_ERROR")` while letting `TtctlError` subclasses
|
|
128
|
+
* (`Cf403Error`, `AuthRevokedError`) bubble up so the CLI/MCP surfaces can
|
|
129
|
+
* apply uniform recovery presentation.
|
|
130
|
+
*/
|
|
131
|
+
async function withTransportErrors(operationName, fn) {
|
|
132
|
+
try {
|
|
133
|
+
return await fn();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err instanceof TtctlError)
|
|
137
|
+
throw err;
|
|
138
|
+
if (err instanceof PortfolioError)
|
|
139
|
+
throw err;
|
|
140
|
+
throw new PortfolioError("NETWORK_ERROR", `${operationName} request failed: ${err.message}`, {
|
|
141
|
+
cause: err,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/* -------------------------------------------------------------------------- */
|
|
146
|
+
/* Operations */
|
|
147
|
+
/* -------------------------------------------------------------------------- */
|
|
148
|
+
/**
|
|
149
|
+
* Full-document `getPortfolioItems` query. Sent against `talent-profile`
|
|
150
|
+
* (Cloudflare-protected, requires impersonation). Mirrors
|
|
151
|
+
* `research/graphql/talent_profile/operations/getPortfolioItems.graphql`,
|
|
152
|
+
* trimmed to the v0 read surface (no skills/industries/files/details
|
|
153
|
+
* fragments — those are separate sub-domains).
|
|
154
|
+
*/
|
|
155
|
+
/**
|
|
156
|
+
* Shared selection set for `PortfolioItem` nodes used across `list`,
|
|
157
|
+
* `create`, `update`, `remove`, `reorder`, and `highlight` mutation
|
|
158
|
+
* responses. Centralized so the `mapPortfolioNode` mapper always sees a
|
|
159
|
+
* consistent shape regardless of which operation produced the response.
|
|
160
|
+
*
|
|
161
|
+
* Includes `kind`, `skills.nodes`, and `industries.nodes` so the
|
|
162
|
+
* `update` read-modify-write path can preserve non-null required fields
|
|
163
|
+
* the server enforces (`showViaToptal`, `skills`).
|
|
164
|
+
*/
|
|
165
|
+
const PORTFOLIO_NODE_SELECTION = `
|
|
166
|
+
id
|
|
167
|
+
title
|
|
168
|
+
description
|
|
169
|
+
link
|
|
170
|
+
highlight
|
|
171
|
+
coverImage
|
|
172
|
+
accomplishment
|
|
173
|
+
publicationPermit
|
|
174
|
+
clientOrCompanyName
|
|
175
|
+
websiteUrl
|
|
176
|
+
toptalRelated
|
|
177
|
+
showViaToptal
|
|
178
|
+
kind
|
|
179
|
+
skills { nodes { id name } }
|
|
180
|
+
industries { nodes { id name } }
|
|
181
|
+
`;
|
|
182
|
+
const GET_PORTFOLIO_ITEMS_QUERY = `query getPortfolioItems($profileId: ID!) {
|
|
183
|
+
profile(id: $profileId) {
|
|
184
|
+
id
|
|
185
|
+
portfolioItems {
|
|
186
|
+
nodes { ${PORTFOLIO_NODE_SELECTION} }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}`;
|
|
190
|
+
/**
|
|
191
|
+
* Fetch the signed-in user's portfolio items. Takes the user's auth token
|
|
192
|
+
* and returns the typed array; the empty-list case returns `[]` (not
|
|
193
|
+
* `null`).
|
|
194
|
+
*/
|
|
195
|
+
export async function list(token) {
|
|
196
|
+
const profileId = await extractProfileId(token);
|
|
197
|
+
// Routed to `talent-profile` (impersonated, Cloudflare-protected): the
|
|
198
|
+
// older `mobile-gateway` schema is missing fields the read surface
|
|
199
|
+
// selects (`toptalRelated` empirically — `Cannot query field
|
|
200
|
+
// "toptalRelated" on type "PortfolioItem"`, captured 2026-05-16). The
|
|
201
|
+
// talent-profile schema is authoritative for all mutations in this
|
|
202
|
+
// service; aligning the query surface keeps a single source of truth.
|
|
203
|
+
const res = await withTransportErrors("getPortfolioItems", async () => impersonatedTransport({
|
|
204
|
+
surface: "talent-profile",
|
|
205
|
+
authToken: token,
|
|
206
|
+
body: {
|
|
207
|
+
operationName: "getPortfolioItems",
|
|
208
|
+
query: GET_PORTFOLIO_ITEMS_QUERY,
|
|
209
|
+
variables: { profileId },
|
|
210
|
+
},
|
|
211
|
+
}));
|
|
212
|
+
const profile = unwrapResponse(res, "getPortfolioItems");
|
|
213
|
+
return profile.portfolioItems.nodes.map(mapPortfolioNode);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Full-document `createPortfolioItem` mutation. Sent against
|
|
217
|
+
* `talent-profile`. Returns the freshly-created list so the caller can
|
|
218
|
+
* surface the new item's id without a second list round-trip.
|
|
219
|
+
*
|
|
220
|
+
* **[INFERRED]** Wrapper key `portfolioItem` per Pattern 2 — see
|
|
221
|
+
* `research/notes/10-mutation-input-patterns.md`. Live-capture this
|
|
222
|
+
* shape if the server rejects the wrapper.
|
|
223
|
+
*/
|
|
224
|
+
const CREATE_PORTFOLIO_ITEM_MUTATION = `mutation createPortfolioItem($input: CreatePortfolioItemInput!) {
|
|
225
|
+
createPortfolioItem(input: $input) {
|
|
226
|
+
profile {
|
|
227
|
+
id
|
|
228
|
+
portfolioItems {
|
|
229
|
+
nodes { ${PORTFOLIO_NODE_SELECTION} }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
success
|
|
233
|
+
notice
|
|
234
|
+
errors {
|
|
235
|
+
code
|
|
236
|
+
key
|
|
237
|
+
message
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}`;
|
|
241
|
+
/**
|
|
242
|
+
* Default description used when the caller does not supply one. The
|
|
243
|
+
* server validates a minimum length on `description` (≥200 characters,
|
|
244
|
+
* empirically discovered during issue #314); a short title-derived
|
|
245
|
+
* default would be rejected with `"is too short (minimum is 200
|
|
246
|
+
* characters)"` for typical CLI titles. This placeholder satisfies the
|
|
247
|
+
* minimum while remaining obviously a placeholder a user would refine.
|
|
248
|
+
*
|
|
249
|
+
* Length: 273 characters.
|
|
250
|
+
*/
|
|
251
|
+
const DEFAULT_PORTFOLIO_DESCRIPTION = "This portfolio item was created via the ttctl CLI without an explicit description. " +
|
|
252
|
+
"Replace this placeholder via `ttctl profile portfolio update <id> --description ...` " +
|
|
253
|
+
"with details about the project, the work performed, the technologies used, and the outcomes achieved.";
|
|
254
|
+
export async function add(token, input) {
|
|
255
|
+
if (!input.title || input.title.trim() === "") {
|
|
256
|
+
throw new PortfolioError("VALIDATION_ERROR", "Portfolio item requires a non-empty `title`.");
|
|
257
|
+
}
|
|
258
|
+
// Runtime guard: TS narrowing requires `industryIds` to exist as a
|
|
259
|
+
// `string[]`, but JS callers / MCP tool inputs / `as any` casts can
|
|
260
|
+
// bypass that. Server-verified to reject `[]` / `null` / omitted with
|
|
261
|
+
// `code: blank, key: industries` (live probe 2026-05-16,
|
|
262
|
+
// `.tmp/probe-empty-industries.mjs`); we surface the same constraint as
|
|
263
|
+
// VALIDATION_ERROR before reaching the wire.
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- defense-in-depth for non-TS callers
|
|
265
|
+
if (!input.industryIds || input.industryIds.length === 0) {
|
|
266
|
+
throw new PortfolioError("VALIDATION_ERROR", "Portfolio item requires at least one industry id (`industryIds`). " +
|
|
267
|
+
"Discover catalog IDs via `ttctl profile industries autocomplete <query>`, " +
|
|
268
|
+
"then pass `--industry-id <id>` (repeatable) on the CLI.");
|
|
269
|
+
}
|
|
270
|
+
const profileId = await extractProfileId(token);
|
|
271
|
+
// The server rejects `skills: []` with `"You need to add at least one
|
|
272
|
+
// tag"`. When the caller does not supply skills, fall back to the
|
|
273
|
+
// user's first profile skill — preserves the "minimal CLI invocation
|
|
274
|
+
// just works" experience and aligns the portfolio entry with the
|
|
275
|
+
// user's primary skill domain. Empty-profile case surfaces a clear
|
|
276
|
+
// VALIDATION_ERROR pointing the user at remediation.
|
|
277
|
+
const callerSkills = input.skills;
|
|
278
|
+
let resolvedSkills;
|
|
279
|
+
if (callerSkills !== undefined && callerSkills.length > 0) {
|
|
280
|
+
resolvedSkills = callerSkills;
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const profileSkills = await listSkills(token, profileId);
|
|
284
|
+
if (profileSkills.length === 0) {
|
|
285
|
+
throw new PortfolioError("VALIDATION_ERROR", "Cannot create portfolio item: profile has no skills. " +
|
|
286
|
+
"Add at least one skill via `ttctl profile skills add <name>`, " +
|
|
287
|
+
"then retry — or pass `skills` explicitly.");
|
|
288
|
+
}
|
|
289
|
+
// The first skill set is typically the talent's primary skill;
|
|
290
|
+
// users who want a different tag association can update the item
|
|
291
|
+
// afterward (or thread `skills` explicitly).
|
|
292
|
+
const first = profileSkills[0];
|
|
293
|
+
if (first === undefined) {
|
|
294
|
+
throw new PortfolioError("UNKNOWN", "Skills list non-empty but indexed lookup returned undefined.");
|
|
295
|
+
}
|
|
296
|
+
resolvedSkills = [{ id: first.skill.id, name: first.skill.name }];
|
|
297
|
+
}
|
|
298
|
+
const portfolioItem = {
|
|
299
|
+
kind: PORTFOLIO_ITEM_KIND.BASIC,
|
|
300
|
+
showViaToptal: true,
|
|
301
|
+
description: DEFAULT_PORTFOLIO_DESCRIPTION,
|
|
302
|
+
publicationPermit: true,
|
|
303
|
+
...input,
|
|
304
|
+
// `skills` is set AFTER the spread so the resolved default applies
|
|
305
|
+
// even when the caller passed an empty array (the server treats `[]`
|
|
306
|
+
// identically to `null` for this field).
|
|
307
|
+
skills: resolvedSkills,
|
|
308
|
+
};
|
|
309
|
+
const variables = {
|
|
310
|
+
input: { profileId, portfolioItem },
|
|
311
|
+
};
|
|
312
|
+
const res = await withTransportErrors("createPortfolioItem", async () => impersonatedTransport({
|
|
313
|
+
surface: "talent-profile",
|
|
314
|
+
authToken: token,
|
|
315
|
+
body: {
|
|
316
|
+
operationName: "createPortfolioItem",
|
|
317
|
+
query: CREATE_PORTFOLIO_ITEM_MUTATION,
|
|
318
|
+
variables,
|
|
319
|
+
},
|
|
320
|
+
}));
|
|
321
|
+
const payload = unwrapResponse(res, "createPortfolioItem");
|
|
322
|
+
rejectIfUserErrors(payload.errors, "createPortfolioItem");
|
|
323
|
+
if (payload.success === false) {
|
|
324
|
+
throw new PortfolioError("USER_ERROR", `createPortfolioItem reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
|
|
325
|
+
}
|
|
326
|
+
if (!payload.profile) {
|
|
327
|
+
throw new PortfolioError("UNKNOWN", "createPortfolioItem succeeded but response had no profile payload.");
|
|
328
|
+
}
|
|
329
|
+
return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Full-document `updatePortfolioItem` mutation.
|
|
333
|
+
*
|
|
334
|
+
* **[INFERRED]** Wrapper key `portfolioItem` per Pattern 1.
|
|
335
|
+
*/
|
|
336
|
+
const UPDATE_PORTFOLIO_ITEM_MUTATION = `mutation updatePortfolioItem($input: UpdatePortfolioItemInput!) {
|
|
337
|
+
updatePortfolioItem(input: $input) {
|
|
338
|
+
profile {
|
|
339
|
+
id
|
|
340
|
+
portfolioItems {
|
|
341
|
+
nodes { ${PORTFOLIO_NODE_SELECTION} }
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
success
|
|
345
|
+
notice
|
|
346
|
+
errors {
|
|
347
|
+
code
|
|
348
|
+
key
|
|
349
|
+
message
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}`;
|
|
353
|
+
/**
|
|
354
|
+
* Update a portfolio item by id. Conceptually a partial update — callers
|
|
355
|
+
* supply only the fields they want to change — but the server's
|
|
356
|
+
* `updatePortfolioItem` mutation enforces full-replace semantics on
|
|
357
|
+
* `PortfolioItemUpdateInput` non-null fields. Verified live 2026-05-16
|
|
358
|
+
* (probe `.tmp/probe-portfolio-update-shape2.mjs`): the update input
|
|
359
|
+
* shape DIFFERS from create:
|
|
360
|
+
*
|
|
361
|
+
* - GQL-level non-null required: `showViaToptal`, `skills`.
|
|
362
|
+
* - USER_ERROR (`code: blank`) if missing: `description` (≥200 chars),
|
|
363
|
+
* `publicationPermit`, `industries` (i.e. `industryIds`).
|
|
364
|
+
* - **NOT defined** on `PortfolioItemUpdateInput` (rejected at GQL
|
|
365
|
+
* layer): `kind`, `coverImage`. These are write-once on create; to
|
|
366
|
+
* change a cover, the caller goes through `uploadCover()` →
|
|
367
|
+
* separate mutation, not via `update`.
|
|
368
|
+
*
|
|
369
|
+
* To preserve the partial-update UX over the full-replace wire shape,
|
|
370
|
+
* this function does read-modify-write: fetch the current item, merge
|
|
371
|
+
* caller's `changes` on top of the current state, then send the merged
|
|
372
|
+
* input. The mutation response carries the full post-mutation list,
|
|
373
|
+
* which is returned to callers as `PortfolioItem[]`.
|
|
374
|
+
*
|
|
375
|
+
* The function ACCEPTS the broader `PortfolioItemInput` shape (which
|
|
376
|
+
* still types `kind` / `coverImage`) so callers don't need a separate
|
|
377
|
+
* partial type, but it INTENTIONALLY DROPS those fields from the merged
|
|
378
|
+
* payload before sending — keeping them would surface a confusing
|
|
379
|
+
* "Field is not defined on PortfolioItemUpdateInput" GraphQL error.
|
|
380
|
+
*/
|
|
381
|
+
export async function update(token, id, changes) {
|
|
382
|
+
if (Object.keys(changes).length === 0) {
|
|
383
|
+
throw new PortfolioError("VALIDATION_ERROR", "Portfolio update requires at least one field.");
|
|
384
|
+
}
|
|
385
|
+
// Read-modify-write: fetch current state of the target item so we can
|
|
386
|
+
// satisfy the server's non-null requirements on update. `list()` is the
|
|
387
|
+
// single source of truth for the current shape — using it keeps the
|
|
388
|
+
// selection-set in sync with `mapPortfolioNode`.
|
|
389
|
+
const current = (await list(token)).find((it) => it.id === id);
|
|
390
|
+
if (!current) {
|
|
391
|
+
throw new PortfolioError("VALIDATION_ERROR", `Portfolio item ${id} not found.`);
|
|
392
|
+
}
|
|
393
|
+
// Build the merged input: current state as base, caller's `changes` as
|
|
394
|
+
// overrides. The wire shape uses `industryIds: string[]` (catalog IDs)
|
|
395
|
+
// whereas the read shape returns `industries: { id, name }[]` — bridge
|
|
396
|
+
// by projecting current.industries to ids. Caller's `industryIds`
|
|
397
|
+
// (when supplied via `changes`) wins over the projected current set.
|
|
398
|
+
//
|
|
399
|
+
// Conditional spread is required by `exactOptionalPropertyTypes: true`:
|
|
400
|
+
// a `T?: string` field cannot be assigned `undefined` (only omitted or
|
|
401
|
+
// a value), so each potentially-null current field becomes a `&& {...}`
|
|
402
|
+
// spread that contributes nothing when the current value is null.
|
|
403
|
+
//
|
|
404
|
+
// `kind` and `coverImage` are intentionally NOT spread from current —
|
|
405
|
+
// `PortfolioItemUpdateInput` does not define them. Caller's `changes`
|
|
406
|
+
// are merged below; we strip these two fields out of the final payload.
|
|
407
|
+
const merged = {
|
|
408
|
+
...(current.title !== null && { title: current.title }),
|
|
409
|
+
...(current.description !== null && { description: current.description }),
|
|
410
|
+
...(current.link !== null && { link: current.link }),
|
|
411
|
+
...(current.websiteUrl !== null && { websiteUrl: current.websiteUrl }),
|
|
412
|
+
...(current.accomplishment !== null && { accomplishment: current.accomplishment }),
|
|
413
|
+
...(current.publicationPermit !== null && { publicationPermit: current.publicationPermit }),
|
|
414
|
+
...(current.clientOrCompanyName !== null && { clientOrCompanyName: current.clientOrCompanyName }),
|
|
415
|
+
...(current.toptalRelated !== null && { toptalRelated: current.toptalRelated }),
|
|
416
|
+
...(current.showViaToptal !== null && { showViaToptal: current.showViaToptal }),
|
|
417
|
+
highlight: current.highlight,
|
|
418
|
+
skills: current.skills,
|
|
419
|
+
industryIds: current.industries.map((i) => i.id),
|
|
420
|
+
...changes,
|
|
421
|
+
};
|
|
422
|
+
// Strip the two fields rejected by `PortfolioItemUpdateInput` even if
|
|
423
|
+
// a caller passed them in `changes` — the typed `PortfolioItemInput`
|
|
424
|
+
// is shared with `add()` which DOES accept them. Deleting here keeps
|
|
425
|
+
// the call-site UX uniform: callers can pass `kind` / `coverImage`
|
|
426
|
+
// through the same typed dict; we'll quietly drop them on the update
|
|
427
|
+
// path rather than crashing with a server-side GraphQL error.
|
|
428
|
+
delete merged.kind;
|
|
429
|
+
delete merged.coverImage;
|
|
430
|
+
const variables = {
|
|
431
|
+
input: { portfolioItemId: id, portfolioItem: merged },
|
|
432
|
+
};
|
|
433
|
+
const res = await withTransportErrors("updatePortfolioItem", async () => impersonatedTransport({
|
|
434
|
+
surface: "talent-profile",
|
|
435
|
+
authToken: token,
|
|
436
|
+
body: {
|
|
437
|
+
operationName: "updatePortfolioItem",
|
|
438
|
+
query: UPDATE_PORTFOLIO_ITEM_MUTATION,
|
|
439
|
+
variables,
|
|
440
|
+
},
|
|
441
|
+
}));
|
|
442
|
+
const payload = unwrapResponse(res, "updatePortfolioItem");
|
|
443
|
+
rejectIfUserErrors(payload.errors, "updatePortfolioItem");
|
|
444
|
+
if (payload.success === false) {
|
|
445
|
+
throw new PortfolioError("USER_ERROR", `updatePortfolioItem reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
|
|
446
|
+
}
|
|
447
|
+
if (!payload.profile) {
|
|
448
|
+
throw new PortfolioError("UNKNOWN", "updatePortfolioItem succeeded but response had no profile payload.");
|
|
449
|
+
}
|
|
450
|
+
return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
|
|
451
|
+
}
|
|
452
|
+
/** Full-document `removePortfolioItem` mutation. Pattern 3 (`Remove<Entity>Input`). */
|
|
453
|
+
const REMOVE_PORTFOLIO_ITEM_MUTATION = `mutation removePortfolioItem($input: RemovePortfolioItemInput!) {
|
|
454
|
+
removePortfolioItem(input: $input) {
|
|
455
|
+
profile {
|
|
456
|
+
id
|
|
457
|
+
portfolioItems {
|
|
458
|
+
nodes { ${PORTFOLIO_NODE_SELECTION} }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
success
|
|
462
|
+
notice
|
|
463
|
+
errors {
|
|
464
|
+
code
|
|
465
|
+
key
|
|
466
|
+
message
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}`;
|
|
470
|
+
/** Remove a portfolio item by id. Returns the post-removal list. */
|
|
471
|
+
export async function remove(token, id) {
|
|
472
|
+
const variables = { input: { portfolioItemId: id } };
|
|
473
|
+
const res = await withTransportErrors("removePortfolioItem", async () => impersonatedTransport({
|
|
474
|
+
surface: "talent-profile",
|
|
475
|
+
authToken: token,
|
|
476
|
+
body: {
|
|
477
|
+
operationName: "removePortfolioItem",
|
|
478
|
+
query: REMOVE_PORTFOLIO_ITEM_MUTATION,
|
|
479
|
+
variables,
|
|
480
|
+
},
|
|
481
|
+
}));
|
|
482
|
+
const payload = unwrapResponse(res, "removePortfolioItem");
|
|
483
|
+
rejectIfUserErrors(payload.errors, "removePortfolioItem");
|
|
484
|
+
if (!payload.profile) {
|
|
485
|
+
throw new PortfolioError("UNKNOWN", "removePortfolioItem succeeded but response had no profile payload.");
|
|
486
|
+
}
|
|
487
|
+
return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Full-document `changePortfolioItemPosition` mutation. Pattern 5
|
|
491
|
+
* (`Change<Entity>PositionInput { <entity>Id, position }`).
|
|
492
|
+
*/
|
|
493
|
+
const CHANGE_PORTFOLIO_ITEM_POSITION_MUTATION = `mutation changePortfolioItemPosition($input: ChangePortfolioItemPositionInput!) {
|
|
494
|
+
changePortfolioItemPosition(input: $input) {
|
|
495
|
+
profile {
|
|
496
|
+
id
|
|
497
|
+
portfolioItems {
|
|
498
|
+
nodes { ${PORTFOLIO_NODE_SELECTION} }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
success
|
|
502
|
+
notice
|
|
503
|
+
errors {
|
|
504
|
+
code
|
|
505
|
+
key
|
|
506
|
+
message
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}`;
|
|
510
|
+
/**
|
|
511
|
+
* Reorder portfolio items by setting `id` to the absolute `position`. The
|
|
512
|
+
* CLI surface translates the more user-friendly `--before <id>` /
|
|
513
|
+
* `--after <id>` flags to this absolute position; the helpers
|
|
514
|
+
* {@link positionBefore} / {@link positionAfter} compute the position
|
|
515
|
+
* given the current list.
|
|
516
|
+
*
|
|
517
|
+
* Wire shape (verified live 2026-05-16, probe
|
|
518
|
+
* `.tmp/probe-portfolio-reorder.mjs`): the position lives INSIDE
|
|
519
|
+
* `portfolioItem: { position: Int! }` — NOT at the top level of
|
|
520
|
+
* `ChangePortfolioItemPositionInput`. The naive `{ portfolioItemId,
|
|
521
|
+
* position }` shape is rejected with "Field is not defined on
|
|
522
|
+
* ChangePortfolioItemPositionInput" (for `position`) and
|
|
523
|
+
* "portfolioItem (Expected value to not be null)".
|
|
524
|
+
*/
|
|
525
|
+
export async function reorder(token, id, position) {
|
|
526
|
+
if (!Number.isInteger(position) || position < 0) {
|
|
527
|
+
throw new PortfolioError("VALIDATION_ERROR", "Portfolio reorder position must be a non-negative integer.");
|
|
528
|
+
}
|
|
529
|
+
const variables = { input: { portfolioItemId: id, portfolioItem: { position } } };
|
|
530
|
+
const res = await withTransportErrors("changePortfolioItemPosition", async () => impersonatedTransport({
|
|
531
|
+
surface: "talent-profile",
|
|
532
|
+
authToken: token,
|
|
533
|
+
body: {
|
|
534
|
+
operationName: "changePortfolioItemPosition",
|
|
535
|
+
query: CHANGE_PORTFOLIO_ITEM_POSITION_MUTATION,
|
|
536
|
+
variables,
|
|
537
|
+
},
|
|
538
|
+
}));
|
|
539
|
+
const payload = unwrapResponse(res, "changePortfolioItemPosition");
|
|
540
|
+
rejectIfUserErrors(payload.errors, "changePortfolioItemPosition");
|
|
541
|
+
if (!payload.profile) {
|
|
542
|
+
throw new PortfolioError("UNKNOWN", "changePortfolioItemPosition succeeded but response had no profile payload.");
|
|
543
|
+
}
|
|
544
|
+
return payload.profile.portfolioItems.nodes.map(mapPortfolioNode);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Compute the absolute position (0-based) the moved item should land at,
|
|
548
|
+
* given a target item to be placed BEFORE. Returns `null` if `targetId`
|
|
549
|
+
* is not in the list. The current list is required because the server's
|
|
550
|
+
* `changePortfolioItemPosition` takes an absolute index, not a relative
|
|
551
|
+
* one — the CLI computes the index from a friendlier neighbour-anchored
|
|
552
|
+
* flag.
|
|
553
|
+
*
|
|
554
|
+
* Verified live 2026-05-16: the server interprets `position` against the
|
|
555
|
+
* POST-REMOVAL list (i.e. it first removes the moving item, then inserts
|
|
556
|
+
* at `position`). Valid range is `[0, N-1]` where `N` is the original
|
|
557
|
+
* list length; `position = N` (= the naive "after the last item")
|
|
558
|
+
* returns `USER_ERROR code: invalidPosition` ("Position should be
|
|
559
|
+
* greater or equal to 0 and less than the number of items").
|
|
560
|
+
*
|
|
561
|
+
* When `movingId` is supplied AND points at an item that exists in
|
|
562
|
+
* `items`, that item is filtered out before computing the target's
|
|
563
|
+
* index — this correctly handles the case where the moving item is
|
|
564
|
+
* already in the list and sits before the target (removing it shifts
|
|
565
|
+
* the target left by 1).
|
|
566
|
+
*/
|
|
567
|
+
export function positionBefore(items, targetId, movingId) {
|
|
568
|
+
const filtered = movingId !== undefined ? items.filter((it) => it.id !== movingId) : items;
|
|
569
|
+
const idx = filtered.findIndex((it) => it.id === targetId);
|
|
570
|
+
return idx === -1 ? null : idx;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Compute the absolute position the moved item should land at, given a
|
|
574
|
+
* target item to be placed AFTER. Returns `null` if `targetId` is not in
|
|
575
|
+
* the list.
|
|
576
|
+
*
|
|
577
|
+
* Same post-removal semantics as {@link positionBefore} — pass the
|
|
578
|
+
* `movingId` so the helper can filter it out of `items` before
|
|
579
|
+
* computing the target's index. Without `movingId`, the helper assumes
|
|
580
|
+
* the moving item is not present and computes `idx + 1` directly; that
|
|
581
|
+
* value will be one too high for the common "move A `--after` B" CLI
|
|
582
|
+
* flow when both items are in the list and A is before B, and the
|
|
583
|
+
* server will reject it.
|
|
584
|
+
*/
|
|
585
|
+
export function positionAfter(items, targetId, movingId) {
|
|
586
|
+
const filtered = movingId !== undefined ? items.filter((it) => it.id !== movingId) : items;
|
|
587
|
+
const idx = filtered.findIndex((it) => it.id === targetId);
|
|
588
|
+
return idx === -1 ? null : idx + 1;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* Full-document `highlightPortfolioItem` mutation. Pattern 4
|
|
592
|
+
* (`Highlight<Entity>Input { <entity>Id, highlight }`). The operation
|
|
593
|
+
* source-of-truth is `research/graphql/talent_profile/operations/highlightPortfolioItem.graphql`.
|
|
594
|
+
*
|
|
595
|
+
* Currently TTCtl exposes a single `highlight <id>` toggle that flips the
|
|
596
|
+
* highlight state on; un-highlighting via this surface lands as a
|
|
597
|
+
* follow-up if the use case surfaces.
|
|
598
|
+
*/
|
|
599
|
+
const HIGHLIGHT_PORTFOLIO_ITEM_MUTATION = `mutation highlightPortfolioItem($id: ID!, $highlight: Boolean!) {
|
|
600
|
+
highlightPortfolioItem(input: { portfolioItemId: $id, highlight: $highlight }) {
|
|
601
|
+
portfolioItem {
|
|
602
|
+
id
|
|
603
|
+
highlight
|
|
604
|
+
}
|
|
605
|
+
success
|
|
606
|
+
notice
|
|
607
|
+
errors {
|
|
608
|
+
code
|
|
609
|
+
key
|
|
610
|
+
message
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}`;
|
|
614
|
+
/**
|
|
615
|
+
* Set or clear the "highlight" flag on a portfolio item. Returns the
|
|
616
|
+
* minimal `{ id, highlight }` projection from the mutation payload —
|
|
617
|
+
* fetching the full updated item is a separate `list()` round-trip when
|
|
618
|
+
* needed.
|
|
619
|
+
*/
|
|
620
|
+
export async function highlight(token, id, flag = true) {
|
|
621
|
+
const res = await withTransportErrors("highlightPortfolioItem", async () => impersonatedTransport({
|
|
622
|
+
surface: "talent-profile",
|
|
623
|
+
authToken: token,
|
|
624
|
+
body: {
|
|
625
|
+
operationName: "highlightPortfolioItem",
|
|
626
|
+
query: HIGHLIGHT_PORTFOLIO_ITEM_MUTATION,
|
|
627
|
+
variables: { id, highlight: flag },
|
|
628
|
+
},
|
|
629
|
+
}));
|
|
630
|
+
const payload = unwrapResponse(res, "highlightPortfolioItem");
|
|
631
|
+
rejectIfUserErrors(payload.errors, "highlightPortfolioItem");
|
|
632
|
+
if (!payload.portfolioItem) {
|
|
633
|
+
throw new PortfolioError("UNKNOWN", "highlightPortfolioItem succeeded but response had no portfolioItem payload.");
|
|
634
|
+
}
|
|
635
|
+
return { id: payload.portfolioItem.id, highlight: payload.portfolioItem.highlight };
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Resolve a {@link FileSource} into a {@link MultipartFile} ready for the
|
|
639
|
+
* multipart body. Path-mode reads the file off disk; buffer-mode is a
|
|
640
|
+
* pass-through.
|
|
641
|
+
*/
|
|
642
|
+
async function resolveFileSource(source, fieldLabel) {
|
|
643
|
+
if (source.kind === "buffer") {
|
|
644
|
+
return {
|
|
645
|
+
filename: source.filename,
|
|
646
|
+
content: source.content,
|
|
647
|
+
...(source.contentType !== undefined ? { contentType: source.contentType } : {}),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
let content;
|
|
651
|
+
try {
|
|
652
|
+
content = await readFile(source.path);
|
|
653
|
+
}
|
|
654
|
+
catch (err) {
|
|
655
|
+
const code = err.code;
|
|
656
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
657
|
+
if (code === "ENOENT") {
|
|
658
|
+
throw new PortfolioError("FILE_NOT_FOUND", `${fieldLabel}: file not found: ${source.path}`);
|
|
659
|
+
}
|
|
660
|
+
throw new PortfolioError("FILE_READ_ERROR", `${fieldLabel}: failed to read ${source.path}: ${message}`);
|
|
661
|
+
}
|
|
662
|
+
return { filename: basename(source.path), content };
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Full-document `uploadPortfolioCover` mutation. The `file` variable is a
|
|
666
|
+
* GraphQL `Upload` slot — the multipart map binds it to the
|
|
667
|
+
* `variables.file` path.
|
|
668
|
+
*
|
|
669
|
+
* `transformation` is `PortfolioCoverImageTransformationInput!` with four
|
|
670
|
+
* non-null `Int!` fields — `cropX`, `cropY`, `cropW`, `cropH` (verified
|
|
671
|
+
* live 2026-05-16, probe `.tmp/probe-cover-crop.mjs`). Sending `{}` is
|
|
672
|
+
* rejected at the GQL layer ("Expected value to not be null"). All four
|
|
673
|
+
* fields must be integers in PIXEL units. The server is tolerant of
|
|
674
|
+
* oversized crop boxes (e.g. `cropW: 99999`) and will clamp; it does
|
|
675
|
+
* NOT support normalized 0..1 units.
|
|
676
|
+
*
|
|
677
|
+
* The server also enforces a minimum cover-image dimension of
|
|
678
|
+
* **750 × 500 px** (USER_ERROR `code: invalidImage` —
|
|
679
|
+
* "This image must be at least 750 x 500px and under 5MB to be
|
|
680
|
+
* accepted"). Callers are responsible for ensuring the uploaded file
|
|
681
|
+
* meets this bound; this service does not pre-validate dimensions.
|
|
682
|
+
*/
|
|
683
|
+
const UPLOAD_PORTFOLIO_COVER_MUTATION = `mutation uploadPortfolioCover(
|
|
684
|
+
$profileId: ID!
|
|
685
|
+
$transformation: PortfolioCoverImageTransformationInput!
|
|
686
|
+
$file: Upload!
|
|
687
|
+
) {
|
|
688
|
+
uploadPortfolioCoverImage(
|
|
689
|
+
input: { profileId: $profileId, transformation: $transformation, file: $file }
|
|
690
|
+
) {
|
|
691
|
+
coverImageCacheName
|
|
692
|
+
coverImageUrl
|
|
693
|
+
success
|
|
694
|
+
notice
|
|
695
|
+
errors {
|
|
696
|
+
code
|
|
697
|
+
key
|
|
698
|
+
message
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}`;
|
|
702
|
+
/**
|
|
703
|
+
* Read width/height from a PNG file's IHDR chunk. PNG signature is
|
|
704
|
+
* 8 bytes; the IHDR chunk follows with a 4-byte length, "IHDR" type,
|
|
705
|
+
* then 4-byte width, 4-byte height (big-endian uint32s). Returns
|
|
706
|
+
* `null` for non-PNG inputs or short buffers (caller falls back to a
|
|
707
|
+
* sentinel oversized crop).
|
|
708
|
+
*/
|
|
709
|
+
function parsePngDimensions(buffer) {
|
|
710
|
+
if (buffer.length < 24)
|
|
711
|
+
return null;
|
|
712
|
+
const isPng = buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47 && buffer[4] === 0x0d;
|
|
713
|
+
if (!isPng)
|
|
714
|
+
return null;
|
|
715
|
+
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
|
716
|
+
const width = view.getUint32(16);
|
|
717
|
+
const height = view.getUint32(20);
|
|
718
|
+
if (width <= 0 || height <= 0)
|
|
719
|
+
return null;
|
|
720
|
+
return { width, height };
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Upload a cover image for a portfolio item. The cover image is bound to
|
|
724
|
+
* the user's profile rather than a specific portfolio item — the response
|
|
725
|
+
* carries a `coverImageCacheName` the caller passes back into a
|
|
726
|
+
* subsequent `update()` call (or `add()` for new items) via the
|
|
727
|
+
* `coverImage` field.
|
|
728
|
+
*
|
|
729
|
+
* The `transformation` parameter is optional. When omitted, the service
|
|
730
|
+
* resolves a sensible default: PNG sources have their dimensions read
|
|
731
|
+
* from the IHDR header and used as the whole-image crop
|
|
732
|
+
* `(0, 0, width, height)`; non-PNG sources fall back to a large
|
|
733
|
+
* bounding box `(0, 0, 99999, 99999)` (the server clamps oversized
|
|
734
|
+
* crops). Callers who need explicit control pass `transformation`
|
|
735
|
+
* directly.
|
|
736
|
+
*
|
|
737
|
+
* The server enforces a minimum cover-image size of 750x500px — the
|
|
738
|
+
* service does NOT pre-validate this; callers receive a `USER_ERROR`
|
|
739
|
+
* (`code: invalidImage`) when the image is too small.
|
|
740
|
+
*/
|
|
741
|
+
export async function uploadCover(token, source, transformation) {
|
|
742
|
+
const profileId = await extractProfileId(token);
|
|
743
|
+
const file = await resolveFileSource(source, "uploadCover");
|
|
744
|
+
const resolvedTransformation = transformation ?? defaultCoverTransformation(file.content);
|
|
745
|
+
const res = await withTransportErrors("uploadPortfolioCover", async () => impersonatedMultipartTransport({
|
|
746
|
+
surface: "talent-profile",
|
|
747
|
+
authToken: token,
|
|
748
|
+
body: {
|
|
749
|
+
operationName: "uploadPortfolioCover",
|
|
750
|
+
query: UPLOAD_PORTFOLIO_COVER_MUTATION,
|
|
751
|
+
variables: {
|
|
752
|
+
profileId,
|
|
753
|
+
transformation: resolvedTransformation,
|
|
754
|
+
file: null,
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
files: { "0": file },
|
|
758
|
+
map: { "0": ["variables.file"] },
|
|
759
|
+
}));
|
|
760
|
+
const payload = unwrapResponse(res, "uploadPortfolioCover");
|
|
761
|
+
rejectIfUserErrors(payload.errors, "uploadPortfolioCover");
|
|
762
|
+
if (payload.success === false) {
|
|
763
|
+
throw new PortfolioError("USER_ERROR", `uploadPortfolioCover reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
coverImageCacheName: payload.coverImageCacheName ?? null,
|
|
767
|
+
coverImageUrl: payload.coverImageUrl ?? null,
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Sentinel oversized crop bounding box used when image dimensions
|
|
772
|
+
* cannot be statically inferred (non-PNG, malformed PNG). The server
|
|
773
|
+
* clamps oversized crops to the actual image extent.
|
|
774
|
+
*/
|
|
775
|
+
const OVERSIZED_CROP = { cropX: 0, cropY: 0, cropW: 99999, cropH: 99999 };
|
|
776
|
+
function defaultCoverTransformation(content) {
|
|
777
|
+
const dims = parsePngDimensions(content);
|
|
778
|
+
if (dims) {
|
|
779
|
+
return { cropX: 0, cropY: 0, cropW: dims.width, cropH: dims.height };
|
|
780
|
+
}
|
|
781
|
+
return OVERSIZED_CROP;
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Full-document `uploadPortfolioFile` mutation. Variant of `uploadCover`
|
|
785
|
+
* for arbitrary attachment files (not necessarily images). Same
|
|
786
|
+
* multipart-map shape; the response carries a `fileCacheName` /
|
|
787
|
+
* `fileUrl` pair.
|
|
788
|
+
*/
|
|
789
|
+
const UPLOAD_PORTFOLIO_FILE_MUTATION = `mutation uploadPortfolioFile($profileId: ID!, $file: Upload!) {
|
|
790
|
+
uploadPortfolioFile(input: { profileId: $profileId, file: $file }) {
|
|
791
|
+
fileCacheName
|
|
792
|
+
fileUrl
|
|
793
|
+
success
|
|
794
|
+
notice
|
|
795
|
+
errors {
|
|
796
|
+
code
|
|
797
|
+
key
|
|
798
|
+
message
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}`;
|
|
802
|
+
/**
|
|
803
|
+
* Upload a file attachment associated with the user's portfolio. Returns
|
|
804
|
+
* the cache name + url; the CLI surface prints both for downstream use.
|
|
805
|
+
*/
|
|
806
|
+
export async function uploadFile(token, source) {
|
|
807
|
+
const profileId = await extractProfileId(token);
|
|
808
|
+
const file = await resolveFileSource(source, "uploadFile");
|
|
809
|
+
const res = await withTransportErrors("uploadPortfolioFile", async () => impersonatedMultipartTransport({
|
|
810
|
+
surface: "talent-profile",
|
|
811
|
+
authToken: token,
|
|
812
|
+
body: {
|
|
813
|
+
operationName: "uploadPortfolioFile",
|
|
814
|
+
query: UPLOAD_PORTFOLIO_FILE_MUTATION,
|
|
815
|
+
variables: {
|
|
816
|
+
profileId,
|
|
817
|
+
file: null,
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
files: { "0": file },
|
|
821
|
+
map: { "0": ["variables.file"] },
|
|
822
|
+
}));
|
|
823
|
+
const payload = unwrapResponse(res, "uploadPortfolioFile");
|
|
824
|
+
rejectIfUserErrors(payload.errors, "uploadPortfolioFile");
|
|
825
|
+
if (payload.success === false) {
|
|
826
|
+
throw new PortfolioError("USER_ERROR", `uploadPortfolioFile reported success=false${payload.notice ? `: ${payload.notice}` : ""}`);
|
|
827
|
+
}
|
|
828
|
+
return {
|
|
829
|
+
fileCacheName: payload.fileCacheName ?? null,
|
|
830
|
+
fileUrl: payload.fileUrl ?? null,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
//# sourceMappingURL=index.js.map
|