@ttctl/core 0.1.0-rc.6 → 0.1.0-rc.8
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/__generated__/gateway.d.ts +9 -9
- package/dist/__generated__/gateway.d.ts.map +1 -1
- package/dist/__generated__/zod-schemas.d.ts +9 -9
- package/dist/__generated__/zod-schemas.d.ts.map +1 -1
- package/dist/__generated__/zod-schemas.js +9 -9
- package/dist/__generated__/zod-schemas.js.map +1 -1
- package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -1
- package/dist/__tests__/fixtures/profile/builders.js +1 -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 +2 -0
- package/dist/__tests__/fixtures/profile/data.js.map +1 -1
- package/dist/consent.d.ts +236 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/consent.js +225 -0
- package/dist/consent.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/services/applications/index.d.ts +917 -13
- package/dist/services/applications/index.d.ts.map +1 -1
- package/dist/services/applications/index.js +1284 -6
- package/dist/services/applications/index.js.map +1 -1
- package/dist/services/jobs/index.d.ts.map +1 -1
- package/dist/services/jobs/index.js.map +1 -1
- package/dist/services/payments/index.d.ts +66 -0
- package/dist/services/payments/index.d.ts.map +1 -1
- package/dist/services/payments/index.js +120 -0
- package/dist/services/payments/index.js.map +1 -1
- package/dist/services/profile/employment/index.d.ts +50 -4
- package/dist/services/profile/employment/index.d.ts.map +1 -1
- package/dist/services/profile/employment/index.js +91 -16
- package/dist/services/profile/employment/index.js.map +1 -1
- package/dist/services/profile/reviews/index.d.ts +31 -1
- package/dist/services/profile/reviews/index.d.ts.map +1 -1
- package/dist/services/profile/reviews/index.js +19 -1
- package/dist/services/profile/reviews/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (C) 2026 Oleksii PELYKH
|
|
3
|
+
import { JobExpertiseAnswerInputSchema, JobPositionAnswerInputSchema, PitchInputSchema, } from "../../__generated__/zod-schemas.js";
|
|
3
4
|
import { buildDryRunPreview } from "../../transport.js";
|
|
4
5
|
import { callGatewayShared } from "../_shared/transport.js";
|
|
6
|
+
export { JobExpertiseAnswerInputSchema, JobPositionAnswerInputSchema, PitchInputSchema };
|
|
5
7
|
export class ApplicationsError extends Error {
|
|
6
8
|
code;
|
|
7
9
|
name = "ApplicationsError";
|
|
@@ -318,16 +320,20 @@ export async function list(token, opts = {}) {
|
|
|
318
320
|
* Throws `ApplicationsError("NOT_FOUND")` for two distinct wire shapes
|
|
319
321
|
* — both meaning "id doesn't resolve to a viewable item":
|
|
320
322
|
*
|
|
321
|
-
* 1. **Top-level GraphQL error
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* `
|
|
323
|
+
* 1. **Top-level GraphQL error matched by {@link NOT_FOUND_MESSAGE_PATTERN}**
|
|
324
|
+
* — the shared regex covers `Record not found` (the empirical
|
|
325
|
+
* happy-sad path on `JobActivityItem(id:)`, verified live on
|
|
326
|
+
* 2026-05-10), `Invalid ID` (jobs-service precedent), and
|
|
327
|
+
* `Node id ... resolves to ...` (the Relay decode error per
|
|
328
|
+
* `project-toptal-wire-quirks` memory; load-bearing for the
|
|
329
|
+
* pre-apply read suite added in #424 where `viewer.job(id:)`
|
|
330
|
+
* bad-ids surface as Relay decode errors). `callGateway` raises
|
|
331
|
+
* `GRAPHQL_ERROR`; we catch and translate.
|
|
326
332
|
* 2. **Successful response with `viewer.jobActivityItem === null`** —
|
|
327
333
|
* not observed in practice but kept as defensive coverage in case
|
|
328
334
|
* the gateway ever switches to the data-shape sentinel.
|
|
329
335
|
*/
|
|
330
|
-
const NOT_FOUND_MESSAGE_PATTERN = /Record not found/i;
|
|
336
|
+
const NOT_FOUND_MESSAGE_PATTERN = /Record not found|Invalid ID|Node id .*? resolves to/i;
|
|
331
337
|
export async function show(token, id) {
|
|
332
338
|
let data;
|
|
333
339
|
try {
|
|
@@ -390,6 +396,382 @@ export async function stats(token) {
|
|
|
390
396
|
return { total, groups: groupResults };
|
|
391
397
|
}
|
|
392
398
|
// ---------------------------------------------------------------------
|
|
399
|
+
// Trimmed inline query strings for the three pre-apply read ops
|
|
400
|
+
// (#424). Operation NAMES are kept verbatim from the captured wire
|
|
401
|
+
// (`JobApplyData`, `JobApplicationQuestions`,
|
|
402
|
+
// `JobApplicationRateInsight`) — any future server-side allowlisting
|
|
403
|
+
// that gates on operation name continues to recognize them.
|
|
404
|
+
//
|
|
405
|
+
// Schema gaps acknowledged:
|
|
406
|
+
// - `TalentJob.operations { apply { errors } }` — `JobOperationsApply.errors`
|
|
407
|
+
// types are in the schema (`JobOperationsApplyError { code, message }`),
|
|
408
|
+
// but the OP shape itself is captured-only; the captured-op
|
|
409
|
+
// selection is the wire authority.
|
|
410
|
+
// - `TalentJob.expertiseQuestions` — not present in the synthesized
|
|
411
|
+
// schema at all; selection mirrors the captured-op shape verbatim
|
|
412
|
+
// (`{ id subject { ... on Industry / Skill } }`).
|
|
413
|
+
// - `TalentJob.rateInsight(onlyHourlyRates, requestedRate)` — also
|
|
414
|
+
// not in the synthesized schema with that argument signature; the
|
|
415
|
+
// captured op passes both args, schema declares the field with no
|
|
416
|
+
// args (same pattern as `jobActivityList`).
|
|
417
|
+
// - Un-aliased union-member fields on `TalentJobRateInsight`: the
|
|
418
|
+
// captured `JobApplicationRateInsight.graphql` aliases
|
|
419
|
+
// `competitiveRevenue: estimatedRevenue` /
|
|
420
|
+
// `uncompetitiveRevenue: estimatedRevenue` (Apollo client-side
|
|
421
|
+
// normalization hint to avoid same-name-different-meaning
|
|
422
|
+
// collisions on the cache side). The inline query below selects
|
|
423
|
+
// them BARE — spec-conformant per GraphQL FieldsInSetCanMerge
|
|
424
|
+
// since both wire members carry `BigDecimal estimatedRevenue` /
|
|
425
|
+
// `BigDecimal estimatedRevenueExplanation` (same scalar type, so
|
|
426
|
+
// same-name selection across union members merges cleanly).
|
|
427
|
+
// {@link projectRateInsight} consumes the un-aliased response
|
|
428
|
+
// keys. If the server ever rejects un-aliased selections, #445
|
|
429
|
+
// E2E catches it on the live wire (this is a Track 1 op).
|
|
430
|
+
// ---------------------------------------------------------------------
|
|
431
|
+
const JOB_APPLY_DATA_QUERY = `query JobApplyData($jobId: ID!) {
|
|
432
|
+
viewer {
|
|
433
|
+
__typename
|
|
434
|
+
id
|
|
435
|
+
viewerRole {
|
|
436
|
+
__typename
|
|
437
|
+
rates { __typename hourly }
|
|
438
|
+
}
|
|
439
|
+
job(id: $jobId) {
|
|
440
|
+
__typename
|
|
441
|
+
id
|
|
442
|
+
isCoaching
|
|
443
|
+
hasRequiredApplicationPitch
|
|
444
|
+
operations {
|
|
445
|
+
__typename
|
|
446
|
+
apply {
|
|
447
|
+
__typename
|
|
448
|
+
errors { __typename code message }
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
platformConfiguration {
|
|
454
|
+
__typename
|
|
455
|
+
id
|
|
456
|
+
rateValidationRules {
|
|
457
|
+
__typename
|
|
458
|
+
hourly { __typename minRate rateStep }
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}`;
|
|
462
|
+
const JOB_APPLICATION_QUESTIONS_QUERY = `query JobApplicationQuestions($jobId: ID!) {
|
|
463
|
+
viewer {
|
|
464
|
+
__typename
|
|
465
|
+
id
|
|
466
|
+
job(id: $jobId) {
|
|
467
|
+
__typename
|
|
468
|
+
id
|
|
469
|
+
questions(hideExpertiseQuestion: true) {
|
|
470
|
+
__typename
|
|
471
|
+
id
|
|
472
|
+
question
|
|
473
|
+
isRequired
|
|
474
|
+
}
|
|
475
|
+
expertiseQuestions {
|
|
476
|
+
__typename
|
|
477
|
+
id
|
|
478
|
+
subject {
|
|
479
|
+
__typename
|
|
480
|
+
... on Industry { __typename id name }
|
|
481
|
+
... on Skill { __typename id name }
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}`;
|
|
487
|
+
// `$requestedRate: BigDecimal` is kept in the operation signature to
|
|
488
|
+
// stay verbatim-faithful to the captured wire (per the
|
|
489
|
+
// schema/contract rule's "live API is the authority" principle).
|
|
490
|
+
// The public {@link rateInsight} signature does NOT expose
|
|
491
|
+
// `requestedRate` per #424 AC; the variable is always threaded as
|
|
492
|
+
// `null`, which the wire treats equivalently to "show me the insight
|
|
493
|
+
// for my default rate". Re-exposing the parameter is a future-issue
|
|
494
|
+
// widening — surface stays additive.
|
|
495
|
+
const JOB_APPLICATION_RATE_INSIGHT_QUERY = `query JobApplicationRateInsight($jobId: ID!, $requestedRate: BigDecimal) {
|
|
496
|
+
viewer {
|
|
497
|
+
__typename
|
|
498
|
+
id
|
|
499
|
+
job(id: $jobId) {
|
|
500
|
+
__typename
|
|
501
|
+
id
|
|
502
|
+
hourlyRateInsights: rateInsight(onlyHourlyRates: true, requestedRate: $requestedRate) {
|
|
503
|
+
__typename
|
|
504
|
+
... on TalentJobRateInsightCompetitive {
|
|
505
|
+
__typename
|
|
506
|
+
estimatedRevenue
|
|
507
|
+
estimatedRevenueExplanation
|
|
508
|
+
longTermDisclaimer
|
|
509
|
+
}
|
|
510
|
+
... on TalentJobRateInsightUncompetitive {
|
|
511
|
+
__typename
|
|
512
|
+
estimatedRevenue
|
|
513
|
+
estimatedRevenueExplanation
|
|
514
|
+
recentApplicationRate
|
|
515
|
+
recommendedRate
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}`;
|
|
521
|
+
/**
|
|
522
|
+
* Project `viewer.job.operations.apply.errors` from the captured wire
|
|
523
|
+
* shape into the public {@link ApplyError}[] form. Filters list-entry
|
|
524
|
+
* nulls defensively (the schema declares
|
|
525
|
+
* `JobOperationsApply.errors: [JobOperationsApplyError]!` — non-null
|
|
526
|
+
* LIST but nullable ENTRIES); the resulting list always carries
|
|
527
|
+
* non-null entries.
|
|
528
|
+
*/
|
|
529
|
+
function projectApplyErrors(errors) {
|
|
530
|
+
if (errors == null)
|
|
531
|
+
return [];
|
|
532
|
+
return errors.filter((e) => e !== null).map((e) => ({ code: e.code, message: e.message }));
|
|
533
|
+
}
|
|
534
|
+
function projectMatcherQuestion(wire) {
|
|
535
|
+
return {
|
|
536
|
+
identifier: wire.id,
|
|
537
|
+
prompt: wire.question,
|
|
538
|
+
type: "matcher",
|
|
539
|
+
isMandatory: wire.isRequired ?? false,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
function projectExpertiseQuestion(wire) {
|
|
543
|
+
// `subject.name` is selected on both `Industry` and `Skill` inline
|
|
544
|
+
// fragments in the captured op; defensive `?? ""` covers a
|
|
545
|
+
// wire-shape regression where neither inline fragment matched (the
|
|
546
|
+
// server returned an as-yet-unknown subject variant). #445 live
|
|
547
|
+
// E2E is the wire authority on what subject variants exist.
|
|
548
|
+
const prompt = wire.subject?.name ?? "";
|
|
549
|
+
return {
|
|
550
|
+
identifier: wire.id,
|
|
551
|
+
prompt,
|
|
552
|
+
type: "expertise",
|
|
553
|
+
// The captured `JobApplicationQuestions` operation selects no
|
|
554
|
+
// `isRequired` on `expertiseQuestions` — projected as `true`
|
|
555
|
+
// here because the apply flow requires expertise answers. See
|
|
556
|
+
// {@link ApplicationQuestion.isMandatory} JSDoc for the
|
|
557
|
+
// grounded inference + the #445 wire-authority follow-up.
|
|
558
|
+
isMandatory: true,
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
function projectRateInsight(wire) {
|
|
562
|
+
if (wire.__typename === "TalentJobRateInsightCompetitive") {
|
|
563
|
+
return {
|
|
564
|
+
kind: "competitive",
|
|
565
|
+
estimatedRevenue: wire.estimatedRevenue,
|
|
566
|
+
estimatedRevenueExplanation: wire.estimatedRevenueExplanation,
|
|
567
|
+
longTermDisclaimer: wire.longTermDisclaimer,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
// Capture the discriminant through a widened `string` local so the
|
|
571
|
+
// runtime defense below survives ESLint's `no-unnecessary-condition`
|
|
572
|
+
// rule — without the widening, the narrower would prove the
|
|
573
|
+
// !== arm dead (TS exhausts the closed union to
|
|
574
|
+
// `TalentJobRateInsightUncompetitive` after the early return above).
|
|
575
|
+
// At RUNTIME, this op is in `GATEWAY_MOBILE_KNOWN_UNTRUSTED_OPS` and
|
|
576
|
+
// the wire may carry a `__typename` outside the closed type union
|
|
577
|
+
// (future server-side union extension lands here even though the
|
|
578
|
+
// type system thinks it's unreachable). Mirrors the
|
|
579
|
+
// {@link kindFromMetadataTypename} pattern below (L1939+):
|
|
580
|
+
// unknown typename → typed `WIRE_SHAPE_ERROR` with the offending
|
|
581
|
+
// value echoed, instead of silently mislabelling as `uncompetitive`
|
|
582
|
+
// with `undefined` `recentApplicationRate` / `recommendedRate`.
|
|
583
|
+
const typename = wire.__typename;
|
|
584
|
+
if (typename !== "TalentJobRateInsightUncompetitive") {
|
|
585
|
+
throw new ApplicationsError("WIRE_SHAPE_ERROR", `Unknown rate insight variant: "${typename}".`);
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
kind: "uncompetitive",
|
|
589
|
+
estimatedRevenue: wire.estimatedRevenue,
|
|
590
|
+
estimatedRevenueExplanation: wire.estimatedRevenueExplanation,
|
|
591
|
+
recentApplicationRate: wire.recentApplicationRate,
|
|
592
|
+
recommendedRate: wire.recommendedRate,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Pre-apply aggregate context for a job (#424). Wraps `JobApplyData
|
|
597
|
+
* ($jobId)` — the mobile gateway's aggregate pre-apply query — and
|
|
598
|
+
* trims the response to the load-bearing scalars (REQ-A4 rate
|
|
599
|
+
* default, apply-state errors, platform validation bounds, plus
|
|
600
|
+
* basic job context). The captured operation also pulls in the
|
|
601
|
+
* pitch / talent-card / market-condition cascades; those are
|
|
602
|
+
* deliberately elided here. The apply path (#426) takes the pitch
|
|
603
|
+
* from `--pitch-file` per ADR-008's grammar, NOT from
|
|
604
|
+
* `suggestedPitch` / `lastPitches`, and `applyQuestions` /
|
|
605
|
+
* `rateInsight` cover the other captured slices. Future widening is
|
|
606
|
+
* additive.
|
|
607
|
+
*
|
|
608
|
+
* **Wire authority**: hand-authored from the captured
|
|
609
|
+
* `JobApplyData.graphql` selection set; CLAUDE.md schema/contract
|
|
610
|
+
* rule TRIGGERED for #424, live E2E coverage in #445.
|
|
611
|
+
*
|
|
612
|
+
* **Bad-id behavior**: `viewer.job(id:)` returns the Relay decode
|
|
613
|
+
* error (per `project-toptal-wire-quirks` memory) when the supplied
|
|
614
|
+
* id doesn't resolve to a viewable job; remapped to
|
|
615
|
+
* `ApplicationsError("NOT_FOUND")` via the shared
|
|
616
|
+
* {@link NOT_FOUND_MESSAGE_PATTERN} (widened in #424).
|
|
617
|
+
*
|
|
618
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the job id doesn't
|
|
619
|
+
* resolve (Relay decode error, `Invalid ID`, `Record not found`,
|
|
620
|
+
* or successful response with `viewer.job === null`).
|
|
621
|
+
* @throws `ApplicationsError("NO_VIEWER")` when the session is valid
|
|
622
|
+
* but no viewer is bound (defensive — `callGateway` with
|
|
623
|
+
* `requireViewer: true` already raises this case, but the
|
|
624
|
+
* post-call null check keeps the type narrowing clean).
|
|
625
|
+
*/
|
|
626
|
+
export async function applyData(token, jobId) {
|
|
627
|
+
let data;
|
|
628
|
+
try {
|
|
629
|
+
data = await callGateway(token, "JobApplyData", JOB_APPLY_DATA_QUERY, {
|
|
630
|
+
jobId,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
catch (err) {
|
|
634
|
+
if (err instanceof ApplicationsError &&
|
|
635
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
636
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
637
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`, {
|
|
638
|
+
cause: err,
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
if (data.viewer === null) {
|
|
644
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
645
|
+
}
|
|
646
|
+
if (data.viewer.job === null) {
|
|
647
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`);
|
|
648
|
+
}
|
|
649
|
+
const jobWire = data.viewer.job;
|
|
650
|
+
const applyErrors = projectApplyErrors(jobWire.operations.apply.errors);
|
|
651
|
+
const suggestedRate = data.viewer.viewerRole?.rates.hourly ?? null;
|
|
652
|
+
const rateValidationWire = data.platformConfiguration?.rateValidationRules?.hourly ?? null;
|
|
653
|
+
const rateValidation = rateValidationWire === null ? null : { minRate: rateValidationWire.minRate, rateStep: rateValidationWire.rateStep };
|
|
654
|
+
return {
|
|
655
|
+
job: {
|
|
656
|
+
id: jobWire.id,
|
|
657
|
+
isCoaching: jobWire.isCoaching,
|
|
658
|
+
hasRequiredApplicationPitch: jobWire.hasRequiredApplicationPitch,
|
|
659
|
+
},
|
|
660
|
+
applyErrors,
|
|
661
|
+
canApply: applyErrors.length === 0,
|
|
662
|
+
suggestedRate,
|
|
663
|
+
rateValidation,
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Pre-apply matcher + expertise questions inventory for a job (#424).
|
|
668
|
+
* Wraps `JobApplicationQuestions($jobId)`; trims the captured
|
|
669
|
+
* operation's `subject.possibleAnswers` cascades — the four-field
|
|
670
|
+
* {@link ApplicationQuestion} shape is the public projection per
|
|
671
|
+
* #424 AC.
|
|
672
|
+
*
|
|
673
|
+
* The two arrays surface verbatim presence: empty when the job has
|
|
674
|
+
* no questions of that kind. Order is server-supplied; no
|
|
675
|
+
* client-side re-sorting.
|
|
676
|
+
*
|
|
677
|
+
* **Bad-id behavior + NOT_FOUND mapping**: identical to
|
|
678
|
+
* {@link applyData}.
|
|
679
|
+
*
|
|
680
|
+
* @throws `ApplicationsError("NOT_FOUND")` for unresolved job ids.
|
|
681
|
+
* @throws `ApplicationsError("NO_VIEWER")` for sessions with no
|
|
682
|
+
* bound viewer.
|
|
683
|
+
*/
|
|
684
|
+
export async function applyQuestions(token, jobId) {
|
|
685
|
+
let data;
|
|
686
|
+
try {
|
|
687
|
+
data = await callGateway(token, "JobApplicationQuestions", JOB_APPLICATION_QUESTIONS_QUERY, { jobId });
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
if (err instanceof ApplicationsError &&
|
|
691
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
692
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
693
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`, {
|
|
694
|
+
cause: err,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
throw err;
|
|
698
|
+
}
|
|
699
|
+
if (data.viewer === null) {
|
|
700
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
701
|
+
}
|
|
702
|
+
if (data.viewer.job === null) {
|
|
703
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`);
|
|
704
|
+
}
|
|
705
|
+
const jobWire = data.viewer.job;
|
|
706
|
+
const matcherWire = jobWire.questions ?? [];
|
|
707
|
+
const expertiseWire = jobWire.expertiseQuestions ?? [];
|
|
708
|
+
return {
|
|
709
|
+
matcherQuestions: matcherWire.filter((q) => q !== null).map(projectMatcherQuestion),
|
|
710
|
+
expertiseQuestions: expertiseWire
|
|
711
|
+
.filter((q) => q !== null)
|
|
712
|
+
.map(projectExpertiseQuestion),
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Pre-apply rate guidance for a job (#424). Wraps
|
|
717
|
+
* `JobApplicationRateInsight($jobId)`; surfaces the captured
|
|
718
|
+
* operation's `TalentJobRateInsight` discriminated union as
|
|
719
|
+
* {@link RateInsight}. Returns `null` when the gateway omits the
|
|
720
|
+
* insight payload (the `rateInsight` field on the job resolves to
|
|
721
|
+
* null).
|
|
722
|
+
*
|
|
723
|
+
* The operation declares `$requestedRate: BigDecimal` (verbatim from
|
|
724
|
+
* the captured wire) but the public signature does NOT expose
|
|
725
|
+
* `requestedRate` per #424 AC — the variable is threaded as `null`,
|
|
726
|
+
* which the gateway treats as "show me the insight for the talent's
|
|
727
|
+
* default rate". Re-exposing the parameter is a future widening.
|
|
728
|
+
*
|
|
729
|
+
* **Wire shape**: union members carry `BigDecimal` scalar fields
|
|
730
|
+
* (`estimatedRevenue`, `recommendedRate`, `recentApplicationRate`)
|
|
731
|
+
* — NOT `Money { decimal verbose }` objects. The captured
|
|
732
|
+
* `JobApplicationRateInsight.graphql` operation selects them bare
|
|
733
|
+
* (no sub-selection), and the synthesized schema confirms
|
|
734
|
+
* `BigDecimal`. The #424 issue parenthetical "Money shape `{ decimal,
|
|
735
|
+
* verbose }` + range guidance" reflects an intuition rather than the
|
|
736
|
+
* captured wire; the captured operation's selection set is
|
|
737
|
+
* authoritative per the issue's own primary directive ("define shape
|
|
738
|
+
* based on captured operation's selection set"). PR body documents
|
|
739
|
+
* the deviation.
|
|
740
|
+
*
|
|
741
|
+
* **Bad-id behavior + NOT_FOUND mapping**: identical to
|
|
742
|
+
* {@link applyData}.
|
|
743
|
+
*
|
|
744
|
+
* @throws `ApplicationsError("NOT_FOUND")` for unresolved job ids.
|
|
745
|
+
* @throws `ApplicationsError("NO_VIEWER")` for sessions with no
|
|
746
|
+
* bound viewer.
|
|
747
|
+
*/
|
|
748
|
+
export async function rateInsight(token, jobId) {
|
|
749
|
+
let data;
|
|
750
|
+
try {
|
|
751
|
+
data = await callGateway(token, "JobApplicationRateInsight", JOB_APPLICATION_RATE_INSIGHT_QUERY, { jobId, requestedRate: null });
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
if (err instanceof ApplicationsError &&
|
|
755
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
756
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
757
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`, {
|
|
758
|
+
cause: err,
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
throw err;
|
|
762
|
+
}
|
|
763
|
+
if (data.viewer === null) {
|
|
764
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
765
|
+
}
|
|
766
|
+
if (data.viewer.job === null) {
|
|
767
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`);
|
|
768
|
+
}
|
|
769
|
+
const insightWire = data.viewer.job.hourlyRateInsights;
|
|
770
|
+
if (insightWire === null)
|
|
771
|
+
return null;
|
|
772
|
+
return projectRateInsight(insightWire);
|
|
773
|
+
}
|
|
774
|
+
// ---------------------------------------------------------------------
|
|
393
775
|
// Interest Request write-side ops (#411) — `confirm` / `reject` /
|
|
394
776
|
// `rejectReasons`. All three are HAND-AUTHORED inline strings, NOT
|
|
395
777
|
// codegen-driven:
|
|
@@ -751,4 +1133,900 @@ export async function rejectReasons(token) {
|
|
|
751
1133
|
flexible: reasons.flexible ?? [],
|
|
752
1134
|
};
|
|
753
1135
|
}
|
|
1136
|
+
const JOB_APPLY_MUTATION = `mutation JobApply($id: ID!, $comment: String, $matcherQuestionsAnswers: [JobPositionAnswerInput!], $expertiseQuestionsAnswers: [JobExpertiseAnswerInput!], $consentIssued: Boolean!, $requestedHourlyRate: BigDecimal!, $talentCard: PitchInput) {
|
|
1137
|
+
job(id: $id) {
|
|
1138
|
+
__typename
|
|
1139
|
+
apply(input: {
|
|
1140
|
+
comment: $comment
|
|
1141
|
+
matcherQuestionsAnswers: $matcherQuestionsAnswers
|
|
1142
|
+
expertiseQuestionsAnswers: $expertiseQuestionsAnswers
|
|
1143
|
+
understand: $consentIssued
|
|
1144
|
+
requestedHourlyRate: $requestedHourlyRate
|
|
1145
|
+
pitchData: $talentCard
|
|
1146
|
+
}) {
|
|
1147
|
+
__typename
|
|
1148
|
+
success
|
|
1149
|
+
errors { __typename code key message }
|
|
1150
|
+
job {
|
|
1151
|
+
__typename
|
|
1152
|
+
id
|
|
1153
|
+
activityItem {
|
|
1154
|
+
__typename
|
|
1155
|
+
id
|
|
1156
|
+
statusV2 { __typename value verbose }
|
|
1157
|
+
jobApplication {
|
|
1158
|
+
__typename
|
|
1159
|
+
id
|
|
1160
|
+
requestedHourlyRate { __typename decimal }
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}`;
|
|
1167
|
+
function projectJobApplicationRecord(activityItem) {
|
|
1168
|
+
if (activityItem.jobApplication === null) {
|
|
1169
|
+
throw new ApplicationsError("UNKNOWN", "JobApply returned success but activityItem.jobApplication was null.");
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
id: activityItem.jobApplication.id,
|
|
1173
|
+
statusV2: activityItem.statusV2,
|
|
1174
|
+
requestedHourlyRate: activityItem.jobApplication.requestedHourlyRate,
|
|
1175
|
+
jobActivityItemId: activityItem.id,
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Structural validation: every entry in `answers[]` must carry a
|
|
1180
|
+
* string id at `idField` matching one of `validIds`. Rejects unknown
|
|
1181
|
+
* ids with `WIRE_SHAPE_ERROR` carrying the offending array path
|
|
1182
|
+
* (e.g. `matcherAnswers[2]`) so callers can fix the input.
|
|
1183
|
+
*
|
|
1184
|
+
* `idField` is parameterized because the recovered SDL uses
|
|
1185
|
+
* **asymmetric field names** across the two answer types — matcher
|
|
1186
|
+
* answers (`JobPositionAnswerInput`) carry the id at `id`, while
|
|
1187
|
+
* expertise answers (`JobExpertiseAnswerInput`) carry it at
|
|
1188
|
+
* `questionId`. See #438 § Stage-2 tightening (the recovered shapes
|
|
1189
|
+
* are committed in `packages/core/src/__generated__/zod-schemas.ts`
|
|
1190
|
+
* and treated as the canonical wire-contract authority).
|
|
1191
|
+
*/
|
|
1192
|
+
function validateAnswerIds(answers, validIds, path, idField) {
|
|
1193
|
+
if (answers === undefined)
|
|
1194
|
+
return;
|
|
1195
|
+
for (let i = 0; i < answers.length; i++) {
|
|
1196
|
+
const entry = answers[i];
|
|
1197
|
+
if (typeof entry !== "object" || entry === null) {
|
|
1198
|
+
throw new ApplicationsError("WIRE_SHAPE_ERROR", `${path}[${i}]: not an object — expected { ${idField}, answer, ... }.`);
|
|
1199
|
+
}
|
|
1200
|
+
const qid = entry[idField];
|
|
1201
|
+
if (typeof qid !== "string") {
|
|
1202
|
+
throw new ApplicationsError("WIRE_SHAPE_ERROR", `${path}[${i}]: missing or non-string "${idField}" property.`);
|
|
1203
|
+
}
|
|
1204
|
+
if (!validIds.has(qid)) {
|
|
1205
|
+
throw new ApplicationsError("WIRE_SHAPE_ERROR", `${path}[${i}]: ${idField} "${qid}" does not match any question returned from applyQuestions().`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Direct-apply to a Toptal job — wire `JobApply` (#426, ADR-008
|
|
1211
|
+
* § Decision Part 5).
|
|
1212
|
+
*
|
|
1213
|
+
* Flow:
|
|
1214
|
+
*
|
|
1215
|
+
* 1. **Consent gate**: refuses the call (`CONSENT_REQUIRED`) BEFORE
|
|
1216
|
+
* any wire call when `input.consentIssued !== true`. Type-system
|
|
1217
|
+
* gate at `ApplyInput.consentIssued: true` covers compile-time;
|
|
1218
|
+
* the runtime check covers `as`-cast bypasses and JSON-sourced
|
|
1219
|
+
* inputs.
|
|
1220
|
+
* 2. **Dry-run short-circuit**: when `options.dryRun === true`,
|
|
1221
|
+
* emits a {@link DryRunPreview} with the prepared variables
|
|
1222
|
+
* (including `<resolved at apply time>` placeholders for fields
|
|
1223
|
+
* that would have been resolved by the pre-fetch) and returns
|
|
1224
|
+
* `{ kind: "preview", preview }`. Zero wire calls under dry-run —
|
|
1225
|
+
* including the 3 pre-fetch calls.
|
|
1226
|
+
* 3. **Pre-fetch via Promise.all**: runs `applyData` +
|
|
1227
|
+
* `applyQuestions` + `rateInsight` concurrently. Promise.all
|
|
1228
|
+
* rejects-on-first; any pre-fetch failure (NOT_FOUND,
|
|
1229
|
+
* GRAPHQL_ERROR, AuthRevokedError) blocks the apply.
|
|
1230
|
+
* 4. **Answer validation**: every `matcherAnswers[]` /
|
|
1231
|
+
* `expertiseAnswers[]` entry's `questionId` must resolve against
|
|
1232
|
+
* the inventory; unknown ids throw `WIRE_SHAPE_ERROR` with the
|
|
1233
|
+
* offending array path.
|
|
1234
|
+
* 5. **Rate default**: when `input.requestedHourlyRate` is omitted,
|
|
1235
|
+
* threads `PreApplyData.suggestedRate` (the talent's own
|
|
1236
|
+
* configured rate). Throws `MUTATION_ERROR` if neither source
|
|
1237
|
+
* yields a rate.
|
|
1238
|
+
* 6. **Wire call**: issues `JobApply` against the mobile gateway.
|
|
1239
|
+
* 7. **Error mapping**: a `MutationResult.errors[]` entry with
|
|
1240
|
+
* `key === "already_applied"` is mapped to `ALREADY_APPLIED`
|
|
1241
|
+
* with a hint pointing at `ttctl applications show
|
|
1242
|
+
* <activity-id>`. Other `success: false` responses surface as
|
|
1243
|
+
* `MUTATION_ERROR` with the formatted error detail.
|
|
1244
|
+
*
|
|
1245
|
+
* **Bad-id behavior**: mutations crash 500 on bad ids per
|
|
1246
|
+
* `project-toptal-wire-quirks` auto-memory. The service does NOT
|
|
1247
|
+
* pre-validate the job id; the pre-fetch suite (`applyData` etc.)
|
|
1248
|
+
* already surfaces NOT_FOUND via the shared widened
|
|
1249
|
+
* {@link NOT_FOUND_MESSAGE_PATTERN} when the bad id is detected
|
|
1250
|
+
* read-side.
|
|
1251
|
+
*/
|
|
1252
|
+
export async function apply(token, jobId, input, options = {}) {
|
|
1253
|
+
// Consent gate — runtime check covers `as`-cast bypasses and
|
|
1254
|
+
// JSON-sourced inputs from CLI/MCP. Fires BEFORE any wire call (no
|
|
1255
|
+
// pre-fetch under refusal either — the dry-run path below still
|
|
1256
|
+
// honors the consent gate so a probe with `consentIssued: false`
|
|
1257
|
+
// does not emit a preview for a call that would have been refused).
|
|
1258
|
+
//
|
|
1259
|
+
// The widening cast (`as { consentIssued: unknown }`) is load-bearing:
|
|
1260
|
+
// the static type `consentIssued: true` (literal) narrows the value
|
|
1261
|
+
// to compile-time-true, which makes `!== true` look like dead code to
|
|
1262
|
+
// the linter. The runtime check exists for the bypass paths the type
|
|
1263
|
+
// system can't reach (CLI / MCP / agents passing JSON), where the
|
|
1264
|
+
// value may genuinely be `undefined` or `false`.
|
|
1265
|
+
if (input.consentIssued !== true) {
|
|
1266
|
+
throw new ApplicationsError("CONSENT_REQUIRED", "Apply requires explicit consent: `consentIssued: true` is mandatory before any wire call.");
|
|
1267
|
+
}
|
|
1268
|
+
if (options.dryRun === true) {
|
|
1269
|
+
// Skip the pre-fetch entirely (zero transport calls under dry-run)
|
|
1270
|
+
// and emit a preview with placeholders for fields that would have
|
|
1271
|
+
// been resolved live. Matches the
|
|
1272
|
+
// `applications.confirm()` skipped-prefetch pattern.
|
|
1273
|
+
const previewVariables = {
|
|
1274
|
+
id: jobId,
|
|
1275
|
+
comment: input.message ?? null,
|
|
1276
|
+
matcherQuestionsAnswers: input.matcherAnswers ?? null,
|
|
1277
|
+
expertiseQuestionsAnswers: input.expertiseAnswers ?? null,
|
|
1278
|
+
consentIssued: true,
|
|
1279
|
+
requestedHourlyRate: input.requestedHourlyRate ?? "<resolved at apply time>",
|
|
1280
|
+
talentCard: input.pitchData ?? null,
|
|
1281
|
+
};
|
|
1282
|
+
return {
|
|
1283
|
+
kind: "preview",
|
|
1284
|
+
preview: buildDryRunPreview({
|
|
1285
|
+
surface: "mobile-gateway",
|
|
1286
|
+
authToken: token,
|
|
1287
|
+
body: {
|
|
1288
|
+
operationName: "JobApply",
|
|
1289
|
+
query: JOB_APPLY_MUTATION,
|
|
1290
|
+
variables: previewVariables,
|
|
1291
|
+
},
|
|
1292
|
+
}),
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
// Pre-fetch via Promise.all — applyData + applyQuestions + rateInsight
|
|
1296
|
+
// run concurrently. Promise.all rejects-on-first; intentional —
|
|
1297
|
+
// any failure (NOT_FOUND, AuthRevokedError, GRAPHQL_ERROR) should
|
|
1298
|
+
// block the mutation.
|
|
1299
|
+
//
|
|
1300
|
+
// The third return (rateInsight) is currently UNUSED by the apply
|
|
1301
|
+
// path itself — the rate default comes from `preApply.suggestedRate`
|
|
1302
|
+
// per REQ-A4, not from the insight's `recommendedRate`. The
|
|
1303
|
+
// pre-fetch is still issued per the #426 AC ("Pre-fetch via
|
|
1304
|
+
// Promise.all: applyData + applyQuestions + rateInsight") so the
|
|
1305
|
+
// wire-traffic shape matches what the mobile app's apply screen
|
|
1306
|
+
// issues (the insight is what the mobile app shows alongside the
|
|
1307
|
+
// rate input).
|
|
1308
|
+
// Future widening: surface the insight in the apply outcome so
|
|
1309
|
+
// callers can render it post-apply.
|
|
1310
|
+
const [preApply, questions] = await Promise.all([
|
|
1311
|
+
applyData(token, jobId),
|
|
1312
|
+
applyQuestions(token, jobId),
|
|
1313
|
+
rateInsight(token, jobId),
|
|
1314
|
+
]);
|
|
1315
|
+
// Structural validation: every answer's id must resolve against
|
|
1316
|
+
// the inventory. The id-field name is asymmetric per the recovered
|
|
1317
|
+
// SDL — matcher answers use `id`, expertise answers use
|
|
1318
|
+
// `questionId` (see {@link validateAnswerIds} for the rationale).
|
|
1319
|
+
const matcherIds = new Set(questions.matcherQuestions.map((q) => q.identifier));
|
|
1320
|
+
const expertiseIds = new Set(questions.expertiseQuestions.map((q) => q.identifier));
|
|
1321
|
+
validateAnswerIds(input.matcherAnswers, matcherIds, "matcherAnswers", "id");
|
|
1322
|
+
validateAnswerIds(input.expertiseAnswers, expertiseIds, "expertiseAnswers", "questionId");
|
|
1323
|
+
// Rate default per REQ-A4 — caller-supplied overrides
|
|
1324
|
+
// `PreApplyData.suggestedRate`. Throw `MUTATION_ERROR` if neither
|
|
1325
|
+
// source produces a rate; the wire `$requestedHourlyRate` is
|
|
1326
|
+
// `BigDecimal!` (non-null).
|
|
1327
|
+
const requestedHourlyRate = input.requestedHourlyRate ?? preApply.suggestedRate;
|
|
1328
|
+
if (requestedHourlyRate === null) {
|
|
1329
|
+
throw new ApplicationsError("MUTATION_ERROR", "requestedHourlyRate is required and could not be defaulted from PreApplyData.suggestedRate — pass an explicit rate.");
|
|
1330
|
+
}
|
|
1331
|
+
const variables = {
|
|
1332
|
+
id: jobId,
|
|
1333
|
+
comment: input.message ?? null,
|
|
1334
|
+
matcherQuestionsAnswers: input.matcherAnswers ?? null,
|
|
1335
|
+
expertiseQuestionsAnswers: input.expertiseAnswers ?? null,
|
|
1336
|
+
consentIssued: true,
|
|
1337
|
+
requestedHourlyRate,
|
|
1338
|
+
talentCard: input.pitchData ?? null,
|
|
1339
|
+
};
|
|
1340
|
+
const data = await callGatewayNoViewer(token, "JobApply", JOB_APPLY_MUTATION, variables);
|
|
1341
|
+
if (data.job === null || data.job.apply === null) {
|
|
1342
|
+
throw new ApplicationsError("UNKNOWN", "JobApply returned a null payload.");
|
|
1343
|
+
}
|
|
1344
|
+
const payload = data.job.apply;
|
|
1345
|
+
if (!payload.success) {
|
|
1346
|
+
// Map the wire's `already_applied` key to the typed
|
|
1347
|
+
// `ALREADY_APPLIED` code so callers can render a targeted "you
|
|
1348
|
+
// already applied" hint. Other `success: false` envelopes flow
|
|
1349
|
+
// through the generic `MUTATION_ERROR` taxonomy.
|
|
1350
|
+
const errors = payload.errors ?? [];
|
|
1351
|
+
if (errors.some((e) => e.key === "already_applied")) {
|
|
1352
|
+
throw new ApplicationsError("ALREADY_APPLIED", `You have already applied to job "${jobId}". Run \`ttctl applications show <activity-id>\` to find your existing application.`);
|
|
1353
|
+
}
|
|
1354
|
+
throw new ApplicationsError("MUTATION_ERROR", formatMutationErrors("JobApply failed", errors));
|
|
1355
|
+
}
|
|
1356
|
+
if (payload.job === null || payload.job.activityItem === null) {
|
|
1357
|
+
throw new ApplicationsError("UNKNOWN", "JobApply returned success but the job / activityItem echo was null.");
|
|
1358
|
+
}
|
|
1359
|
+
return { kind: "applied", result: projectJobApplicationRecord(payload.job.activityItem) };
|
|
1360
|
+
}
|
|
1361
|
+
// Captured operation document at
|
|
1362
|
+
// `../research/graphql/gateway/operations/mobile/SimilarJobQuestionAnswers.graphql`
|
|
1363
|
+
// and `research/apk/decoded/smali/fn/ji.smali:89` (verbatim:
|
|
1364
|
+
// `query SimilarJobQuestionAnswers($id: ID!) { viewer { __typename id
|
|
1365
|
+
// jobPositionAnswers(filters: { forQuestionsSimilarToQuestionId: $id
|
|
1366
|
+
// uniqueAnswer: true } , pageSize: 10) { __typename nodes
|
|
1367
|
+
// { __typename id answer createdAt } } } }`).
|
|
1368
|
+
//
|
|
1369
|
+
// Schema gaps:
|
|
1370
|
+
// - `viewer.jobPositionAnswers(filters:, pageSize:)` — the
|
|
1371
|
+
// synthesized schema declares the field as
|
|
1372
|
+
// `viewer.jobPositionAnswers: [JobPositionAnswer]!` with no args
|
|
1373
|
+
// (line 814). The captured op passes both `filters` (a
|
|
1374
|
+
// `JobPositionAnswerFiltersInput { forQuestionsSimilarToQuestionId,
|
|
1375
|
+
// uniqueAnswer }`) and `pageSize`. Empirically these work — the
|
|
1376
|
+
// mobile app calls this every time the apply screen renders a
|
|
1377
|
+
// question autocomplete; the live E2E + T1 snapshot are the
|
|
1378
|
+
// authority.
|
|
1379
|
+
// - The return shape on the wire is a connection-like
|
|
1380
|
+
// `{ nodes: JobPositionAnswer[] }` rather than the schema's bare
|
|
1381
|
+
// `[JobPositionAnswer]!` list. The captured op selects `nodes`
|
|
1382
|
+
// verbatim; the projection unwraps to the bare list.
|
|
1383
|
+
// - `JobPositionAnswer.createdAt` is not declared in the synthesized
|
|
1384
|
+
// SDL (`type JobPositionAnswer` at line 1134 carries only `answer`,
|
|
1385
|
+
// `id`, `question`). The captured op selects it anyway — same gap
|
|
1386
|
+
// pattern as `availabilityRequest.metadata.offeredHourlyRate` (#410)
|
|
1387
|
+
// where the trimmed mobile selection extends the schema. T1 snapshot
|
|
1388
|
+
// is the authority.
|
|
1389
|
+
const SIMILAR_JOB_QUESTION_ANSWERS_QUERY = `query SimilarJobQuestionAnswers($id: ID!) {
|
|
1390
|
+
viewer {
|
|
1391
|
+
__typename
|
|
1392
|
+
id
|
|
1393
|
+
jobPositionAnswers(filters: { forQuestionsSimilarToQuestionId: $id, uniqueAnswer: true }, pageSize: 10) {
|
|
1394
|
+
__typename
|
|
1395
|
+
nodes {
|
|
1396
|
+
__typename
|
|
1397
|
+
id
|
|
1398
|
+
answer
|
|
1399
|
+
createdAt
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}`;
|
|
1404
|
+
/**
|
|
1405
|
+
* Project the wire's `jobPositionAnswers.nodes[]` into the public
|
|
1406
|
+
* {@link SimilarJobAnswer}[] shape. Filters list-entry nulls
|
|
1407
|
+
* defensively (the schema declares `[JobPositionAnswer]!` as a
|
|
1408
|
+
* non-null LIST with nullable entries; the captured op honors the
|
|
1409
|
+
* same nullability); the resulting list always carries non-null
|
|
1410
|
+
* entries.
|
|
1411
|
+
*/
|
|
1412
|
+
function projectSimilarAnswers(nodes) {
|
|
1413
|
+
if (nodes == null)
|
|
1414
|
+
return [];
|
|
1415
|
+
return nodes
|
|
1416
|
+
.filter((n) => n !== null)
|
|
1417
|
+
.map((n) => ({ id: n.id, answer: n.answer, createdAt: n.createdAt }));
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Fetch one question's similar-answer suggestions from the gateway.
|
|
1421
|
+
* Internal helper; the public surface is {@link similarAnswers} which
|
|
1422
|
+
* fans out across the full question inventory of a job.
|
|
1423
|
+
*
|
|
1424
|
+
* `questionId` is the `ApplicationQuestion.identifier` (either matcher
|
|
1425
|
+
* or expertise — the wire accepts both since they're both `Node`-typed
|
|
1426
|
+
* via `JobPositionQuestion.id` / `JobExpertiseQuestion.id`).
|
|
1427
|
+
*
|
|
1428
|
+
* Bad-id behavior: an unknown `questionId` surfaces as the shared
|
|
1429
|
+
* Relay decode error pattern (`Node id ... resolves to ...`) and is
|
|
1430
|
+
* remapped to `NOT_FOUND` via the widened
|
|
1431
|
+
* {@link NOT_FOUND_MESSAGE_PATTERN}.
|
|
1432
|
+
*
|
|
1433
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the questionId
|
|
1434
|
+
* doesn't resolve.
|
|
1435
|
+
* @throws `ApplicationsError("NO_VIEWER")` when the session is valid
|
|
1436
|
+
* but no viewer is bound (defensive — `callGateway` with
|
|
1437
|
+
* `requireViewer: true` already raises this case).
|
|
1438
|
+
*/
|
|
1439
|
+
async function similarAnswersForQuestion(token, questionId) {
|
|
1440
|
+
let data;
|
|
1441
|
+
try {
|
|
1442
|
+
data = await callGateway(token, "SimilarJobQuestionAnswers", SIMILAR_JOB_QUESTION_ANSWERS_QUERY, { id: questionId });
|
|
1443
|
+
}
|
|
1444
|
+
catch (err) {
|
|
1445
|
+
if (err instanceof ApplicationsError &&
|
|
1446
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
1447
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
1448
|
+
throw new ApplicationsError("NOT_FOUND", `No question found with id "${questionId}" (or you don't have access to it).`, { cause: err });
|
|
1449
|
+
}
|
|
1450
|
+
throw err;
|
|
1451
|
+
}
|
|
1452
|
+
if (data.viewer === null) {
|
|
1453
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
1454
|
+
}
|
|
1455
|
+
// The wire returns `jobPositionAnswers: null` for unknown ids on
|
|
1456
|
+
// some accounts (instead of a top-level GraphQL error). Treat the
|
|
1457
|
+
// null connection as "no similar answers" rather than NOT_FOUND —
|
|
1458
|
+
// the wire authoritatively returned a successful response.
|
|
1459
|
+
if (data.viewer.jobPositionAnswers === null)
|
|
1460
|
+
return [];
|
|
1461
|
+
return projectSimilarAnswers(data.viewer.jobPositionAnswers.nodes);
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Fetch the talent's similar-answer suggestions for every question on
|
|
1465
|
+
* a job's apply form (#452).
|
|
1466
|
+
*
|
|
1467
|
+
* Returns one {@link SimilarJobAnswerGroup} per question — matcher
|
|
1468
|
+
* AND expertise. The order mirrors the underlying
|
|
1469
|
+
* {@link applyQuestions} inventory (matcher questions first, then
|
|
1470
|
+
* expertise; server-supplied order within each section).
|
|
1471
|
+
*
|
|
1472
|
+
* **Cardinality**: N+1 wire calls — first an {@link applyQuestions}
|
|
1473
|
+
* fetch to resolve the question identifier list, then N parallel
|
|
1474
|
+
* `SimilarJobQuestionAnswers` calls (one per question) via
|
|
1475
|
+
* `Promise.all`. The fan-out matches the mobile app's apply-screen
|
|
1476
|
+
* autocomplete behavior; aggregating server-side is not exposed by
|
|
1477
|
+
* the wire.
|
|
1478
|
+
*
|
|
1479
|
+
* **Bad-id behavior**: a bad `jobId` surfaces NOT_FOUND from
|
|
1480
|
+
* `applyQuestions` (same mapping as the rest of the pre-apply suite).
|
|
1481
|
+
* A per-question `SimilarJobQuestionAnswers` call that surfaces
|
|
1482
|
+
* NOT_FOUND propagates verbatim via `Promise.all`'s reject-on-first
|
|
1483
|
+
* — intentional: callers that want graceful per-question fallback
|
|
1484
|
+
* (e.g. the CLI's `--suggest-answers` flag) should catch the rejection
|
|
1485
|
+
* and continue without suggestions.
|
|
1486
|
+
*
|
|
1487
|
+
* **Performance**: when the job has zero questions, the call resolves
|
|
1488
|
+
* to `[]` after the single `applyQuestions` fetch — no wasted parallel
|
|
1489
|
+
* round-trips. When the job has questions but the talent's account
|
|
1490
|
+
* has no similar-job history, each per-question call returns an empty
|
|
1491
|
+
* `suggestions` array; surface the empty grouping verbatim.
|
|
1492
|
+
*
|
|
1493
|
+
* **Off the critical apply path**: this fn is NOT called from
|
|
1494
|
+
* {@link apply}. It's opt-in via the CLI's `--suggest-answers` flag
|
|
1495
|
+
* (#452) and the MCP's `ttctl_jobs_apply_similar_answers` tool. The
|
|
1496
|
+
* design rationale is in the ADR-008 follow-ups thread.
|
|
1497
|
+
*
|
|
1498
|
+
* @param token - Bearer token from the resolved auth config.
|
|
1499
|
+
* @param jobId - The `TalentJob.id` to resolve questions against.
|
|
1500
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the jobId or a
|
|
1501
|
+
* resolved questionId doesn't resolve.
|
|
1502
|
+
* @throws `ApplicationsError("NO_VIEWER")` for sessions with no
|
|
1503
|
+
* bound viewer.
|
|
1504
|
+
*/
|
|
1505
|
+
export async function similarAnswers(token, jobId) {
|
|
1506
|
+
const questions = await applyQuestions(token, jobId);
|
|
1507
|
+
const allIdentifiers = [
|
|
1508
|
+
...questions.matcherQuestions.map((q) => q.identifier),
|
|
1509
|
+
...questions.expertiseQuestions.map((q) => q.identifier),
|
|
1510
|
+
];
|
|
1511
|
+
if (allIdentifiers.length === 0)
|
|
1512
|
+
return [];
|
|
1513
|
+
const suggestions = await Promise.all(allIdentifiers.map((qid) => similarAnswersForQuestion(token, qid)));
|
|
1514
|
+
return allIdentifiers.map((questionId, i) => ({ questionId, suggestions: suggestions[i] ?? [] }));
|
|
1515
|
+
}
|
|
1516
|
+
export const INTERVIEW_STATUSES = [
|
|
1517
|
+
"ACCEPTED",
|
|
1518
|
+
"MISSED",
|
|
1519
|
+
"PENDING",
|
|
1520
|
+
"REJECTED",
|
|
1521
|
+
"SCHEDULED",
|
|
1522
|
+
"TIME_ACCEPTED",
|
|
1523
|
+
"TIME_REJECTED",
|
|
1524
|
+
];
|
|
1525
|
+
// Trimmed strict subset of the captured Interview op in
|
|
1526
|
+
// `research/graphql/gateway/operations/mobile/Interview.graphql`. The
|
|
1527
|
+
// captured doc selects via the `interviewWithJobActivityFields →
|
|
1528
|
+
// jobActivityItemData` cascade; this trim drops the activity-item
|
|
1529
|
+
// cascade (the caller already has the activity row from
|
|
1530
|
+
// `applications.show`) and keeps only the interview-specific selection.
|
|
1531
|
+
// `statuses: ALL` keeps parity with the captured op so any interview
|
|
1532
|
+
// state is fetchable.
|
|
1533
|
+
const INTERVIEW_QUERY = `query Interview($id: ID!) {
|
|
1534
|
+
viewer {
|
|
1535
|
+
__typename
|
|
1536
|
+
id
|
|
1537
|
+
interview(id: $id, statuses: ALL) {
|
|
1538
|
+
__typename
|
|
1539
|
+
id
|
|
1540
|
+
interviewStatus: statusV2 { __typename value }
|
|
1541
|
+
kind
|
|
1542
|
+
interviewType
|
|
1543
|
+
interviewTime
|
|
1544
|
+
information
|
|
1545
|
+
initiator
|
|
1546
|
+
scheduledAtTimes
|
|
1547
|
+
schedulingComment
|
|
1548
|
+
interviewMethod {
|
|
1549
|
+
__typename
|
|
1550
|
+
typeV2
|
|
1551
|
+
conferenceUrl
|
|
1552
|
+
resource
|
|
1553
|
+
}
|
|
1554
|
+
interviewContacts {
|
|
1555
|
+
__typename
|
|
1556
|
+
id
|
|
1557
|
+
fullName
|
|
1558
|
+
email
|
|
1559
|
+
phoneNumber
|
|
1560
|
+
main
|
|
1561
|
+
position
|
|
1562
|
+
timeZone { __typename value location }
|
|
1563
|
+
}
|
|
1564
|
+
guide { __typename id }
|
|
1565
|
+
talentNotes {
|
|
1566
|
+
__typename
|
|
1567
|
+
id
|
|
1568
|
+
section
|
|
1569
|
+
note
|
|
1570
|
+
}
|
|
1571
|
+
job {
|
|
1572
|
+
__typename
|
|
1573
|
+
id
|
|
1574
|
+
activityItem { __typename id }
|
|
1575
|
+
}
|
|
1576
|
+
updatedAt
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
}`;
|
|
1580
|
+
function projectInterviewContact(c) {
|
|
1581
|
+
return {
|
|
1582
|
+
id: c.id,
|
|
1583
|
+
fullName: c.fullName ?? null,
|
|
1584
|
+
email: c.email ?? null,
|
|
1585
|
+
phoneNumber: c.phoneNumber ?? null,
|
|
1586
|
+
position: c.position ?? null,
|
|
1587
|
+
main: c.main ?? null,
|
|
1588
|
+
timeZone: c.timeZone == null
|
|
1589
|
+
? null
|
|
1590
|
+
: {
|
|
1591
|
+
value: c.timeZone.value ?? null,
|
|
1592
|
+
location: c.timeZone.location ?? null,
|
|
1593
|
+
},
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
function projectInterviewDetail(w) {
|
|
1597
|
+
return {
|
|
1598
|
+
id: w.id,
|
|
1599
|
+
status: (w.interviewStatus?.value ?? null),
|
|
1600
|
+
kind: (w.kind ?? null),
|
|
1601
|
+
interviewType: w.interviewType ?? null,
|
|
1602
|
+
interviewTime: w.interviewTime ?? null,
|
|
1603
|
+
information: w.information ?? null,
|
|
1604
|
+
initiator: w.initiator ?? null,
|
|
1605
|
+
scheduledAtTimes: (w.scheduledAtTimes ?? []).filter((s) => typeof s === "string"),
|
|
1606
|
+
schedulingComment: w.schedulingComment ?? null,
|
|
1607
|
+
method: w.interviewMethod == null
|
|
1608
|
+
? null
|
|
1609
|
+
: {
|
|
1610
|
+
typeV2: w.interviewMethod.typeV2 ?? null,
|
|
1611
|
+
conferenceUrl: w.interviewMethod.conferenceUrl ?? null,
|
|
1612
|
+
resource: w.interviewMethod.resource ?? null,
|
|
1613
|
+
},
|
|
1614
|
+
contacts: (w.interviewContacts ?? [])
|
|
1615
|
+
.filter((c) => c != null)
|
|
1616
|
+
.map(projectInterviewContact),
|
|
1617
|
+
guideId: w.guide?.id ?? null,
|
|
1618
|
+
talentNotes: (w.talentNotes ?? [])
|
|
1619
|
+
.filter((n) => n != null)
|
|
1620
|
+
.map((n) => ({
|
|
1621
|
+
id: n.id,
|
|
1622
|
+
section: n.section ?? null,
|
|
1623
|
+
note: n.note ?? null,
|
|
1624
|
+
})),
|
|
1625
|
+
job: w.job == null
|
|
1626
|
+
? null
|
|
1627
|
+
: {
|
|
1628
|
+
id: w.job.id,
|
|
1629
|
+
activityItemId: w.job.activityItem?.id ?? null,
|
|
1630
|
+
},
|
|
1631
|
+
updatedAt: w.updatedAt ?? null,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Read one `TalentInterview` by id via the mobile-gateway `Interview`
|
|
1636
|
+
* query (#439). Sibling sub-namespace to the top-level activity-row
|
|
1637
|
+
* leaves (`list` / `show` / `stats`) — fetches the rich interview
|
|
1638
|
+
* detail once the user knows the id from `applications show
|
|
1639
|
+
* <activityId>` (the `Interview: <id>` line).
|
|
1640
|
+
*
|
|
1641
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the id doesn't resolve
|
|
1642
|
+
* to an interview the signed-in user can see, OR when the wire
|
|
1643
|
+
* surfaces a `NOT_FOUND_MESSAGE_PATTERN`-matched GraphQL error
|
|
1644
|
+
* (`Record not found` / `Invalid ID` / Relay `Node id ... resolves to`).
|
|
1645
|
+
* @throws `ApplicationsError("NO_VIEWER")` when the session is valid
|
|
1646
|
+
* but no viewer is bound.
|
|
1647
|
+
*/
|
|
1648
|
+
async function interviewsShow(token, id) {
|
|
1649
|
+
let data;
|
|
1650
|
+
try {
|
|
1651
|
+
data = await callGateway(token, "Interview", INTERVIEW_QUERY, { id });
|
|
1652
|
+
}
|
|
1653
|
+
catch (err) {
|
|
1654
|
+
if (err instanceof ApplicationsError &&
|
|
1655
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
1656
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
1657
|
+
throw new ApplicationsError("NOT_FOUND", `No interview found with id "${id}" (or you don't have access to it).`, {
|
|
1658
|
+
cause: err,
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
throw err;
|
|
1662
|
+
}
|
|
1663
|
+
if (data.viewer === null) {
|
|
1664
|
+
// Defensive — `callGateway` with `requireViewer: true` already
|
|
1665
|
+
// raises `NO_VIEWER` for this case; keep the check for type
|
|
1666
|
+
// narrowing parity with sibling `show()` / `stats()`.
|
|
1667
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
1668
|
+
}
|
|
1669
|
+
if (data.viewer.interview === null) {
|
|
1670
|
+
throw new ApplicationsError("NOT_FOUND", `No interview found with id "${id}" (or you don't have access to it).`);
|
|
1671
|
+
}
|
|
1672
|
+
return projectInterviewDetail(data.viewer.interview);
|
|
1673
|
+
}
|
|
1674
|
+
// Trimmed strict subset of the captured `GetInterviewNotes` op in
|
|
1675
|
+
// `research/graphql/gateway/operations/portal/GetInterviewNotes.graphql`.
|
|
1676
|
+
// The captured doc selects a heavy cascade (~25 types via `JobClient`,
|
|
1677
|
+
// `JobOperationsFragment`, `JobMatcherData`, `JobSkillV2Data`,
|
|
1678
|
+
// `JobIndustriesData`, etc.); this trim drops everything except the
|
|
1679
|
+
// notes-specific selection on
|
|
1680
|
+
// `viewer.job(id).activityItem.interview.{id, kind, talentNotes{…}}`.
|
|
1681
|
+
// Authoritative wire shape is the captured doc; selection is the
|
|
1682
|
+
// projection contract.
|
|
1683
|
+
const GET_INTERVIEW_NOTES_QUERY = `query GetInterviewNotes($jobId: ID!) {
|
|
1684
|
+
viewer {
|
|
1685
|
+
__typename
|
|
1686
|
+
id
|
|
1687
|
+
job(id: $jobId) {
|
|
1688
|
+
__typename
|
|
1689
|
+
activityItem {
|
|
1690
|
+
__typename
|
|
1691
|
+
interview {
|
|
1692
|
+
__typename
|
|
1693
|
+
id
|
|
1694
|
+
kind
|
|
1695
|
+
talentNotes { __typename id note section }
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
}`;
|
|
1701
|
+
/**
|
|
1702
|
+
* Read the talent's prep notes for the interview attached to a given
|
|
1703
|
+
* job via the portal-side `GetInterviewNotes` query (#440). Sub-sub-
|
|
1704
|
+
* namespace leaf of `applications.interviews.*` — wraps the same
|
|
1705
|
+
* read-only path the portal matcher UI uses to load interview notes.
|
|
1706
|
+
*
|
|
1707
|
+
* @param token Captured bearer.
|
|
1708
|
+
* @param jobId `TalentJob.id` (NOT the interview id). Discover via
|
|
1709
|
+
* `applications interview show <interviewId>` (the
|
|
1710
|
+
* `Job → Job id` line, populated by the #439 projection)
|
|
1711
|
+
* or `applications show <activityId>`.
|
|
1712
|
+
*
|
|
1713
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the job id doesn't
|
|
1714
|
+
* resolve to a job the signed-in user can see, OR when the wire
|
|
1715
|
+
* surfaces a `NOT_FOUND_MESSAGE_PATTERN`-matched GraphQL error
|
|
1716
|
+
* (`Record not found` / `Invalid ID` / Relay `Node id ... resolves to`).
|
|
1717
|
+
* @throws `ApplicationsError("NO_VIEWER")` when the session is valid
|
|
1718
|
+
* but no viewer is bound.
|
|
1719
|
+
*/
|
|
1720
|
+
async function interviewsNotesShow(token, jobId) {
|
|
1721
|
+
let data;
|
|
1722
|
+
try {
|
|
1723
|
+
data = await callGateway(token, "GetInterviewNotes", GET_INTERVIEW_NOTES_QUERY, { jobId });
|
|
1724
|
+
}
|
|
1725
|
+
catch (err) {
|
|
1726
|
+
if (err instanceof ApplicationsError &&
|
|
1727
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
1728
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
1729
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`, {
|
|
1730
|
+
cause: err,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
throw err;
|
|
1734
|
+
}
|
|
1735
|
+
if (data.viewer === null) {
|
|
1736
|
+
// Defensive — `callGateway` with `requireViewer: true` already
|
|
1737
|
+
// raises `NO_VIEWER` for this case; keep the check for type
|
|
1738
|
+
// narrowing parity with sibling leaves.
|
|
1739
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
1740
|
+
}
|
|
1741
|
+
if (data.viewer.job === null) {
|
|
1742
|
+
throw new ApplicationsError("NOT_FOUND", `No job found with id "${jobId}" (or you don't have access to it).`);
|
|
1743
|
+
}
|
|
1744
|
+
const interview = data.viewer.job.activityItem?.interview ?? null;
|
|
1745
|
+
return {
|
|
1746
|
+
jobId,
|
|
1747
|
+
interviewId: interview?.id ?? null,
|
|
1748
|
+
interviewKind: (interview?.kind ?? null),
|
|
1749
|
+
notes: (interview?.talentNotes ?? [])
|
|
1750
|
+
.filter((n) => n != null)
|
|
1751
|
+
.map((n) => ({
|
|
1752
|
+
id: n.id,
|
|
1753
|
+
section: n.section ?? null,
|
|
1754
|
+
note: n.note ?? null,
|
|
1755
|
+
})),
|
|
1756
|
+
};
|
|
1757
|
+
}
|
|
1758
|
+
export const INTERVIEW_GUIDE_SECTION_IDENTIFIERS = [
|
|
1759
|
+
"ASK_YOUR_CLIENT",
|
|
1760
|
+
"GAPS",
|
|
1761
|
+
"JOB_HIGHLIGHTS",
|
|
1762
|
+
"POTENTIAL_QUESTIONS",
|
|
1763
|
+
"PRO_TIPS",
|
|
1764
|
+
"STRENGTHS",
|
|
1765
|
+
];
|
|
1766
|
+
export const INTERVIEW_GUIDE_TIP_IDENTIFIERS = [
|
|
1767
|
+
"BE_PRESENTABLE",
|
|
1768
|
+
"CAMERA_ON",
|
|
1769
|
+
"DONT_DISCUSS_RATE",
|
|
1770
|
+
"GAP_ANALYSIS",
|
|
1771
|
+
"HIRING_FACTORS",
|
|
1772
|
+
"JOB_SUMMARY",
|
|
1773
|
+
"PROFILE_REFERENCES",
|
|
1774
|
+
"QUESTIONS_TO_ASK",
|
|
1775
|
+
"QUESTIONS_TO_PREPARE_FOR",
|
|
1776
|
+
"SMALL_TALK",
|
|
1777
|
+
"STANDARD_QUESTIONS",
|
|
1778
|
+
"STRENGTHS_OVERLAP",
|
|
1779
|
+
];
|
|
1780
|
+
// Trimmed strict subset of the captured `InterviewGuide` op in
|
|
1781
|
+
// `research/graphql/gateway/operations/mobile/InterviewGuide.graphql`.
|
|
1782
|
+
// The captured doc selects a heavy cascade
|
|
1783
|
+
// (`interviewContacts` + `job → jobData` + `client` + `mobileFeedbackForm`);
|
|
1784
|
+
// this trim drops everything except the guide-specific selection on
|
|
1785
|
+
// `viewer.interview(id).guide.{id, sections[].{identifier, title,
|
|
1786
|
+
// subtitle, tips[].{identifier, title, content, hardcodedContent}}}`.
|
|
1787
|
+
// Authoritative wire shape is the captured doc; selection is the
|
|
1788
|
+
// projection contract. `statuses: ALL` keeps parity with the captured
|
|
1789
|
+
// op so any interview state's guide is fetchable.
|
|
1790
|
+
const INTERVIEW_GUIDE_QUERY = `query InterviewGuide($id: ID!) {
|
|
1791
|
+
viewer {
|
|
1792
|
+
__typename
|
|
1793
|
+
id
|
|
1794
|
+
interview(id: $id, statuses: ALL) {
|
|
1795
|
+
__typename
|
|
1796
|
+
id
|
|
1797
|
+
guide {
|
|
1798
|
+
__typename
|
|
1799
|
+
id
|
|
1800
|
+
sections {
|
|
1801
|
+
__typename
|
|
1802
|
+
identifier
|
|
1803
|
+
title
|
|
1804
|
+
subtitle
|
|
1805
|
+
tips {
|
|
1806
|
+
__typename
|
|
1807
|
+
identifier
|
|
1808
|
+
title
|
|
1809
|
+
content
|
|
1810
|
+
hardcodedContent
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}`;
|
|
1817
|
+
/**
|
|
1818
|
+
* Read the interview-prep guide content (sections + tips) for one
|
|
1819
|
+
* interview via the mobile-gateway `InterviewGuide` query (#470).
|
|
1820
|
+
* Sub-sub-namespace leaf of `applications.interviews.*` — wraps the
|
|
1821
|
+
* mobile-portal interview-prep view that talents use to prepare.
|
|
1822
|
+
*
|
|
1823
|
+
* @param token Captured bearer.
|
|
1824
|
+
* @param interviewId `TalentInterview.id` (NOT the guide id). Discover
|
|
1825
|
+
* via `applications interview show <interviewId>`
|
|
1826
|
+
* or `applications show <activityId>`.
|
|
1827
|
+
*
|
|
1828
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the id doesn't resolve
|
|
1829
|
+
* to an interview the signed-in user can see, OR when the wire
|
|
1830
|
+
* surfaces a `NOT_FOUND_MESSAGE_PATTERN`-matched GraphQL error
|
|
1831
|
+
* (`Record not found` / `Invalid ID` / Relay `Node id ... resolves to`).
|
|
1832
|
+
* @throws `ApplicationsError("NO_VIEWER")` when the session is valid
|
|
1833
|
+
* but no viewer is bound.
|
|
1834
|
+
*/
|
|
1835
|
+
async function interviewsGuideShow(token, interviewId) {
|
|
1836
|
+
let data;
|
|
1837
|
+
try {
|
|
1838
|
+
data = await callGateway(token, "InterviewGuide", INTERVIEW_GUIDE_QUERY, { id: interviewId });
|
|
1839
|
+
}
|
|
1840
|
+
catch (err) {
|
|
1841
|
+
if (err instanceof ApplicationsError &&
|
|
1842
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
1843
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
1844
|
+
throw new ApplicationsError("NOT_FOUND", `No interview found with id "${interviewId}" (or you don't have access to it).`, { cause: err });
|
|
1845
|
+
}
|
|
1846
|
+
throw err;
|
|
1847
|
+
}
|
|
1848
|
+
if (data.viewer === null) {
|
|
1849
|
+
// Defensive — `callGateway` with `requireViewer: true` already
|
|
1850
|
+
// raises `NO_VIEWER` for this case; keep the check for type
|
|
1851
|
+
// narrowing parity with sibling leaves.
|
|
1852
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
1853
|
+
}
|
|
1854
|
+
if (data.viewer.interview === null) {
|
|
1855
|
+
throw new ApplicationsError("NOT_FOUND", `No interview found with id "${interviewId}" (or you don't have access to it).`);
|
|
1856
|
+
}
|
|
1857
|
+
const wire = data.viewer.interview;
|
|
1858
|
+
return {
|
|
1859
|
+
interviewId,
|
|
1860
|
+
guideId: wire.guide?.id ?? null,
|
|
1861
|
+
sections: (wire.guide?.sections ?? [])
|
|
1862
|
+
.filter((s) => s != null)
|
|
1863
|
+
.map((s) => ({
|
|
1864
|
+
identifier: (s.identifier ?? null),
|
|
1865
|
+
title: s.title ?? null,
|
|
1866
|
+
subtitle: s.subtitle ?? null,
|
|
1867
|
+
tips: (s.tips ?? [])
|
|
1868
|
+
.filter((t) => t != null)
|
|
1869
|
+
.map((t) => ({
|
|
1870
|
+
identifier: (t.identifier ?? null),
|
|
1871
|
+
title: t.title ?? null,
|
|
1872
|
+
content: t.content ?? null,
|
|
1873
|
+
hardcodedContent: t.hardcodedContent ?? null,
|
|
1874
|
+
})),
|
|
1875
|
+
})),
|
|
1876
|
+
};
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* `applications.interviews.*` sub-namespace. Read-only leaves for
|
|
1880
|
+
* interview-detail access — sibling to the top-level activity-row
|
|
1881
|
+
* leaves on this module. Sub-namespace grouping pattern follows
|
|
1882
|
+
* `payments.rate.*` (#447) and `payments.payouts.*` / `payments.methods.*`
|
|
1883
|
+
* (#149); the plural form here matches `payouts` / `methods` for
|
|
1884
|
+
* collection-style namespaces.
|
|
1885
|
+
*
|
|
1886
|
+
* Sub-sub-namespaces:
|
|
1887
|
+
* - `interviews.notes.*` (#440) — portal-side notes-focused projection.
|
|
1888
|
+
* `notes.show(jobId)` is the lightweight read of the talent's prep
|
|
1889
|
+
* notes for one job's interview, paired with the heavier
|
|
1890
|
+
* `interviews.show(interviewId)` from #439.
|
|
1891
|
+
* - `interviews.guide.*` (#470) — mobile-gateway guide-content
|
|
1892
|
+
* projection. `guide.show(interviewId)` is the read of the
|
|
1893
|
+
* interview-prep guide (sections + tips). Paired with #439 —
|
|
1894
|
+
* `interviews.show` surfaces the guide-id presence indicator;
|
|
1895
|
+
* `interviews.guide.show` fetches the full content.
|
|
1896
|
+
*/
|
|
1897
|
+
export const interviews = {
|
|
1898
|
+
show: interviewsShow,
|
|
1899
|
+
notes: {
|
|
1900
|
+
show: interviewsNotesShow,
|
|
1901
|
+
},
|
|
1902
|
+
guide: {
|
|
1903
|
+
show: interviewsGuideShow,
|
|
1904
|
+
},
|
|
1905
|
+
};
|
|
1906
|
+
export const AVAILABILITY_REQUEST_STATUSES = [
|
|
1907
|
+
"CANCELLED",
|
|
1908
|
+
"CONFIRMED",
|
|
1909
|
+
"EXPIRED",
|
|
1910
|
+
"PENDING",
|
|
1911
|
+
"REJECTED",
|
|
1912
|
+
"WITHDRAWN",
|
|
1913
|
+
];
|
|
1914
|
+
// Trimmed strict subset of the captured AvailabilityRequest op in
|
|
1915
|
+
// `research/graphql/gateway/operations/mobile/AvailabilityRequest.graphql`.
|
|
1916
|
+
// The captured doc selects `job { ...jobData }` (a ~25-type cascade
|
|
1917
|
+
// touching `Unknown`-typed positions) plus the `AvailabilityRequest`
|
|
1918
|
+
// gap fields; this trim keeps only the well-typed selection the CLI /
|
|
1919
|
+
// MCP renders. The `metadata` selection mirrors
|
|
1920
|
+
// `GET_AVAILABILITY_REQUEST_KIND_QUERY` — `__typename` per union variant
|
|
1921
|
+
// (drives {@link kindFromMetadataTypename}) plus `offeredHourlyRate` on
|
|
1922
|
+
// the Fixed variant. `job` is trimmed to the {@link ApplicationJobRef}
|
|
1923
|
+
// shape.
|
|
1924
|
+
const AVAILABILITY_REQUEST_QUERY = `query AvailabilityRequest($id: ID!) {
|
|
1925
|
+
viewer {
|
|
1926
|
+
__typename
|
|
1927
|
+
id
|
|
1928
|
+
availabilityRequest(id: $id) {
|
|
1929
|
+
__typename
|
|
1930
|
+
id
|
|
1931
|
+
createdAt
|
|
1932
|
+
updatedAt
|
|
1933
|
+
answeredAt
|
|
1934
|
+
comment
|
|
1935
|
+
jirStatus: statusV2 { __typename value }
|
|
1936
|
+
metadata {
|
|
1937
|
+
__typename
|
|
1938
|
+
... on AvailabilityRequestFixedMetadata {
|
|
1939
|
+
__typename
|
|
1940
|
+
offeredHourlyRate { __typename decimal verbose }
|
|
1941
|
+
}
|
|
1942
|
+
... on AvailabilityRequestFlexibleMetadata { __typename }
|
|
1943
|
+
... on MarketplaceAvailabilityRequestFlexibleMetadata { __typename }
|
|
1944
|
+
}
|
|
1945
|
+
job {
|
|
1946
|
+
__typename
|
|
1947
|
+
id
|
|
1948
|
+
title
|
|
1949
|
+
url
|
|
1950
|
+
client { __typename id fullName }
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
}`;
|
|
1955
|
+
function projectAvailabilityRequestDetail(w) {
|
|
1956
|
+
const offered = w.metadata?.offeredHourlyRate;
|
|
1957
|
+
const fixedRate = offered != null && typeof offered.decimal === "string" && typeof offered.verbose === "string"
|
|
1958
|
+
? { decimal: offered.decimal, verbose: offered.verbose }
|
|
1959
|
+
: null;
|
|
1960
|
+
return {
|
|
1961
|
+
id: w.id,
|
|
1962
|
+
status: (w.jirStatus?.value ?? null),
|
|
1963
|
+
kind: kindFromMetadataTypename(w.metadata?.__typename ?? null),
|
|
1964
|
+
fixedRate,
|
|
1965
|
+
comment: w.comment ?? null,
|
|
1966
|
+
createdAt: w.createdAt ?? null,
|
|
1967
|
+
updatedAt: w.updatedAt ?? null,
|
|
1968
|
+
answeredAt: w.answeredAt ?? null,
|
|
1969
|
+
job: w.job == null
|
|
1970
|
+
? null
|
|
1971
|
+
: {
|
|
1972
|
+
id: w.job.id,
|
|
1973
|
+
title: w.job.title ?? null,
|
|
1974
|
+
url: w.job.url ?? null,
|
|
1975
|
+
client: w.job.client == null ? null : { id: w.job.client.id, fullName: w.job.client.fullName ?? null },
|
|
1976
|
+
},
|
|
1977
|
+
};
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* Read one `AvailabilityRequest` by id via the mobile-gateway
|
|
1981
|
+
* `AvailabilityRequest` query (#442). Sibling sub-namespace to the
|
|
1982
|
+
* top-level activity-row leaves (`list` / `show` / `stats`) and to
|
|
1983
|
+
* `interviews.show` (#439) — fetches the rich availability-request
|
|
1984
|
+
* detail once the user knows the id from `applications show
|
|
1985
|
+
* <activityId>` (the `Availability request: <id>` line). The id is the
|
|
1986
|
+
* same `AvailabilityRequest.id` the #411 `confirm` / `reject` write-side
|
|
1987
|
+
* ops accept.
|
|
1988
|
+
*
|
|
1989
|
+
* @throws `ApplicationsError("NOT_FOUND")` when the id doesn't resolve
|
|
1990
|
+
* to an availability request the signed-in user can see, OR when the
|
|
1991
|
+
* wire surfaces a `NOT_FOUND_MESSAGE_PATTERN`-matched GraphQL error
|
|
1992
|
+
* (`Record not found` / `Invalid ID` / Relay `Node id ... resolves to`).
|
|
1993
|
+
* @throws `ApplicationsError("NO_VIEWER")` when the session is valid
|
|
1994
|
+
* but no viewer is bound.
|
|
1995
|
+
*/
|
|
1996
|
+
async function availabilityRequestsShow(token, id) {
|
|
1997
|
+
let data;
|
|
1998
|
+
try {
|
|
1999
|
+
data = await callGateway(token, "AvailabilityRequest", AVAILABILITY_REQUEST_QUERY, { id });
|
|
2000
|
+
}
|
|
2001
|
+
catch (err) {
|
|
2002
|
+
if (err instanceof ApplicationsError &&
|
|
2003
|
+
err.code === "GRAPHQL_ERROR" &&
|
|
2004
|
+
NOT_FOUND_MESSAGE_PATTERN.test(err.message)) {
|
|
2005
|
+
throw new ApplicationsError("NOT_FOUND", `No availability request found with id "${id}" (or you don't have access to it).`, { cause: err });
|
|
2006
|
+
}
|
|
2007
|
+
throw err;
|
|
2008
|
+
}
|
|
2009
|
+
if (data.viewer === null) {
|
|
2010
|
+
// Defensive — `callGateway` with `requireViewer: true` already
|
|
2011
|
+
// raises `NO_VIEWER` for this case; keep the check for type
|
|
2012
|
+
// narrowing parity with sibling `interviews.show()`.
|
|
2013
|
+
throw new ApplicationsError("NO_VIEWER", "Session is valid but no viewer is bound to it.");
|
|
2014
|
+
}
|
|
2015
|
+
if (data.viewer.availabilityRequest === null) {
|
|
2016
|
+
throw new ApplicationsError("NOT_FOUND", `No availability request found with id "${id}" (or you don't have access to it).`);
|
|
2017
|
+
}
|
|
2018
|
+
return projectAvailabilityRequestDetail(data.viewer.availabilityRequest);
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* `applications.availabilityRequests.*` sub-namespace. Read-only leaf
|
|
2022
|
+
* for availability-request-detail access — sibling to `interviews.*`
|
|
2023
|
+
* (#439 / #440) and to the top-level activity-row leaves. The plural
|
|
2024
|
+
* `availabilityRequests` form matches `interviews` / `payouts` /
|
|
2025
|
+
* `methods` for collection-style namespaces; the #411 write-side ops
|
|
2026
|
+
* (`confirm` / `reject` / `rejectReasons`) stay top-level flat exports
|
|
2027
|
+
* (they predate the sub-namespace convention).
|
|
2028
|
+
*/
|
|
2029
|
+
export const availabilityRequests = {
|
|
2030
|
+
show: availabilityRequestsShow,
|
|
2031
|
+
};
|
|
754
2032
|
//# sourceMappingURL=index.js.map
|