@thejob/schema 2.1.6 → 2.1.8

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,18 +1,33 @@
1
- import { array, boolean, number, object, string } from "yup";
1
+ import { array, boolean, lazy, mixed, number, object, string } from "yup";
2
2
  import {
3
+ SupportedEducationLevels,
3
4
  SupportedExperienceLevels,
4
5
  SupportedStudyTypes,
5
6
  } from "../common/common.constant.js";
7
+ import { SupportedPageTypes } from "../page/page.constant.js";
6
8
  import { SupportedSocialAccounts } from "../social-account/social-account.constant.js";
7
9
  import {
10
+ SupportedJobAlertFrequencies,
8
11
  SupportedJobSearchUrgencies,
12
+ SupportedNotificationChannels,
9
13
  SupportedProficiencyLevels,
10
14
  SupportedReferralSources,
11
15
  SupportedSalaryCurrencies,
16
+ SupportedSignupContextSources,
12
17
  SupportedUserProfileVisibilities,
13
18
  SupportedUserRoles,
14
19
  SupportedUserStatuses,
15
20
  } from "../user/user.constant.js";
21
+ import {
22
+ CAMPAIGN_MAX_RECIPIENTS_CEILING,
23
+ CampaignEndKind,
24
+ CampaignTriggerKind,
25
+ SupportedBindingKinds,
26
+ SupportedCampaignAnchorFields,
27
+ SupportedCampaignEndKinds,
28
+ SupportedCampaignIntervalUnits,
29
+ SupportedCampaignTriggerKinds,
30
+ } from "./marketing.constant.js";
16
31
 
17
32
  // ─── Audience filter ─────────────────────────────────────────────────────────
18
33
  // The saved query a mailing list resolves against the users collection. Every
@@ -34,7 +49,50 @@ const enumFacet = (values: readonly string[], label: string) =>
34
49
  .default([])
35
50
  .label(label);
36
51
 
37
- export const ListFilterSchema = object({
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
+ });
89
+
90
+ /**
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({
38
96
  // ── Enum facets (closed vocabularies, chip pickers) ─────────────────────────
39
97
  countries: array().of(string().required()).optional().default([]).label("Countries"),
40
98
  roles: enumFacet(SupportedUserRoles, "Roles"),
@@ -91,8 +149,12 @@ export const ListFilterSchema = object({
91
149
  keywords: tokens("Profile Keywords"),
92
150
 
93
151
  // ── Range / boolean facets ──────────────────────────────────────────────────
94
- minSalaryFloor: number().integer().min(0).optional().label("Min Salary ≥"),
95
- minSalaryCeil: number().integer().min(0).optional().label("Min Salary ≤"),
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 ≤"),
96
158
  openToWorkOnly: boolean().optional().default(false).label("Open to Work Only"),
97
159
  remoteExperienceOnly: boolean()
98
160
  .optional()
@@ -108,7 +170,177 @@ export const ListFilterSchema = object({
108
170
  .optional()
109
171
  .default(false)
110
172
  .label("Mobile Verified Only"),
111
- }).label("Audience Filter");
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.
284
+ *
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.
290
+ *
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.
325
+ */
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
+ );
112
344
 
113
345
  // ─── Mailing list ────────────────────────────────────────────────────────────
114
346
 
