founder-schema 0.16.0 → 0.17.0

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 (34) hide show
  1. package/README.md +138 -2
  2. package/dist/core/common.d.ts +73 -16
  3. package/dist/core/common.d.ts.map +1 -1
  4. package/dist/core/common.js +68 -14
  5. package/dist/core/common.js.map +1 -1
  6. package/dist/core/limits.d.ts +7 -0
  7. package/dist/core/limits.d.ts.map +1 -1
  8. package/dist/core/limits.js +7 -0
  9. package/dist/core/limits.js.map +1 -1
  10. package/dist/core/validation.d.ts +26 -0
  11. package/dist/core/validation.d.ts.map +1 -1
  12. package/dist/core/validation.js +26 -0
  13. package/dist/core/validation.js.map +1 -1
  14. package/dist/domains/location/india.d.ts +205 -0
  15. package/dist/domains/location/india.d.ts.map +1 -0
  16. package/dist/domains/location/india.js +1175 -0
  17. package/dist/domains/location/india.js.map +1 -0
  18. package/dist/domains/onboarding/onboarding-answers.d.ts +81 -0
  19. package/dist/domains/onboarding/onboarding-answers.d.ts.map +1 -0
  20. package/dist/domains/onboarding/onboarding-answers.js +141 -0
  21. package/dist/domains/onboarding/onboarding-answers.js.map +1 -0
  22. package/dist/domains/onboarding/onboarding.d.ts +178 -14
  23. package/dist/domains/onboarding/onboarding.d.ts.map +1 -1
  24. package/dist/domains/onboarding/onboarding.js +215 -47
  25. package/dist/domains/onboarding/onboarding.js.map +1 -1
  26. package/dist/domains/profile/founder-profile.d.ts +94 -6
  27. package/dist/domains/profile/founder-profile.d.ts.map +1 -1
  28. package/dist/domains/profile/founder-profile.js +26 -9
  29. package/dist/domains/profile/founder-profile.js.map +1 -1
  30. package/dist/index.d.ts +6 -4
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -4
  33. package/dist/index.js.map +1 -1
  34. package/package.json +1 -1
@@ -16,8 +16,8 @@
16
16
  * for the end. Letting those two drift is how a wizard says a step is optional
17
17
  * and then answers 422 at Finish.
18
18
  *
19
- * NO COPY IN HERE. Step titles and help lines are translation KEYS
20
- * (`titleKey`, `helpKey`), Jansathi's rule for every config string: an English
19
+ * NO COPY IN HERE. A step's label and title are translation KEYS
20
+ * (`labelKey`, `titleKey`), Jansathi's rule for every config string: an English
21
21
  * sentence in a shared package is a sentence every other language has to match
22
22
  * by wording. A topic category is named by its `key`, which the client words
