@thejob/schema 2.1.9 → 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.
@@ -1,360 +1,51 @@
1
1
  import { array, boolean, lazy, mixed, number, object, string } from "yup";
2
- import {
3
- SupportedEducationLevels,
4
- SupportedExperienceLevels,
5
- SupportedStudyTypes,
6
- } from "../common/common.constant.js";
7
- import { SupportedPageTypes } from "../page/page.constant.js";
8
- import { SupportedSocialAccounts } from "../social-account/social-account.constant.js";
9
- import {
10
- SupportedJobAlertFrequencies,
11
- SupportedJobSearchUrgencies,
12
- SupportedNotificationChannels,
13
- SupportedProficiencyLevels,
14
- SupportedReferralSources,
15
- SupportedSalaryCurrencies,
16
- SupportedSignupContextSources,
17
- SupportedUserProfileVisibilities,
18
- SupportedUserRoles,
19
- SupportedUserStatuses,
20
- } from "../user/user.constant.js";
21
2
  import {
22
3
  CAMPAIGN_MAX_RECIPIENTS_CEILING,
23
4
  CampaignEndKind,
24
5
  CampaignTriggerKind,
25
- SupportedBindingKinds,
26
- SupportedCampaignAnchorFields,
6
+ CampaignStatus,
27
7
  SupportedCampaignEndKinds,
8
+ SupportedCampaignStatuses,
28
9
  SupportedCampaignIntervalUnits,
29
10
  SupportedCampaignTriggerKinds,
11
+ SupportedTemplateVariableBindingKinds,
30
12
  } from "./marketing.constant.js";
31
-
32
- // ─── Audience filter ─────────────────────────────────────────────────────────
33
- // The saved query a mailing list resolves against the users collection. Every
34
- // clause is optional and AND-ed; empty groups match everyone.
35
- //
36
- // Two kinds of clause:
37
- // • Enum facets (`.oneOf(...)`) — closed vocabularies rendered as chip pickers.
38
- // • Free-text token facets — open vocabularies (skills, companies, institutes,
39
- // …) matched case-insensitively against a nested array/field. Any token in
40
- // the list matches (OR within a facet), facets AND together.
41
-
42
- const tokens = (label: string) =>
43
- array().of(string().trim().required()).optional().default([]).label(label);
44
-
45
- const enumFacet = (values: readonly string[], label: string) =>
46
- array()
47
- .of(string().oneOf(values).required())
48
- .optional()
49
- .default([])
50
- .label(label);
51
-
52
- // Relative windows are counted in whole days/months at resolution time. Bounded
53
- // so a typo cannot turn into an unbounded scan; `undefined` (not 0) is "unset",
54
- // because 0 days is a meaningful value.
55
- const days = (label: string) =>
56
- number().integer().min(0).max(3650).optional().default(undefined).label(label);
57
-
58
- const months = (label: string) =>
59
- number().integer().min(0).max(1200).optional().default(undefined).label(label);
60
-
61
- const percent = (label: string) =>
62
- number().integer().min(0).max(100).optional().default(undefined).label(label);
63
-
64
- /** Absolute epoch milliseconds. */
65
- const epoch = (label: string) =>
66
- number().integer().min(0).optional().default(undefined).label(label);
67
-
68
- /**
69
- * A salary bound, valid only when the filter also pins exactly one currency.
70
- * `minSalary` is stored as a bare integer, so comparing across USD/EUR/GBP/SEK/INR
71
- * is not a comparison at all.
72
- */
73
- const salaryBound = (label: string) =>
74
- number()
75
- .integer()
76
- .min(0)
77
- .optional()
78
- .default(undefined)
79
- .label(label)
80
- .when("salaryCurrencies", {
81
- is: (v: unknown) => !Array.isArray(v) || v.length !== 1,
82
- then: (s) =>
83
- s.test(
84
- "requires-single-currency",
85
- "Pick exactly one salary currency to filter by salary",
86
- (value) => value === undefined || value === null,
87
- ),
88
- });
13
+ import { epoch } from "../common/facet.schema.js";
14
+ import { UserFilterSchema } from "../user/user-filter.schema.js";
89
15
 
