founder-schema 0.15.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 (43) hide show
  1. package/README.md +197 -5
  2. package/dist/core/common.d.ts +103 -20
  3. package/dist/core/common.d.ts.map +1 -1
  4. package/dist/core/common.js +104 -16
  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/communities/communities.d.ts +9 -9
  15. package/dist/domains/discover/discover.d.ts +7 -0
  16. package/dist/domains/discover/discover.d.ts.map +1 -1
  17. package/dist/domains/feed/feed.d.ts +11 -11
  18. package/dist/domains/location/india.d.ts +205 -0
  19. package/dist/domains/location/india.d.ts.map +1 -0
  20. package/dist/domains/location/india.js +1175 -0
  21. package/dist/domains/location/india.js.map +1 -0
  22. package/dist/domains/onboarding/onboarding-answers.d.ts +81 -0
  23. package/dist/domains/onboarding/onboarding-answers.d.ts.map +1 -0
  24. package/dist/domains/onboarding/onboarding-answers.js +141 -0
  25. package/dist/domains/onboarding/onboarding-answers.js.map +1 -0
  26. package/dist/domains/onboarding/onboarding.d.ts +422 -34
  27. package/dist/domains/onboarding/onboarding.d.ts.map +1 -1
  28. package/dist/domains/onboarding/onboarding.js +314 -58
  29. package/dist/domains/onboarding/onboarding.js.map +1 -1
  30. package/dist/domains/products/products.d.ts +3 -3
  31. package/dist/domains/profile/founder-profile.d.ts +122 -6
  32. package/dist/domains/profile/founder-profile.d.ts.map +1 -1
  33. package/dist/domains/profile/founder-profile.js +29 -9
  34. package/dist/domains/profile/founder-profile.js.map +1 -1
  35. package/dist/domains/profile/my-profile.d.ts +386 -0
  36. package/dist/domains/profile/my-profile.d.ts.map +1 -0
  37. package/dist/domains/profile/my-profile.js +52 -0
  38. package/dist/domains/profile/my-profile.js.map +1 -0
  39. package/dist/index.d.ts +7 -4
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +9 -4
  42. package/dist/index.js.map +1 -1
  43. package/package.json +1 -1
package/README.md CHANGED
@@ -86,8 +86,12 @@ SAME schema object, so the two can never disagree about what is allowed.
86
86
  (`ONBOARDING_STEP_SCHEMAS`), so a step the wizard lets through is a step the
87
87
  finish accepts. The step list (`FOUNDER_ONBOARDING_STEPS`) is built from those
88
88
  slices, including whether a step may be skipped.
89
- - **No copy.** Step titles, help lines and topic categories are translation keys
90
- (`titleKey`, `helpKey`, `labelKey`); the words live in each client.
89
+ - **No copy.** A step's label and title, a topic category and a goal category
90
+ are translation keys or plain keys (`labelKey`, `titleKey`, `key`); the words
91
+ live in each client. The web keys its category words by `key`, type-checked
92
+ against it, and its step words by the step's own key (`OnboardingStepKey`);
93
+ `labelKey` / `titleKey` are there for a translated client (i18n), and
94
+ nothing reads them yet.
91
95
  - **Bodies are typed JSON; only queries coerce.** `teamSize: "5"` is refused in a
92
96
  body, and `?limit=5` is read as 5.
93
97
  - **Shapes are derived, never copied.** One `locationSchema`, one
@@ -99,6 +103,189 @@ instance; this package must not bring its own.
99
103
 
100
104
  ---
101
105
 
