@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.
Files changed (39) hide show
  1. package/dist/__generated__/gateway.d.ts +9 -9
  2. package/dist/__generated__/gateway.d.ts.map +1 -1
  3. package/dist/__generated__/zod-schemas.d.ts +9 -9
  4. package/dist/__generated__/zod-schemas.d.ts.map +1 -1
  5. package/dist/__generated__/zod-schemas.js +9 -9
  6. package/dist/__generated__/zod-schemas.js.map +1 -1
  7. package/dist/__tests__/fixtures/profile/builders.d.ts.map +1 -1
  8. package/dist/__tests__/fixtures/profile/builders.js +1 -0
  9. package/dist/__tests__/fixtures/profile/builders.js.map +1 -1
  10. package/dist/__tests__/fixtures/profile/data.d.ts.map +1 -1
  11. package/dist/__tests__/fixtures/profile/data.js +2 -0
  12. package/dist/__tests__/fixtures/profile/data.js.map +1 -1
  13. package/dist/consent.d.ts +236 -0
  14. package/dist/consent.d.ts.map +1 -0
  15. package/dist/consent.js +225 -0
  16. package/dist/consent.js.map +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/services/applications/index.d.ts +917 -13
  22. package/dist/services/applications/index.d.ts.map +1 -1
  23. package/dist/services/applications/index.js +1284 -6
  24. package/dist/services/applications/index.js.map +1 -1
  25. package/dist/services/jobs/index.d.ts.map +1 -1
  26. package/dist/services/jobs/index.js.map +1 -1
  27. package/dist/services/payments/index.d.ts +66 -0
  28. package/dist/services/payments/index.d.ts.map +1 -1
  29. package/dist/services/payments/index.js +120 -0
  30. package/dist/services/payments/index.js.map +1 -1
  31. package/dist/services/profile/employment/index.d.ts +50 -4
  32. package/dist/services/profile/employment/index.d.ts.map +1 -1
  33. package/dist/services/profile/employment/index.js +91 -16
  34. package/dist/services/profile/employment/index.js.map +1 -1
  35. package/dist/services/profile/reviews/index.d.ts +31 -1
  36. package/dist/services/profile/reviews/index.d.ts.map +1 -1
  37. package/dist/services/profile/reviews/index.js +19 -1
  38. package/dist/services/profile/reviews/index.js.map +1 -1
  39. 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 `Record not found`** (the empirical
322
- * happy-sad path verified live on 2026-05-10). The gateway
323
- * short-circuits with a top-level `errors[]` carrying the literal
324
- * message "Record not found" rather than returning `data: null`.
325
- * `callGateway` raises `GRAPHQL_ERROR`; we catch and translate.
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