@thejob/schema 2.1.9 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +17 -0
- package/dist/index.cjs +2178 -723
- package/dist/index.d.cts +1991 -535
- package/dist/index.d.ts +1991 -535
- package/dist/index.js +2080 -698
- 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/push-subscription.schema.ts +97 -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
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Common schemas and constants.
|
|
3
3
|
*/
|
|
4
4
|
export * from "./common/common.schema.js";
|
|
5
|
+
export * from "./common/facet.schema.js";
|
|
5
6
|
export * from "./common/common.constant.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -19,11 +20,13 @@ export * from "./extensions/date-string.extension.js";
|
|
|
19
20
|
*/
|
|
20
21
|
export * from "./group/group.schema.js";
|
|
21
22
|
export * from "./group/group.constant.js";
|
|
23
|
+
export * from "./group/group-filter.schema.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Job schemas and constants.
|
|
25
27
|
*/
|
|
26
28
|
export * from "./job/job.schema.js";
|
|
29
|
+
export * from "./job/job-filter.schema.js";
|
|
27
30
|
export * from "./job/job.constant.js";
|
|
28
31
|
export * from "./job/job-taxonomy.constant.js";
|
|
29
32
|
export * from "./job/job-taxonomy.util.js";
|
|
@@ -44,11 +47,30 @@ export * from "./question/index.js";
|
|
|
44
47
|
*/
|
|
45
48
|
export * from "./location/location.schema.js";
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Geo reference schemas and constants (countries / states / cities).
|
|
52
|
+
*
|
|
53
|
+
* A MIRROR of the dr5hn countries-states-cities-database, owned and refreshed
|
|
54
|
+
* wholesale by common-service. Distinct from `LocationSchema` above, which is
|
|
55
|
+
* the per-document geo shape a job or user CARRIES; these are the reference
|
|
56
|
+
* tables its dropdowns are populated from.
|
|
57
|
+
*/
|
|
58
|
+
export * from "./geo/geo.constant.js";
|
|
59
|
+
export * from "./geo/country.schema.js";
|
|
60
|
+
export * from "./geo/state.schema.js";
|
|
61
|
+
export * from "./geo/city.schema.js";
|
|
62
|
+
export * from "./geo/country-filter.schema.js";
|
|
63
|
+
export * from "./geo/state-filter.schema.js";
|
|
64
|
+
export * from "./geo/city-filter.schema.js";
|
|
65
|
+
export * from "./geo/geo-sync.schema.js";
|
|
66
|
+
|
|
47
67
|
/**
|
|
48
68
|
* Page schemas and constants.
|
|
49
69
|
*/
|
|
50
70
|
export * from "./page/page.schema.js";
|
|
51
71
|
export * from "./page/page.constant.js";
|
|
72
|
+
export * from "./language/language-filter.schema.js";
|
|
73
|
+
export * from "./page/page-filter.schema.js";
|
|
52
74
|
|
|
53
75
|
/**
|
|
54
76
|
* Marketing schemas and constants (mailing lists, campaigns).
|
|
@@ -77,14 +99,25 @@ export * from "./report/report.constant.js";
|
|
|
77
99
|
/**
|
|
78
100
|
* Feedback schemas and constants (feedback widget + reports inbox).
|
|
79
101
|
*/
|
|
102
|
+
export * from "./feedback/feedback-filter.schema.js";
|
|
80
103
|
export * from "./feedback/feedback.schema.js";
|
|
81
104
|
export * from "./feedback/feedback.constant.js";
|
|
82
105
|
|
|
83
106
|
/**
|
|
84
107
|
* Skill schemas and constants.
|
|
85
108
|
*/
|
|
109
|
+
export * from "./skill/skill-filter.schema.js";
|
|
86
110
|
export * from "./skill/skill.schema.js";
|
|
87
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Billing schemas and constants (Paddle).
|
|
114
|
+
*
|
|
115
|
+
* Note that money here is in MINOR units, the opposite of the salary fields
|
|
116
|
+
* elsewhere in this package. See `MoneySchema`.
|
|
117
|
+
*/
|
|
118
|
+
export * from "./billing/billing.schema.js";
|
|
119
|
+
export * from "./billing/billing.constant.js";
|
|
120
|
+
|
|
88
121
|
/**
|
|
89
122
|
* Social account schemas and constants.
|
|
90
123
|
*/
|
|
@@ -95,6 +128,8 @@ export * from "./social-account/social-account.constant.js";
|
|
|
95
128
|
* User schemas and constants.
|
|
96
129
|
*/
|
|
97
130
|
export * from "./user/user.schema.js";
|
|
131
|
+
export * from "./user/user-filter.schema.js";
|
|
132
|
+
export * from "./user/push-subscription.schema.js";
|
|
98
133
|
|
|
99
134
|
export * from "./user/user-language.schema.js";
|
|
100
135
|
export * from "./user/general-detail.schema.js";
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The job filter: the closed vocabulary of facets a job query can filter on.
|
|
3
|
+
*
|
|
4
|
+
* The counterpart of `UserFilterSchema`, and the single declaration that
|
|
5
|
+
* `thejob-jobs-service` and `@thejob/jobs-client` both derive from — the client
|
|
6
|
+
* types its `JobListQuery` facets with `InferType`, and the service checks
|
|
7
|
+
* incoming keys against `Object.keys` of it. Two hand-maintained lists had
|
|
8
|
+
* already drifted: six facets the service accepted (`tags`, `subCategories`,
|
|
9
|
+
* `companyNames`, `isPromoted`, `featuredMarkets`, `createdAt`) could not be
|
|
10
|
+
* expressed through the client at all.
|
|
11
|
+
*
|
|
12
|
+
* WHAT A FACET MEANS stays in the service (`JOB_FACETS` maps each name to a
|
|
13
|
+
* document path and a match operator, which is not a consumer's business). This
|
|
14
|
+
* declares only the NAMES and the SHAPES a caller may send.
|
|
15
|
+
*
|
|
16
|
+
* `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are
|
|
17
|
+
* deliberately absent: they are reserved query params, not facets. `search` in
|
|
18
|
+
* particular is hybrid search over meaning — it names no field on a job
|
|
19
|
+
* document and cannot sit inside a filter clause.
|
|
20
|
+
*/
|
|
21
|
+
import { array, boolean, number, object, string } from "yup";
|
|
22
|
+
import {
|
|
23
|
+
SupportedEducationLevels,
|
|
24
|
+
SupportedExperienceLevels,
|
|
25
|
+
} from "../common/common.constant.js";
|
|
26
|
+
import { SupportedSalaryCurrencies } from "../user/user.constant.js";
|
|
27
|
+
import {
|
|
28
|
+
SupportedEmploymentTypes,
|
|
29
|
+
SupportedJobStatuses,
|
|
30
|
+
SupportedWorkModes,
|
|
31
|
+
} from "./job.constant.js";
|
|
32
|
+
import {
|
|
33
|
+
SupportedJobCategories,
|
|
34
|
+
SupportedJobIndustries,
|
|
35
|
+
SupportedJobSubCategories,
|
|
36
|
+
} from "./job-taxonomy.constant.js";
|
|
37
|
+
import {
|
|
38
|
+
codeTokens,
|
|
39
|
+
days,
|
|
40
|
+
enumFacet,
|
|
41
|
+
epoch,
|
|
42
|
+
rejectUnknownKeys,
|
|
43
|
+
tokens,
|
|
44
|
+
} from "../common/facet.schema.js";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every clause AND-s together; every LIST means OR ("remote or hybrid"). An
|
|
48
|
+
* empty list is "no constraint" and contributes no clause at all — never a term
|
|
49
|
+
* that matches nothing.
|
|
50
|
+
*/
|
|
51
|
+
/**
|
|
52
|
+
* The form section the geo facets share.
|
|
53
|
+
*
|
|
54
|
+
* Named once because it must be IDENTICAL across the four: `describeParams`
|
|
55
|
+
* buckets by this exact string, so one typo silently opens a second section
|
|
56
|
+
* with one control in it.
|
|
57
|
+
*/
|
|
58
|
+
const GEO_GROUP = "Geo";
|
|
59
|
+
|
|
60
|
+
const JobFilterShape = object({
|
|
61
|
+
// ── Classification ──────────────────────────────────────────────────────────
|
|
62
|
+
// Closed vocabularies, sent verbatim and matched exactly: a partial match
|
|
63
|
+
// would make "remote" also match a hypothetical "remote_first".
|
|
64
|
+
employmentType: enumFacet(SupportedEmploymentTypes, "Employment Type"),
|
|
65
|
+
workMode: enumFacet(SupportedWorkModes, "Work Mode"),
|
|
66
|
+
experienceLevel: enumFacet(SupportedExperienceLevels, "Experience Level"),
|
|
67
|
+
educationLevel: enumFacet(SupportedEducationLevels, "Education Level"),
|
|
68
|
+
status: enumFacet(SupportedJobStatuses, "Status"),
|
|
69
|
+
|
|
70
|
+
// ── Taxonomy ────────────────────────────────────────────────────────────────
|
|
71
|
+
// NOT `enumFacet`, deliberately. Taxonomy is stored as the ENGLISH LABEL
|
|
72
|
+
// ("Technology, Data & Digital"), not the enum constant, so a caller sends the
|
|
73
|
+
// label and constraining these to `SupportedJobCategories` would reject every
|
|
74
|
+
// value that actually matches.
|
|
75
|
+
//
|
|
76
|
+
// `meta.options` publishes the vocabulary anyway, for forms only. The schema
|
|
77
|
+
// still accepts any string — it must, per the note above — but an admin
|
|
78
|
+
// picking one of eight categories should not be typing it: the stored label
|
|
79
|
+
// is "Technology, Data & Digital", and a hand-typed "Technology" matches
|
|
80
|
+
// nothing while looking correctly configured.
|
|
81
|
+
category: tokens("Category").meta({ options: SupportedJobCategories }),
|
|
82
|
+
industry: tokens("Industry").meta({ options: SupportedJobIndustries }),
|
|
83
|
+
subCategories: tokens("Sub-category").meta({
|
|
84
|
+
options: SupportedJobSubCategories,
|
|
85
|
+
}),
|
|
86
|
+
tags: tokens("Tags"),
|
|
87
|
+
|
|
88
|
+
// ── Identity ────────────────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Specific jobs, by `shortId`. The one facet that names jobs outright rather
|
|
91
|
+
* than narrowing by an attribute of them.
|
|
92
|
+
*
|
|
93
|
+
* It exists for a HAND-PICKED set: a group whose jobs an admin chose
|
|
94
|
+
* individually stores exactly this list as `group.jobIds`, so the field and
|
|
95
|
+
* the facet share a name and the stored data needs no translation.
|
|
96
|
+
*
|
|
97
|
+
* Case is LOAD-BEARING and must not be normalised: a shortId is mixed case
|
|
98
|
+
* (`tI8wnSIgRG5O`), so the uppercase treatment country and state codes get
|
|
99
|
+
* would turn every lookup into a match on nothing.
|
|
100
|
+
*/
|
|
101
|
+
jobIds: tokens("Jobs"),
|
|
102
|
+
|
|
103
|
+
// ── Skills and company ──────────────────────────────────────────────────────
|
|
104
|
+
//
|
|
105
|
+
// `widget` is a RENDERING hint for whoever builds a form from this schema: it
|
|
106
|
+
// names the picker that produces values this facet can actually match, and
|
|
107
|
+
// nothing validates differently because of it. A form generated without one
|
|
108
|
+
// falls back to a free text box, which is what every admin filter was doing —
|
|
109
|
+
// so "Angular" was typed where the stored skill is "angular" and the filter
|
|
110
|
+
// narrowed to nothing while looking correctly configured.
|
|
111
|
+
skills: tokens("Skills").meta({ widget: "skill" }),
|
|
112
|
+
/** Company page SLUGS: a display name breaks the day a company renames itself. */
|
|
113
|
+
companies: tokens("Companies").meta({ widget: "company" }),
|
|
114
|
+
/**
|
|
115
|
+
* Company display names, for callers that only have one. Prefer `companies`.
|
|
116
|
+
*
|
|
117
|
+
* `hidden`: not a second control in a generated form. An admin picking a
|
|
118
|
+
* company gets the page, and `companies` (its slug) is the stable way to name
|
|
119
|
+
* it — a display name stops matching the day a company renames itself. This
|
|
120
|
+
* stays in the vocabulary for API callers who genuinely only hold a name.
|
|
121
|
+
*/
|
|
122
|
+
companyNames: tokens("Company Names").meta({ widget: "hidden" }),
|
|
123
|
+
|
|
124
|
+
// ── Location ────────────────────────────────────────────────────────────────
|
|
125
|
+
//
|
|
126
|
+
// Four facets, THREE controls. They are independent OR-lists: jobs-service
|
|
127
|
+
// matches each path separately, so `countryCodes` alone asks "anywhere in
|
|
128
|
+
// India" and `cities` alone asks "in Pune, wherever that is". Collapsing them
|
|
129
|
+
// into one object facet would lose exactly that, and lose the cross-entry
|
|
130
|
+
// reading ("has an India office OR a London office") with it.
|
|
131
|
+
//
|
|
132
|
+
// What they must NOT be is free text. A country here is `IN`, not "India",
|
|
133
|
+
// and a state code is a BARE `KA`, not `IN-KA` — type either long form and
|
|
134
|
+
// the filter matches nothing while looking correctly configured.
|
|
135
|
+
//
|
|
136
|
+
// So each names the GEO reference autocomplete (common-service `/geo/*`, a
|
|
137
|
+
// closed vocabulary) and the field of the picked row it stores. `geoLevel`
|
|
138
|
+
// says which endpoint to search and `geoField` which key lands in the facet.
|
|
139
|
+
//
|
|
140
|
+
// THREE controls, not four: a picked state carries its name and its code, so
|
|
141
|
+
// the old separate "State Codes" box asked twice for one fact. See `states`.
|
|
142
|
+
//
|
|
143
|
+
// Geoapify's `/locations` is NOT the vocabulary here. It geocodes free-text
|
|
144
|
+
// addresses — the right tool for "where is this job", the wrong one for a
|
|
145
|
+
// filter, since it is a paid call per keystroke and its results are open.
|
|
146
|
+
/** ISO 3166-1 alpha-2, uppercase. */
|
|
147
|
+
countryCodes: codeTokens("Countries").meta({
|
|
148
|
+
group: GEO_GROUP,
|
|
149
|
+
widget: "geo",
|
|
150
|
+
geoLevel: "country",
|
|
151
|
+
geoField: "countryCode",
|
|
152
|
+
}),
|
|
153
|
+
/** Stored BARE (`KA`, not `IN-KA`) — see @todo STATE-CODE-ISO-PREFIX. */
|
|
154
|
+
stateCodes: codeTokens("States").meta({
|
|
155
|
+
group: GEO_GROUP,
|
|
156
|
+
widget: "geo",
|
|
157
|
+
geoLevel: "state",
|
|
158
|
+
geoField: "stateCode",
|
|
159
|
+
}),
|
|
160
|
+
/**
|
|
161
|
+
* State NAMES, kept as a facet but WITHOUT a control of its own.
|
|
162
|
+
*
|
|
163
|
+
* There is no second box to fill: a picked state carries its name and its
|
|
164
|
+
* code together, so asking an admin for both was asking twice for one fact.
|
|
165
|
+
* The picker writes `stateCodes`, which is where the data is going.
|
|
166
|
+
*
|
|
167
|
+
* The facet stays because jobs still hold state names — 15,483 carry a name
|
|
168
|
+
* with NO code — and a saved campaign may already constrain on it. Removing
|
|
169
|
+
* it would silently drop that constraint from those campaigns. It becomes
|
|
170
|
+
* unreachable from the UI and is settled by the data migration.
|
|
171
|
+
*
|
|
172
|
+
* @todo STATE-CODE-ISO-PREFIX — retire once every job carries a stateCode.
|
|
173
|
+
*/
|
|
174
|
+
states: tokens("States").meta({ group: GEO_GROUP, widget: "hidden" }),
|
|
175
|
+
/**
|
|
176
|
+
* City NAMES, and the one facet here that does not match on a code.
|
|
177
|
+
*
|
|
178
|
+
* A name is a display label, not a matching key: it is not unique (99 Indian
|
|
179
|
+
* city names repeat in the reference data) and not stable (Bengaluru was
|
|
180
|
+
* Bangalore). Every other facet here matches a code for exactly that reason.
|
|
181
|
+
*
|
|
182
|
+
* Cities cannot yet. There is no city code to match ON either side: the
|
|
183
|
+
* reference dataset carries none, and no job stores one — `LocationSchema`
|
|
184
|
+
* has country/state codes and a city NAME. A `cityCodes` facet would match
|
|
185
|
+
* zero of 82,875 jobs on day one.
|
|
186
|
+
*
|
|
187
|
+
* So this stays on the name, and the picker does the work a code would: the
|
|
188
|
+
* admin selects a real city from the reference data, so the stored string is
|
|
189
|
+
* guaranteed to be a spelling that exists rather than a typo that silently
|
|
190
|
+
* matches nothing.
|
|
191
|
+
*
|
|
192
|
+
* @todo CITY-CODE-UNLOCODE — the fix is UN/LOCODE (or IATA) as the city key,
|
|
193
|
+
* and it is a data programme, not a facet change: ingest and reconcile the
|
|
194
|
+
* codes against the 152,970 reference rows, add `cityCode` to
|
|
195
|
+
* `LocationSchema`, backfill the ~81,000 jobs that carry only a name, and fix
|
|
196
|
+
* the write paths that emit it. Only then can this match on a code.
|
|
197
|
+
*/
|
|
198
|
+
cities: tokens("Cities").meta({
|
|
199
|
+
group: GEO_GROUP,
|
|
200
|
+
widget: "geo",
|
|
201
|
+
geoLevel: "city",
|
|
202
|
+
geoField: "name",
|
|
203
|
+
}),
|
|
204
|
+
|
|
205
|
+
// ── Flags ───────────────────────────────────────────────────────────────────
|
|
206
|
+
// No `.default(false)`: an unchecked box must mean "no constraint", not "only
|
|
207
|
+
// the non-featured ones", so absent simply omits the clause.
|
|
208
|
+
isFeatured: boolean().optional().label("Featured Only"),
|
|
209
|
+
isPromoted: boolean().optional().label("Promoted Only"),
|
|
210
|
+
featuredMarkets: tokens("Featured Markets"),
|
|
211
|
+
|
|
212
|
+
// ── Ranges ──────────────────────────────────────────────────────────────────
|
|
213
|
+
/**
|
|
214
|
+
* Matched as an OVERLAP against the job's advertised band, not as a bound on
|
|
215
|
+
* one end: `{min}` keeps jobs whose `salaryRange.max` reaches it.
|
|
216
|
+
*/
|
|
217
|
+
// NOT `currencyBound`: that guards the USER filter, where several currencies
|
|
218
|
+
// can be selected and a bound is meaningless across them. Here the currency is
|
|
219
|
+
// a single sibling field, so the bound is plain and the currency simply
|
|
220
|
+
// narrows which jobs are compared.
|
|
221
|
+
salary: object({
|
|
222
|
+
min: number().integer().min(0).optional().default(undefined).label("Salary ≥"),
|
|
223
|
+
max: number().integer().min(0).optional().default(undefined).label("Salary ≤"),
|
|
224
|
+
currency: string().oneOf(SupportedSalaryCurrencies).optional().label("Currency"),
|
|
225
|
+
})
|
|
226
|
+
.optional()
|
|
227
|
+
.default(undefined)
|
|
228
|
+
.label("Salary"),
|
|
229
|
+
|
|
230
|
+
/** Posted within this many days. Relative, so a saved filter does not rot. */
|
|
231
|
+
postedWithinDays: days("Posted Within (days)"),
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Absolute posting window, for callers that want bounds rather than the
|
|
235
|
+
* relative `postedWithinDays`. EPOCH MS, matching how the field is stored — a
|
|
236
|
+
* coerced date string would not compare.
|
|
237
|
+
*/
|
|
238
|
+
createdAt: object({
|
|
239
|
+
min: epoch("Posted After"),
|
|
240
|
+
max: epoch("Posted Before"),
|
|
241
|
+
})
|
|
242
|
+
.optional()
|
|
243
|
+
.default(undefined)
|
|
244
|
+
.label("Posted Between"),
|
|
245
|
+
}).label("Job Filter");
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* THE job filter, with unknown keys REJECTED.
|
|
249
|
+
*
|
|
250
|
+
* The guard its three sibling filter schemas (user, group, page) already carry,
|
|
251
|
+
* and this one was written without. Yup's own `.noUnknown()` is not an
|
|
252
|
+
* alternative: it only errors under `.strict()`, and strict mode also disables
|
|
253
|
+
* coercion — which would reject the `"7"` and `"true"` strings every query
|
|
254
|
+
* string is made of. `rejectUnknownKeys` is a `.test()` so both hold at once.
|
|
255
|
+
*
|
|
256
|
+
* Why it matters more here than anywhere else: a job filter arrives from a
|
|
257
|
+
* caller-controlled URL. Without this, a typo'd facet (`workplaceType`) and the
|
|
258
|
+
* retired `filter[workMode]=remote` form both VALIDATE, then reach the service
|
|
259
|
+
* as a facet no job document has — a 200 with a plausible full page, which is
|
|
260
|
+
* indistinguishable from a working search. Three consumers each hand-rolled an
|
|
261
|
+
* `assertKnownFacets` over `Object.keys` of this schema to compensate; this is
|
|
262
|
+
* the declaration those existed to substitute for.
|
|
263
|
+
*/
|
|
264
|
+
export const JobFilterSchema = rejectUnknownKeys(JobFilterShape, () =>
|
|
265
|
+
Object.keys(JobFilterShape.fields),
|
|
266
|
+
);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language filter: the closed vocabulary of facets a language query can
|
|
3
|
+
* filter on, for common-service's `GET /languages`.
|
|
4
|
+
*
|
|
5
|
+
* The counterpart of `SkillFilterSchema`, and it exists for the same reason:
|
|
6
|
+
* `parseFilter` there accepted ANY key and passed it to Mongo, so an invented
|
|
7
|
+
* facet returned 200 OK with zero rows and no error anywhere.
|
|
8
|
+
*
|
|
9
|
+
* ONE facet, because one is all this resource has. There is no `LanguageSchema`
|
|
10
|
+
* in this package: the `languages` collection is a flat reference list, and the
|
|
11
|
+
* only field common-service commits to is `name`, which is what its free-text
|
|
12
|
+
* search matches on. `UserLanguageSchema` is a different thing entirely — a
|
|
13
|
+
* USER's claim about a language, carrying `proficiencyLevel` — and its fields
|
|
14
|
+
* are not facets of the reference list.
|
|
15
|
+
*
|
|
16
|
+
* The collection is EMPTY in both `thejob` and `thejob-dev` today, and the one
|
|
17
|
+
* live caller (`language-field.svelte`) sends only `search`, `fields` and
|
|
18
|
+
* `sortBy`. So this is not modelling observed queries: it exists so the service
|
|
19
|
+
* has a closed vocabulary like every other one, and so the first real facet is
|
|
20
|
+
* an addition to a declaration rather than a new mechanism. Add facets here
|
|
21
|
+
* when a caller needs one, against real data.
|
|
22
|
+
*
|
|
23
|
+
* `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are reserved
|
|
24
|
+
* query params, not facets, and are absent for that reason instead.
|
|
25
|
+
*/
|
|
26
|
+
import { object } from "yup";
|
|
27
|
+
import { rejectUnknownKeys, tokens } from "../common/facet.schema.js";
|
|
28
|
+
|
|
29
|
+
const LanguageFilterShape = object({
|
|
30
|
+
/** Language names, matched exactly. Free-text matching is `?search=`. */
|
|
31
|
+
names: tokens("Names"),
|
|
32
|
+
}).label("Language Filter");
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Wrapped so an unknown key is REJECTED rather than silently ignored. See
|
|
36
|
+
* `SkillFilterSchema` for why this is a `.test()` and not `.noUnknown()`.
|
|
37
|
+
*/
|
|
38
|
+
export const LanguageFilterSchema = rejectUnknownKeys(LanguageFilterShape, () =>
|
|
39
|
+
Object.keys(LanguageFilterShape.fields),
|
|
40
|
+
);
|
|
@@ -1,24 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a campaign is in its life, as an OPERATOR would describe it.
|
|
3
|
+
*
|
|
4
|
+
* The states answer "what is this campaign doing?", not "what is the engine
|
|
5
|
+
* doing this second". That distinction is the whole design: a repeating
|
|
6
|
+
* campaign spends almost all its time between occurrences, and a status that
|
|
7
|
+
* tracked the send itself left it reading `sent` while it had years of mail
|
|
8
|
+
* still to go, indistinguishable from a one-shot that was finished forever.
|
|
9
|
+
*
|
|
10
|
+
* Per-occurrence detail (which run sent, how many, what failed) lives on the
|
|
11
|
+
* occurrence rows. This field is about the CAMPAIGN.
|
|
12
|
+
*
|
|
13
|
+
* draft ──activate──> running ──pause──> paused ──resume──> running
|
|
14
|
+
* │ │
|
|
15
|
+
* ├──────stop────────┤
|
|
16
|
+
* │ │
|
|
17
|
+
* v v
|
|
18
|
+
* stopped stopped (terminal)
|
|
19
|
+
* │
|
|
20
|
+
* end condition met
|
|
21
|
+
* v
|
|
22
|
+
* completed (terminal)
|
|
23
|
+
*
|
|
24
|
+
* `failed` is reachable from `running` when a send throws, and is terminal.
|
|
25
|
+
*/
|
|
1
26
|
export enum CampaignStatus {
|
|
27
|
+
/** Being written. Has never sent and is not considered by the scheduler. */
|
|
2
28
|
Draft = "draft",
|
|
3
|
-
|
|
4
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Live: the scheduler will fire it whenever its schedule says so.
|
|
31
|
+
*
|
|
32
|
+
* A campaign is `running` from activation until something ends it, INCLUDING
|
|
33
|
+
* the gaps between occurrences. An "every day, forever" campaign is therefore
|
|
34
|
+
* `running` permanently, which is the honest answer — it is never finished.
|
|
35
|
+
*/
|
|
36
|
+
Running = "running",
|
|
37
|
+
/**
|
|
38
|
+
* Halted by a person, and resumable.
|
|
39
|
+
*
|
|
40
|
+
* Moments that pass while paused are MISSED, and missed moments are skipped
|
|
41
|
+
* rather than replayed, so resuming does not produce a burst of backdated
|
|
42
|
+
* mail. An `afterOccurrences` campaign keeps its remaining budget, because
|
|
43
|
+
* that budget counts sends that happened.
|
|
44
|
+
*/
|
|
45
|
+
Paused = "paused",
|
|
46
|
+
/** Halted by a person, permanently. Cannot be resumed; clone it instead. */
|
|
47
|
+
Stopped = "stopped",
|
|
48
|
+
/** Its end condition is satisfied: afterOccurrences spent, or onDate passed. */
|
|
49
|
+
Completed = "completed",
|
|
50
|
+
/** A send threw and the run could not be completed. */
|
|
5
51
|
Failed = "failed",
|
|
6
52
|
}
|
|
7
53
|
|
|
8
54
|
export const SupportedCampaignStatuses = Object.values(CampaignStatus);
|
|
9
55
|
|
|
56
|
+
/** No transition leaves these. */
|
|
57
|
+
export const TerminalCampaignStatuses = [
|
|
58
|
+
CampaignStatus.Stopped,
|
|
59
|
+
CampaignStatus.Completed,
|
|
60
|
+
CampaignStatus.Failed,
|
|
61
|
+
] as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The only status changes allowed, keyed by where the campaign is now.
|
|
65
|
+
*
|
|
66
|
+
* Declared as data rather than written as `if` chains in each route, so an
|
|
67
|
+
* illegal move (resuming a stopped campaign, pausing a draft) is refused in one
|
|
68
|
+
* place and the diagram above cannot quietly drift from the code.
|
|
69
|
+
*/
|
|
70
|
+
export const CampaignStatusTransitions: Record<CampaignStatus, readonly CampaignStatus[]> = {
|
|
71
|
+
[CampaignStatus.Draft]: [CampaignStatus.Running],
|
|
72
|
+
[CampaignStatus.Running]: [
|
|
73
|
+
CampaignStatus.Paused,
|
|
74
|
+
CampaignStatus.Stopped,
|
|
75
|
+
CampaignStatus.Completed,
|
|
76
|
+
CampaignStatus.Failed,
|
|
77
|
+
],
|
|
78
|
+
[CampaignStatus.Paused]: [CampaignStatus.Running, CampaignStatus.Stopped],
|
|
79
|
+
[CampaignStatus.Stopped]: [],
|
|
80
|
+
[CampaignStatus.Completed]: [],
|
|
81
|
+
[CampaignStatus.Failed]: [],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Whether a campaign may move from `from` to `to`. */
|
|
85
|
+
export function canTransitionCampaign(from: CampaignStatus, to: CampaignStatus): boolean {
|
|
86
|
+
return CampaignStatusTransitions[from]?.includes(to) ?? false;
|
|
87
|
+
}
|
|
88
|
+
|
|
10
89
|
/**
|
|
11
90
|
* How a campaign decides when to send.
|
|
12
91
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
92
|
+
* Every kind here fires on a wall clock, so an occurrence is a shared moment:
|
|
93
|
+
* its identity is the occurrence itself, and a recipient may legitimately be
|
|
94
|
+
* mailed again on the next one.
|
|
15
95
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* there is no shared moment. Identity is the user, permanently: a lifecycle
|
|
21
|
-
* campaign mails a given person at most once, ever.
|
|
96
|
+
* A `Lifecycle` kind (per-user, N days after that user's own anchor date) was
|
|
97
|
+
* removed; it was never used by any campaign. It did not belong beside these:
|
|
98
|
+
* having no shared moment, it deduped on the USER permanently rather than on
|
|
99
|
+
* the occurrence, which is a different engine wearing the same word.
|
|
22
100
|
*/
|
|
23
101
|
export enum CampaignTriggerKind {
|
|
24
102
|
/** Admin presses send. Also the dry-run test path. */
|
|
@@ -27,8 +105,6 @@ export enum CampaignTriggerKind {
|
|
|
27
105
|
Once = "once",
|
|
28
106
|
/** Repeats on a wall-clock schedule until its end condition. */
|
|
29
107
|
Schedule = "schedule",
|
|
30
|
-
/** Fires per user, N days after that user's anchor date. Once per user ever. */
|
|
31
|
-
Lifecycle = "lifecycle",
|
|
32
108
|
}
|
|
33
109
|
|
|
34
110
|
export const SupportedCampaignTriggerKinds = Object.values(CampaignTriggerKind);
|
|
@@ -51,20 +127,6 @@ export enum CampaignEndKind {
|
|
|
51
127
|
|
|
52
128
|
export const SupportedCampaignEndKinds = Object.values(CampaignEndKind);
|
|
53
129
|
|
|
54
|
-
/**
|
|
55
|
-
* The user-document date a `lifecycle` trigger counts its offset from. A closed
|
|
56
|
-
* list because each value must be a real, indexed, immutable-enough date on the
|
|
57
|
-
* user: an anchor that moves would re-fire the campaign for someone already
|
|
58
|
-
* mailed, which the permanent idempotency key would then silently suppress.
|
|
59
|
-
*/
|
|
60
|
-
export enum CampaignAnchorField {
|
|
61
|
-
CreatedAt = "createdAt",
|
|
62
|
-
OnboardingCompletedAt = "onboardingCompletedAt",
|
|
63
|
-
MarketingConsentUpdatedAt = "marketingConsent.updatedAt",
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export const SupportedCampaignAnchorFields = Object.values(CampaignAnchorField);
|
|
67
|
-
|
|
68
130
|
/**
|
|
69
131
|
* Where a single template variable's value comes from. The admin picks one per
|
|
70
132
|
* variable the chosen template declares.
|
|
@@ -73,7 +135,18 @@ export const SupportedCampaignAnchorFields = Object.values(CampaignAnchorField);
|
|
|
73
135
|
* personalised iff any binding is `Recipient` or `Source`. A stored flag could
|
|
74
136
|
* disagree with the bindings it describes.
|
|
75
137
|
*/
|
|
76
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Where ONE template variable's value comes from.
|
|
140
|
+
*
|
|
141
|
+
* A campaign names a template, the template declares its variables, and each
|
|
142
|
+
* variable needs a source: a fixed string, something about the recipient, a
|
|
143
|
+
* link, or a field of whatever the campaign's data source returned. That
|
|
144
|
+
* mapping is a "binding".
|
|
145
|
+
*
|
|
146
|
+
* The string VALUES are stored in campaign documents — renaming one is a data
|
|
147
|
+
* migration, not a refactor.
|
|
148
|
+
*/
|
|
149
|
+
export enum TemplateVariableBindingKind {
|
|
77
150
|
/** A literal typed by the admin. Same for everyone. */
|
|
78
151
|
Static = "static",
|
|
79
152
|
/** A field of the resolved recipient, e.g. `name`. */
|
|
@@ -84,7 +157,9 @@ export enum BindingKind {
|
|
|
84
157
|
Source = "source",
|
|
85
158
|
}
|
|
86
159
|
|
|
87
|
-
export const
|
|
160
|
+
export const SupportedTemplateVariableBindingKinds = Object.values(
|
|
161
|
+
TemplateVariableBindingKind,
|
|
162
|
+
);
|
|
88
163
|
|
|
89
164
|
/** Lifecycle of one materialised occurrence of a scheduled campaign. */
|
|
90
165
|
export enum OccurrenceStatus {
|
|
@@ -98,8 +173,8 @@ export enum OccurrenceStatus {
|
|
|
98
173
|
export const SupportedOccurrenceStatuses = Object.values(OccurrenceStatus);
|
|
99
174
|
|
|
100
175
|
/**
|
|
101
|
-
* Hard ceiling on recipients per scheduled
|
|
102
|
-
*
|
|
176
|
+
* Hard ceiling on recipients per scheduled send, enforced in code and not
|
|
177
|
+
* overridable by config. Distinct from the one-shot audience cap: a campaign
|
|
103
178
|
* that repeats forever gets the tighter bound, because a misconfigured filter
|
|
104
179
|
* that mails too many people does it again every occurrence.
|
|
105
180
|
*/
|