@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
|
@@ -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
|
+
);
|
|
@@ -0,0 +1,97 @@
|
|
|
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
|
+
/**
|
|
85
|
+
* Epoch ms the owner explicitly signed out on this device, or absent.
|
|
86
|
+
*
|
|
87
|
+
* A PAUSE, not a removal: the sender skips flagged rows, so a shared device
|
|
88
|
+
* stops receiving the previous user's notifications, while the same user
|
|
89
|
+
* signing back in clears the flag with no prompt. A flag rather than a delete
|
|
90
|
+
* so that a MISSING row keeps one meaning to the browser ("stop, this device
|
|
91
|
+
* was removed"), which it acts on by dropping its own subscription.
|
|
92
|
+
*/
|
|
93
|
+
signedOutAt: number().nullable().optional().label("Signed Out At"),
|
|
94
|
+
})
|
|
95
|
+
.noUnknown()
|
|
96
|
+
.strict()
|
|
97
|
+
.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
|
-
|
|
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");
|