@hadoku/jobplatform-worker 2.32.36 → 2.32.38

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/dist/index.js CHANGED
@@ -532,6 +532,14 @@ const JobSummarySchema = z.object({
532
532
  vote: z.union([z.literal(1), z.literal(-1)]).nullable().optional(),
533
533
  vote_reasons: z.array(z.string()).optional()
534
534
  }).openapi("JobSummary");
535
+ const PrioritySchema = z.union([z.literal(1), z.literal(2), z.literal(3)]);
536
+ const TrackingSchema = z.object({
537
+ notes: z.string().nullable(),
538
+ priority: PrioritySchema.nullable(),
539
+ follow_up_at: z.string().nullable(),
540
+ contact_name: z.string().nullable(),
541
+ contact_email: z.string().nullable()
542
+ }).openapi("Tracking");
535
543
  const JobDetailSchema = JobSummarySchema.extend({
536
544
  /**
537
545
  * Whether the form runner has an adapter for this posting. A PREDICTION
@@ -562,7 +570,9 @@ const JobDetailSchema = JobSummarySchema.extend({
562
570
  department: z.string().nullable(),
563
571
  scraper_used: z.string().nullable(),
564
572
  run_id: z.string().nullable(),
565
- state_updated_at: z.string().nullable()
573
+ state_updated_at: z.string().nullable(),
574
+ /** V5 tracking; null when the caller has no state for this posting (or is anonymous). */
575
+ tracking: TrackingSchema.nullable()
566
576
  }).openapi("JobDetail");