106
+ ## Breaking in 0.17.0: the onboarding photo is saved, and onboarding's single source of truth (2026-10-19)
107
+
108
+ Two pieces of the user's work on onboarding, 2026-10-19.
109
+
110
+ **The photo.** Going back to the photo step showed no photo, though the member
111
+ had pressed Save and continue on it. Every other answer came back from the
112
+ draft; the photo was held for the visit only, so any reload or a resume the
113
+ next day lost it.
114
+
115
+ - **New:** `onboardingStatusSchema.photo` (optional, a `mediaImageSchema`): the
116
+ photo the member saved, as it will appear. Absent when there is none.
117
+ - **Changed meaning:** a step save carrying `draft.avatarMediaId` (an upload id,
118
+ or `null`) is the photo step SAVING its photo. The server holds that upload
119
+ for the draft, so the 24 hour unattached sweep never deletes it, and lets go
120
+ of the one it replaces; `null` lets go of it. It is not kept in the draft:
121
+ `readOnboardingDraft` still gives `null` for it, and the photo comes back only
122
+ as `photo`, from what the server holds.
123
+ - **Stricter:** `onboardingProgressUpdateSchema` refuses a `draft.avatarMediaId`
124
+ that is neither an upload id nor `null` (`invalidId`, at
125
+ `draft.avatarMediaId`). No client sent the key before 0.17.0.
126
+ - **Backend:** a held photo is a media row attached to the member's onboarding
127
+ progress row (attachment kind `onboarding`, backend only). Finish moves it to
128
+ the new profile in the same transaction; a scheduled job lets go of a held
129
+ photo whose progress row the 90 day TTL deleted (Privacy: sign-up answers go
130
+ 90 days after they were last changed). New partial index on `media` for that
131
+ job; run `sync-indexes --apply` after deploy.
132
+ - **Deploy order: the backend, then the web.** The web reads the status with
133
+ `z.object`, which drops a key it does not know, so a 0.16 web on a 0.17
134
+ backend simply shows no photo on Back, as today.
135
+ - **Rollback floor:** a backend below 0.17 does not know the `onboarding`
136
+ attachment, so photos held by then stay attached and are never swept until
137
+ 0.17 runs again, and a member who finishes on the old build is told the photo
138
+ expired and asked to add it again (the old finish takes only an unattached
139
+ upload). A rollback that is KEPT therefore needs a one-off release of every
140
+ `media` row attached as kind `onboarding`, or those photos outlive the
141
+ Privacy's 90 days (account deletion still erases them).
142
+
143
+ **One source of truth** (the user: "all the data for all the onboarding steps
144
+ and all the errors and validation types ... should come from the founder
145
+ circle schema"). What a client or the backend kept its own copy of now lives
146
+ here, and each consumer reads it:
147
+
148
+ - **New, data:** `GOAL_CATEGORIES` / `GOAL_CATEGORY_KEYS` (the goals step's
149
+ three groups, from the web); `domains/location/india`: `INDIA`,
150
+ `INDIA_DIALING_CODE`, `INDIA_STATES` (each state's cities and other names,
151
+ from the web) with names typed against `GST_STATES` (ONE list of state
152
+ names), `INDIA_STATE_NAMES`, `STATE_ALIASES`, `CITY_ALIASES`,
153
+ `findIndiaState`, `listedCityName`, `canonicalIndiaLocation`;
154
+ `ONBOARDING_DRAFT_RETENTION_DAYS` (90, from the backend's TTL);
155
+ `PHONE_DIGITS`.
156
+ - **New, rules every side applies the same way:** `normalizePhone`,
157
+ `normalizeLinkUrl`, `tidyFullName`, `teamSizeDigits` (from the web);
158
+ `isRealIsoDate`, `todayInIndia`, `dateOfBirthBounds` (a picker's window,
159
+ exact at both ends), `isUnderAgeDateOfBirth` (the draft's age rule, from the
160
+ backend, now with the real-date check), `isoDateToDate` / `dateToIsoDate`;
161
+ `goalListSchema` / `skillListSchema` (one list rule for the finish and Me).
162
+ - **New, from the answers to what is sent** (`domains/onboarding/
163
+ onboarding-answers`, from the web): `emptyOnboardingForm`, `ventureStarted`,
164
+ `onboardingDraftForStep` (exactly a step's own fields), and
165
+ `completeOnboardingBodyFrom` (the finish body: every unanswered optional
166
+ answer absent, each answer cleaned as above), with the
167
+ `CompleteOnboardingBodyCandidate` type; `locationStarted` (a place opened
168
+ but nothing chosen is "no location").
169
+ - **New, the keys of shapes** (so no consumer lists them by hand):
170
+ `FOUNDER_LINK_KEYS` / `FounderLinkKey`, `VENTURE_KEYS` /
171
+ `VENTURE_OPTIONAL_KEYS` / `VentureKey`; types `IndiaLocation`, `IndiaState`,
172
+ `IndiaStateName`, `GoalCategory`.
173
+ - **New message keys:** `guidelinesRequired`, `photoUnavailable`,
174
+ `ventureRequired`, `stateRequired`, `stateUnknown`, `goalsDuplicate`,
175
+ `skillsDuplicate`. Each client words them in the same change (a client
176
+ holding its words as `Record<ValidationMessageKey, string>` fails to build
177
+ until it does).
178
+ - **Stricter:**
179
+ - every location WRITE (`completeOnboardingBodySchema`,
180
+ `updateFounderProfileBodySchema`, `updateMyPersonalDetailsBodySchema`, and
181
+ the server's `suggestedLocationSchema`) is `indiaLocationSchema`: country
182
+ `India`, a state from the list (required: `stateRequired`; not on it:
183
+ `stateUnknown`; read in any common spelling, "KARNATAKA", "Orissa",
184
+ "Jammu & Kashmir", and kept as the list's name), a city. READS keep
185
+ `locationSchema`, so a place stored before this is read as it is;
186
+ - `businessType` / `legalStructure` without a `venture` are each refused ON
187
+ THEIR OWN FIELD (`ventureRequired`), on the finish body and the business
188
+ step; the backend used to drop them without a word, and the web dropped a
189
+ lone registration (hary's ruling: no silent drop). `ventureStarted` no
190
+ longer counts the type: it describes a business, it does not start one;
191
+ - `displayNameSchema` stores the name tidied (`tidyFullName`): every body
192
+ that writes a name, the Me patches included;
193
+ - goals and skills each once (`goalsDuplicate`, `skillsDuplicate`), on the
194
+ finish and the Me patch;
195
+ - the guidelines: a box left unticked is `guidelinesRequired` (it was
196
+ `guidelinesOutdated`, the words for an older text).
197
+ - **Breaking:** `completeOnboardingBodySchema` and
198
+ `ONBOARDING_STEP_SCHEMAS.business` carry the venture rule (refinements), so
199
+ Zod 4.6 throws on `.pick`, `.omit`, `.partial` and `.merge` of them, and on
200
+ an `.extend` that overwrites a key. Read a field with `.shape.<field>`, and a
201
+ step's fields with `ONBOARDING_STEP_SCHEMAS.<step>` (picked before the rule;
202
+ the business slice carries it again). The web picked from the finish body in
203
+ three places (the `/dummy` showcase, at module scope, and two tests); they
204
+ read `.shape` now.
205
+ - **Changed type:** `displayNameSchema` is a pipe (`.transform(tidyFullName)`),
206
+ no longer a `ZodString`, so `.min` / `.max` / `.regex` cannot be chained on
207
+ it. Nothing did; a length is read through the pipe.
208
+ - **Breaking (no consumer reads it):** `FOUNDER_ONBOARDING_STEPS[i].helpKey`
209
+ is gone (there is no help line any more; the user, 2026-10-19), and
210
+ `labelKey` names the step's short label.
211
+ - **Moved, same export name:** `suggestedLocationSchema` / `SuggestedLocation`
212
+ now come from `domains/location/india`.
213
+ - **Deploy order: still the backend first.** A 0.16 web is refused a little
214
+ that it used to send, on its field: a place with no state (an old draft's,
215
+ or the 0.16 Me editor saving an OLD stateless place, which it sends without
216
+ one) at `location.state`. (It never sends a lone business type: its
217
+ `ventureStarted` counted the type, so a type on its own went with an empty
218
+ venture, refused at `venture.name` / `industry` / `stage` as on 0.16.) A
219
+ 0.17 web needs the 0.17 backend for the photo. It reads the location
220
+ suggestion with the strict write shape, which takes a state in any common
221
+ spelling ("KARNATAKA"), so a 0.16 backend's answer is read unless it has no
222
+ state, or one the list does not know: then it is no suggestion.
223
+ - **Rollback floor (also):** the 0.17 web on a backend rolled below 0.17 saves
224
+ no photo, and shows no suggestion where the old backend answered without a
225
+ state the list knows.
226
+ - **Known, follow-up (hary, 2026-10-19), not in 0.17.0:**
227
+ - a step save racing a finish when the member has NO progress row (the TTL
228
+ deleted it while a tab stayed open) can leave a photo held for a finished
229
+ member, until the TTL takes that new row: the finish's update of a row
230
+ that is not there writes nothing, so the two transactions never conflict.
231
+ Fix: the finish upserts its completion stamp;
232
+ - finish's retry loop sets its result inside the transaction callback, so a
233
+ commit that fails and a retry that then meets a username clash could
234
+ answer success for a profile never committed. Fix: reset the result on
235
+ every attempt. Do both together: an 11000 on that upsert would otherwise
236
+ read as a username clash.
237
+
238
+ ## Breaking in 0.16.0: onboarding in nine steps (2026-10-18)
239
+
240
+ The user's rework of onboarding. The finish body takes the same answers plus one.
241
+
242
+ - **Breaking:** `ONBOARDING_STEP_KEYS` is nine: `basics` (full name, gender,
243
+ date of birth, nothing else), `photo`, `professional` (title, about, skills),
244
+ then `contact`, `location`, `goals`, `interests`, `business`, `guidelines` as
245
+ before. `identity` and `ONBOARDING_STEP_SCHEMAS.identity` are gone;
246
+ `ONBOARDING_TOTAL_STEPS` is 9. The resume cursor is an index into this list:
247
+ the backend translates a cursor saved under the seven steps once, by step
248
+ name (its progress rows carry `stepsVersion`), so nobody resumes on the wrong
249
+ step or loses an answer.
250
+ - **Breaking:** `onboardingFormSchema` gains `legalStructure` (nullable), so a
251
+ form built from it must hold the key.
252
+ - **New:** `LEGAL_STRUCTURE_VALUES` / `legalStructureSchema` / `LegalStructure`:
253
+ how a venture is registered in India (sole proprietorship, partnership, LLP,
254
+ OPC, private and public limited, Section 8, trust or society, co-operative),
255
+ or not yet, or outside India. Optional on the finish body and the business
256
+ step; stored on the profile's venture beside `businessType`, and shown only
257
+ to the member (below).
258
+ - **New: the member's private answers in Me** (`domains/profile/my-profile.ts`,
259
+ the user, 2026-10-18): `GET`/`PUT /profiles/me/business` (`myBusinessSchema`,
260
+ `putMyBusinessBodySchema`: the WHOLE venture with its `businessType` and
261
+ `legalStructure`, which are `myVentureSchema`, or `null` to remove it) and
262
+ `GET`/`PUT /profiles/me/interests` (`myInterestsSchema`,
263
+ `putMyInterestsBodySchema`). Owner only: none of the three is on
264
+ `founderProfileSchema`, a card, a byline or a member's page.
265
+ - **Stricter:** `interestsSchema` is the ONE interests rule, used by the finish
266
+ body and the Me editor, and it now refuses a topic twice (`topicsDuplicate`):
267
+ three copies of one topic met the minimum of three. Neither client sends one.
268
+ - **New:** `splitFullName(fullName)`: the first and last name a full name
269
+ deduces to, at the first space; a one-word name is whole. Derived, never
270
+ stored: `displayName` stays the one owner of a name.
271
+ - **Appended:** `GOAL_VALUES` +7 (`get_feedback`, `find_testers`,
272
+ `mentor_others`, `invest_in_startups`, `join_startup`,
273
+ `find_service_providers`, `attend_events`); `BUSINESS_TYPE_VALUES` +5
274
+ (`freelancer`, `agency`, `social_enterprise`, `family_business`, `franchise`).
275
+ Nothing renamed or removed.
276
+ - **Deploy order: the backend, then the web straight after.** A 0.15 backend
277
+ refuses the `legalStructure` draft key and steps above 7, so a 0.16 web on it
278
+ is stuck at the business step; a 0.15 web cannot parse a 9-step status or a
279
+ card holding a new goal. Open tabs and an installed app's cached copy keep the
280
+ old bundle until they update.
281
+ - **Rollback floor:** once a member saves a new goal, a build below 0.16
282
+ cannot parse their card, member page or own profile; roll back only after a
283
+ backfill. A new business type or registration travels on none of those (only
284
+ on the owner's own `/profiles/me/business`, new in 0.16), so it does not
285
+ move the floor.
286
+ - **Backend, at deploy:** no new index and no migration (old cursors are
287
+ translated on the member's next visit).
288
+
102
289
  ## Breaking in 0.15.0: the launch waitlist (2026-10-16)
103
290
 
104
291
  Until the launch instant `FC_LAUNCH_AT` (a server-only env on BOTH the backend
@@ -574,8 +761,13 @@ is imported, so a server process still up on 1 January rejects the new year unti
574
761
  somebody restarts it.
575
762
 
576
763
  **Changing a member of an enum array is a data change, not a type change.** The
577
- four `*_VALUES` arrays are Mongoose `enum` constraints in the backend. Removing or
578
- renaming a value orphans documents already saved with it. Appending is safe.
764
+ `*_VALUES` arrays the backend's profile model uses (stage, industry, skill, goal,
765
+ gender, topic, business type, legal structure) are Mongoose `enum` constraints.
766
+ Removing or renaming a value orphans documents already saved with it. Appending
767
+ is safe for the database, but not for an OLDER CLIENT, which parses strictly and
768
+ refuses a value it does not know: deploy the readers (backend, then web) before
769
+ anything can write the new value, and a rollback below the version that added it
770
+ needs a backfill first.
579
771
  `STARTUP_STAGE_VALUES` is ordered earliest to latest and that order is load
580
772
  bearing, so append at the end and never insert.
581
773
 
@@ -584,7 +776,7 @@ bearing, so append at the end and never insert.
584
776
  ## Settled since the scaffold
585
777
 
586
778
  - **The npm name and the first publish:** `founder-schema`, 0.1.0, by the user.
587
- - **The onboarding steps:** seven, ruled 2026-09-22; see `src/domains/onboarding/onboarding.ts`.
779
+ - **The onboarding steps:** nine since 0.16.0 (the user, 2026-10-18), seven before (ruled 2026-09-22); see `src/domains/onboarding/onboarding.ts`.
588
780
 
589
781
  ## Contract decisions, 2026-09-21
590
782
 
@@ -78,6 +78,14 @@ export type SafeUrl = z.infer<typeof safeUrlSchema>;
78
78
  */
79
79
  export declare const httpUrlSchema: z.ZodURL;
80
80
  export type HttpUrl = z.infer<typeof httpUrlSchema>;
81
+ /**
82
+ * A link as people paste it, made into what `httpUrlSchema` checks (0.17.0,
83
+ * moved here from the web so every client cleans a link the same way):
84
+ * `linkedin.com/in/asha` gains `https://`; blank stays blank. Something that
85
+ * already names a scheme keeps it, so `javascript:` is never quietly "fixed"
86
+ * into a link: the schema then refuses it, as it only takes http and https.
87
+ */
88
+ export declare function normalizeLinkUrl(raw: string): string;
81
89
  /**
82
90
  * True when `url` parses and its hostname is EXACTLY one of `hosts`
83
91
  * (lowercase). The one host check in this package, written the way the warning
@@ -238,11 +246,12 @@ export type Skill = z.infer<typeof skillSchema>;
238
246
  * venture works only for a member who ALREADY HAS a venture row, and somebody
239
247
  * who wants to start a company is precisely the member with no venture at all,
240
248
  * so there is nothing to infer from. It also cannot express a founder with a
241
- * running company who wants to start another. Every other value is an ask of
242
- * other people and this one is an ask of the product, which is the whole of the
243
- * oddness. (hary, 2026-09-21.)
249
+ * running company who wants to start another. The others were all asks of other
250
+ * people and this one an ask of the product, which is the whole of the oddness.
251
+ * (hary, 2026-09-21.) Since 0.16.0 the list also holds offers (`mentor_others`,
252
+ * `invest_in_startups`) and a second ask of the product (`attend_events`).
244
253
  */
245
- export declare const GOAL_VALUES: readonly ["find_cofounder", "find_investors", "find_customers", "hiring", "partnerships", "mentorship", "grow_network", "business_ideas", "market_insights", "learn_startup", "start_company"];
254
+ export declare const GOAL_VALUES: readonly ["find_cofounder", "find_investors", "find_customers", "hiring", "partnerships", "mentorship", "grow_network", "business_ideas", "market_insights", "learn_startup", "start_company", "get_feedback", "find_testers", "mentor_others", "invest_in_startups", "join_startup", "find_service_providers", "attend_events"];
246
255
  export declare const goalSchema: z.ZodEnum<{
247
256
  find_cofounder: "find_cofounder";
248
257
  find_investors: "find_investors";
@@ -255,8 +264,59 @@ export declare const goalSchema: z.ZodEnum<{
255
264
  market_insights: "market_insights";
256
265
  learn_startup: "learn_startup";
257
266
  start_company: "start_company";
267
+ get_feedback: "get_feedback";
268
+ find_testers: "find_testers";
269
+ mentor_others: "mentor_others";
270
+ invest_in_startups: "invest_in_startups";
271
+ join_startup: "join_startup";
272
+ find_service_providers: "find_service_providers";
273
+ attend_events: "attend_events";
258
274
  }>;
259
275
  export type Goal = z.infer<typeof goalSchema>;
276
+ /**
277
+ * A member's goals as a list (0.17.0): each goal once, at most every goal.
278
+ * ONE rule for onboarding's finish (which adds its minimum, `GOALS_MIN`) and
279
+ * the Me tab's editor: two copies of one goal met the minimum while meaning
280
+ * one, and the profile would show it twice.
281
+ */
282
+ export declare const goalListSchema: z.ZodArray<z.ZodEnum<{
283
+ find_cofounder: "find_cofounder";
284
+ find_investors: "find_investors";
285
+ find_customers: "find_customers";
286
+ hiring: "hiring";
287
+ partnerships: "partnerships";
288
+ mentorship: "mentorship";
289
+ grow_network: "grow_network";
290
+ business_ideas: "business_ideas";
291
+ market_insights: "market_insights";
292
+ learn_startup: "learn_startup";
293
+ start_company: "start_company";
294
+ get_feedback: "get_feedback";
295
+ find_testers: "find_testers";
296
+ mentor_others: "mentor_others";
297
+ invest_in_startups: "invest_in_startups";
298
+ join_startup: "join_startup";
299
+ find_service_providers: "find_service_providers";
300
+ attend_events: "attend_events";
301
+ }>>;
302
+ /** A member's skills (0.17.0): each skill once, at most `SKILLS_MAX`. ONE rule for onboarding and Me. */
303
+ export declare const skillListSchema: z.ZodArray<z.ZodEnum<{
304
+ engineering: "engineering";
305
+ product: "product";
306
+ design: "design";
307
+ data: "data";
308
+ growth: "growth";
309
+ sales: "sales";
310
+ marketing: "marketing";
311
+ operations: "operations";
312
+ finance: "finance";
313
+ legal: "legal";
314
+ hr_people: "hr_people";
315
+ supply_chain: "supply_chain";
316
+ research: "research";
317
+ fundraising: "fundraising";
318
+ community: "community";
319
+ }>>;
260
320
  /**
261
321
  * Demographic only. Nothing here computes anything from it, unlike the sibling
262
322
  * gym product where gender feeds body-metric maths.
@@ -273,14 +333,42 @@ export declare const genderSchema: z.ZodEnum<{
273
333
  prefer_not_to_say: "prefer_not_to_say";
274
334
  }>;
275
335
  export type Gender = z.infer<typeof genderSchema>;
336
+ /**
337
+ * A full name as it is sent and stored (0.17.0, moved here from the web):
338
+ * trimmed, and every run of spaces made one. `displayNameSchema` applies it,
339
+ * so the server stores it from every client; a client applies it to show the
340
+ * member what will be kept. What `splitFullName` reads.
341
+ */
342
+ export declare const tidyFullName: (fullName: string) => string;
276
343
  /**
277
344
  * The name a member is shown by. ONE rule for every body that writes it:
278
345
  * onboarding's finish, the profile patch and the personal-details patch, so a
279
- * name one accepts the others can never refuse. Plain bounds, no custom
280
- * message: `too_small` with minimum 1 already says "required", and the client
281
- * words it.
346
+ * name one accepts the others can never refuse, and each stores it TIDIED
347
+ * (`tidyFullName`, 0.17.0: "Asha Rao" from the Me editor was stored as
348
+ * typed, while onboarding sent it tidy). Plain bounds, no custom message:
349
+ * `too_small` with minimum 1 already says "required", and the client words it.
282
350
  */
283
- export declare const displayNameSchema: z.ZodString;
351
+ export declare const displayNameSchema: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
352
+ /**
353
+ * A full name's first and last name, DEDUCED, never asked (the user,
354
+ * 2026-10-18: onboarding asks one "Full name" and works the two out from the
355
+ * space). The first word is the first name and everything after the first
356
+ * space is the last name, kept as written ("Asha Rao Kulkarni" is Asha and
357
+ * Rao Kulkarni).
358
+ *
359
+ * ⚠ A ONE-WORD NAME IS A WHOLE NAME. Many people in India go by a single name,
360
+ * so `lastName` is then `''`, and nothing may refuse it for that.
361
+ *
362
+ * Derived wherever it is needed, never stored as a second copy of the name:
363
+ * `displayName` stays the one owner of a member's name, so a rename can never
364
+ * leave a stale first name behind.
365
+ */
366
+ export declare function splitFullName(fullName: string): {
367
+ firstName: string;
368
+ lastName: string;
369
+ };
370
+ /** The digits an Indian mobile number has: what `phoneSchema` takes and the field keeps. */
371
+ export declare const PHONE_DIGITS = 10;
284
372
  /**
285
373
  * Indian mobile number: ten raw digits, no country code, matching the standard
286
374
  * used across this family. Display adds the `+91`.
@@ -292,6 +380,13 @@ export declare const displayNameSchema: z.ZodString;
292
380
  */
293
381
  export declare const phoneSchema: z.ZodString;
294
382
  export type Phone = z.infer<typeof phoneSchema>;
383
+ /**
384
+ * An Indian mobile number as `phoneSchema`'s ten digits, from however it was
385
+ * typed (0.17.0, moved here from the web): `98765 43210`, `+91 98765-43210`,
386
+ * `091 9876543210`. Anything else comes back digits only and fails the schema
387
+ * with `phoneInvalid`, rather than being guessed at.
388
+ */
389
+ export declare function normalizePhone(raw: string): string;
295
390
  /**
296
391
  * Where the founder is based. City and country are what Discover filters on.
297
392
  *
@@ -328,18 +423,6 @@ export declare const coordinatesQuerySchema: z.ZodObject<{
328
423
  }, z.core.$strip>;
329
424
  export type CoordinatesQuery = z.infer<typeof coordinatesQuerySchema>;
330
425
  export type CoordinatesQueryInput = z.input<typeof coordinatesQuerySchema>;
331
- /**
332
- * The city these coordinates look like, for the member to CONFIRM, or `null`
333
- * when the lookup could not place them confidently (or they are outside India,
334
- * whose area data is all there is). A suggestion, never a write: only what the
335
- * member then submits becomes their location.
336
- */
337
- export declare const suggestedLocationSchema: z.ZodNullable<z.ZodObject<{
338
- city: z.ZodString;
339
- state: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
340
- country: z.ZodString;
341
- }, z.core.$strip>>;
342
- export type SuggestedLocation = z.infer<typeof suggestedLocationSchema>;
343
426
  /**
344
427
  * Cursor pagination query. Feed and Discover both use it.
345
428
  *
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../src/core/common.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../../src/core/common.ts"],"names":[],"mappings":"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"}
@@ -7,7 +7,7 @@
7
7
  * @module founder-schema/core/common
8
8
  */
9
9
  import { z } from 'zod';
10
- import { CITY_MAX, COUNTRY_MAX, DISPLAY_NAME_MAX, LINK_URL_MAX, PAGE_SIZE_DEFAULT, PAGE_SIZE_MAX, STATE_MAX, } from './limits.js';
10
+ import { CITY_MAX, COUNTRY_MAX, DISPLAY_NAME_MAX, LINK_URL_MAX, PAGE_SIZE_DEFAULT, PAGE_SIZE_MAX, SKILLS_MAX, STATE_MAX, } from './limits.js';
11
11
  import { VALIDATION_MESSAGE } from './validation.js';
12
12
  // ─── String helpers ──────────────────────────────────────────────────────────
13
13
  /**
@@ -94,6 +94,19 @@ export const safeUrlSchema = z.url({
94
94
  * `imageUrl` would have failed a whole feed page.
95
95
  */
96
96
  export const httpUrlSchema = safeUrlSchema.max(LINK_URL_MAX);
97
+ /**
98
+ * A link as people paste it, made into what `httpUrlSchema` checks (0.17.0,
99
+ * moved here from the web so every client cleans a link the same way):
100
+ * `linkedin.com/in/asha` gains `https://`; blank stays blank. Something that
101
+ * already names a scheme keeps it, so `javascript:` is never quietly "fixed"
102
+ * into a link: the schema then refuses it, as it only takes http and https.
103
+ */
104
+ export function normalizeLinkUrl(raw) {
105
+ const trimmed = raw.trim();
106
+ if (!trimmed)
107
+ return '';
108
+ return /^[a-z][a-z0-9+.-]*:/i.test(trimmed) ? trimmed : `https://${trimmed}`;
109
+ }
97
110
  /**
98
111
  * True when `url` parses and its hostname is EXACTLY one of `hosts`
99
112
  * (lowercase). The one host check in this package, written the way the warning
@@ -312,9 +325,10 @@ export const skillSchema = z.enum(SKILL_VALUES);
312
325
  * venture works only for a member who ALREADY HAS a venture row, and somebody
313
326
  * who wants to start a company is precisely the member with no venture at all,
314
327
  * so there is nothing to infer from. It also cannot express a founder with a
315
- * running company who wants to start another. Every other value is an ask of
316
- * other people and this one is an ask of the product, which is the whole of the
317
- * oddness. (hary, 2026-09-21.)
328
+ * running company who wants to start another. The others were all asks of other
329
+ * people and this one an ask of the product, which is the whole of the oddness.
330
+ * (hary, 2026-09-21.) Since 0.16.0 the list also holds offers (`mentor_others`,
331
+ * `invest_in_startups`) and a second ask of the product (`attend_events`).
318
332
  */
319
333
  export const GOAL_VALUES = [
320
334
  'find_cofounder',
@@ -328,8 +342,37 @@ export const GOAL_VALUES = [
328
342
  'market_insights',
329
343
  'learn_startup',
330
344
  'start_company',
345
+ // 0.16.0 (the user, 2026-10-18: "a few more ... cover most of the goals"):
346
+ // the journey's own asks (feedback on an idea, early testers), the other
347
+ // side of mentorship and of fundraising (mentoring, investing), and three
348
+ // more a founder network is asked for: joining a startup, finding
349
+ // service providers, and events. Appended, per the rule above.
350
+ 'get_feedback',
351
+ 'find_testers',
352
+ 'mentor_others',
353
+ 'invest_in_startups',
354
+ 'join_startup',
355
+ 'find_service_providers',
356
+ 'attend_events',
331
357
  ];
332
358
  export const goalSchema = z.enum(GOAL_VALUES);
359
+ /** True when no value is in the list twice. */
360
+ const eachOnce = (items) => new Set(items).size === items.length;
361
+ /**
362
+ * A member's goals as a list (0.17.0): each goal once, at most every goal.
363
+ * ONE rule for onboarding's finish (which adds its minimum, `GOALS_MIN`) and
364
+ * the Me tab's editor: two copies of one goal met the minimum while meaning
365
+ * one, and the profile would show it twice.
366
+ */
367
+ export const goalListSchema = z
368
+ .array(goalSchema)
369
+ .max(GOAL_VALUES.length)
370
+ .refine(eachOnce, { error: VALIDATION_MESSAGE.goalsDuplicate });
371
+ /** A member's skills (0.17.0): each skill once, at most `SKILLS_MAX`. ONE rule for onboarding and Me. */
372
+ export const skillListSchema = z
373
+ .array(skillSchema)
374
+ .max(SKILLS_MAX)
375
+ .refine(eachOnce, { error: VALIDATION_MESSAGE.skillsDuplicate });
333
376
  // ─── Gender ──────────────────────────────────────────────────────────────────
334
377
  /**
335
378
  * Demographic only. Nothing here computes anything from it, unlike the sibling
@@ -342,15 +385,51 @@ export const goalSchema = z.enum(GOAL_VALUES);
342
385
  export const GENDER_VALUES = ['male', 'female', 'other', 'prefer_not_to_say'];
343
386
  export const genderSchema = z.enum(GENDER_VALUES);
344
387
  // ─── Display name ────────────────────────────────────────────────────────────
388
+ /**
389
+ * A full name as it is sent and stored (0.17.0, moved here from the web):
390
+ * trimmed, and every run of spaces made one. `displayNameSchema` applies it,
391
+ * so the server stores it from every client; a client applies it to show the
392
+ * member what will be kept. What `splitFullName` reads.
393
+ */
394
+ export const tidyFullName = (fullName) => fullName.trim().replace(/\s+/g, ' ');
345
395
  /**
346
396
  * The name a member is shown by. ONE rule for every body that writes it:
347
397
  * onboarding's finish, the profile patch and the personal-details patch, so a
348
- * name one accepts the others can never refuse. Plain bounds, no custom
349
- * message: `too_small` with minimum 1 already says "required", and the client
350
- * words it.
398
+ * name one accepts the others can never refuse, and each stores it TIDIED
399
+ * (`tidyFullName`, 0.17.0: "Asha Rao" from the Me editor was stored as
400
+ * typed, while onboarding sent it tidy). Plain bounds, no custom message:
401
+ * `too_small` with minimum 1 already says "required", and the client words it.
351
402
  */
352
- export const displayNameSchema = z.string().trim().min(1).max(DISPLAY_NAME_MAX);
403
+ export const displayNameSchema = z
404
+ .string()
405
+ .trim()
406
+ .min(1)
407
+ .max(DISPLAY_NAME_MAX)
408
+ .transform(tidyFullName);
409
+ /**
410
+ * A full name's first and last name, DEDUCED, never asked (the user,
411
+ * 2026-10-18: onboarding asks one "Full name" and works the two out from the
412
+ * space). The first word is the first name and everything after the first
413
+ * space is the last name, kept as written ("Asha Rao Kulkarni" is Asha and
414
+ * Rao Kulkarni).
415
+ *
416
+ * ⚠ A ONE-WORD NAME IS A WHOLE NAME. Many people in India go by a single name,
417
+ * so `lastName` is then `''`, and nothing may refuse it for that.
418
+ *
419
+ * Derived wherever it is needed, never stored as a second copy of the name:
420
+ * `displayName` stays the one owner of a member's name, so a rename can never
421
+ * leave a stale first name behind.
422
+ */
423
+ export function splitFullName(fullName) {
424
+ const name = tidyFullName(fullName);
425
+ const space = name.indexOf(' ');
426
+ return space === -1
427
+ ? { firstName: name, lastName: '' }
428
+ : { firstName: name.slice(0, space), lastName: name.slice(space + 1) };
429
+ }
353
430
  // ─── Phone ───────────────────────────────────────────────────────────────────
431
+ /** The digits an Indian mobile number has: what `phoneSchema` takes and the field keeps. */
432
+ export const PHONE_DIGITS = 10;
354
433
  /**
355
434
  * Indian mobile number: ten raw digits, no country code, matching the standard
356
435
  * used across this family. Display adds the `+91`.
@@ -363,7 +442,23 @@ export const displayNameSchema = z.string().trim().min(1).max(DISPLAY_NAME_MAX);
363
442
  export const phoneSchema = z
364
443
  .string()
365
444
  .trim()
366
- .regex(/^[6-9]\d{9}$/, VALIDATION_MESSAGE.phoneInvalid);
445
+ .regex(new RegExp(`^[6-9]\\d{${PHONE_DIGITS - 1}}$`), VALIDATION_MESSAGE.phoneInvalid);
446
+ /**
447
+ * An Indian mobile number as `phoneSchema`'s ten digits, from however it was
448
+ * typed (0.17.0, moved here from the web): `98765 43210`, `+91 98765-43210`,
449
+ * `091 9876543210`. Anything else comes back digits only and fails the schema
450
+ * with `phoneInvalid`, rather than being guessed at.
451
+ */
452
+ export function normalizePhone(raw) {
453
+ let digits = raw.replace(/\D/g, '');
454
+ if (digits.length === PHONE_DIGITS + 2 && digits.startsWith('91'))
455
+ digits = digits.slice(2);
456
+ else if (digits.length === PHONE_DIGITS + 1 && digits.startsWith('0'))
457
+ digits = digits.slice(1);
458
+ else if (digits.length === PHONE_DIGITS + 3 && digits.startsWith('091'))
459
+ digits = digits.slice(3);
460
+ return digits;
461
+ }
367
462
  // ─── Location ────────────────────────────────────────────────────────────────
368
463
  /**
369
464
  * Where the founder is based. City and country are what Discover filters on.
@@ -398,13 +493,6 @@ export const coordinatesQuerySchema = z.object({
398
493
  lat: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-90).max(90)),
399
494
  lng: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-180).max(180)),
400
495
  });
401
- /**
402
- * The city these coordinates look like, for the member to CONFIRM, or `null`
403
- * when the lookup could not place them confidently (or they are outside India,
404
- * whose area data is all there is). A suggestion, never a write: only what the
405
- * member then submits becomes their location.
406
- */
407
- export const suggestedLocationSchema = locationSchema.nullable();
408
496
  // ─── Pagination ──────────────────────────────────────────────────────────────
409
497
  /**
410
498
  * Cursor pagination query. Feed and Discover both use it.