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
package/dist/common.d.ts CHANGED
@@ -20,6 +20,8 @@ export declare const emptyStringToUndefined: (value: unknown) => unknown;
20
20
  export declare const optionalTrimmedString: (maxLength?: number) => z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
21
21
  /** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */
22
22
  export declare const objectIdSchema: z.ZodPipe<z.ZodString, z.ZodTransform<string, string>>;
23
+ /** Named for what it is on the wire, so it never collides with Mongoose's `ObjectId`. */
24
+ export type ObjectIdString = z.infer<typeof objectIdSchema>;
23
25
  /**
24
26
  * A URL safe to store and, after the consumer's own check, to render.
25
27
  *
@@ -53,19 +55,55 @@ export declare const objectIdSchema: z.ZodPipe<z.ZodString, z.ZodTransform<strin
53
55
  * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing
54
56
  * and comparing the hostname exactly.)
55
57
  */
58
+ export declare const safeUrlSchema: z.ZodURL;
59
+ export type SafeUrl = z.infer<typeof safeUrlSchema>;
60
+ /**
61
+ * A URL a member TYPES: `safeUrlSchema` plus the `LINK_URL_MAX` ceiling, so
62
+ * every member URL carries the same cap and no consumer has to remember it.
63
+ *
64
+ * Two schemas, not one, because the cap is about what a person types and is
65
+ * wrong for what the server reads from elsewhere. Until 0.2.0 there was only
66
+ * this one, and it capped a link preview's CDN image URL too: one 314-character
67
+ * `imageUrl` would have failed a whole feed page.
68
+ */
56
69
  export declare const httpUrlSchema: z.ZodURL;
70
+ export type HttpUrl = z.infer<typeof httpUrlSchema>;
57
71
  /**
58
- * Public handle a founder picks and can change later.
72
+ * Public handle. Derived by the server at onboarding, changeable later.
59
73
  *
60
74
  * Rules are deliberately narrow because the handle appears in URLs and in
61
75
  * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30
62
- * characters, must start with a letter. Reserved words are refused by the
63
- * backend, not here, so the list can change without a schema publish.
76
+ * characters, must start with a letter.
77
+ *
78
+ * This is the FORMAT, and it is what every read shape uses. A handle being
79
+ * CHOSEN must also not be reserved: that is `chosenUsernameSchema`.
64
80
  */
65
81
  export declare const USERNAME_MIN = 3;
66
82
  export declare const USERNAME_MAX = 30;
67
83
  export declare const usernameSchema: z.ZodString;
68
84
  export type Username = z.infer<typeof usernameSchema>;
85
+ /**
86
+ * Handles this product keeps for itself: route names a handle would shadow,
87
+ * and words that would let a member pass as staff.
88
+ *
89
+ * ⚠ HERE, IN THE CONTRACT, since 0.2.0. It used to live in the backend so it
90
+ * could grow without a publish, and nothing ever read it: the server derived
91
+ * handles without checking it, so a member called "Support" was handed
92
+ * `support`. One list, read by the server when it derives a handle and by any
93
+ * form that lets a member choose one, is worth a publish per addition.
94
+ *
95
+ * Matched exactly, after lowercasing.
96
+ */
97
+ export declare const RESERVED_USERNAMES: readonly ["admin", "about", "api", "chat", "discover", "feed", "founder", "foundercircle", "help", "home", "me", "meetup", "moderator", "onboarding", "settings", "staff", "superadmin", "support", "system", "team"];
98
+ export declare function isReservedUsername(handle: string): boolean;
99
+ /**
100
+ * A handle a member is CHOOSING: the format, and not reserved.
101
+ *
102
+ * ⚠ Not used on read shapes, deliberately. A handle stored before the reserved
103
+ * check existed must still READ, or one member's old handle would fail every
104
+ * page that shows their name.
105
+ */
106
+ export declare const chosenUsernameSchema: z.ZodString;
69
107
  /**
70
108
  * Stage of the founder's current venture.
71
109
  *
@@ -201,19 +239,55 @@ export type Gender = z.infer<typeof genderSchema>;
201
239
  * would be the copy nobody keeps in sync.
202
240
  */
203
241
  export declare const phoneSchema: z.ZodString;
242
+ export type Phone = z.infer<typeof phoneSchema>;
204
243
  /**
205
- * Where the founder is based. City and country are what Discover filters on;
206
- * coordinates are optional and only present when the member granted location.
244
+ * Where the founder is based. City and country are what Discover filters on.
207
245
  *
208
- * Note for later: this is a DECLARED location, not a GPS reading. Jansathi's
209
- * rule applies here too — never write a declared location from a GPS capture.
246
+ * ONE SHAPE for the profile, the Me tab's patch, onboarding's finish and the
247
+ * location suggestion. It was written out three times until 0.2.0, the same
248
+ * kind of copy that let the profile model lose Instagram.
249
+ *
250
+ * ⚠ `country` is REQUIRED and has NO default. A default here (it used to be
251
+ * `'India'`) is destructive on every write path: a founder in Berlin who
252
+ * corrects only their city would be moved to India by a value they never saw.
253
+ * A body that carries a location carries the whole location. Every stored
254
+ * location has a country, because the model requires one, so the read side
255
+ * loses nothing.
256
+ *
257
+ * This is a DECLARED location, not a GPS reading. Jansathi's rule applies
258
+ * here too: never write a declared location from a GPS capture.
210
259
  */
211
260
  export declare const locationSchema: z.ZodObject<{
212
261
  city: z.ZodString;
213
262
  state: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
214
- country: z.ZodDefault<z.ZodString>;
263
+ country: z.ZodString;
215
264
  }, z.core.$strip>;
216
265
  export type FounderLocation = z.infer<typeof locationSchema>;
