@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.
- package/CLAUDE.md +17 -0
- package/dist/index.cjs +2174 -722
- package/dist/index.d.cts +1992 -534
- package/dist/index.d.ts +1992 -534
- package/dist/index.js +2075 -696
- package/package.json +1 -1
- package/src/billing/billing.constant.ts +179 -0
- package/src/billing/billing.schema.ts +280 -0
- package/src/common/facet.schema.ts +342 -0
- package/src/feedback/feedback-filter.schema.ts +39 -0
- package/src/geo/city-filter.schema.ts +41 -0
- package/src/geo/city.schema.ts +104 -0
- package/src/geo/country-filter.schema.ts +47 -0
- package/src/geo/country.schema.ts +148 -0
- package/src/geo/geo-sync.schema.ts +116 -0
- package/src/geo/geo.constant.ts +78 -0
- package/src/geo/state-filter.schema.ts +57 -0
- package/src/geo/state.schema.ts +101 -0
- package/src/group/group-filter.schema.ts +81 -0
- package/src/group/group.constant.ts +15 -0
- package/src/index.ts +35 -0
- package/src/job/job-filter.schema.ts +266 -0
- package/src/language/language-filter.schema.ts +40 -0
- package/src/marketing/marketing.constant.ts +105 -30
- package/src/marketing/marketing.schema.ts +118 -403
- package/src/page/page-filter.schema.ts +67 -0
- package/src/skill/skill-filter.schema.ts +61 -0
- package/src/user/general-detail.schema.ts +7 -0
- package/src/user/push-subscription.schema.ts +86 -0
- package/src/user/user-filter.schema.ts +438 -0
- package/src/user/user-interest.schema.ts +34 -2
- package/src/user/user.constant.ts +20 -8
- package/src/user/user.schema.ts +45 -0
- package/src/user/user.types.ts +35 -0
|
@@ -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
|
+
);
|