Sign In

@ttctl/core

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@ttctl/core - npm Package Compare versions

Comparing version
0.2.0
to
0.2.1
+19
-11
dist/services/surveys/index.d.ts

@@ -30,6 +30,10 @@ /**

/**
* A single survey question. `inputType` describes how the answer is
* collected (e.g. a rating scale, free text); `answers` enumerates the
* selectable options (empty for free-text and checkbox questions). Downstream
* `surveys submit` / `surveys feedback` consume `id` + `inputType` +
* A single survey question. `inputType` is a `SurveyInputTypeEnum` value
* describing how the answer is collected (a rating scale, free text, …);
* `answers` enumerates the selectable options.
*
* `answers` emptiness does NOT classify the question: an `OPEN_TEXT` question
* on an `ENGAGEMENT_ENDED` survey arrives carrying one sentinel option, while
* the same type on `MID_ENGAGEMENT` arrives with none (#877). `inputType` is
* the authority. Downstream `surveys submit` consumes `id` + `inputType` +
* `answers[].value` to build a valid response.

@@ -79,5 +83,5 @@ */

* `id` is the selected {@link SurveyAnswerOption} id for a multiple-choice
* question and `null` for free-text; `value` is the option's `value`
* (multiple-choice) or the free-text answer. Matches the `SurveyAnswerInput`
* wire shape verified by a live round-trip (2026-05-29).
* question and `null` for every other treatment; `value` is the option's
* `value` (multiple-choice) or the caller's answer. Matches the
* `SurveyAnswerInput` wire shape verified by a live round-trip (2026-05-29).
*/