266
+ /**
267
+ * `GET /api/v1/location/areas/reverse?lat=&lng=`: plain decimal degrees.
268
+ *
269
+ * A QUERY, so coercion is right here: everything in a URL is a string. An
270
+ * emptied parameter is "missing", never 0, because 0,0 is a real place in the
271
+ * Gulf of Guinea and would be looked up rather than refused.
272
+ */
273
+ export declare const coordinatesQuerySchema: z.ZodObject<{
274
+ lat: z.ZodPreprocess<z.ZodCoercedNumber<unknown>, unknown>;
275
+ lng: z.ZodPreprocess<z.ZodCoercedNumber<unknown>, unknown>;
276
+ }, z.core.$strip>;
277
+ export type CoordinatesQuery = z.infer<typeof coordinatesQuerySchema>;
278
+ export type CoordinatesQueryInput = z.input<typeof coordinatesQuerySchema>;
279
+ /**
280
+ * The city these coordinates look like, for the member to CONFIRM, or `null`
281
+ * when the lookup could not place them confidently (or they are outside India,
282
+ * whose area data is all there is). A suggestion, never a write: only what the
283
+ * member then submits becomes their location.
284
+ */
285
+ export declare const suggestedLocationSchema: z.ZodNullable<z.ZodObject<{
286
+ city: z.ZodString;
287
+ state: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
288
+ country: z.ZodString;
289
+ }, z.core.$strip>>;
290
+ export type SuggestedLocation = z.infer<typeof suggestedLocationSchema>;
217
291
  /**
218
292
  * Cursor pagination query. Feed and Discover both use it.
219
293
  *
@@ -225,4 +299,6 @@ export declare const paginationQuerySchema: z.ZodObject<{
225
299
  cursor: z.ZodOptional<z.ZodString>;
226
300
  }, z.core.$strip>;
227
301
  export type PaginationQuery = z.infer<typeof paginationQuerySchema>;
302
+ /** What a client may put IN the URL: every field optional, numbers as strings. */
303
+ export type PaginationQueryInput = z.input<typeof paginationQuerySchema>;
228
304
  //# sourceMappingURL=common.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,GAAI,OAAO,OAAO,KAAG,OAGvD,CAAC;AAEF,yEAAyE;AACzE,eAAO,MAAM,qBAAqB,GAAI,kBAAe,yDAC8B,CAAC;AAEpF,4EAA4E;AAC5E,eAAO,MAAM,cAAc,wDAGS,CAAC;AAIrC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,eAAO,MAAM,aAAa,UAGxB,CAAC;AAIH;;;;;;;GAOG;AACH,eAAO,MAAM,YAAY,IAAI,CAAC;AAC9B,eAAO,MAAM,YAAY,KAAK,CAAC;AAE/B,eAAO,MAAM,cAAc,aASxB,CAAC;AACJ,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAItD;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB,qGASvB,CAAC;AACX,eAAO,MAAM,kBAAkB;;;;;;;;;EAA+B,CAAC;AAC/D,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;GAGG;AACH,eAAO,MAAM,eAAe,2PAsBlB,CAAC;AACX,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;EAA0B,CAAC;AACtD,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;GAIG;AACH,eAAO,MAAM,YAAY,8LAgBf,CAAC;AACX,eAAO,MAAM,WAAW;;;;;;;;;;;;;;;;EAAuB,CAAC;AAChD,MAAM,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,WAAW,CAAC,CAAC;AAEhD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,WAAW,gMAYd,CAAC;AACX,eAAO,MAAM,UAAU;;;;;;;;;;;;EAAsB,CAAC;AAC9C,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AAI9C;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,2DAA4D,CAAC;AACvF,eAAO,MAAM,YAAY;;;;;EAAwB,CAAC;AAClD,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY,CAAC,CAAC;AAIlD;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,aAGkC,CAAC;AAI3D;;;;;;GAMG;AACH,eAAO,MAAM,cAAc;;;;iBAIzB,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAI7D;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB;;;iBAShC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
1
+ {"version":3,"file":"common.d.ts","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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"}
package/dist/common.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * @module founder-schema/common
8
8
  */
9
9
  import { z } from 'zod';
10
+ import { CITY_MAX, COUNTRY_MAX, LINK_URL_MAX, PAGE_SIZE_DEFAULT, PAGE_SIZE_MAX, STATE_MAX, } from './limits.js';
11
+ import { VALIDATION_MESSAGE } from './validation.js';
10
12
  // ─── String helpers ──────────────────────────────────────────────────────────
11
13
  /**
12
14
  * Converts empty / whitespace-only strings to `undefined` so Zod `.optional()`
@@ -26,7 +28,7 @@ export const optionalTrimmedString = (maxLength = 100) => z.preprocess(emptyStri
26
28
  /** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */
27
29
  export const objectIdSchema = z
28
30
  .string()
29
- .regex(/^[0-9a-f]{24}$/i, 'Invalid id')
31
+ .regex(/^[0-9a-f]{24}$/i, VALIDATION_MESSAGE.invalidId)
30
32
  .transform((v) => v.toLowerCase());
31
33
  // ─── URLs ────────────────────────────────────────────────────────────────────
32
34
  /**
@@ -62,18 +64,31 @@ export const objectIdSchema = z
62
64
  * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing
63
65
  * and comparing the hostname exactly.)
64
66
  */
65
- export const httpUrlSchema = z.url({
67
+ export const safeUrlSchema = z.url({
66
68
  protocol: /^https?$/,
67
69
  hostname: z.regexes.domain,
70
+ error: VALIDATION_MESSAGE.urlInvalid,
68
71
  });
72
+ /**
73
+ * A URL a member TYPES: `safeUrlSchema` plus the `LINK_URL_MAX` ceiling, so
74
+ * every member URL carries the same cap and no consumer has to remember it.
75
+ *
76
+ * Two schemas, not one, because the cap is about what a person types and is
77
+ * wrong for what the server reads from elsewhere. Until 0.2.0 there was only
78
+ * this one, and it capped a link preview's CDN image URL too: one 314-character
79
+ * `imageUrl` would have failed a whole feed page.
80
+ */
81
+ export const httpUrlSchema = safeUrlSchema.max(LINK_URL_MAX);
69
82
  // ─── Username ────────────────────────────────────────────────────────────────
70
83
  /**
71
- * Public handle a founder picks and can change later.
84
+ * Public handle. Derived by the server at onboarding, changeable later.
72
85
  *
73
86
  * Rules are deliberately narrow because the handle appears in URLs and in
74
87
  * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30
75
- * characters, must start with a letter. Reserved words are refused by the
76
- * backend, not here, so the list can change without a schema publish.
88
+ * characters, must start with a letter.
89
+ *
90
+ * This is the FORMAT, and it is what every read shape uses. A handle being
91
+ * CHOSEN must also not be reserved: that is `chosenUsernameSchema`.
77
92
  */
78
93
  export const USERNAME_MIN = 3;
79
94
  export const USERNAME_MAX = 30;
@@ -81,9 +96,58 @@ export const usernameSchema = z
81
96
  .string()
82
97
  .trim()
83
98
  .toLowerCase()
84
- .min(USERNAME_MIN, `Username must be at least ${USERNAME_MIN} characters`)
85
- .max(USERNAME_MAX, `Username must be at most ${USERNAME_MAX} characters`)
86
- .regex(/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)?$/, 'Use letters, numbers and underscore. One dot allowed, and it cannot start with a dot.');
99
+ // Plain bounds: the client words them from the issue's own minimum/maximum.
100
+ .min(USERNAME_MIN)
101
+ .max(USERNAME_MAX)
102
+ .regex(/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)?$/, VALIDATION_MESSAGE.usernameInvalid);
103
+ /**
104
+ * Handles this product keeps for itself: route names a handle would shadow,
105
+ * and words that would let a member pass as staff.
106
+ *
107
+ * ⚠ HERE, IN THE CONTRACT, since 0.2.0. It used to live in the backend so it
108
+ * could grow without a publish, and nothing ever read it: the server derived
109
+ * handles without checking it, so a member called "Support" was handed
110
+ * `support`. One list, read by the server when it derives a handle and by any
111
+ * form that lets a member choose one, is worth a publish per addition.
112
+ *
113
+ * Matched exactly, after lowercasing.
114
+ */
115
+ export const RESERVED_USERNAMES = [
116
+ 'admin',
117
+ 'about',
118
+ 'api',
119
+ 'chat',
120
+ 'discover',
121
+ 'feed',
122
+ 'founder',
123
+ 'foundercircle',
124
+ 'help',
125
+ 'home',
126
+ 'me',
127
+ 'meetup',
128
+ 'moderator',
129
+ 'onboarding',
130
+ 'settings',
131
+ 'staff',
132
+ 'superadmin',
133
+ 'support',
134
+ 'system',
135
+ 'team',
136
+ ];
137
+ const RESERVED = new Set(RESERVED_USERNAMES);
138
+ export function isReservedUsername(handle) {
139
+ return RESERVED.has(handle.trim().toLowerCase());
140
+ }
141
+ /**
142
+ * A handle a member is CHOOSING: the format, and not reserved.
143
+ *
144
+ * ⚠ Not used on read shapes, deliberately. A handle stored before the reserved
145
+ * check existed must still READ, or one member's old handle would fail every
146
+ * page that shows their name.
147
+ */
148
+ export const chosenUsernameSchema = usernameSchema.refine((u) => !isReservedUsername(u), {
149
+ error: VALIDATION_MESSAGE.usernameReserved,
150
+ });
87
151
  // ─── Founder-facing enums ────────────────────────────────────────────────────
