@thejob/schema 2.1.8 → 2.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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Reusable builders for FILTER FACETS, shared by every filter schema.
3
+ *
4
+ * These describe the SHAPE a facet takes, not what it means: a token list, a
5
+ * closed enum list, a relative window in days. Nothing here knows about users,
6
+ * jobs or groups, which is why they live in `common/` rather than beside the
7
+ * first filter that happened to need them.
8
+ *
9
+ * Two conventions are load-bearing and easy to undo by accident:
10
+ *
11
+ * • LIST facets `.default([])`, so a validated filter always has a concrete
12
+ * array and a consumer never writes `?? []`.
13
+ * • SCALAR facets `.default(undefined)`, NOT 0. "Unset" and "zero" are
14
+ * different questions — `days(0)` means "today", and defaulting to 0 would
15
+ * turn an absent window into a filter that matches almost nobody.
16
+ *
17
+ * A facet that a filter declares but a service does not accept is a 400 on a
18
+ * request that validated cleanly where it was built, so filter schemas are the
19
+ * single declaration services derive their vocabulary from rather than restate.
20
+ */
21
+ import { array, boolean, number, object, string } from "yup";
22
+
23
+ /**
24
+ * An open vocabulary: skills, companies, institutes. Any token matches (OR
25
+ * within a facet); facets AND together.
26
+ */
27
+ export const tokens = (label: string) =>
28
+ array().of(string().trim().required()).optional().default([]).label(label);
29
+
30
+ /**
31
+ * An open vocabulary of CODES, normalised to uppercase.
32
+ *
33
+ * For short closed-ish codes stored uppercase (ISO country, state). The
34
+ * service matches these case-insensitively anyway, so this is belt-and-braces
35
+ * — but a filter that reads back `["in"]` after saving looks broken to an
36
+ * admin, and any consumer comparing the saved value to a stored one would be
37
+ * comparing "in" against "IN".
38
+ */
39
+ export const codeTokens = (label: string) =>
40
+ array()
41
+ .of(string().trim().uppercase().required())
42
+ .optional()
43
+ .default([])
44
+ .label(label);
45
+
46
+ /** A closed vocabulary, rendered as a chip picker. */
47
+ export const enumFacet = (values: readonly string[], label: string) =>
48
+ array()
49
+ .of(string().oneOf(values).required())
50
+ .optional()
51
+ .default([])
52
+ .label(label);
53
+
54
+ /**
55
+ * A relative window in whole days, resolved to an absolute bound at query time.
56
+ *
57
+ * Relative rather than absolute because a saved filter is re-resolved on every
58
+ * run: "within 30 days" keeps meaning what its author intended, while a stored
59
+ * timestamp rots. Bounded so a typo cannot become an unbounded scan.
60
+ */
61
+ export const days = (label: string) =>
62
+ number().integer().min(0).max(3650).optional().default(undefined).label(label);
63
+
64
+ /** A relative window in whole months. See `days`. */
65
+ export const months = (label: string) =>
66
+ number().integer().min(0).max(1200).optional().default(undefined).label(label);
67
+
68
+ /** A 0-100 percentage bound. */
69
+ export const percent = (label: string) =>
70
+ number().integer().min(0).max(100).optional().default(undefined).label(label);
71
+
72
+ /**
73
+ * Absolute epoch milliseconds.
74
+ *
75
+ * `widget: "date"` because the STORED form and the ENTERED form are not the
76
+ * same thing. Epoch ms is right for storage and comparison — `createdAt` is an
77
+ * integer, and a coerced date string would not compare — but it is not
78
+ * something anyone types. Rendered as a plain number box, "8" is accepted and
79
+ * silently means 8ms after 1 Jan 1970, so a filter meant to say "this week"
80
+ * matches every record ever created and looks correctly configured doing it.
81
+ *
82
+ * The form converts; the wire format does not change.
83
+ */
84
+ export const epoch = (label: string) =>
85
+ number()
86
+ .integer()
87
+ .min(0)
88
+ .optional()
89
+ .default(undefined)
90
+ .label(label)
91
+ .meta({ widget: "date" });
92
+
93
+ /**
94
+ * A money bound that is only meaningful once the filter pins ONE currency.
95
+ *
96
+ * Salary is stored as a bare integer with the currency in a separate field, so
97
+ * "at least 50000" across USD/EUR/GBP/SEK/INR is not a comparison at all — it
98
+ * silently ranks a Swedish krona salary against a US dollar one. Rather than
99
+ * return a wrong set, the bound is rejected unless exactly one currency is
100
+ * selected.
101
+ *
102
+ * `currencyField` is a PARAMETER because the sibling facet is named differently
103
+ * per resource: a user filter pins `salaryCurrencies` against the salary a
104
+ * candidate wants, while a job filter pins its own currency facet against the
105
+ * band a job advertises. Hardcoding one name would make this builder silently
106
+ * inert everywhere else — the `.when` would watch a field that does not exist,
107
+ * the test would never run, and cross-currency bounds would sail through.
108
+ */
109
+ export const currencyBound = (label: string, currencyField: string) =>
110
+ number()
111
+ .integer()
112
+ .min(0)
113
+ .optional()
114
+ .default(undefined)
115
+ .label(label)
116
+ .when(currencyField, {
117
+ is: (v: unknown) => !Array.isArray(v) || v.length !== 1,
118
+ then: (s) =>
119
+ s.test(
120
+ "requires-single-currency",
121
+ "Pick exactly one salary currency to filter by salary",
122
+ (value) => value === undefined || value === null,
123
+ ),
124
+ });
125
+
126
+ /**
127
+ * A facet over an array of OBJECTS, where the parts must describe the SAME entry.
128
+ *
129
+ * The problem this solves, measured on production users:
130
+ *
131
+ * skills[]=python & skillProficiencyLevels[]=expert -> 301 users
132
+ * one skill entry that is BOTH python AND expert -> 103 users
133
+ *
134
+ * Those 198 extra people are expert at something else and happen to also list
135
+ * Python. As two independent facets the query is `{'skills.name': python,
136
+ * 'skills.proficiencyLevel': expert}`, and Mongo satisfies each from a
137
+ * DIFFERENT array entry. English + expert over-counted by 1,755 the same way.
138
+ *
139
+ * It is silent: the count is plausible, the filter reads correctly, and the
140
+ * only symptom is mail reaching people who do not match what the admin chose.
141
+ *
142
+ * So the pair is ONE facet with the qualifier nested inside it, not two facets
143
+ * that a caller is trusted to combine. A single value cannot express the wrong
144
+ * query, which is the same reason `currencyBound` refuses a bound without
145
+ * exactly one currency: if a combination is only meaningful together, the
146
+ * schema should make the meaningless form unsayable rather than answer it
147
+ * wrongly.
148
+ *
149
+ * Each entry is `{ name, <qualifier> }`, where `name` is required (a qualifier
150
+ * alone means "expert at anything", which is not a segment anyone wants) and
151
+ * the qualifier is optional (naming the skill without a level is a perfectly
152
+ * good filter). Entries OR together, matching every other list facet.
153
+ */
154
+ export const qualified = (
155
+ qualifierValues: readonly string[] | "open",
156
+ qualifierKey: string,
157
+ label: string,
158
+ ) =>
159
+ array()
160
+ .of(
161
+ // `rejectUnknownKeys`, not `.noUnknown()`, for the reason spelled out on
162
+ // that helper: outside strict mode `.noUnknown()` silently STRIPS an
163
+ // unknown key rather than failing, so a misspelled qualifier would arrive
164
+ // as a name-only entry and quietly widen the match instead of erroring.
165
+ rejectUnknownKeys(
166
+ object({
167
+ name: string().trim().required().label("Name"),
168
+ // `"open"` for a free-text qualifier (a certification's issuing body).
169
+ // An empty array would NOT mean "anything goes" — `.oneOf([])` rejects
170
+ // every value — so the open case is named rather than inferred from an
171
+ // empty vocabulary.
172
+ [qualifierKey]:
173
+ qualifierValues === "open"
174
+ ? string().trim().optional().default(undefined).label(label)
175
+ : string()
176
+ .oneOf(qualifierValues)
177
+ .optional()
178
+ .default(undefined)
179
+ .label(label),
180
+ })
181
+ .label(label)
182
+ .required(),
183
+ () => ["name", qualifierKey],
184
+ ),
185
+ )
186
+ .optional()
187
+ .default([])
188
+ .label(label);
189
+
190
+ /**
191
+ * A facet over an array of objects where SEVERAL fields describe one entry and
192
+ * none of them is "the name" the others qualify.
193
+ *
194
+ * `qualified` above covers `{ name, <qualifier> }`, where the qualifier is
195
+ * meaningless alone ("expert" at nothing). This covers the other shape: one
196
+ * work-history entry is a company AND a job title, one education entry is an
197
+ * institute AND a course AND a field of study, and each part is a perfectly
198
+ * good filter on its own.
199
+ *
200
+ * That is why the flat `companies` / `designations` facets REMAIN beside this
201
+ * one rather than being replaced. "Worked at Google" and "was an engineer,
202
+ * somewhere" are real questions, and an admin asking for both usually does mean
203
+ * "has each of these somewhere in their history". What was missing is the
204
+ * narrower question — "was an engineer AT Google" — which as two flat facets
205
+ * silently also matched someone who was an engineer at Acme and a designer at
206
+ * Google. Measured on production: VIT + B.Tech returned 71 users where 64 had
207
+ * one education entry that was both.
208
+ *
209
+ * So this ADDS a way to say the precise thing; it does not take away the loose
210
+ * one. Every field is optional, but an entry must carry at least one — an
211
+ * all-empty entry would match every row and silently widen the audience, which
212
+ * is the failure this whole family of facets exists to prevent. Entries OR
213
+ * together, like every other list facet.
214
+ *
215
+ * ── Field types ─────────────────────────────────────────────────────────────
216
+ * A field is text by default. Pass an object to type it instead:
217
+ *
218
+ * conjunction(["company", "designation", { name: "isCurrent", type: "boolean" }], …)
219
+ *
220
+ * The typed forms exist because not every part of an entry is free text. A work
221
+ * entry is also "is this their CURRENT job" (`duration.isActive`) and "was it
222
+ * remote" (`isRemote`), and an education entry has a `studyType` from a closed
223
+ * vocabulary. Those are the parts that make a same-entry filter worth asking:
224
+ * "engineer at Google" is a different question from "engineer at Google NOW".
225
+ *
226
+ * A boolean field is only a constraint when present. `false` is a real value
227
+ * here ("not their current job"), so it is NOT dropped as empty the way a blank
228
+ * string is — which is also why a boolean alone satisfies "at least one field".
229
+ */
230
+ type ConjunctionField =
231
+ | string
232
+ | { name: string; type: "string" | "boolean"; oneOf?: readonly string[] };
233
+
234
+ const conjunctionFieldName = (f: ConjunctionField): string =>
235
+ typeof f === "string" ? f : f.name;
236
+
237
+ export const conjunction = (
238
+ fields: readonly ConjunctionField[],
239
+ label: string,
240
+ ) => {
241
+ const names = fields.map(conjunctionFieldName);
242
+ const isBoolean = (name: string) =>
243
+ fields.some(
244
+ (f) => typeof f !== "string" && f.name === name && f.type === "boolean",
245
+ );
246
+
247
+ return array()
248
+ .of(
249
+ rejectUnknownKeys(
250
+ object(
251
+ Object.fromEntries(
252
+ fields.map((f) => {
253
+ const name = conjunctionFieldName(f);
254
+ if (typeof f !== "string" && f.type === "boolean") {
255
+ return [name, boolean().optional().default(undefined).label(name)];
256
+ }
257
+ const base = string().trim().optional().default(undefined).label(name);
258
+ return [
259
+ name,
260
+ typeof f !== "string" && f.oneOf ? base.oneOf([...f.oneOf, undefined]) : base,
261
+ ];
262
+ }),
263
+ ),
264
+ )
265
+ .test(
266
+ "at-least-one-field",
267
+ // Names the unknown key when that is the real problem. A misspelled
268
+ // field (`companyName`) is stripped before this test sees it, so it
269
+ // would otherwise report "needs at least one field set" — true, but
270
+ // it sends the reader looking in the wrong place.
271
+ ({ value }) => {
272
+ const unknown = Object.keys(
273
+ (value ?? {}) as Record<string, unknown>,
274
+ ).filter((k) => !names.includes(k));
275
+ return unknown.length
276
+ ? `${label} has unsupported keys: ${unknown.join(", ")}`
277
+ : `${label} needs at least one of: ${names.join(", ")}`;
278
+ },
279
+ (value) =>
280
+ value == null ||
281
+ names.some((f) => {
282
+ const v = (value as Record<string, unknown>)[f];
283
+ // A boolean counts when PRESENT: `false` is the constraint
284
+ // "not current", not an empty field.
285
+ if (isBoolean(f)) return typeof v === "boolean";
286
+ return typeof v === "string" && v.trim() !== "";
287
+ }),
288
+ )
289
+ .label(label)
290
+ .required(),
291
+ () => names,
292
+ ),
293
+ )
294
+ .optional()
295
+ .default([])
296
+ .label(label);
297
+ };
298
+
299
+ /**
300
+ * Reject keys a schema does not declare, WITHOUT turning on strict mode.
301
+ *
302
+ * A silently dropped key is the dangerous failure: a filter whose "Has Resume"
303
+ * checkbox arrives under a misspelled name is not a narrower query, it is the
304
+ * unfiltered one, and it mails everyone while looking correctly configured. The
305
+ * caller sees a plausible result and no error, so the mistake survives review.
306
+ *
307
+ * Yup's own `.noUnknown()` only ERRORS under `.strict()`, and strict mode also
308
+ * disables coercion — which would reject the `"80"` and `"true"` strings that
309
+ * HTML form payloads legitimately send. This is written as a `.test()` instead
310
+ * so both hold at once: unknown keys fail, coercion stays on.
311
+ *
312
+ * `allowed` is a FUNCTION, not an array, for two reasons: it is evaluated at
313
+ * validation time rather than module-load time (so a schema may reference keys
314
+ * declared later in the file), and it lets two schemas built from the same shape
315
+ * permit different key sets — which is how a filter's `or` branch forbids a
316
+ * nested `or` while the top-level filter allows one.
317
+ */
318
+ export const rejectUnknownKeys = <T extends { test: (...a: never[]) => T }>(
319
+ schema: T,
320
+ allowed: () => readonly string[],
321
+ ): T =>
322
+ (schema.test as unknown as (
323
+ name: string,
324
+ msg: (p: { value: unknown; label: string }) => string,
325
+ fn: (v: unknown) => boolean,
326
+ ) => T)(
327
+ "no-unknown-keys",
328
+ ({ value, label }) => {
329
+ const known = new Set(allowed());
330
+ const bad = Object.keys((value ?? {}) as Record<string, unknown>).filter(
331
+ (k) => !known.has(k),
332
+ );
333
+ return `${label} has unsupported keys: ${bad.join(", ")}`;
334
+ },
335
+ (value) => {
336
+ if (value == null) return true;
337
+ const known = new Set(allowed());
338
+ return Object.keys(value as Record<string, unknown>).every((k) =>
339
+ known.has(k),
340
+ );
341
+ },
342
+ );
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The feedback filter: the closed vocabulary of facets the admin inbox can
3
+ * filter on, for common-service's `GET /feedback`.
4
+ *
5
+ * The counterpart of `PageFilterSchema` and `GroupFilterSchema`. This route had
6
+ * no declaration at all: it read `filter[type]` and `type` by hand, checked the
7
+ * value against the enum inline, and SILENTLY DROPPED anything that failed. So
8
+ * a typo'd or renamed status did not narrow the inbox and did not error — it
9
+ * returned the whole unfiltered inbox, which looks like "no filter applied"
10
+ * rather than like a bug.
11
+ *
12
+ * Two facets, because the record has two closed vocabularies. Everything else
13
+ * on `FeedbackSchema` (`message`, `email`, `pageUrl`) is free text, which the
14
+ * route already matches through `?search=` across all of them at once; a facet
15
+ * for one of them would be a worse version of the search that exists.
16
+ *
17
+ * `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are reserved
18
+ * query params, not facets, and are absent for that reason instead.
19
+ */
20
+ import { object } from "yup";
21
+ import { enumFacet, rejectUnknownKeys } from "../common/facet.schema.js";
22
+ import {
23
+ SupportedFeedbackStatuses,
24
+ SupportedFeedbackTypes,
25
+ } from "./feedback.constant.js";
26
+
27
+ const FeedbackFilterShape = object({
28
+ type: enumFacet(SupportedFeedbackTypes, "Type"),
29
+ status: enumFacet(SupportedFeedbackStatuses, "Status"),
30
+ }).label("Feedback Filter");
31
+
32
+ /**
33
+ * Wrapped so an unknown key is REJECTED rather than silently ignored, which is
34
+ * the whole point here: the hand-rolled reader this replaces ignored not just
35
+ * unknown KEYS but unknown VALUES too.
36
+ */
37
+ export const FeedbackFilterSchema = rejectUnknownKeys(FeedbackFilterShape, () =>
38
+ Object.keys(FeedbackFilterShape.fields),
39
+ );
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The city filter: the closed vocabulary of facets the city endpoints read.
3
+ *
4
+ * Shared by both shapes of the same query, exactly as `StateFilterSchema` is:
5
+ *
6
+ * • `GET /geo/countries/:countryCode/states/:stateCode/cities` — the cascade,
7
+ * where both codes are PATH params folded into this filter.
8
+ * • `GET /geo/cities?search=mum&countryCode=IN` — the flat search, for a user
9
+ * typing a city before picking a state.
10
+ *
11
+ * The flat route requires at least one of `countryCodes` or `search`: an
12
+ * unnarrowed call would be a paged walk of ~153,000 rows, which is never what
13
+ * the caller meant.
14
+ *
15
+ * `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are reserved
16
+ * query params, not facets, and are absent for that reason instead.
17
+ */
18
+ import { object } from "yup";
19
+ import { codeTokens, rejectUnknownKeys, tokens } from "../common/facet.schema.js";
20
+
21
+ const CityFilterShape = object({
22
+ /** ISO 3166-1 alpha-2 of the parent country, e.g. `IN`. */
23
+ countryCodes: codeTokens("Country Codes"),
24
+
25
+ /**
26
+ * BARE subdivision codes, e.g. `KA`. Ambiguous without `countryCodes`; see
27
+ * `StateFilterSchema.stateCodes` and `@todo STATE-CODE-ISO-PREFIX`.
28
+ */
29
+ stateCodes: codeTokens("State Codes"),
30
+
31
+ /** Display names, matched exactly. Free-text matching is `?search=`. */
32
+ names: tokens("Names"),
33
+ }).label("City Filter");
34
+
35
+ /**
36
+ * Wrapped so an unknown key is REJECTED rather than silently ignored. See
37
+ * `SkillFilterSchema` for why this is a `.test()` and not `.noUnknown()`.
38
+ */
39
+ export const CityFilterSchema = rejectUnknownKeys(CityFilterShape, () =>
40
+ Object.keys(CityFilterShape.fields),
41
+ );
@@ -0,0 +1,104 @@
1
+ import { array, number, object, string } from "yup";
2
+ import type { InferType } from "yup";
3
+
4
+ /**
5
+ * A city in the geo reference mirror, backing
6
+ * `GET /geo/countries/:countryCode/states/:stateCode/cities` and `GET /geo/cities`.
7
+ *
8
+ * The big one: ~153,000 rows, ingested by STREAMING the 182 MB upstream JSON
9
+ * rather than parsing it. See `CountrySchema` for why this is neither `.strict()`
10
+ * nor concatenated with `DbDefaultSchema`.
11
+ *
12
+ * Both parent levels are stored denormalized (`stateCode`/`stateName` and
13
+ * `countryCode`/`countryName`), as upstream ships them, so a flat search hit
14
+ * carries everything a UI needs to back-fill the whole cascade.
15
+ */
16
+ export const CitySchema = object({
17
+ /** The upstream dr5hn id. Stable, and the key every upsert matches on. */
18
+ id: number().integer().required().label("ID"),
19
+
20
+ name: string().trim().required().label("Name"),
21
+
22
+ stateId: number().integer().optional().nullable().label("State ID"),
23
+
24
+ /** The BARE subdivision code. Only meaningful paired with `countryCode`; see `StateSchema`. */
25
+ stateCode: string().trim().uppercase().optional().nullable().label("State Code"),
26
+
27
+ /**
28
+ * Fully-qualified ISO 3166-2, e.g. `IN-KA`.
29
+ *
30
+ * DERIVED on ingest as `${countryCode}-${stateCode}`, because the upstream city
31
+ * record carries only the bare `state_code` — unlike the state record, which
32
+ * ships `iso3166_2` directly. Stored anyway so the `@todo STATE-CODE-ISO-PREFIX`
33
+ * migration can re-point the cities route without a 153k-row re-ingest.
34
+ * Null when either half is missing.
35
+ */
36
+ stateCodeIso: string().trim().uppercase().optional().nullable().label("ISO 3166-2 Code"),
37
+
38
+ stateName: string().trim().optional().nullable().label("State Name"),
39
+
40
+ countryId: number().integer().optional().nullable().label("Country ID"),
41
+
42
+ /** ISO 3166-1 alpha-2 of the parent, UPPERCASE. See `CountrySchema.countryCode`. */
43
+ countryCode: string().trim().uppercase().length(2).required().label("Country Code"),
44
+
45
+ countryName: string().trim().optional().nullable().label("Country Name"),
46
+
47
+ /** Numbers, not the strings upstream ships. See `CountrySchema.latitude`. */
48
+ latitude: number().optional().nullable().label("Latitude"),
49
+ longitude: number().optional().nullable().label("Longitude"),
50
+
51
+ /** The name in its own script. Stored, but NOT searchable; see `searchByLocale`. */
52
+ native: string().trim().optional().nullable().label("Native Name"),
53
+
54
+ type: string().trim().optional().nullable().label("Type"),
55
+ level: number().integer().optional().nullable().label("Level"),
56
+ parentId: number().integer().optional().nullable().label("Parent ID"),
57
+
58
+ /**
59
+ * Null on most rows. Worth keeping (it is a good relevance signal when
60
+ * present) but never worth SORTING by alone, since a null-heavy sort buries
61
+ * every city upstream has no figure for.
62
+ */
63
+ population: number().optional().nullable().label("Population"),
64
+
65
+ timezone: string().trim().optional().nullable().label("Timezone"),
66
+
67
+ /** Localized names keyed by locale. Unshaped; see `CountrySchema.translations`. */
68
+ translations: object().optional().nullable().label("Translations"),
69
+
70
+ /**
71
+ * The name field searched for the DEFAULT (English) locale: the canonical
72
+ * `name`, and nothing else.
73
+ *
74
+ * Search is scoped to ONE locale, so the searchable names are split across
75
+ * this field and `searchByLocale` rather than pooled into one array. A pooled
76
+ * field would make every locale's search implicitly a search of all 19
77
+ * languages: a French UI searching "Inde" would also match German "Indien".
78
+ */
79
+ searchDefault: array().of(string().required()).optional().default([]).label("Search Default"),
80
+
81
+ /**
82
+ * Per-locale name fields, keyed by lowercased BCP-47 tag (`de`, `pt-br`).
83
+ * Each carries ONLY that locale's translation, which is what makes
84
+ * `?locale=de` German and not "German plus everything that looks similar".
85
+ *
86
+ * Unshaped for the same reason as `translations`: the key set is upstream's.
87
+ *
88
+ * `native` is deliberately NOT indexed here. It cannot be attributed to a
89
+ * language with this data — 84% of city native names equal SEVERAL locales'
90
+ * translations (Latin spellings coinciding across de/it/nl/hr/pl) while
91
+ * distinctive non-Latin names like `भारत` match none — so filing `कर्नाटक`
92
+ * under German would be worse than not indexing it at all.
93
+ *
94
+ * The 19 upstream locales are br, ko, pt-BR, pt, nl, hr, fa, de, es, fr, ja,
95
+ * it, zh-CN, tr, ru, uk, pl, hi, ar. **`sv` is absent**, so a Swedish search
96
+ * matches nothing. That is the accepted cost of strict scoping.
97
+ */
98
+ searchByLocale: object().optional().nullable().label("Search By Locale"),
99
+
100
+ /** Epoch ms of the run that last wrote this row. Drives the stale-row prune. */
101
+ syncedAt: number().optional().nullable().label("Synced At"),
102
+ }).label("City");
103
+
104
+ export type City = InferType<typeof CitySchema>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The country filter: the closed vocabulary of facets `GET /geo/countries` reads.
3
+ *
4
+ * The counterpart of `SkillFilterSchema` and `FeedbackFilterSchema`, and it
5
+ * exists for the same reason: a filter parser that accepts any key passes a
6
+ * misspelled facet straight to Mongo, which is not a narrower query but a query
7
+ * for a field no document has — 200 OK, zero rows, no error anywhere.
8
+ *
9
+ * Every facet is a field `CountrySchema` actually declares and that someone
10
+ * would plausibly group by. ABSENT on purpose: `currency`, `tld`, `phonecode`
11
+ * and the rest, which are display values, not things you filter a dropdown by;
12
+ * and `native`/`translations`, which are reachable through `?search=` instead —
13
+ * a facet on them would be exact-match on a value the user cannot be expected to
14
+ * type exactly.
15
+ *
16
+ * `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are reserved
17
+ * query params, not facets, and are absent for that reason instead.
18
+ */
19
+ import { object } from "yup";
20
+ import { codeTokens, rejectUnknownKeys, tokens } from "../common/facet.schema.js";
21
+
22
+ const CountryFilterShape = object({
23
+ /** ISO 3166-1 alpha-2 codes, e.g. `IN`. Uppercased, matching how they are stored. */
24
+ countryCodes: codeTokens("Country Codes"),
25
+
26
+ /** ISO 3166-1 alpha-3 codes, e.g. `IND`. */
27
+ iso3s: codeTokens("ISO3 Codes"),
28
+
29
+ /**
30
+ * `tokens`, NOT `codeTokens`: a region is a display string ("Asia", "Europe"),
31
+ * and uppercasing it would match nothing.
32
+ */
33
+ regions: tokens("Regions"),
34
+
35
+ subregions: tokens("Subregions"),
36
+
37
+ /** Display names, matched exactly. Free-text matching is `?search=`. */
38
+ names: tokens("Names"),
39
+ }).label("Country Filter");
40
+
41
+ /**
42
+ * Wrapped so an unknown key is REJECTED rather than silently ignored. See
43
+ * `SkillFilterSchema` for why this is a `.test()` and not `.noUnknown()`.
44
+ */
45
+ export const CountryFilterSchema = rejectUnknownKeys(CountryFilterShape, () =>
46
+ Object.keys(CountryFilterShape.fields),
47
+ );