23
23
  * (0.6.0 dropped its `labelKey`: the web, English-only since 2026-10-06, read
@@ -26,9 +26,11 @@
26
26
  * @module founder-schema/domains/onboarding
27
27
  */
28
28
  import { z } from 'zod';
29
- import { displayNameSchema, GENDER_VALUES, GOAL_VALUES, genderSchema, goalSchema, INDUSTRY_VALUES, industrySchema, locationSchema, objectIdSchema, optionalTrimmedString, phoneSchema, SKILL_VALUES, STARTUP_STAGE_VALUES, skillSchema, startupStageSchema, } from '../../core/common.js';
29
+ import { displayNameSchema, GENDER_VALUES, GOAL_VALUES, genderSchema, goalListSchema, goalSchema, INDUSTRY_VALUES, industrySchema, objectIdSchema, optionalTrimmedString, phoneSchema, SKILL_VALUES, STARTUP_STAGE_VALUES, skillListSchema, skillSchema, startupStageSchema, } from '../../core/common.js';
30
30
  import { ABOUT_MAX, CITY_MAX, COUNTRY_MAX, DISPLAY_NAME_MAX, GOALS_MIN, HEADLINE_MAX, LINK_URL_MAX, SKILLS_MAX, STATE_MAX, TEAM_SIZE_MAX, VENTURE_NAME_MAX, } from '../../core/limits.js';
31
31
  import { VALIDATION_MESSAGE } from '../../core/validation.js';
32
+ import { indiaLocationSchema } from '../location/india.js';
33
+ import { mediaImageSchema } from '../media/media.js';
32
34
  import { founderLinksSchema, ventureSchema } from '../profile/founder-profile.js';
33
35
  // ─── Age ─────────────────────────────────────────────────────────────────────
34
36
  /**
@@ -87,6 +89,58 @@ function isRealDate(iso) {
87
89
  const asDate = new Date(Date.UTC(y, m - 1, d));
88
90
  return (asDate.getUTCFullYear() === y && asDate.getUTCMonth() === m - 1 && asDate.getUTCDate() === d);
89
91
  }
92
+ /**
93
+ * True for a `YYYY-MM-DD` that is a real calendar date (0.17.0, exported so a
94
+ * client's date picker checks a date exactly as `dateOfBirthSchema` does):
95
+ * not 2026-02-30, not 2026-13-01.
96
+ */
97
+ export function isRealIsoDate(value) {
98
+ return isoDateSchema.safeParse(value).success && isRealDate(value.trim());
99
+ }
100
+ /** `YYYY-MM-DD` for a moment, counted in India's calendar. */
101
+ const isoInIndia = (moment) => new Date(moment + INDIA_OFFSET_MS).toISOString().slice(0, 10);
102
+ /**
103
+ * Today in India, `YYYY-MM-DD` (0.17.0): the day every age here is counted
104
+ * from (`ageInYears`), so a client's "today" must be this one, never the
105
+ * device's own: east of India the device is already on tomorrow.
106
+ */
107
+ export function todayInIndia(now = new Date()) {
108
+ return isoInIndia(now.getTime());
109
+ }
110
+ const DAY_MS = 24 * 60 * 60 * 1000;
111
+ const isoOfUtc = (ms) => new Date(ms).toISOString().slice(0, 10);
112
+ /**
113
+ * The dates of birth `dateOfBirthSchema` accepts TODAY, both ends included
114
+ * (0.17.0): `latest` makes someone exactly `MIN_AGE_YEARS` today, `earliest`
115
+ * exactly `MAX_AGE_YEARS`. Worked out with `ageInYears` itself, so a picker
116
+ * that offers only this window can never offer a date the schema refuses, a
117
+ * 29 February included.
118
+ */
119
+ export function dateOfBirthBounds(now = new Date()) {
120
+ const [y, m, d] = todayInIndia(now).split('-').map(Number);
121
+ let latest = Date.UTC(y - MIN_AGE_YEARS, m - 1, d);
122
+ while (ageInYears(isoOfUtc(latest), now) < MIN_AGE_YEARS)
123
+ latest -= DAY_MS;
124
+ let earliest = Date.UTC(y - MAX_AGE_YEARS - 1, m - 1, d);
125
+ while (ageInYears(isoOfUtc(earliest), now) > MAX_AGE_YEARS)
126
+ earliest += DAY_MS;
127
+ return { earliest: isoOfUtc(earliest), latest: isoOfUtc(latest) };
128
+ }
129
+ /**
130
+ * True only for a COMPLETE, real date of birth that is under `MIN_AGE_YEARS`
131
+ * today (0.17.0): the rule the server applies to a draft, where a date still
132
+ * being typed must pass ("19" says nothing about anyone's age yet). Until
133
+ * 0.17.0 the backend wrote this out by hand and skipped the real-date check.
134
+ */
135
+ export function isUnderAgeDateOfBirth(value, now = new Date()) {
136
+ if (typeof value !== 'string' || !isRealIsoDate(value))
137
+ return false;
138
+ return ageInYears(value.trim(), now) < MIN_AGE_YEARS;
139
+ }
140
+ /** A `YYYY-MM-DD` as the instant it is stored as: midnight UTC that day (0.17.0). */
141
+ export const isoDateToDate = (iso) => new Date(`${iso.trim()}T00:00:00.000Z`);
142
+ /** A stored date (midnight UTC) as `YYYY-MM-DD` (0.17.0): `isoDateToDate`'s inverse. */
143
+ export const dateToIsoDate = (date) => date.toISOString().slice(0, 10);
90
144
  /**
91
145
  * Date of birth, with the age gate applied per parse.
92
146
  *
@@ -370,6 +424,53 @@ export const TOPIC_CATEGORIES = [
370
424
  'education_access',
371
425
  ]),
372
426
  ];
427
+ // ─── Goals: the grouping the picker renders ──────────────────────────────────
428
+ /**
429
+ * The goals step's three headings (0.17.0, moved here from the web, where the
430
+ * grouping lived in the screen): eighteen goals since 0.16.0, shown a group at
431
+ * a time so the eye takes six tiles, not a wall of eighteen. A group is named
432
+ * by its `key`, which the client words, like a topic category.
433
+ *
434
+ * ⚠ `GOAL_VALUES` stays the single source of truth for what a goal IS; this is
435
+ * typed against it, and a test holds the two together in both directions:
436
+ * every goal in exactly one group, nothing in a group that is not a goal.
437
+ */
438
+ export const GOAL_CATEGORY_KEYS = ['build', 'grow', 'learn'];
439
+ export const GOAL_CATEGORIES = [
440
+ {
441
+ key: 'build',
442
+ values: [
443
+ 'start_company',
444
+ 'get_feedback',
445
+ 'find_cofounder',
446
+ 'find_testers',
447
+ 'hiring',
448
+ 'find_service_providers',
449
+ ],
450
+ },
451
+ {
452
+ key: 'grow',
453
+ values: [
454
+ 'find_customers',
455
+ 'find_investors',
456
+ 'partnerships',
457
+ 'market_insights',
458
+ 'business_ideas',
459
+ 'grow_network',
460
+ ],
461
+ },
462
+ {
463
+ key: 'learn',
464
+ values: [
465
+ 'learn_startup',
466
+ 'mentorship',
467
+ 'attend_events',
468
+ 'join_startup',
469
+ 'mentor_others',
470
+ 'invest_in_startups',
471
+ ],
472
+ },
473
+ ];
373
474
  /**
374
475
  * How many a member must pick. A minimum exists because these drive feed
375
476
  * relevance and zero of them means no relevance at all.
@@ -438,8 +539,15 @@ export const ONBOARDING_STEP_KEYS = [
438
539
  * profile; that record is the point.
439
540
  */
440
541
  export const GUIDELINES_VERSION = '2026-10-03';
542
+ /**
543
+ * Not agreed at all (the box left unticked) is `guidelinesRequired`; agreed to
544
+ * another text is `guidelinesOutdated` (0.17.0: both were the second, so a
545
+ * client had to word an unticked box itself).
546
+ */
441
547
  export const guidelinesVersionSchema = z.literal(GUIDELINES_VERSION, {
442
- error: VALIDATION_MESSAGE.guidelinesOutdated,
548
+ error: (issue) => issue.input === undefined || issue.input === null
549
+ ? VALIDATION_MESSAGE.guidelinesRequired
550
+ : VALIDATION_MESSAGE.guidelinesOutdated,
443
551
  });
444
552
  // ─── Contact ─────────────────────────────────────────────────────────────────
445
553
  /**
@@ -454,6 +562,12 @@ export const contactLinksSchema = founderLinksSchema.pick({
454
562
  });
455
563
  /** A team size has at most this many digits: `TEAM_SIZE_MAX` written out. */
456
564
  export const TEAM_SIZE_DIGITS_MAX = String(TEAM_SIZE_MAX).length;
565
+ /**
566
+ * A team size as typed, kept to what the field may hold: digits only, at most
567
+ * `TEAM_SIZE_DIGITS_MAX` (0.17.0: the draft reader and every client's field
568
+ * clean it with this one rule).
569
+ */
570
+ export const teamSizeDigits = (raw) => raw.replace(/\D/g, '').slice(0, TEAM_SIZE_DIGITS_MAX);
457
571
  // ─── The form, mid-flight: the draft each step saves ──────────────────────────
458
572
  /**
459
573
  * The answers so far, keyed by the finish body's own field names: what each
@@ -485,11 +599,16 @@ export const onboardingFormSchema = z.object({
485
599
  headline: z.string().max(HEADLINE_MAX),
486
600
  about: z.string().max(ABOUT_MAX),
487
601
  /**
488
- * The completed upload's id (`media`, purpose `avatar`). Allowed in the form,
489
- * and NEVER restored by `readOnboardingDraft`: an upload not attached within
490
- * `MEDIA_UNATTACHED_TTL_HOURS` is deleted, so an id from an older draft would
491
- * fail at Finish, on the last screen, about a photo picked days ago. Clients
492
- * hold it for the visit and do not save it.
602
+ * The completed upload's id (`media`, purpose `avatar`), or `null` for no
603
+ * photo.
604
+ *
605
+ * SAVED BY THE PHOTO STEP, KEPT BY THE SERVER, NOT IN THE DRAFT (0.17.0). A
606
+ * step save carrying this key hands the upload to the server, which holds it
607
+ * for the draft (so the unattached sweep, `MEDIA_UNATTACHED_TTL_HOURS`, never
608
+ * deletes it) and lets go of the one it replaces; `null` lets go of it. It
609
+ * comes back as the status's `photo`, read from what the server holds, never
610
+ * from a stored draft: `readOnboardingDraft` still gives `null` here, so an
611
+ * id an older client left in a draft is never trusted.
493
612
  */
494
613
  avatarMediaId: objectIdSchema.nullable(),
495
614
  skills: z.array(skillSchema).max(SKILLS_MAX),
@@ -587,10 +706,7 @@ const draftReaderSchema = z.object({
587
706
  website: draftText(LINK_URL_MAX),
588
707
  industry: draftChoice(INDUSTRY_VALUES),
589
708
  stage: draftChoice(STARTUP_STAGE_VALUES),
590
- teamSize: z
591
- .string()
592
- .catch('')
593
- .transform((v) => v.replace(/\D/g, '').slice(0, TEAM_SIZE_DIGITS_MAX)),
709
+ teamSize: z.string().catch('').transform(teamSizeDigits),
594
710
  }).transform((venture) => venture ?? { ...EMPTY_ONBOARDING_VENTURE }),
595
711
  businessType: draftChoice(BUSINESS_TYPE_VALUES),
596
712
  legalStructure: draftChoice(LEGAL_STRUCTURE_VALUES),
@@ -615,8 +731,8 @@ export const EMPTY_ONBOARDING_VENTURE = {
615
731
  * one that does not fit becomes "not answered" (`''`, `null`, `[]`) and the
616
732
  * rest of the draft survives. Never coerced into a different answer: a value
617
733
  * outside a vocabulary is dropped, not mapped to the nearest one. A photo
618
- * is never restored (see `avatarMediaId`), and agreement only counts for the
619
- * CURRENT guidelines.
734
+ * is never restored from here (it comes back as the status's `photo`; see
735
+ * `avatarMediaId`), and agreement only counts for the CURRENT guidelines.
620
736
  *
621
737
  * It fills in no answer the member did not give: a blank country stays blank.
622
738
  * Whatever a screen shows by default is that screen's choice.
@@ -627,23 +743,12 @@ export function readOnboardingDraft(draft) {
627
743
  }
628
744
  // ─── The finalize body ───────────────────────────────────────────────────────
629
745
  /**
630
- * What the client sends to finish. Completed answers only.
631
- *
632
- * ⚠️ EVERY OPTIONAL FIELD HERE MUST BE ABSENT WHEN UNANSWERED, never a default
633
- * standing in for one. The sibling gym product sent `bloodGroup ?? 'O+'`, so
634
- * every member who skipped the question was recorded as O-positive: a real
635
- * answer, indistinguishable from one somebody gave, sitting on a screen staff
636
- * would read in an emergency. Absent has to stay absent.
637
- *
638
- * ⚠️ AND THIS IS NOT THE GATE EITHER. The server re-checks the age and refuses
639
- * at finalize. `isOnboarded` may be set here and nowhere else: four of the
640
- * profile's partial indexes are gated on it, so a half-built profile carrying
641
- * it true appears in Discover half-built.
642
- *
643
- * Location, venture and links are the profile's own shapes, not copies: the
644
- * copies are how the profile model came to lack Instagram.
746
+ * The finish body's fields, before the rules that span fields: what each
747
+ * step's slice is PICKED from (Zod 4 refuses `.pick` on a refined object).
748
+ * Everything outside this file reads `completeOnboardingBodySchema`, whose
749
+ * note below says what the body is.
645
750
  */
646
- export const completeOnboardingBodySchema = z.object({
751
+ const completeOnboardingBodyFields = z.object({
647
752
  // Plain bounds, no custom message: `too_small` with minimum 1 already says
648
753
  // "required", and the client words it in the member's language.
649
754
  displayName: displayNameSchema,
@@ -674,11 +779,16 @@ export const completeOnboardingBodySchema = z.object({
674
779
  avatarMediaId: objectIdSchema.optional(),
675
780
  phone: phoneSchema,
676
781
  links: contactLinksSchema.optional(),
677
- /** Absent when the member skipped the step or refused the permission. */
678
- location: locationSchema.optional(),
679
- goals: z.array(goalSchema).min(GOALS_MIN).max(GOAL_VALUES.length),
782
+ /**
783
+ * Absent when the member skipped the step or refused the permission. In
784
+ * India, one of its states (0.17.0: `indiaLocationSchema`, the rule the
785
+ * screen always showed).
786
+ */
787
+ location: indiaLocationSchema.optional(),
788
+ /** The shared list rule (each goal once, 0.17.0) plus finish's minimum. */
789
+ goals: goalListSchema.min(GOALS_MIN),
680
790
  interests: interestsSchema,
681
- skills: z.array(skillSchema).max(SKILLS_MAX).optional(),
791
+ skills: skillListSchema.optional(),
682
792
  /**
683
793
  * Absent when the member skipped the business step. The profile's venture
684
794
  * without `foundedYear`, which onboarding does not ask; the Me tab does.
@@ -698,6 +808,37 @@ export const completeOnboardingBodySchema = z.object({
698
808
  */
699
809
  guidelinesVersion: guidelinesVersionSchema,
700
810
  });
811
+ const typeHasVenture = (body) => body.venture !== undefined || body.businessType === undefined;
812
+ const registrationHasVenture = (body) => body.venture !== undefined || body.legalStructure === undefined;
813
+ const TYPE_NEEDS_VENTURE = { error: VALIDATION_MESSAGE.ventureRequired, path: ['businessType'] };
814
+ const REGISTRATION_NEEDS_VENTURE = {
815
+ error: VALIDATION_MESSAGE.ventureRequired,
816
+ path: ['legalStructure'],
817
+ };
818
+ /**
819
+ * What the client sends to finish. Completed answers only.
820
+ *
821
+ * ⚠️ EVERY OPTIONAL FIELD HERE MUST BE ABSENT WHEN UNANSWERED, never a default
822
+ * standing in for one. The sibling gym product sent `bloodGroup ?? 'O+'`, so
823
+ * every member who skipped the question was recorded as O-positive: a real
824
+ * answer, indistinguishable from one somebody gave, sitting on a screen staff
825
+ * would read in an emergency. Absent has to stay absent.
826
+ *
827
+ * ⚠️ AND THIS IS NOT THE GATE EITHER. The server re-checks the age and refuses
828
+ * at finalize. `isOnboarded` may be set here and nowhere else: four of the
829
+ * profile's partial indexes are gated on it, so a half-built profile carrying
830
+ * it true appears in Discover half-built.
831
+ *
832
+ * Location, venture and links are the profile's own shapes, not copies: the
833
+ * copies are how the profile model came to lack Instagram.
834
+ *
835
+ * Refined (the business type and registration need a venture, above), so Zod 4
836
+ * throws on `.pick` / `.omit` / `.partial` / `.merge` of it: read a field with
837
+ * `.shape.<field>`, a step's with `ONBOARDING_STEP_SCHEMAS.<step>`.
838
+ */
839
+ export const completeOnboardingBodySchema = completeOnboardingBodyFields
840
+ .refine(typeHasVenture, TYPE_NEEDS_VENTURE)
841
+ .refine(registrationHasVenture, REGISTRATION_NEEDS_VENTURE);
701
842
  // ─── The steps ───────────────────────────────────────────────────────────────
702
843
  /**
703
844
  * Each step's slice of the finish body, picked FROM it, so a client validating
@@ -717,32 +858,35 @@ export const ONBOARDING_STEP_SCHEMAS = {
717
858
  // 0.16.0: the old `identity` step, split three ways by the user. The basics
718
859
  // are the step's only required answers; the photo and the professional
719
860
  // details may each be passed over.
720
- basics: completeOnboardingBodySchema.pick({ displayName: true, gender: true, dateOfBirth: true }),
721
- photo: completeOnboardingBodySchema.pick({ avatarMediaId: true }),
722
- professional: completeOnboardingBodySchema.pick({
861
+ basics: completeOnboardingBodyFields.pick({ displayName: true, gender: true, dateOfBirth: true }),
862
+ photo: completeOnboardingBodyFields.pick({ avatarMediaId: true }),
863
+ professional: completeOnboardingBodyFields.pick({
723
864
  headline: true,
724
865
  about: true,
725
866
  // Ruling D8 folded the design's "title, achievements, skills" screen into
726
867
  // the profile's first questions; it now has a step of its own again.
727
868
  skills: true,
728
869
  }),
729
- contact: completeOnboardingBodySchema.pick({ phone: true, links: true }),
730
- location: completeOnboardingBodySchema.pick({ location: true }),
731
- goals: completeOnboardingBodySchema.pick({ goals: true }),
732
- interests: completeOnboardingBodySchema.pick({ interests: true }),
733
- business: completeOnboardingBodySchema.pick({
870
+ contact: completeOnboardingBodyFields.pick({ phone: true, links: true }),
871
+ location: completeOnboardingBodyFields.pick({ location: true }),
872
+ goals: completeOnboardingBodyFields.pick({ goals: true }),
873
+ interests: completeOnboardingBodyFields.pick({ interests: true }),
874
+ business: completeOnboardingBodyFields
875
+ .pick({
734
876
  venture: true,
735
877
  businessType: true,
736
878
  legalStructure: true,
737
- }),
738
- guidelines: completeOnboardingBodySchema.pick({ guidelinesVersion: true }),
879
+ })
880
+ .refine(typeHasVenture, TYPE_NEEDS_VENTURE)
881
+ .refine(registrationHasVenture, REGISTRATION_NEEDS_VENTURE),
882
+ guidelines: completeOnboardingBodyFields.pick({ guidelinesVersion: true }),
739
883
  };
740
884
  export const FOUNDER_ONBOARDING_STEPS = ONBOARDING_STEP_KEYS.map((key) => {
741
885
  const slice = ONBOARDING_STEP_SCHEMAS[key];
742
886
  return {
743
887
  key,
888
+ labelKey: `onboarding.steps.${key}.label`,
744
889
  titleKey: `onboarding.steps.${key}.title`,
745
- helpKey: `onboarding.steps.${key}.help`,
746
890
  fields: Object.keys(slice.shape),
747
891
  required: !slice.safeParse({}).success,
748
892
  };
@@ -791,10 +935,26 @@ export const onboardingProgressSchema = z.object({
791
935
  * `PATCH` body: any subset of the cursor, merged server-side. No defaults.
792
936
  * `step` may equal `ONBOARDING_TOTAL_STEPS`: "past the last step", which the
793
937
  * client sends on its way to finish.
938
+ *
939
+ * The draft's values are opaque, but ONE is not kept as typed (0.17.0): the
940
+ * photo step's `avatarMediaId` is handed to the server, which holds that
941
+ * upload, so it must be an upload id or `null` like the form's own field.
794
942
  */
795
943
  export const onboardingProgressUpdateSchema = z.object({
796
944
  step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS).optional(),
797
- draft: onboardingDraftSchema.optional(),
945
+ draft: onboardingDraftSchema
946
+ .refine((draft) => !('avatarMediaId' in draft) ||
947
+ onboardingFormSchema.shape.avatarMediaId.safeParse(draft.avatarMediaId).success, { error: VALIDATION_MESSAGE.invalidId, path: ['avatarMediaId'] })
948
+ // ...and read as an id is read everywhere (`objectIdSchema`: lower case),
949
+ // since the server compares it with the upload it already holds: the
950
+ // same photo sent in capitals was not "the one held", and was refused.
951
+ .transform((draft) => 'avatarMediaId' in draft
952
+ ? {
953
+ ...draft,
954
+ avatarMediaId: onboardingFormSchema.shape.avatarMediaId.parse(draft.avatarMediaId),
955
+ }
956
+ : draft)
957
+ .optional(),
798
958
  });
799
959
  /**
800
960
  * What the client reads on return, from `GET` and from every `PATCH`.
@@ -808,5 +968,13 @@ export const onboardingStatusSchema = z.object({
808
968
  onboarded: z.boolean(),
809
969
  step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
810
970
  draft: onboardingDraftSchema.optional(),
971
+ /**
972
+ * The photo the member saved on the photo step, as it will appear (0.17.0):
973
+ * the upload the server holds for this member's draft. Absent when they
974
+ * saved none, removed it, or have finished. A client resumes the photo step
975
+ * from this (its `id` is the finish body's `avatarMediaId`), never from the
976
+ * draft.
977
+ */
978
+ photo: mediaImageSchema.optional(),
811
979
  });
812
980
  //# sourceMappingURL=onboarding.js.map