88
152
  /**
89
153
  * Stage of the founder's current venture.
@@ -214,20 +278,48 @@ export const genderSchema = z.enum(GENDER_VALUES);
214
278
  export const phoneSchema = z
215
279
  .string()
216
280
  .trim()
217
- .regex(/^[6-9]\d{9}$/, 'Enter a 10 digit mobile number');
281
+ .regex(/^[6-9]\d{9}$/, VALIDATION_MESSAGE.phoneInvalid);
218
282
  // ─── Location ────────────────────────────────────────────────────────────────
219
283
  /**
220
- * Where the founder is based. City and country are what Discover filters on;
221
- * coordinates are optional and only present when the member granted location.
284
+ * Where the founder is based. City and country are what Discover filters on.
285
+ *
286
+ * ONE SHAPE for the profile, the Me tab's patch, onboarding's finish and the
287
+ * location suggestion. It was written out three times until 0.2.0, the same
288
+ * kind of copy that let the profile model lose Instagram.
222
289
  *
223
- * Note for later: this is a DECLARED location, not a GPS reading. Jansathi's
224
- * rule applies here too — never write a declared location from a GPS capture.
290
+ * ⚠ `country` is REQUIRED and has NO default. A default here (it used to be
291
+ * `'India'`) is destructive on every write path: a founder in Berlin who
292
+ * corrects only their city would be moved to India by a value they never saw.
293
+ * A body that carries a location carries the whole location. Every stored
294
+ * location has a country, because the model requires one, so the read side
295
+ * loses nothing.
296
+ *
297
+ * This is a DECLARED location, not a GPS reading. Jansathi's rule applies
298
+ * here too: never write a declared location from a GPS capture.
225
299
  */
226
300
  export const locationSchema = z.object({
227
- city: z.string().trim().min(1).max(80),
228
- state: optionalTrimmedString(80),
229
- country: z.string().trim().min(1).max(80).default('India'),
301
+ city: z.string().trim().min(1).max(CITY_MAX),
302
+ state: optionalTrimmedString(STATE_MAX),
303
+ country: z.string().trim().min(1).max(COUNTRY_MAX),
304
+ });
305
+ /**
306
+ * `GET /api/v1/location/areas/reverse?lat=&lng=`: plain decimal degrees.
307
+ *
308
+ * A QUERY, so coercion is right here: everything in a URL is a string. An
309
+ * emptied parameter is "missing", never 0, because 0,0 is a real place in the
310
+ * Gulf of Guinea and would be looked up rather than refused.
311
+ */
312
+ export const coordinatesQuerySchema = z.object({
313
+ lat: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-90).max(90)),
314
+ lng: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-180).max(180)),
230
315
  });
316
+ /**
317
+ * The city these coordinates look like, for the member to CONFIRM, or `null`
318
+ * when the lookup could not place them confidently (or they are outside India,
319
+ * whose area data is all there is). A suggestion, never a write: only what the
320
+ * member then submits becomes their location.
321
+ */
322
+ export const suggestedLocationSchema = locationSchema.nullable();
231
323
  // ─── Pagination ──────────────────────────────────────────────────────────────
232
324
  /**
233
325
  * Cursor pagination query. Feed and Discover both use it.
@@ -242,7 +334,7 @@ export const paginationQuerySchema = z.object({
242
334
  * string, and `z.coerce.number()` turns that into 0, which then fails
243
335
  * `.min(1)`: the member sees a 400 for a page they never asked to size.
244
336
  */
245
- limit: z.preprocess(emptyStringToUndefined, z.coerce.number().int().min(1).max(50).default(20)),
337
+ limit: z.preprocess(emptyStringToUndefined, z.coerce.number().int().min(1).max(PAGE_SIZE_MAX).default(PAGE_SIZE_DEFAULT)),
246
338
  cursor: z.string().trim().min(1).optional(),
247
339
  });
