founder-schema 0.1.0 → 0.2.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.
- package/README.md +112 -40
- package/dist/chat.d.ts +63 -15
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +55 -17
- package/dist/chat.js.map +1 -1
- package/dist/common.d.ts +84 -8
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +109 -17
- package/dist/common.js.map +1 -1
- package/dist/discover.d.ts +98 -6
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +25 -11
- package/dist/discover.js.map +1 -1
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +89 -0
- package/dist/errors.js.map +1 -0
- package/dist/feed.d.ts +400 -52
- package/dist/feed.d.ts.map +1 -1
- package/dist/feed.js +98 -68
- package/dist/feed.js.map +1 -1
- package/dist/founder-profile.d.ts +76 -46
- package/dist/founder-profile.d.ts.map +1 -1
- package/dist/founder-profile.js +46 -46
- package/dist/founder-profile.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +76 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +84 -0
- package/dist/limits.js.map +1 -0
- package/dist/onboarding.d.ts +407 -53
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +281 -234
- package/dist/onboarding.js.map +1 -1
- package/dist/responses/common.d.ts +122 -4
- package/dist/responses/common.d.ts.map +1 -1
- package/dist/responses/common.js +26 -3
- package/dist/responses/common.js.map +1 -1
- package/dist/responses/index.d.ts +1 -1
- package/dist/responses/index.d.ts.map +1 -1
- package/dist/responses/index.js +1 -1
- package/dist/responses/index.js.map +1 -1
- package/dist/uploads.d.ts +113 -0
- package/dist/uploads.d.ts.map +1 -0
- package/dist/uploads.js +106 -0
- package/dist/uploads.js.map +1 -0
- package/dist/validation.d.ts +136 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +142 -0
- package/dist/validation.js.map +1 -0
- package/package.json +4 -3
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,
|
|
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
|
|
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
|
|
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.
|
|
76
|
-
*
|
|
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
|
-
|
|
85
|
-
.
|
|
86
|
-
.
|
|
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}$/,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
224
|
-
*
|
|
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(
|
|
228
|
-
state: optionalTrimmedString(
|
|
229
|
-
country: z.string().trim().min(1).max(
|
|
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(
|
|
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
|
package/dist/common.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"common.js","sourceRoot":"","sources":["../src/common.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,gFAAgF;AAEhF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,CAAC,KAAc,EAAW,EAAE;IAChE,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE;QAAE,OAAO,SAAS,CAAC;IACvE,OAAO,KAAK,CAAC;AACf,CAAC,CAAC;AAEF,yEAAyE;AACzE,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,SAAS,GAAG,GAAG,EAAE,EAAE,CACvD,CAAC,CAAC,UAAU,CAAC,sBAAsB,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;AAEpF,4EAA4E;AAC5E,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC;KAC5B,MAAM,EAAE;KACR,KAAK,CAAC,iBAAiB,EAAE,YAAY,CAAC;KACtC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;AAErC,gFAAgF;AAEhF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,CAAC,GAAG,CAAC;IACjC,QAAQ,EAAE,UAAU;IACpB,QAAQ,EAAE,CAAC,CAAC,OAAO,CAAC,MAAM;CAC3B,CAAC,CAAC;AAEH,gFAAgF;AAEhF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,CAAC;AAC9B,MAAM,CAAC,MAAM,YAAY,GAAG,EAAE,CAAC;AAE/B,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC;KAC5B,MAAM,EAAE;KACR,IAAI,EAAE;KACN,WAAW,EAAE;KACb,GAAG,CAAC,YAAY,EAAE,6BAA6B,YAAY,aAAa,CAAC;KACzE,GAAG,CAAC,YAAY,EAAE,4BAA4B,YAAY,aAAa,CAAC;KACxE,KAAK,CACJ,kCAAkC,EAClC,uFAAuF,CACxF,CAAC;AAGJ,gFAAgF;AAEhF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,MAAM;IACN,YAAY;IACZ,KAAK;IACL,UAAU;IACV,MAAM;IACN,eAAe;IACf,YAAY;IACZ,QAAQ;CACA,CAAC;AACX,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC;AAG/D;;;GAGG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,UAAU;IACV,OAAO;IACP,UAAU;IACV,UAAU;IACV,SAAS;IACT,KAAK;IACL,UAAU;IACV,QAAQ;IACR,SAAS;IACT,QAAQ;IACR,YAAY;IACZ,WAAW;IACX,eAAe;IACf,aAAa;IACb,OAAO;IACP,UAAU;IACV,UAAU;IACV,UAAU;IACV,UAAU;IACV,OAAO;IACP,OAAO;CACC,CAAC;AACX,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;AAGtD;;;;GAIG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG;IAC1B,aAAa;IACb,SAAS;IACT,QAAQ;IACR,MAAM;IACN,QAAQ;IACR,OAAO;IACP,WAAW;IACX,YAAY;IACZ,SAAS;IACT,OAAO;IACP,WAAW;IACX,cAAc;IACd,UAAU;IACV,aAAa;IACb,WAAW;CACH,CAAC;AACX,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;AAGhD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG;IACzB,gBAAgB;IAChB,gBAAgB;IAChB,gBAAgB;IAChB,QAAQ;IACR,cAAc;IACd,YAAY;IACZ,cAAc;IACd,gBAAgB;IAChB,iBAAiB;IACjB,eAAe;IACf,eAAe;CACP,CAAC;AACX,MAAM,CAAC,MAAM,UAAU,GAAG,CAAC,CAAC,IAAI,CAAC,WAAW,CAAC,CAAC;AAG9C,gFAAgF;AAEhF;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,mBAAmB,CAAU,CAAC;AACvF,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;AAGlD,gFAAgF;AAEhF;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC;KACzB,MAAM,EAAE;KACR,IAAI,EAAE;KACN,KAAK,CAAC,cAAc,EAAE,gCAAgC,CAAC,CAAC;AAE3D,gFAAgF;AAEhF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC,MAAM,CAAC;IACrC,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;IACtC,KAAK,EAAE,qBAAqB,CAAC,EAAE,CAAC;IAChC,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;CAC3D,CAAC,CAAC;AAGH,gFAAgF;AAEhF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC5C;;;;;OAKG;IACH,KAAK,EAAE,CAAC,CAAC,UAAU,CAAC,sBAAsB,EAAE,CAAC,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IAC/F,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,IAAI,EAAE,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,EAAE;CAC5C,CAAC,CAAC","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"]}
|
package/dist/discover.d.ts
CHANGED
|
@@ -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
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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<{
|
package/dist/discover.d.ts.map
CHANGED
|
@@ -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;
|
|
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(
|
|
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(
|
|
39
|
+
q: optionalTrimmedString(DISCOVER_TEXT_MAX),
|
|
37
40
|
industry: multiValue(industrySchema),
|
|
38
41
|
skill: multiValue(skillSchema),
|
|
39
42
|
stage: multiValue(startupStageSchema),
|
|
40
|
-
city: optionalTrimmedString(
|
|
41
|
-
country: optionalTrimmedString(
|
|
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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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(
|
|
50
|
-
skills: z.array(
|
|
51
|
-
stages: z.array(
|
|
52
|
-
cities: z.array(
|
|
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
|
package/dist/discover.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover.js","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,WAAW,EACX,kBAAkB,GACnB,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"discover.js","sourceRoot":"","sources":["../src/discover.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,qBAAqB,EACrB,WAAW,EACX,kBAAkB,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EAAE,0BAA0B,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AAExD;;;;;;GAMG;AACH,MAAM,UAAU,GAAG,CAAsB,KAAQ,EAAE,EAAE,CACnD,CAAC,CAAC,UAAU,CAAC,CAAC,KAAK,EAAE,EAAE;IACrB,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,SAAS,CAAC;IAC5D,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;AAChD,CAAC,EAAE,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,0BAA0B,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC;AAEhE;;;;;;;;;GASG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG,qBAAqB,CAAC,MAAM,CAAC;IAC9D,wEAAwE;IACxE,CAAC,EAAE,qBAAqB,CAAC,iBAAiB,CAAC;IAC3C,QAAQ,EAAE,UAAU,CAAC,cAAc,CAAC;IACpC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC;IAC9B,KAAK,EAAE,UAAU,CAAC,kBAAkB,CAAC;IACrC,IAAI,EAAE,qBAAqB,CAAC,iBAAiB,CAAC;IAC9C,OAAO,EAAE,qBAAqB,CAAC,iBAAiB,CAAC;CAClD,CAAC,CAAC;AAIH,4DAA4D;AAC5D,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAC,iBAAiB,CAAC,CAAC;AAGrE,+DAA+D;AAC/D,MAAM,WAAW,GAAG,CAAsB,KAAQ,EAAE,EAAE,CACpD,CAAC,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,WAAW,EAAE,EAAE,CAAC,CAAC;AAE7D;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG,CAAC,CAAC,MAAM,CAAC;IAC3C,UAAU,EAAE,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,cAAc,CAAC,CAAC;IAChD,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,WAAW,CAAC,CAAC;IACzC,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,kBAAkB,CAAC,CAAC;IAChD,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;CACzC,CAAC,CAAC","sourcesContent":["/**\n * founder-schema — Discover search and filters\n *\n * Search is by name; the filters are industry, skill, startup stage and\n * location. The community surface the Discover tab will grow later is not\n * modelled yet.\n *\n * @module founder-schema/discover\n */\n\nimport { z } from 'zod';\nimport {\n industrySchema,\n optionalTrimmedString,\n paginationQuerySchema,\n skillSchema,\n startupStageSchema,\n} from './common.js';\nimport { founderCardSchema } from './founder-profile.js';\nimport { DISCOVER_FILTER_VALUES_MAX, DISCOVER_TEXT_MAX } from './limits.js';\nimport { paginatedSchema } from './responses/common.js';\n\n/**\n * Accepts a repeated query param (`?skill=growth&skill=sales`) or a single\n * value, and always produces an array.\n *\n * Express gives `string` for one occurrence and `string[]` for several, so\n * without this the same URL shape parses two different ways.\n */\nconst multiValue = <T extends z.ZodType>(inner: T) =>\n z.preprocess((value) => {\n if (value === undefined || value === null) return undefined;\n return Array.isArray(value) ? value : [value];\n }, z.array(inner).max(DISCOVER_FILTER_VALUES_MAX).optional());\n\n/**\n * Every free-text filter here is `optionalTrimmedString`, so an EMPTY one is\n * treated as \"not provided\" rather than as a validation failure.\n *\n * A search box the member has just cleared sends `?q=`, and a select they reset\n * sends `?city=`. Refusing those turns an ordinary interaction into a 400 with\n * nothing useful to show for it: the member asked for fewer filters, not for an\n * error. This is the one place the create-body rule and the patch-body rule\n * agree, because a query has nothing to erase.\n */\nexport const discoverQuerySchema = paginationQuerySchema.extend({\n /** Free-text name search. Matched against display name and username. */\n q: optionalTrimmedString(DISCOVER_TEXT_MAX),\n industry: multiValue(industrySchema),\n skill: multiValue(skillSchema),\n stage: multiValue(startupStageSchema),\n city: optionalTrimmedString(DISCOVER_TEXT_MAX),\n country: optionalTrimmedString(DISCOVER_TEXT_MAX),\n});\nexport type DiscoverQuery = z.infer<typeof discoverQuerySchema>;\nexport type DiscoverQueryInput = z.input<typeof discoverQuerySchema>;\n\n/** A page of founder cards, most recently updated first. */\nexport const discoverPageSchema = paginatedSchema(founderCardSchema);\nexport type DiscoverPage = z.infer<typeof discoverPageSchema>;\n\n/** One filter option and how many listed founders match it. */\nconst facetOption = <T extends z.ZodType>(value: T) =>\n z.object({ value, count: z.number().int().nonnegative() });\n\n/**\n * The filter options the Discover drawer offers, with counts. The VALUES come\n * from the enums here, so the options cannot disagree with what the query\n * accepts.\n *\n * ⚠ NO LABELS. The value IS the translation key (`industries.<value>`,\n * `stages.<value>`, `skills.<value>`), and a client words it in the member's\n * language. Until 0.2.0 the server also sent an English `label` per option,\n * built by capitalising the value (\"Ai ml\"), which no client read and which\n * would have been wrong in Hindi if one had. Cities are member-typed place\n * names, so their value is shown as it is.\n */\nexport const discoverFacetsSchema = z.object({\n industries: z.array(facetOption(industrySchema)),\n skills: z.array(facetOption(skillSchema)),\n stages: z.array(facetOption(startupStageSchema)),\n cities: z.array(facetOption(z.string())),\n});\nexport type DiscoverFacets = z.infer<typeof discoverFacetsSchema>;\n"]}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* founder-schema — Every error code the API answers with
|
|
3
|
+
*
|
|
4
|
+
* THE CODE IS WHAT A CLIENT ACTS ON; the `message` beside it is English for
|
|
5
|
+
* logs and for a client too old to know the code. A client words each code in
|
|
6
|
+
* the member's language, and a test on the web side asserts every value here
|
|
7
|
+
* has a message in every locale it ships.
|
|
8
|
+
*
|
|
9
|
+
* Before 0.2.0 the backend kept this list to itself, and every domain refusal
|
|
10
|
+
* (a closed poll, a reply to a reply, the edit window) went out as
|
|
11
|
+
* `VALIDATION_ERROR` with an English sentence, so the only way for a client to
|
|
12
|
+
* tell them apart was to match on the wording.
|
|
13
|
+
*
|
|
14
|
+
* WHEN TO USE WHICH:
|
|
15
|
+
* - A request that does not match its schema: `VALIDATION_ERROR`, with every
|
|
16
|
+
* problem in `error.details.issues`.
|
|
17
|
+
* - A well-formed request the current STATE of things refuses: its own code
|
|
18
|
+
* below. These carry no issues; the code says it all.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ Append freely; remove deliberately. A code removed here that an older
|
|
21
|
+
* server still sends fails the client's parse of the error envelope.
|
|
22
|
+
*
|
|
23
|
+
* @module founder-schema/errors
|
|
24
|
+
*/
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
/**
|
|
27
|
+
* As a tuple, the way `GENDER_VALUES` is, so a consumer can iterate it (a
|
|
28
|
+
* translation-coverage test does exactly that) and TypeScript keeps the union.
|
|
29
|
+
*/
|
|
30
|
+
export declare const API_ERROR_CODE_VALUES: readonly ["INTERNAL_ERROR", "VALIDATION_ERROR", "NOT_FOUND", "CONFLICT", "RATE_LIMITED", "SERVICE_UNAVAILABLE", "UNAUTHORIZED", "INVALID_TOKEN", "TOKEN_EXPIRED", "SESSION_INVALIDATED", "FORBIDDEN", "ONBOARDING_INCOMPLETE", "ALREADY_ONBOARDED", "UNDER_AGE", "UPLOAD_NOT_ATTACHABLE", "UPLOAD_TOO_LARGE", "UPLOAD_WRONG_TYPE", "EDIT_WINDOW_CLOSED", "POLL_NOT_EDITABLE", "POLL_CLOSED", "POLL_CHOICE_INVALID", "REPLY_TOO_DEEP", "CANNOT_REPORT_OWN", "CHAT_WITH_SELF"];
|
|
31
|
+
export declare const apiErrorCodeSchema: z.ZodEnum<{
|
|
32
|
+
INTERNAL_ERROR: "INTERNAL_ERROR";
|
|
33
|
+
VALIDATION_ERROR: "VALIDATION_ERROR";
|
|
34
|
+
NOT_FOUND: "NOT_FOUND";
|
|
35
|
+
CONFLICT: "CONFLICT";
|
|
36
|
+
RATE_LIMITED: "RATE_LIMITED";
|
|
37
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
|
|
38
|
+
UNAUTHORIZED: "UNAUTHORIZED";
|
|
39
|
+
INVALID_TOKEN: "INVALID_TOKEN";
|
|
40
|
+
TOKEN_EXPIRED: "TOKEN_EXPIRED";
|
|
41
|
+
SESSION_INVALIDATED: "SESSION_INVALIDATED";
|
|
42
|
+
FORBIDDEN: "FORBIDDEN";
|
|
43
|
+
ONBOARDING_INCOMPLETE: "ONBOARDING_INCOMPLETE";
|
|
44
|
+
ALREADY_ONBOARDED: "ALREADY_ONBOARDED";
|
|
45
|
+
UNDER_AGE: "UNDER_AGE";
|
|
46
|
+
UPLOAD_NOT_ATTACHABLE: "UPLOAD_NOT_ATTACHABLE";
|
|
47
|
+
UPLOAD_TOO_LARGE: "UPLOAD_TOO_LARGE";
|
|
48
|
+
UPLOAD_WRONG_TYPE: "UPLOAD_WRONG_TYPE";
|
|
49
|
+
EDIT_WINDOW_CLOSED: "EDIT_WINDOW_CLOSED";
|
|
50
|
+
POLL_NOT_EDITABLE: "POLL_NOT_EDITABLE";
|
|
51
|
+
POLL_CLOSED: "POLL_CLOSED";
|
|
52
|
+
POLL_CHOICE_INVALID: "POLL_CHOICE_INVALID";
|
|
53
|
+
REPLY_TOO_DEEP: "REPLY_TOO_DEEP";
|
|
54
|
+
CANNOT_REPORT_OWN: "CANNOT_REPORT_OWN";
|
|
55
|
+
CHAT_WITH_SELF: "CHAT_WITH_SELF";
|
|
56
|
+
}>;
|
|
57
|
+
export type ApiErrorCode = z.infer<typeof apiErrorCodeSchema>;
|
|
58
|
+
/** The same codes by name: `API_ERROR_CODE.POLL_CLOSED`. */
|
|
59
|
+
export declare const API_ERROR_CODE: {
|
|
60
|
+
INTERNAL_ERROR: "INTERNAL_ERROR";
|
|
61
|
+
VALIDATION_ERROR: "VALIDATION_ERROR";
|
|
62
|
+
NOT_FOUND: "NOT_FOUND";
|
|
63
|
+
CONFLICT: "CONFLICT";
|
|
64
|
+
RATE_LIMITED: "RATE_LIMITED";
|
|
65
|
+
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE";
|
|
66
|
+
UNAUTHORIZED: "UNAUTHORIZED";
|
|
67
|
+
INVALID_TOKEN: "INVALID_TOKEN";
|
|
68
|
+
TOKEN_EXPIRED: "TOKEN_EXPIRED";
|
|
69
|
+
SESSION_INVALIDATED: "SESSION_INVALIDATED";
|
|
70
|
+
FORBIDDEN: "FORBIDDEN";
|
|
71
|
+
ONBOARDING_INCOMPLETE: "ONBOARDING_INCOMPLETE";
|
|
72
|
+
ALREADY_ONBOARDED: "ALREADY_ONBOARDED";
|
|
73
|
+
UNDER_AGE: "UNDER_AGE";
|
|
74
|
+
UPLOAD_NOT_ATTACHABLE: "UPLOAD_NOT_ATTACHABLE";
|
|
75
|
+
UPLOAD_TOO_LARGE: "UPLOAD_TOO_LARGE";
|
|
76
|
+
UPLOAD_WRONG_TYPE: "UPLOAD_WRONG_TYPE";
|
|
77
|
+
EDIT_WINDOW_CLOSED: "EDIT_WINDOW_CLOSED";
|
|
78
|
+
POLL_NOT_EDITABLE: "POLL_NOT_EDITABLE";
|
|
79
|
+
POLL_CLOSED: "POLL_CLOSED";
|
|
80
|
+
POLL_CHOICE_INVALID: "POLL_CHOICE_INVALID";
|
|
81
|
+
REPLY_TOO_DEEP: "REPLY_TOO_DEEP";
|
|
82
|
+
CANNOT_REPORT_OWN: "CANNOT_REPORT_OWN";
|
|
83
|
+
CHAT_WITH_SELF: "CHAT_WITH_SELF";
|
|
84
|
+
};
|
|
85
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;GAGG;AACH,eAAO,MAAM,qBAAqB,8cA4DxB,CAAC;AAEX,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;EAAgC,CAAC;AAChE,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,4DAA4D;AAC5D,eAAO,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;CAA0B,CAAC"}
|