founder-schema 0.16.0 → 0.18.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 (116) hide show
  1. package/README.md +263 -19
  2. package/dist/access/access.d.ts +1 -1
  3. package/dist/access/access.d.ts.map +1 -1
  4. package/dist/access/access.js +4 -1
  5. package/dist/access/access.js.map +1 -1
  6. package/dist/access/permissions.d.ts +27 -29
  7. package/dist/access/permissions.d.ts.map +1 -1
  8. package/dist/access/permissions.js +39 -3
  9. package/dist/access/permissions.js.map +1 -1
  10. package/dist/core/common.d.ts +117 -23
  11. package/dist/core/common.d.ts.map +1 -1
  12. package/dist/core/common.js +104 -21
  13. package/dist/core/common.js.map +1 -1
  14. package/dist/core/errors.d.ts +7 -5
  15. package/dist/core/errors.d.ts.map +1 -1
  16. package/dist/core/errors.js +17 -11
  17. package/dist/core/errors.js.map +1 -1
  18. package/dist/core/limits.d.ts +55 -17
  19. package/dist/core/limits.d.ts.map +1 -1
  20. package/dist/core/limits.js +59 -22
  21. package/dist/core/limits.js.map +1 -1
  22. package/dist/core/money.d.ts +33 -0
  23. package/dist/core/money.d.ts.map +1 -0
  24. package/dist/core/money.js +36 -0
  25. package/dist/core/money.js.map +1 -0
  26. package/dist/core/responses.d.ts +14 -4
  27. package/dist/core/responses.d.ts.map +1 -1
  28. package/dist/core/responses.js.map +1 -1
  29. package/dist/core/validation.d.ts +32 -2
  30. package/dist/core/validation.d.ts.map +1 -1
  31. package/dist/core/validation.js +32 -2
  32. package/dist/core/validation.js.map +1 -1
  33. package/dist/domains/billing/billing.d.ts +61 -8
  34. package/dist/domains/billing/billing.d.ts.map +1 -1
  35. package/dist/domains/billing/billing.js +58 -22
  36. package/dist/domains/billing/billing.js.map +1 -1
  37. package/dist/domains/billing/gst.d.ts.map +1 -1
  38. package/dist/domains/billing/gst.js +4 -3
  39. package/dist/domains/billing/gst.js.map +1 -1
  40. package/dist/domains/chat/chat.d.ts +0 -21
  41. package/dist/domains/chat/chat.d.ts.map +1 -1
  42. package/dist/domains/chat/chat.js +0 -20
  43. package/dist/domains/chat/chat.js.map +1 -1
  44. package/dist/domains/communities/communities.d.ts +30 -12
  45. package/dist/domains/communities/communities.d.ts.map +1 -1
  46. package/dist/domains/communities/communities.js +43 -15
  47. package/dist/domains/communities/communities.js.map +1 -1
  48. package/dist/domains/contributions/contributions.d.ts +1 -1
  49. package/dist/domains/contributions/contributions.js +1 -1
  50. package/dist/domains/contributions/contributions.js.map +1 -1
  51. package/dist/domains/dashboard/dashboard.d.ts +3 -1
  52. package/dist/domains/dashboard/dashboard.d.ts.map +1 -1
  53. package/dist/domains/dashboard/dashboard.js +23 -7
  54. package/dist/domains/dashboard/dashboard.js.map +1 -1
  55. package/dist/domains/discover/discover.d.ts +8 -8
  56. package/dist/domains/feed/feed.d.ts +55 -153
  57. package/dist/domains/feed/feed.d.ts.map +1 -1
  58. package/dist/domains/feed/feed.js +53 -58
  59. package/dist/domains/feed/feed.js.map +1 -1
  60. package/dist/domains/location/india.d.ts +215 -0
  61. package/dist/domains/location/india.d.ts.map +1 -0
  62. package/dist/domains/location/india.js +1203 -0
  63. package/dist/domains/location/india.js.map +1 -0
  64. package/dist/domains/members/members.d.ts +2 -2
  65. package/dist/domains/membership/membership.d.ts +26 -0
  66. package/dist/domains/membership/membership.d.ts.map +1 -1
  67. package/dist/domains/membership/membership.js +55 -1
  68. package/dist/domains/membership/membership.js.map +1 -1
  69. package/dist/domains/moderation/moderation.d.ts +2 -2
  70. package/dist/domains/moderation/moderation.js +3 -3
  71. package/dist/domains/moderation/moderation.js.map +1 -1
  72. package/dist/domains/notifications/notifications.d.ts +1 -10
  73. package/dist/domains/notifications/notifications.d.ts.map +1 -1
  74. package/dist/domains/notifications/notifications.js +2 -3
  75. package/dist/domains/notifications/notifications.js.map +1 -1
  76. package/dist/domains/onboarding/onboarding-answers.d.ts +112 -0
  77. package/dist/domains/onboarding/onboarding-answers.d.ts.map +1 -0
  78. package/dist/domains/onboarding/onboarding-answers.js +164 -0
  79. package/dist/domains/onboarding/onboarding-answers.js.map +1 -0
  80. package/dist/domains/onboarding/onboarding.d.ts +398 -62
  81. package/dist/domains/onboarding/onboarding.d.ts.map +1 -1
  82. package/dist/domains/onboarding/onboarding.js +229 -68
  83. package/dist/domains/onboarding/onboarding.js.map +1 -1
  84. package/dist/domains/products/products.d.ts +43 -8
  85. package/dist/domains/products/products.d.ts.map +1 -1
  86. package/dist/domains/products/products.js +57 -11
  87. package/dist/domains/products/products.js.map +1 -1
  88. package/dist/domains/profile/founder-profile.d.ts +120 -68
  89. package/dist/domains/profile/founder-profile.d.ts.map +1 -1
  90. package/dist/domains/profile/founder-profile.js +80 -29
  91. package/dist/domains/profile/founder-profile.js.map +1 -1
  92. package/dist/domains/profile/my-profile.d.ts +3 -3
  93. package/dist/domains/reviews/reviews.d.ts +1 -1
  94. package/dist/domains/reviews/reviews.d.ts.map +1 -1
  95. package/dist/domains/reviews/reviews.js +2 -2
  96. package/dist/domains/reviews/reviews.js.map +1 -1
  97. package/dist/domains/support/support.d.ts +22 -3
  98. package/dist/domains/support/support.d.ts.map +1 -1
  99. package/dist/domains/support/support.js +19 -5
  100. package/dist/domains/support/support.js.map +1 -1
  101. package/dist/domains/team/team.d.ts.map +1 -1
  102. package/dist/domains/team/team.js +3 -6
  103. package/dist/domains/team/team.js.map +1 -1
  104. package/dist/domains/uploads/uploads.d.ts +7 -22
  105. package/dist/domains/uploads/uploads.d.ts.map +1 -1
  106. package/dist/domains/uploads/uploads.js +7 -21
  107. package/dist/domains/uploads/uploads.js.map +1 -1
  108. package/dist/env.d.ts +26 -13
  109. package/dist/env.d.ts.map +1 -1
  110. package/dist/env.js +58 -23
  111. package/dist/env.js.map +1 -1
  112. package/dist/index.d.ts +18 -15
  113. package/dist/index.d.ts.map +1 -1
  114. package/dist/index.js +19 -14
  115. package/dist/index.js.map +1 -1
  116. 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';
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';
29
+ import { displayNameSchema, eachOnce, GENDER_VALUES, GOAL_VALUES, genderSchema, goalSchema, INDUSTRY_VALUES, industrySchema, objectIdSchema, optionalTrimmedString, phoneSchema, requiredGoalListSchema, SKILL_VALUES, STARTUP_STAGE_VALUES, skillListSchema, skillSchema, startupStageSchema, usernameSchema, } from '../../core/common.js';
30
+ import { ABOUT_MAX, CITY_MAX, COUNTRY_MAX, DAY_MS, DISPLAY_NAME_MAX, HEADLINE_MAX, INDIA_UTC_OFFSET_MS, 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
  /**
@@ -52,8 +54,6 @@ export const MAX_AGE_YEARS = 120;
52
54
  * Measured: it disagrees with the calendar on all 400 of the next 400 days.
53
55
  * An age gate is exactly the place an approximation is not good enough.
54
56
  */
55
- /** India Standard Time, UTC+05:30, with no daylight saving. */
56
- const INDIA_OFFSET_MS = (5 * 60 + 30) * 60 * 1000;
57
57
  export function ageInYears(isoDate, now = new Date()) {
58
58
  // ⚠️ A STRING, `YYYY-MM-DD`, not a `Date`. This is a wire contract, so it
59
59
  // takes the wire type. The backend is the obvious caller and the backend
@@ -67,7 +67,7 @@ export function ageInYears(isoDate, now = new Date()) {
67
67
  // The date TODAY IS IN INDIA (0.13.0), where the members are: a birthday
68
68
  // begins at midnight there, not five and a half hours later at UTC's
69
69
  // midnight. India keeps one offset all year.
70
- const today = new Date(now.getTime() + INDIA_OFFSET_MS);
70
+ const today = new Date(now.getTime() + INDIA_UTC_OFFSET_MS);
71
71
  let age = today.getUTCFullYear() - y;
72
72
  const monthNow = today.getUTCMonth() + 1;
73
73
  if (monthNow < m || (monthNow === m && today.getUTCDate() < d))
@@ -87,6 +87,57 @@ function isRealDate(iso) {
87
87
  const asDate = new Date(Date.UTC(y, m - 1, d));
88
88
  return (asDate.getUTCFullYear() === y && asDate.getUTCMonth() === m - 1 && asDate.getUTCDate() === d);
89
89
  }
90
+ /**
91
+ * True for a `YYYY-MM-DD` that is a real calendar date (0.17.0, exported so a
92
+ * client's date picker checks a date exactly as `dateOfBirthSchema` does):
93
+ * not 2026-02-30, not 2026-13-01.
94
+ */
95
+ export function isRealIsoDate(value) {
96
+ return isoDateSchema.safeParse(value).success && isRealDate(value.trim());
97
+ }
98
+ /** `YYYY-MM-DD` for a moment, counted in India's calendar. */
99
+ const isoInIndia = (moment) => new Date(moment + INDIA_UTC_OFFSET_MS).toISOString().slice(0, 10);
100
+ /**
101
+ * Today in India, `YYYY-MM-DD` (0.17.0): the day every age here is counted
102
+ * from (`ageInYears`), so a client's "today" must be this one, never the
103
+ * device's own: east of India the device is already on tomorrow.
104
+ */
105
+ export function todayInIndia(now = new Date()) {
106
+ return isoInIndia(now.getTime());
107
+ }
108
+ const isoOfUtc = (ms) => new Date(ms).toISOString().slice(0, 10);
109
+ /**
110
+ * The dates of birth `dateOfBirthSchema` accepts TODAY, both ends included
111
+ * (0.17.0): `latest` makes someone exactly `MIN_AGE_YEARS` today, `earliest`
112
+ * exactly `MAX_AGE_YEARS`. Worked out with `ageInYears` itself, so a picker
113
+ * that offers only this window can never offer a date the schema refuses, a
114
+ * 29 February included.
115
+ */
116
+ export function dateOfBirthBounds(now = new Date()) {
117
+ const [y, m, d] = todayInIndia(now).split('-').map(Number);
118
+ let latest = Date.UTC(y - MIN_AGE_YEARS, m - 1, d);
119
+ while (ageInYears(isoOfUtc(latest), now) < MIN_AGE_YEARS)
120
+ latest -= DAY_MS;
121
+ let earliest = Date.UTC(y - MAX_AGE_YEARS - 1, m - 1, d);
122
+ while (ageInYears(isoOfUtc(earliest), now) > MAX_AGE_YEARS)
123
+ earliest += DAY_MS;
124
+ return { earliest: isoOfUtc(earliest), latest: isoOfUtc(latest) };
125
+ }
126
+ /**
127
+ * True only for a COMPLETE, real date of birth that is under `MIN_AGE_YEARS`
128
+ * today (0.17.0): the rule the server applies to a draft, where a date still
129
+ * being typed must pass ("19" says nothing about anyone's age yet). Until
130
+ * 0.17.0 the backend wrote this out by hand and skipped the real-date check.
131
+ */
132
+ export function isUnderAgeDateOfBirth(value, now = new Date()) {
133
+ if (typeof value !== 'string' || !isRealIsoDate(value))
134
+ return false;
135
+ return ageInYears(value.trim(), now) < MIN_AGE_YEARS;
136
+ }
137
+ /** A `YYYY-MM-DD` as the instant it is stored as: midnight UTC that day (0.17.0). */
138
+ export const isoDateToDate = (iso) => new Date(`${iso.trim()}T00:00:00.000Z`);
139
+ /** A stored date (midnight UTC) as `YYYY-MM-DD` (0.17.0): `isoDateToDate`'s inverse. */
140
+ export const dateToIsoDate = (date) => date.toISOString().slice(0, 10);
90
141
  /**
91
142
  * Date of birth, with the age gate applied per parse.
92
143
  *
@@ -370,6 +421,53 @@ export const TOPIC_CATEGORIES = [
370
421
  'education_access',
371
422
  ]),
372
423
  ];
424
+ // ─── Goals: the grouping the picker renders ──────────────────────────────────
425
+ /**
426
+ * The goals step's three headings (0.17.0, moved here from the web, where the
427
+ * grouping lived in the screen): eighteen goals since 0.16.0, shown a group at
428
+ * a time so the eye takes six tiles, not a wall of eighteen. A group is named
429
+ * by its `key`, which the client words, like a topic category.
430
+ *
431
+ * ⚠ `GOAL_VALUES` stays the single source of truth for what a goal IS; this is
432
+ * typed against it, and a test holds the two together in both directions:
433
+ * every goal in exactly one group, nothing in a group that is not a goal.
434
+ */
435
+ export const GOAL_CATEGORY_KEYS = ['build', 'grow', 'learn'];
436
+ export const GOAL_CATEGORIES = [
437
+ {
438
+ key: 'build',
439
+ values: [
440
+ 'start_company',
441
+ 'get_feedback',
442
+ 'find_cofounder',
443
+ 'find_testers',
444
+ 'hiring',
445
+ 'find_service_providers',
446
+ ],
447
+ },
448
+ {
449
+ key: 'grow',
450
+ values: [
451
+ 'find_customers',
452
+ 'find_investors',
453
+ 'partnerships',
454
+ 'market_insights',
455
+ 'business_ideas',
456
+ 'grow_network',
457
+ ],
458
+ },
459
+ {
460
+ key: 'learn',
461
+ values: [
462
+ 'learn_startup',
463
+ 'mentorship',
464
+ 'attend_events',
465
+ 'join_startup',
466
+ 'mentor_others',
467
+ 'invest_in_startups',
468
+ ],
469
+ },
470
+ ];
373
471
  /**
374
472
  * How many a member must pick. A minimum exists because these drive feed
375
473
  * relevance and zero of them means no relevance at all.
@@ -393,7 +491,7 @@ export const interestsSchema = z
393
491
  .array(topicSchema)
394
492
  .min(INTERESTS_MIN)
395
493
  .max(INTERESTS_MAX)
396
- .refine((topics) => new Set(topics).size === topics.length, {
494
+ .refine(eachOnce, {
397
495
  error: VALIDATION_MESSAGE.topicsDuplicate,
398
496
  });
399
497
  // ─── The step keys ───────────────────────────────────────────────────────────
@@ -438,8 +536,15 @@ export const ONBOARDING_STEP_KEYS = [
438
536
  * profile; that record is the point.
439
537
  */
440
538
  export const GUIDELINES_VERSION = '2026-10-03';
539
+ /**
540
+ * Not agreed at all (the box left unticked) is `guidelinesRequired`; agreed to
541
+ * another text is `guidelinesOutdated` (0.17.0: both were the second, so a
542
+ * client had to word an unticked box itself).
543
+ */
441
544
  export const guidelinesVersionSchema = z.literal(GUIDELINES_VERSION, {
442
- error: VALIDATION_MESSAGE.guidelinesOutdated,
545
+ error: (issue) => issue.input === undefined || issue.input === null
546
+ ? VALIDATION_MESSAGE.guidelinesRequired
547
+ : VALIDATION_MESSAGE.guidelinesOutdated,
443
548
  });
444
549
  // ─── Contact ─────────────────────────────────────────────────────────────────
445
550
  /**
@@ -454,6 +559,12 @@ export const contactLinksSchema = founderLinksSchema.pick({
454
559
  });
455
560
  /** A team size has at most this many digits: `TEAM_SIZE_MAX` written out. */
456
561
  export const TEAM_SIZE_DIGITS_MAX = String(TEAM_SIZE_MAX).length;
562
+ /**
563
+ * A team size as typed, kept to what the field may hold: digits only, at most
564
+ * `TEAM_SIZE_DIGITS_MAX` (0.17.0: the draft reader and every client's field
565
+ * clean it with this one rule).
566
+ */
567
+ export const teamSizeDigits = (raw) => raw.replace(/\D/g, '').slice(0, TEAM_SIZE_DIGITS_MAX);
457
568
  // ─── The form, mid-flight: the draft each step saves ──────────────────────────
458
569
  /**
459
570
  * The answers so far, keyed by the finish body's own field names: what each
@@ -477,24 +588,35 @@ export const TEAM_SIZE_DIGITS_MAX = String(TEAM_SIZE_MAX).length;
477
588
  * one, wrote it, and the server stores values opaquely. Read it with
478
589
  * `readOnboardingDraft`, never by casting.
479
590
  */
591
+ /** Longest date of birth an answer may be: `YYYY-MM-DD` (0.18.0: exported; the form and the draft reader use it). */
592
+ export const DATE_INPUT_MAX = 10;
593
+ /** Longest phone number an answer may be, as typed with spaces and a `+91`. */
594
+ export const PHONE_INPUT_MAX = 20;
595
+ /** Longest guidelines version an answer may carry (`GUIDELINES_VERSION` is a date). */
596
+ export const GUIDELINES_VERSION_MAX = 32;
480
597
  export const onboardingFormSchema = z.object({
481
598
  displayName: z.string().max(DISPLAY_NAME_MAX),
482
599
  gender: genderSchema.nullable(),
483
600
  /** `YYYY-MM-DD` once complete; whatever has been typed until then. */
484
- dateOfBirth: z.string(),
601
+ dateOfBirth: z.string().max(DATE_INPUT_MAX),
485
602
  headline: z.string().max(HEADLINE_MAX),
486
603
  about: z.string().max(ABOUT_MAX),
487
604
  /**
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.
605
+ * The completed upload's id (`media`, purpose `avatar`), or `null` for no
606
+ * photo.
607
+ *
608
+ * SAVED BY THE PHOTO STEP, KEPT BY THE SERVER, NOT IN THE DRAFT (0.17.0). A
609
+ * step save carrying this key hands the upload to the server, which holds it
610
+ * for the draft (so the unattached sweep, `MEDIA_UNATTACHED_TTL_HOURS`, never
611
+ * deletes it) and lets go of the one it replaces; `null` lets go of it. It
612
+ * comes back as the status's `photo`, read from what the server holds, never
613
+ * from a stored draft: `readOnboardingDraft` still gives `null` here, so an
614
+ * id an older client left in a draft is never trusted.
493
615
  */
494
616
  avatarMediaId: objectIdSchema.nullable(),
495
617
  skills: z.array(skillSchema).max(SKILLS_MAX),
496
618
  /** As typed; the finish body takes `phoneSchema`'s ten digits. */
497
- phone: z.string(),
619
+ phone: z.string().max(PHONE_INPUT_MAX),
498
620
  /** As typed; the finish body takes full `https://` URLs. */
499
621
  links: z.object({
500
622
  linkedin: z.string().max(LINK_URL_MAX),
@@ -526,13 +648,9 @@ export const onboardingFormSchema = z.object({
526
648
  businessType: businessTypeSchema.nullable(),
527
649
  legalStructure: legalStructureSchema.nullable(),
528
650
  /** The guidelines text the member agreed to, or `null`. */
529
- guidelinesVersion: z.string().nullable(),
651
+ guidelinesVersion: z.string().max(GUIDELINES_VERSION_MAX).nullable(),
530
652
  });
531
653
  // ─── Reading a stored draft back ─────────────────────────────────────────────
532
- /** Longest date of birth worth keeping from a draft: `YYYY-MM-DD`. */
533
- const DATE_INPUT_MAX = 10;
534
- /** Longest phone number worth keeping from a draft, as typed with spaces. */
535
- const PHONE_INPUT_MAX = 20;
536
654
  /** Text, cut to its limit; anything that is not text is "not answered". */
537
655
  const draftText = (max) => z
538
656
  .string()
@@ -587,10 +705,7 @@ const draftReaderSchema = z.object({
587
705
  website: draftText(LINK_URL_MAX),
588
706
  industry: draftChoice(INDUSTRY_VALUES),
589
707
  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)),
708
+ teamSize: z.string().catch('').transform(teamSizeDigits),
594
709
  }).transform((venture) => venture ?? { ...EMPTY_ONBOARDING_VENTURE }),
595
710
  businessType: draftChoice(BUSINESS_TYPE_VALUES),
596
711
  legalStructure: draftChoice(LEGAL_STRUCTURE_VALUES),
@@ -615,8 +730,8 @@ export const EMPTY_ONBOARDING_VENTURE = {
615
730
  * one that does not fit becomes "not answered" (`''`, `null`, `[]`) and the
616
731
  * rest of the draft survives. Never coerced into a different answer: a value
617
732
  * 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.
733
+ * is never restored from here (it comes back as the status's `photo`; see
734
+ * `avatarMediaId`), and agreement only counts for the CURRENT guidelines.
620
735
  *
621
736
  * It fills in no answer the member did not give: a blank country stays blank.
622
737
  * Whatever a screen shows by default is that screen's choice.
@@ -627,23 +742,12 @@ export function readOnboardingDraft(draft) {
627
742
  }
628
743
  // ─── The finalize body ───────────────────────────────────────────────────────
629
744
  /**
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.
745
+ * The finish body's fields, before the rules that span fields: what each
746
+ * step's slice is PICKED from (Zod 4 refuses `.pick` on a refined object).
747
+ * Everything outside this file reads `completeOnboardingBodySchema`, whose
748
+ * note below says what the body is.
645
749
  */
646
- export const completeOnboardingBodySchema = z.object({
750
+ const completeOnboardingBodyFields = z.object({
647
751
  // Plain bounds, no custom message: `too_small` with minimum 1 already says
648
752
  // "required", and the client words it in the member's language.
649
753
  displayName: displayNameSchema,
@@ -674,11 +778,16 @@ export const completeOnboardingBodySchema = z.object({
674
778
  avatarMediaId: objectIdSchema.optional(),
675
779
  phone: phoneSchema,
676
780
  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),
781
+ /**
782
+ * Absent when the member skipped the step or refused the permission. In
783
+ * India, one of its states (0.17.0: `indiaLocationSchema`, the rule the
784
+ * screen always showed).
785
+ */
786
+ location: indiaLocationSchema.optional(),
787
+ /** The shared list rule (each goal once, 0.17.0) plus finish's minimum. */
788
+ goals: requiredGoalListSchema,
680
789
  interests: interestsSchema,
681
- skills: z.array(skillSchema).max(SKILLS_MAX).optional(),
790
+ skills: skillListSchema.optional(),
682
791
  /**
683
792
  * Absent when the member skipped the business step. The profile's venture
684
793
  * without `foundedYear`, which onboarding does not ask; the Me tab does.
@@ -698,6 +807,37 @@ export const completeOnboardingBodySchema = z.object({
698
807
  */
699
808
  guidelinesVersion: guidelinesVersionSchema,
700
809
  });
810
+ const typeHasVenture = (body) => body.venture !== undefined || body.businessType === undefined;
811
+ const registrationHasVenture = (body) => body.venture !== undefined || body.legalStructure === undefined;
812
+ const TYPE_NEEDS_VENTURE = { error: VALIDATION_MESSAGE.ventureRequired, path: ['businessType'] };
813
+ const REGISTRATION_NEEDS_VENTURE = {
814
+ error: VALIDATION_MESSAGE.ventureRequired,
815
+ path: ['legalStructure'],
816
+ };
817
+ /**
818
+ * What the client sends to finish. Completed answers only.
819
+ *
820
+ * ⚠️ EVERY OPTIONAL FIELD HERE MUST BE ABSENT WHEN UNANSWERED, never a default
821
+ * standing in for one. The sibling gym product sent `bloodGroup ?? 'O+'`, so
822
+ * every member who skipped the question was recorded as O-positive: a real
823
+ * answer, indistinguishable from one somebody gave, sitting on a screen staff
824
+ * would read in an emergency. Absent has to stay absent.
825
+ *
826
+ * ⚠️ AND THIS IS NOT THE GATE EITHER. The server re-checks the age and refuses
827
+ * at finalize. `isOnboarded` may be set here and nowhere else: four of the
828
+ * profile's partial indexes are gated on it, so a half-built profile carrying
829
+ * it true appears in Discover half-built.
830
+ *
831
+ * Location, venture and links are the profile's own shapes, not copies: the
832
+ * copies are how the profile model came to lack Instagram.
833
+ *
834
+ * Refined (the business type and registration need a venture, above), so Zod 4
835
+ * throws on `.pick` / `.omit` / `.partial` / `.merge` of it: read a field with
836
+ * `.shape.<field>`, a step's with `ONBOARDING_STEP_SCHEMAS.<step>`.
837
+ */
838
+ export const completeOnboardingBodySchema = completeOnboardingBodyFields
839
+ .refine(typeHasVenture, TYPE_NEEDS_VENTURE)
840
+ .refine(registrationHasVenture, REGISTRATION_NEEDS_VENTURE);
701
841
  // ─── The steps ───────────────────────────────────────────────────────────────
702
842
  /**
703
843
  * Each step's slice of the finish body, picked FROM it, so a client validating
@@ -717,32 +857,35 @@ export const ONBOARDING_STEP_SCHEMAS = {
717
857
  // 0.16.0: the old `identity` step, split three ways by the user. The basics
718
858
  // are the step's only required answers; the photo and the professional
719
859
  // 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({
860
+ basics: completeOnboardingBodyFields.pick({ displayName: true, gender: true, dateOfBirth: true }),
861
+ photo: completeOnboardingBodyFields.pick({ avatarMediaId: true }),
862
+ professional: completeOnboardingBodyFields.pick({
723
863
  headline: true,
724
864
  about: true,
725
865
  // Ruling D8 folded the design's "title, achievements, skills" screen into
726
866
  // the profile's first questions; it now has a step of its own again.
727
867
  skills: true,
728
868
  }),
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({
869
+ contact: completeOnboardingBodyFields.pick({ phone: true, links: true }),
870
+ location: completeOnboardingBodyFields.pick({ location: true }),
871
+ goals: completeOnboardingBodyFields.pick({ goals: true }),
872
+ interests: completeOnboardingBodyFields.pick({ interests: true }),
873
+ business: completeOnboardingBodyFields
874
+ .pick({
734
875
  venture: true,
735
876
  businessType: true,
736
877
  legalStructure: true,
737
- }),
738
- guidelines: completeOnboardingBodySchema.pick({ guidelinesVersion: true }),
878
+ })
879
+ .refine(typeHasVenture, TYPE_NEEDS_VENTURE)
880
+ .refine(registrationHasVenture, REGISTRATION_NEEDS_VENTURE),
881
+ guidelines: completeOnboardingBodyFields.pick({ guidelinesVersion: true }),
739
882
  };
740
883
  export const FOUNDER_ONBOARDING_STEPS = ONBOARDING_STEP_KEYS.map((key) => {
741
884
  const slice = ONBOARDING_STEP_SCHEMAS[key];
742
885
  return {
743
886
  key,
887
+ labelKey: `onboarding.steps.${key}.label`,
744
888
  titleKey: `onboarding.steps.${key}.title`,
745
- helpKey: `onboarding.steps.${key}.help`,
746
889
  fields: Object.keys(slice.shape),
747
890
  required: !slice.safeParse({}).success,
748
891
  };
@@ -752,8 +895,8 @@ export const ONBOARDING_TOTAL_STEPS = FOUNDER_ONBOARDING_STEPS.length;
752
895
  export const ONBOARDING_STEP_FIELDS = FOUNDER_ONBOARDING_STEPS.map((s) => s.fields);
753
896
  /** What finishing answers: the new profile's id and the handle derived for it. */
754
897
  export const completeOnboardingResponseSchema = z.object({
755
- id: z.string(),
756
- username: z.string(),
898
+ id: objectIdSchema,
899
+ username: usernameSchema,
757
900
  });
758
901
  // ─── The resume cursor ───────────────────────────────────────────────────────
759
902
  /**
@@ -775,26 +918,36 @@ export const ONBOARDING_DRAFT_KEYS = onboardingDraftKeySchema.options;
775
918
  * pair: one number is what lets a member start on the web and resume elsewhere,
776
919
  * provided both ends agree on the encoding.
777
920
  *
778
- * The draft's KEYS come from the form; its VALUES are opaque on purpose. A
779
- * half-answered step must be storable even though it would fail the finish,
780
- * and everything in it is re-validated strictly at finalize.
921
+ * What is READ BACK: the draft's KEYS come from the form, its VALUES are
922
+ * opaque, since a stored draft may predate 0.18.0 (any JSON was stored then);
923
+ * read it through `readOnboardingDraft`, never by casting. What is WRITTEN is
924
+ * the form's own shape (`onboardingProgressUpdateSchema.draft`, 0.18.0), and
925
+ * everything is re-validated strictly at finalize.
781
926
  *
782
927
  * `partialRecord`, not `record`: in Zod 4 an enum-keyed `z.record` demands
783
928
  * every key, and a draft holds whichever steps the member has reached.
784
929
  */
785
930
  export const onboardingDraftSchema = z.partialRecord(onboardingDraftKeySchema, z.unknown());
786
- export const onboardingProgressSchema = z.object({
787
- step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
788
- draft: onboardingDraftSchema.optional(),
789
- });
790
931
  /**
791
932
  * `PATCH` body: any subset of the cursor, merged server-side. No defaults.
792
933
  * `step` may equal `ONBOARDING_TOTAL_STEPS`: "past the last step", which the
793
934
  * client sends on its way to finish.
935
+ *
936
+ * The photo step's `avatarMediaId` is not kept in the draft (0.17.0): it is
937
+ * handed to the server, which holds that upload for the member.
794
938
  */
795
939
  export const onboardingProgressUpdateSchema = z.object({
796
940
  step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS).optional(),
797
- draft: onboardingDraftSchema.optional(),
941
+ /**
942
+ * The answers of the step being saved, IN THE FORM'S OWN SHAPE (0.18.0):
943
+ * half-answered is fine (that is the form: text as typed, `null` for "not
944
+ * yet"), but every value has its type and its bound, and no other key is
945
+ * taken. Before 0.18.0 any JSON up to the body limit was stored as sent,
946
+ * for 90 days, and sent back. The photo's id reads as `objectIdSchema` reads
947
+ * every id (lower case), since the server compares it with the upload it
948
+ * holds.
949
+ */
950
+ draft: onboardingFormSchema.partial().strict().optional(),
798
951
  });
799
952
  /**
800
953
  * What the client reads on return, from `GET` and from every `PATCH`.
@@ -808,5 +961,13 @@ export const onboardingStatusSchema = z.object({
808
961
  onboarded: z.boolean(),
809
962
  step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
810
963
  draft: onboardingDraftSchema.optional(),
964
+ /**
965
+ * The photo the member saved on the photo step, as it will appear (0.17.0):
966
+ * the upload the server holds for this member's draft. Absent when they
967
+ * saved none, removed it, or have finished. A client resumes the photo step
968
+ * from this (its `id` is the finish body's `avatarMediaId`), never from the
969
+ * draft.
970
+ */
971
+ photo: mediaImageSchema.optional(),
811
972
  });
812
973
  //# sourceMappingURL=onboarding.js.map