@thejob/schema 2.1.9 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +17 -0
- package/dist/index.cjs +2178 -723
- package/dist/index.d.cts +1991 -535
- package/dist/index.d.ts +1991 -535
- package/dist/index.js +2080 -698
- package/package.json +1 -1
- package/src/billing/billing.constant.ts +179 -0
- package/src/billing/billing.schema.ts +280 -0
- package/src/common/facet.schema.ts +342 -0
- package/src/feedback/feedback-filter.schema.ts +39 -0
- package/src/geo/city-filter.schema.ts +41 -0
- package/src/geo/city.schema.ts +104 -0
- package/src/geo/country-filter.schema.ts +47 -0
- package/src/geo/country.schema.ts +148 -0
- package/src/geo/geo-sync.schema.ts +116 -0
- package/src/geo/geo.constant.ts +78 -0
- package/src/geo/state-filter.schema.ts +57 -0
- package/src/geo/state.schema.ts +101 -0
- package/src/group/group-filter.schema.ts +81 -0
- package/src/group/group.constant.ts +15 -0
- package/src/index.ts +35 -0
- package/src/job/job-filter.schema.ts +266 -0
- package/src/language/language-filter.schema.ts +40 -0
- package/src/marketing/marketing.constant.ts +105 -30
- package/src/marketing/marketing.schema.ts +118 -403
- package/src/page/page-filter.schema.ts +67 -0
- package/src/skill/skill-filter.schema.ts +61 -0
- package/src/user/push-subscription.schema.ts +97 -0
- package/src/user/user-filter.schema.ts +438 -0
- package/src/user/user-interest.schema.ts +34 -2
- package/src/user/user.constant.ts +20 -8
- package/src/user/user.schema.ts +45 -0
- package/src/user/user.types.ts +35 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
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
|
-
*
|
|
292
|
-
*
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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
|
|
488
|
-
*
|
|
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
|
|
491
|
-
kind: string()
|
|
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
|
|
511
|
-
*
|
|
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,
|
|
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
|
-
/**
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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
|
|
285
|
+
.label("Data Sources"),
|
|
578
286
|
|
|
579
287
|
trigger: CampaignTriggerSchema.default(() =>
|
|
580
288
|
CampaignTriggerSchema.getDefault(),
|
|
581
289
|
),
|
|
582
290
|
|
|
583
291
|
// ── Safety ──────────────────────────────────────────────────────────────────
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
+
);
|