@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.
package/dist/index.cjs CHANGED
@@ -32,8 +32,16 @@ var index_exports = {};
32
32
  __export(index_exports, {
33
33
  AnswerChoiceType: () => AnswerChoiceType,
34
34
  ApplicationReceivePreference: () => ApplicationReceivePreference,
35
+ BindingKind: () => BindingKind,
36
+ BindingSchema: () => BindingSchema,
37
+ CAMPAIGN_MAX_RECIPIENTS_CEILING: () => CAMPAIGN_MAX_RECIPIENTS_CEILING,
38
+ CampaignAnchorField: () => CampaignAnchorField,
39
+ CampaignEndKind: () => CampaignEndKind,
40
+ CampaignIntervalUnit: () => CampaignIntervalUnit,
35
41
  CampaignSchema: () => CampaignSchema,
36
42
  CampaignStatus: () => CampaignStatus,
43
+ CampaignTriggerKind: () => CampaignTriggerKind,
44
+ CampaignTriggerSchema: () => CampaignTriggerSchema,
37
45
  ChoiceQuestionSchema: () => ChoiceQuestionSchema,
38
46
  Common: () => Common,
39
47
  CompensationType: () => CompensationType,
@@ -75,12 +83,15 @@ __export(index_exports, {
75
83
  JobSearchUrgency: () => JobSearchUrgency,
76
84
  JobStatus: () => JobStatus,
77
85
  JobSubCategory: () => JobSubCategory,
86
+ ListFilterBranch: () => ListFilterBranch,
87
+ ListFilterBranchSchema: () => ListFilterBranchSchema,
78
88
  ListFilterSchema: () => ListFilterSchema,
79
89
  LocationSchema: () => LocationSchema,
80
90
  MIN_SALARY_LOWER_BOUND: () => MIN_SALARY_LOWER_BOUND,
81
91
  MIN_SALARY_UPPER_BOUND: () => MIN_SALARY_UPPER_BOUND,
82
92
  MailingListSchema: () => MailingListSchema,
83
93
  NotificationChannel: () => NotificationChannel,
94
+ OccurrenceStatus: () => OccurrenceStatus,
84
95
  PREFILL_MIRRORED_KEYS: () => PREFILL_MIRRORED_KEYS,
85
96
  PRIVATE_PROFILE_FIELDS: () => PRIVATE_PROFILE_FIELDS,
86
97
  PageSchema: () => PageSchema,
@@ -110,7 +121,12 @@ __export(index_exports, {
110
121
  StudyType: () => StudyType,
111
122
  SupportedAnswerChoiceTypes: () => SupportedAnswerChoiceTypes,
112
123
  SupportedApplicationReceivePreferences: () => SupportedApplicationReceivePreferences,
124
+ SupportedBindingKinds: () => SupportedBindingKinds,
125
+ SupportedCampaignAnchorFields: () => SupportedCampaignAnchorFields,
126
+ SupportedCampaignEndKinds: () => SupportedCampaignEndKinds,
127
+ SupportedCampaignIntervalUnits: () => SupportedCampaignIntervalUnits,
113
128
  SupportedCampaignStatuses: () => SupportedCampaignStatuses,
129
+ SupportedCampaignTriggerKinds: () => SupportedCampaignTriggerKinds,
114
130
  SupportedCompensationTypes: () => SupportedCompensationTypes,
115
131
  SupportedContactTypes: () => SupportedContactTypes,
116
132
  SupportedEducationLevels: () => SupportedEducationLevels,
@@ -126,6 +142,7 @@ __export(index_exports, {
126
142
  SupportedJobStatuses: () => SupportedJobStatuses,
127
143
  SupportedJobSubCategories: () => SupportedJobSubCategories,
128
144
  SupportedNotificationChannels: () => SupportedNotificationChannels,
145
+ SupportedOccurrenceStatuses: () => SupportedOccurrenceStatuses,
129
146
  SupportedPageStatuses: () => SupportedPageStatuses,
130
147
  SupportedPageTypes: () => SupportedPageTypes,
131
148
  SupportedPostStatuses: () => SupportedPostStatuses,
@@ -2225,10 +2242,79 @@ var PRIVATE_PROFILE_FIELDS = [
2225
2242
  "additionalInfo"
2226
2243
  ];
2227
2244
 
2245
+ // src/marketing/marketing.constant.ts
2246
+ var CampaignStatus = /* @__PURE__ */ ((CampaignStatus2) => {
2247
+ CampaignStatus2["Draft"] = "draft";
2248
+ CampaignStatus2["Sending"] = "sending";
2249
+ CampaignStatus2["Sent"] = "sent";
2250
+ CampaignStatus2["Failed"] = "failed";
2251
+ return CampaignStatus2;
2252
+ })(CampaignStatus || {});
2253
+ var SupportedCampaignStatuses = Object.values(CampaignStatus);
2254
+ var CampaignTriggerKind = /* @__PURE__ */ ((CampaignTriggerKind2) => {
2255
+ CampaignTriggerKind2["Manual"] = "manual";
2256
+ CampaignTriggerKind2["Once"] = "once";
2257
+ CampaignTriggerKind2["Schedule"] = "schedule";
2258
+ CampaignTriggerKind2["Lifecycle"] = "lifecycle";
2259
+ return CampaignTriggerKind2;
2260
+ })(CampaignTriggerKind || {});
2261
+ var SupportedCampaignTriggerKinds = Object.values(CampaignTriggerKind);
2262
+ var CampaignIntervalUnit = /* @__PURE__ */ ((CampaignIntervalUnit2) => {
2263
+ CampaignIntervalUnit2["Day"] = "day";
2264
+ CampaignIntervalUnit2["Week"] = "week";
2265
+ CampaignIntervalUnit2["Month"] = "month";
2266
+ return CampaignIntervalUnit2;
2267
+ })(CampaignIntervalUnit || {});
2268
+ var SupportedCampaignIntervalUnits = Object.values(CampaignIntervalUnit);
2269
+ var CampaignEndKind = /* @__PURE__ */ ((CampaignEndKind2) => {
2270
+ CampaignEndKind2["Never"] = "never";
2271
+ CampaignEndKind2["AfterOccurrences"] = "afterOccurrences";
2272
+ CampaignEndKind2["OnDate"] = "onDate";
2273
+ return CampaignEndKind2;
2274
+ })(CampaignEndKind || {});
2275
+ var SupportedCampaignEndKinds = Object.values(CampaignEndKind);
2276
+ var CampaignAnchorField = /* @__PURE__ */ ((CampaignAnchorField2) => {
2277
+ CampaignAnchorField2["CreatedAt"] = "createdAt";
2278
+ CampaignAnchorField2["OnboardingCompletedAt"] = "onboardingCompletedAt";
2279
+ CampaignAnchorField2["MarketingConsentUpdatedAt"] = "marketingConsent.updatedAt";
2280
+ return CampaignAnchorField2;
2281
+ })(CampaignAnchorField || {});
2282
+ var SupportedCampaignAnchorFields = Object.values(CampaignAnchorField);
2283
+ var BindingKind = /* @__PURE__ */ ((BindingKind2) => {
2284
+ BindingKind2["Static"] = "static";
2285
+ BindingKind2["Recipient"] = "recipient";
2286
+ BindingKind2["Url"] = "url";
2287
+ BindingKind2["Source"] = "source";
2288
+ return BindingKind2;
2289
+ })(BindingKind || {});
2290
+ var SupportedBindingKinds = Object.values(BindingKind);
2291
+ var OccurrenceStatus = /* @__PURE__ */ ((OccurrenceStatus2) => {
2292
+ OccurrenceStatus2["Pending"] = "pending";
2293
+ OccurrenceStatus2["Running"] = "running";
2294
+ OccurrenceStatus2["Sent"] = "sent";
2295
+ OccurrenceStatus2["Skipped"] = "skipped";
2296
+ OccurrenceStatus2["Failed"] = "failed";
2297
+ return OccurrenceStatus2;
2298
+ })(OccurrenceStatus || {});
2299
+ var SupportedOccurrenceStatuses = Object.values(OccurrenceStatus);
2300
+ var CAMPAIGN_MAX_RECIPIENTS_CEILING = 5e3;
2301
+
2228
2302
  // src/marketing/marketing.schema.ts
2229
2303
  var tokens = (label) => (0, import_yup20.array)().of((0, import_yup20.string)().trim().required()).optional().default([]).label(label);
2230
2304
  var enumFacet = (values, label) => (0, import_yup20.array)().of((0, import_yup20.string)().oneOf(values).required()).optional().default([]).label(label);
2231
- var ListFilterSchema = (0, import_yup20.object)({
2305
+ var days = (label) => (0, import_yup20.number)().integer().min(0).max(3650).optional().default(void 0).label(label);
2306
+ var months = (label) => (0, import_yup20.number)().integer().min(0).max(1200).optional().default(void 0).label(label);
2307
+ var percent = (label) => (0, import_yup20.number)().integer().min(0).max(100).optional().default(void 0).label(label);
2308
+ var epoch = (label) => (0, import_yup20.number)().integer().min(0).optional().default(void 0).label(label);
2309
+ var salaryBound = (label) => (0, import_yup20.number)().integer().min(0).optional().default(void 0).label(label).when("salaryCurrencies", {
2310
+ is: (v) => !Array.isArray(v) || v.length !== 1,
2311
+ then: (s) => s.test(
2312
+ "requires-single-currency",
2313
+ "Pick exactly one salary currency to filter by salary",
2314
+ (value) => value === void 0 || value === null
2315
+ )
2316
+ });
2317
+ var ListFilterBranchSchema = (0, import_yup20.object)({
2232
2318
  // ── Enum facets (closed vocabularies, chip pickers) ─────────────────────────
2233
2319
  countries: (0, import_yup20.array)().of((0, import_yup20.string)().required()).optional().default([]).label("Countries"),
2234
2320
  roles: enumFacet(SupportedUserRoles, "Roles"),
@@ -2283,39 +2369,285 @@ var ListFilterSchema = (0, import_yup20.object)({
2283
2369
  // Case-insensitive keyword match against headline + aboutMe.
2284
2370
  keywords: tokens("Profile Keywords"),
2285
2371
  // ── Range / boolean facets ──────────────────────────────────────────────────
2286
- minSalaryFloor: (0, import_yup20.number)().integer().min(0).optional().label("Min Salary \u2265"),
2287
- minSalaryCeil: (0, import_yup20.number)().integer().min(0).optional().label("Min Salary \u2264"),
2372
+ // A salary bound is meaningless without a currency: `minSalary` is a raw
2373
+ // integer and 50000 SEK is not 50000 USD, so an un-currencied range matches
2374
+ // across currencies and quietly means nothing. Requiring exactly one currency
2375
+ // alongside a bound is what makes the comparison sound.
2376
+ minSalaryFloor: salaryBound("Min Salary \u2265"),
2377
+ minSalaryCeil: salaryBound("Min Salary \u2264"),
2288
2378
  openToWorkOnly: (0, import_yup20.boolean)().optional().default(false).label("Open to Work Only"),
2289
2379
  remoteExperienceOnly: (0, import_yup20.boolean)().optional().default(false).label("Has Remote Experience"),
2290
2380
  hasResumeOnly: (0, import_yup20.boolean)().optional().default(false).label("Has Resume"),
2291
2381
  onboardingCompletedOnly: (0, import_yup20.boolean)().optional().default(false).label("Onboarding Completed"),
2292
2382
  emailVerifiedOnly: (0, import_yup20.boolean)().optional().default(false).label("Email Verified Only"),
2293
- mobileVerifiedOnly: (0, import_yup20.boolean)().optional().default(false).label("Mobile Verified Only")
2294
- }).label("Audience Filter");
2383
+ mobileVerifiedOnly: (0, import_yup20.boolean)().optional().default(false).label("Mobile Verified Only"),
2384
+ // ── Lifecycle windows ───────────────────────────────────────────────────────
2385
+ // Relative, because a saved list is resolved fresh on every send: a stored
2386
+ // absolute epoch would freeze at authoring time and the audience would decay
2387
+ // to nothing. The absolute forms below exist for one-shot sends; when both are
2388
+ // present the relative form wins.
2389
+ lastSeenBeforeDays: days("Inactive For (days) \u2265"),
2390
+ lastSeenAfterDays: days("Active Within (days)"),
2391
+ createdAtWithinDays: days("Signed Up Within (days)"),
2392
+ createdAtOlderThanDays: days("Signed Up Before (days ago)"),
2393
+ completenessMin: percent("Profile Completeness \u2265"),
2394
+ completenessMax: percent("Profile Completeness \u2264"),
2395
+ // Absolute epoch-ms forms. Prefer the relative fields above for anything that
2396
+ // repeats.
2397
+ lastSeenBefore: epoch("Last Seen Before"),
2398
+ lastSeenAfter: epoch("Last Seen After"),
2399
+ createdAtFrom: epoch("Signed Up After"),
2400
+ createdAtBefore: epoch("Signed Up Before"),
2401
+ // ── Geography ───────────────────────────────────────────────────────────────
2402
+ // `countries` above matches location.countryCode. These narrow within it.
2403
+ // NOTE: stateCodes are stored bare (`KA`, not `IN-KA`) — see the pending
2404
+ // @todo STATE-CODE-ISO-PREFIX migration. Match the stored form, do not prefix.
2405
+ states: tokens("States"),
2406
+ stateCodes: tokens("State Codes"),
2407
+ cities: tokens("Cities"),
2408
+ // Radius search around a point, against location.geo. All three required
2409
+ // together; validated as a group rather than field-by-field so a half-filled
2410
+ // radius cannot silently match everyone.
2411
+ withinRadius: (0, import_yup20.object)({
2412
+ lat: (0, import_yup20.number)().min(-90).max(90).required().label("Latitude"),
2413
+ lng: (0, import_yup20.number)().min(-180).max(180).required().label("Longitude"),
2414
+ km: (0, import_yup20.number)().moreThan(0).max(2e4).required().label("Radius (km)")
2415
+ }).optional().default(void 0).label("Within Radius"),
2416
+ // ── Signup context (immutable market / locale) ──────────────────────────────
2417
+ signupCountries: tokens("Signup Countries"),
2418
+ signupLocales: tokens("Signup Locales"),
2419
+ signupSources: enumFacet(SupportedSignupContextSources, "Signup Source"),
2420
+ // ── Attainment ──────────────────────────────────────────────────────────────
2421
+ // Absent on users who predate the field. Absent means "unknown", never a
2422
+ // match — filtering on this necessarily excludes them.
2423
+ educationLevels: enumFacet(SupportedEducationLevels, "Education Level"),
2424
+ // ── Consent metadata ────────────────────────────────────────────────────────
2425
+ // `marketingConsent.subscribed` is NOT a facet: it is a hard opt-in gate
2426
+ // applied unconditionally by user-service and is not relaxable from here.
2427
+ jobAlertFrequencies: enumFacet(
2428
+ SupportedJobAlertFrequencies,
2429
+ "Job Alert Frequency"
2430
+ ),
2431
+ consentSources: tokens("Consent Source"),
2432
+ consentUpdatedWithinDays: days("Consent Updated Within (days)"),
2433
+ // ── Notification preferences ────────────────────────────────────────────────
2434
+ // Read as opt-IN (`=== true`), matching marketingConsent and NOT the `!== false`
2435
+ // reading used for transactional notification routing. A campaign is bulk mail,
2436
+ // so a channel the user never affirmatively enabled does not count as consent.
2437
+ // `productUpdates` defaults to false on the user document, so targeting it
2438
+ // selects only people who deliberately turned it on.
2439
+ notificationChannelsEnabled: enumFacet(
2440
+ SupportedNotificationChannels,
2441
+ "Notification Channels Enabled"
2442
+ ),
2443
+ // ── Tenure / recency ────────────────────────────────────────────────────────
2444
+ // Against workExperiences[].duration and educations[].duration ("YYYY-MM").
2445
+ currentlyEmployed: (0, import_yup20.boolean)().optional().default(false).label("Currently Employed"),
2446
+ graduatedWithinMonths: months("Graduated Within (months)"),
2447
+ skillUsedWithinMonths: months("Skill Used Within (months)"),
2448
+ // ── Profile richness ────────────────────────────────────────────────────────
2449
+ hasImage: (0, import_yup20.boolean)().optional().default(false).label("Has Photo"),
2450
+ hasHeadline: (0, import_yup20.boolean)().optional().default(false).label("Has Headline"),
2451
+ hasAboutMe: (0, import_yup20.boolean)().optional().default(false).label("Has About Me"),
2452
+ hasMobile: (0, import_yup20.boolean)().optional().default(false).label("Has Mobile Number"),
2453
+ // Segment by WHICH profile section is missing (profileCompleteness.missing[]),
2454
+ // rather than only by the overall percentage.
2455
+ missingSections: tokens("Missing Profile Sections"),
2456
+ // ── Employer / institute taxonomy ───────────────────────────────────────────
2457
+ companyTypes: enumFacet(SupportedPageTypes, "Company Type"),
2458
+ instituteTypes: enumFacet(SupportedPageTypes, "Institute Type"),
2459
+ // ── Activity ────────────────────────────────────────────────────────────────
2460
+ // Backed by denormalised counters on the user document, because the source
2461
+ // data lives in other services' collections. Tri-state: `false` is a real
2462
+ // filter ("has NO job alert"), which is the audience an alert nudge wants, so
2463
+ // these are `undefined` when unset rather than defaulting to false.
2464
+ hasJobAlert: (0, import_yup20.boolean)().optional().default(void 0).label("Has a Job Alert"),
2465
+ hasApplied: (0, import_yup20.boolean)().optional().default(void 0).label("Has Applied"),
2466
+ appliedWithinDays: days("Applied Within (days)"),
2467
+ // ── Free-text search (name / email) ─────────────────────────────────────────
2468
+ search: (0, import_yup20.string)().trim().optional().label("Search")
2469
+ }).label("Audience Filter Branch");
2470
+ var rejectUnknownKeys = (schema, allowed) => schema.test(
2471
+ "no-unknown-filter-keys",
2472
+ ({ value, label }) => {
2473
+ const known = new Set(allowed());
2474
+ const bad = Object.keys(value ?? {}).filter(
2475
+ (k) => !known.has(k)
2476
+ );
2477
+ return `${label} has unsupported keys: ${bad.join(", ")}`;
2478
+ },
2479
+ (value) => {
2480
+ if (value == null) return true;
2481
+ const known = new Set(allowed());
2482
+ return Object.keys(value).every(
2483
+ (k) => known.has(k)
2484
+ );
2485
+ }
2486
+ );
2487
+ var BRANCH_KEYS = Object.keys(ListFilterBranchSchema.fields);
2488
+ var ListFilterBranch = rejectUnknownKeys(
2489
+ ListFilterBranchSchema,
2490
+ () => BRANCH_KEYS
2491
+ );
2492
+ var ListFilterSchema = rejectUnknownKeys(
2493
+ ListFilterBranchSchema.shape({
2494
+ or: (0, import_yup20.array)().of(ListFilterBranch).optional().default([]).label("Or Any Of")
2495
+ }).label("Audience Filter"),
2496
+ () => [...BRANCH_KEYS, "or"]
2497
+ );
2295
2498
  var MailingListSchema = (0, import_yup20.object)({
2296
2499
  name: (0, import_yup20.string)().required().min(1).max(120).label("Name"),
2297
2500
  description: (0, import_yup20.string)().optional().max(500).label("Description"),
2298
2501
  filter: ListFilterSchema.default(() => ListFilterSchema.getDefault())
2299
2502
  }).label("Mailing List");
2503
+ var CampaignTriggerSchema = (0, import_yup20.object)({
2504
+ kind: (0, import_yup20.string)().oneOf(SupportedCampaignTriggerKinds).required().default("manual" /* Manual */).label("Trigger"),
2505
+ // IANA zone, e.g. "Asia/Kolkata". Local wall-clock times are interpreted in it,
2506
+ // so occurrences land at the intended hour across DST rather than drifting.
2507
+ timezone: (0, import_yup20.string)().optional().default("UTC").label("Timezone").when("kind", {
2508
+ is: (k) => k !== "manual" /* Manual */,
2509
+ then: (s) => s.required()
2510
+ }),
2511
+ // kind: once
2512
+ runAt: epoch("Send At").when("kind", {
2513
+ is: "once" /* Once */,
2514
+ then: (s) => s.required()
2515
+ }),
2516
+ // kind: schedule
2517
+ startAt: epoch("Starts").when("kind", {
2518
+ is: "schedule" /* Schedule */,
2519
+ then: (s) => s.required()
2520
+ }),
2521
+ interval: (0, import_yup20.object)({
2522
+ every: (0, import_yup20.number)().integer().min(1).max(365).required().label("Every"),
2523
+ unit: (0, import_yup20.string)().oneOf(SupportedCampaignIntervalUnits).required().label("Unit")
2524
+ }).optional().default(void 0).label("Repeats").when("kind", {
2525
+ is: "schedule" /* Schedule */,
2526
+ then: (s) => s.required()
2527
+ }),
2528
+ /** Local wall-clock send time, "HH:mm" in `timezone`. */
2529
+ timeOfDay: (0, import_yup20.string)().matches(/^([01]\d|2[0-3]):[0-5]\d$/, "Use HH:mm").optional().default("09:00").label("Time of Day"),
2530
+ /** For weekly intervals: ISO weekdays, 1 = Monday .. 7 = Sunday. */
2531
+ byWeekday: (0, import_yup20.array)().of((0, import_yup20.number)().integer().min(1).max(7).required()).optional().default([]).label("On Days"),
2532
+ /**
2533
+ * For monthly intervals: 1-31, or -1 meaning "last day of the month". A month
2534
+ * shorter than the chosen day is clamped to its last day, so 31 in February
2535
+ * fires on the 28th/29th rather than being skipped.
2536
+ */
2537
+ byMonthday: (0, import_yup20.number)().integer().min(-1).max(31).notOneOf([0]).optional().default(void 0).label("Day of Month"),
2538
+ end: (0, import_yup20.object)({
2539
+ kind: (0, import_yup20.string)().oneOf(SupportedCampaignEndKinds).required().label("Ends"),
2540
+ occurrences: (0, import_yup20.number)().integer().min(1).max(1e4).optional().default(void 0).label("After N Sends").when("kind", {
2541
+ is: "afterOccurrences" /* AfterOccurrences */,
2542
+ then: (s) => s.required()
2543
+ }),
2544
+ date: epoch("End Date").when("kind", {
2545
+ is: "onDate" /* OnDate */,
2546
+ then: (s) => s.required()
2547
+ })
2548
+ }).optional().default(() => ({ kind: "never" /* Never */ })).label("End Condition"),
2549
+ // kind: lifecycle
2550
+ anchorField: (0, import_yup20.string)().oneOf(SupportedCampaignAnchorFields).optional().default(void 0).label("Anchor Date").when("kind", {
2551
+ is: "lifecycle" /* Lifecycle */,
2552
+ then: (s) => s.required()
2553
+ }),
2554
+ /** Days after the anchor at which this user becomes eligible. */
2555
+ offsetDays: (0, import_yup20.number)().integer().min(0).max(3650).optional().default(void 0).label("Days After Anchor").when("kind", {
2556
+ is: "lifecycle" /* Lifecycle */,
2557
+ then: (s) => s.required()
2558
+ }),
2559
+ /**
2560
+ * How long a user stays eligible after crossing the offset. Needed because the
2561
+ * scheduler samples periodically and a zero-width window would miss anyone not
2562
+ * observed on the exact day. A user inside the window on several ticks is still
2563
+ * mailed once: the permanent idempotency key, not this value, guarantees that.
2564
+ */
2565
+ windowDays: (0, import_yup20.number)().integer().min(1).max(90).optional().default(2).label("Eligibility Window (days)")
2566
+ }).label("Trigger");
2567
+ var BindingSchema = (0, import_yup20.object)({
2568
+ kind: (0, import_yup20.string)().oneOf(SupportedBindingKinds).required().label("Source"),
2569
+ value: (0, import_yup20.string)().required().min(1).max(2e3).label("Value")
2570
+ }).label("Binding");
2300
2571
  var CampaignSchema = (0, import_yup20.object)({
2301
2572
  name: (0, import_yup20.string)().required().min(1).max(200).label("Campaign Name"),
2302
- subject: (0, import_yup20.string)().required().min(1).max(255).label("Subject"),
2303
- // HTML body; may contain a {{unsubscribe_url}} placeholder.
2304
- html: (0, import_yup20.string)().required().min(1).label("HTML Body"),
2573
+ // The template renders its own subject; an override is only needed for the
2574
+ // inline-HTML path, hence not required when a template is chosen.
2575
+ subject: (0, import_yup20.string)().optional().max(255).label("Subject").when("templateKey", {
2576
+ is: (v) => !v,
2577
+ then: (s) => s.required().min(1)
2578
+ }),
2579
+ /**
2580
+ * Inline HTML body; may contain {{unsubscribe_url}}. Optional now that a
2581
+ * campaign can instead name a registry template — exactly one of the two is
2582
+ * required. Scheduled and lifecycle campaigns must use a template: the
2583
+ * registry path is the one with a compliance-checked footer and a render test,
2584
+ * and hand-pasted HTML that repeats forever is a mistake that repeats forever.
2585
+ */
2586
+ html: (0, import_yup20.string)().optional().label("HTML Body").when("templateKey", {
2587
+ is: (v) => !v,
2588
+ then: (s) => s.required().min(1)
2589
+ }),
2305
2590
  text: (0, import_yup20.string)().optional().label("Plain-text Body"),
2306
- listId: (0, import_yup20.string)().required().label("Mailing List")
2591
+ listId: (0, import_yup20.string)().required().label("Mailing List"),
2592
+ /** Key from email-service's template registry. Mutually exclusive with `html`. */
2593
+ templateKey: (0, import_yup20.string)().optional().label("Email Template"),
2594
+ /**
2595
+ * One entry per variable the chosen template declares. Validated at save time
2596
+ * against the template's own `variables`, so a missing required binding is a
2597
+ * rejected save rather than a broken send.
2598
+ */
2599
+ /**
2600
+ * A DICTIONARY keyed by template variable name, which Yup has no native type
2601
+ * for. It must be built with `lazy` from the keys actually present: a bare
2602
+ * `object()` has no known keys, so under `stripUnknown: true` (which the
2603
+ * consumers' validate helper passes) every binding is "unknown" and the whole
2604
+ * map arrives as `{}` — silently turning a fully-configured campaign into one
2605
+ * whose required variables all appear unbound. Shaping each entry also means a
2606
+ * malformed binding is rejected here rather than at send time.
2607
+ */
2608
+ bindings: (0, import_yup20.lazy)((value) => {
2609
+ const keys = value && typeof value === "object" ? Object.keys(value) : [];
2610
+ return (0, import_yup20.object)(
2611
+ Object.fromEntries(keys.map((k) => [k, BindingSchema.required()]))
2612
+ ).optional().default(() => ({})).label("Template Data");
2613
+ }),
2614
+ /** Registered data source supplying per-recipient content, plus its params. */
2615
+ dataSource: (0, import_yup20.object)({
2616
+ id: (0, import_yup20.string)().required().label("Data Source"),
2617
+ /**
2618
+ * A free-form bag whose keys are defined by the chosen source's OWN
2619
+ * `paramsSchema`, which this package cannot see. Same `lazy` treatment as
2620
+ * `bindings` above, and for the same reason: a bare `object()` has no known
2621
+ * keys, so under `stripUnknown: true` every param is "unknown" and the bag
2622
+ * arrives as `{}` — a campaign configured to mail remote Python roles in
2623
+ * Bangalore silently saves as an unfiltered platform-wide digest.
2624
+ *
2625
+ * Values stay unvalidated here on purpose; the marketing service validates
2626
+ * them against the real source schema in `checkCampaignConfig`, which is the
2627
+ * only place that knows what a given source accepts.
2628
+ */
2629
+ params: (0, import_yup20.lazy)((value) => {
2630
+ const keys = value && typeof value === "object" ? Object.keys(value) : [];
2631
+ return (0, import_yup20.object)(
2632
+ Object.fromEntries(keys.map((k) => [k, (0, import_yup20.mixed)().optional()]))
2633
+ ).optional().default(() => ({})).label("Parameters");
2634
+ })
2635
+ }).optional().default(void 0).label("Data Source"),
2636
+ trigger: CampaignTriggerSchema.default(
2637
+ () => CampaignTriggerSchema.getDefault()
2638
+ ),
2639
+ // ── Safety ──────────────────────────────────────────────────────────────────
2640
+ // Both default to the safe position: a new campaign neither runs nor sends
2641
+ // until someone deliberately says so, and says so twice.
2642
+ enabled: (0, import_yup20.boolean)().optional().default(false).label("Enabled"),
2643
+ dryRun: (0, import_yup20.boolean)().optional().default(true).label("Dry Run"),
2644
+ maxRecipients: (0, import_yup20.number)().integer().min(1).max(CAMPAIGN_MAX_RECIPIENTS_CEILING).optional().default(void 0).label("Max Recipients"),
2645
+ utm: (0, import_yup20.object)({
2646
+ campaign: (0, import_yup20.string)().optional().max(120).label("utm_campaign"),
2647
+ content: (0, import_yup20.string)().optional().max(120).label("utm_content")
2648
+ }).optional().default(void 0).label("Link Tagging")
2307
2649
  }).label("Campaign");
2308
2650
 
2309
- // src/marketing/marketing.constant.ts
2310
- var CampaignStatus = /* @__PURE__ */ ((CampaignStatus2) => {
2311
- CampaignStatus2["Draft"] = "draft";
2312
- CampaignStatus2["Sending"] = "sending";
2313
- CampaignStatus2["Sent"] = "sent";
2314
- CampaignStatus2["Failed"] = "failed";
2315
- return CampaignStatus2;
2316
- })(CampaignStatus || {});
2317
- var SupportedCampaignStatuses = Object.values(CampaignStatus);
2318
-
2319
2651
  // src/pagination/pagination.schema.ts
2320
2652
  var import_yup21 = require("yup");
2321
2653
  var PaginationSchema = (0, import_yup21.object)().shape({
@@ -2569,6 +2901,25 @@ var UserGeneralDetailSchema = (0, import_yup33.object)({
2569
2901
  emailVerified: (0, import_yup33.string)().nullable().optional().label("Email Verified"),
2570
2902
  mobileVerified: (0, import_yup33.string)().nullable().optional().label("Mobile Verified"),
2571
2903
  experienceLevel: (0, import_yup33.string)().oneOf(SupportedExperienceLevels).required().label("Experience level"),
2904
+ /**
2905
+ * The user's HIGHEST qualification, e.g. `bachelor`.
2906
+ *
2907
+ * The counterpart of `JobSchema.educationLevel` (what a listing requires), so
2908
+ * matching can compare the two directly. Nothing on the user side answered
2909
+ * that question before: `educations[].studyType` is the MODE of study
2910
+ * (`full_time`, `online`) and says nothing about attainment.
2911
+ *
2912
+ * Deliberately top-level and singular, alongside `experienceLevel`, rather
2913
+ * than per `educations` entry: what matching needs is one fact about the
2914
+ * person, and deriving it by ranking every entry would make the answer depend
2915
+ * on how completely a user filled in their history.
2916
+ *
2917
+ * Optional, unlike `experienceLevel`: every existing user predates it. An
2918
+ * absent value means "level unknown", which must leave matching
2919
+ * UNCONSTRAINED on education rather than filtering the user out — the same
2920
+ * opt-out reasoning `notificationPrefs` uses.
2921
+ */
2922
+ educationLevel: (0, import_yup33.string)().oneOf(SupportedEducationLevels).optional().label("Education level"),
2572
2923
  location: LocationSchema.required().label("Location"),
2573
2924
  /**
2574
2925
  * Where the account was created, captured once and never rewritten.
@@ -2871,8 +3222,16 @@ var StudentCompletenessSchema = UserCompletenessSchema.omit([
2871
3222
  0 && (module.exports = {
2872
3223
  AnswerChoiceType,
2873
3224
  ApplicationReceivePreference,
3225
+ BindingKind,
3226
+ BindingSchema,
3227
+ CAMPAIGN_MAX_RECIPIENTS_CEILING,
3228
+ CampaignAnchorField,
3229
+ CampaignEndKind,
3230
+ CampaignIntervalUnit,
2874
3231
  CampaignSchema,
2875
3232
  CampaignStatus,
3233
+ CampaignTriggerKind,
3234
+ CampaignTriggerSchema,
2876
3235
  ChoiceQuestionSchema,
2877
3236
  Common,
2878
3237
  CompensationType,
@@ -2914,12 +3273,15 @@ var StudentCompletenessSchema = UserCompletenessSchema.omit([
2914
3273
  JobSearchUrgency,
2915
3274
  JobStatus,
2916
3275
  JobSubCategory,
3276
+ ListFilterBranch,
3277
+ ListFilterBranchSchema,
2917
3278
  ListFilterSchema,
2918
3279
  LocationSchema,
2919
3280
  MIN_SALARY_LOWER_BOUND,
2920
3281
  MIN_SALARY_UPPER_BOUND,
2921
3282
  MailingListSchema,
2922
3283
  NotificationChannel,
3284
+ OccurrenceStatus,
2923
3285
  PREFILL_MIRRORED_KEYS,
2924
3286
  PRIVATE_PROFILE_FIELDS,
2925
3287
  PageSchema,
@@ -2949,7 +3311,12 @@ var StudentCompletenessSchema = UserCompletenessSchema.omit([
2949
3311
  StudyType,
2950
3312
  SupportedAnswerChoiceTypes,
2951
3313
  SupportedApplicationReceivePreferences,
3314
+ SupportedBindingKinds,
3315
+ SupportedCampaignAnchorFields,
3316
+ SupportedCampaignEndKinds,
3317
+ SupportedCampaignIntervalUnits,
2952
3318
  SupportedCampaignStatuses,
3319
+ SupportedCampaignTriggerKinds,
2953
3320
  SupportedCompensationTypes,
2954
3321
  SupportedContactTypes,
2955
3322
  SupportedEducationLevels,
@@ -2965,6 +3332,7 @@ var StudentCompletenessSchema = UserCompletenessSchema.omit([
2965
3332
  SupportedJobStatuses,
2966
3333
  SupportedJobSubCategories,
2967
3334
  SupportedNotificationChannels,
3335
+ SupportedOccurrenceStatuses,
2968
3336
  SupportedPageStatuses,
2969
3337
  SupportedPageTypes,
2970
3338
  SupportedPostStatuses,