founder-schema 0.1.0 → 0.3.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 (58) hide show
  1. package/README.md +112 -40
  2. package/dist/account.d.ts +40 -0
  3. package/dist/account.d.ts.map +1 -0
  4. package/dist/account.js +39 -0
  5. package/dist/account.js.map +1 -0
  6. package/dist/chat.d.ts +63 -15
  7. package/dist/chat.d.ts.map +1 -1
  8. package/dist/chat.js +55 -17
  9. package/dist/chat.js.map +1 -1
  10. package/dist/common.d.ts +84 -8
  11. package/dist/common.d.ts.map +1 -1
  12. package/dist/common.js +109 -17
  13. package/dist/common.js.map +1 -1
  14. package/dist/discover.d.ts +98 -6
  15. package/dist/discover.d.ts.map +1 -1
  16. package/dist/discover.js +25 -11
  17. package/dist/discover.js.map +1 -1
  18. package/dist/errors.d.ts +85 -0
  19. package/dist/errors.d.ts.map +1 -0
  20. package/dist/errors.js +89 -0
  21. package/dist/errors.js.map +1 -0
  22. package/dist/feed.d.ts +400 -52
  23. package/dist/feed.d.ts.map +1 -1
  24. package/dist/feed.js +98 -68
  25. package/dist/feed.js.map +1 -1
  26. package/dist/founder-profile.d.ts +76 -46
  27. package/dist/founder-profile.d.ts.map +1 -1
  28. package/dist/founder-profile.js +46 -46
  29. package/dist/founder-profile.js.map +1 -1
  30. package/dist/index.d.ts +15 -9
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +22 -10
  33. package/dist/index.js.map +1 -1
  34. package/dist/limits.d.ts +76 -0
  35. package/dist/limits.d.ts.map +1 -0
  36. package/dist/limits.js +84 -0
  37. package/dist/limits.js.map +1 -0
  38. package/dist/onboarding.d.ts +407 -53
  39. package/dist/onboarding.d.ts.map +1 -1
  40. package/dist/onboarding.js +281 -234
  41. package/dist/onboarding.js.map +1 -1
  42. package/dist/responses/common.d.ts +122 -4
  43. package/dist/responses/common.d.ts.map +1 -1
  44. package/dist/responses/common.js +26 -3
  45. package/dist/responses/common.js.map +1 -1
  46. package/dist/responses/index.d.ts +1 -1
  47. package/dist/responses/index.d.ts.map +1 -1
  48. package/dist/responses/index.js +1 -1
  49. package/dist/responses/index.js.map +1 -1
  50. package/dist/uploads.d.ts +113 -0
  51. package/dist/uploads.d.ts.map +1 -0
  52. package/dist/uploads.js +106 -0
  53. package/dist/uploads.js.map +1 -0
  54. package/dist/validation.d.ts +136 -0
  55. package/dist/validation.d.ts.map +1 -0
  56. package/dist/validation.js +142 -0
  57. package/dist/validation.js.map +1 -0
  58. package/package.json +4 -3
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * founder-schema — Onboarding: steps, enums, and the two shapes of the answers
3
3
  *
4
- * ONE LIST DEFINES THE FLOW. `FOUNDER_ONBOARDING_STEPS` carries the screen
5
- * title, the help line, the progress denominator AND the fields each step
6
- * gates on. GymMonk arrived here after keeping two lists in sync by hand, where
7
- * a step could exist with no title or, worse, be left with no validation gate
8
- * at all. Dating reached the same place independently. One list makes both
9
- * impossible.
4
+ * ONE LIST DEFINES THE FLOW, AND IT IS DERIVED. Each step's fields are a pick
5
+ * of the finish body (`ONBOARDING_STEP_SCHEMAS`), and `FOUNDER_ONBOARDING_STEPS`
6
+ * is built FROM those picks: its `fields`, and even whether a step may be
7
+ * skipped, come from the schema rather than from a second hand-written list.
8
+ * GymMonk arrived at one list after keeping two in sync by hand, where a step
9
+ * could exist with no title or, worse, no validation gate at all; until 0.2.0
10
+ * this file still wrote the field lists twice.
10
11
  *
