@thejob/schema 2.0.4 → 2.0.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.
@@ -0,0 +1,111 @@
1
+ import {
2
+ JOB_TAXONOMY,
3
+ type JobCategory,
4
+ type JobIndustry,
5
+ type JobSubCategory,
6
+ } from "./job-taxonomy.constant.js";
7
+
8
+ /**
9
+ * Taxonomy lookups + industry derivation.
10
+ *
11
+ * Hand-written (unlike job-taxonomy.constant.ts, which is generated) — the generator
12
+ * will not overwrite this file.
13
+ *
14
+ * WHY DERIVE INDUSTRY INSTEAD OF ASKING FOR IT
15
+ * `industry` is redundant: every subCategory name is globally unique and every industry
16
+ * belongs to exactly one category, so a subCategory already determines both. Asking an
17
+ * LLM to pick the industry *as well* asks it to re-derive what it just implied — and
18
+ * measured across ~57k enriched jobs it derived differently 15.9% of the time (e.g.
19
+ * "Data Science" filed under "Software & Web Development" rather than "Data, AI &
20
+ * Analytics"). 89.5% of those were a sibling industry under the SAME category: the right
21
+ * neighbourhood, the wrong box. Deriving in code removes that failure mode entirely
22
+ * rather than validating against it — a mismatch must never cost us the posting.
23
+ *
24
+ * WHY CATEGORY IS STILL ASKED FOR, NOT DERIVED
25
+ * The reverse (derive category from the subCategory too) measured 98.5% agreement, but
26
+ * the disagreements showed the MODEL was usually right and the tree wrong: a "Financial
27
+ * Analyst" tagged `business_intelligence` derives to Technology, while the model said
28
+ * Finance — and it is a finance job that uses BI tools. The category captures what the
29
+ * job IS; a subCategory's position in the tree does not. So the model keeps choosing it.
30
+ */
31
+
32
+ /** subCategory -> the industry that owns it. */
33
+ const INDUSTRY_OF_SUB = new Map<string, JobIndustry>(
34
+ JOB_TAXONOMY.flatMap((c) =>
35
+ c.industries.flatMap((i) => i.subCategories.map((s) => [s, i.industry] as const)),
36
+ ),
37
+ );
38
+
39
+ /** subCategory -> the category that owns it (via its industry). */
40
+ const CATEGORY_OF_SUB = new Map<string, JobCategory>(
41
+ JOB_TAXONOMY.flatMap((c) =>
42
+ c.industries.flatMap((i) => i.subCategories.map((s) => [s, c.category] as const)),
43
+ ),
44
+ );
45
+
46
+ /** industry -> the category that owns it. */
47
+ const CATEGORY_OF_INDUSTRY = new Map<string, JobCategory>(
48
+ JOB_TAXONOMY.flatMap((c) => c.industries.map((i) => [i.industry, c.category] as const)),
49
+ );
50
+
51
+ /** The industry a subCategory belongs to, or undefined if it is not a known subCategory. */
52
+ export function industryOfSubCategory(sub: string): JobIndustry | undefined {
53
+ return INDUSTRY_OF_SUB.get(sub);
54
+ }
55
+
56
+ /** The category a subCategory belongs to, or undefined if unknown. */
57
+ export function categoryOfSubCategory(sub: string): JobCategory | undefined {
58
+ return CATEGORY_OF_SUB.get(sub);
59
+ }
60
+
61
+ /** The category an industry belongs to, or undefined if unknown. */
62
+ export function categoryOfIndustry(industry: string): JobCategory | undefined {
63
+ return CATEGORY_OF_INDUSTRY.get(industry);
64
+ }
65
+
66
+ /** The industries under a category (empty if the category is unknown). */
67
+ export function industriesOfCategory(category: string): readonly JobIndustry[] {
68
+ return JOB_TAXONOMY.find((c) => c.category === category)?.industries.map((i) => i.industry) ?? [];
69
+ }
70
+
71
+ /** The subCategories under an industry (empty if the industry is unknown). */
72
+ export function subCategoriesOfIndustry(industry: string): readonly JobSubCategory[] {
73
+ for (const c of JOB_TAXONOMY) {
74
+ for (const i of c.industries) if (i.industry === industry) return i.subCategories;
75
+ }
76
+ return [];
77
+ }
78
+
79
+ /**
80
+ * Derive a job's industry from its category + subCategories. This is the function that
81
+ * replaces asking the model for `industry`.
82
+ *
83
+ * Rule: the first subCategory that sits INSIDE the given category wins. Preferring the
84
+ * category keeps the model's judgement about what the job is (see the file header) while
85
+ * still deriving a structurally coherent industry; measured on ~57k jobs, 98.7% of those
86
+ * with a real category and real subCategories have at least one subCategory inside it.
87
+ *
88
+ * Falls back, in order:
89
+ * 1. no category given -> the first known subCategory's own industry;
90
+ * 2. no subCategory sits in the category -> undefined (caller keeps whatever it had,
91
+ * or stores null; we never drop a posting over this).
92
+ *
93
+ * Order matters: `subCategories` is the model's own ranking, most relevant first.
94
+ */
95
+ export function deriveIndustry(
96
+ category: string | null | undefined,
97
+ subCategories: readonly string[] | null | undefined,
98
+ ): JobIndustry | undefined {
99
+ const subs = (subCategories ?? []).filter((s) => INDUSTRY_OF_SUB.has(s));
100
+ if (subs.length === 0) return undefined;
101
+
102
+ if (category) {
103
+ const inCategory = subs.find((s) => CATEGORY_OF_SUB.get(s) === category);
104
+ if (inCategory) return INDUSTRY_OF_SUB.get(inCategory);
105
+ // The model's subCategories all sit outside its chosen category. Rather than force
106
+ // an industry that contradicts the category, leave it unset.
107
+ return undefined;
108
+ }
109
+
110
+ return INDUSTRY_OF_SUB.get(subs[0]);
111
+ }
@@ -196,6 +196,24 @@ export const JobSchema = object({
196
196
 
197
197
  jdSummary: string().optional().label("JD Summary"),
198
198
 
199
+ /**
200
+ * Structured, model-extracted sections of the posting, re-organized into clean
201
+ * bullet points. This is our OWN unique presentation of the job (rendered as the
202
+ * primary, server-side page content) so the page adds value over the raw scraped
203
+ * description and is not penalized by Google as duplicate content. All sections
204
+ * optional: absent on jobs not yet (re-)enriched, and any section is `[]`/omitted
205
+ * when the posting genuinely has none.
206
+ */
207
+ jobHighlights: object({
208
+ responsibilities: array().of(string()).optional().label("Responsibilities"),
209
+ requirements: array().of(string()).optional().label("Requirements"),
210
+ benefits: array().of(string()).optional().label("Benefits"),
211
+ qualifications: array().of(string()).optional().label("Qualifications"),
212
+ })
213
+ .nullable()
214
+ .optional()
215
+ .label("Job Highlights"),
216
+
199
217
  canCreateAlert: boolean().optional().label("Can create alert"),
200
218
 
201
219
  canDirectApply: boolean().optional().label("Can direct apply"),
@@ -233,6 +251,18 @@ export const JobSchema = object({
233
251
 
234
252
  reports: array().of(UserIdAndCreatedAtSchema).optional().label("Reports"),
235
253
 
254
+ // Taxonomy fields are constants (see job-taxonomy.constant.ts), stored as slugs and
255
+ // translated for display. They are deliberately NOT `oneOf`-constrained here: a value
256
+ // outside the taxonomy must be DROPPED, never a reason to reject the posting. Yup
257
+ // `oneOf` fails the whole object, so one stale subCategory would cost a user their
258
+ // entire job on the create/edit form, and a single drifted LLM value would bin an
259
+ // otherwise-good enriched job.
260
+ //
261
+ // Callers sanitize instead: jobs-service's sanitizeTaxonomy() coerces unknown values
262
+ // to null / filters them out of the array before validation. Taxonomies change over
263
+ // time (values get renamed, retired, added), so stored jobs will always outlive the
264
+ // enum — being lenient here is what lets the taxonomy evolve without a migration
265
+ // gate on every write.
236
266
  category: string().nullable().optional().label("Category"),
237
267
 
238
268
  industry: string().nullable().optional().label("Industry"),
@@ -0,0 +1,7 @@
1
+ export enum PostStatus {
2
+ Draft = "draft",
3
+ Published = "published",
4
+ Archived = "archived",
5
+ }
6
+
7
+ export const SupportedPostStatuses = Object.values(PostStatus);
@@ -0,0 +1,68 @@
1
+ import { array, boolean, number, object, string } from "yup";
2
+ import type { InferType } from "yup";
3
+ import { DbDefaultSchema } from "../common/common.schema.js";
4
+ import { PostStatus, SupportedPostStatuses } from "./post.constant.js";
5
+
6
+ export const PostSchema = object({
7
+ /** Headline shown in listings and at the top of the post. */
8
+ title: string().required().min(5).max(200).label("Title"),
9
+
10
+ /** User-friendly, unique URL identifier. */
11
+ slug: string().required().max(250).label("Slug"),
12
+
13
+ /** Short summary used in listings and meta description fallback. */
14
+ excerpt: string().max(500).optional().label("Excerpt"),
15
+
16
+ /** Rendered HTML source of truth for the post body (sanitized on write/render).
17
+ * Supports rich formatting markdown can't express (image size/alignment, etc). */
18
+ bodyHTML: string().required().min(1).label("Body (HTML)"),
19
+
20
+ /** Optional Markdown mirror of the body (portable text / search fallback).
21
+ * Kept for back-compat and plain-text derivation; bodyHTML is authoritative. */
22
+ bodyMarkdown: string().optional().label("Body (Markdown)"),
23
+
24
+ /** Cover/hero image URL. Not `.url()`-validated: the value is either an
25
+ * uploaded file URL served by this platform (which may be a bare host like
26
+ * `http://localhost:3082/...` in dev, that yup's `.url()` wrongly rejects) or
27
+ * an admin-pasted link. Kept as a plain string; empty ⇒ null. */
28
+ coverImage: string().nullable().optional().label("Cover image"),
29
+
30
+ /** Author (user id). */
31
+ authorId: string().required().label("Author ID"),
32
+
33
+ /** Free-form tags for filtering and related-posts. */
34
+ tags: array().of(string().trim().required()).default([]).label("Tags"),
35
+
36
+ /** Estimated reading time in minutes (server-derived from body length). */
37
+ readingTimeMinutes: number()
38
+ .min(0)
39
+ .optional()
40
+ .label("Reading time (minutes)"),
41
+
42
+ /** SEO overrides. */
43
+ seoTags: object({
44
+ description: string().optional().label("Description"),
45
+ keywords: array().of(string().required()).optional().label("Keywords"),
46
+ })
47
+ .nullable()
48
+ .optional()
49
+ .label("SEO Tags"),
50
+
51
+ /** When the post was first published (epoch ms). Server-owned. */
52
+ publishedAt: number().nullable().optional().label("Published at"),
53
+
54
+ /** Whether the post is featured in the blog listing. */
55
+ isFeatured: boolean().default(false).optional().label("Is featured"),
56
+
57
+ /** Publication state. */
58
+ status: string()
59
+ .oneOf(SupportedPostStatuses)
60
+ .default(PostStatus.Draft)
61
+ .required()
62
+ .label("Status"),
63
+ })
64
+ .concat(DbDefaultSchema)
65
+ .noUnknown()
66
+ .label("Post");
67
+
68
+ export type TPostSchema = InferType<typeof PostSchema>;