90
16
  /**
91
- * Every clause except `or`. Declared separately so `ListFilterSchema` can embed
92
- * it as its branch type without referring to itself: the union is deliberately
93
- * one level deep, and a non-recursive definition is what enforces that.
94
- */
95
- export const ListFilterBranchSchema = object({
96
- // ── Enum facets (closed vocabularies, chip pickers) ─────────────────────────
97
- countries: array().of(string().required()).optional().default([]).label("Countries"),
98
- roles: enumFacet(SupportedUserRoles, "Roles"),
99
- // The user's own current seniority (UserSchema.experienceLevel).
100
- experienceLevels: enumFacet(SupportedExperienceLevels, "Experience Levels"),
101
- // Levels the user is *targeting* (distinct from their current experienceLevel).
102
- targetExperienceLevels: enumFacet(
103
- SupportedExperienceLevels,
104
- "Target Experience Levels",
105
- ),
106
- jobSearchUrgencies: enumFacet(SupportedJobSearchUrgencies, "Job Search Urgency"),
107
- referralSources: enumFacet(SupportedReferralSources, "Referral Source"),
108
- statuses: enumFacet(SupportedUserStatuses, "Account Status"),
109
- profileVisibilities: enumFacet(
110
- SupportedUserProfileVisibilities,
111
- "Profile Visibility",
112
- ),
113
- // Proficiency held on any skill entry (skills[].proficiencyLevel).
114
- skillProficiencyLevels: enumFacet(
115
- SupportedProficiencyLevels,
116
- "Skill Proficiency",
117
- ),
118
- // Proficiency held on any language entry (languages[].proficiencyLevel).
119
- languageProficiencyLevels: enumFacet(
120
- SupportedProficiencyLevels,
121
- "Language Proficiency",
122
- ),
123
- // Mode of any education entry (educations[].studyType).
124
- studyTypes: enumFacet(SupportedStudyTypes, "Study Type"),
125
- // Platforms the user linked a social account for (socialAccounts[].type).
126
- socialAccountTypes: enumFacet(SupportedSocialAccounts, "Social Accounts"),
127
- // Currency of the user's salary floor (minSalaryCurrency).
128
- salaryCurrencies: enumFacet(SupportedSalaryCurrencies, "Salary Currency"),
129
-
130
- // ── Free-text token facets (nested profile arrays) ──────────────────────────
131
- skills: tokens("Skills"),
132
- languages: tokens("Languages"),
133
- interests: tokens("Interests"),
134
- companies: tokens("Companies"),
135
- designations: tokens("Job Titles / Designations"),
136
- institutes: tokens("Institutes"),
137
- courses: tokens("Courses"),
138
- fieldsOfStudy: tokens("Fields of Study"),
139
- certifications: tokens("Certifications"),
140
- certificationAuthorities: tokens("Certification Authorities"),
141
- projects: tokens("Projects"),
142
- jobRoles: tokens("Target Roles"),
143
- jobLocations: tokens("Preferred Job Locations"),
144
- // Companies a recruiter is hiring for (recruiterProfile.hiringFor[].page.name).
145
- hiringForCompanies: tokens("Hiring For (Companies)"),
146
- // Institutes a coordinator works at (coordinatorProfile.coordinatesAt[].page.name).
147
- coordinatesAtInstitutes: tokens("Coordinates At (Institutes)"),
148
- // Case-insensitive keyword match against headline + aboutMe.
149
- keywords: tokens("Profile Keywords"),
150
-
151
- // ── Range / boolean facets ──────────────────────────────────────────────────
152
- // A salary bound is meaningless without a currency: `minSalary` is a raw
153
- // integer and 50000 SEK is not 50000 USD, so an un-currencied range matches
154
- // across currencies and quietly means nothing. Requiring exactly one currency
155
- // alongside a bound is what makes the comparison sound.
156
- minSalaryFloor: salaryBound("Min Salary ≥"),
157
- minSalaryCeil: salaryBound("Min Salary ≤"),
158
- openToWorkOnly: boolean().optional().default(false).label("Open to Work Only"),
159
- remoteExperienceOnly: boolean()
160
- .optional()
161
- .default(false)
162
- .label("Has Remote Experience"),
163
- hasResumeOnly: boolean().optional().default(false).label("Has Resume"),
164
- onboardingCompletedOnly: boolean()
165
- .optional()
166
- .default(false)
167
- .label("Onboarding Completed"),
168
- emailVerifiedOnly: boolean().optional().default(false).label("Email Verified Only"),
169
- mobileVerifiedOnly: boolean()
170
- .optional()
171
- .default(false)
172
- .label("Mobile Verified Only"),
173
-
174
- // ── Lifecycle windows ───────────────────────────────────────────────────────
175
- // Relative, because a saved list is resolved fresh on every send: a stored
176
- // absolute epoch would freeze at authoring time and the audience would decay
177
- // to nothing. The absolute forms below exist for one-shot sends; when both are
178
- // present the relative form wins.
179
- lastSeenBeforeDays: days("Inactive For (days) ≥"),
180
- lastSeenAfterDays: days("Active Within (days)"),
181
- createdAtWithinDays: days("Signed Up Within (days)"),
182
- createdAtOlderThanDays: days("Signed Up Before (days ago)"),
183
- completenessMin: percent("Profile Completeness ≥"),
184
- completenessMax: percent("Profile Completeness ≤"),
185
- // Absolute epoch-ms forms. Prefer the relative fields above for anything that
186
- // repeats.
187
- lastSeenBefore: epoch("Last Seen Before"),
188
- lastSeenAfter: epoch("Last Seen After"),
189
- createdAtFrom: epoch("Signed Up After"),
190
- createdAtBefore: epoch("Signed Up Before"),
191
-
192
- // ── Geography ───────────────────────────────────────────────────────────────
193
- // `countries` above matches location.countryCode. These narrow within it.
194
- // NOTE: stateCodes are stored bare (`KA`, not `IN-KA`) — see the pending
195
- // @todo STATE-CODE-ISO-PREFIX migration. Match the stored form, do not prefix.
196
- states: tokens("States"),
197
- stateCodes: tokens("State Codes"),
198
- cities: tokens("Cities"),
199
- // Radius search around a point, against location.geo. All three required
200
- // together; validated as a group rather than field-by-field so a half-filled
201
- // radius cannot silently match everyone.
202
- withinRadius: object({
203
- lat: number().min(-90).max(90).required().label("Latitude"),
204
- lng: number().min(-180).max(180).required().label("Longitude"),
205
- km: number().moreThan(0).max(20_000).required().label("Radius (km)"),
206
- })
207
- .optional()
208
- .default(undefined)
209
- .label("Within Radius"),
210
-
211
- // ── Signup context (immutable market / locale) ──────────────────────────────
212
- signupCountries: tokens("Signup Countries"),
213
- signupLocales: tokens("Signup Locales"),
214
- signupSources: enumFacet(SupportedSignupContextSources, "Signup Source"),
215
-
216
- // ── Attainment ──────────────────────────────────────────────────────────────
217
- // Absent on users who predate the field. Absent means "unknown", never a
218
- // match — filtering on this necessarily excludes them.
219
- educationLevels: enumFacet(SupportedEducationLevels, "Education Level"),
220
-
221
- // ── Consent metadata ────────────────────────────────────────────────────────
222
- // `marketingConsent.subscribed` is NOT a facet: it is a hard opt-in gate
223
- // applied unconditionally by user-service and is not relaxable from here.
224
- jobAlertFrequencies: enumFacet(
225
- SupportedJobAlertFrequencies,
226
- "Job Alert Frequency",
227
- ),
228
- consentSources: tokens("Consent Source"),
229
- consentUpdatedWithinDays: days("Consent Updated Within (days)"),
230
-
231
- // ── Notification preferences ────────────────────────────────────────────────
232
- // Read as opt-IN (`=== true`), matching marketingConsent and NOT the `!== false`
233
- // reading used for transactional notification routing. A campaign is bulk mail,
234
- // so a channel the user never affirmatively enabled does not count as consent.
235
- // `productUpdates` defaults to false on the user document, so targeting it
236
- // selects only people who deliberately turned it on.
237
- notificationChannelsEnabled: enumFacet(
238
- SupportedNotificationChannels,
239
- "Notification Channels Enabled",
240
- ),
241
-
242
- // ── Tenure / recency ────────────────────────────────────────────────────────
243
- // Against workExperiences[].duration and educations[].duration ("YYYY-MM").
244
- currentlyEmployed: boolean()
245
- .optional()
246
- .default(false)
247
- .label("Currently Employed"),
248
- graduatedWithinMonths: months("Graduated Within (months)"),
249
- skillUsedWithinMonths: months("Skill Used Within (months)"),
250
-
251
- // ── Profile richness ────────────────────────────────────────────────────────
252
- hasImage: boolean().optional().default(false).label("Has Photo"),
253
- hasHeadline: boolean().optional().default(false).label("Has Headline"),
254
- hasAboutMe: boolean().optional().default(false).label("Has About Me"),
255
- hasMobile: boolean().optional().default(false).label("Has Mobile Number"),
256
- // Segment by WHICH profile section is missing (profileCompleteness.missing[]),
257
- // rather than only by the overall percentage.
258
- missingSections: tokens("Missing Profile Sections"),
259
-
260
- // ── Employer / institute taxonomy ───────────────────────────────────────────
261
- companyTypes: enumFacet(SupportedPageTypes, "Company Type"),
262
- instituteTypes: enumFacet(SupportedPageTypes, "Institute Type"),
263
-
264
- // ── Activity ────────────────────────────────────────────────────────────────
265
- // Backed by denormalised counters on the user document, because the source
266
- // data lives in other services' collections. Tri-state: `false` is a real
267
- // filter ("has NO job alert"), which is the audience an alert nudge wants, so
268
- // these are `undefined` when unset rather than defaulting to false.
269
- hasJobAlert: boolean()
270
- .optional()
271
- .default(undefined)
272
- .label("Has a Job Alert"),
273
- hasApplied: boolean().optional().default(undefined).label("Has Applied"),
274
- appliedWithinDays: days("Applied Within (days)"),
275
-
276
- // ── Free-text search (name / email) ─────────────────────────────────────────
277
- search: string().trim().optional().label("Search"),
278
-
279
- })
280
- .label("Audience Filter Branch");
281
-
282
- /**
283
- * Rejects filter keys the query builder does not understand.
17
+ * A mailing list's saved audience query.
284
18
  *
285
- * A silently dropped filter key widens the audience with no error, which is how
286
- * a "Has Resume" checkbox ends up mailing everyone. Yup's own `noUnknown` only
287
- * errors under `.strict()`, which would additionally disable coercion and reject
288
- * the "80" / "true" strings form payloads legitimately send, so this is done as
289
- * a test instead: unknown keys fail, coercion stays on.
19
+ * The SAME filter user-service validates, re-exported under the name it carries
20
+ * where it is stored. Not a separate vocabulary: a list resolves against
21
+ * user-service, so anything it can save must be something that service accepts.
22
+ * Defined in `user/user-filter.schema.ts` because it describes users.
290
23
  *
291
- * `allowed` is passed in rather than read from the schema, so that the branch
292
- * schema can forbid `or` while the top-level filter permits it.
293
- */
294
- const rejectUnknownKeys = <T extends { test: (...a: never[]) => T }>(
295
- schema: T,
296
- allowed: () => readonly string[],
297
- ): T =>
298
- (schema.test as unknown as (
299
- name: string,
300
- msg: (p: { value: unknown; label: string }) => string,
301
- fn: (v: unknown) => boolean,
302
- ) => T)(
303
- "no-unknown-filter-keys",
304
- ({ value, label }) => {
305
- const known = new Set(allowed());
306
- const bad = Object.keys((value ?? {}) as Record<string, unknown>).filter(
307
- (k) => !known.has(k),
308
- );
309
- return `${label} has unsupported keys: ${bad.join(", ")}`;
310
- },
311
- (value) => {
312
- if (value == null) return true;
313
- const known = new Set(allowed());
314
- return Object.keys(value as Record<string, unknown>).every((k) =>
315
- known.has(k),
316
- );
317
- },
318
- );
319
-
320
- /**
321
- * The saved audience query. Every clause AND-s together; `or` unions the whole
322
- * filter with each branch, deduplicating by user, for audiences that are
323
- * genuinely a union rather than an intersection ("signed up recently OR active
324
- * recently"). One level only, branches do not nest.
24
+ * @todo SVELTE-UI-SCHEMA-RENAME — this was `ListFilterSchema`, and
25
+ * `thejob-svelte-ui` still imports THAT name in
26
+ * `src/lib/services/admin.service.ts` (3 lines: the import, a doc reference and
27
+ * the `InferType` argument). It will not typecheck against this build until
28
+ * those are updated. Deliberately left unmigrated rather than fixed in passing:
29
+ * renaming an export here is a change to every consumer, and each one is its
30
+ * own decision. No back-compat alias, so the break is loud rather than silent.
325
31
  */