@@ -120,11 +352,252 @@ export const MailingListSchema = object({
120
352
 
121
353
  // ─── Campaign ────────────────────────────────────────────────────────────────
122
354
 
355
+ /**
356
+ * 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.
358
+ */
359
+ export const CampaignTriggerSchema = object({
360
+ kind: string()
361
+ .oneOf(SupportedCampaignTriggerKinds)
362
+ .required()
363
+ .default(CampaignTriggerKind.Manual)
364
+ .label("Trigger"),
365
+
366
+ // IANA zone, e.g. "Asia/Kolkata". Local wall-clock times are interpreted in it,
367
+ // so occurrences land at the intended hour across DST rather than drifting.
368
+ timezone: string()
369
+ .optional()
370
+ .default("UTC")
371
+ .label("Timezone")
372
+ .when("kind", {
373
+ is: (k: string) => k !== CampaignTriggerKind.Manual,
374
+ then: (s) => s.required(),
375
+ }),
376
+
377
+ // kind: once
378
+ runAt: epoch("Send At").when("kind", {
379
+ is: CampaignTriggerKind.Once,
380
+ then: (s) => s.required(),
381
+ }),
382
+
383
+ // kind: schedule
384
+ startAt: epoch("Starts").when("kind", {
385
+ is: CampaignTriggerKind.Schedule,
386
+ then: (s) => s.required(),
387
+ }),
388
+ interval: object({
389
+ every: number().integer().min(1).max(365).required().label("Every"),
390
+ unit: string()
391
+ .oneOf(SupportedCampaignIntervalUnits)
392
+ .required()
393
+ .label("Unit"),
394
+ })
395
+ .optional()
396
+ .default(undefined)
397
+ .label("Repeats")
398
+ .when("kind", {
399
+ is: CampaignTriggerKind.Schedule,
400
+ then: (s) => s.required(),
401
+ }),
402
+ /** Local wall-clock send time, "HH:mm" in `timezone`. */
403
+ timeOfDay: string()
404
+ .matches(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:mm")
405
+ .optional()
406
+ .default("09:00")
407
+ .label("Time of Day"),
408
+ /** For weekly intervals: ISO weekdays, 1 = Monday .. 7 = Sunday. */
409
+ byWeekday: array()
410
+ .of(number().integer().min(1).max(7).required())
411
+ .optional()
412
+ .default([])
413
+ .label("On Days"),
414
+ /**
415
+ * For monthly intervals: 1-31, or -1 meaning "last day of the month". A month
416
+ * shorter than the chosen day is clamped to its last day, so 31 in February
417
+ * fires on the 28th/29th rather than being skipped.
418
+ */
419
+ byMonthday: number()
420
+ .integer()
421
+ .min(-1)
422
+ .max(31)
423
+ .notOneOf([0])
424
+ .optional()
425
+ .default(undefined)
426
+ .label("Day of Month"),
427
+ end: object({
428
+ kind: string().oneOf(SupportedCampaignEndKinds).required().label("Ends"),
429
+ occurrences: number()
430
+ .integer()
431
+ .min(1)
432
+ .max(10_000)
433
+ .optional()
434
+ .default(undefined)
435
+ .label("After N Sends")
436
+ .when("kind", {
437
+ is: CampaignEndKind.AfterOccurrences,
438
+ then: (s) => s.required(),
439
+ }),
440
+ date: epoch("End Date").when("kind", {
441
+ is: CampaignEndKind.OnDate,
442
+ then: (s) => s.required(),
443
+ }),
444
+ })
445
+ .optional()
446
+ .default(() => ({ kind: CampaignEndKind.Never }))
447
+ .label("End Condition"),
448
+
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
+ }),
471
+ /**
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.
476
+ */
477
+ windowDays: number()
478
+ .integer()
479
+ .min(1)
480
+ .max(90)
481
+ .optional()
482
+ .default(2)
483
+ .label("Eligibility Window (days)"),
484
+ }).label("Trigger");
485
+
486
+ /**
487
+ * Where one template variable's value comes from. `value` carries the literal for
488
+ * `static`/`url`, and the field path for `recipient`/`source`.
489
+ */
490
+ export const BindingSchema = object({
491
+ kind: string().oneOf(SupportedBindingKinds).required().label("Source"),
492
+ value: string().required().min(1).max(2000).label("Value"),
493
+ }).label("Binding");
494
+
123
495
  export const CampaignSchema = object({
124
496
  name: string().required().min(1).max(200).label("Campaign Name"),
125
- subject: string().required().min(1).max(255).label("Subject"),
126
- // HTML body; may contain a {{unsubscribe_url}} placeholder.
127
- html: string().required().min(1).label("HTML Body"),
497
+ // The template renders its own subject; an override is only needed for the
498
+ // inline-HTML path, hence not required when a template is chosen.
499
+ subject: string()
500
+ .optional()
501
+ .max(255)
502
+ .label("Subject")
503
+ .when("templateKey", {
504
+ is: (v: unknown) => !v,
505
+ then: (s) => s.required().min(1),
506
+ }),
507
+ /**
508
+ * Inline HTML body; may contain {{unsubscribe_url}}. Optional now that a
509
+ * 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,
512
+ * and hand-pasted HTML that repeats forever is a mistake that repeats forever.
513
+ */
514
+ html: string()
515
+ .optional()
516
+ .label("HTML Body")
517
+ .when("templateKey", {
518
+ is: (v: unknown) => !v,
519
+ then: (s) => s.required().min(1),
520
+ }),
128
521
  text: string().optional().label("Plain-text Body"),
129
522
  listId: string().required().label("Mailing List"),
523
+
524
+ /** Key from email-service's template registry. Mutually exclusive with `html`. */
525
+ templateKey: string().optional().label("Email Template"),
526
+ /**
527
+ * One entry per variable the chosen template declares. Validated at save time
528
+ * against the template's own `variables`, so a missing required binding is a
529
+ * rejected save rather than a broken send.
530
+ */
531
+ /**
532
+ * A DICTIONARY keyed by template variable name, which Yup has no native type
533
+ * for. It must be built with `lazy` from the keys actually present: a bare
534
+ * `object()` has no known keys, so under `stripUnknown: true` (which the
535
+ * consumers' validate helper passes) every binding is "unknown" and the whole
536
+ * map arrives as `{}` — silently turning a fully-configured campaign into one
537
+ * whose required variables all appear unbound. Shaping each entry also means a
538
+ * malformed binding is rejected here rather than at send time.
539
+ */
540
+ bindings: lazy((value: unknown) => {
541
+ const keys = value && typeof value === "object" ? Object.keys(value) : [];
542
+ return object(
543
+ Object.fromEntries(keys.map((k) => [k, BindingSchema.required()])),
544
+ )
545
+ .optional()
546
+ .default(() => ({}))
547
+ .label("Template Data");
548
+ }),
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
+ })
575
+ .optional()
576
+ .default(undefined)
577
+ .label("Data Source"),
578
+
579
+ trigger: CampaignTriggerSchema.default(() =>
580
+ CampaignTriggerSchema.getDefault(),
581
+ ),
582
+
583
+ // ── 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"),
587
+ dryRun: boolean().optional().default(true).label("Dry Run"),
588
+ maxRecipients: number()
589
+ .integer()
590
+ .min(1)
591
+ .max(CAMPAIGN_MAX_RECIPIENTS_CEILING)
592
+ .optional()
593
+ .default(undefined)
594
+ .label("Max Recipients"),
595
+
596
+ utm: object({
597
+ campaign: string().optional().max(120).label("utm_campaign"),
598
+ content: string().optional().max(120).label("utm_content"),
599
+ })
600
+ .optional()
601
+ .default(undefined)
602
+ .label("Link Tagging"),
130
603
  }).label("Campaign");
