@thejob/schema 2.1.3 → 2.1.5

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,84 @@
1
+ import { number, object, string } from "yup";
2
+ import { JobAlertFrequency, SupportedJobAlertFrequencies } from "../user/user.constant.js";
3
+ import { SavedSearchStatus, SupportedSavedSearchStatuses } from "./saved-search.constant.js";
4
+
5
+ /**
6
+ * A search a user asked to be kept, and optionally to be alerted about.
7
+ *
8
+ * Two jobs in one document, deliberately: the query the user built, and the
9
+ * cadence at which they want it re-run for them. Splitting them would mean a
10
+ * user with three saved searches and one alert preference has no way to say
11
+ * which search the alert is for.
12
+ *
13
+ * The stored query mirrors what `GET /jobs/search-v3` accepts — a free-text
14
+ * `search` plus a `filter` map — so replaying a saved search is running the same
15
+ * search the user originally ran, not a second, subtly different implementation
16
+ * that drifts from it.
17
+ */
18
+ export const SavedSearchSchema = object({
19
+ /** Public identifier. Server-assigned. */
20
+ shortId: string().trim().label("Short Id"),
21
+
22
+ /**
23
+ * Owner's Better Auth account id. Server-assigned from the verified token,
24
+ * never accepted from a request body.
25
+ */
26
+ ownerUserId: string().trim().label("Owner"),
27
+
28
+ /** What the user calls this search, e.g. "Remote React roles". */
29
+ label: string().trim().max(120).required().label("Label"),
30
+
31
+ /**
32
+ * The free-text query. Optional because a search can be filters-only ("all
33
+ * remote jobs in Sweden"), which is a legitimate alert to want.
34
+ */
35
+ search: string().trim().max(500).optional().label("Search Text"),
36
+
37
+ /**
38
+ * Field filters, in the same `{ field: values }` shape the search endpoint
39
+ * parses from `filter[field]=a,b`. Held as a free-form object rather than a
40
+ * closed schema because the job facets it addresses live in jobs-service and
41
+ * change independently; validating them here would put this package in the
42
+ * business of tracking that vocabulary.
43
+ */
44
+ filter: object().optional().default({}).label("Filters"),
45
+
46
+ /**
47
+ * How often to mail matches. `off` keeps the search saved but silent, which is
48
+ * the difference between "I don't want mail" and "delete my search".
49
+ *
50
+ * Reuses the cadence enum from `marketingConsent.jobAlertFrequency` rather
51
+ * than defining a second one: two vocabularies for the same concept is how
52
+ * they drift.
53
+ */
54
+ frequency: string()
55
+ .oneOf(SupportedJobAlertFrequencies)
56
+ .default(JobAlertFrequency.Off)
57
+ .label("Alert Frequency"),
58
+
59
+ status: string()
60
+ .oneOf(SupportedSavedSearchStatuses)
61
+ .default(SavedSearchStatus.Active)
62
+ .label("Status"),
63
+
64
+ /**
65
+ * Epoch ms of the last alert sent for this search. The matching sweep reads it
66
+ * to decide whether the cadence is due, and writes it only AFTER a successful
67
+ * publish, so a failed run retries next tick rather than silently skipping a
68
+ * period.
69
+ */
70
+ lastNotifiedAt: number().optional().label("Last Notified At"),
71
+
72
+ /**
73
+ * Epoch ms high-water mark: only jobs created after this are "new" for the
74
+ * next alert. Kept separate from `lastNotifiedAt` because a run that finds no
75
+ * matches should still advance what counts as new, without implying mail was
76
+ * sent.
77
+ */
78
+ lastMatchedAt: number().optional().label("Last Matched At"),
79
+ })
80
+ // `createdAt`/`updatedAt` are deliberately absent, matching every other schema
81
+ // here: audit timestamps are the storing service's concern, not part of the
82
+ // shape a client sends or validates.
83
+ .noUnknown()
84
+ .strict();
@@ -1,7 +1,8 @@
1
- import { object, string } from "yup";
1
+ import { number, object, string } from "yup";
2
2
  import { SupportedExperienceLevels } from "../common/common.constant.js";
3
3
  import { LocationSchema } from "../location/location.schema.js";
4
4
  import {
5
+ SupportedSignupContextSources,
5
6
  SupportedUserProfileVisibilities,
6
7
  UserProfileVisibility,
7
8
  } from "./user.constant.js";
@@ -30,14 +31,35 @@ export const UserGeneralDetailSchema = object({
30
31
  .required()
31
32
  .label("Experience level"),
32
33
  location: LocationSchema.required().label("Location"),
33
- region: object()
34
+ /**
35
+ * Where the account was created, captured once and never rewritten.
36
+ *
37
+ * Server-owned: listed in user-service's SERVER_CONTROLLED_FIELDS and absent
38
+ * from every user-writable schema, so a client cannot set or amend it. That
39
+ * immutability is the point — this answers "which market/jurisdiction governed
40
+ * this user at sign-up?", which a field the user can PATCH could never do.
41
+ *
42
+ * `source` records HOW the value was obtained, because most of it is inferred
43
+ * rather than measured, and an audit has to be able to tell those apart:
44
+ * - `cf-header` measured from Cloudflare's `cf-ipcountry` at sign-up
45
+ * - `location` derived from the user's self-entered profile location
46
+ * - `default` assumed (no signal available); presumption, not evidence
47
+ */
48
+ signupContext: object()
34
49
  .shape({
35
- country: string().trim().required().label("Region Country"),
36
- lang: string().trim().required().label("Region Language"),
50
+ // ISO 3166-1 alpha-2, uppercase — the shape `cf-ipcountry` returns, so
51
+ // measured and inferred values are directly comparable.
52
+ country: string().trim().uppercase().length(2).required().label("Signup Country"),
53
+ locale: string().trim().required().label("Signup Locale"),
54
+ source: string()
55
+ .oneOf(SupportedSignupContextSources)
56
+ .required()
57
+ .label("Signup Context Source"),
58
+ at: number().required().label("Signup Context At"),
37
59
  })
38
60
  .optional()
39
61
  .default(undefined)
40
- .label("Region"),
62
+ .label("Signup Context"),
41
63
  profileVisibility: string()
42
64
  .oneOf(SupportedUserProfileVisibilities)
43
65
  .default(UserProfileVisibility.Public)
@@ -34,6 +34,26 @@ export const SupportedUserProfileVisibilities = Object.values(
34
34
  UserProfileVisibility,
35
35
  );
36
36
 
37
+ /**
38
+ * How a user's `signupContext` was obtained. Stored on the document, so these
39
+ * strings are data: lowercase, and a rename is a migration.
40
+ *
41
+ * The distinction is deliberate. Only `CfHeader` is measured; the other two are
42
+ * inferred, and `Default` is a bare presumption with no signal behind it at all.
43
+ * Collapsing them would make an assumed country indistinguishable from an
44
+ * observed one, which is precisely what a compliance record must not do.
45
+ */
46
+ export enum SignupContextSource {
47
+ /** Measured from Cloudflare's `cf-ipcountry` header at sign-up. */
48
+ CfHeader = "cf-header",
49
+ /** Derived from the user's self-entered profile location (backfill). */
50
+ Location = "location",
51
+ /** Assumed; no signal was available. A presumption, not evidence. */
52
+ Default = "default",
53
+ }
54
+
55
+ export const SupportedSignupContextSources = Object.values(SignupContextSource);
56
+
37
57
  export enum UserDetailType {
38
58
  Overview = "overview",
39
59
  AdditionalInfo = "additionalInfo",