326
- const BRANCH_KEYS = Object.keys(ListFilterBranchSchema.fields);
327
-
328
- /** A branch of `or`: every clause except `or` itself, so unions cannot nest. */
329
- export const ListFilterBranch = rejectUnknownKeys(
330
- ListFilterBranchSchema,
331
- () => BRANCH_KEYS,
332
- );
333
-
334
- export const ListFilterSchema = rejectUnknownKeys(
335
- ListFilterBranchSchema.shape({
336
- or: array()
337
- .of(ListFilterBranch)
338
- .optional()
339
- .default([])
340
- .label("Or Any Of"),
341
- }).label("Audience Filter"),
342
- () => [...BRANCH_KEYS, "or"],
343
- );
32
+ export const MailingListFilterSchema = UserFilterSchema;
344
33
 
345
34
  // ─── Mailing list ────────────────────────────────────────────────────────────
346
35
 
347
36
  export const MailingListSchema = object({
348
37
  name: string().required().min(1).max(120).label("Name"),
349
38
  description: string().optional().max(500).label("Description"),
350
- filter: ListFilterSchema.default(() => ListFilterSchema.getDefault()),
39
+ filter: MailingListFilterSchema.default(() =>
40
+ MailingListFilterSchema.getDefault(),
41
+ ),
351
42
  }).label("Mailing List");