@@ -1,5 +1,8 @@
1
1
  import { number, object, string } from "yup";
2
- import { SupportedExperienceLevels } from "../common/common.constant.js";
2
+ import {
3
+ SupportedEducationLevels,
4
+ SupportedExperienceLevels,
5
+ } from "../common/common.constant.js";
3
6
  import { LocationSchema } from "../location/location.schema.js";
4
7
  import {
5
8
  SupportedSignupContextSources,
@@ -30,6 +33,28 @@ export const UserGeneralDetailSchema = object({
30
33
  .oneOf(SupportedExperienceLevels)
31
34
  .required()
32
35
  .label("Experience level"),
36
+ /**
37
+ * The user's HIGHEST qualification, e.g. `bachelor`.
38
+ *
39
+ * The counterpart of `JobSchema.educationLevel` (what a listing requires), so
40
+ * matching can compare the two directly. Nothing on the user side answered
41
+ * that question before: `educations[].studyType` is the MODE of study
42
+ * (`full_time`, `online`) and says nothing about attainment.
43
+ *
44
+ * Deliberately top-level and singular, alongside `experienceLevel`, rather
45
+ * than per `educations` entry: what matching needs is one fact about the
46
+ * person, and deriving it by ranking every entry would make the answer depend
47
+ * on how completely a user filled in their history.
48
+ *
49
+ * Optional, unlike `experienceLevel`: every existing user predates it. An
50
+ * absent value means "level unknown", which must leave matching
51
+ * UNCONSTRAINED on education rather than filtering the user out — the same
52
+ * opt-out reasoning `notificationPrefs` uses.
53
+ */
54
+ educationLevel: string()
55
+ .oneOf(SupportedEducationLevels)
56
+ .optional()
57
+ .label("Education level"),
33
58
  location: LocationSchema.required().label("Location"),
34
59
  /**
35
60
  * Where the account was created, captured once and never rewritten.