@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/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
- Sending = "sending",
4
- Sent = "sent",
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
- * The two scheduled kinds dedupe differently, and that difference is the reason
14
- * they are separate kinds rather than one kind with a flag:
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
- * • `Schedule` fires on a wall clock. Every occurrence is a shared moment, so
17
- * its identity is the occurrence itself and a recipient may legitimately be
18
- * mailed again next occurrence.
19
- * • `Lifecycle` fires relative to each user's own anchor date (signup, …), so
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
- export enum BindingKind {
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 SupportedBindingKinds = Object.values(BindingKind);
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/lifecycle send, enforced in code and
102
- * not overridable by config. Distinct from the one-shot audience cap: a campaign
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
  */