352
43
 
353
44
  // ─── Campaign ────────────────────────────────────────────────────────────────
354
45
 
355
46
  /**
356
47
  * When a campaign sends. Discriminated by `kind`; the fields each kind needs are
357
- * required only for that kind, so one stored shape covers all four.
48
+ * required only for that kind, so one stored shape covers them all.
358
49
  */
359
50
  export const CampaignTriggerSchema = object({
360
51
  kind: string()
@@ -445,52 +136,71 @@ export const CampaignTriggerSchema = object({
445
136
  .optional()
446
137
  .default(() => ({ kind: CampaignEndKind.Never }))
447
138
  .label("End Condition"),
139
+ }).label("Trigger");
448
140
 
449
- // kind: lifecycle
450
- anchorField: string()
451
- .oneOf(SupportedCampaignAnchorFields)
452
- .optional()
453
- .default(undefined)
454
- .label("Anchor Date")
455
- .when("kind", {
456
- is: CampaignTriggerKind.Lifecycle,
457
- then: (s) => s.required(),
458
- }),
459
- /** Days after the anchor at which this user becomes eligible. */
460
- offsetDays: number()
461
- .integer()
462
- .min(0)
463
- .max(3650)
464
- .optional()
465
- .default(undefined)
466
- .label("Days After Anchor")
467
- .when("kind", {
468
- is: CampaignTriggerKind.Lifecycle,
469
- then: (s) => s.required(),
470
- }),
141
+ /**
142
+ * ONE data source a campaign draws content from: which source, how it is
143
+ * configured, and whether it personalises.
144
+ *
145
+ * Extracted so `CampaignSchema.dataSources` can hold several. A campaign used
146
+ * to store exactly one, which meant a template could never combine (say) jobs
147
+ * and groups in one mail.
148
+ */
149
+ export const CampaignDataSourceSchema = object({
150
+ id: string().required().label("Data Source"),
471
151
  /**
472
- * How long a user stays eligible after crossing the offset. Needed because the
473
- * scheduler samples periodically and a zero-width window would miss anyone not
474
- * observed on the exact day. A user inside the window on several ticks is still
475
- * mailed once: the permanent idempotency key, not this value, guarantees that.
152
+ * Derive this source's filter per recipient from their profile, instead of
153
+ * using the admin-entered `params` for everyone.
154
+ *
155
+ * A second AXIS on a datasource, not a datasource of its own: a
156
+ * personalisation filter is an ordinary filter for that source, the only
157
+ * difference being who wrote it. Jobs previously shipped the two modes as
158
+ * two registry entries (`recent-jobs` / `matched-jobs`), which let them drift
159
+ * apart down to their output names.
160
+ *
161
+ * Optional, and absent reads as false, so every existing campaign stays
162
+ * un-personalised. Whether a source can honour it at all is declared by the
163
+ * source itself (`supportsPersonalisation`), which this package cannot see.
476
164
  */
477
- windowDays: number()
478
- .integer()
479
- .min(1)
480
- .max(90)
481
- .optional()
482
- .default(2)
483
- .label("Eligibility Window (days)"),
484
- }).label("Trigger");
165
+ personalised: boolean().optional().label("Personalised"),
166
+ /**
167
+ * A free-form bag whose keys are defined by the chosen source's OWN
168
+ * `paramsSchema`, which this package cannot see. Same `lazy` treatment as
169
+ * `bindings` above, and for the same reason: a bare `object()` has no known
170
+ * keys, so under `stripUnknown: true` every param is "unknown" and the bag
171
+ * arrives as `{}` — a campaign configured to mail remote Python roles in
172
+ * Bangalore silently saves as an unfiltered platform-wide digest.
173
+ *
174
+ * Values stay unvalidated here on purpose; the marketing service validates
175
+ * them against the real source schema in `checkCampaignConfig`, which is the
176
+ * only place that knows what a given source accepts.
177
+ */
178
+ params: lazy((value: unknown) => {
179
+ const keys =
180
+ value && typeof value === "object" ? Object.keys(value) : [];
181
+ return object(
182
+ Object.fromEntries(keys.map((k) => [k, mixed().optional()])),
183
+ )
184
+ .optional()
185
+ .default(() => ({}))
186
+ .label("Parameters");
187
+ }),
188
+ }).label("Data Source");
485
189
 