@@ -90,6 +94,10 @@ export interface SurveyAnswerInput {

/**
* A caller-supplied answer before resolution. For a multiple-choice
* question the `value` is matched against the question's answer options to
* recover the option id; a free-text value is sent verbatim and a checkbox
* takes `"true"`/`"false"`. {@link buildSurveyAnswers} performs the resolution.
* A caller-supplied answer before resolution. For a multiple-choice question
* (`RADIO_BUTTONS` / `RATING` / `SLIDER`) the `value` is matched against the
* question's answer options to recover the option id; a free-text
* (`OPEN_TEXT`) value is sent verbatim, a `CHECKBOX` takes `"true"`/`"false"`,
* and a `PROPOSED_ENGAGEMENT_END_DATE` sends the caller's date (or the
* `"I do not know"` sentinel) with no option id. The question's `inputType` —
* not its `answers` length — selects the treatment.
* {@link buildSurveyAnswers} performs the resolution.
*/

@@ -96,0 +104,0 @@ export interface RawSurveyAnswer {

@@ -139,21 +139,58 @@ // SPDX-License-Identifier: AGPL-3.0-only

}`;
// ---------------------------------------------------------------------
// submit — answer resolution
// ---------------------------------------------------------------------
/**
* `SurveyQuestion.inputType` for a boolean checkbox question — carries no
* answer options yet requires a stringified-boolean value (e.g. the mandatory
* "This interview didn't occur." question on an `INTERVIEW_ENDED` survey).
* Exhaustive `inputType` → answer-shape classification, keyed on the GENERATED
* `SurveyInputTypeEnum` so a member added by `pnpm codegen` stops this map
* compiling until it is classified, instead of falling through to
* option-matching.
*
* That gate catches a REGENERATED schema, not a new value on the wire —
* `inputType` is `String!` in the SDL and the enum is an orphan no field
* references — which is why unrecognized values route to `unmodelled`.
*/
const CHECKBOX_INPUT_TYPE = "CHECKBOX";
const ANSWER_SHAPE_BY_INPUT_TYPE = {
CHECKBOX: "boolean",
OPEN_TEXT: "free-text",
PROPOSED_ENGAGEMENT_END_DATE: "date",
RADIO_BUTTONS: "option",
RATING: "option",
SLIDER: "option",
};
/**
* Classify a question's declared `inputType`. Matching is EXACT: the two values
* grounded in live wire evidence are `CHECKBOX` (#754) and `OPEN_TEXT` (#877),
* both upper-case, and case-folding a near-miss into a modelled branch would
* change how an already-working question is answered — a lower-case
* `"checkbox"` would flip from verbatim pass-through to boolean normalization.
* Every value the schema does not declare is `unmodelled`, and every
* `unmodelled` path behaves exactly as it did before #877.
*/
function classifyInputType(inputType) {
if (inputType !== null && Object.hasOwn(ANSWER_SHAPE_BY_INPUT_TYPE, inputType)) {
return ANSWER_SHAPE_BY_INPUT_TYPE[inputType];
}
return "unmodelled";
}
/** `"<id>" (<label>)`, or just `"<id>"` when the question carries no label. */
function describeQuestion(question) {
return question.label === null ? `"${question.id}"` : `"${question.id}" (${question.label})`;
}
/**
* Resolve caller-supplied {@link RawSurveyAnswer}s against a {@link Survey}
* into wire {@link SurveyAnswerInput}s. A question carrying answer options
* (multiple-choice) has its `value` matched against an option's `value` and
* the option `id` attached; an option-less question sends the `value` with a
* `null` id — verbatim for free-text, or `"true"`/`"false"` for a checkbox
* ({@link CHECKBOX_INPUT_TYPE}, which has no options but accepts only a
* stringified boolean). Throws `SurveysError(VALIDATION_ERROR)` for an unknown
* question id, a value matching no option of a multiple-choice question, or a
* non-boolean checkbox value.
* into wire {@link SurveyAnswerInput}s.
*
* An option-less question sends its `value` with a `null` id — verbatim, or
* `"true"`/`"false"` for a `CHECKBOX`. An option-bearing question has its
* `value` matched against an option's `value` and that option's `id` attached.
*
* The declared `inputType` ({@link classifyInputType}) then qualifies the
* option-bearing path, which `answers` emptiness alone cannot: an `OPEN_TEXT`
* question arriving WITH options is refused rather than validated against its
* sentinel, a `PROPOSED_ENGAGEMENT_END_DATE` sends its caller-supplied value
* with a `null` id either way, and a value matching no option is reported as a
* shape mismatch only for a `CHECKBOX` (which should carry none) — every other
* type gets the plain value error.
*
* Throws `SurveysError(VALIDATION_ERROR)` for an unknown question id, a
* non-boolean checkbox value, an unanswerable free-text question, or a value
* matching no option.
*/

@@ -166,8 +203,13 @@ function buildSurveyAnswers(survey, raw) {

}
const shape = classifyInputType(question.inputType);
// A date is caller-supplied, never chosen from `answers[]` — matching it
// against the question's options would attach an id no Toptal client sends.
if (shape === "date") {
return { questionId: answer.questionId, id: null, value: answer.value };
}
if (question.answers.length === 0) {
if (question.inputType === CHECKBOX_INPUT_TYPE) {
if (shape === "boolean") {
const normalized = answer.value.trim().toLowerCase();
if (normalized !== "true" && normalized !== "false") {
const labelHint = question.label === null ? "" : ` (${question.label})`;
throw new SurveysError("VALIDATION_ERROR", `Checkbox question "${answer.questionId}"${labelHint} accepts only "true" or "false", got "${answer.value}".`);
throw new SurveysError("VALIDATION_ERROR", `Checkbox question ${describeQuestion(question)} accepts only "true" or "false", got "${answer.value}".`);
}

@@ -178,2 +220,10 @@ return { questionId: answer.questionId, id: null, value: normalized };

}
// The options are a sentinel vocabulary, not an answer vocabulary.
if (shape === "free-text") {
throw new SurveysError("VALIDATION_ERROR", `ttctl cannot yet answer free-text questions on this survey — answer it in the Toptal ` +
`web portal instead. Question ${describeQuestion(question)} declares inputType ` +
`"OPEN_TEXT" but arrived carrying answer options, a shape ttctl has not validated ` +
`against the live API; submitting a guess would close the survey and forfeit the ` +
`answer. Progress: https://github.com/alexey-pelykh/ttctl/issues/877.`);
}
const option = question.answers.find((o) => o.value === answer.value);