248
340
  //# sourceMappingURL=common.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema — Shared helpers and core enums\n *\n * The stable building blocks reused across every Founder Circle schema.\n * Anything that both the backend and a client need to agree on lives here.\n *\n * @module founder-schema/common\n */\n\nimport { z } from 'zod';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined` so Zod `.optional()`\n * treats them as \"not provided\".\n *\n * Why this exists: an HTML form submits `\"\"` for a cleared field, and without\n * this an optional field would be stored as an empty string rather than left\n * alone. Jansathi shipped that bug in dating onboarding.\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n/** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */\nexport const objectIdSchema = z\n .string()\n .regex(/^[0-9a-f]{24}$/i, 'Invalid id')\n .transform((v) => v.toLowerCase());\n\n// ─── URLs ────────────────────────────────────────────────────────────────────\n\n/**\n * A URL safe to store and, after the consumer's own check, to render.\n *\n * Use this for EVERY member-supplied URL. Never bare `z.url()`: it accepts any\n * scheme the URL parser accepts, so `javascript:alert(document.cookie)`,\n * `data:text/html,<script>...`, `vbscript:` and `file:///etc/passwd` all pass.\n * Each of those is stored XSS the moment a client renders it as an `href`, and\n * `data:` is the same in an `<img src>`.\n *\n * `hostname` is restricted too, not only the scheme. A bare host like\n * `localhost` or a literal IP is refused, which closes server-side request\n * forgery: this product runs on EC2 with an instance role, so a member-supplied\n * `http://169.254.169.254/latest/meta-data/` that anything server-side ever\n * fetches is a credential read.\n *\n * Validating here is necessary and not sufficient. A consumer still treats a\n * stored URL as untrusted at render time, because rows written before this\n * existed were never checked.\n *\n * ⚠️ IF THIS EVER GROWS A HOST ALLOWLIST, compare the PARSED hostname for\n * exact equality. Never `includes`, never `endsWith`, never a regex over the\n * whole URL. Each of these is a real bypass of a naive \"is it YouTube\" check,\n * and all three read as obviously fine:\n *\n * https://evil.example/?next=youtube.com/watch contains the string\n * youtube.com.evil.example starts with it\n * youtu.be.evil.example a suffix check passes it\n *\n * The same mistake in the opposite direction to the `javascript:` hole: there\n * the scheme was too permissive, here the host check only LOOKS strict.\n * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing\n * and comparing the hostname exactly.)\n */\nexport const httpUrlSchema = z.url({\n protocol: /^https?$/,\n hostname: z.regexes.domain,\n});\n\n// ─── Username ────────────────────────────────────────────────────────────────\n\n/**\n * Public handle a founder picks and can change later.\n *\n * Rules are deliberately narrow because the handle appears in URLs and in\n * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30\n * characters, must start with a letter. Reserved words are refused by the\n * backend, not here, so the list can change without a schema publish.\n */\nexport const USERNAME_MIN = 3;\nexport const USERNAME_MAX = 30;\n\nexport const usernameSchema = z\n .string()\n .trim()\n .toLowerCase()\n .min(USERNAME_MIN, `Username must be at least ${USERNAME_MIN} characters`)\n .max(USERNAME_MAX, `Username must be at most ${USERNAME_MAX} characters`)\n .regex(\n /^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)?$/,\n 'Use letters, numbers and underscore. One dot allowed, and it cannot start with a dot.',\n );\nexport type Username = z.infer<typeof usernameSchema>;\n\n// ─── Founder-facing enums ────────────────────────────────────────────────────\n\n/**\n * Stage of the founder's current venture.\n *\n * Ordered from earliest to latest so a range filter can compare by index. Add\n * new stages at the END or the stored ordinal of existing rows shifts.\n */\nexport const STARTUP_STAGE_VALUES = [\n 'idea',\n 'validation',\n 'mvp',\n 'pre_seed',\n 'seed',\n 'series_a_plus',\n 'profitable',\n 'exited',\n] as const;\nexport const startupStageSchema = z.enum(STARTUP_STAGE_VALUES);\nexport type StartupStage = z.infer<typeof startupStageSchema>;\n\n/**\n * Industry the venture operates in. Kept as a closed list so Discover filters\n * are a single indexed equality match rather than a text search.\n */\nexport const INDUSTRY_VALUES = [\n 'agritech',\n 'ai_ml',\n 'b2b_saas',\n 'consumer',\n 'climate',\n 'd2c',\n 'deeptech',\n 'edtech',\n 'fintech',\n 'gaming',\n 'healthtech',\n 'logistics',\n 'manufacturing',\n 'marketplace',\n 'media',\n 'mobility',\n 'proptech',\n 'robotics',\n 'services',\n 'space',\n 'other',\n] as const;\nexport const industrySchema = z.enum(INDUSTRY_VALUES);\nexport type Industry = z.infer<typeof industrySchema>;\n\n/**\n * What the founder brings. Free-text skills were considered and rejected: two\n * people typing \"growth\" and \"Growth Marketing\" would never match each other in\n * a filter, and the Discover tab is the whole point of the product.\n */\nexport const SKILL_VALUES = [\n 'engineering',\n 'product',\n 'design',\n 'data',\n 'growth',\n 'sales',\n 'marketing',\n 'operations',\n 'finance',\n 'legal',\n 'hr_people',\n 'supply_chain',\n 'research',\n 'fundraising',\n 'community',\n] as const;\nexport const skillSchema = z.enum(SKILL_VALUES);\nexport type Skill = z.infer<typeof skillSchema>;\n\n/**\n * What the member is here for. ONE list, replacing three.\n *\n * This was `lookingFor` with six values. The design draws eight \"Goals &\n * Passion\" tiles and four more on the business step; all three were the same\n * idea at three grains, and shipping them together would have meant nobody\n * could say which one Discover filters on - the answer settled by whichever\n * query got written first. Merged on hary's ruling, 2026-09-21, while there\n * were still no rows in any database.\n *\n * ⚠️ Stored per member and used as a Mongoose enum. Append only; removing a\n * value orphans documents already saved with it.\n *\n * ⚠️ `start_company` READS ODDLY next to its neighbours and is correct. It was\n * queried on the grounds that it restates `venture.stage: 'idea'`; it does not,\n * and the reason is worth keeping so nobody re-opens it. Inferring it from the\n * venture works only for a member who ALREADY HAS a venture row, and somebody\n * who wants to start a company is precisely the member with no venture at all,\n * so there is nothing to infer from. It also cannot express a founder with a\n * running company who wants to start another. Every other value is an ask of\n * other people and this one is an ask of the product, which is the whole of the\n * oddness. (hary, 2026-09-21.)\n */\nexport const GOAL_VALUES = [\n 'find_cofounder',\n 'find_investors',\n 'find_customers',\n 'hiring',\n 'partnerships',\n 'mentorship',\n 'grow_network',\n 'business_ideas',\n 'market_insights',\n 'learn_startup',\n 'start_company',\n] as const;\nexport const goalSchema = z.enum(GOAL_VALUES);\nexport type Goal = z.infer<typeof goalSchema>;\n\n// ─── Gender ──────────────────────────────────────────────────────────────────\n\n/**\n * Demographic only. Nothing here computes anything from it, unlike the sibling\n * gym product where gender feeds body-metric maths.\n *\n * Takes the family's three values and ADDS one rather than renaming any: a\n * network for founders should let somebody decline the question without having\n * to describe themselves as \"other\".\n */\nexport const GENDER_VALUES = ['male', 'female', 'other', 'prefer_not_to_say'] as const;\nexport const genderSchema = z.enum(GENDER_VALUES);\nexport type Gender = z.infer<typeof genderSchema>;\n\n// ─── Phone ───────────────────────────────────────────────────────────────────\n\n/**\n * Indian mobile number: ten raw digits, no country code, matching the standard\n * used across this family. Display adds the `+91`.\n *\n * ⚠️ WHERE IT IS STORED IS NOT A SCHEMA QUESTION, and it has been ruled: the\n * phone lives on the account mirror, never as a second copy on the profile.\n * Two stored copies drift the first time somebody edits one, and the profile's\n * would be the copy nobody keeps in sync.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(/^[6-9]\\d{9}$/, 'Enter a 10 digit mobile number');\n\n// ─── Location ────────────────────────────────────────────────────────────────\n\n/**\n * Where the founder is based. City and country are what Discover filters on;\n * coordinates are optional and only present when the member granted location.\n *\n * Note for later: this is a DECLARED location, not a GPS reading. Jansathi's\n * rule applies here too — never write a declared location from a GPS capture.\n */\nexport const locationSchema = z.object({\n city: z.string().trim().min(1).max(80),\n state: optionalTrimmedString(80),\n country: z.string().trim().min(1).max(80).default('India'),\n});\nexport type FounderLocation = z.infer<typeof locationSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/**\n * Cursor pagination query. Feed and Discover both use it.\n *\n * Cursor, not page number: the community feed changes under the reader, and an\n * offset would repeat or skip posts as new ones land.\n */\nexport const paginationQuerySchema = z.object({\n /**\n * `?limit=` with nothing after it is treated as \"not provided\", not as a\n * validation failure. A client that clears the control sends the empty\n * string, and `z.coerce.number()` turns that into 0, which then fails\n * `.min(1)`: the member sees a 400 for a page they never asked to size.\n */\n limit: z.preprocess(emptyStringToUndefined, z.coerce.number().int().min(1).max(50).default(20)),\n cursor: z.string().trim().min(1).optional(),\n});\nexport type PaginationQuery = z.infer<typeof paginationQuerySchema>;\n"]}
