@thejob/schema 2.1.7 → 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 +368 -19
- package/dist/index.d.cts +842 -13
- package/dist/index.d.ts +842 -13
- package/dist/index.js +358 -26
- package/package.json +1 -1
- package/src/marketing/marketing.constant.ts +98 -0
- package/src/marketing/marketing.schema.ts +481 -8
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
126
|
-
// HTML
|
|
127
|
-
|
|
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");
|