@@ -185,2 +235,12 @@ if (option === undefined) {

.join(", ");
// `CHECKBOX` is the one type whose observed shape genuinely contradicts
// arriving with options. Everything else keeps the plain value error —
// claiming ttctl cannot model a question it answers on a matching value
// would be false, and a typo is not a shape surprise.
if (shape === "boolean") {
throw new SurveysError("VALIDATION_ERROR", `Question ${describeQuestion(question)} declares inputType "CHECKBOX", which takes ` +
`"true"/"false" and carries no options — yet it arrived with them, and "${answer.value}" ` +
`matched none (${valid}). ttctl does not model this question shape; please report it at ` +
`https://github.com/alexey-pelykh/ttctl/issues so it can be answered correctly.`);
}
throw new SurveysError("VALIDATION_ERROR", `"${answer.value}" is not a valid answer for question "${answer.questionId}". Valid values: ${valid}.`);

@@ -197,2 +257,18 @@ }

/**
* Warn on stderr when a submission would leave optional questions unanswered.
* Submitting CLOSES the survey, so an omitted optional question is forfeited
* permanently — silence there is the harm #877 reports. Advisory only: an
* omission is a legitimate choice, so this never blocks. stderr keeps `-o json`
* stdout clean, as `loadConfigFile` does for its file-permission warning.
*/
function warnUnansweredOptional(survey, answers) {
const skipped = survey.questions.filter((q) => q.isMandatory !== true && !answers.some((a) => a.questionId === q.id));
if (skipped.length === 0) {
return;
}
process.stderr.write(`warning: submitting survey "${survey.id}" leaves ${skipped.length.toString()} optional question(s) ` +
`unanswered; submission closes it, so they cannot be answered afterwards: ` +
`${skipped.map(describeQuestion).join(", ")}\n`);
}
/**
* Resolve {@link SubmitSurveyArgs} into a wire {@link ResolvedSubmission} by

@@ -224,3 +300,3 @@ * fetching the pending-survey list and matching the target survey. Pure

if (unanswered.length > 0) {
const names = unanswered.map((q) => (q.label === null ? `"${q.id}"` : `"${q.id}" (${q.label})`)).join(", ");
const names = unanswered.map(describeQuestion).join(", ");
throw new SurveysError("VALIDATION_ERROR", `Survey "${args.surveyId}" has unanswered mandatory question(s): ${names}. Run \`ttctl surveys list\` to see all questions.`);

@@ -232,3 +308,7 @@ }

}
return { kind, surveyId: args.surveyId, answers: buildSurveyAnswers(survey, args.answers) };
const answers = buildSurveyAnswers(survey, args.answers);
// Only once the submission is known to be well-formed — a warning ahead of a
// throw would be noise.
warnUnansweredOptional(survey, args.answers);
return { kind, surveyId: args.surveyId, answers };
}

@@ -235,0 +315,0 @@ /**

{
"name": "@ttctl/core",
"version": "0.2.0",
"version": "0.2.1",
"description": "Core library for Toptal Talent platform integration: API client, auth, services",

@@ -41,6 +41,6 @@ "type": "module",

"@types/proper-lockfile": "^4.1.4",
"eslint": "^10.7.0",
"eslint": "^10.8.1",
"graphql": "^17.0.2",
"typescript": "~6.0.3",
"vitest": "^4.1.9"
"vitest": "^4.1.10"
},

@@ -50,3 +50,3 @@ "dependencies": {

"proper-lockfile": "^4.1.2",
"undici": "^8.5.0",
"undici": "^8.9.0",
"yaml": "^2.9.0",

@@ -53,0 +53,0 @@ "zod": "^4.4.3"