1
+ {"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * founder-schema — Shared helpers and core enums\n *\n * The stable building blocks reused across every Founder Circle schema.\n * Anything that both the backend and a client need to agree on lives here.\n *\n * @module founder-schema/common\n */\n\nimport { z } from 'zod';\nimport {\n CITY_MAX,\n COUNTRY_MAX,\n LINK_URL_MAX,\n PAGE_SIZE_DEFAULT,\n PAGE_SIZE_MAX,\n STATE_MAX,\n} from './limits.js';\nimport { VALIDATION_MESSAGE } from './validation.js';\n\n// ─── String helpers ──────────────────────────────────────────────────────────\n\n/**\n * Converts empty / whitespace-only strings to `undefined` so Zod `.optional()`\n * treats them as \"not provided\".\n *\n * Why this exists: an HTML form submits `\"\"` for a cleared field, and without\n * this an optional field would be stored as an empty string rather than left\n * alone. Jansathi shipped that bug in dating onboarding.\n */\nexport const emptyStringToUndefined = (value: unknown): unknown => {\n if (typeof value === 'string' && value.trim() === '') return undefined;\n return value;\n};\n\n/** Optional trimmed string. Empty strings are coerced to `undefined`. */\nexport const optionalTrimmedString = (maxLength = 100) =>\n z.preprocess(emptyStringToUndefined, z.string().trim().max(maxLength).optional());\n\n/** A Mongo ObjectId as it crosses the wire: 24 lowercase hex characters. */\nexport const objectIdSchema = z\n .string()\n .regex(/^[0-9a-f]{24}$/i, VALIDATION_MESSAGE.invalidId)\n .transform((v) => v.toLowerCase());\n/** Named for what it is on the wire, so it never collides with Mongoose's `ObjectId`. */\nexport type ObjectIdString = z.infer<typeof objectIdSchema>;\n\n// ─── URLs ────────────────────────────────────────────────────────────────────\n\n/**\n * A URL safe to store and, after the consumer's own check, to render.\n *\n * Use this for EVERY member-supplied URL. Never bare `z.url()`: it accepts any\n * scheme the URL parser accepts, so `javascript:alert(document.cookie)`,\n * `data:text/html,<script>...`, `vbscript:` and `file:///etc/passwd` all pass.\n * Each of those is stored XSS the moment a client renders it as an `href`, and\n * `data:` is the same in an `<img src>`.\n *\n * `hostname` is restricted too, not only the scheme. A bare host like\n * `localhost` or a literal IP is refused, which closes server-side request\n * forgery: this product runs on EC2 with an instance role, so a member-supplied\n * `http://169.254.169.254/latest/meta-data/` that anything server-side ever\n * fetches is a credential read.\n *\n * Validating here is necessary and not sufficient. A consumer still treats a\n * stored URL as untrusted at render time, because rows written before this\n * existed were never checked.\n *\n * ⚠️ IF THIS EVER GROWS A HOST ALLOWLIST, compare the PARSED hostname for\n * exact equality. Never `includes`, never `endsWith`, never a regex over the\n * whole URL. Each of these is a real bypass of a naive \"is it YouTube\" check,\n * and all three read as obviously fine:\n *\n * https://evil.example/?next=youtube.com/watch contains the string\n * youtube.com.evil.example starts with it\n * youtu.be.evil.example a suffix check passes it\n *\n * The same mistake in the opposite direction to the `javascript:` hole: there\n * the scheme was too permissive, here the host check only LOOKS strict.\n * (krish hit this rendering inline video, 2026-09-21, and handles it by parsing\n * and comparing the hostname exactly.)\n */\nexport const safeUrlSchema = z.url({\n protocol: /^https?$/,\n hostname: z.regexes.domain,\n error: VALIDATION_MESSAGE.urlInvalid,\n});\nexport type SafeUrl = z.infer<typeof safeUrlSchema>;\n\n/**\n * A URL a member TYPES: `safeUrlSchema` plus the `LINK_URL_MAX` ceiling, so\n * every member URL carries the same cap and no consumer has to remember it.\n *\n * Two schemas, not one, because the cap is about what a person types and is\n * wrong for what the server reads from elsewhere. Until 0.2.0 there was only\n * this one, and it capped a link preview's CDN image URL too: one 314-character\n * `imageUrl` would have failed a whole feed page.\n */\nexport const httpUrlSchema = safeUrlSchema.max(LINK_URL_MAX);\nexport type HttpUrl = z.infer<typeof httpUrlSchema>;\n\n// ─── Username ────────────────────────────────────────────────────────────────\n\n/**\n * Public handle. Derived by the server at onboarding, changeable later.\n *\n * Rules are deliberately narrow because the handle appears in URLs and in\n * mentions: lowercase letters, digits, underscore and a single dot, 3 to 30\n * characters, must start with a letter.\n *\n * This is the FORMAT, and it is what every read shape uses. A handle being\n * CHOSEN must also not be reserved: that is `chosenUsernameSchema`.\n */\nexport const USERNAME_MIN = 3;\nexport const USERNAME_MAX = 30;\n\nexport const usernameSchema = z\n .string()\n .trim()\n .toLowerCase()\n // Plain bounds: the client words them from the issue's own minimum/maximum.\n .min(USERNAME_MIN)\n .max(USERNAME_MAX)\n .regex(/^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)?$/, VALIDATION_MESSAGE.usernameInvalid);\nexport type Username = z.infer<typeof usernameSchema>;\n\n/**\n * Handles this product keeps for itself: route names a handle would shadow,\n * and words that would let a member pass as staff.\n *\n * ⚠ HERE, IN THE CONTRACT, since 0.2.0. It used to live in the backend so it\n * could grow without a publish, and nothing ever read it: the server derived\n * handles without checking it, so a member called \"Support\" was handed\n * `support`. One list, read by the server when it derives a handle and by any\n * form that lets a member choose one, is worth a publish per addition.\n *\n * Matched exactly, after lowercasing.\n */\nexport const RESERVED_USERNAMES = [\n 'admin',\n 'about',\n 'api',\n 'chat',\n 'discover',\n 'feed',\n 'founder',\n 'foundercircle',\n 'help',\n 'home',\n 'me',\n 'meetup',\n 'moderator',\n 'onboarding',\n 'settings',\n 'staff',\n 'superadmin',\n 'support',\n 'system',\n 'team',\n] as const;\n\nconst RESERVED = new Set<string>(RESERVED_USERNAMES);\n\nexport function isReservedUsername(handle: string): boolean {\n return RESERVED.has(handle.trim().toLowerCase());\n}\n\n/**\n * A handle a member is CHOOSING: the format, and not reserved.\n *\n * ⚠ Not used on read shapes, deliberately. A handle stored before the reserved\n * check existed must still READ, or one member's old handle would fail every\n * page that shows their name.\n */\nexport const chosenUsernameSchema = usernameSchema.refine((u) => !isReservedUsername(u), {\n error: VALIDATION_MESSAGE.usernameReserved,\n});\n\n// ─── Founder-facing enums ────────────────────────────────────────────────────\n\n/**\n * Stage of the founder's current venture.\n *\n * Ordered from earliest to latest so a range filter can compare by index. Add\n * new stages at the END or the stored ordinal of existing rows shifts.\n */\nexport const STARTUP_STAGE_VALUES = [\n 'idea',\n 'validation',\n 'mvp',\n 'pre_seed',\n 'seed',\n 'series_a_plus',\n 'profitable',\n 'exited',\n] as const;\nexport const startupStageSchema = z.enum(STARTUP_STAGE_VALUES);\nexport type StartupStage = z.infer<typeof startupStageSchema>;\n\n/**\n * Industry the venture operates in. Kept as a closed list so Discover filters\n * are a single indexed equality match rather than a text search.\n */\nexport const INDUSTRY_VALUES = [\n 'agritech',\n 'ai_ml',\n 'b2b_saas',\n 'consumer',\n 'climate',\n 'd2c',\n 'deeptech',\n 'edtech',\n 'fintech',\n 'gaming',\n 'healthtech',\n 'logistics',\n 'manufacturing',\n 'marketplace',\n 'media',\n 'mobility',\n 'proptech',\n 'robotics',\n 'services',\n 'space',\n 'other',\n] as const;\nexport const industrySchema = z.enum(INDUSTRY_VALUES);\nexport type Industry = z.infer<typeof industrySchema>;\n\n/**\n * What the founder brings. Free-text skills were considered and rejected: two\n * people typing \"growth\" and \"Growth Marketing\" would never match each other in\n * a filter, and the Discover tab is the whole point of the product.\n */\nexport const SKILL_VALUES = [\n 'engineering',\n 'product',\n 'design',\n 'data',\n 'growth',\n 'sales',\n 'marketing',\n 'operations',\n 'finance',\n 'legal',\n 'hr_people',\n 'supply_chain',\n 'research',\n 'fundraising',\n 'community',\n] as const;\nexport const skillSchema = z.enum(SKILL_VALUES);\nexport type Skill = z.infer<typeof skillSchema>;\n\n/**\n * What the member is here for. ONE list, replacing three.\n *\n * This was `lookingFor` with six values. The design draws eight \"Goals &\n * Passion\" tiles and four more on the business step; all three were the same\n * idea at three grains, and shipping them together would have meant nobody\n * could say which one Discover filters on - the answer settled by whichever\n * query got written first. Merged on hary's ruling, 2026-09-21, while there\n * were still no rows in any database.\n *\n * ⚠️ Stored per member and used as a Mongoose enum. Append only; removing a\n * value orphans documents already saved with it.\n *\n * ⚠️ `start_company` READS ODDLY next to its neighbours and is correct. It was\n * queried on the grounds that it restates `venture.stage: 'idea'`; it does not,\n * and the reason is worth keeping so nobody re-opens it. Inferring it from the\n * venture works only for a member who ALREADY HAS a venture row, and somebody\n * who wants to start a company is precisely the member with no venture at all,\n * so there is nothing to infer from. It also cannot express a founder with a\n * running company who wants to start another. Every other value is an ask of\n * other people and this one is an ask of the product, which is the whole of the\n * oddness. (hary, 2026-09-21.)\n */\nexport const GOAL_VALUES = [\n 'find_cofounder',\n 'find_investors',\n 'find_customers',\n 'hiring',\n 'partnerships',\n 'mentorship',\n 'grow_network',\n 'business_ideas',\n 'market_insights',\n 'learn_startup',\n 'start_company',\n] as const;\nexport const goalSchema = z.enum(GOAL_VALUES);\nexport type Goal = z.infer<typeof goalSchema>;\n\n// ─── Gender ──────────────────────────────────────────────────────────────────\n\n/**\n * Demographic only. Nothing here computes anything from it, unlike the sibling\n * gym product where gender feeds body-metric maths.\n *\n * Takes the family's three values and ADDS one rather than renaming any: a\n * network for founders should let somebody decline the question without having\n * to describe themselves as \"other\".\n */\nexport const GENDER_VALUES = ['male', 'female', 'other', 'prefer_not_to_say'] as const;\nexport const genderSchema = z.enum(GENDER_VALUES);\nexport type Gender = z.infer<typeof genderSchema>;\n\n// ─── Phone ───────────────────────────────────────────────────────────────────\n\n/**\n * Indian mobile number: ten raw digits, no country code, matching the standard\n * used across this family. Display adds the `+91`.\n *\n * ⚠️ WHERE IT IS STORED IS NOT A SCHEMA QUESTION, and it has been ruled: the\n * phone lives on the account mirror, never as a second copy on the profile.\n * Two stored copies drift the first time somebody edits one, and the profile's\n * would be the copy nobody keeps in sync.\n */\nexport const phoneSchema = z\n .string()\n .trim()\n .regex(/^[6-9]\\d{9}$/, VALIDATION_MESSAGE.phoneInvalid);\nexport type Phone = z.infer<typeof phoneSchema>;\n\n// ─── Location ────────────────────────────────────────────────────────────────\n\n/**\n * Where the founder is based. City and country are what Discover filters on.\n *\n * ONE SHAPE for the profile, the Me tab's patch, onboarding's finish and the\n * location suggestion. It was written out three times until 0.2.0, the same\n * kind of copy that let the profile model lose Instagram.\n *\n * ⚠ `country` is REQUIRED and has NO default. A default here (it used to be\n * `'India'`) is destructive on every write path: a founder in Berlin who\n * corrects only their city would be moved to India by a value they never saw.\n * A body that carries a location carries the whole location. Every stored\n * location has a country, because the model requires one, so the read side\n * loses nothing.\n *\n * This is a DECLARED location, not a GPS reading. Jansathi's rule applies\n * here too: never write a declared location from a GPS capture.\n */\nexport const locationSchema = z.object({\n city: z.string().trim().min(1).max(CITY_MAX),\n state: optionalTrimmedString(STATE_MAX),\n country: z.string().trim().min(1).max(COUNTRY_MAX),\n});\nexport type FounderLocation = z.infer<typeof locationSchema>;\n\n/**\n * `GET /api/v1/location/areas/reverse?lat=&lng=`: plain decimal degrees.\n *\n * A QUERY, so coercion is right here: everything in a URL is a string. An\n * emptied parameter is \"missing\", never 0, because 0,0 is a real place in the\n * Gulf of Guinea and would be looked up rather than refused.\n */\nexport const coordinatesQuerySchema = z.object({\n lat: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-90).max(90)),\n lng: z.preprocess(emptyStringToUndefined, z.coerce.number().min(-180).max(180)),\n});\nexport type CoordinatesQuery = z.infer<typeof coordinatesQuerySchema>;\nexport type CoordinatesQueryInput = z.input<typeof coordinatesQuerySchema>;\n\n/**\n * The city these coordinates look like, for the member to CONFIRM, or `null`\n * when the lookup could not place them confidently (or they are outside India,\n * whose area data is all there is). A suggestion, never a write: only what the\n * member then submits becomes their location.\n */\nexport const suggestedLocationSchema = locationSchema.nullable();\nexport type SuggestedLocation = z.infer<typeof suggestedLocationSchema>;\n\n// ─── Pagination ──────────────────────────────────────────────────────────────\n\n/**\n * Cursor pagination query. Feed and Discover both use it.\n *\n * Cursor, not page number: the community feed changes under the reader, and an\n * offset would repeat or skip posts as new ones land.\n */\nexport const paginationQuerySchema = z.object({\n /**\n * `?limit=` with nothing after it is treated as \"not provided\", not as a\n * validation failure. A client that clears the control sends the empty\n * string, and `z.coerce.number()` turns that into 0, which then fails\n * `.min(1)`: the member sees a 400 for a page they never asked to size.\n */\n limit: z.preprocess(\n emptyStringToUndefined,\n z.coerce.number().int().min(1).max(PAGE_SIZE_MAX).default(PAGE_SIZE_DEFAULT),\n ),\n cursor: z.string().trim().min(1).optional(),\n});\nexport type PaginationQuery = z.infer<typeof paginationQuerySchema>;\n/** What a client may put IN the URL: every field optional, numbers as strings. */\nexport type PaginationQueryInput = z.input<typeof paginationQuerySchema>;\n"]}
@@ -76,10 +76,105 @@ export declare const discoverQuerySchema: z.ZodObject<{
76
76
  country: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
77
77
  }, z.core.$strip>;
