@thejob/schema 2.1.4 → 2.1.6

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 CHANGED
@@ -50,8 +50,31 @@ Practical consequences:
50
50
 
51
51
  Enum string values are consumed as literals across services and stored in Mongo, so
52
52
  their casing is data, not style. Group `managedBy` / `visibility` / `status` are
53
- **lowercase** (`system`, `private`, `archived`). Changing a case is a data migration,
54
- not a refactor existing documents will silently stop matching.
53
+ **lowercase** (`system`, `private`, `archived`), as are `SavedSearchStatus` and
54
+ `JobAlertFrequency`. Changing a case is a data migration, not a refactor —
55
+ existing documents will silently stop matching.
56
+
57
+ ## The consent fields are a compliance surface
58
+
59
+ Three shapes here decide whether the platform may mail someone, and each behaves
60
+ differently on purpose:
61
+
62
+ - **`notificationPrefs`** (`user.schema.ts`) — per-channel, opt-**out**. The
63
+ consumer reads `!== false`, so a user whose document predates the field must
64
+ read as *enabled*. That is why adding it needed no backfill migration, and why
65
+ a "tidy-up" that made the fields required would silently mute every legacy user.
66
+ - **`marketingConsent.subscribed`** — opt-**in**, read as `=== true`. The exact
67
+ inverse. Do not collapse the two into one shape; they have different legal
68
+ footing, which email-service's footer rules already encode.
69
+ - **`marketingConsent.jobAlertFrequency`** — a *second* gate on job-alert mail
70
+ only, so a user can stop alerts while staying subscribed generally.
71
+
72
+ `NotificationChannel` values are duplicated as a string union in `@thejob/notify`
73
+ (so that package stays dependency-free). This package remains the source of
74
+ truth; adding a channel means editing the enum, the `notificationPrefs` object
75
+ keys, its `.default()`, and that union.
76
+
77
+ Full picture: `thejob-notification-service/NOTIFICATIONS.md`.
55
78
 
56
79
  ## Conventions
57
80
 