11
12
  * TWO SHAPES, ONE SET OF RULES. A half-filled form legitimately holds `null`
12
13
  * for a choice nobody has made yet; the finalize body only ever sees completed
@@ -15,10 +16,19 @@
15
16
  * for the end. Letting those two drift is how a wizard says a step is optional
16
17
  * and then answers 422 at Finish.
17
18
  *
19
+ * NO COPY IN HERE. Step titles, help lines and topic category names are
20
+ * translation KEYS (`titleKey`, `helpKey`, `labelKey`), Jansathi's rule for
21
+ * every config string: an English sentence in a shared package is a sentence
22
+ * every other language has to match by wording.
23
+ *
18
24
  * @module founder-schema/onboarding
19
25
  */
20
26
  import { z } from 'zod';
21
- import { GOAL_VALUES, genderSchema, goalSchema, httpUrlSchema, industrySchema, optionalTrimmedString, phoneSchema, skillSchema, startupStageSchema, } from './common.js';
27
+ import { GOAL_VALUES, genderSchema, goalSchema, industrySchema, locationSchema, optionalTrimmedString, phoneSchema, skillSchema, startupStageSchema, } from './common.js';
28
+ import { founderLinksSchema, ventureSchema } from './founder-profile.js';
29
+ import { ABOUT_MAX, CITY_MAX, COUNTRY_MAX, DISPLAY_NAME_MAX, GOALS_MIN, HEADLINE_MAX, SKILLS_MAX, STATE_MAX, VENTURE_NAME_MAX, VENTURE_TAGLINE_MAX, } from './limits.js';
30
+ import { uploadKeySchema } from './uploads.js';
31
+ import { VALIDATION_MESSAGE } from './validation.js';
22
32
  // ─── Age ─────────────────────────────────────────────────────────────────────
23
33
  /**
24
34
  * 18, ruled by the user 2026-09-21. Under-18 would pull in parental-consent
@@ -61,7 +71,7 @@ export function ageInYears(isoDate, now = new Date()) {
61
71
  export const isoDateSchema = z
62
72
  .string()
63
73
  .trim()
64
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Expected a YYYY-MM-DD date');
74
+ .regex(/^\d{4}-\d{2}-\d{2}$/, VALIDATION_MESSAGE.dateInvalid);
65
75
  /** True only for a date that exists. Rejects 2026-02-30 and 2026-13-01. */