78
78
  export type DiscoverQuery = z.infer<typeof discoverQuerySchema>;
79
+ export type DiscoverQueryInput = z.input<typeof discoverQuerySchema>;
80
+ /** A page of founder cards, most recently updated first. */
81
+ export declare const discoverPageSchema: z.ZodObject<{
82
+ items: z.ZodArray<z.ZodObject<{
83
+ location: z.ZodOptional<z.ZodObject<{
84
+ city: z.ZodString;
85
+ state: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
86
+ country: z.ZodString;
87
+ }, z.core.$strip>>;
88
+ username: z.ZodString;
89
+ displayName: z.ZodString;
90
+ avatarUrl: z.ZodOptional<z.ZodURL>;
91
+ id: z.ZodString;
92
+ headline: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
93
+ venture: z.ZodOptional<z.ZodObject<{
94
+ name: z.ZodString;
95
+ tagline: z.ZodPreprocess<z.ZodOptional<z.ZodString>, unknown>;
96
+ website: z.ZodOptional<z.ZodURL>;
97
+ industry: z.ZodEnum<{
98
+ agritech: "agritech";
99
+ ai_ml: "ai_ml";
100
+ b2b_saas: "b2b_saas";
101
+ consumer: "consumer";
102
+ climate: "climate";
103
+ d2c: "d2c";
104
+ deeptech: "deeptech";
105
+ edtech: "edtech";
106
+ fintech: "fintech";
107
+ gaming: "gaming";
108
+ healthtech: "healthtech";
109
+ logistics: "logistics";
110
+ manufacturing: "manufacturing";
111
+ marketplace: "marketplace";
112
+ media: "media";
113
+ mobility: "mobility";
114
+ proptech: "proptech";
115
+ robotics: "robotics";
116
+ services: "services";
117
+ space: "space";
118
+ other: "other";
119
+ }>;
120
+ stage: z.ZodEnum<{
121
+ idea: "idea";
122
+ validation: "validation";
123
+ mvp: "mvp";
124
+ pre_seed: "pre_seed";
125
+ seed: "seed";
126
+ series_a_plus: "series_a_plus";
127
+ profitable: "profitable";
128
+ exited: "exited";
129
+ }>;
130
+ teamSize: z.ZodOptional<z.ZodNumber>;
131
+ foundedYear: z.ZodOptional<z.ZodNumber>;
132
+ }, z.core.$strip>>;
133
+ skills: z.ZodDefault<z.ZodArray<z.ZodEnum<{
134
+ engineering: "engineering";
135
+ product: "product";
136
+ design: "design";
137
+ data: "data";
138
+ growth: "growth";
139
+ sales: "sales";
140
+ marketing: "marketing";
141
+ operations: "operations";
142
+ finance: "finance";
143
+ legal: "legal";
144
+ hr_people: "hr_people";
145
+ supply_chain: "supply_chain";
146
+ research: "research";
147
+ fundraising: "fundraising";
148
+ community: "community";
149
+ }>>>;
150
+ goals: z.ZodDefault<z.ZodArray<z.ZodEnum<{
151
+ find_cofounder: "find_cofounder";
152
+ find_investors: "find_investors";
153
+ find_customers: "find_customers";
154
+ hiring: "hiring";
155
+ partnerships: "partnerships";
156
+ mentorship: "mentorship";
157
+ grow_network: "grow_network";
158
+ business_ideas: "business_ideas";
159
+ market_insights: "market_insights";
160
+ learn_startup: "learn_startup";
161
+ start_company: "start_company";
162
+ }>>>;
163
+ }, z.core.$strip>>;
164
+ nextCursor: z.ZodNullable<z.ZodString>;
165
+ }, z.core.$strip>;
166
+ export type DiscoverPage = z.infer<typeof discoverPageSchema>;
79
167
  /**
80
- * The filter set the Discover UI renders. Served by the backend rather than
81
- * hard-coded in each client, so adding an industry does not need three
82
- * releases.
168
+ * The filter options the Discover drawer offers, with counts. The VALUES come
169
+ * from the enums here, so the options cannot disagree with what the query
170
+ * accepts.
171
+ *
172
+ * ⚠ NO LABELS. The value IS the translation key (`industries.<value>`,
173
+ * `stages.<value>`, `skills.<value>`), and a client words it in the member's
174
+ * language. Until 0.2.0 the server also sent an English `label` per option,
175
+ * built by capitalising the value ("Ai ml"), which no client read and which
176
+ * would have been wrong in Hindi if one had. Cities are member-typed place
177
+ * names, so their value is shown as it is.
83
178
  */