package/dist/index.cjs CHANGED
@@ -102,6 +102,7 @@ __export(index_exports, {
102
102
  SYSTEM_DATE_FORMAT: () => SYSTEM_DATE_FORMAT,
103
103
  SavedSearchSchema: () => SavedSearchSchema,
104
104
  SavedSearchStatus: () => SavedSearchStatus,
105
+ SignupContextSource: () => SignupContextSource,
105
106
  SkillSchema: () => SkillSchema,
106
107
  SocialAccount: () => SocialAccount,
107
108
  SocialAccountSchema: () => SocialAccountSchema,
@@ -135,6 +136,7 @@ __export(index_exports, {
135
136
  SupportedResourceTypes: () => SupportedResourceTypes,
136
137
  SupportedSalaryCurrencies: () => SupportedSalaryCurrencies,
137
138
  SupportedSavedSearchStatuses: () => SupportedSavedSearchStatuses,
139
+ SupportedSignupContextSources: () => SupportedSignupContextSources,
138
140
  SupportedSocialAccounts: () => SupportedSocialAccounts,
139
141
  SupportedStudyTypes: () => SupportedStudyTypes,
140
142
  SupportedUserProfileVisibilities: () => SupportedUserProfileVisibilities,
@@ -592,6 +594,26 @@ var PageSchema = (0, import_yup12.object)().shape({
592
594
  var import_yup13 = require("yup");
593
595
  var SkillSchema = (0, import_yup13.object)({
594
596
  name: (0, import_yup13.string)().trim().required().label("Skill Name"),
597
+ /**
598
+ * The taxonomy concept this skill resolved to, assigned by thejob-taxonomy-service
599
+ * via `POST /normalize/batch`. Stable slug, e.g. "power-bi".
600
+ *
601
+ * Normalization is ADDITIVE and never authoritative over `name`: the raw string a
602
+ * job was posted with is always preserved, and these two fields are written
603
+ * alongside it. That is what makes a wrong match, a taxonomy re-run, or a service
604
+ * outage recoverable rather than a data loss.
605
+ *
606
+ * Optional and nullable because they genuinely are absent in two normal cases,
607
+ * neither of which is an error:
608
+ * - the term did not resolve (~22% of mentions are a long tail the taxonomy has
609
+ * no concept for yet), and the raw `name` stands alone;
610
+ * - the document predates normalization and has not been re-processed.
611
+ *
612
+ * Consumers must therefore read `conceptName ?? name`, never `conceptName` alone.
613
+ */
614
+ conceptId: (0, import_yup13.string)().optional().nullable().label("Concept ID"),
615
+ /** Canonical display name for `conceptId`, e.g. "Power BI". See `conceptId`. */
616
+ conceptName: (0, import_yup13.string)().optional().nullable().label("Concept Name"),
595
617
  logo: (0, import_yup13.object)({
596
618
  light: (0, import_yup13.string)().required().label("Light Logo"),
597
619
  dark: (0, import_yup13.string)().optional().nullable().label("Dark Logo")
@@ -797,7 +819,15 @@ var JobSchema = (0, import_yup18.object)({
797
819
  designation: (0, import_yup18.string)().optional().nullable().label("Designation"),
798
820
  employmentType: (0, import_yup18.string)().required().label("Employment type"),
799
821
  workMode: (0, import_yup18.string)().required().label("Work mode"),
800
- skills: (0, import_yup18.array)().of(SkillSchema.pick(["name", "logo"]).required().label("Skill")).nullable().optional().label("Skills"),
822
+ /**
823
+ * `conceptId`/`conceptName` carry the taxonomy-service normalization. They are
824
+ * picked here (not just `name`/`logo`) because SkillSchema is `.noUnknown()`:
825
+ * an unpicked field that is actually PRESENT fails validation, so omitting them
826
+ * would reject every normalized job.
827
+ */
828
+ skills: (0, import_yup18.array)().of(
829
+ SkillSchema.pick(["name", "logo", "conceptId", "conceptName"]).required().label("Skill")
830
+ ).nullable().optional().label("Skills"),
801
831
  bookmarks: (0, import_yup18.array)().of(UserIdAndCreatedAtSchema).optional().label("Bookmarks"),
802
832
  embedding: (0, import_yup18.object)({
803
833
  vector: (0, import_yup18.array)((0, import_yup18.number)().required()).optional().label("Embedding vector"),
@@ -2110,6 +2140,13 @@ var UserProfileVisibility = /* @__PURE__ */ ((UserProfileVisibility2) => {
2110
2140
  var SupportedUserProfileVisibilities = Object.values(
2111
2141
  UserProfileVisibility
2112
2142
  );
2143
+ var SignupContextSource = /* @__PURE__ */ ((SignupContextSource2) => {
2144
+ SignupContextSource2["CfHeader"] = "cf-header";
2145
+ SignupContextSource2["Location"] = "location";
2146
+ SignupContextSource2["Default"] = "default";
2147
+ return SignupContextSource2;
2148
+ })(SignupContextSource || {});
2149
+ var SupportedSignupContextSources = Object.values(SignupContextSource);
2113
2150
  var UserDetailType = /* @__PURE__ */ ((UserDetailType2) => {
2114
2151
  UserDetailType2["Overview"] = "overview";
2115
2152
  UserDetailType2["AdditionalInfo"] = "additionalInfo";
@@ -2533,10 +2570,28 @@ var UserGeneralDetailSchema = (0, import_yup33.object)({
2533
2570
  mobileVerified: (0, import_yup33.string)().nullable().optional().label("Mobile Verified"),
2534
2571
  experienceLevel: (0, import_yup33.string)().oneOf(SupportedExperienceLevels).required().label("Experience level"),
2535
2572
  location: LocationSchema.required().label("Location"),
2536
- region: (0, import_yup33.object)().shape({
2537
- country: (0, import_yup33.string)().trim().required().label("Region Country"),
2538
- lang: (0, import_yup33.string)().trim().required().label("Region Language")
2539
- }).optional().default(void 0).label("Region"),
2573
+ /**
2574
+ * Where the account was created, captured once and never rewritten.
2575
+ *
2576
+ * Server-owned: listed in user-service's SERVER_CONTROLLED_FIELDS and absent
2577
+ * from every user-writable schema, so a client cannot set or amend it. That
2578
+ * immutability is the point — this answers "which market/jurisdiction governed
2579
+ * this user at sign-up?", which a field the user can PATCH could never do.
2580
+ *
2581
+ * `source` records HOW the value was obtained, because most of it is inferred
2582
+ * rather than measured, and an audit has to be able to tell those apart:
2583
+ * - `cf-header` measured from Cloudflare's `cf-ipcountry` at sign-up
2584
+ * - `location` derived from the user's self-entered profile location
2585
+ * - `default` assumed (no signal available); presumption, not evidence
2586
+ */
2587
+ signupContext: (0, import_yup33.object)().shape({
2588
+ // ISO 3166-1 alpha-2, uppercase — the shape `cf-ipcountry` returns, so
2589
+ // measured and inferred values are directly comparable.
2590
+ country: (0, import_yup33.string)().trim().uppercase().length(2).required().label("Signup Country"),
2591
+ locale: (0, import_yup33.string)().trim().required().label("Signup Locale"),
2592
+ source: (0, import_yup33.string)().oneOf(SupportedSignupContextSources).required().label("Signup Context Source"),
2593
+ at: (0, import_yup33.number)().required().label("Signup Context At")
2594
+ }).optional().default(void 0).label("Signup Context"),
2540
2595
  profileVisibility: (0, import_yup33.string)().oneOf(SupportedUserProfileVisibilities).default("public" /* Public */).label("Profile Visibility")
2541
2596
  }).noUnknown().strict().label("General Detail");
2542
2597
 
@@ -2886,6 +2941,7 @@ var StudentCompletenessSchema = UserCompletenessSchema.omit([
2886
2941
  SYSTEM_DATE_FORMAT,
2887
2942
  SavedSearchSchema,
2888
2943
  SavedSearchStatus,
2944
+ SignupContextSource,
2889
2945
  SkillSchema,
2890
2946
  SocialAccount,
2891
2947
  SocialAccountSchema,
@@ -2919,6 +2975,7 @@ var StudentCompletenessSchema = UserCompletenessSchema.omit([
2919
2975
  SupportedResourceTypes,
2920
2976
  SupportedSalaryCurrencies,
2921
2977
  SupportedSavedSearchStatuses,
2978
+ SupportedSignupContextSources,
2922
2979
  SupportedSocialAccounts,
2923
2980
  SupportedStudyTypes,
2924
2981
  SupportedUserProfileVisibilities,
package/dist/index.d.cts CHANGED
@@ -437,6 +437,8 @@ declare const JobSchema: ObjectSchema<{
437
437
  employmentType: string;
438
438
  workMode: string;
439
439
  skills: {
440
+ conceptId?: string | null | undefined;
441
+ conceptName?: string | null | undefined;
440
442
  name: string;
441
443
  logo: {
442
444
  dark?: string | null | undefined;
@@ -1500,6 +1502,24 @@ declare enum UserProfileVisibility {
1500
1502
  Hidden = "hidden"
1501
1503
  }
1502
1504
  declare const SupportedUserProfileVisibilities: UserProfileVisibility[];
1505
+ /**
1506
+ * How a user's `signupContext` was obtained. Stored on the document, so these
1507
+ * strings are data: lowercase, and a rename is a migration.
1508
+ *
1509
+ * The distinction is deliberate. Only `CfHeader` is measured; the other two are
1510
+ * inferred, and `Default` is a bare presumption with no signal behind it at all.
1511
+ * Collapsing them would make an assumed country indistinguishable from an
1512
+ * observed one, which is precisely what a compliance record must not do.
1513
+ */
1514
+ declare enum SignupContextSource {
1515
+ /** Measured from Cloudflare's `cf-ipcountry` header at sign-up. */
1516
+ CfHeader = "cf-header",
1517
+ /** Derived from the user's self-entered profile location (backfill). */
1518
+ Location = "location",
1519
+ /** Assumed; no signal was available. A presumption, not evidence. */
1520
+ Default = "default"
1521
+ }
1522
+ declare const SupportedSignupContextSources: SignupContextSource[];
1503
1523
  declare enum UserDetailType {
1504
1524
  Overview = "overview",
1505
1525
  AdditionalInfo = "additionalInfo",
@@ -1718,6 +1738,8 @@ declare const FeedbackSchema: yup.ObjectSchema<{
1718
1738
 
1719
1739
  declare const SkillSchema: yup.ObjectSchema<{
1720
1740
  name: string;
1741
+ conceptId: string | null | undefined;
1742
+ conceptName: string | null | undefined;
1721
1743
  logo: {
1722
1744
  dark?: string | null | undefined;
1723
1745
  light: string;
@@ -1731,6 +1753,8 @@ declare const SkillSchema: yup.ObjectSchema<{
1731
1753
  updatedAt: number | undefined;
1732
1754
  }, yup.AnyObject, {
1733
1755
  name: undefined;
1756
+ conceptId: undefined;
1757
+ conceptName: undefined;
1734
1758
  logo: null;
1735
1759
  tags: "";
1736
1760
  shortId: undefined;
@@ -1932,9 +1956,11 @@ declare const UserSchema: yup.ObjectSchema<{
1932
1956
  coordinates: number[];
1933
1957
  };
1934
1958
  };
1935
- region: {
1959
+ signupContext: {
1936
1960
  country: string;
1937
- lang: string;
1961
+ at: number;
1962
+ locale: string;
1963
+ source: NonNullable<SignupContextSource | undefined>;
1938
1964
  } | undefined;
1939
1965
  profileVisibility: UserProfileVisibility;
1940
1966
  openToWork: boolean;
@@ -2137,7 +2163,7 @@ declare const UserSchema: yup.ObjectSchema<{
2137
2163
  coordinates: "";
2138
2164
  };
2139
2165
  };
2140
- region: undefined;
2166
+ signupContext: undefined;
2141
2167
  profileVisibility: UserProfileVisibility.Public;
2142
2168
  openToWork: false;
2143
2169
  jobSearchUrgency: undefined;
@@ -2189,9 +2215,11 @@ declare const UserGeneralDetailSchema: yup.ObjectSchema<{
2189
2215
  coordinates: number[];
2190
2216
  };
2191
2217
  };
2192
- region: {
2218
+ signupContext: {
2193
2219
  country: string;
2194
- lang: string;
2220
+ at: number;
2221
+ locale: string;
2222
+ source: NonNullable<SignupContextSource | undefined>;
2195
2223
  } | undefined;
2196
2224
  profileVisibility: UserProfileVisibility;
2197
2225
  }, yup.AnyObject, {
@@ -2221,7 +2249,7 @@ declare const UserGeneralDetailSchema: yup.ObjectSchema<{
2221
2249
  coordinates: "";
2222
2250
  };
2223
2251
  };
2224
- region: undefined;
2252
+ signupContext: undefined;
2225
2253
  profileVisibility: UserProfileVisibility.Public;
2226
2254
  }, "">;
2227
2255
 
@@ -2995,4 +3023,4 @@ declare const StudentCompletenessSchema: yup.ObjectSchema<{
2995
3023
  };
2996
3024
  }, "">;
2997
3025
 
2998
- export { AnswerChoiceType, ApplicationReceivePreference, CampaignSchema, CampaignStatus, ChoiceQuestionSchema, Common, CompensationType, CompletenessScoreSchema, ContactTypes, CoordinatorPageLinkSchema, DISPLAY_DATE_FORMAT, DISPLAY_DATE_FORMAT_SHORT, DateStringSchema, DbDefaultSchema, DefaultPaginatedResponse, DefaultPaginationOptions, DefaultUserRoles, DurationSchema, EMPTY_STRING, EducationLevel, EducationSchema, EmployeeCount, EmploymentType, ExperienceLevel, FEEDBACK_MESSAGE_MAX, FeedbackSchema, FeedbackStatus, FeedbackType, GeneraDetailFields, GroupManagedBy, GroupMembershipSchema, GroupMembershipStatus, GroupSchema, GroupStatus, GroupTranslationSchema, GroupVisibility, InputQuestionSchema, JOB_TAXONOMY, JobAlertFrequency, JobCategory, JobIndustry, JobSchema, JobSearchUrgency, JobStatus, JobSubCategory, ListFilterSchema, LocationSchema, MIN_SALARY_LOWER_BOUND, MIN_SALARY_UPPER_BOUND, MailingListSchema, NotificationChannel, PREFILL_MIRRORED_KEYS, PRIVATE_PROFILE_FIELDS, PageSchema, PageStatus, PageType, type PaginatedResponse, PaginationSchema, PostSchema, PostStatus, type PrefillMirroredKey, type PrivateProfileField, ProficiencyLevel, QuestionSchema, QuestionType, ReadAndAcknowledgeQuestionSchema, RecruiterPageLinkSchema, ReferralSource, ReportReason, ReportSchema, ResourceType, SITEMAP_FORMAT, SYSTEM_DATE_FORMAT, SavedSearchSchema, SavedSearchStatus, SkillSchema, SocialAccount, SocialAccountSchema, StudentCompletenessSchema, StudyType, SupportedAnswerChoiceTypes, SupportedApplicationReceivePreferences, SupportedCampaignStatuses, SupportedCompensationTypes, SupportedContactTypes, SupportedEducationLevels, SupportedEmployeeCounts, SupportedEmploymentTypes, SupportedExperienceLevels, SupportedFeedbackStatuses, SupportedFeedbackTypes, SupportedJobAlertFrequencies, SupportedJobCategories, SupportedJobIndustries, SupportedJobSearchUrgencies, SupportedJobStatuses, SupportedJobSubCategories, SupportedNotificationChannels, SupportedPageStatuses, SupportedPageTypes, SupportedPostStatuses, SupportedProficiencyLevels, SupportedQuestionTypes, SupportedReferralSources, SupportedReportReasons, SupportedResourceTypes, SupportedSalaryCurrencies, type SupportedSalaryCurrency, SupportedSavedSearchStatuses, SupportedSocialAccounts, SupportedStudyTypes, SupportedUserProfileVisibilities, SupportedUserRoles, SupportedUserStatuses, SupportedWorkModes, TAXONOMY_LABELS, type TChoiceQuestionSchema, type TDurationSchema, type TInputQuestionSchema, type TJobSchema, type TPostSchema, type TQuestionSchema, type TReadAndAcknowledgeQuestionSchema, type TUserIdAndCreatedAtSchema, TermsAcceptedSchema, UserAdditionalInfoSchema, UserCertificationSchema, UserCompletenessSchema, UserCoordinatorProfileSchema, UserDetailType, UserGeneralDetailSchema, UserIdAndCreatedAtSchema, UserInterestSchema, UserJobPreferencesSchema, UserLanguageSchema, type UserProfileOverview, UserProfileVisibility, UserProjectSchema, UserRecruiterProfileSchema, UserRole, UserSchema, UserSkillSchema, UserStatus, WorkExperienceSchema, WorkMode, categoryOfIndustry, categoryOfSubCategory, dateString, deriveIndustry, getSchemaByQuestion, industriesOfCategory, industryOfSubCategory, subCategoriesOfIndustry };
3026
+ export { AnswerChoiceType, ApplicationReceivePreference, CampaignSchema, CampaignStatus, ChoiceQuestionSchema, Common, CompensationType, CompletenessScoreSchema, ContactTypes, CoordinatorPageLinkSchema, DISPLAY_DATE_FORMAT, DISPLAY_DATE_FORMAT_SHORT, DateStringSchema, DbDefaultSchema, DefaultPaginatedResponse, DefaultPaginationOptions, DefaultUserRoles, DurationSchema, EMPTY_STRING, EducationLevel, EducationSchema, EmployeeCount, EmploymentType, ExperienceLevel, FEEDBACK_MESSAGE_MAX, FeedbackSchema, FeedbackStatus, FeedbackType, GeneraDetailFields, GroupManagedBy, GroupMembershipSchema, GroupMembershipStatus, GroupSchema, GroupStatus, GroupTranslationSchema, GroupVisibility, InputQuestionSchema, JOB_TAXONOMY, JobAlertFrequency, JobCategory, JobIndustry, JobSchema, JobSearchUrgency, JobStatus, JobSubCategory, ListFilterSchema, LocationSchema, MIN_SALARY_LOWER_BOUND, MIN_SALARY_UPPER_BOUND, MailingListSchema, NotificationChannel, PREFILL_MIRRORED_KEYS, PRIVATE_PROFILE_FIELDS, PageSchema, PageStatus, PageType, type PaginatedResponse, PaginationSchema, PostSchema, PostStatus, type PrefillMirroredKey, type PrivateProfileField, ProficiencyLevel, QuestionSchema, QuestionType, ReadAndAcknowledgeQuestionSchema, RecruiterPageLinkSchema, ReferralSource, ReportReason, ReportSchema, ResourceType, SITEMAP_FORMAT, SYSTEM_DATE_FORMAT, SavedSearchSchema, SavedSearchStatus, SignupContextSource, SkillSchema, SocialAccount, SocialAccountSchema, StudentCompletenessSchema, StudyType, SupportedAnswerChoiceTypes, SupportedApplicationReceivePreferences, SupportedCampaignStatuses, SupportedCompensationTypes, SupportedContactTypes, SupportedEducationLevels, SupportedEmployeeCounts, SupportedEmploymentTypes, SupportedExperienceLevels, SupportedFeedbackStatuses, SupportedFeedbackTypes, SupportedJobAlertFrequencies, SupportedJobCategories, SupportedJobIndustries, SupportedJobSearchUrgencies, SupportedJobStatuses, SupportedJobSubCategories, SupportedNotificationChannels, SupportedPageStatuses, SupportedPageTypes, SupportedPostStatuses, SupportedProficiencyLevels, SupportedQuestionTypes, SupportedReferralSources, SupportedReportReasons, SupportedResourceTypes, SupportedSalaryCurrencies, type SupportedSalaryCurrency, SupportedSavedSearchStatuses, SupportedSignupContextSources, SupportedSocialAccounts, SupportedStudyTypes, SupportedUserProfileVisibilities, SupportedUserRoles, SupportedUserStatuses, SupportedWorkModes, TAXONOMY_LABELS, type TChoiceQuestionSchema, type TDurationSchema, type TInputQuestionSchema, type TJobSchema, type TPostSchema, type TQuestionSchema, type TReadAndAcknowledgeQuestionSchema, type TUserIdAndCreatedAtSchema, TermsAcceptedSchema, UserAdditionalInfoSchema, UserCertificationSchema, UserCompletenessSchema, UserCoordinatorProfileSchema, UserDetailType, UserGeneralDetailSchema, UserIdAndCreatedAtSchema, UserInterestSchema, UserJobPreferencesSchema, UserLanguageSchema, type UserProfileOverview, UserProfileVisibility, UserProjectSchema, UserRecruiterProfileSchema, UserRole, UserSchema, UserSkillSchema, UserStatus, WorkExperienceSchema, WorkMode, categoryOfIndustry, categoryOfSubCategory, dateString, deriveIndustry, getSchemaByQuestion, industriesOfCategory, industryOfSubCategory, subCategoriesOfIndustry };
package/dist/index.d.ts CHANGED
@@ -437,6 +437,8 @@ declare const JobSchema: ObjectSchema<{
437
437
  employmentType: string;
438
438
  workMode: string;
439
439
  skills: {
440
+ conceptId?: string | null | undefined;
441
+ conceptName?: string | null | undefined;
440
442
  name: string;
441
443
  logo: {
442
444
  dark?: string | null | undefined;
@@ -1500,6 +1502,24 @@ declare enum UserProfileVisibility {
1500
1502
  Hidden = "hidden"
1501
1503
  }
1502
1504
  declare const SupportedUserProfileVisibilities: UserProfileVisibility[];
1505
+ /**
1506
+ * How a user's `signupContext` was obtained. Stored on the document, so these
1507
+ * strings are data: lowercase, and a rename is a migration.
1508
+ *
1509
+ * The distinction is deliberate. Only `CfHeader` is measured; the other two are
1510
+ * inferred, and `Default` is a bare presumption with no signal behind it at all.
1511
+ * Collapsing them would make an assumed country indistinguishable from an
1512
+ * observed one, which is precisely what a compliance record must not do.
1513
+ */
1514
+ declare enum SignupContextSource {
1515
+ /** Measured from Cloudflare's `cf-ipcountry` header at sign-up. */
1516
+ CfHeader = "cf-header",
1517
+ /** Derived from the user's self-entered profile location (backfill). */
1518
+ Location = "location",
1519
+ /** Assumed; no signal was available. A presumption, not evidence. */
1520
+ Default = "default"
1521
+ }
1522
+ declare const SupportedSignupContextSources: SignupContextSource[];
1503
1523
  declare enum UserDetailType {
1504
1524
  Overview = "overview",
1505
1525
  AdditionalInfo = "additionalInfo",
@@ -1718,6 +1738,8 @@ declare const FeedbackSchema: yup.ObjectSchema<{
1718
1738
 
1719
1739
  declare const SkillSchema: yup.ObjectSchema<{
1720
1740
  name: string;
1741
+ conceptId: string | null | undefined;
1742
+ conceptName: string | null | undefined;
1721
1743
  logo: {
1722
1744
  dark?: string | null | undefined;
1723
1745
  light: string;
@@ -1731,6 +1753,8 @@ declare const SkillSchema: yup.ObjectSchema<{
1731
1753
  updatedAt: number | undefined;
1732
1754
  }, yup.AnyObject, {
1733
1755
  name: undefined;
1756
+ conceptId: undefined;
1757
+ conceptName: undefined;
1734
1758
  logo: null;
1735
1759
  tags: "";
1736
1760
  shortId: undefined;
@@ -1932,9 +1956,11 @@ declare const UserSchema: yup.ObjectSchema<{
1932
1956
  coordinates: number[];
1933
1957
  };
1934
1958
  };
1935
- region: {
1959
+ signupContext: {
1936
1960
  country: string;
1937
- lang: string;
1961
+ at: number;
1962
+ locale: string;
1963
+ source: NonNullable<SignupContextSource | undefined>;
1938
1964
  } | undefined;
1939
1965
  profileVisibility: UserProfileVisibility;
1940
1966
  openToWork: boolean;
@@ -2137,7 +2163,7 @@ declare const UserSchema: yup.ObjectSchema<{
2137
2163
  coordinates: "";
2138
2164
  };
2139
2165
  };
2140
- region: undefined;
2166
+ signupContext: undefined;
2141
2167
  profileVisibility: UserProfileVisibility.Public;
2142
2168
  openToWork: false;
2143
2169
  jobSearchUrgency: undefined;
@@ -2189,9 +2215,11 @@ declare const UserGeneralDetailSchema: yup.ObjectSchema<{
2189
2215
  coordinates: number[];
2190
2216
  };
2191
2217
  };
2192
- region: {
2218
+ signupContext: {
2193
2219
  country: string;
2194
- lang: string;
2220
+ at: number;
2221
+ locale: string;
2222
+ source: NonNullable<SignupContextSource | undefined>;
2195
2223
  } | undefined;
2196
2224
  profileVisibility: UserProfileVisibility;
2197
2225
  }, yup.AnyObject, {
@@ -2221,7 +2249,7 @@ declare const UserGeneralDetailSchema: yup.ObjectSchema<{
2221
2249
  coordinates: "";
2222
2250
  };
2223
2251
  };
2224
- region: undefined;
2252
+ signupContext: undefined;
2225
2253
  profileVisibility: UserProfileVisibility.Public;
2226
2254
  }, "">;
2227
2255
 
@@ -2995,4 +3023,4 @@ declare const StudentCompletenessSchema: yup.ObjectSchema<{
2995
3023
  };
2996
3024
  }, "">;
2997
3025
 
2998
- export { AnswerChoiceType, ApplicationReceivePreference, CampaignSchema, CampaignStatus, ChoiceQuestionSchema, Common, CompensationType, CompletenessScoreSchema, ContactTypes, CoordinatorPageLinkSchema, DISPLAY_DATE_FORMAT, DISPLAY_DATE_FORMAT_SHORT, DateStringSchema, DbDefaultSchema, DefaultPaginatedResponse, DefaultPaginationOptions, DefaultUserRoles, DurationSchema, EMPTY_STRING, EducationLevel, EducationSchema, EmployeeCount, EmploymentType, ExperienceLevel, FEEDBACK_MESSAGE_MAX, FeedbackSchema, FeedbackStatus, FeedbackType, GeneraDetailFields, GroupManagedBy, GroupMembershipSchema, GroupMembershipStatus, GroupSchema, GroupStatus, GroupTranslationSchema, GroupVisibility, InputQuestionSchema, JOB_TAXONOMY, JobAlertFrequency, JobCategory, JobIndustry, JobSchema, JobSearchUrgency, JobStatus, JobSubCategory, ListFilterSchema, LocationSchema, MIN_SALARY_LOWER_BOUND, MIN_SALARY_UPPER_BOUND, MailingListSchema, NotificationChannel, PREFILL_MIRRORED_KEYS, PRIVATE_PROFILE_FIELDS, PageSchema, PageStatus, PageType, type PaginatedResponse, PaginationSchema, PostSchema, PostStatus, type PrefillMirroredKey, type PrivateProfileField, ProficiencyLevel, QuestionSchema, QuestionType, ReadAndAcknowledgeQuestionSchema, RecruiterPageLinkSchema, ReferralSource, ReportReason, ReportSchema, ResourceType, SITEMAP_FORMAT, SYSTEM_DATE_FORMAT, SavedSearchSchema, SavedSearchStatus, SkillSchema, SocialAccount, SocialAccountSchema, StudentCompletenessSchema, StudyType, SupportedAnswerChoiceTypes, SupportedApplicationReceivePreferences, SupportedCampaignStatuses, SupportedCompensationTypes, SupportedContactTypes, SupportedEducationLevels, SupportedEmployeeCounts, SupportedEmploymentTypes, SupportedExperienceLevels, SupportedFeedbackStatuses, SupportedFeedbackTypes, SupportedJobAlertFrequencies, SupportedJobCategories, SupportedJobIndustries, SupportedJobSearchUrgencies, SupportedJobStatuses, SupportedJobSubCategories, SupportedNotificationChannels, SupportedPageStatuses, SupportedPageTypes, SupportedPostStatuses, SupportedProficiencyLevels, SupportedQuestionTypes, SupportedReferralSources, SupportedReportReasons, SupportedResourceTypes, SupportedSalaryCurrencies, type SupportedSalaryCurrency, SupportedSavedSearchStatuses, SupportedSocialAccounts, SupportedStudyTypes, SupportedUserProfileVisibilities, SupportedUserRoles, SupportedUserStatuses, SupportedWorkModes, TAXONOMY_LABELS, type TChoiceQuestionSchema, type TDurationSchema, type TInputQuestionSchema, type TJobSchema, type TPostSchema, type TQuestionSchema, type TReadAndAcknowledgeQuestionSchema, type TUserIdAndCreatedAtSchema, TermsAcceptedSchema, UserAdditionalInfoSchema, UserCertificationSchema, UserCompletenessSchema, UserCoordinatorProfileSchema, UserDetailType, UserGeneralDetailSchema, UserIdAndCreatedAtSchema, UserInterestSchema, UserJobPreferencesSchema, UserLanguageSchema, type UserProfileOverview, UserProfileVisibility, UserProjectSchema, UserRecruiterProfileSchema, UserRole, UserSchema, UserSkillSchema, UserStatus, WorkExperienceSchema, WorkMode, categoryOfIndustry, categoryOfSubCategory, dateString, deriveIndustry, getSchemaByQuestion, industriesOfCategory, industryOfSubCategory, subCategoriesOfIndustry };
3026
+ export { AnswerChoiceType, ApplicationReceivePreference, CampaignSchema, CampaignStatus, ChoiceQuestionSchema, Common, CompensationType, CompletenessScoreSchema, ContactTypes, CoordinatorPageLinkSchema, DISPLAY_DATE_FORMAT, DISPLAY_DATE_FORMAT_SHORT, DateStringSchema, DbDefaultSchema, DefaultPaginatedResponse, DefaultPaginationOptions, DefaultUserRoles, DurationSchema, EMPTY_STRING, EducationLevel, EducationSchema, EmployeeCount, EmploymentType, ExperienceLevel, FEEDBACK_MESSAGE_MAX, FeedbackSchema, FeedbackStatus, FeedbackType, GeneraDetailFields, GroupManagedBy, GroupMembershipSchema, GroupMembershipStatus, GroupSchema, GroupStatus, GroupTranslationSchema, GroupVisibility, InputQuestionSchema, JOB_TAXONOMY, JobAlertFrequency, JobCategory, JobIndustry, JobSchema, JobSearchUrgency, JobStatus, JobSubCategory, ListFilterSchema, LocationSchema, MIN_SALARY_LOWER_BOUND, MIN_SALARY_UPPER_BOUND, MailingListSchema, NotificationChannel, PREFILL_MIRRORED_KEYS, PRIVATE_PROFILE_FIELDS, PageSchema, PageStatus, PageType, type PaginatedResponse, PaginationSchema, PostSchema, PostStatus, type PrefillMirroredKey, type PrivateProfileField, ProficiencyLevel, QuestionSchema, QuestionType, ReadAndAcknowledgeQuestionSchema, RecruiterPageLinkSchema, ReferralSource, ReportReason, ReportSchema, ResourceType, SITEMAP_FORMAT, SYSTEM_DATE_FORMAT, SavedSearchSchema, SavedSearchStatus, SignupContextSource, SkillSchema, SocialAccount, SocialAccountSchema, StudentCompletenessSchema, StudyType, SupportedAnswerChoiceTypes, SupportedApplicationReceivePreferences, SupportedCampaignStatuses, SupportedCompensationTypes, SupportedContactTypes, SupportedEducationLevels, SupportedEmployeeCounts, SupportedEmploymentTypes, SupportedExperienceLevels, SupportedFeedbackStatuses, SupportedFeedbackTypes, SupportedJobAlertFrequencies, SupportedJobCategories, SupportedJobIndustries, SupportedJobSearchUrgencies, SupportedJobStatuses, SupportedJobSubCategories, SupportedNotificationChannels, SupportedPageStatuses, SupportedPageTypes, SupportedPostStatuses, SupportedProficiencyLevels, SupportedQuestionTypes, SupportedReferralSources, SupportedReportReasons, SupportedResourceTypes, SupportedSalaryCurrencies, type SupportedSalaryCurrency, SupportedSavedSearchStatuses, SupportedSignupContextSources, SupportedSocialAccounts, SupportedStudyTypes, SupportedUserProfileVisibilities, SupportedUserRoles, SupportedUserStatuses, SupportedWorkModes, TAXONOMY_LABELS, type TChoiceQuestionSchema, type TDurationSchema, type TInputQuestionSchema, type TJobSchema, type TPostSchema, type TQuestionSchema, type TReadAndAcknowledgeQuestionSchema, type TUserIdAndCreatedAtSchema, TermsAcceptedSchema, UserAdditionalInfoSchema, UserCertificationSchema, UserCompletenessSchema, UserCoordinatorProfileSchema, UserDetailType, UserGeneralDetailSchema, UserIdAndCreatedAtSchema, UserInterestSchema, UserJobPreferencesSchema, UserLanguageSchema, type UserProfileOverview, UserProfileVisibility, UserProjectSchema, UserRecruiterProfileSchema, UserRole, UserSchema, UserSkillSchema, UserStatus, WorkExperienceSchema, WorkMode, categoryOfIndustry, categoryOfSubCategory, dateString, deriveIndustry, getSchemaByQuestion, industriesOfCategory, industryOfSubCategory, subCategoriesOfIndustry };
package/dist/index.js CHANGED
@@ -417,6 +417,26 @@ var PageSchema = object10().shape({
417
417
  import { array as array6, object as object11, string as string8 } from "yup";
418
418
  var SkillSchema = object11({
419
419
  name: string8().trim().required().label("Skill Name"),
420
+ /**
421
+ * The taxonomy concept this skill resolved to, assigned by thejob-taxonomy-service
422
+ * via `POST /normalize/batch`. Stable slug, e.g. "power-bi".
423
+ *
424
+ * Normalization is ADDITIVE and never authoritative over `name`: the raw string a
425
+ * job was posted with is always preserved, and these two fields are written
426
+ * alongside it. That is what makes a wrong match, a taxonomy re-run, or a service
427
+ * outage recoverable rather than a data loss.
428
+ *
429
+ * Optional and nullable because they genuinely are absent in two normal cases,
430
+ * neither of which is an error:
431
+ * - the term did not resolve (~22% of mentions are a long tail the taxonomy has
432
+ * no concept for yet), and the raw `name` stands alone;
433
+ * - the document predates normalization and has not been re-processed.
434
+ *
435
+ * Consumers must therefore read `conceptName ?? name`, never `conceptName` alone.
436
+ */
437
+ conceptId: string8().optional().nullable().label("Concept ID"),
438
+ /** Canonical display name for `conceptId`, e.g. "Power BI". See `conceptId`. */
439
+ conceptName: string8().optional().nullable().label("Concept Name"),
420
440
  logo: object11({
421
441
  light: string8().required().label("Light Logo"),
422
442
  dark: string8().optional().nullable().label("Dark Logo")
@@ -622,7 +642,15 @@ var JobSchema = object16({
622
642
  designation: string13().optional().nullable().label("Designation"),
623
643
  employmentType: string13().required().label("Employment type"),
624
644
  workMode: string13().required().label("Work mode"),
625
- skills: array8().of(SkillSchema.pick(["name", "logo"]).required().label("Skill")).nullable().optional().label("Skills"),
645
+ /**
646
+ * `conceptId`/`conceptName` carry the taxonomy-service normalization. They are
647
+ * picked here (not just `name`/`logo`) because SkillSchema is `.noUnknown()`:
648
+ * an unpicked field that is actually PRESENT fails validation, so omitting them
649
+ * would reject every normalized job.
650
+ */
651
+ skills: array8().of(
652
+ SkillSchema.pick(["name", "logo", "conceptId", "conceptName"]).required().label("Skill")
653
+ ).nullable().optional().label("Skills"),
626
654
  bookmarks: array8().of(UserIdAndCreatedAtSchema).optional().label("Bookmarks"),
627
655
  embedding: object16({
628
656
  vector: array8(number5().required()).optional().label("Embedding vector"),
@@ -1935,6 +1963,13 @@ var UserProfileVisibility = /* @__PURE__ */ ((UserProfileVisibility2) => {
1935
1963
  var SupportedUserProfileVisibilities = Object.values(
1936
1964
  UserProfileVisibility
1937
1965
  );
1966
+ var SignupContextSource = /* @__PURE__ */ ((SignupContextSource2) => {
1967
+ SignupContextSource2["CfHeader"] = "cf-header";
1968
+ SignupContextSource2["Location"] = "location";
1969
+ SignupContextSource2["Default"] = "default";
1970
+ return SignupContextSource2;
1971
+ })(SignupContextSource || {});
1972
+ var SupportedSignupContextSources = Object.values(SignupContextSource);
1938
1973
  var UserDetailType = /* @__PURE__ */ ((UserDetailType2) => {
1939
1974
  UserDetailType2["Overview"] = "overview";
1940
1975
  UserDetailType2["AdditionalInfo"] = "additionalInfo";
@@ -2253,7 +2288,7 @@ var FeedbackSchema = object22({
2253
2288
  }).label("Feedback Schema");
2254
2289
 
2255
2290
  // src/user/user.schema.ts
2256
- import { array as array14, boolean as boolean15, number as number11, object as object34, string as string32 } from "yup";
2291
+ import { array as array14, boolean as boolean15, number as number12, object as object34, string as string32 } from "yup";
2257
2292
 
2258
2293
  // src/user/work-experience.schema.ts
2259
2294
  import { boolean as boolean12, object as object23, string as string20 } from "yup";
@@ -2342,7 +2377,7 @@ var UserAdditionalInfoSchema = object29({
2342
2377
  }).noUnknown().strict().label("User Additional Info");
2343
2378
 
2344
2379
  // src/user/general-detail.schema.ts
2345
- import { object as object30, string as string28 } from "yup";
2380
+ import { number as number10, object as object30, string as string28 } from "yup";
2346
2381
  var UserGeneralDetailSchema = object30({
2347
2382
  id: string28().optional().label("ID"),
2348
2383
  name: object30().shape({
@@ -2358,15 +2393,33 @@ var UserGeneralDetailSchema = object30({
2358
2393
  mobileVerified: string28().nullable().optional().label("Mobile Verified"),
2359
2394
  experienceLevel: string28().oneOf(SupportedExperienceLevels).required().label("Experience level"),
2360
2395
  location: LocationSchema.required().label("Location"),
2361
- region: object30().shape({
2362
- country: string28().trim().required().label("Region Country"),
2363
- lang: string28().trim().required().label("Region Language")
2364
- }).optional().default(void 0).label("Region"),
2396
+ /**
2397
+ * Where the account was created, captured once and never rewritten.
2398
+ *
2399
+ * Server-owned: listed in user-service's SERVER_CONTROLLED_FIELDS and absent
2400
+ * from every user-writable schema, so a client cannot set or amend it. That
2401
+ * immutability is the point — this answers "which market/jurisdiction governed
2402
+ * this user at sign-up?", which a field the user can PATCH could never do.
2403
+ *
2404
+ * `source` records HOW the value was obtained, because most of it is inferred
2405
+ * rather than measured, and an audit has to be able to tell those apart:
2406
+ * - `cf-header` measured from Cloudflare's `cf-ipcountry` at sign-up
2407
+ * - `location` derived from the user's self-entered profile location
2408
+ * - `default` assumed (no signal available); presumption, not evidence
2409
+ */
2410
+ signupContext: object30().shape({
2411
+ // ISO 3166-1 alpha-2, uppercase — the shape `cf-ipcountry` returns, so
2412
+ // measured and inferred values are directly comparable.
2413
+ country: string28().trim().uppercase().length(2).required().label("Signup Country"),
2414
+ locale: string28().trim().required().label("Signup Locale"),
2415
+ source: string28().oneOf(SupportedSignupContextSources).required().label("Signup Context Source"),
2416
+ at: number10().required().label("Signup Context At")
2417
+ }).optional().default(void 0).label("Signup Context"),
2365
2418
  profileVisibility: string28().oneOf(SupportedUserProfileVisibilities).default("public" /* Public */).label("Profile Visibility")
2366
2419
  }).noUnknown().strict().label("General Detail");
2367
2420
 
2368
2421
  // src/user/user-job-preferences.schema.ts
2369
- import { array as array11, boolean as boolean14, date as date2, number as number10, object as object31, string as string29 } from "yup";
2422
+ import { array as array11, boolean as boolean14, date as date2, number as number11, object as object31, string as string29 } from "yup";
2370
2423
  var UserJobPreferencesSchema = object31({
2371
2424
  /**
2372
2425
  * Whether the job seeker is openly signalling availability. Surfaces the
@@ -2412,7 +2465,7 @@ var UserJobPreferencesSchema = object31({
2412
2465
  * Defaults to 0 ("any salary") rather than being optional - every job-seeker
2413
2466
  * has a floor, even if it's zero.
2414
2467
  */
2415
- minSalary: number10().integer().min(MIN_SALARY_LOWER_BOUND).max(MIN_SALARY_UPPER_BOUND).default(0).required().label("Minimum Salary"),
2468
+ minSalary: number11().integer().min(MIN_SALARY_LOWER_BOUND).max(MIN_SALARY_UPPER_BOUND).default(0).required().label("Minimum Salary"),
2416
2469
  minSalaryCurrency: string29().oneOf(SupportedSalaryCurrencies).default("USD").required().label("Minimum Salary Currency"),
2417
2470
  /**
2418
2471
  * Marketing-attribution capture from the final onboarding step.
@@ -2439,7 +2492,7 @@ var UserJobPreferencesSchema = object31({
2439
2492
  id: string29().required().label("ID"),
2440
2493
  url: string29().required().label("Resume URL"),
2441
2494
  filename: string29().trim().max(255).optional().label("Filename"),
2442
- sizeBytes: number10().integer().min(0).optional().label("Size (bytes)"),
2495
+ sizeBytes: number11().integer().min(0).optional().label("Size (bytes)"),
2443
2496
  mimeType: string29().trim().max(120).optional().label("MIME Type"),
2444
2497
  uploadedAt: date2().required().label("Uploaded At"),
2445
2498
  isPrimary: boolean14().default(false).label("Is Primary")
@@ -2569,7 +2622,7 @@ var UserSchema = object34({
2569
2622
  marketingConsent: object34({
2570
2623
  subscribed: boolean15().default(false).label("Subscribed to marketing email"),
2571
2624
  /** Epoch ms of the last consent change (opt-in or opt-out). */
2572
- updatedAt: number11().optional().label("Consent updated at"),
2625
+ updatedAt: number12().optional().label("Consent updated at"),
2573
2626
  /** Where the consent change originated, e.g. `onboarding`, `unsubscribe`, `settings`. */
2574
2627
  source: string32().optional().label("Consent source"),
2575
2628
  /**
@@ -2609,7 +2662,7 @@ var UserSchema = object34({
2609
2662
  * Only generated for public profiles with sufficient completeness (≥60%).
2610
2663
  */
2611
2664
  embedding: object34({
2612
- vector: array14(number11().required()).optional().label("Embedding vector"),
2665
+ vector: array14(number12().required()).optional().label("Embedding vector"),
2613
2666
  model: string32().optional().label("Embedding model")
2614
2667
  }).nullable().optional().label("Embedding")
2615
2668
  }).concat(UserGeneralDetailSchema).concat(UserJobPreferencesSchema).concat(UserRecruiterProfileSchema).concat(UserCoordinatorProfileSchema).noUnknown().strict().label("User Schema");
@@ -2710,6 +2763,7 @@ export {
2710
2763
  SYSTEM_DATE_FORMAT,
2711
2764
  SavedSearchSchema,
2712
2765
  SavedSearchStatus,
2766
+ SignupContextSource,
2713
2767
  SkillSchema,
2714
2768
  SocialAccount,
2715
2769
  SocialAccountSchema,
@@ -2743,6 +2797,7 @@ export {
2743
2797
  SupportedResourceTypes,
2744
2798
  SupportedSalaryCurrencies,
2745
2799
  SupportedSavedSearchStatuses,
2800
+ SupportedSignupContextSources,
2746
2801
  SupportedSocialAccounts,
2747
2802
  SupportedStudyTypes,
2748
2803
  SupportedUserProfileVisibilities,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thejob/schema",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -69,8 +69,18 @@ export const JobSchema = object({
69
69
 
70
70
  workMode: string().required().label("Work mode"),
71
71
 
72
+ /**
73
+ * `conceptId`/`conceptName` carry the taxonomy-service normalization. They are
74
+ * picked here (not just `name`/`logo`) because SkillSchema is `.noUnknown()`:
75
+ * an unpicked field that is actually PRESENT fails validation, so omitting them
76
+ * would reject every normalized job.
77
+ */
72
78
  skills: array()
73
- .of(SkillSchema.pick(["name", "logo"]).required().label("Skill"))
79
+ .of(
80
+ SkillSchema.pick(["name", "logo", "conceptId", "conceptName"])
81
+ .required()
82
+ .label("Skill"),
83
+ )
74
84
  .nullable()
75
85
  .optional()
76
86
  .label("Skills"),
@@ -3,6 +3,29 @@ import { DbDefaultSchema } from "../common/common.schema.js";
3
3
 
4
4
  export const SkillSchema = object({
5
5
  name: string().trim().required().label("Skill Name"),
6
+
7
+ /**
8
+ * The taxonomy concept this skill resolved to, assigned by thejob-taxonomy-service
9
+ * via `POST /normalize/batch`. Stable slug, e.g. "power-bi".
10
+ *
11
+ * Normalization is ADDITIVE and never authoritative over `name`: the raw string a
12
+ * job was posted with is always preserved, and these two fields are written
13
+ * alongside it. That is what makes a wrong match, a taxonomy re-run, or a service
14
+ * outage recoverable rather than a data loss.
15
+ *
16
+ * Optional and nullable because they genuinely are absent in two normal cases,
17
+ * neither of which is an error:
18
+ * - the term did not resolve (~22% of mentions are a long tail the taxonomy has
19
+ * no concept for yet), and the raw `name` stands alone;
20
+ * - the document predates normalization and has not been re-processed.
21
+ *
22
+ * Consumers must therefore read `conceptName ?? name`, never `conceptName` alone.
23
+ */
24
+ conceptId: string().optional().nullable().label("Concept ID"),
25
+
26
+ /** Canonical display name for `conceptId`, e.g. "Power BI". See `conceptId`. */
27
+ conceptName: string().optional().nullable().label("Concept Name"),
28
+
6
29
  logo: object({
7
30
  light: string().required().label("Light Logo"),
8
31
  dark: string().optional().nullable().label("Dark Logo"),
@@ -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",