567
577
  const FEEDBACK_REASONS = [
568
578
  // downvote-flavored
@@ -998,7 +1008,6 @@ const AddJobEventSchema = z.object({
998
1008
  /** When it happened; defaults to now. */
999
1009
  occurred_at: WhenSchema.optional()
1000
1010
  }).openapi("AddJobEvent");
1001
- const PrioritySchema = z.union([z.literal(1), z.literal(2), z.literal(3)]);
1002
1011
  const SetTrackingSchema = z.object({
1003
1012
  notes: z.string().max(NOTE_MAX).nullable().optional(),
1004
1013
  priority: PrioritySchema.nullable().optional(),
@@ -1008,13 +1017,6 @@ const SetTrackingSchema = z.object({
1008
1017
  }).refine((o) => Object.values(o).some((v) => v !== void 0), {
1009
1018
  message: "name at least one field to change"
1010
1019
  }).openapi("SetTracking");
1011
- const TrackingSchema = z.object({
1012
- notes: z.string().nullable(),
1013
- priority: PrioritySchema.nullable(),
1014
- follow_up_at: z.string().nullable(),
1015
- contact_name: z.string().nullable(),
1016
- contact_email: z.string().nullable()
1017
- }).openapi("Tracking");
1018
1020
  const TrackingResponseSchema = S(
1019
1021
  z.object({ job_id: z.string(), tracking: TrackingSchema })
1020
1022
  ).openapi("TrackingResponse");
@@ -2579,6 +2581,74 @@ function scheduleRankBuild(c, db, profileId, profile) {
2579
2581
  rebuildRank(db, profileId, profile).finally(() => building.delete(profileId))
2580
2582
  );
2581
2583
  }
2584
+ function normalizeWorkplaceType(wt) {
2585
+ if (wt === "on_site") return "onsite";
2586
+ return wt;
2587
+ }
2588
+ function insertJobStatement(db, job, { source, board_slug, now }) {
2589
+ const workplaceType = normalizeWorkplaceType(job.workplace_type);
2590
+ const structuredMin = job.salary?.min ?? null;
2591
+ const structuredMax = job.salary?.max ?? null;
2592
+ const prose = structuredMin === null && structuredMax === null ? parseSalaryRange(job.description) : null;
2593
+ const salaryMin = structuredMin ?? prose?.min ?? null;
2594
+ const salaryMax = structuredMax ?? prose?.max ?? null;
2595
+ const parsed = parseAtsSlug(job.url);
2596
+ const ats = parsed.ats ?? (board_slug ? source : null);
2597
+ const slug2 = board_slug ?? parsed.slug;
2598
+ const slugSource = slug2 === null ? null : board_slug ? "scraped" : "guessed";
2599
+ const role = classifyRole(job.title, job.description);
2600
+ return db.prepare(
2601
+ `INSERT OR IGNORE INTO jobs (
2602
+ id, url, source_site, title, company, location,
2603
+ job_type, workplace_type, salary_min, salary_max,
2604
+ description, posted_date, application_url, department,
2605
+ scraper_used, raw, scraped_at, last_seen_at, ats, slug,
2606
+ role_track, role_level, slug_source, geo_keys
2607
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2608
+ ).bind(
2609
+ job.id,
2610
+ job.url,
2611
+ job.source_site,
2612
+ job.title,
2613
+ job.company,
2614
+ job.location,
2615
+ job.job_type,
2616
+ workplaceType,
2617
+ salaryMin,
2618
+ salaryMax,
2619
+ job.description,
2620
+ job.posted_date ?? null,
2621
+ job.application_url ?? null,
2622
+ job.department ?? null,
2623
+ job.scraper_used ?? null,
2624
+ JSON.stringify(job.raw),
2625
+ now,
2626
+ now,
2627
+ ats,
2628
+ slug2,
2629
+ role.track,
2630
+ role.level,
2631
+ slugSource,
2632
+ geoKeys(job.location)
2633
+ );
2634
+ }
2635
+ async function rankNewJobs(db) {
2636
+ const ranked = await db.prepare("SELECT profile_id, criteria_hash, max_job_rowid FROM job_profile_rank_state").all();
2637
+ if (ranked.results.length === 0) return;
2638
+ const head2 = await jobsHead(db);
2639
+ for (const state of ranked.results) {
2640
+ const from = Number(state.max_job_rowid);
2641
+ if (from >= head2) continue;
2642
+ const profile = await loadScorableProfile(db, state.profile_id);
2643
+ if (criteriaHash(profile) !== state.criteria_hash) continue;
2644
+ const rows = await db.prepare(
2645
+ `SELECT id, title, location, workplace_type, salary_max, role_level
2646
+ FROM jobs WHERE rowid > ? AND rowid <= ?`
2647
+ ).bind(from, head2).all();
2648
+ await writeRanks(db, state.profile_id, profile, rows.results);
2649
+ await markRankBuilt(db, state.profile_id, profile, head2);
2650
+ }
2651
+ }
2582
2652
  const app$4 = new OpenAPIHono();
2583
2653
  const CULL_DAYS = 60;
2584
2654
  const CULL_BATCH = 500;
@@ -2612,23 +2682,6 @@ async function stripStaleDescriptions(db) {
2612
2682
  ).bind(cutoff, cutoff).run();
2613
2683
  return res.meta?.changes ?? 0;
2614
2684
  }
2615
- async function rankNewJobs(db) {
2616
- const ranked = await db.prepare("SELECT profile_id, criteria_hash, max_job_rowid FROM job_profile_rank_state").all();
2617
- if (ranked.results.length === 0) return;
2618
- const head2 = await jobsHead(db);
2619
- for (const state of ranked.results) {
2620
- const from = Number(state.max_job_rowid);
2621
- if (from >= head2) continue;
2622
- const profile = await loadScorableProfile(db, state.profile_id);
2623
- if (criteriaHash(profile) !== state.criteria_hash) continue;
2624
- const rows = await db.prepare(
2625
- `SELECT id, title, location, workplace_type, salary_max, role_level
2626
- FROM jobs WHERE rowid > ? AND rowid <= ?`
2627
- ).bind(from, head2).all();
2628
- await writeRanks(db, state.profile_id, profile, rows.results);
2629
- await markRankBuilt(db, state.profile_id, profile, head2);
2630
- }
2631
- }
2632
2685
  app$4.use("/ingest", requireMinTier("friend"));
2633
2686
  app$4.use("/ingest/backfill-slugs", requireMinTier("friend"));
2634
2687
  app$4.use("/ingest/backfill-roles", requireMinTier("friend"));
@@ -2638,10 +2691,6 @@ app$4.use("/ingest/cull", requireMinTier("friend"));
2638
2691
  app$4.use("/ingest/rebuild-rank", requireMinTier("friend"));
2639
2692
  app$4.use("/ingest/reconcile-mail", requireMinTier("friend"));
2640
2693
  app$4.use("/directives", requireMinTier("friend"));
2641
- function normalizeWorkplaceType(wt) {
2642
- if (wt === "on_site") return "onsite";
2643
- return wt;
2644
- }
2645
2694
  const ingestRoute = createRoute({
2646
2695
  method: "post",
2647
2696
  path: "/ingest",
@@ -2665,53 +2714,7 @@ app$4.openapi(ingestRoute, async (c) => {
2665
2714
  const { jobs, batch_number, is_final, source, board_slug } = c.req.valid("json");
2666
2715
  const db = c.env.JOB_PLATFORM_DB;
2667
2716
  const now = (/* @__PURE__ */ new Date()).toISOString();
2668
- const stmts = jobs.map((job) => {
2669
- const workplaceType = normalizeWorkplaceType(job.workplace_type);
2670
- const structuredMin = job.salary?.min ?? null;
2671
- const structuredMax = job.salary?.max ?? null;
2672
- const prose = structuredMin === null && structuredMax === null ? parseSalaryRange(job.description) : null;
2673
- const salaryMin = structuredMin ?? prose?.min ?? null;
2674
- const salaryMax = structuredMax ?? prose?.max ?? null;
2675
- const parsed = parseAtsSlug(job.url);
2676
- const ats = parsed.ats ?? (board_slug ? source : null);
2677
- const slug2 = board_slug ?? parsed.slug;
2678
- const slugSource = slug2 === null ? null : board_slug ? "scraped" : "guessed";
2679
- const role = classifyRole(job.title, job.description);
2680
- return db.prepare(
2681
- `INSERT OR IGNORE INTO jobs (
2682
- id, url, source_site, title, company, location,
2683
- job_type, workplace_type, salary_min, salary_max,
2684
- description, posted_date, application_url, department,
2685
- scraper_used, raw, scraped_at, last_seen_at, ats, slug,
2686
- role_track, role_level, slug_source, geo_keys
2687
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2688
- ).bind(
2689
- job.id,
2690
- job.url,
2691
- job.source_site,
2692
- job.title,
2693
- job.company,
2694
- job.location,
2695
- job.job_type,
2696
- workplaceType,
2697
- salaryMin,
2698
- salaryMax,
2699
- job.description,
2700
- job.posted_date ?? null,
2701
- job.application_url ?? null,
2702
- job.department ?? null,
2703
- job.scraper_used ?? null,
2704
- JSON.stringify(job.raw),
2705
- now,
2706
- now,
2707
- ats,
2708
- slug2,
2709
- role.track,
2710
- role.level,
2711
- slugSource,
2712
- geoKeys(job.location)
2713
- );
2714
- });
2717
+ const stmts = jobs.map((job) => insertJobStatement(db, job, { source, board_slug, now }));
2715
2718
  const inserted = [];
2716
2719
  startTime(c, "insert");
2717
2720
  for (let i = 0; i < stmts.length; i += INGEST_BATCH_CHUNK) {
@@ -3399,6 +3402,34 @@ async function triggerSearch(env) {
3399
3402
  );
3400
3403
  }
3401
3404
  }
3405
+ function scraperMessage(body) {
3406
+ try {
3407
+ const parsed = JSON.parse(body);
3408
+ if (typeof parsed.detail === "string") return parsed.detail;
3409
+ if (typeof parsed.error?.message === "string") return parsed.error.message;
3410
+ } catch {
3411
+ }
3412
+ return null;
3413
+ }
3414
+ async function extractPosting(env, url, greenhouseSlug) {
3415
+ const response = await scraperFetch(env, "/api/v1/jobboards/extract", {
3416
+ method: "POST",
3417
+ body: JSON.stringify(greenhouseSlug ? { url, greenhouse_slug: greenhouseSlug } : { url })
3418
+ });
3419
+ const text = await response.text();
3420
+ if (!response.ok) {
3421
+ throw new ScraperClientError(
3422
+ scraperMessage(text) ?? `scraper /extract returned ${response.status}`,
3423
+ response.status,
3424
+ text
3425
+ );
3426
+ }
3427
+ const json = JSON.parse(text);
3428
+ if (!json.success || !json.data) {
3429
+ throw new ScraperClientError("scraper /extract reported failure", 502, text);
3430
+ }
3431
+ return json.data;
3432
+ }
3402
3433
  const app$3 = new OpenAPIHono();
3403
3434
  app$3.use("/profiles", requireMinTier("friend"));
3404
3435
  app$3.use("/profiles/*", requireMinTier("friend"));
@@ -4425,42 +4456,354 @@ function applyTier(applicationUrl, ats) {
4425
4456
  if (KNOWN_UNSUPPORTED.some((h) => host === h || host.endsWith(`.${h}`))) return "unsupported";
4426
4457
  return "unknown";
4427
4458
  }
4428
- function registerDetailRoute(app2) {
4459
+ const IDENTITY_ERRORS = {
4460
+ 404: {
4461
+ description: "No such owner name, or no such job",
4462
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4463
+ },
4464
+ 409: {
4465
+ description: "That owner name has never signed in, or the posting is not in the pipeline",
4466
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4467
+ },
4468
+ 503: {
4469
+ description: "Identity could not be resolved right now — retry",
4470
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4471
+ }
4472
+ };
4473
+ const FORBIDDEN$1 = {
4474
+ description: "Forbidden",
4475
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4476
+ };
4477
+ const EVENT_COLS = "id, job_id, kind, from_state, to_state, note, occurred_at, created_at";
4478
+ const TRACKING_COLS = "notes, priority, follow_up_at, contact_name, contact_email";
4479
+ function stateOrNull(v) {
4480
+ const s = asJobStateRead(v);
4481
+ return s === "new" ? null : s;
4482
+ }
4483
+ function toEvent(r) {
4484
+ return {
4485
+ ...r,
4486
+ kind: r.kind,
4487
+ from_state: stateOrNull(r.from_state),
4488
+ to_state: stateOrNull(r.to_state)
4489
+ };
4490
+ }
4491
+ function asPriority(p) {
4492
+ return p === 1 || p === 2 || p === 3 ? p : null;
4493
+ }
4494
+ function toTracking(r) {
4495
+ return {
4496
+ notes: r.notes,
4497
+ priority: asPriority(r.priority),
4498
+ follow_up_at: r.follow_up_at,
4499
+ contact_name: r.contact_name,
4500
+ contact_email: r.contact_email
4501
+ };
4502
+ }
4503
+ function notInPipeline(id) {
4504
+ return {
4505
+ success: false,
4506
+ error: "Conflict",
4507
+ message: `Job '${id}' is not in your pipeline — set its state first (PUT /jobs/${id}/state)`
4508
+ };
4509
+ }
4510
+ function jobNotFound(id) {
4511
+ return { success: false, error: "Not found", message: `Job '${id}' not found` };
4512
+ }
4513
+ async function stateOf(db, jobId, userId) {
4514
+ const row = await db.prepare(
4515
+ `SELECT j.id, js.state FROM jobs j
4516
+ LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
4517
+ WHERE j.id = ?`
4518
+ ).bind(userId, jobId).first();
4519
+ if (!row) return "missing";
4520
+ return row.state ? "tracked" : "untracked";
4521
+ }
4522
+ function registerPipelineRoutes(app2) {
4523
+ app2.get("/pipeline", gateAuthed);
4524
+ app2.patch("/jobs/:id/tracking", gateAuthed);
4525
+ app2.get("/jobs/:id/events", gateAuthed);
4526
+ app2.post("/jobs/:id/events", gateAuthed);
4527
+ app2.delete("/jobs/:id/events/:eventId", gateAuthed);
4429
4528
  app2.openapi(
4430
4529
  createRoute({
4431
4530
  method: "get",
4432
- path: "/jobs/{id}",
4433
- tags: ["Jobs"],
4434
- summary: "Get full job detail",
4531
+ path: "/pipeline",
4532
+ tags: ["Pipeline"],
4533
+ summary: "Every posting in the caller's pipeline, most recently changed first",
4435
4534
  request: {
4436
- params: z.object({ id: z.string() }),
4437
- query: z.object({
4438
- profile_id: z.string().optional().openapi({ description: "Include score for this profile" })
4535
+ query: ownerNameQuery.extend({
4536
+ include_dismissed: z.enum(["true", "false"]).optional().openapi({
4537
+ description: "Dismissed postings are left out unless this is true."
4538
+ })
4439
4539
  })
4440
4540
  },
4441
4541
  responses: {
4442
4542
  200: {
4443
- description: "Job detail",
4444
- content: { "application/json": { schema: JobResponseSchema } }
4543
+ description: "Pipeline",
4544
+ content: { "application/json": { schema: PipelineResponseSchema } }
4445
4545
  },
4446
- 404: {
4447
- description: "Not found",
4448
- content: { "application/json": { schema: ErrorResponseSchema } }
4449
- }
4546
+ 403: FORBIDDEN$1,
4547
+ ...IDENTITY_ERRORS
4548
+ }
4549
+ }),
4550
+ async (c) => {
4551
+ const { ownerName, include_dismissed } = c.req.valid("query");
4552
+ const who = await effectiveUserId(c, ownerName);
4553
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4554
+ const dismissed = include_dismissed === "true" ? "" : "AND js.state != 'dismissed'";
4555
+ const rows = await c.env.JOB_PLATFORM_DB.prepare(
4556
+ `SELECT js.job_id, j.title, j.company, j.location, j.workplace_type,
4557
+ j.salary_min, j.salary_max, j.url, j.posted_date,
4558
+ js.state, js.updated_at, js.variant_slug,
4559
+ js.notes, js.priority, js.follow_up_at, js.contact_name, js.contact_email,
4560
+ ap.status AS application_status,
4561
+ (SELECT MIN(e.occurred_at) FROM job_events e
4562
+ WHERE e.job_id = js.job_id AND e.user_id = js.user_id
4563
+ AND e.kind = 'state' AND e.to_state = 'applied') AS applied_at,
4564
+ (SELECT MAX(e.occurred_at) FROM job_events e
4565
+ WHERE e.job_id = js.job_id AND e.user_id = js.user_id) AS last_event_at
4566
+ FROM job_states js
4567
+ JOIN jobs j ON j.id = js.job_id
4568
+ LEFT JOIN applications ap ON ap.job_id = js.job_id AND ap.user_id = js.user_id
4569
+ WHERE js.user_id = ? ${dismissed}
4570
+ ORDER BY js.updated_at DESC`
4571
+ ).bind(who.userId).all();
4572
+ const items = rows.results.flatMap((r) => {
4573
+ const state = stateOrNull(r.state);
4574
+ if (!state) return [];
4575
+ return [
4576
+ {
4577
+ job_id: r.job_id,
4578
+ title: r.title,
4579
+ company: r.company,
4580
+ location: r.location,
4581
+ workplace_type: r.workplace_type,
4582
+ salary_min: r.salary_min,
4583
+ salary_max: r.salary_max,
4584
+ url: r.url,
4585
+ posted_date: r.posted_date,
4586
+ state,
4587
+ updated_at: r.updated_at,
4588
+ tracking: toTracking(r),
4589
+ variant_slug: r.variant_slug,
4590
+ application_status: asApplicationStatus(r.application_status),
4591
+ applied_at: r.applied_at,
4592
+ last_event_at: r.last_event_at
4593
+ }
4594
+ ];
4595
+ });
4596
+ return c.json({ success: true, data: { items } }, 200);
4597
+ }
4598
+ );
4599
+ app2.openapi(
4600
+ createRoute({
4601
+ method: "patch",
4602
+ path: "/jobs/{id}/tracking",
4603
+ tags: ["Pipeline"],
4604
+ summary: "Change what the caller tracks about one application; null clears a field",
4605
+ request: {
4606
+ params: z.object({ id: z.string() }),
4607
+ query: ownerNameQuery,
4608
+ body: { content: { "application/json": { schema: SetTrackingSchema } } }
4609
+ },
4610
+ responses: {
4611
+ 200: {
4612
+ description: "Tracking after the change",
4613
+ content: { "application/json": { schema: TrackingResponseSchema } }
4614
+ },
4615
+ 403: FORBIDDEN$1,
4616
+ ...IDENTITY_ERRORS
4450
4617
  }
4451
4618
  }),
4452
4619
  async (c) => {
4453
4620
  const { id } = c.req.valid("param");
4454
- const { profile_id } = c.req.valid("query");
4621
+ const { ownerName } = c.req.valid("query");
4622
+ const body = c.req.valid("json");
4623
+ const who = await effectiveUserId(c, ownerName);
4624
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4455
4625
  const db = c.env.JOB_PLATFORM_DB;
4456
- const row = await db.prepare("SELECT * FROM jobs WHERE id = ?").bind(id).first();
4457
- if (!row) {
4458
- return c.json(
4459
- { success: false, error: "Not found", message: `Job '${id}' not found` },
4460
- 404
4461
- );
4462
- }
4463
- let score = 0;
4626
+ const sets = [];
4627
+ const binds = [];
4628
+ for (const col of [
4629
+ "notes",
4630
+ "priority",
4631
+ "follow_up_at",
4632
+ "contact_name",
4633
+ "contact_email"
4634
+ ]) {
4635
+ const v = body[col];
4636
+ if (v === void 0) continue;
4637
+ sets.push(`${col} = ?`);
4638
+ binds.push(typeof v === "string" && v.trim() === "" ? null : v);
4639
+ }
4640
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4641
+ const row = await db.prepare(
4642
+ `UPDATE job_states SET ${sets.join(", ")}, updated_at = ?
4643
+ WHERE job_id = ? AND user_id = ?
4644
+ RETURNING ${TRACKING_COLS}`
4645
+ ).bind(...binds, now, id, who.userId).first();
4646
+ if (!row) {
4647
+ const s = await stateOf(db, id, who.userId);
4648
+ return s === "missing" ? c.json(jobNotFound(id), 404) : c.json(notInPipeline(id), 409);
4649
+ }
4650
+ return c.json(
4651
+ { success: true, data: { job_id: id, tracking: toTracking(row) } },
4652
+ 200
4653
+ );
4654
+ }
4655
+ );
4656
+ app2.openapi(
4657
+ createRoute({
4658
+ method: "get",
4659
+ path: "/jobs/{id}/events",
4660
+ tags: ["Pipeline"],
4661
+ summary: "One posting's timeline for the caller, oldest first",
4662
+ request: { params: z.object({ id: z.string() }), query: ownerNameQuery },
4663
+ responses: {
4664
+ 200: {
4665
+ description: "Timeline (empty for a posting with none)",
4666
+ content: { "application/json": { schema: JobEventsResponseSchema } }
4667
+ },
4668
+ 403: FORBIDDEN$1,
4669
+ ...IDENTITY_ERRORS
4670
+ }
4671
+ }),
4672
+ async (c) => {
4673
+ const { id } = c.req.valid("param");
4674
+ const { ownerName } = c.req.valid("query");
4675
+ const who = await effectiveUserId(c, ownerName);
4676
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4677
+ const rows = await c.env.JOB_PLATFORM_DB.prepare(
4678
+ `SELECT ${EVENT_COLS} FROM job_events
4679
+ WHERE job_id = ? AND user_id = ?
4680
+ ORDER BY occurred_at, id`
4681
+ ).bind(id, who.userId).all();
4682
+ return c.json({ success: true, data: { events: rows.results.map(toEvent) } }, 200);
4683
+ }
4684
+ );
4685
+ app2.openapi(
4686
+ createRoute({
4687
+ method: "post",
4688
+ path: "/jobs/{id}/events",
4689
+ tags: ["Pipeline"],
4690
+ summary: "Log something that happened on one application",
4691
+ request: {
4692
+ params: z.object({ id: z.string() }),
4693
+ query: ownerNameQuery,
4694
+ body: { content: { "application/json": { schema: AddJobEventSchema } } }
4695
+ },
4696
+ responses: {
4697
+ 201: {
4698
+ description: "Logged",
4699
+ content: { "application/json": { schema: JobEventResponseSchema } }
4700
+ },
4701
+ 403: FORBIDDEN$1,
4702
+ ...IDENTITY_ERRORS
4703
+ }
4704
+ }),
4705
+ async (c) => {
4706
+ const { id } = c.req.valid("param");
4707
+ const { ownerName } = c.req.valid("query");
4708
+ const body = c.req.valid("json");
4709
+ const who = await effectiveUserId(c, ownerName);
4710
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4711
+ const db = c.env.JOB_PLATFORM_DB;
4712
+ const s = await stateOf(db, id, who.userId);
4713
+ if (s === "missing") return c.json(jobNotFound(id), 404);
4714
+ if (s === "untracked") return c.json(notInPipeline(id), 409);
4715
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4716
+ const occurred = body.occurred_at ? new Date(
4717
+ body.occurred_at.length === 10 ? `${body.occurred_at}T00:00:00Z` : body.occurred_at
4718
+ ).toISOString() : now;
4719
+ const row = await db.prepare(
4720
+ `INSERT INTO job_events
4721
+ (job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
4722
+ VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)
4723
+ RETURNING ${EVENT_COLS}`
4724
+ ).bind(id, who.userId, body.kind, body.note, occurred, now).first();
4725
+ if (!row) throw new Error("job_events INSERT … RETURNING returned no row");
4726
+ return c.json({ success: true, data: { event: toEvent(row) } }, 201);
4727
+ }
4728
+ );
4729
+ app2.openapi(
4730
+ createRoute({
4731
+ method: "delete",
4732
+ path: "/jobs/{id}/events/{eventId}",
4733
+ tags: ["Pipeline"],
4734
+ summary: "Remove a note/interview/contact/follow-up the caller logged",
4735
+ request: {
4736
+ params: z.object({ id: z.string(), eventId: z.coerce.number().int().positive() }),
4737
+ query: ownerNameQuery
4738
+ },
4739
+ responses: {
4740
+ 200: {
4741
+ description: "Removed (or already absent)",
4742
+ content: {
4743
+ "application/json": {
4744
+ schema: z.object({
4745
+ success: z.literal(true),
4746
+ data: z.object({ id: z.number().int(), deleted: z.boolean() })
4747
+ }).openapi("DeleteJobEventResponse")
4748
+ }
4749
+ }
4750
+ },
4751
+ 403: FORBIDDEN$1,
4752
+ ...IDENTITY_ERRORS
4753
+ }
4754
+ }),
4755
+ async (c) => {
4756
+ const { id, eventId } = c.req.valid("param");
4757
+ const { ownerName } = c.req.valid("query");
4758
+ const who = await effectiveUserId(c, ownerName);
4759
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4760
+ const res = await c.env.JOB_PLATFORM_DB.prepare(
4761
+ `DELETE FROM job_events
4762
+ WHERE id = ? AND job_id = ? AND user_id = ? AND kind != 'state'`
4763
+ ).bind(eventId, id, who.userId).run();
4764
+ return c.json(
4765
+ { success: true, data: { id: eventId, deleted: (res.meta?.changes ?? 0) > 0 } },
4766
+ 200
4767
+ );
4768
+ }
4769
+ );
4770
+ }
4771
+ function registerDetailRoute(app2) {
4772
+ app2.openapi(
4773
+ createRoute({
4774
+ method: "get",
4775
+ path: "/jobs/{id}",
4776
+ tags: ["Jobs"],
4777
+ summary: "Get full job detail",
4778
+ request: {
4779
+ params: z.object({ id: z.string() }),
4780
+ query: z.object({
4781
+ profile_id: z.string().optional().openapi({ description: "Include score for this profile" })
4782
+ })
4783
+ },
4784
+ responses: {
4785
+ 200: {
4786
+ description: "Job detail",
4787
+ content: { "application/json": { schema: JobResponseSchema } }
4788
+ },
4789
+ 404: {
4790
+ description: "Not found",
4791
+ content: { "application/json": { schema: ErrorResponseSchema } }
4792
+ }
4793
+ }
4794
+ }),
4795
+ async (c) => {
4796
+ const { id } = c.req.valid("param");
4797
+ const { profile_id } = c.req.valid("query");
4798
+ const db = c.env.JOB_PLATFORM_DB;
4799
+ const row = await db.prepare("SELECT * FROM jobs WHERE id = ?").bind(id).first();
4800
+ if (!row) {
4801
+ return c.json(
4802
+ { success: false, error: "Not found", message: `Job '${id}' not found` },
4803
+ 404
4804
+ );
4805
+ }
4806
+ let score = 0;
4464
4807
  let score_breakdown = { ...ZERO_BREAKDOWN };
4465
4808
  const roleLevel = asRoleLevel(row.role_level ?? null);
4466
4809
  if (profile_id) {
@@ -4482,6 +4825,7 @@ function registerDetailRoute(app2) {
4482
4825
  const userId = await maybeUserId(c);
4483
4826
  let state = null;
4484
4827
  let stateUpdatedAt = null;
4828
+ let tracking = null;
4485
4829
  let applyVerified = false;
4486
4830
  if (userId && row.ats && row.slug) {
4487
4831
  const proven = await db.prepare(
@@ -4503,12 +4847,15 @@ function registerDetailRoute(app2) {
4503
4847
  let applicationStatus = null;
4504
4848
  if (userId) {
4505
4849
  const [stateRes, appRes] = await db.batch([
4506
- db.prepare("SELECT state, updated_at FROM job_states WHERE job_id = ? AND user_id = ?").bind(id, userId),
4850
+ db.prepare(
4851
+ `SELECT state, updated_at, ${TRACKING_COLS} FROM job_states WHERE job_id = ? AND user_id = ?`
4852
+ ).bind(id, userId),
4507
4853
  db.prepare("SELECT status FROM applications WHERE job_id = ? AND user_id = ?").bind(id, userId)
4508
4854
  ]);
4509
4855
  const stateRow = stateRes.results[0];
4510
4856
  state = asJobStateRead(stateRow?.state);
4511
4857
  stateUpdatedAt = stateRow?.updated_at ?? null;
4858
+ tracking = stateRow ? toTracking(stateRow) : null;
4512
4859
  const appRow = appRes.results[0];
4513
4860
  applicationStatus = appRow?.status ?? null;
4514
4861
  }
@@ -4544,7 +4891,8 @@ function registerDetailRoute(app2) {
4544
4891
  score_breakdown,
4545
4892
  state,
4546
4893
  application_status: asApplicationStatus(applicationStatus),
4547
- state_updated_at: stateUpdatedAt
4894
+ state_updated_at: stateUpdatedAt,
4895
+ tracking
4548
4896
  };
4549
4897
  return c.json({ success: true, data: { job } }, 200);
4550
4898
  }
@@ -5797,7 +6145,7 @@ async function delistedReason(db, jobId) {
5797
6145
  if (newest - seen <= DELISTED_LAG_MS) return null;
5798
6146
  return `this posting has not appeared in a scrape of the ${job.ats} board '${job.slug}' since ${job.last_seen_at}, while other jobs on that board were seen as recently as ${board.newest} — it looks taken down`;
5799
6147
  }
5800
- const FORBIDDEN$1 = {
6148
+ const FORBIDDEN = {
5801
6149
  success: false,
5802
6150
  error: "Forbidden",
5803
6151
  message: "Authentication required"
@@ -6096,7 +6444,7 @@ function registerApplicationRoutes(app2) {
6096
6444
  }),
6097
6445
  async (c) => {
6098
6446
  const userId = await maybeUserId(c);
6099
- if (!userId) return c.json(FORBIDDEN$1, 403);
6447
+ if (!userId) return c.json(FORBIDDEN, 403);
6100
6448
  const { id } = c.req.valid("param");
6101
6449
  const db = c.env.JOB_PLATFORM_DB;
6102
6450
  const existing = await db.prepare("SELECT * FROM applications WHERE id = ? AND user_id = ?").bind(id, userId).first();
@@ -6340,13 +6688,17 @@ function registerFillRoutes(app2) {
6340
6688
  );
6341
6689
  });
6342
6690
  }
6343
- const IDENTITY_ERRORS = {
6691
+ const ERRORS$1 = {
6692
+ 403: {
6693
+ description: "Forbidden",
6694
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
6695
+ },
6344
6696
  404: {
6345
- description: "No such owner name, or no such job",
6697
+ description: "No such owner name",
6346
6698
  content: { "application/json": { schema: IdentityErrorResponseSchema } }
6347
6699
  },
6348
6700
  409: {
6349
- description: "That owner name has never signed in, or the posting is not in the pipeline",
6701
+ description: "That owner name has never signed in",
6350
6702
  content: { "application/json": { schema: IdentityErrorResponseSchema } }
6351
6703
  },
6352
6704
  503: {
@@ -6354,326 +6706,10 @@ const IDENTITY_ERRORS = {
6354
6706
  content: { "application/json": { schema: IdentityErrorResponseSchema } }
6355
6707
  }
6356
6708
  };
6357
- const FORBIDDEN = {
6358
- description: "Forbidden",
6359
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6360
- };
6361
- const EVENT_COLS = "id, job_id, kind, from_state, to_state, note, occurred_at, created_at";
6362
- const TRACKING_COLS = "notes, priority, follow_up_at, contact_name, contact_email";
6363
- function stateOrNull(v) {
6364
- const s = asJobStateRead(v);
6365
- return s === "new" ? null : s;
6366
- }
6367
- function toEvent(r) {
6368
- return {
6369
- ...r,
6370
- kind: r.kind,
6371
- from_state: stateOrNull(r.from_state),
6372
- to_state: stateOrNull(r.to_state)
6373
- };
6374
- }
6375
- function asPriority(p) {
6376
- return p === 1 || p === 2 || p === 3 ? p : null;
6377
- }
6378
- function toTracking(r) {
6379
- return {
6380
- notes: r.notes,
6381
- priority: asPriority(r.priority),
6382
- follow_up_at: r.follow_up_at,
6383
- contact_name: r.contact_name,
6384
- contact_email: r.contact_email
6385
- };
6386
- }
6387
- function notInPipeline(id) {
6388
- return {
6389
- success: false,
6390
- error: "Conflict",
6391
- message: `Job '${id}' is not in your pipeline — set its state first (PUT /jobs/${id}/state)`
6392
- };
6393
- }
6394
- function jobNotFound(id) {
6395
- return { success: false, error: "Not found", message: `Job '${id}' not found` };
6396
- }
6397
- async function stateOf(db, jobId, userId) {
6398
- const row = await db.prepare(
6399
- `SELECT j.id, js.state FROM jobs j
6400
- LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
6401
- WHERE j.id = ?`
6402
- ).bind(userId, jobId).first();
6403
- if (!row) return "missing";
6404
- return row.state ? "tracked" : "untracked";
6405
- }
6406
- function registerPipelineRoutes(app2) {
6407
- app2.get("/pipeline", gateAuthed);
6408
- app2.patch("/jobs/:id/tracking", gateAuthed);
6409
- app2.get("/jobs/:id/events", gateAuthed);
6410
- app2.post("/jobs/:id/events", gateAuthed);
6411
- app2.delete("/jobs/:id/events/:eventId", gateAuthed);
6412
- app2.openapi(
6413
- createRoute({
6414
- method: "get",
6415
- path: "/pipeline",
6416
- tags: ["Pipeline"],
6417
- summary: "Every posting in the caller's pipeline, most recently changed first",
6418
- request: {
6419
- query: ownerNameQuery.extend({
6420
- include_dismissed: z.enum(["true", "false"]).optional().openapi({
6421
- description: "Dismissed postings are left out unless this is true."
6422
- })
6423
- })
6424
- },
6425
- responses: {
6426
- 200: {
6427
- description: "Pipeline",
6428
- content: { "application/json": { schema: PipelineResponseSchema } }
6429
- },
6430
- 403: FORBIDDEN,
6431
- ...IDENTITY_ERRORS
6432
- }
6433
- }),
6434
- async (c) => {
6435
- const { ownerName, include_dismissed } = c.req.valid("query");
6436
- const who = await effectiveUserId(c, ownerName);
6437
- if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
6438
- const dismissed = include_dismissed === "true" ? "" : "AND js.state != 'dismissed'";
6439
- const rows = await c.env.JOB_PLATFORM_DB.prepare(
6440
- `SELECT js.job_id, j.title, j.company, j.location, j.workplace_type,
6441
- j.salary_min, j.salary_max, j.url, j.posted_date,
6442
- js.state, js.updated_at, js.variant_slug,
6443
- js.notes, js.priority, js.follow_up_at, js.contact_name, js.contact_email,
6444
- ap.status AS application_status,
6445
- (SELECT MIN(e.occurred_at) FROM job_events e
6446
- WHERE e.job_id = js.job_id AND e.user_id = js.user_id
6447
- AND e.kind = 'state' AND e.to_state = 'applied') AS applied_at,
6448
- (SELECT MAX(e.occurred_at) FROM job_events e
6449
- WHERE e.job_id = js.job_id AND e.user_id = js.user_id) AS last_event_at
6450
- FROM job_states js
6451
- JOIN jobs j ON j.id = js.job_id
6452
- LEFT JOIN applications ap ON ap.job_id = js.job_id AND ap.user_id = js.user_id
6453
- WHERE js.user_id = ? ${dismissed}
6454
- ORDER BY js.updated_at DESC`
6455
- ).bind(who.userId).all();
6456
- const items = rows.results.flatMap((r) => {
6457
- const state = stateOrNull(r.state);
6458
- if (!state) return [];
6459
- return [
6460
- {
6461
- job_id: r.job_id,
6462
- title: r.title,
6463
- company: r.company,
6464
- location: r.location,
6465
- workplace_type: r.workplace_type,
6466
- salary_min: r.salary_min,
6467
- salary_max: r.salary_max,
6468
- url: r.url,
6469
- posted_date: r.posted_date,
6470
- state,
6471
- updated_at: r.updated_at,
6472
- tracking: toTracking(r),
6473
- variant_slug: r.variant_slug,
6474
- application_status: asApplicationStatus(r.application_status),
6475
- applied_at: r.applied_at,
6476
- last_event_at: r.last_event_at
6477
- }
6478
- ];
6479
- });
6480
- return c.json({ success: true, data: { items } }, 200);
6481
- }
6482
- );
6483
- app2.openapi(
6484
- createRoute({
6485
- method: "patch",
6486
- path: "/jobs/{id}/tracking",
6487
- tags: ["Pipeline"],
6488
- summary: "Change what the caller tracks about one application; null clears a field",
6489
- request: {
6490
- params: z.object({ id: z.string() }),
6491
- query: ownerNameQuery,
6492
- body: { content: { "application/json": { schema: SetTrackingSchema } } }
6493
- },
6494
- responses: {
6495
- 200: {
6496
- description: "Tracking after the change",
6497
- content: { "application/json": { schema: TrackingResponseSchema } }
6498
- },
6499
- 403: FORBIDDEN,
6500
- ...IDENTITY_ERRORS
6501
- }
6502
- }),
6503
- async (c) => {
6504
- const { id } = c.req.valid("param");
6505
- const { ownerName } = c.req.valid("query");
6506
- const body = c.req.valid("json");
6507
- const who = await effectiveUserId(c, ownerName);
6508
- if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
6509
- const db = c.env.JOB_PLATFORM_DB;
6510
- const sets = [];
6511
- const binds = [];
6512
- for (const col of [
6513
- "notes",
6514
- "priority",
6515
- "follow_up_at",
6516
- "contact_name",
6517
- "contact_email"
6518
- ]) {
6519
- const v = body[col];
6520
- if (v === void 0) continue;
6521
- sets.push(`${col} = ?`);
6522
- binds.push(typeof v === "string" && v.trim() === "" ? null : v);
6523
- }
6524
- const now = (/* @__PURE__ */ new Date()).toISOString();
6525
- const row = await db.prepare(
6526
- `UPDATE job_states SET ${sets.join(", ")}, updated_at = ?
6527
- WHERE job_id = ? AND user_id = ?
6528
- RETURNING ${TRACKING_COLS}`
6529
- ).bind(...binds, now, id, who.userId).first();
6530
- if (!row) {
6531
- const s = await stateOf(db, id, who.userId);
6532
- return s === "missing" ? c.json(jobNotFound(id), 404) : c.json(notInPipeline(id), 409);
6533
- }
6534
- return c.json(
6535
- { success: true, data: { job_id: id, tracking: toTracking(row) } },
6536
- 200
6537
- );
6538
- }
6539
- );
6540
- app2.openapi(
6541
- createRoute({
6542
- method: "get",
6543
- path: "/jobs/{id}/events",
6544
- tags: ["Pipeline"],
6545
- summary: "One posting's timeline for the caller, oldest first",
6546
- request: { params: z.object({ id: z.string() }), query: ownerNameQuery },
6547
- responses: {
6548
- 200: {
6549
- description: "Timeline (empty for a posting with none)",
6550
- content: { "application/json": { schema: JobEventsResponseSchema } }
6551
- },
6552
- 403: FORBIDDEN,
6553
- ...IDENTITY_ERRORS
6554
- }
6555
- }),
6556
- async (c) => {
6557
- const { id } = c.req.valid("param");
6558
- const { ownerName } = c.req.valid("query");
6559
- const who = await effectiveUserId(c, ownerName);
6560
- if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
6561
- const rows = await c.env.JOB_PLATFORM_DB.prepare(
6562
- `SELECT ${EVENT_COLS} FROM job_events
6563
- WHERE job_id = ? AND user_id = ?
6564
- ORDER BY occurred_at, id`
6565
- ).bind(id, who.userId).all();
6566
- return c.json({ success: true, data: { events: rows.results.map(toEvent) } }, 200);
6567
- }
6568
- );
6569
- app2.openapi(
6570
- createRoute({
6571
- method: "post",
6572
- path: "/jobs/{id}/events",
6573
- tags: ["Pipeline"],
6574
- summary: "Log something that happened on one application",
6575
- request: {
6576
- params: z.object({ id: z.string() }),
6577
- query: ownerNameQuery,
6578
- body: { content: { "application/json": { schema: AddJobEventSchema } } }
6579
- },
6580
- responses: {
6581
- 201: {
6582
- description: "Logged",
6583
- content: { "application/json": { schema: JobEventResponseSchema } }
6584
- },
6585
- 403: FORBIDDEN,
6586
- ...IDENTITY_ERRORS
6587
- }
6588
- }),
6589
- async (c) => {
6590
- const { id } = c.req.valid("param");
6591
- const { ownerName } = c.req.valid("query");
6592
- const body = c.req.valid("json");
6593
- const who = await effectiveUserId(c, ownerName);
6594
- if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
6595
- const db = c.env.JOB_PLATFORM_DB;
6596
- const s = await stateOf(db, id, who.userId);
6597
- if (s === "missing") return c.json(jobNotFound(id), 404);
6598
- if (s === "untracked") return c.json(notInPipeline(id), 409);
6599
- const now = (/* @__PURE__ */ new Date()).toISOString();
6600
- const occurred = body.occurred_at ? new Date(
6601
- body.occurred_at.length === 10 ? `${body.occurred_at}T00:00:00Z` : body.occurred_at
6602
- ).toISOString() : now;
6603
- const row = await db.prepare(
6604
- `INSERT INTO job_events
6605
- (job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
6606
- VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)
6607
- RETURNING ${EVENT_COLS}`
6608
- ).bind(id, who.userId, body.kind, body.note, occurred, now).first();
6609
- if (!row) throw new Error("job_events INSERT … RETURNING returned no row");
6610
- return c.json({ success: true, data: { event: toEvent(row) } }, 201);
6611
- }
6612
- );
6613
- app2.openapi(
6614
- createRoute({
6615
- method: "delete",
6616
- path: "/jobs/{id}/events/{eventId}",
6617
- tags: ["Pipeline"],
6618
- summary: "Remove a note/interview/contact/follow-up the caller logged",
6619
- request: {
6620
- params: z.object({ id: z.string(), eventId: z.coerce.number().int().positive() }),
6621
- query: ownerNameQuery
6622
- },
6623
- responses: {
6624
- 200: {
6625
- description: "Removed (or already absent)",
6626
- content: {
6627
- "application/json": {
6628
- schema: z.object({
6629
- success: z.literal(true),
6630
- data: z.object({ id: z.number().int(), deleted: z.boolean() })
6631
- }).openapi("DeleteJobEventResponse")
6632
- }
6633
- }
6634
- },
6635
- 403: FORBIDDEN,
6636
- ...IDENTITY_ERRORS
6637
- }
6638
- }),
6639
- async (c) => {
6640
- const { id, eventId } = c.req.valid("param");
6641
- const { ownerName } = c.req.valid("query");
6642
- const who = await effectiveUserId(c, ownerName);
6643
- if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
6644
- const res = await c.env.JOB_PLATFORM_DB.prepare(
6645
- `DELETE FROM job_events
6646
- WHERE id = ? AND job_id = ? AND user_id = ? AND kind != 'state'`
6647
- ).bind(eventId, id, who.userId).run();
6648
- return c.json(
6649
- { success: true, data: { id: eventId, deleted: (res.meta?.changes ?? 0) > 0 } },
6650
- 200
6651
- );
6652
- }
6653
- );
6654
- }
6655
- const ERRORS$1 = {
6656
- 403: {
6657
- description: "Forbidden",
6658
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6659
- },
6660
- 404: {
6661
- description: "No such owner name",
6662
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6663
- },
6664
- 409: {
6665
- description: "That owner name has never signed in",
6666
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6667
- },
6668
- 503: {
6669
- description: "Identity could not be resolved right now — retry",
6670
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6671
- }
6672
- };
6673
- function registerBlockedRoutes(app2) {
6674
- app2.get("/blocked-companies", gateAuthed);
6675
- app2.put("/blocked-companies", gateAuthed);
6676
- app2.delete("/blocked-companies", gateAuthed);
6709
+ function registerBlockedRoutes(app2) {
6710
+ app2.get("/blocked-companies", gateAuthed);
6711
+ app2.put("/blocked-companies", gateAuthed);
6712
+ app2.delete("/blocked-companies", gateAuthed);
6677
6713
  app2.openapi(
6678
6714
  createRoute({
6679
6715
  method: "get",
@@ -7099,6 +7135,175 @@ function registerLocationsRoute(app2) {
7099
7135
  }
7100
7136
  );
7101
7137
  }
7138
+ const ImportJobSchema = z.object({
7139
+ url: z.string().trim().url().max(2e3),
7140
+ /** Where it lands in the pipeline when it is new to the caller. */
7141
+ state: JobStateSchema.exclude(["dismissed"]).default("saved")
7142
+ }).openapi("ImportJob");
7143
+ const ImportedSchema = z.object({
7144
+ success: z.literal(true),
7145
+ data: z.object({
7146
+ job: z.object({
7147
+ id: z.string(),
7148
+ title: z.string(),
7149
+ company: z.string(),
7150
+ location: z.string(),
7151
+ url: z.string()
7152
+ }),
7153
+ /** The posting was new to the corpus (false: it was already there). */
7154
+ created: z.boolean(),
7155
+ /** The caller's state for it now. */
7156
+ state: JobStateSchema,
7157
+ /** The caller already tracked it, so its state was left alone. */
7158
+ already_tracked: z.boolean()
7159
+ })
7160
+ }).openapi("ImportedJob");
7161
+ function failure(status, message) {
7162
+ return {
7163
+ success: false,
7164
+ error: status === 422 ? "Unprocessable" : "Bad gateway",
7165
+ message
7166
+ };
7167
+ }
7168
+ const JOB_COLS = "id, title, company, location, url";
7169
+ async function greenhouseSlugFor(db, url) {
7170
+ const parsed = new URL(url);
7171
+ if (!parsed.searchParams.has("gh_jid")) return void 0;
7172
+ const prefix = `${parsed.protocol}//${parsed.host}/`;
7173
+ const row = await db.prepare(
7174
+ `SELECT slug FROM jobs
7175
+ WHERE url >= ? AND url < ? AND ats = 'greenhouse' AND slug IS NOT NULL
7176
+ AND slug_source = 'scraped'
7177
+ LIMIT 1`
7178
+ ).bind(prefix, `${prefix.slice(0, -1)}0`).first();
7179
+ return row?.slug;
7180
+ }
7181
+ function registerImportRoute(app2) {
7182
+ app2.post("/jobs/import", gateAuthed);
7183
+ app2.openapi(
7184
+ createRoute({
7185
+ method: "post",
7186
+ path: "/jobs/import",
7187
+ tags: ["Pipeline"],
7188
+ summary: "Import a posting from its URL into the corpus and the caller’s pipeline",
7189
+ request: {
7190
+ query: ownerNameQuery,
7191
+ body: { content: { "application/json": { schema: ImportJobSchema } } }
7192
+ },
7193
+ responses: {
7194
+ 200: {
7195
+ description: "Imported (or found, and added to the pipeline)",
7196
+ content: { "application/json": { schema: ImportedSchema } }
7197
+ },
7198
+ 403: {
7199
+ description: "Forbidden",
7200
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
7201
+ },
7202
+ 404: {
7203
+ description: "The board no longer lists that posting, or no such owner name",
7204
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
7205
+ },
7206
+ 409: {
7207
+ description: "That owner name has never signed in",
7208
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
7209
+ },
7210
+ 422: {
7211
+ description: "No posting could be read from that page (or it is a LinkedIn URL)",
7212
+ content: { "application/json": { schema: ErrorResponseSchema } }
7213
+ },
7214
+ 502: {
7215
+ description: "The site, or the scraper, could not be reached",
7216
+ content: { "application/json": { schema: ErrorResponseSchema } }
7217
+ },
7218
+ 503: {
7219
+ description: "Identity could not be resolved right now — retry",
7220
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
7221
+ }
7222
+ }
7223
+ }),
7224
+ async (c) => {
7225
+ const { url, state } = c.req.valid("json");
7226
+ const { ownerName } = c.req.valid("query");
7227
+ const who = await effectiveUserId(c, ownerName);
7228
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
7229
+ const db = c.env.JOB_PLATFORM_DB;
7230
+ let row = await db.prepare(`SELECT ${JOB_COLS} FROM jobs WHERE url = ?`).bind(url).first();
7231
+ let created = false;
7232
+ if (!row) {
7233
+ let extracted;
7234
+ try {
7235
+ extracted = await extractPosting(c.env, url, await greenhouseSlugFor(db, url));
7236
+ } catch (err) {
7237
+ if (!(err instanceof ScraperClientError)) throw err;
7238
+ if (err.status === 404) {
7239
+ return c.json(
7240
+ { success: false, error: "Not found", message: err.message },
7241
+ 404
7242
+ );
7243
+ }
7244
+ if (err.status === 400 || err.status === 422) {
7245
+ return c.json(failure(422, err.message), 422);
7246
+ }
7247
+ return c.json(failure(502, `Could not read that posting: ${err.message}`), 502);
7248
+ }
7249
+ const job = IngestJobSchema.safeParse(extracted.job);
7250
+ if (!job.success) {
7251
+ return c.json(
7252
+ failure(
7253
+ 502,
7254
+ `The scraper returned a posting this API cannot store: ${job.error.message}`
7255
+ ),
7256
+ 502
7257
+ );
7258
+ }
7259
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
7260
+ const [res] = await db.batch([
7261
+ insertJobStatement(db, job.data, {
7262
+ source: extracted.ats ?? "import",
7263
+ board_slug: extracted.board_slug ?? void 0,
7264
+ now: now2
7265
+ })
7266
+ ]);
7267
+ created = (res.meta?.changes ?? 0) > 0;
7268
+ row = await db.prepare(`SELECT ${JOB_COLS} FROM jobs WHERE url = ? OR id = ? LIMIT 1`).bind(job.data.url, job.data.id).first();
7269
+ if (!row) throw new Error(`import: ${job.data.id} neither inserted nor found`);
7270
+ if (created) await rankNewJobs(db);
7271
+ }
7272
+ const prev = await db.prepare("SELECT state FROM job_states WHERE job_id = ? AND user_id = ?").bind(row.id, who.userId).first();
7273
+ if (prev) {
7274
+ const kept = JobStateSchema.safeParse(prev.state);
7275
+ return c.json(
7276
+ {
7277
+ success: true,
7278
+ data: {
7279
+ job: row,
7280
+ created,
7281
+ state: kept.success ? kept.data : state,
7282
+ already_tracked: true
7283
+ }
7284
+ },
7285
+ 200
7286
+ );
7287
+ }
7288
+ const now = (/* @__PURE__ */ new Date()).toISOString();
7289
+ await db.batch([
7290
+ db.prepare(
7291
+ `INSERT INTO job_events (job_id, user_id, kind, from_state, to_state, occurred_at, created_at)
7292
+ SELECT ?, ?, 'state', NULL, ?, ?, ?
7293
+ WHERE NOT EXISTS (SELECT 1 FROM job_states WHERE job_id = ? AND user_id = ?)`
7294
+ ).bind(row.id, who.userId, state, now, now, row.id, who.userId),
7295
+ db.prepare(
7296
+ `INSERT INTO job_states (job_id, user_id, state, updated_at) VALUES (?, ?, ?, ?)
7297
+ ON CONFLICT (job_id, user_id) DO NOTHING`
7298
+ ).bind(row.id, who.userId, state, now)
7299
+ ]);
7300
+ return c.json(
7301
+ { success: true, data: { job: row, created, state, already_tracked: false } },
7302
+ 200
7303
+ );
7304
+ }
7305
+ );
7306
+ }
7102
7307
  const app$2 = new OpenAPIHono();
7103
7308
  registerFeedRoute(app$2);
7104
7309
  registerPreflightRoute(app$2);
@@ -7114,6 +7319,7 @@ registerFillRoutes(app$2);
7114
7319
  registerBlockedRoutes(app$2);
7115
7320
  registerInsightsRoute(app$2);
7116
7321
  registerLocationsRoute(app$2);
7322
+ registerImportRoute(app$2);
7117
7323
  registerAnswerRoutes(app$2);
7118
7324
  registerOwnerRoutes(app$2);
7119
7325
  const jobRoutes = app$2;