@thejob/schema 2.1.8 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The skill filter: the closed vocabulary of facets a skill query can filter on.
3
+ *
4
+ * The counterpart of `PageFilterSchema` and `GroupFilterSchema`, for
5
+ * common-service's `GET /skills`. Same reason those exist: `parseFilter` there
6
+ * accepted ANY key and passed it straight to Mongo, so a misspelled or invented
7
+ * facet was not a narrower query — it was a query for a field no document has,
8
+ * returning 200 OK with zero rows and no error anywhere.
9
+ *
10
+ * Deliberately SMALL, and every facet below is a field `SkillSchema` actually
11
+ * declares rather than one that seemed plausible:
12
+ *
13
+ * • `name` — the display name, and the field the free-text search
14
+ * already matches on.
15
+ * • `conceptIds` — the taxonomy concept a skill resolved to (stable slug,
16
+ * e.g. "power-bi"). The one facet with real query value:
17
+ * it groups every alias of a skill under one id.
18
+ * • `tags` — declared on `SkillSchema`, capped at 20 per document.
19
+ *
20
+ * ABSENT on purpose: `conceptName` (a display string for `conceptId`, so
21
+ * filtering on it is the same question asked less reliably) and `logo` (an
22
+ * object of URLs, not a value anyone filters by).
23
+ *
24
+ * The collection is EMPTY in both `thejob` and `thejob-dev` today, and the two
25
+ * live callers (`skill-field.svelte`, `language-field.svelte`) send only
26
+ * `search`, `fields` and `sortBy`. So this is not modelling observed queries —
27
+ * it exists so the service has a closed vocabulary like every other one, and so
28
+ * the first real facet is an addition to a declaration rather than a new
29
+ * mechanism. Add facets here when a caller needs one, against real data.
30
+ *
31
+ * `search`, `page`, `limit`, `sortBy`, `sortDirection` and `fields` are reserved
32
+ * query params, not facets, and are absent for that reason instead.
33
+ */
34
+ import { object } from "yup";
35
+ import { rejectUnknownKeys, tokens } from "../common/facet.schema.js";
36
+
37
+ const SkillFilterShape = object({
38
+ /** Display names, matched exactly. Free-text matching is `?search=`. */
39
+ names: tokens("Names"),
40
+
41
+ /**
42
+ * Taxonomy concept ids, e.g. "power-bi".
43
+ *
44
+ * `tokens`, NOT `codeTokens`: a conceptId is a lowercase slug, and
45
+ * uppercasing it would match nothing.
46
+ */
47
+ conceptIds: tokens("Concept IDs"),
48
+
49
+ tags: tokens("Tags"),
50
+ }).label("Skill Filter");
51
+
52
+ /**
53
+ * Wrapped so an unknown key is REJECTED rather than silently ignored.
54
+ *
55
+ * Yup gives you unknown-key rejection or coercion, never both: `.noUnknown()`
56
+ * alone accepts `{bogus:1}`, and with `strict: true` it rejects but then throws
57
+ * on the string values every query param arrives as. Hence a `.test()`.
58
+ */
59
+ export const SkillFilterSchema = rejectUnknownKeys(SkillFilterShape, () =>
60
+ Object.keys(SkillFilterShape.fields),
61
+ );
@@ -21,6 +21,13 @@ export const UserGeneralDetailSchema = object({
21
21
  .label("Name"),
22
22
  headline: string().trim().max(200).optional().label("Headline"),
23
23
  image: string().optional().label("Image"),
24
+ // The DISPLAYED avatar (`image`) is a flattened export with any status frame
25
+ // ("#OpenToWork", …) already burned into the pixels. `imageOriginal` keeps the
26
+ // un-framed source the user uploaded, so the image builder can reopen it and
27
+ // apply a DIFFERENT frame instead of stacking a second one on top of the first.
28
+ // Optional and absent on every pre-existing user: treat a missing value as
29
+ // "only the flattened image is available".
30
+ imageOriginal: string().optional().label("Original Image"),
24
31
  aboutMe: string().trim().max(3000).optional().label("About Me"),
25
32
  email: string()
26
33
  // .emailStrict() // TODO: Enable strict email validation.
@@ -0,0 +1,86 @@
1
+ import { number, object, string } from "yup";
2
+
3
+ /**
4
+ * A single browser's Web Push subscription.
5
+ *
6
+ * This is the DEVICE grant, distinct from `user.pushConsent`, which is the
7
+ * user's intent. Both are required to deliver: consent without a subscription
8
+ * has nowhere to send, and a subscription without consent must not be used.
9
+ *
10
+ * Shape dictated by the Push API, not by us. `endpoint` and `keys` come back
11
+ * verbatim from `PushSubscription.toJSON()` in the browser and are passed
12
+ * verbatim to the sender, so nothing here may be normalised or reformatted —
13
+ * `p256dh` and `auth` are base64url and case-sensitive.
14
+ *
15
+ * MANY per user, one per browser profile per device, which is why this is its
16
+ * own collection rather than a field on the user. The same person on a phone
17
+ * and a laptop is two rows, and both should receive.
18
+ */
19
+ export const PushSubscriptionSchema = object({
20
+ /**
21
+ * The user this subscription belongs to, by the `shortId` every service
22
+ * keys a user on, matching `UserSchema.shortId`.
23
+ *
24
+ * NOT the Better Auth account id. That id is meaningful only inside the auth
25
+ * service, and owning these rows by one would force the service that sends
26
+ * push to translate an id it has no other use for. The signed-in browser
27
+ * gets its own shortId straight off the JWT claim.
28
+ *
29
+ * Named `userShortId` rather than `shortId` because a stored row has a
30
+ * `shortId` of its OWN (see `PushSubscriptionRecord`); one field cannot be
31
+ * both the row's id and its owner's.
32
+ */
33
+ userShortId: string().required().label("User Short ID"),
34
+
35
+ /**
36
+ * The push service URL the browser issued. Vendor-specific (FCM, Mozilla,
37
+ * Apple) and effectively unbounded in length, so it carries no length cap.
38
+ *
39
+ * This is the IDENTITY of a subscription: it is what a re-subscribing
40
+ * browser sends back unchanged, and what a 404/410 response identifies as
41
+ * dead. Upserts and deletes both key on it.
42
+ */
43
+ endpoint: string().required().label("Push Endpoint"),
44
+
45
+ /**
46
+ * Encryption material from the browser. Web Push payloads are encrypted to
47
+ * these keys, so a row missing either one cannot be delivered to at all.
48
+ */
49
+ keys: object({
50
+ /** Client public key, base64url. */
51
+ p256dh: string().required().label("P256DH Key"),
52
+ /** Client auth secret, base64url. */
53
+ auth: string().required().label("Auth Secret"),
54
+ })
55
+ .required()
56
+ .label("Push Keys"),
57
+
58
+ /**
59
+ * Epoch ms the push service says the subscription expires, when it says
60
+ * anything at all. Usually absent — most browsers return null — so this is
61
+ * informational and never the basis for pruning. A 404/410 on send is the
62
+ * only reliable death signal.
63
+ */
64
+ expirationTime: number().nullable().optional().label("Expiration Time"),
65
+
66
+ /**
67
+ * The subscribing browser's user agent, kept so a user can recognise their
68
+ * own devices in a "where you receive notifications" list. Display only:
69
+ * never parsed, and never used to decide delivery.
70
+ */
71
+ userAgent: string().optional().label("User Agent"),
72
+
73
+ /** Epoch ms the subscription was first registered. */
74
+ createdAt: number().optional().label("Created At"),
75
+
76
+ /**
77
+ * Epoch ms this subscription was last confirmed live, refreshed when the
78
+ * browser re-registers it. A subscription that stops being refreshed is a
79
+ * device that stopped visiting, which is useful for pruning rows that never
80
+ * fail loudly enough to be caught by a 410.
81
+ */
82
+ lastSeenAt: number().optional().label("Last Seen At"),
83
+ })
84
+ .noUnknown()
85
+ .strict()
86
+ .label("Push Subscription Schema");
@@ -0,0 +1,438 @@
1
+ /**
2
+ * The user filter: the closed vocabulary of facets a user query can filter on.
3
+ *
4
+ * Lives HERE, with the user module, because it describes USERS. It sat in
5
+ * `marketing/` for as long as marketing was its only caller, which made it look
6
+ * like a marketing concept; it is not. `thejob-user-service` derives the
7
+ * vocabulary it validates against from this schema, and `@thejob/users-client`
8
+ * derives its `UserFilter` type from it, so this is the single declaration all
9
+ * three agree on. A mailing list merely STORES one
10
+ * (`MailingListSchema.filter`).
11
+ *
12
+ * `search` is deliberately absent. It is free text over name/email: it names no
13
+ * field on a user document, it cannot be meaningfully unioned inside an `or`
14
+ * branch, and user-service treats it as a reserved param beside the filter. See
15
+ * `ListUsersOptions` in `@thejob/users-client`.
16
+ */
17
+ import { array, boolean, number, object, string } from "yup";
18
+ import {
19
+ conjunction,
20
+ currencyBound,
21
+ days,
22
+ enumFacet,
23
+ epoch,
24
+ months,
25
+ percent,
26
+ qualified,
27
+ rejectUnknownKeys,
28
+ tokens,
29
+ } from "../common/facet.schema.js";
30
+ import {
31
+ SupportedEducationLevels,
32
+ SupportedExperienceLevels,
33
+ SupportedStudyTypes,
34
+ } from "../common/common.constant.js";
35
+ import { SupportedPageTypes } from "../page/page.constant.js";
36
+ import { SupportedSocialAccounts } from "../social-account/social-account.constant.js";
37
+ import {
38
+ SupportedJobAlertFrequencies,
39
+ SupportedJobSearchUrgencies,
40
+ SupportedNotificationTypes,
41
+ SupportedProficiencyLevels,
42
+ SupportedReferralSources,
43
+ SupportedSalaryCurrencies,
44
+ SupportedSignupContextSources,
45
+ SupportedUserProfileVisibilities,
46
+ SupportedUserRoles,
47
+ SupportedUserStatuses,
48
+ } from "./user.constant.js";
49
+
50
+ // The saved query a mailing list resolves against the users collection. Every
51
+ // clause is optional and AND-ed; empty groups match everyone.
52
+ //
53
+ // Two kinds of clause:
54
+ // • Enum facets (`.oneOf(...)`) — closed vocabularies rendered as chip pickers.
55
+ // • Free-text token facets — open vocabularies (skills, companies, institutes,
56
+ // …) matched case-insensitively against a nested array/field. Any token in
57
+ // the list matches (OR within a facet), facets AND together.
58
+
59
+
60
+ /**
61
+ * Every clause except `or`. Declared separately so `UserFilterSchema` can embed
62
+ * it as its branch type without referring to itself: the union is deliberately
63
+ * one level deep, and a non-recursive definition enforces that.
64
+ *
65
+ * INTERNAL. Not exported: it is scaffolding for the two schemas below, and the
66
+ * only filter a consumer should reach for is `UserFilterSchema`.
67
+ */
68
+ const UserFilterBranchShape = object({
69
+ /**
70
+ * Exact email addresses. An IDENTITY lookup, not a search.
71
+ *
72
+ * The admin `search` box already matches email, but as a case-insensitive
73
+ * unanchored regex over email + first + last, which no index can seek: it
74
+ * scans every key. Measured against 103,680 users, finding one person by
75
+ * their full address examined all 103,680 documents and took 552ms, where the
76
+ * same lookup as an exact match on the indexed `email` field is 0ms and
77
+ * examines 1.
78
+ *
79
+ * So this is for when the caller ALREADY KNOWS the address (previewing what
80
+ * one named recipient will receive, reconciling a bounce) and `search` is for
81
+ * when a human is typing a fragment.
82
+ *
83
+ * Values are LOWER-CASED before matching, not matched case-insensitively: all
84
+ * 103,680 stored addresses are already lower case (verified), so normalising
85
+ * the input keeps the match an indexed equality instead of a regex that would
86
+ * put the scan straight back.
87
+ */
88
+ emails: array().of(string().required()).optional().default([]).label("Email Addresses"),
89
+ /**
90
+ * Better Auth account ids. An IDENTITY lookup like `emails`, and for the same
91
+ * reason: the caller already knows the exact value.
92
+ *
93
+ * BACKEND-ONLY, despite living beside the human-facing facets. The consumer
94
+ * is hono-auth, which holds an account id and needs the profile's `shortId`
95
+ * to sign into the JWT it is minting. It cannot use `GET /me` (it is the one
96
+ * issuing the token) and cannot use `GET /users/:shortId` (the shortId is
97
+ * precisely what it lacks), so the list route with a facet is the lookup.
98
+ *
99
+ * Matched as an indexed equality on `authAccountId`, which is already indexed
100
+ * as the mirror's link key. No case normalisation, unlike `emails`: these are
101
+ * opaque generated ids, stored exactly as Better Auth issued them, so folding
102
+ * case could only ever turn a hit into a miss.
103
+ *
104
+ * The admin filter panel builds its controls explicitly rather than from this
105
+ * shape, so declaring it here does not put an account-id box in the UI.
106
+ */
107
+ authAccountIds: array().of(string().required()).optional().default([]).label("Auth Account IDs"),
108
+ // ── Enum facets (closed vocabularies, chip pickers) ─────────────────────────
109
+ countries: array().of(string().required()).optional().default([]).label("Countries"),
110
+ roles: enumFacet(SupportedUserRoles, "Roles"),
111
+ // The user's own current seniority (UserSchema.experienceLevel).
112
+ experienceLevels: enumFacet(SupportedExperienceLevels, "Experience Levels"),
113
+ // Levels the user is *targeting* (distinct from their current experienceLevel).
114
+ targetExperienceLevels: enumFacet(
115
+ SupportedExperienceLevels,
116
+ "Target Experience Levels",
117
+ ),
118
+ jobSearchUrgencies: enumFacet(SupportedJobSearchUrgencies, "Job Search Urgency"),
119
+ referralSources: enumFacet(SupportedReferralSources, "Referral Source"),
120
+ statuses: enumFacet(SupportedUserStatuses, "Account Status"),
121
+ profileVisibilities: enumFacet(
122
+ SupportedUserProfileVisibilities,
123
+ "Profile Visibility",
124
+ ),
125
+ // Mode of any education entry (educations[].studyType).
126
+ //
127
+ // Left as a STANDALONE enum, unlike skill/language proficiency above: it is a
128
+ // property of a person's education in general ("studied full time"), and the
129
+ // measured divergence against pairing it with `courses` was 1 user out of
130
+ // 3,465. Pairing is for qualifiers that are MEANINGLESS alone; this one is
131
+ // not, so it stays cheap to use.
132
+ studyTypes: enumFacet(SupportedStudyTypes, "Study Type"),
133
+ // Platforms the user linked a social account for (socialAccounts[].type).
134
+ socialAccountTypes: enumFacet(SupportedSocialAccounts, "Social Accounts"),
135
+ // Currency of the user's salary floor (minSalaryCurrency).
136
+ salaryCurrencies: enumFacet(SupportedSalaryCurrencies, "Salary Currency"),
137
+
138
+ // ── Free-text token facets (nested profile arrays) ──────────────────────────
139
+ // Skill and language each carry their OWN proficiency, because a level is
140
+ // only meaningful attached to the thing it qualifies. See `qualified`:
141
+ // as two independent facets, "python + expert" returned 301 users where only
142
+ // 103 were actually expert at Python.
143
+ //
144
+ // `[{ name: 'python', proficiencyLevel: 'expert' }]` — the level is optional,
145
+ // so naming a skill without one still filters on the skill alone.
146
+ skills: qualified(SupportedProficiencyLevels, "proficiencyLevel", "Skills"),
147
+ languages: qualified(SupportedProficiencyLevels, "proficiencyLevel", "Languages"),
148
+ interests: tokens("Interests"),
149
+
150
+ // LOOSE forms, kept deliberately. Each of these is a real question on its own
151
+ // ("worked at Google", "was an engineer somewhere"), and asking for two of
152
+ // them usually does mean "has each of these somewhere in their history".
153
+ // They AND across the whole array, so different entries may satisfy each.
154
+ //
155
+ // `companyName` / `instituteName`, NOT the `company` widget the job filter
156
+ // uses. Both are PICKED from real pages — a profile stores the whole
157
+ // `PageSchema.pick(["name","slug","type","logo"])` object, not free text — but
158
+ // the facet matches `workExperiences.company.name`, so the value to store is
159
+ // the page's NAME. Storing its slug would be a picker that matches nothing.
160
+ companies: tokens("Companies").meta({ widget: "companyName" }),
161
+ designations: tokens("Job Titles / Designations"),
162
+ institutes: tokens("Institutes").meta({ widget: "instituteName" }),
163
+ courses: tokens("Courses"),
164
+ fieldsOfStudy: tokens("Fields of Study"),
165
+
166
+ // PRECISE forms: all parts must describe ONE entry.
167
+ //
168
+ // The narrower question the loose facets above cannot express. "Engineer AT
169
+ // Google" as `companies=Google & designations=engineer` also matches someone
170
+ // who was an engineer at Acme and a designer at Google, because Mongo
171
+ // satisfies each path from a different array entry. Measured on production:
172
+ // VIT + B.Tech returned 71 users where 64 had one education entry that was
173
+ // both, and Infosys + Test Engineer returned 7 where 6 actually matched.
174
+ //
175
+ // Additive rather than a replacement: both readings are legitimate, so the
176
+ // vocabulary carries both and the caller says which it means. 1,573 users
177
+ // have two or more work entries and 1,761 have two or more educations, so the
178
+ // distinction is real and grows as profiles fill in.
179
+ //
180
+ // The fields mirror what the profile form actually collects for one entry
181
+ // (`WorkExperienceSchema` / `EducationSchema`), minus the parts that cannot
182
+ // narrow an audience: `description` is prose, and a per-entry `location`
183
+ // duplicates the top-level geography facets against a much emptier path.
184
+ //
185
+ // `isCurrent` reads `duration.isActive`, which is what the profile form calls
186
+ // "I currently work here". It is the field that makes this facet worth having
187
+ // per entry: "engineer at Google" and "engineer at Google NOW" are different
188
+ // audiences, and only the second is reachable without it. Live counts:
189
+ // 2,190 users have a current job against 4,527 with a past one, 1,456 have a
190
+ // remote entry, 4,088 are still studying.
191
+ workHistory: conjunction(
192
+ [
193
+ "company",
194
+ "designation",
195
+ { name: "isCurrent", type: "boolean" },
196
+ { name: "isRemote", type: "boolean" },
197
+ ],
198
+ "Work History",
199
+ ),
200
+ educationHistory: conjunction(
201
+ [
202
+ "institute",
203
+ "course",
204
+ "fieldOfStudy",
205
+ { name: "studyType", type: "string", oneOf: SupportedStudyTypes },
206
+ { name: "isCurrent", type: "boolean" },
207
+ { name: "isDistanceLearning", type: "boolean" },
208
+ ],
209
+ "Education History",
210
+ ),
211
+ // The issuing body belongs to the certificate, not to the person: "AWS SA"
212
+ // plus "Microsoft" as two facets matches someone holding an AWS cert AND any
213
+ // unrelated Microsoft one. 38% of users with a certification hold two or more,
214
+ // so the entries are there to diverge.
215
+ //
216
+ // `authority` is free text rather than a closed vocabulary, hence `"open"`.
217
+ certifications: qualified("open", "authority", "Certifications"),
218
+ projects: tokens("Projects"),
219
+ jobRoles: tokens("Target Roles"),
220
+ jobLocations: tokens("Preferred Job Locations"),
221
+ // Companies a recruiter is hiring for (recruiterProfile.hiringFor[].page.name).
222
+ // A picked page (`recruiterProfile.hiringFor.page`), matched on `.name`.
223
+ //
224
+ // `pageName`, which searches BOTH page types: `RecruiterPageLinkSchema` picks
225
+ // `PageSchema` without constraining `type`, so a recruiter may be hiring for
226
+ // an institute as legitimately as for a company. Production carries exactly
227
+ // that on the coordinator side — a `coordinatesAt` entry holding a company —
228
+ // so scoping either of these to one type would hide real data.
229
+ hiringForCompanies: tokens("Hiring For").meta({ widget: "pageName" }),
230
+ // Institutes a coordinator works at (coordinatorProfile.coordinatesAt[].page.name).
231
+ // Same: a picked page (`coordinatorProfile.coordinatesAt.page`), on `.name`,
232
+ // and likewise unconstrained in `type`.
233
+ coordinatesAtInstitutes: tokens("Coordinates At").meta({ widget: "pageName" }),
234
+ // Case-insensitive keyword match against headline + aboutMe.
235
+ keywords: tokens("Profile Keywords"),
236
+
237
+ // ── Range / boolean facets ──────────────────────────────────────────────────
238
+ // A salary bound is meaningless without a currency: `minSalary` is a raw
239
+ // integer and 50000 SEK is not 50000 USD, so an un-currencied range matches
240
+ // across currencies and quietly means nothing. Requiring exactly one currency
241
+ // alongside a bound is what makes the comparison sound.
242
+ // The sibling currency facet is named explicitly: this bound is inert without
243
+ // it, so the dependency is stated rather than assumed.
244
+ minSalaryFloor: currencyBound("Min Salary ≥", "salaryCurrencies"),
245
+ minSalaryCeil: currencyBound("Min Salary ≤", "salaryCurrencies"),
246
+ openToWorkOnly: boolean().optional().default(false).label("Open to Work Only"),
247
+ remoteExperienceOnly: boolean()
248
+ .optional()
249
+ .default(false)
250
+ .label("Has Remote Experience"),
251
+ hasResumeOnly: boolean().optional().default(false).label("Has Resume"),
252
+ onboardingCompletedOnly: boolean()
253
+ .optional()
254
+ .default(false)
255
+ .label("Onboarding Completed"),
256
+ emailVerifiedOnly: boolean().optional().default(false).label("Email Verified Only"),
257
+ mobileVerifiedOnly: boolean()
258
+ .optional()
259
+ .default(false)
260
+ .label("Mobile Verified Only"),
261
+
262
+ // ── Lifecycle windows ───────────────────────────────────────────────────────
263
+ // Relative, because a saved list is resolved fresh on every send: a stored
264
+ // absolute epoch would freeze at authoring time and the audience would decay
265
+ // to nothing. The absolute forms below exist for one-shot sends; when both are
266
+ // present the relative form wins.
267
+ lastSeenBeforeDays: days("Inactive For (days) ≥"),
268
+ lastSeenAfterDays: days("Active Within (days)"),
269
+ createdAtWithinDays: days("Signed Up Within (days)"),
270
+ createdAtOlderThanDays: days("Signed Up Before (days ago)"),
271
+ completenessMin: percent("Profile Completeness ≥"),
272
+ completenessMax: percent("Profile Completeness ≤"),
273
+ // Absolute epoch-ms forms. Prefer the relative fields above for anything that
274
+ // repeats.
275
+ lastSeenBefore: epoch("Last Seen Before"),
276
+ lastSeenAfter: epoch("Last Seen After"),
277
+ createdAtFrom: epoch("Signed Up After"),
278
+ createdAtBefore: epoch("Signed Up Before"),
279
+
280
+ // ── Geography ───────────────────────────────────────────────────────────────
281
+ // `countries` above matches location.countryCode. These narrow within it.
282
+ // NOTE: stateCodes are stored bare (`KA`, not `IN-KA`) — see the pending
283
+ // @todo STATE-CODE-ISO-PREFIX migration. Match the stored form, do not prefix.
284
+ states: tokens("States"),
285
+ stateCodes: tokens("State Codes"),
286
+ cities: tokens("Cities"),
287
+ // Radius search around a point, against location.geo. All three required
288
+ // together; validated as a group rather than field-by-field so a half-filled
289
+ // radius cannot silently match everyone.
290
+ withinRadius: object({
291
+ lat: number().min(-90).max(90).required().label("Latitude"),
292
+ lng: number().min(-180).max(180).required().label("Longitude"),
293
+ km: number().moreThan(0).max(20_000).required().label("Radius (km)"),
294
+ })
295
+ .optional()
296
+ .default(undefined)
297
+ .label("Within Radius"),
298
+
299
+ // ── Signup context (immutable market / locale) ──────────────────────────────
300
+ signupCountries: tokens("Signup Countries"),
301
+ signupLocales: tokens("Signup Locales"),
302
+ signupSources: enumFacet(SupportedSignupContextSources, "Signup Source"),
303
+
304
+ // ── Attainment ──────────────────────────────────────────────────────────────
305
+ // Absent on users who predate the field. Absent means "unknown", never a
306
+ // match — filtering on this necessarily excludes them.
307
+ educationLevels: enumFacet(SupportedEducationLevels, "Education Level"),
308
+
309
+ // ── Consent metadata ────────────────────────────────────────────────────────
310
+ // `marketingSubscribed` IS a facet, and deliberately so. It used to be a hard
311
+ // opt-in gate applied unconditionally by user-service; that gate was removed
312
+ // when user-service became a pure data source, so consent is now an ordinary
313
+ // filter that marketing-service defaults to `true` in `resolveAudience`.
314
+ //
315
+ // NO `.default()` HERE, unlike every other boolean facet. The send spreads its
316
+ // default first and lets a stored value win, so this must distinguish "the
317
+ // list said nothing" (absent -> default applies) from "the list said false"
318
+ // (override -> mails users who never opted in). A `.default(false)` would
319
+ // materialise the second case on every list that never chose it.
320
+ //
321
+ // @todo CONSENT-FILTER-ENFORCEMENT — nothing enforces consent anywhere now.
322
+ marketingSubscribed: boolean().optional().label("Marketing Subscribed"),
323
+ jobAlertFrequencies: enumFacet(
324
+ SupportedJobAlertFrequencies,
325
+ "Job Alert Frequency",
326
+ ),
327
+ consentSources: tokens("Consent Source"),
328
+ consentUpdatedWithinDays: days("Consent Updated Within (days)"),
329
+ /** The absolute bound `consentUpdatedWithinDays` resolves into (epoch ms). */
330
+ consentUpdatedAfter: epoch("Consent Updated After"),
331
+
332
+ // ── Notification preferences ────────────────────────────────────────────────
333
+ // Read as opt-IN (`=== true`), matching marketingConsent and NOT the `!== false`
334
+ // reading used for transactional notification routing. A campaign is bulk mail,
335
+ // so a channel the user never affirmatively enabled does not count as consent.
336
+ // `productUpdates` defaults to false on the user document, so targeting it
337
+ // selects only people who deliberately turned it on.
338
+ notificationChannelsEnabled: enumFacet(
339
+ SupportedNotificationTypes,
340
+ "Notification Channels Enabled",
341
+ ),
342
+
343
+ // ── Tenure / recency ────────────────────────────────────────────────────────
344
+ // Against workExperiences[].duration and educations[].duration ("YYYY-MM").
345
+ currentlyEmployed: boolean()
346
+ .optional()
347
+ .default(false)
348
+ .label("Currently Employed"),
349
+ graduatedWithinMonths: months("Graduated Within (months)"),
350
+ skillUsedWithinMonths: months("Skill Used Within (months)"),
351
+
352
+ // The ABSOLUTE bounds the three relative windows above resolve into
353
+ // (`graduatedWithinMonths` -> `graduatedAfter`, and so on). Both forms are
354
+ // accepted: a caller may send either, and user-service derives the bound when
355
+ // only the window is given.
356
+ //
357
+ // A UI should offer the WINDOW, not these. A saved list is re-resolved on
358
+ // every send, so a stored absolute date rots — "graduated after 2024-03" keeps
359
+ // meaning that forever, while "within 18 months" keeps meaning what the admin
360
+ // intended. They are declared because the vocabulary must be complete, not
361
+ // because a list should store one.
362
+ graduatedAfter: string().trim().optional().label("Graduated After"),
363
+ skillUsedAfter: string().trim().optional().label("Skill Used After"),
364
+
365
+ // ── Profile richness ────────────────────────────────────────────────────────
366
+ hasImage: boolean().optional().default(false).label("Has Photo"),
367
+ hasHeadline: boolean().optional().default(false).label("Has Headline"),
368
+ hasAboutMe: boolean().optional().default(false).label("Has About Me"),
369
+ hasMobile: boolean().optional().default(false).label("Has Mobile Number"),
370
+ // Segment by WHICH profile section is missing (profileCompleteness.missing[]),
371
+ // rather than only by the overall percentage.
372
+ missingSections: tokens("Missing Profile Sections"),
373
+
374
+ // ── Employer / institute taxonomy ───────────────────────────────────────────
375
+ companyTypes: enumFacet(SupportedPageTypes, "Company Type"),
376
+ instituteTypes: enumFacet(SupportedPageTypes, "Institute Type"),
377
+
378
+ // ── Activity ────────────────────────────────────────────────────────────────
379
+ // Backed by denormalised counters on the user document, because the source
380
+ // data lives in other services' collections. Tri-state: `false` is a real
381
+ // filter ("has NO job alert"), which is the audience an alert nudge wants, so
382
+ // these are `undefined` when unset rather than defaulting to false.
383
+ hasJobAlert: boolean()
384
+ .optional()
385
+ .default(undefined)
386
+ .label("Has a Job Alert"),
387
+ hasApplied: boolean().optional().default(undefined).label("Has Applied"),
388
+ appliedWithinDays: days("Applied Within (days)"),
389
+
390
+ // `search` is NOT here. It is free text over name/email, not a facet: it names
391
+ // no field on a user document, user-service treats it as a RESERVED PARAM
392
+ // beside the filter rather than a filter key, and it is meaningless inside an
393
+ // `or` branch (a union of "matches this text" with a facet clause). It travels
394
+ // as a sibling of the filter — see `PreviewFilterSchema` in marketing-service —
395
+ // so a saved audience cannot carry free text it would silently re-run later.
396
+ })
397
+ .label("Audience Filter Branch");
398
+
399
+ const BRANCH_KEYS = Object.keys(UserFilterBranchShape.fields);
400
+
401
+ /**
402
+ * One branch of `or`: every clause except `or` itself, so unions cannot nest.
403
+ *
404
+ * INTERNAL, for the same reason as the shape above — a consumer validates a
405
+ * whole filter, never a lone branch.
406
+ */
407
+ const UserFilterBranch = rejectUnknownKeys(UserFilterBranchShape, () => BRANCH_KEYS);
408
+
409
+ /**
410
+ * THE user filter. One declaration, three derived uses:
411
+ *
412
+ * - `@thejob/users-client` derives its `UserFilter` type with `InferType`.
413
+ * - `thejob-user-service` derives `USER_FILTER_FACETS` with
414
+ * `Object.keys(UserFilterSchema.fields)` — the closed vocabulary it 400s
415
+ * against.
416
+ * - `MailingListSchema.filter` stores one, as does the audience preview.
417
+ *
418
+ * It was previously written out three times: a yup schema here, a hand-written
419
+ * TS interface in the client, and a hand-listed `Set` of ~74 strings in the
420
+ * service. Three lists of the same names drift, and the drift is invisible — a
421
+ * facet the schema accepts but the service does not is a 400 on a send that
422
+ * validated cleanly when it was saved.
423
+ *
424
+ * A FILTER, not an audience: it says which users match and nothing about what a
425
+ * caller intends to do with them. Marketing narrows it one way, an admin list
426
+ * another, and neither meaning belongs here.
427
+ *
428
+ * Every clause AND-s together; `or` unions the whole filter with each branch,
429
+ * deduplicating by user, for audiences that are genuinely a union rather than an
430
+ * intersection ("signed up recently OR active recently"). One level only:
431
+ * branches do not nest, which the non-recursive definition above enforces.
432
+ */
433
+ export const UserFilterSchema = rejectUnknownKeys(
434
+ UserFilterBranchShape.shape({
435
+ or: array().of(UserFilterBranch).optional().default([]).label("Or Any Of"),
436
+ }).label("User Filter"),
437
+ () => [...BRANCH_KEYS, "or"],
438
+ );
@@ -1,3 +1,35 @@
1
- import { string } from "yup";
1
+ import { object, string } from "yup";
2
2
 
3
- export const UserInterestSchema = string().trim().required().label("Interest");
3
+ /**
4
+ * One interest on a user profile, as a REFERENCE to a taxonomy entry.
5
+ *
6
+ * This was declared as a bare `string()` and that was simply wrong about the
7
+ * data: a full census of the live users collection found 41,822 interest
8
+ * entries, 100% of them objects, with `id`, `name` and `logo` present on every
9
+ * single one. Not one string.
10
+ *
11
+ * The damage was silent and reached the UI. Because the schema said `string`,
12
+ * every type derived from it said `string[]`, so an admin panel rendering
13
+ * `{interest}` type-checked cleanly and printed `[object Object]` to the
14
+ * operator. The public profile page had already worked around it by declaring
15
+ * its own local `Interest` interface with the real shape, which is how a wrong
16
+ * declaration survives: each consumer patches around it privately instead of
17
+ * one fix at the source.
18
+ *
19
+ * `logo` is a material-symbols icon NAME (`cloud`, `code`, `psychology`), not a
20
+ * URL or a storage key, so it is rendered as an icon rather than fetched.
21
+ *
22
+ * Every field is required because the data says so: there is no partial entry
23
+ * among the 41,822, and these are written by picking an existing taxonomy
24
+ * entry rather than typed free-hand.
25
+ */
26
+ export const UserInterestSchema = object({
27
+ /** The taxonomy entry's id. */
28
+ id: string().trim().required().label("Interest Id"),
29
+ /** Display name, e.g. "Cloud Computing". */
30
+ name: string().trim().required().label("Interest"),
31
+ /** Material-symbols icon name, e.g. "cloud". */
32
+ logo: string().trim().required().label("Interest Logo"),
33
+ })
34
+ .required()
35
+ .label("Interest");