66
76
  function isRealDate(iso) {
67
77
  const [y, m, d] = iso.split('-').map(Number);
@@ -80,11 +90,9 @@ function isRealDate(iso) {
80
90
  * discoverable profile, because the client was the only thing checking.
81
91
  */
82
92
  export const dateOfBirthSchema = isoDateSchema
83
- .refine(isRealDate, { message: 'That date does not exist' })
84
- .refine((v) => ageInYears(v) >= MIN_AGE_YEARS, {
85
- message: `You must be at least ${MIN_AGE_YEARS} to join`,
86
- })
87
- .refine((v) => ageInYears(v) <= MAX_AGE_YEARS, { message: 'Check the year of birth' });
93
+ .refine(isRealDate, { error: VALIDATION_MESSAGE.dobInvalid })
94
+ .refine((v) => ageInYears(v) >= MIN_AGE_YEARS, { error: VALIDATION_MESSAGE.dobUnderAge })
95
+ .refine((v) => ageInYears(v) <= MAX_AGE_YEARS, { error: VALIDATION_MESSAGE.dobTooOld });
88
96
  // ─── Business ────────────────────────────────────────────────────────────────
89
97
  export const BUSINESS_TYPE_VALUES = ['startup', 'sme', 'enterprise'];
90
98
  export const businessTypeSchema = z.enum(BUSINESS_TYPE_VALUES);
@@ -197,113 +205,111 @@ export const TOPIC_VALUES = [
197
205
  'education_access',
198
206
  ];
199
207
  export const topicSchema = z.enum(TOPIC_VALUES);
208
+ /**
209
+ * The grouping the picker renders.
210
+ *
211
+ * ⚠️ `TOPIC_VALUES` above is the single source of truth and this is typed
212
+ * against it, so a typo here is a compile error rather than a chip that renders
213
+ * and can never be selected. The dating product keeps a categorised list and a
214
+ * flat list side by side and they have already drifted apart by two values;
215
+ * a test in this package asserts these two agree exactly, in both directions.
216
+ */
217
+ export const TOPIC_CATEGORY_KEYS = [
218
+ 'career',
219
+ 'tech',
220
+ 'business',
221
+ 'growth',
222
+ 'creative',
223
+ 'impact',
224
+ ];
225
+ const topicCategory = (key, values) => ({
226
+ key,
227
+ labelKey: `topics.category.${key}`,
228
+ values,
229
+ });
200
230
  export const TOPIC_CATEGORIES = [
201
- {
202
- key: 'career',
203
- label: 'Career Growth',
204
- values: [
205
- 'mentoring',
206
- 'leadership',
207
- 'networking',
208
- 'public_speaking',
209
- 'teaching',
210
- 'hiring_talent',
211
- 'personal_branding',
212
- 'career_transition',
213
- 'remote_work',
214
- ],
215
- },
216
- {
217
- key: 'tech',
218
- label: 'Innovation & Tech',
219
- values: [
220
- 'ai_ml',
221
- 'blockchain',
222
- 'crypto_web3',
223
- 'robotics',
224
- 'data_science',
225
- 'cybersecurity',
226
- 'game_dev',
227
- 'ux_design',
228
- 'app_development',
229
- 'open_source',
230
- 'electronics',
231
- 'space_exploration',
232
- 'green_tech',
233
- 'deeptech',
234
- 'no_code',
235
- ],
236
- },
237
- {
238
- key: 'business',
239
- label: 'Business',
240
- values: [
241
- 'entrepreneurship',
242
- 'investing',
243
- 'stock_trading',
244
- 'personal_finance',
245
- 'fundraising',
246
- 'marketing',
247
- 'sales',
248
- 'consulting',
249
- 'ecommerce',
250
- 'retail',
251
- 'real_estate',
252
- 'supply_chain',
253
- 'product_management',
254
- 'growth',
255
- 'b2b_saas',
256
- 'd2c',
257
- ],
258
- },
259
- {
260
- key: 'growth',
261
- label: 'Personal Development',
262
- values: [
263
- 'meditation',
264
- 'journaling',
265
- 'self_improvement',
266
- 'productivity',
267
- 'reading',
268
- 'online_courses',
269
- 'philosophy',
270
- 'psychology',
271
- 'therapy',
272
- 'sleep_optimization',
273
- 'gym',
274
- 'yoga',
275
- ],
276
- },
277
- {
278
- key: 'creative',
279
- label: 'Creative Arts',
280
- values: [
281
- 'photography',
282
- 'writing',
283
- 'graphic_design',
284
- 'filmmaking',
285
- 'digital_art',
286
- 'content_creation',
287
- 'blogging',
288
- 'vlogging',
289
- 'podcasts',
290
- 'animation',
291
- 'music_production',
292
- ],
293
- },
294
- {
295
- key: 'impact',
296
- label: 'Impact & Community',
297
- values: [
298
- 'volunteering',
299
- 'ngo_work',
300
- 'environmental',
301
- 'women_empowerment',
302
- 'community_service',
303
- 'social_enterprise',
304
- 'education_access',
305
- ],
306
- },
231
+ topicCategory('career', [
232
+ 'mentoring',
233
+ 'leadership',
234
+ 'networking',
235
+ 'public_speaking',
236
+ 'teaching',
237
+ 'hiring_talent',
238
+ 'personal_branding',
239
+ 'career_transition',
240
+ 'remote_work',
241
+ ]),
242
+ topicCategory('tech', [
243
+ 'ai_ml',
244
+ 'blockchain',
245
+ 'crypto_web3',
246
+ 'robotics',
247
+ 'data_science',
248
+ 'cybersecurity',
249
+ 'game_dev',
250
+ 'ux_design',
251
+ 'app_development',
252
+ 'open_source',
253
+ 'electronics',
254
+ 'space_exploration',
255
+ 'green_tech',
256
+ 'deeptech',
257
+ 'no_code',
258
+ ]),
259
+ topicCategory('business', [
260
+ 'entrepreneurship',
261
+ 'investing',
262
+ 'stock_trading',
263
+ 'personal_finance',
264
+ 'fundraising',
265
+ 'marketing',
266
+ 'sales',
267
+ 'consulting',
268
+ 'ecommerce',
269
+ 'retail',
270
+ 'real_estate',
271
+ 'supply_chain',
272
+ 'product_management',
273
+ 'growth',
274
+ 'b2b_saas',
275
+ 'd2c',
276
+ ]),
277
+ topicCategory('growth', [
278
+ 'meditation',
279
+ 'journaling',
280
+ 'self_improvement',
281
+ 'productivity',
282
+ 'reading',
283
+ 'online_courses',
284
+ 'philosophy',
285
+ 'psychology',
286
+ 'therapy',
287
+ 'sleep_optimization',
288
+ 'gym',
289
+ 'yoga',
290
+ ]),
291
+ topicCategory('creative', [
292
+ 'photography',
293
+ 'writing',
294
+ 'graphic_design',
295
+ 'filmmaking',
296
+ 'digital_art',
297
+ 'content_creation',
298
+ 'blogging',
299
+ 'vlogging',
300
+ 'podcasts',
301
+ 'animation',
302
+ 'music_production',
303
+ ]),
304
+ topicCategory('impact', [
305
+ 'volunteering',
306
+ 'ngo_work',
307
+ 'environmental',
308
+ 'women_empowerment',
309
+ 'community_service',
310
+ 'social_enterprise',
311
+ 'education_access',
312
+ ]),
307
313
  ];
308
314
  /**
309
315
  * How many a member must pick. A minimum exists because these drive feed
@@ -314,82 +320,51 @@ export const TOPIC_CATEGORIES = [
314
320
  * minimum at all, and its own field config says "pick up to 10" while every
315
321
  * screen and schema enforces 20.
316
322
  */
317
- /**
318
- * ⚠️ TRANSLATION KEY SHAPE, for whoever builds the picker:
319
- * `<fieldKey>.<value>` for a value and `<fieldKey>._label` for the field name.
320
- * The dating product's interests JSON is keyed by CATEGORY instead, so all five
321
- * of its label lookups miss and every chip renders its raw English in every
322
- * locale, with a fully translated Hindi file sitting unreachable. Lift the
323
- * translations, reshape the keys.
324
- */
325
323
  export const INTERESTS_MIN = 3;
326
324
  export const INTERESTS_MAX = 10;
327
- export const FOUNDER_ONBOARDING_STEPS = [
328
- {
329
- key: 'identity',
330
- title: "We'd love to hear more about you",
331
- helpText: 'This is what other founders see first.',
332
- fields: ['displayName', 'gender', 'dateOfBirth', 'headline', 'about', 'avatarUploadKey'],
333
- required: true,
334
- },
335
- {
336
- key: 'contact',
337
- title: 'How can people reach you?',
338
- helpText: 'Your number is never shown on your profile.',
339
- fields: ['phone', 'links'],
340
- required: true,
341
- },
342
- {
343
- key: 'location',
344
- title: 'Where are you based?',
345
- helpText: 'Used to show founders near you. You can skip this.',
346
- fields: ['location'],
347
- required: false,
348
- },
349
- {
350
- key: 'goals',
351
- title: 'Goals & Passion',
352
- helpText: 'Pick what matters most to you.',
353
- fields: ['goals'],
354
- required: true,
355
- },
356
- {
357
- key: 'interests',
358
- title: 'What inspires you the most?',
359
- helpText: 'We use these to shape your feed.',
360
- fields: ['interests'],
361
- required: true,
362
- },
363
- {
364
- key: 'business',
365
- title: 'Tell us about your business',
366
- helpText: 'Skip it if you are still figuring things out.',
367
- fields: ['venture', 'businessType'],
368
- required: false,
369
- },
370
- {
371
- key: 'guidelines',
372
- title: 'How we behave here',
373
- helpText: 'A short read, and the only rule that matters.',
374
- fields: ['guidelinesVersion'],
375
- required: true,
376
- },
325
+ // ─── The step keys ───────────────────────────────────────────────────────────
326
+ /**
327
+ * The step keys, in order, as a literal union, so a consumer can key a table by
328
+ * step and have TypeScript insist on every one. Before 0.2.0 `key` was a plain
329
+ * string and the web client kept its own copy of this list to get the same
330
+ * check; a test there held the two together. Now there is one list.
331
+ */
332
+ export const ONBOARDING_STEP_KEYS = [
333
+ 'identity',
334
+ 'contact',
335
+ 'location',
336
+ 'goals',
337
+ 'interests',
338
+ 'business',
339
+ 'guidelines',
377
340
  ];
378
- export const ONBOARDING_TOTAL_STEPS = FOUNDER_ONBOARDING_STEPS.length;
341
+ // ─── Guidelines ──────────────────────────────────────────────────────────────
379
342
  /**
380
- * Fields gating each step, DERIVED from the list above so the two can never
381
- * disagree.
343
+ * The community guidelines a member agrees to at the last step, by version.
344
+ *
345
+ * The finish body accepts THIS version and no other: an old tab still showing
346
+ * last month's text cannot record agreement to text it never displayed, and
347
+ * `'whatever'` is not a version. Until 0.2.0 any non-empty string passed, and
348
+ * the web client held this constant on its own.
349
+ *
350
+ * ⚠ Changing the guidelines text means bumping this, in the same publish.
351
+ * Every member who agreed to an earlier version keeps that version on their
352
+ * profile; that record is the point.
382
353
  */
383
- export const ONBOARDING_STEP_FIELDS = FOUNDER_ONBOARDING_STEPS.map((s) => s.fields);
354
+ export const GUIDELINES_VERSION = '2026-10-03';
355
+ export const guidelinesVersionSchema = z.literal(GUIDELINES_VERSION, {
356
+ error: VALIDATION_MESSAGE.guidelinesOutdated,
357
+ });
384
358
  // ─── Contact ─────────────────────────────────────────────────────────────────
385
359
  /**
386
- * Indian mobile number, ten digits, stored raw with no country code, matching
387
- * the standard used across this family. Display adds `+91`.
360
+ * The links the contact step collects, picked from the profile's own links so
361
+ * the two cannot disagree on what a link is. The phone is `phoneSchema`: ten
362
+ * digits, stored raw with no country code, on the account rather than here.
388
363
  */
389
- export const contactLinksSchema = z.object({
390
- linkedin: httpUrlSchema.optional(),
391
- x: httpUrlSchema.optional(),
392
- instagram: httpUrlSchema.optional(),
364
+ export const contactLinksSchema = founderLinksSchema.pick({
365
+ linkedin: true,
366
+ x: true,
367
+ instagram: true,
393
368
  });
394
369
  // ─── The form, mid-flight ────────────────────────────────────────────────────
395
370
  /**
@@ -400,29 +375,34 @@ export const contactLinksSchema = z.object({
400
375
  * this schema is required, on purpose: it describes a form somebody is halfway
401
376
  * through, and the rules that decide whether they may FINISH live in
402
377
  * `completeOnboardingBodySchema`.
378
+ *
379
+ * Text inputs stay text here (`teamSize`, `dateOfBirth`), because that is what
380
+ * an input holds while somebody types; the form converts them when it builds
381
+ * the finish body, which takes real numbers.
403
382
  */
404
383
  export const onboardingFormSchema = z.object({
405
- displayName: z.string().trim().max(80),
384
+ displayName: z.string().trim().max(DISPLAY_NAME_MAX),
406
385
  gender: genderSchema.nullable(),
407
386
  dateOfBirth: z.string().trim(),
408
- headline: z.string().trim().max(120),
409
- about: z.string().trim().max(1000),
410
- avatarUploadKey: z.string().trim().nullable(),
387
+ headline: z.string().trim().max(HEADLINE_MAX),
388
+ about: z.string().trim().max(ABOUT_MAX),
389
+ /** Only ever a key the server minted for this member. */
390
+ avatarUploadKey: uploadKeySchema.nullable(),
411
391
  phone: z.string().trim(),
412
392
  links: contactLinksSchema,
413
393
  location: z
414
394
  .object({
415
- city: z.string().trim().max(80),
416
- state: z.string().trim().max(80),
417
- country: z.string().trim().max(80),
395
+ city: z.string().trim().max(CITY_MAX),
396
+ state: z.string().trim().max(STATE_MAX),
397
+ country: z.string().trim().max(COUNTRY_MAX),
418
398
  })
419
399
  .nullable(),
420
400
  goals: z.array(goalSchema).max(GOAL_VALUES.length),
421
401
  interests: z.array(topicSchema).max(INTERESTS_MAX),
422
402
  venture: z
423
403
  .object({
424
- name: z.string().trim().max(120),
425
- tagline: z.string().trim().max(160),
404
+ name: z.string().trim().max(VENTURE_NAME_MAX),
405
+ tagline: z.string().trim().max(VENTURE_TAGLINE_MAX),
426
406
  website: z.string().trim(),
427
407
  industry: industrySchema.nullable(),
428
408
  stage: startupStageSchema.nullable(),
@@ -430,7 +410,7 @@ export const onboardingFormSchema = z.object({
430
410
  })
431
411
  .nullable(),
432
412
  businessType: businessTypeSchema.nullable(),
433
- skills: z.array(skillSchema).max(8),
413
+ skills: z.array(skillSchema).max(SKILLS_MAX),
434
414
  guidelinesVersion: z.string().trim().nullable(),
435
415
  });
436
416
  // ─── The finalize body ───────────────────────────────────────────────────────
@@ -447,9 +427,14 @@ export const onboardingFormSchema = z.object({
447
427
  * at finalize. `isOnboarded` may be set here and nowhere else: four of the
448
428
  * profile's partial indexes are gated on it, so a half-built profile carrying
449
429
  * it true appears in Discover half-built.
430
+ *
431
+ * Location, venture and links are the profile's own shapes, not copies: the
432
+ * copies are how the profile model came to lack Instagram.
450
433
  */
451
434
  export const completeOnboardingBodySchema = z.object({
452
- displayName: z.string().trim().min(1, 'Tell us your name').max(80),
435
+ // Plain bounds, no custom message: `too_small` with minimum 1 already says
436
+ // "required", and the client words it in the member's language.
437
+ displayName: z.string().trim().min(1).max(DISPLAY_NAME_MAX),
453
438
  /**
454
439
  * ⚠️ NO `username` HERE, and its absence is deliberate.
455
440
  *
@@ -467,48 +452,103 @@ export const completeOnboardingBodySchema = z.object({
467
452
  */
468
453
  gender: genderSchema,
469
454
  dateOfBirth: dateOfBirthSchema,
470
- headline: optionalTrimmedString(120),
471
- about: optionalTrimmedString(1000),
472
- avatarUploadKey: z.string().trim().min(1).optional(),
455
+ headline: optionalTrimmedString(HEADLINE_MAX),
456
+ about: optionalTrimmedString(ABOUT_MAX),
457
+ /**
458
+ * The key the presign handed out, in the minted format. It was a bare
459
+ * string until 0.2.0, so `'../../etc/passwd'` passed this schema and only
460
+ * the server's own commit check stood in the way.
461
+ */
462
+ avatarUploadKey: uploadKeySchema.optional(),
473
463
  phone: phoneSchema,
474
464
  links: contactLinksSchema.optional(),
475
465
  /** Absent when the member skipped the step or refused the permission. */
476
- location: z
477
- .object({
478
- city: z.string().trim().min(1).max(80),
479
- state: optionalTrimmedString(80),
480
- country: z.string().trim().min(1).max(80),
481
- })
482
- .optional(),
483
- goals: z.array(goalSchema).min(1, 'Pick at least one').max(GOAL_VALUES.length),
484
- interests: z
485
- .array(topicSchema)
486
- .min(INTERESTS_MIN, `Pick at least ${INTERESTS_MIN}`)
487
- .max(INTERESTS_MAX),
488
- skills: z.array(skillSchema).max(8).optional(),
489
- /** Absent when the member skipped the business step. */
490
- venture: z
491
- .object({
492
- name: z.string().trim().min(1).max(120),
493
- tagline: optionalTrimmedString(160),
494
- website: httpUrlSchema.optional(),
495
- industry: industrySchema,
496
- stage: startupStageSchema,
497
- teamSize: z.coerce.number().int().min(1).max(100000).optional(),
498
- })
499
- .optional(),
466
+ location: locationSchema.optional(),
467
+ goals: z.array(goalSchema).min(GOALS_MIN).max(GOAL_VALUES.length),
468
+ interests: z.array(topicSchema).min(INTERESTS_MIN).max(INTERESTS_MAX),
469
+ skills: z.array(skillSchema).max(SKILLS_MAX).optional(),
470
+ /**
471
+ * Absent when the member skipped the business step. The profile's venture
472
+ * without `foundedYear`, which onboarding does not ask; the Me tab does.
473
+ */
474
+ venture: ventureSchema.omit({ foundedYear: true }).optional(),
500
475
  businessType: businessTypeSchema.optional(),
501
476
  /**
502
- * The version of the guidelines this member read.
477
+ * The version of the guidelines this member read: `GUIDELINES_VERSION`,
478
+ * exactly.
503
479
  *
504
480
  * A version and not a boolean, and the server stamps the time. A boolean says
505
481
  * somebody agreed; a version and a timestamp say WHAT they agreed to and
506
482
  * WHEN, which is the only form worth anything the day the text changes or a
507
483
  * moderation decision is disputed.
508
484
  */
509
- guidelinesVersion: z.string().trim().min(1),
485
+ guidelinesVersion: guidelinesVersionSchema,
486
+ });
487
+ // ─── The steps ───────────────────────────────────────────────────────────────
488
+ /**
489
+ * Each step's slice of the finish body, picked FROM it, so a client validating
490
+ * "may I leave this step" and the server validating the finish apply the very
491
+ * same rules. A copy would drift, and the drift shows up as a wizard that lets
492
+ * a step pass and a finish that refuses it, on the last screen.
493
+ *
494
+ * THIS IS THE ONE PLACE A STEP'S FIELDS ARE WRITTEN. `FOUNDER_ONBOARDING_STEPS`
495
+ * below reads them from here. A test asserts that every finish field belongs to
496
+ * exactly one step.
497
+ *
498
+ * These validate the FINISH shape (skipped = absent), not the in-progress form:
499
+ * a client maps its form state to the slice first. The server does not run them
500
+ * on a draft save, because a half-answered step must be storable.
501
+ */
502
+ export const ONBOARDING_STEP_SCHEMAS = {
503
+ identity: completeOnboardingBodySchema.pick({
504
+ displayName: true,
505
+ gender: true,
506
+ dateOfBirth: true,
507
+ headline: true,
508
+ about: true,
509
+ avatarUploadKey: true,
510
+ // Here because ruling D8 folded the design's orphan "title, achievements,
511
+ // skills" screen into step one. It was on no step until 0.2.0, which made
512
+ // it the one finish field no step owned.
513
+ skills: true,
514
+ }),
515
+ contact: completeOnboardingBodySchema.pick({ phone: true, links: true }),
516
+ location: completeOnboardingBodySchema.pick({ location: true }),
517
+ goals: completeOnboardingBodySchema.pick({ goals: true }),
518
+ interests: completeOnboardingBodySchema.pick({ interests: true }),
519
+ business: completeOnboardingBodySchema.pick({ venture: true, businessType: true }),
520
+ guidelines: completeOnboardingBodySchema.pick({ guidelinesVersion: true }),
521
+ };
522
+ export const FOUNDER_ONBOARDING_STEPS = ONBOARDING_STEP_KEYS.map((key) => {
523
+ const slice = ONBOARDING_STEP_SCHEMAS[key];
524
+ return {
525
+ key,
526
+ titleKey: `onboarding.steps.${key}.title`,
527
+ helpKey: `onboarding.steps.${key}.help`,
528
+ fields: Object.keys(slice.shape),
529
+ required: !slice.safeParse({}).success,
530
+ };
531
+ });
532
+ export const ONBOARDING_TOTAL_STEPS = FOUNDER_ONBOARDING_STEPS.length;
533
+ /** Fields gating each step, in step order. Read from the same picks. */
534
+ export const ONBOARDING_STEP_FIELDS = FOUNDER_ONBOARDING_STEPS.map((s) => s.fields);
535
+ /** What finishing answers: the new profile's id and the handle derived for it. */
536
+ export const completeOnboardingResponseSchema = z.object({
537
+ id: z.string(),
538
+ username: z.string(),
510
539
  });
511
540
  // ─── The resume cursor ───────────────────────────────────────────────────────
541
+ /**
542
+ * The keys a draft may hold: exactly the in-progress form's fields.
543
+ *
544
+ * ⚠ The server writes each key as a dot path (`draft.<key>`). Unchecked, a key
545
+ * holding a dot writes somewhere nested the form never asked for, and the
546
+ * collection fills with whatever a client invents. Until 0.2.0 the backend
547
+ * kept its own copy of this list and DROPPED unknown keys without a word; now a
548
+ * draft carrying one is refused, with the key in the issue's path.
549
+ */
550
+ export const onboardingDraftKeySchema = onboardingFormSchema.keyof();
551
+ export const ONBOARDING_DRAFT_KEYS = onboardingDraftKeySchema.options;
512
552
  /**
513
553
  * Where the member got to, kept in its own collection, separate from the
514
554
  * profile.
@@ -517,22 +557,29 @@ export const completeOnboardingBodySchema = z.object({
517
557
  * pair: one number is what lets a member start on the web and resume elsewhere,
518
558
  * provided both ends agree on the encoding.
519
559
  *
520
- * `draft` is opaque here on purpose. The client owns the form's shape, and it is
521
- * re-validated strictly at finalize, so this schema has no business describing
522
- * it.
560
+ * The draft's KEYS come from the form; its VALUES are opaque on purpose. A
561
+ * half-answered step must be storable even though it would fail the finish,
562
+ * and everything in it is re-validated strictly at finalize.
563
+ *
564
+ * `partialRecord`, not `record`: in Zod 4 an enum-keyed `z.record` demands
565
+ * every key, and a draft holds whichever steps the member has reached.
523
566
  */
524
- export const onboardingDraftSchema = z.record(z.string(), z.unknown());
567
+ export const onboardingDraftSchema = z.partialRecord(onboardingDraftKeySchema, z.unknown());
525
568
  export const onboardingProgressSchema = z.object({
526
569
  step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
527
570
  draft: onboardingDraftSchema.optional(),
528
571
  });
529
- /** `PUT` body: any subset of the cursor, merged server-side. No defaults. */
572
+ /**
573
+ * `PATCH` body: any subset of the cursor, merged server-side. No defaults.
574
+ * `step` may equal `ONBOARDING_TOTAL_STEPS`: "past the last step", which the
575
+ * client sends on its way to finish.
576
+ */
530
577
  export const onboardingProgressUpdateSchema = z.object({
531
578
  step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS).optional(),
532
579
  draft: onboardingDraftSchema.optional(),
533
580
  });
534
581
  /**
535
- * What the client reads on return.
582
+ * What the client reads on return, from `GET` and from every `PATCH`.
536
583
  *
537
584
  * ⚠️ `onboarded` is the gate and it comes from the profile, not from the
538
585
  * cursor: a finished member on a new device must be recognised even when the
@@ -541,7 +588,7 @@ export const onboardingProgressUpdateSchema = z.object({
541
588
  */
542
589
  export const onboardingStatusSchema = z.object({
543
590
  onboarded: z.boolean(),
544
- step: z.number().int().min(0),
591
+ step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
545
592
  draft: onboardingDraftSchema.optional(),
546
593
  });
547
594
  //# sourceMappingURL=onboarding.js.map