486
190
  /**
487
- * Where one template variable's value comes from. `value` carries the literal for
488
- * `static`/`url`, and the field path for `recipient`/`source`.
191
+ * Where ONE template variable's value comes from.
192
+ *
193
+ * `value` carries the literal for `static`/`url`, and the field path for
194
+ * `recipient`/`source`. Named for what it binds: "binding" alone says nothing
195
+ * about what is being bound to what.
489
196
  */
490
- export const BindingSchema = object({
491
- kind: string().oneOf(SupportedBindingKinds).required().label("Source"),
197
+ export const TemplateVariableBindingSchema = object({
198
+ kind: string()
199
+ .oneOf(SupportedTemplateVariableBindingKinds)
200
+ .required()
201
+ .label("Source"),
492
202
  value: string().required().min(1).max(2000).label("Value"),
493
- }).label("Binding");
203
+ }).label("Template Variable Binding");
494
204
 
495
205
  export const CampaignSchema = object({
496
206
  name: string().required().min(1).max(200).label("Campaign Name"),
@@ -507,8 +217,8 @@ export const CampaignSchema = object({
507
217
  /**
508
218
  * Inline HTML body; may contain {{unsubscribe_url}}. Optional now that a
509
219
  * campaign can instead name a registry template — exactly one of the two is
510
- * required. Scheduled and lifecycle campaigns must use a template: the
511
- * registry path is the one with a compliance-checked footer and a render test,
220
+ * required. Scheduled campaigns must use a template: the registry path is the
221
+ * one with a compliance-checked footer and a render test,
512
222
  * and hand-pasted HTML that repeats forever is a mistake that repeats forever.
513
223
  */
514
224
  html: string()
@@ -521,6 +231,20 @@ export const CampaignSchema = object({
521
231
  text: string().optional().label("Plain-text Body"),
522
232
  listId: string().required().label("Mailing List"),
523
233
 
234
+ /**
235
+ * Where the campaign is in its life. See `CampaignStatus` for the transitions.
236
+ *
237
+ * Declared here so the enum is ENFORCED on write. It was previously written
238
+ * straight onto the document by the service with no schema behind it, and a
239
+ * value the enum never contained (`scheduled`) reached production rows and
240
+ * rendered in the admin list as though it were real.
241
+ */
242
+ status: string()
243
+ .oneOf(SupportedCampaignStatuses)
244
+ .optional()
245
+ .default(CampaignStatus.Draft)
246
+ .label("Status"),
247
+
524
248
  /** Key from email-service's template registry. Mutually exclusive with `html`. */
525
249
  templateKey: string().optional().label("Email Template"),
526
250
  /**
@@ -540,50 +264,41 @@ export const CampaignSchema = object({
540
264
  bindings: lazy((value: unknown) => {
541
265
  const keys = value && typeof value === "object" ? Object.keys(value) : [];
542
266
  return object(
543
- Object.fromEntries(keys.map((k) => [k, BindingSchema.required()])),
267
+ Object.fromEntries(keys.map((k) => [k, TemplateVariableBindingSchema.required()])),
544
268
  )
545
269
  .optional()
546
270
  .default(() => ({}))
547
271
  .label("Template Data");
548
272
  }),
549
- /** Registered data source supplying per-recipient content, plus its params. */
550
- dataSource: object({
551
- id: string().required().label("Data Source"),
552
- /**
553
- * A free-form bag whose keys are defined by the chosen source's OWN
554
- * `paramsSchema`, which this package cannot see. Same `lazy` treatment as
555
- * `bindings` above, and for the same reason: a bare `object()` has no known
556
- * keys, so under `stripUnknown: true` every param is "unknown" and the bag
557
- * arrives as `{}` — a campaign configured to mail remote Python roles in
558
- * Bangalore silently saves as an unfiltered platform-wide digest.
559
- *
560
- * Values stay unvalidated here on purpose; the marketing service validates
561
- * them against the real source schema in `checkCampaignConfig`, which is the
562
- * only place that knows what a given source accepts.
563
- */
564
- params: lazy((value: unknown) => {
565
- const keys =
566
- value && typeof value === "object" ? Object.keys(value) : [];
567
- return object(
568
- Object.fromEntries(keys.map((k) => [k, mixed().optional()])),
569
- )
570
- .optional()
571
- .default(() => ({}))
572
- .label("Parameters");
573
- }),
574
- })
273
+ /**
274
+ * The data sources this campaign draws content from.
275
+ *
276
+ * SEVERAL, because one mail may combine them. Each source's outputs are
277
+ * NAMESPACED by its id in the rendered payload (`jobs.jobCount`,
278
+ * `groups.groupCount`), so two sources providing the same name cannot
279
+ * overwrite each other and a template says which one it means.
280
+ */
281
+ dataSources: array()
282
+ .of(CampaignDataSourceSchema)
575
283
  .optional()
576
284
  .default(undefined)
577
- .label("Data Source"),
285
+ .label("Data Sources"),
578
286
 
579
287
  trigger: CampaignTriggerSchema.default(() =>
580
288
  CampaignTriggerSchema.getDefault(),
581
289
  ),
582
290
 
583
291
  // ── Safety ──────────────────────────────────────────────────────────────────
584
- // Both default to the safe position: a new campaign neither runs nor sends
585
- // until someone deliberately says so, and says so twice.
586
- enabled: boolean().optional().default(false).label("Enabled"),
292
+ /**
293
+ * Still TWO deliberate acts before mail leaves, as before: a campaign must be
294
+ * activated (`status: running`) AND taken out of dry run.
295
+ *
296
+ * The first half used to be a separate `enabled` boolean living alongside the
297
+ * status, which meant two fields encoding one idea and able to disagree — an
298
+ * `enabled: false` campaign whose status said it was sending was
299
+ * representable, and the admin list showed exactly that. Activation is a
300
+ * state transition, so it belongs in the state.
301
+ */
587
302
  dryRun: boolean().optional().default(true).label("Dry Run"),
588
303
  maxRecipients: number()
589
304
  .integer()
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The page filter: the closed vocabulary of facets a page query can filter on.
3
+ *
4
+ * The counterpart of `GroupFilterSchema`; see that file for why an open
5
+ * `parseFilter` makes this declaration load-bearing rather than decorative.
6
+ *
7
+ * Every facet below was checked against production data before being declared.
8
+ * Three candidates that look filterable from `PageSchema` are deliberately
9
+ * ABSENT, because each would validate, save, render a form input, and match
10
+ * nothing:
11
+ *
12
+ * • `pageType` — the stored key is `type` (see below).
13
+ * • `categories` — declared as `array of string` in `PageSchema`, but actually
14
+ * stored as `{ id, name }` OBJECTS. A string comparison never matches. A
15
+ * `categories.name` dotted facet would probably work, but "probably" is how
16
+ * a dead facet ships. @todo verify against live data, then add it.
17
+ * • `industries` — present on 1 of 1,449 documents. Filtering on it excludes
18
+ * 99.9% of the corpus, which is a footgun rendered as a form input.
19
+ *
20
+ * `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are reserved
21
+ * query params, not facets, and are absent for that reason instead.
22
+ */
23
+ import { boolean, object } from "yup";
24
+ import { enumFacet, rejectUnknownKeys, tokens } from "../common/facet.schema.js";
25
+ import {
26
+ SupportedPageStatuses,
27
+ SupportedPageTypes,
28
+ } from "./page.constant.js";
29
+
30
+ const PageFilterShape = object({
31
+ /**
32
+ * `type`, NOT `pageType`.
33
+ *
34
+ * The enum constant is named `PageType`, which makes `pageType` the obvious
35
+ * guess, and it is wrong: the stored key is `type` (verified — `pageType`
36
+ * exists on 0 of 1,449 documents, while `type` splits 1,198 company / 251
37
+ * institute). The name of a constant is not the name of a field.
38
+ */
39
+ type: enumFacet(SupportedPageTypes, "Page Type"),
40
+
41
+ /**
42
+ * Publication state. Worth knowing when filtering: 69 documents carry NO
43
+ * `status` at all and some carry `null`, so `status: ['published']` is a real
44
+ * narrowing (1,379 of 1,449) and absent means "no constraint", not "published".
45
+ */
46
+ status: enumFacet(SupportedPageStatuses, "Status"),
47
+
48
+ // ── Flags ───────────────────────────────────────────────────────────────────
49
+ // No `.default(false)`: absent means no constraint. Note `isFeatured` is
50
+ // present on only 55 documents, so combining it with `status: ['published']`
51
+ // leaves 11 pages — a legitimate filter that returns almost nothing.
52
+ isFeatured: boolean().optional().label("Featured Only"),
53
+
54
+ /** Lowercase (`in`, `se`, `ww`), like groups. See `GroupFilterSchema`. */
55
+ featuredMarkets: tokens("Featured Markets"),
56
+
57
+ /** Page SLUGS. A company is matched by slug: a display name breaks on rename. */
58
+ slugs: tokens("Slugs"),
59
+ }).label("Page Filter");
60
+
61
+ const PAGE_FILTER_KEYS = Object.keys(PageFilterShape.fields);
62
+
63
+ /** THE page filter. See `GroupFilterSchema` for why unknown keys must fail loudly. */
64
+ export const PageFilterSchema = rejectUnknownKeys(
65
+ PageFilterShape,
66
+ () => PAGE_FILTER_KEYS,
67
+ );