84
179
  export declare const discoverFacetsSchema: z.ZodObject<{
85
180
  industries: z.ZodArray<z.ZodObject<{
@@ -106,7 +201,6 @@ export declare const discoverFacetsSchema: z.ZodObject<{
106
201
  space: "space";
107
202
  other: "other";
108
203
  }>;
109
- label: z.ZodString;
110
204
  count: z.ZodNumber;
111
205
  }, z.core.$strip>>;
112
206
  skills: z.ZodArray<z.ZodObject<{
@@ -127,7 +221,6 @@ export declare const discoverFacetsSchema: z.ZodObject<{
127
221
  fundraising: "fundraising";
128
222
  community: "community";
129
223
  }>;
130
- label: z.ZodString;
131
224
  count: z.ZodNumber;
132
225
  }, z.core.$strip>>;
133
226
  stages: z.ZodArray<z.ZodObject<{
@@ -141,7 +234,6 @@ export declare const discoverFacetsSchema: z.ZodObject<{
141
234
  profitable: "profitable";
142
235
  exited: "exited";
143
236
  }>;
144
- label: z.ZodString;
145
237
  count: z.ZodNumber;
146
238
  }, z.core.$strip>>;
147
239
  cities: z.ZodArray<z.ZodObject<{
@@ -1 +1 @@
1
- {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAsBxB;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAQ9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAEhE;;;;GAIG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAS/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
1
+ {"version":3,"file":"discover.d.ts","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAyBxB;;;;;;;;;GASG;AACH,eAAO,MAAM,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAQ9B,CAAC;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAChE,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAErE,4DAA4D;AAC5D,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAAqC,CAAC;AACrE,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAM9D;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAK/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC"}
package/dist/discover.js CHANGED
@@ -9,6 +9,9 @@
9
9
  */
10
10
  import { z } from 'zod';
11
11
  import { industrySchema, optionalTrimmedString, paginationQuerySchema, skillSchema, startupStageSchema, } from './common.js';
12
+ import { founderCardSchema } from './founder-profile.js';
13
+ import { DISCOVER_FILTER_VALUES_MAX, DISCOVER_TEXT_MAX } from './limits.js';
14
+ import { paginatedSchema } from './responses/common.js';
12
15
  /**
13
16
  * Accepts a repeated query param (`?skill=growth&skill=sales`) or a single
14
17
  * value, and always produces an array.
@@ -20,7 +23,7 @@ const multiValue = (inner) => z.preprocess((value) => {
20
23
  if (value === undefined || value === null)
21
24
  return undefined;
22
25
  return Array.isArray(value) ? value : [value];
23
- }, z.array(inner).max(10).optional());
26
+ }, z.array(inner).max(DISCOVER_FILTER_VALUES_MAX).optional());
24
27
  /**
25
28
  * Every free-text filter here is `optionalTrimmedString`, so an EMPTY one is
26
29
  * treated as "not provided" rather than as a validation failure.
@@ -33,22 +36,33 @@ const multiValue = (inner) => z.preprocess((value) => {
33
36
  */
34
37
  export const discoverQuerySchema = paginationQuerySchema.extend({
35
38
  /** Free-text name search. Matched against display name and username. */
36
- q: optionalTrimmedString(80),
39
+ q: optionalTrimmedString(DISCOVER_TEXT_MAX),
37
40
  industry: multiValue(industrySchema),
38
41
  skill: multiValue(skillSchema),
39
42
  stage: multiValue(startupStageSchema),
40
- city: optionalTrimmedString(80),
41
- country: optionalTrimmedString(80),
43
+ city: optionalTrimmedString(DISCOVER_TEXT_MAX),
44
+ country: optionalTrimmedString(DISCOVER_TEXT_MAX),
42
45
  });
46
+ /** A page of founder cards, most recently updated first. */
47
+ export const discoverPageSchema = paginatedSchema(founderCardSchema);
48
+ /** One filter option and how many listed founders match it. */
49
+ const facetOption = (value) => z.object({ value, count: z.number().int().nonnegative() });
43
50
  /**
44
- * The filter set the Discover UI renders. Served by the backend rather than
45
- * hard-coded in each client, so adding an industry does not need three
46
- * releases.
51
+ * The filter options the Discover drawer offers, with counts. The VALUES come
52
+ * from the enums here, so the options cannot disagree with what the query
53
+ * accepts.
54
+ *
55
+ * ⚠ NO LABELS. The value IS the translation key (`industries.<value>`,
56
+ * `stages.<value>`, `skills.<value>`), and a client words it in the member's
57
+ * language. Until 0.2.0 the server also sent an English `label` per option,
58
+ * built by capitalising the value ("Ai ml"), which no client read and which
59
+ * would have been wrong in Hindi if one had. Cities are member-typed place
60
+ * names, so their value is shown as it is.
47
61
  */
48
62
  export const discoverFacetsSchema = z.object({
49
- industries: z.array(z.object({ value: industrySchema, label: z.string(), count: z.number().int() })),
50
- skills: z.array(z.object({ value: skillSchema, label: z.string(), count: z.number().int() })),
51
- stages: z.array(z.object({ value: startupStageSchema, label: z.string(), count: z.number().int() })),
52
- cities: z.array(z.object({ value: z.string(), count: z.number().int() })),
63
+ industries: z.array(facetOption(industrySchema)),
64
+ skills: z.array(facetOption(skillSchema)),
65
+ stages: z.array(facetOption(startupStageSchema)),
66
+ cities: z.array(facetOption(z.string())),
53
67
  });
54
68
  //# sourceMappingURL=discover.js.map