@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,148 @@
|
|
|
1
|
+
import { array, number, object, string } from "yup";
|
|
2
|
+
import type { InferType } from "yup";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A country in the geo reference mirror, backing `GET /geo/countries`.
|
|
6
|
+
*
|
|
7
|
+
* Sourced from dr5hn/countries-states-cities-database and refreshed wholesale by
|
|
8
|
+
* common-service. The upstream record is kept WHOLE — `translations`, `timezones`,
|
|
9
|
+
* `gdp`, `emoji` and all — and callers narrow with `?fields=` instead. Storing a
|
|
10
|
+
* projection would mean a re-ingest of 150k+ records every time someone wanted a
|
|
11
|
+
* field we had decided in advance nobody needed.
|
|
12
|
+
*
|
|
13
|
+
* Two deliberate departures from the house style for record schemas:
|
|
14
|
+
*
|
|
15
|
+
* • NOT `.noUnknown().strict()`. `SkillSchema` is strict because it validates
|
|
16
|
+
* user INPUT; this types a STORED shape whose upstream fields we keep
|
|
17
|
+
* wholesale, so strict mode would reject documents the ingest legitimately
|
|
18
|
+
* writes the moment dr5hn adds a column.
|
|
19
|
+
* • NOT `.concat(DbDefaultSchema)`. These records have no `shortId` or
|
|
20
|
+
* `createdBy` — nobody authors them. They carry the upstream `id` (which is
|
|
21
|
+
* also the upsert key) plus our own `syncedAt`.
|
|
22
|
+
*
|
|
23
|
+
* Almost everything is nullable because the dataset genuinely has holes:
|
|
24
|
+
* `capital`, `gdp` and `postalCodeRegex` are absent on many rows. Only the
|
|
25
|
+
* identity fields are required.
|
|
26
|
+
*/
|
|
27
|
+
export const CountrySchema = object({
|
|
28
|
+
/** The upstream dr5hn id. Stable, and the key every upsert matches on. */
|
|
29
|
+
id: number().integer().required().label("ID"),
|
|
30
|
+
|
|
31
|
+
name: string().trim().required().label("Name"),
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* ISO 3166-1 alpha-2, UPPERCASE (`IN`, `SE`).
|
|
35
|
+
*
|
|
36
|
+
* Uppercased on ingest, because every filter facet in this package
|
|
37
|
+
* uppercase-normalizes codes (`codeTokens`) and stored job/user documents are
|
|
38
|
+
* already clean uppercase alpha-2. A lowercase value here would compare
|
|
39
|
+
* unequal to all of them and silently match nothing.
|
|
40
|
+
*/
|
|
41
|
+
countryCode: string().trim().uppercase().length(2).required().label("Country Code"),
|
|
42
|
+
|
|
43
|
+
iso3: string().trim().uppercase().length(3).optional().nullable().label("ISO3"),
|
|
44
|
+
numericCode: string().trim().optional().nullable().label("Numeric Code"),
|
|
45
|
+
phonecode: string().trim().optional().nullable().label("Phone Code"),
|
|
46
|
+
capital: string().trim().optional().nullable().label("Capital"),
|
|
47
|
+
currency: string().trim().optional().nullable().label("Currency"),
|
|
48
|
+
currencyName: string().trim().optional().nullable().label("Currency Name"),
|
|
49
|
+
currencySymbol: string().trim().optional().nullable().label("Currency Symbol"),
|
|
50
|
+
tld: string().trim().optional().nullable().label("TLD"),
|
|
51
|
+
|
|
52
|
+
/** The name in its own script, e.g. "भारत". Stored, but NOT searchable; see `searchByLocale`. */
|
|
53
|
+
native: string().trim().optional().nullable().label("Native Name"),
|
|
54
|
+
|
|
55
|
+
population: number().optional().nullable().label("Population"),
|
|
56
|
+
gdp: number().optional().nullable().label("GDP"),
|
|
57
|
+
region: string().trim().optional().nullable().label("Region"),
|
|
58
|
+
regionId: number().integer().optional().nullable().label("Region ID"),
|
|
59
|
+
subregion: string().trim().optional().nullable().label("Subregion"),
|
|
60
|
+
subregionId: number().integer().optional().nullable().label("Subregion ID"),
|
|
61
|
+
nationality: string().trim().optional().nullable().label("Nationality"),
|
|
62
|
+
areaSqKm: number().optional().nullable().label("Area (sq km)"),
|
|
63
|
+
postalCodeFormat: string().trim().optional().nullable().label("Postal Code Format"),
|
|
64
|
+
postalCodeRegex: string().optional().nullable().label("Postal Code Regex"),
|
|
65
|
+
|
|
66
|
+
timezones: array()
|
|
67
|
+
.of(
|
|
68
|
+
object({
|
|
69
|
+
zoneName: string().optional().nullable().label("Zone Name"),
|
|
70
|
+
gmtOffset: number().optional().nullable().label("GMT Offset"),
|
|
71
|
+
gmtOffsetName: string().optional().nullable().label("GMT Offset Name"),
|
|
72
|
+
abbreviation: string().optional().nullable().label("Abbreviation"),
|
|
73
|
+
tzName: string().optional().nullable().label("TZ Name"),
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
.optional()
|
|
77
|
+
.nullable()
|
|
78
|
+
.label("Timezones"),
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Localized names keyed by locale (`{ sv: "Indien", fr: "Inde", … }`), ~19 of
|
|
82
|
+
* them. Deliberately UNSHAPED: the key set is upstream's to change, and
|
|
83
|
+
* declaring it here would turn a new locale into a validation failure. The
|
|
84
|
+
* VALUES are split per-locale into `searchByLocale`.
|
|
85
|
+
*/
|
|
86
|
+
translations: object().optional().nullable().label("Translations"),
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Coordinates as NUMBERS.
|
|
90
|
+
*
|
|
91
|
+
* Upstream ships these as strings (`"33.00000000"`). They are coerced on
|
|
92
|
+
* ingest, because a range query against a string compares lexicographically:
|
|
93
|
+
* `"9" > "10"` is true, so a bounding-box filter would be quietly wrong rather
|
|
94
|
+
* than failing loudly.
|
|
95
|
+
*/
|
|
96
|
+
latitude: number().optional().nullable().label("Latitude"),
|
|
97
|
+
longitude: number().optional().nullable().label("Longitude"),
|
|
98
|
+
|
|
99
|
+
emoji: string().optional().nullable().label("Emoji"),
|
|
100
|
+
emojiU: string().optional().nullable().label("Emoji Unicode"),
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The name field searched for the DEFAULT (English) locale: the canonical
|
|
104
|
+
* `name`, and nothing else.
|
|
105
|
+
*
|
|
106
|
+
* Search is scoped to ONE locale, so the searchable names are split across
|
|
107
|
+
* this field and `searchByLocale` rather than pooled into one array. A pooled
|
|
108
|
+
* field would make every locale's search implicitly a search of all 19
|
|
109
|
+
* languages: a French UI searching "Inde" would also match German "Indien".
|
|
110
|
+
*/
|
|
111
|
+
searchDefault: array().of(string().required()).optional().default([]).label("Search Default"),
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Per-locale name fields, keyed by lowercased BCP-47 tag (`de`, `pt-br`).
|
|
115
|
+
* Each carries ONLY that locale's translation, which is what makes
|
|
116
|
+
* `?locale=de` German and not "German plus everything that looks similar".
|
|
117
|
+
*
|
|
118
|
+
* Unshaped for the same reason as `translations`: the key set is upstream's.
|
|
119
|
+
*
|
|
120
|
+
* `native` is deliberately NOT indexed here. It cannot be attributed to a
|
|
121
|
+
* language with this data — 84% of city native names equal SEVERAL locales'
|
|
122
|
+
* translations (Latin spellings coinciding across de/it/nl/hr/pl) while
|
|
123
|
+
* distinctive non-Latin names like `भारत` match none — so filing `कर्नाटक`
|
|
124
|
+
* under German would be worse than not indexing it at all.
|
|
125
|
+
*
|
|
126
|
+
* The 19 upstream locales are br, ko, pt-BR, pt, nl, hr, fa, de, es, fr, ja,
|
|
127
|
+
* it, zh-CN, tr, ru, uk, pl, hi, ar. **`sv` is absent**, so a Swedish search
|
|
128
|
+
* matches nothing. That is the accepted cost of strict scoping.
|
|
129
|
+
*/
|
|
130
|
+
searchByLocale: object().optional().nullable().label("Search By Locale"),
|
|
131
|
+
|
|
132
|
+
/** Epoch ms of the run that last wrote this row. Drives the stale-row prune. */
|
|
133
|
+
syncedAt: number().optional().nullable().label("Synced At"),
|
|
134
|
+
}).label("Country");
|
|
135
|
+
|
|
136
|
+
export type Country = InferType<typeof CountrySchema>;
|
|
137
|
+
|
|
138
|
+
/** A single entry of `CountrySchema.timezones`. */
|
|
139
|
+
export type CountryTimezone = NonNullable<Country["timezones"]>[number];
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The localized-name map, keyed by locale.
|
|
143
|
+
*
|
|
144
|
+
* Declared as a type rather than a yup shape for the reason given on
|
|
145
|
+
* `translations` above: the key set belongs to upstream. Shared by all three
|
|
146
|
+
* geo record types.
|
|
147
|
+
*/
|
|
148
|
+
export type GeoTranslations = Record<string, string>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { boolean, mixed, number, object, string } from "yup";
|
|
2
|
+
import type { InferType } from "yup";
|
|
3
|
+
import {
|
|
4
|
+
GeoSyncPhase,
|
|
5
|
+
GeoSyncStatus,
|
|
6
|
+
GeoSyncTrigger,
|
|
7
|
+
SupportedGeoSyncPhases,
|
|
8
|
+
SupportedGeoSyncStatuses,
|
|
9
|
+
SupportedGeoSyncTriggers,
|
|
10
|
+
} from "./geo.constant.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Per-collection tally of one ingest pass.
|
|
14
|
+
*
|
|
15
|
+
* `upserted` vs `modified` is the assertion worth reading: a re-run over
|
|
16
|
+
* unchanged upstream data must be ALL `modified` and ZERO `upserted`. Any
|
|
17
|
+
* `upserted` on a second run means the `id`-keyed upsert is not matching, and
|
|
18
|
+
* the collection is quietly accumulating duplicates.
|
|
19
|
+
*/
|
|
20
|
+
const GeoSyncCountsSchema = object({
|
|
21
|
+
read: number().integer().min(0).default(0).label("Read"),
|
|
22
|
+
upserted: number().integer().min(0).default(0).label("Upserted"),
|
|
23
|
+
modified: number().integer().min(0).default(0).label("Modified"),
|
|
24
|
+
matched: number().integer().min(0).default(0).label("Matched"),
|
|
25
|
+
/** Rows removed because upstream dropped them. See `prunedSkipped`. */
|
|
26
|
+
pruned: number().integer().min(0).default(0).label("Pruned"),
|
|
27
|
+
/**
|
|
28
|
+
* True when the stale-row prune was REFUSED because it would have removed more
|
|
29
|
+
* than its safety threshold of the collection. A partial fetch that looked
|
|
30
|
+
* like a success must not be able to empty the table, so the run reports the
|
|
31
|
+
* refusal rather than acting on it.
|
|
32
|
+
*/
|
|
33
|
+
prunedSkipped: boolean().default(false).label("Prune Skipped"),
|
|
34
|
+
}).label("Sync Counts");
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One run of the geo refresh job, as stored in `geo_sync_runs` and returned by
|
|
38
|
+
* `GET /geo/refresh/status`.
|
|
39
|
+
*
|
|
40
|
+
* The run document is the ONLY shared state between the job and the status
|
|
41
|
+
* endpoint: the job writes phase and progress onto it, the dashboard polls it.
|
|
42
|
+
* Nothing reaches into the job's memory, which is what lets the job be a plain
|
|
43
|
+
* in-process async function rather than a queue.
|
|
44
|
+
*/
|
|
45
|
+
export const GeoSyncRunSchema = object({
|
|
46
|
+
jobId: string().required().label("Job ID"),
|
|
47
|
+
|
|
48
|
+
status: mixed<GeoSyncStatus>()
|
|
49
|
+
.oneOf(SupportedGeoSyncStatuses)
|
|
50
|
+
.required()
|
|
51
|
+
.label("Status"),
|
|
52
|
+
|
|
53
|
+
trigger: mixed<GeoSyncTrigger>()
|
|
54
|
+
.oneOf(SupportedGeoSyncTriggers)
|
|
55
|
+
.optional()
|
|
56
|
+
.label("Trigger"),
|
|
57
|
+
|
|
58
|
+
/** Whether the caller asked to run even though the release tag was unchanged. */
|
|
59
|
+
force: boolean().default(false).label("Force"),
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The upstream release tag this run fetched, e.g. `v3.2-export.7`.
|
|
63
|
+
*
|
|
64
|
+
* Null only when the tag could not be read AND `force` was set — a run that
|
|
65
|
+
* cannot tell what version it is fetching is otherwise a failure, not a
|
|
66
|
+
* refresh, so it is not allowed to proceed unattended.
|
|
67
|
+
*/
|
|
68
|
+
releaseTag: string().optional().nullable().label("Release Tag"),
|
|
69
|
+
|
|
70
|
+
/** The tag of the last successful run, i.e. what this one is replacing. */
|
|
71
|
+
previousReleaseTag: string().optional().nullable().label("Previous Release Tag"),
|
|
72
|
+
|
|
73
|
+
startedAt: number().required().label("Started At"),
|
|
74
|
+
finishedAt: number().optional().nullable().label("Finished At"),
|
|
75
|
+
durationMs: number().optional().nullable().label("Duration (ms)"),
|
|
76
|
+
|
|
77
|
+
phase: mixed<GeoSyncPhase>()
|
|
78
|
+
.oneOf(SupportedGeoSyncPhases)
|
|
79
|
+
.optional()
|
|
80
|
+
.nullable()
|
|
81
|
+
.label("Phase"),
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Live progress for the dashboard bar.
|
|
85
|
+
*
|
|
86
|
+
* `total` is an ESTIMATE — the previous successful run's count for the same
|
|
87
|
+
* collection, or a hardcoded fallback on the very first run, since the true
|
|
88
|
+
* total is not known until the stream ends. The UI should render it as one.
|
|
89
|
+
*/
|
|
90
|
+
progress: object({
|
|
91
|
+
collection: string().optional().nullable().label("Collection"),
|
|
92
|
+
processed: number().integer().min(0).default(0).label("Processed"),
|
|
93
|
+
total: number().integer().min(0).optional().nullable().label("Total"),
|
|
94
|
+
})
|
|
95
|
+
.optional()
|
|
96
|
+
.nullable()
|
|
97
|
+
.label("Progress"),
|
|
98
|
+
|
|
99
|
+
counts: object({
|
|
100
|
+
countries: GeoSyncCountsSchema.optional().nullable().label("Countries"),
|
|
101
|
+
states: GeoSyncCountsSchema.optional().nullable().label("States"),
|
|
102
|
+
cities: GeoSyncCountsSchema.optional().nullable().label("Cities"),
|
|
103
|
+
})
|
|
104
|
+
.optional()
|
|
105
|
+
.nullable()
|
|
106
|
+
.label("Counts"),
|
|
107
|
+
|
|
108
|
+
/** Why a `skipped` run was skipped, in words the dashboard can show verbatim. */
|
|
109
|
+
reason: string().optional().nullable().label("Reason"),
|
|
110
|
+
|
|
111
|
+
/** The failure message on a `failed` run. Surfaced verbatim to the admin. */
|
|
112
|
+
error: string().optional().nullable().label("Error"),
|
|
113
|
+
}).label("Geo Sync Run");
|
|
114
|
+
|
|
115
|
+
export type GeoSyncRun = InferType<typeof GeoSyncRunSchema>;
|
|
116
|
+
export type GeoSyncCounts = InferType<typeof GeoSyncCountsSchema>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants for the geo reference data (countries / states / cities).
|
|
3
|
+
*
|
|
4
|
+
* The data is a MIRROR of the dr5hn/countries-states-cities-database, refreshed
|
|
5
|
+
* wholesale by common-service rather than authored here. That is why these are
|
|
6
|
+
* sync/administrative vocabularies: the records themselves have no lifecycle of
|
|
7
|
+
* their own, only the job that replaces them does.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Which level of the hierarchy a combined `/geo/search` hit came from. */
|
|
11
|
+
export enum GeoKind {
|
|
12
|
+
Country = "country",
|
|
13
|
+
State = "state",
|
|
14
|
+
City = "city",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const SupportedGeoKinds = Object.values(GeoKind);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The state machine of one geo refresh run.
|
|
21
|
+
*
|
|
22
|
+
* `Skipped` is a SUCCESS, not a failure: the upstream release tag had not
|
|
23
|
+
* changed, so there was nothing to fetch. Keeping it distinct from `Succeeded`
|
|
24
|
+
* is what lets the admin dashboard say "already up to date" instead of showing
|
|
25
|
+
* a progress bar that completes instantly and implies work happened.
|
|
26
|
+
*/
|
|
27
|
+
export enum GeoSyncStatus {
|
|
28
|
+
Pending = "pending",
|
|
29
|
+
Running = "running",
|
|
30
|
+
Succeeded = "succeeded",
|
|
31
|
+
Failed = "failed",
|
|
32
|
+
Skipped = "skipped",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const SupportedGeoSyncStatuses = Object.values(GeoSyncStatus);
|
|
36
|
+
|
|
37
|
+
/** Terminal states. A run in any other state is still in flight. */
|
|
38
|
+
export const TerminalGeoSyncStatuses = [
|
|
39
|
+
GeoSyncStatus.Succeeded,
|
|
40
|
+
GeoSyncStatus.Failed,
|
|
41
|
+
GeoSyncStatus.Skipped,
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
/** Which phase a running job is in, for the progress display. */
|
|
45
|
+
export enum GeoSyncPhase {
|
|
46
|
+
Checking = "checking",
|
|
47
|
+
Countries = "countries",
|
|
48
|
+
States = "states",
|
|
49
|
+
Cities = "cities",
|
|
50
|
+
Indexing = "indexing",
|
|
51
|
+
Done = "done",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const SupportedGeoSyncPhases = Object.values(GeoSyncPhase);
|
|
55
|
+
|
|
56
|
+
/** What triggered a run. `Boot` is reserved; today every run is `Manual`. */
|
|
57
|
+
export enum GeoSyncTrigger {
|
|
58
|
+
Manual = "manual",
|
|
59
|
+
Boot = "boot",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const SupportedGeoSyncTriggers = Object.values(GeoSyncTrigger);
|
|
63
|
+
|
|
64
|
+
/** Mongo collection names, so service and consumers cannot disagree on them. */
|
|
65
|
+
export const GEO_COLLECTIONS = {
|
|
66
|
+
countries: "geo_countries",
|
|
67
|
+
states: "geo_states",
|
|
68
|
+
cities: "geo_cities",
|
|
69
|
+
syncRuns: "geo_sync_runs",
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Max length of a `?search=` term on any `/geo` endpoint.
|
|
74
|
+
*
|
|
75
|
+
* Bounded because the term reaches an Atlas `$search` autocomplete clause, and
|
|
76
|
+
* an unbounded one is a needlessly expensive query for input no human types.
|
|
77
|
+
*/
|
|
78
|
+
export const GEO_SEARCH_MAX = 64;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The state filter: the closed vocabulary of facets the state endpoints read.
|
|
3
|
+
*
|
|
4
|
+
* Shared by BOTH shapes of the same query, which is why `countryCodes` is
|
|
5
|
+
* declared here rather than assumed:
|
|
6
|
+
*
|
|
7
|
+
* • `GET /geo/countries/:countryCode/states` — the cascade. The country is a
|
|
8
|
+
* PATH param, folded into this filter by the route.
|
|
9
|
+
* • `GET /geo/states?search=kar&countryCode=IN` — the flat search, for a user
|
|
10
|
+
* who knows "Karnataka" but not which country it is in. Here `countryCodes`
|
|
11
|
+
* is an optional narrowing facet.
|
|
12
|
+
*
|
|
13
|
+
* One vocabulary for both means the two cannot drift into accepting different
|
|
14
|
+
* things. The route rejects a path code that DISAGREES with a facet code rather
|
|
15
|
+
* than silently letting one win.
|
|
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 { codeTokens, rejectUnknownKeys, tokens } from "../common/facet.schema.js";
|
|
22
|
+
|
|
23
|
+
const StateFilterShape = object({
|
|
24
|
+
/** ISO 3166-1 alpha-2 of the parent country, e.g. `IN`. */
|
|
25
|
+
countryCodes: codeTokens("Country Codes"),
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* BARE subdivision codes, e.g. `KA`.
|
|
29
|
+
*
|
|
30
|
+
* `@todo STATE-CODE-ISO-PREFIX` — not globally unique, so this facet ALONE is
|
|
31
|
+
* an ambiguous question: `stateCodes[0]=BDS` matches Badakhshan in Afghanistan
|
|
32
|
+
* and every other `BDS` elsewhere. Pair it with `countryCodes` to mean one
|
|
33
|
+
* place. See `StateSchema.stateCode`.
|
|
34
|
+
*/
|
|
35
|
+
stateCodes: codeTokens("State Codes"),
|
|
36
|
+
|
|
37
|
+
/** Display names, matched exactly. Free-text matching is `?search=`. */
|
|
38
|
+
names: tokens("Names"),
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Upstream's own subdivision word: "province", "state", "district", …
|
|
42
|
+
*
|
|
43
|
+
* `tokens`, NOT `codeTokens`: these are lowercase display strings and
|
|
44
|
+
* uppercasing them would match nothing. An OPEN vocabulary rather than an
|
|
45
|
+
* `enumFacet`, because upstream ships 30-odd values and adding one should not
|
|
46
|
+
* require a schema release here.
|
|
47
|
+
*/
|
|
48
|
+
types: tokens("Types"),
|
|
49
|
+
}).label("State Filter");
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Wrapped so an unknown key is REJECTED rather than silently ignored. See
|
|
53
|
+
* `SkillFilterSchema` for why this is a `.test()` and not `.noUnknown()`.
|
|
54
|
+
*/
|
|
55
|
+
export const StateFilterSchema = rejectUnknownKeys(StateFilterShape, () =>
|
|
56
|
+
Object.keys(StateFilterShape.fields),
|
|
57
|
+
);
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { array, number, object, string } from "yup";
|
|
2
|
+
import type { InferType } from "yup";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A state / province / subdivision in the geo reference mirror, backing
|
|
6
|
+
* `GET /geo/countries/:countryCode/states` and `GET /geo/states`.
|
|
7
|
+
*
|
|
8
|
+
* Sourced from dr5hn and refreshed wholesale; the upstream record is kept whole.
|
|
9
|
+
* See `CountrySchema` for why this is neither `.strict()` nor concatenated with
|
|
10
|
+
* `DbDefaultSchema`.
|
|
11
|
+
*
|
|
12
|
+
* The parent country's `countryCode` and `countryName` are stored DENORMALIZED,
|
|
13
|
+
* as upstream ships them. That is deliberate: a flat search hit ("Karnataka")
|
|
14
|
+
* has to be enough for a UI to back-fill the country dropdown to India without a
|
|
15
|
+
* second round trip, which is the whole point of searching states without first
|
|
16
|
+
* picking a country.
|
|
17
|
+
*/
|
|
18
|
+
export const StateSchema = object({
|
|
19
|
+
/** The upstream dr5hn id. Stable, and the key every upsert matches on. */
|
|
20
|
+
id: number().integer().required().label("ID"),
|
|
21
|
+
|
|
22
|
+
name: string().trim().required().label("Name"),
|
|
23
|
+
|
|
24
|
+
countryId: number().integer().optional().nullable().label("Country ID"),
|
|
25
|
+
|
|
26
|
+
/** ISO 3166-1 alpha-2 of the parent, UPPERCASE. See `CountrySchema.countryCode`. */
|
|
27
|
+
countryCode: string().trim().uppercase().length(2).required().label("Country Code"),
|
|
28
|
+
|
|
29
|
+
countryName: string().trim().optional().nullable().label("Country Name"),
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The BARE subdivision code, e.g. `KA` (upstream `iso2`).
|
|
33
|
+
*
|
|
34
|
+
* `@todo STATE-CODE-ISO-PREFIX` — this is **not globally unique**: `BDS`, `CA`,
|
|
35
|
+
* `NL` and `SP` all recur across several countries, so it is only meaningful
|
|
36
|
+
* PAIRED with `countryCode`. That is why the cities route is nested under both
|
|
37
|
+
* codes rather than under a state code alone, which would silently serve
|
|
38
|
+
* another country's cities. `stateCodeIso` below is the migration target;
|
|
39
|
+
* both are stored so the migration has its destination present before any
|
|
40
|
+
* consumer moves.
|
|
41
|
+
*/
|
|
42
|
+
stateCode: string().trim().uppercase().optional().nullable().label("State Code"),
|
|
43
|
+
|
|
44
|
+
/** Fully-qualified ISO 3166-2, e.g. `IN-KA` (upstream `iso3166_2`). */
|
|
45
|
+
stateCodeIso: string().trim().uppercase().optional().nullable().label("ISO 3166-2 Code"),
|
|
46
|
+
|
|
47
|
+
fipsCode: string().trim().optional().nullable().label("FIPS Code"),
|
|
48
|
+
|
|
49
|
+
/** Upstream's own word for the subdivision: "province", "state", "district", … */
|
|
50
|
+
type: string().trim().optional().nullable().label("Type"),
|
|
51
|
+
|
|
52
|
+
level: number().integer().optional().nullable().label("Level"),
|
|
53
|
+
parentId: number().integer().optional().nullable().label("Parent ID"),
|
|
54
|
+
|
|
55
|
+
/** The name in its own script. Stored, but NOT searchable; see `searchByLocale`. */
|
|
56
|
+
native: string().trim().optional().nullable().label("Native Name"),
|
|
57
|
+
|
|
58
|
+
/** Numbers, not the strings upstream ships. See `CountrySchema.latitude`. */
|
|
59
|
+
latitude: number().optional().nullable().label("Latitude"),
|
|
60
|
+
longitude: number().optional().nullable().label("Longitude"),
|
|
61
|
+
|
|
62
|
+
timezone: string().trim().optional().nullable().label("Timezone"),
|
|
63
|
+
|
|
64
|
+
/** Localized names keyed by locale. Unshaped; see `CountrySchema.translations`. */
|
|
65
|
+
translations: object().optional().nullable().label("Translations"),
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The name field searched for the DEFAULT (English) locale: the canonical
|
|
69
|
+
* `name`, and nothing else.
|
|
70
|
+
*
|
|
71
|
+
* Search is scoped to ONE locale, so the searchable names are split across
|
|
72
|
+
* this field and `searchByLocale` rather than pooled into one array. A pooled
|
|
73
|
+
* field would make every locale's search implicitly a search of all 19
|
|
74
|
+
* languages: a French UI searching "Inde" would also match German "Indien".
|
|
75
|
+
*/
|
|
76
|
+
searchDefault: array().of(string().required()).optional().default([]).label("Search Default"),
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Per-locale name fields, keyed by lowercased BCP-47 tag (`de`, `pt-br`).
|
|
80
|
+
* Each carries ONLY that locale's translation, which is what makes
|
|
81
|
+
* `?locale=de` German and not "German plus everything that looks similar".
|
|
82
|
+
*
|
|
83
|
+
* Unshaped for the same reason as `translations`: the key set is upstream's.
|
|
84
|
+
*
|
|
85
|
+
* `native` is deliberately NOT indexed here. It cannot be attributed to a
|
|
86
|
+
* language with this data — 84% of city native names equal SEVERAL locales'
|
|
87
|
+
* translations (Latin spellings coinciding across de/it/nl/hr/pl) while
|
|
88
|
+
* distinctive non-Latin names like `भारत` match none — so filing `कर्नाटक`
|
|
89
|
+
* under German would be worse than not indexing it at all.
|
|
90
|
+
*
|
|
91
|
+
* The 19 upstream locales are br, ko, pt-BR, pt, nl, hr, fa, de, es, fr, ja,
|
|
92
|
+
* it, zh-CN, tr, ru, uk, pl, hi, ar. **`sv` is absent**, so a Swedish search
|
|
93
|
+
* matches nothing. That is the accepted cost of strict scoping.
|
|
94
|
+
*/
|
|
95
|
+
searchByLocale: object().optional().nullable().label("Search By Locale"),
|
|
96
|
+
|
|
97
|
+
/** Epoch ms of the run that last wrote this row. Drives the stale-row prune. */
|
|
98
|
+
syncedAt: number().optional().nullable().label("Synced At"),
|
|
99
|
+
}).label("State");
|
|
100
|
+
|
|
101
|
+
export type State = InferType<typeof StateSchema>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The group filter: the closed vocabulary of facets a group query can filter on.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart of `UserFilterSchema` and `JobFilterSchema`, and the single
|
|
5
|
+
* declaration that `thejob-group-service` and `@thejob/groups-client` both derive
|
|
6
|
+
* from — the client types its filter with `InferType`, and the service checks
|
|
7
|
+
* incoming keys against `Object.keys` of it.
|
|
8
|
+
*
|
|
9
|
+
* This one matters more than the others did. `parseFilter` in group-service
|
|
10
|
+
* accepts ANY `filter[x]` key, splits it on commas and hands it to Mongo, so
|
|
11
|
+
* before this schema existed a misspelled facet was not a narrower query — it
|
|
12
|
+
* was a query for a field no document has, which returns 200 OK with zero rows
|
|
13
|
+
* and no error anywhere. A marketing campaign configured that way looks correct
|
|
14
|
+
* and mails an empty digest.
|
|
15
|
+
*
|
|
16
|
+
* `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are
|
|
17
|
+
* deliberately absent: they are reserved query params, not facets.
|
|
18
|
+
*
|
|
19
|
+
* WHAT A FACET MEANS stays in the service. This declares only the NAMES and the
|
|
20
|
+
* SHAPES a caller may send.
|
|
21
|
+
*/
|
|
22
|
+
import { boolean, object } from "yup";
|
|
23
|
+
import { enumFacet, rejectUnknownKeys, tokens } from "../common/facet.schema.js";
|
|
24
|
+
import {
|
|
25
|
+
SupportedGroupManagedBy,
|
|
26
|
+
SupportedGroupStatuses,
|
|
27
|
+
SupportedGroupVisibilities,
|
|
28
|
+
} from "./group.constant.js";
|
|
29
|
+
|
|
30
|
+
const GroupFilterShape = object({
|
|
31
|
+
// ── Classification ──────────────────────────────────────────────────────────
|
|
32
|
+
// Closed vocabularies, matched exactly. Values are LOWERCASE (`active`,
|
|
33
|
+
// `public`, `system`) and that casing is data, not style: it is what is stored.
|
|
34
|
+
//
|
|
35
|
+
// All three are currently NON-SELECTIVE — every group in production is
|
|
36
|
+
// `active` / `public` / `system`. They are declared anyway because they are
|
|
37
|
+
// real vocabulary that gains meaning as the corpus grows, and a filter that
|
|
38
|
+
// silently ignores `status` would be worse than one that narrows to everything.
|
|
39
|
+
status: enumFacet(SupportedGroupStatuses, "Status"),
|
|
40
|
+
visibility: enumFacet(SupportedGroupVisibilities, "Visibility"),
|
|
41
|
+
managedBy: enumFacet(SupportedGroupManagedBy, "Managed By"),
|
|
42
|
+
|
|
43
|
+
// ── Flags ───────────────────────────────────────────────────────────────────
|
|
44
|
+
// No `.default(false)`: an unchecked box must mean "no constraint", not "only
|
|
45
|
+
// the non-featured ones", so absent simply omits the clause.
|
|
46
|
+
isFeatured: boolean().optional().label("Featured Only"),
|
|
47
|
+
isDiscoverable: boolean().optional().label("Discoverable Only"),
|
|
48
|
+
|
|
49
|
+
// ── Markets ─────────────────────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* `tokens`, NOT `codeTokens`. This is the trap in this file.
|
|
52
|
+
*
|
|
53
|
+
* Every other filter here uses `codeTokens` for country codes, because users
|
|
54
|
+
* and jobs store uppercase ISO alpha-2. Groups store them LOWERCASE (`in`,
|
|
55
|
+
* `se`, `ww` — verified against production). `codeTokens` uppercases its
|
|
56
|
+
* input, so `$in: ["IN"]` against a stored `"in"` matches nothing: no error,
|
|
57
|
+
* empty result, empty mail.
|
|
58
|
+
*
|
|
59
|
+
* If these are ever normalised to uppercase, this becomes `codeTokens` in the
|
|
60
|
+
* same change as the data migration, not before it.
|
|
61
|
+
*/
|
|
62
|
+
countries: tokens("Countries"),
|
|
63
|
+
featuredMarkets: tokens("Featured Markets"),
|
|
64
|
+
|
|
65
|
+
/** Group SLUGS, for naming specific groups. A display name breaks on rename. */
|
|
66
|
+
slugs: tokens("Slugs"),
|
|
67
|
+
}).label("Group Filter");
|
|
68
|
+
|
|
69
|
+
const GROUP_FILTER_KEYS = Object.keys(GroupFilterShape.fields);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* THE group filter.
|
|
73
|
+
*
|
|
74
|
+
* Wrapped in `rejectUnknownKeys` following `UserFilterSchema` rather than
|
|
75
|
+
* `JobFilterSchema`: an unknown key here is silently dropped by yup and then
|
|
76
|
+
* matches nothing downstream, so it must fail loudly at the point it is written.
|
|
77
|
+
*/
|
|
78
|
+
export const GroupFilterSchema = rejectUnknownKeys(
|
|
79
|
+
GroupFilterShape,
|
|
80
|
+
() => GROUP_FILTER_KEYS,
|
|
81
|
+
);
|
|
@@ -18,3 +18,18 @@ export enum GroupStatus {
|
|
|
18
18
|
Active = "active",
|
|
19
19
|
Archived = "archived",
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The enum values as arrays, for schemas that need `.oneOf(...)`.
|
|
24
|
+
*
|
|
25
|
+
* The same `Object.values(...)` form `page.constant.ts` already exports
|
|
26
|
+
* (`SupportedPageTypes`, `SupportedPageStatuses`). Derived rather than written
|
|
27
|
+
* out, so a new enum member cannot be added to one list and forgotten in the
|
|
28
|
+
* other.
|
|
29
|
+
*/
|
|
30
|
+
export const SupportedGroupStatuses = Object.values(GroupStatus);
|
|
31
|
+
export const SupportedGroupVisibilities = Object.values(GroupVisibility);
|
|
32
|
+
export const SupportedGroupManagedBy = Object.values(GroupManagedBy);
|
|
33
|
+
export const SupportedGroupMembershipStatuses = Object.values(
|
|
34
|
+
GroupMembershipStatus,
|
|
35
|
+
);
|