@hadoku/jobplatform-worker 2.32.36 → 2.32.37

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");
@@ -4425,319 +4427,524 @@ function applyTier(applicationUrl, ats) {
4425
4427
  if (KNOWN_UNSUPPORTED.some((h) => host === h || host.endsWith(`.${h}`))) return "unsupported";
4426
4428
  return "unknown";
4427
4429
  }
4428
- function registerDetailRoute(app2) {
4430
+ const IDENTITY_ERRORS = {
4431
+ 404: {
4432
+ description: "No such owner name, or no such job",
4433
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4434
+ },
4435
+ 409: {
4436
+ description: "That owner name has never signed in, or the posting is not in the pipeline",
4437
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4438
+ },
4439
+ 503: {
4440
+ description: "Identity could not be resolved right now — retry",
4441
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4442
+ }
4443
+ };
4444
+ const FORBIDDEN$1 = {
4445
+ description: "Forbidden",
4446
+ content: { "application/json": { schema: IdentityErrorResponseSchema } }
4447
+ };
4448
+ const EVENT_COLS = "id, job_id, kind, from_state, to_state, note, occurred_at, created_at";
4449
+ const TRACKING_COLS = "notes, priority, follow_up_at, contact_name, contact_email";
4450
+ function stateOrNull(v) {
4451
+ const s = asJobStateRead(v);
4452
+ return s === "new" ? null : s;
4453
+ }
4454
+ function toEvent(r) {
4455
+ return {
4456
+ ...r,
4457
+ kind: r.kind,
4458
+ from_state: stateOrNull(r.from_state),
4459
+ to_state: stateOrNull(r.to_state)
4460
+ };
4461
+ }
4462
+ function asPriority(p) {
4463
+ return p === 1 || p === 2 || p === 3 ? p : null;
4464
+ }
4465
+ function toTracking(r) {
4466
+ return {
4467
+ notes: r.notes,
4468
+ priority: asPriority(r.priority),
4469
+ follow_up_at: r.follow_up_at,
4470
+ contact_name: r.contact_name,
4471
+ contact_email: r.contact_email
4472
+ };
4473
+ }
4474
+ function notInPipeline(id) {
4475
+ return {
4476
+ success: false,
4477
+ error: "Conflict",
4478
+ message: `Job '${id}' is not in your pipeline — set its state first (PUT /jobs/${id}/state)`
4479
+ };
4480
+ }
4481
+ function jobNotFound(id) {
4482
+ return { success: false, error: "Not found", message: `Job '${id}' not found` };
4483
+ }
4484
+ async function stateOf(db, jobId, userId) {
4485
+ const row = await db.prepare(
4486
+ `SELECT j.id, js.state FROM jobs j
4487
+ LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
4488
+ WHERE j.id = ?`
4489
+ ).bind(userId, jobId).first();
4490
+ if (!row) return "missing";
4491
+ return row.state ? "tracked" : "untracked";
4492
+ }
4493
+ function registerPipelineRoutes(app2) {
4494
+ app2.get("/pipeline", gateAuthed);
4495
+ app2.patch("/jobs/:id/tracking", gateAuthed);
4496
+ app2.get("/jobs/:id/events", gateAuthed);
4497
+ app2.post("/jobs/:id/events", gateAuthed);
4498
+ app2.delete("/jobs/:id/events/:eventId", gateAuthed);
4429
4499
  app2.openapi(
4430
4500
  createRoute({
4431
4501
  method: "get",
4432
- path: "/jobs/{id}",
4433
- tags: ["Jobs"],
4434
- summary: "Get full job detail",
4502
+ path: "/pipeline",
4503
+ tags: ["Pipeline"],
4504
+ summary: "Every posting in the caller's pipeline, most recently changed first",
4435
4505
  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" })
4506
+ query: ownerNameQuery.extend({
4507
+ include_dismissed: z.enum(["true", "false"]).optional().openapi({
4508
+ description: "Dismissed postings are left out unless this is true."
4509
+ })
4439
4510
  })
4440
4511
  },
4441
4512
  responses: {
4442
4513
  200: {
4443
- description: "Job detail",
4444
- content: { "application/json": { schema: JobResponseSchema } }
4514
+ description: "Pipeline",
4515
+ content: { "application/json": { schema: PipelineResponseSchema } }
4445
4516
  },
4446
- 404: {
4447
- description: "Not found",
4448
- content: { "application/json": { schema: ErrorResponseSchema } }
4449
- }
4517
+ 403: FORBIDDEN$1,
4518
+ ...IDENTITY_ERRORS
4450
4519
  }
4451
4520
  }),
4452
4521
  async (c) => {
4453
- const { id } = c.req.valid("param");
4454
- const { profile_id } = c.req.valid("query");
4455
- 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;
4464
- let score_breakdown = { ...ZERO_BREAKDOWN };
4465
- const roleLevel = asRoleLevel(row.role_level ?? null);
4466
- if (profile_id) {
4467
- const profile = await loadScorableProfile(db, profile_id);
4468
- const result = scoreJob(
4522
+ const { ownerName, include_dismissed } = c.req.valid("query");
4523
+ const who = await effectiveUserId(c, ownerName);
4524
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4525
+ const dismissed = include_dismissed === "true" ? "" : "AND js.state != 'dismissed'";
4526
+ const rows = await c.env.JOB_PLATFORM_DB.prepare(
4527
+ `SELECT js.job_id, j.title, j.company, j.location, j.workplace_type,
4528
+ j.salary_min, j.salary_max, j.url, j.posted_date,
4529
+ js.state, js.updated_at, js.variant_slug,
4530
+ js.notes, js.priority, js.follow_up_at, js.contact_name, js.contact_email,
4531
+ ap.status AS application_status,
4532
+ (SELECT MIN(e.occurred_at) FROM job_events e
4533
+ WHERE e.job_id = js.job_id AND e.user_id = js.user_id
4534
+ AND e.kind = 'state' AND e.to_state = 'applied') AS applied_at,
4535
+ (SELECT MAX(e.occurred_at) FROM job_events e
4536
+ WHERE e.job_id = js.job_id AND e.user_id = js.user_id) AS last_event_at
4537
+ FROM job_states js
4538
+ JOIN jobs j ON j.id = js.job_id
4539
+ LEFT JOIN applications ap ON ap.job_id = js.job_id AND ap.user_id = js.user_id
4540
+ WHERE js.user_id = ? ${dismissed}
4541
+ ORDER BY js.updated_at DESC`
4542
+ ).bind(who.userId).all();
4543
+ const items = rows.results.flatMap((r) => {
4544
+ const state = stateOrNull(r.state);
4545
+ if (!state) return [];
4546
+ return [
4469
4547
  {
4470
- title: row.title,
4471
- description: row.description,
4472
- location: row.location ?? null,
4473
- workplace_type: row.workplace_type,
4474
- salary_max: row.salary_max ?? null,
4475
- role_level: roleLevel
4476
- },
4477
- profile
4478
- );
4479
- score = result.score;
4480
- score_breakdown = result.breakdown;
4481
- }
4482
- const userId = await maybeUserId(c);
4483
- let state = null;
4484
- let stateUpdatedAt = null;
4485
- let applyVerified = false;
4486
- if (userId && row.ats && row.slug) {
4487
- const proven = await db.prepare(
4488
- // CROSS JOIN pins the caller's applications as the outer
4489
- // loop. Left to choose, SQLite led with jobs' (ats, slug)
4490
- // index and walked the whole board — 632 rows for toast,
4491
- // 4,151 for anduril — to find usually nothing: ~3M rows a
4492
- // day on 2026-10-15, from the apply runner's 5-minute
4493
- // detail reads. Outer applications reads only the
4494
- // caller's own rows (zero for a service key).
4495
- `SELECT 1 FROM applications a
4496
- CROSS JOIN jobs j ON j.id = a.job_id
4497
- WHERE a.user_id = ? AND j.ats = ? AND j.slug = ?
4498
- AND a.status IN ('filled', 'approved', 'submitted')
4499
- LIMIT 1`
4500
- ).bind(userId, row.ats, row.slug).first();
4501
- applyVerified = proven !== null;
4502
- }
4503
- let applicationStatus = null;
4504
- if (userId) {
4505
- 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),
4507
- db.prepare("SELECT status FROM applications WHERE job_id = ? AND user_id = ?").bind(id, userId)
4508
- ]);
4509
- const stateRow = stateRes.results[0];
4510
- state = asJobStateRead(stateRow?.state);
4511
- stateUpdatedAt = stateRow?.updated_at ?? null;
4512
- const appRow = appRes.results[0];
4513
- applicationStatus = appRow?.status ?? null;
4514
- }
4515
- const job = {
4516
- id: row.id,
4517
- title: row.title,
4518
- company: row.company,
4519
- location: row.location,
4520
- workplace_type: row.workplace_type,
4521
- job_type: row.job_type,
4522
- salary_min: row.salary_min,
4523
- salary_max: row.salary_max,
4524
- source_site: row.source_site,
4525
- url: row.url,
4526
- posted_date: row.posted_date,
4527
- scraped_at: row.scraped_at,
4528
- ats: row.ats ?? null,
4529
- slug: row.slug ?? null,
4530
- slug_source: asSlugSource(row.slug_source ?? null),
4531
- apply_tier: applyTier(
4532
- row.application_url ?? row.url,
4533
- row.ats ?? null
4534
- ),
4535
- apply_verified: applyVerified,
4536
- role_track: asRoleTrack(row.role_track ?? "unknown"),
4537
- role_level: roleLevel,
4538
- description: row.description,
4539
- application_url: row.application_url,
4540
- department: row.department,
4541
- scraper_used: row.scraper_used,
4542
- run_id: row.run_id,
4543
- score,
4544
- score_breakdown,
4545
- state,
4546
- application_status: asApplicationStatus(applicationStatus),
4547
- state_updated_at: stateUpdatedAt
4548
- };
4549
- return c.json({ success: true, data: { job } }, 200);
4548
+ job_id: r.job_id,
4549
+ title: r.title,
4550
+ company: r.company,
4551
+ location: r.location,
4552
+ workplace_type: r.workplace_type,
4553
+ salary_min: r.salary_min,
4554
+ salary_max: r.salary_max,
4555
+ url: r.url,
4556
+ posted_date: r.posted_date,
4557
+ state,
4558
+ updated_at: r.updated_at,
4559
+ tracking: toTracking(r),
4560
+ variant_slug: r.variant_slug,
4561
+ application_status: asApplicationStatus(r.application_status),
4562
+ applied_at: r.applied_at,
4563
+ last_event_at: r.last_event_at
4564
+ }
4565
+ ];
4566
+ });
4567
+ return c.json({ success: true, data: { items } }, 200);
4550
4568
  }
4551
4569
  );
4552
- }
4553
- function registerStateRoutes(app2) {
4554
- app2.put("/jobs/:id/state", gateAuthed);
4555
- app2.delete("/jobs/:id/state", gateAuthed);
4556
4570
  app2.openapi(
4557
4571
  createRoute({
4558
- method: "put",
4559
- path: "/jobs/{id}/state",
4560
- tags: ["Jobs"],
4561
- summary: "Set triage state for a job (per-user, upsert)",
4562
- request: {
4572
+ method: "patch",
4573
+ path: "/jobs/{id}/tracking",
4574
+ tags: ["Pipeline"],
4575
+ summary: "Change what the caller tracks about one application; null clears a field",
4576
+ request: {
4563
4577
  params: z.object({ id: z.string() }),
4564
- body: { content: { "application/json": { schema: SetJobStateSchema } } }
4578
+ query: ownerNameQuery,
4579
+ body: { content: { "application/json": { schema: SetTrackingSchema } } }
4565
4580
  },
4566
4581
  responses: {
4567
4582
  200: {
4568
- description: "State updated",
4569
- content: { "application/json": { schema: JobStateResponseSchema } }
4570
- },
4571
- 403: {
4572
- description: "Forbidden",
4573
- content: { "application/json": { schema: ErrorResponseSchema } }
4583
+ description: "Tracking after the change",
4584
+ content: { "application/json": { schema: TrackingResponseSchema } }
4574
4585
  },
4575
- 404: {
4576
- description: "Job not found",
4577
- content: { "application/json": { schema: ErrorResponseSchema } }
4578
- }
4586
+ 403: FORBIDDEN$1,
4587
+ ...IDENTITY_ERRORS
4579
4588
  }
4580
4589
  }),
4581
4590
  async (c) => {
4582
- const userId = await maybeUserId(c);
4583
- if (!userId) {
4584
- return c.json(
4585
- { success: false, error: "Forbidden", message: "Authentication required" },
4586
- 403
4587
- );
4588
- }
4589
4591
  const { id } = c.req.valid("param");
4592
+ const { ownerName } = c.req.valid("query");
4590
4593
  const body = c.req.valid("json");
4594
+ const who = await effectiveUserId(c, ownerName);
4595
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4591
4596
  const db = c.env.JOB_PLATFORM_DB;
4592
- const exists = await db.prepare(
4593
- `SELECT j.id, js.state AS prev FROM jobs j
4594
- LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
4595
- WHERE j.id = ?`
4596
- ).bind(userId, id).first();
4597
- if (!exists) {
4598
- return c.json(
4599
- { success: false, error: "Not found", message: `Job '${id}' not found` },
4600
- 404
4601
- );
4597
+ const sets = [];
4598
+ const binds = [];
4599
+ for (const col of [
4600
+ "notes",
4601
+ "priority",
4602
+ "follow_up_at",
4603
+ "contact_name",
4604
+ "contact_email"
4605
+ ]) {
4606
+ const v = body[col];
4607
+ if (v === void 0) continue;
4608
+ sets.push(`${col} = ?`);
4609
+ binds.push(typeof v === "string" && v.trim() === "" ? null : v);
4602
4610
  }
4603
4611
  const now = (/* @__PURE__ */ new Date()).toISOString();
4604
- const variantSlug = body.variant_slug ?? null;
4605
- const upsert = db.prepare(
4606
- `INSERT INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug)
4607
- VALUES (?, ?, ?, NULL, ?, ?)
4608
- ON CONFLICT (job_id, user_id) DO UPDATE SET
4609
- state = excluded.state,
4610
- updated_at = excluded.updated_at,
4611
- variant_slug = COALESCE(excluded.variant_slug, job_states.variant_slug)`
4612
- ).bind(id, userId, body.state, now, variantSlug);
4613
- if (exists.prev === body.state) {
4614
- await upsert.run();
4615
- } else {
4616
- await db.batch([
4617
- upsert,
4618
- db.prepare(
4619
- `INSERT INTO job_events
4620
- (job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
4621
- VALUES (?, ?, 'state', ?, ?, NULL, ?, ?)`
4622
- ).bind(id, userId, exists.prev, body.state, now, now)
4623
- ]);
4612
+ const row = await db.prepare(
4613
+ `UPDATE job_states SET ${sets.join(", ")}, updated_at = ?
4614
+ WHERE job_id = ? AND user_id = ?
4615
+ RETURNING ${TRACKING_COLS}`
4616
+ ).bind(...binds, now, id, who.userId).first();
4617
+ if (!row) {
4618
+ const s = await stateOf(db, id, who.userId);
4619
+ return s === "missing" ? c.json(jobNotFound(id), 404) : c.json(notInPipeline(id), 409);
4624
4620
  }
4625
4621
  return c.json(
4626
- {
4627
- success: true,
4628
- data: { job_id: id, state: body.state, updated_at: now }
4629
- },
4622
+ { success: true, data: { job_id: id, tracking: toTracking(row) } },
4630
4623
  200
4631
4624
  );
4632
4625
  }
4633
4626
  );
4627
+ app2.openapi(
4628
+ createRoute({
4629
+ method: "get",
4630
+ path: "/jobs/{id}/events",
4631
+ tags: ["Pipeline"],
4632
+ summary: "One posting's timeline for the caller, oldest first",
4633
+ request: { params: z.object({ id: z.string() }), query: ownerNameQuery },
4634
+ responses: {
4635
+ 200: {
4636
+ description: "Timeline (empty for a posting with none)",
4637
+ content: { "application/json": { schema: JobEventsResponseSchema } }
4638
+ },
4639
+ 403: FORBIDDEN$1,
4640
+ ...IDENTITY_ERRORS
4641
+ }
4642
+ }),
4643
+ async (c) => {
4644
+ const { id } = c.req.valid("param");
4645
+ const { ownerName } = c.req.valid("query");
4646
+ const who = await effectiveUserId(c, ownerName);
4647
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4648
+ const rows = await c.env.JOB_PLATFORM_DB.prepare(
4649
+ `SELECT ${EVENT_COLS} FROM job_events
4650
+ WHERE job_id = ? AND user_id = ?
4651
+ ORDER BY occurred_at, id`
4652
+ ).bind(id, who.userId).all();
4653
+ return c.json({ success: true, data: { events: rows.results.map(toEvent) } }, 200);
4654
+ }
4655
+ );
4656
+ app2.openapi(
4657
+ createRoute({
4658
+ method: "post",
4659
+ path: "/jobs/{id}/events",
4660
+ tags: ["Pipeline"],
4661
+ summary: "Log something that happened on one application",
4662
+ request: {
4663
+ params: z.object({ id: z.string() }),
4664
+ query: ownerNameQuery,
4665
+ body: { content: { "application/json": { schema: AddJobEventSchema } } }
4666
+ },
4667
+ responses: {
4668
+ 201: {
4669
+ description: "Logged",
4670
+ content: { "application/json": { schema: JobEventResponseSchema } }
4671
+ },
4672
+ 403: FORBIDDEN$1,
4673
+ ...IDENTITY_ERRORS
4674
+ }
4675
+ }),
4676
+ async (c) => {
4677
+ const { id } = c.req.valid("param");
4678
+ const { ownerName } = c.req.valid("query");
4679
+ const body = c.req.valid("json");
4680
+ const who = await effectiveUserId(c, ownerName);
4681
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4682
+ const db = c.env.JOB_PLATFORM_DB;
4683
+ const s = await stateOf(db, id, who.userId);
4684
+ if (s === "missing") return c.json(jobNotFound(id), 404);
4685
+ if (s === "untracked") return c.json(notInPipeline(id), 409);
4686
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4687
+ const occurred = body.occurred_at ? new Date(
4688
+ body.occurred_at.length === 10 ? `${body.occurred_at}T00:00:00Z` : body.occurred_at
4689
+ ).toISOString() : now;
4690
+ const row = await db.prepare(
4691
+ `INSERT INTO job_events
4692
+ (job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
4693
+ VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)
4694
+ RETURNING ${EVENT_COLS}`
4695
+ ).bind(id, who.userId, body.kind, body.note, occurred, now).first();
4696
+ if (!row) throw new Error("job_events INSERT … RETURNING returned no row");
4697
+ return c.json({ success: true, data: { event: toEvent(row) } }, 201);
4698
+ }
4699
+ );
4634
4700
  app2.openapi(
4635
4701
  createRoute({
4636
4702
  method: "delete",
4637
- path: "/jobs/{id}/state",
4638
- tags: ["Jobs"],
4639
- summary: 'Clear triage state for a job — returns the row to implicit "new" and drops its timeline',
4703
+ path: "/jobs/{id}/events/{eventId}",
4704
+ tags: ["Pipeline"],
4705
+ summary: "Remove a note/interview/contact/follow-up the caller logged",
4640
4706
  request: {
4641
- params: z.object({ id: z.string() })
4707
+ params: z.object({ id: z.string(), eventId: z.coerce.number().int().positive() }),
4708
+ query: ownerNameQuery
4642
4709
  },
4643
4710
  responses: {
4644
4711
  200: {
4645
- description: "State cleared (or already absent)",
4712
+ description: "Removed (or already absent)",
4646
4713
  content: {
4647
4714
  "application/json": {
4648
4715
  schema: z.object({
4649
4716
  success: z.literal(true),
4650
- data: z.object({ job_id: z.string(), deleted: z.boolean() })
4651
- }).openapi("ClearJobStateResponse")
4717
+ data: z.object({ id: z.number().int(), deleted: z.boolean() })
4718
+ }).openapi("DeleteJobEventResponse")
4652
4719
  }
4653
4720
  }
4654
4721
  },
4655
- 403: {
4656
- description: "Forbidden",
4657
- content: { "application/json": { schema: ErrorResponseSchema } }
4658
- }
4722
+ 403: FORBIDDEN$1,
4723
+ ...IDENTITY_ERRORS
4659
4724
  }
4660
4725
  }),
4661
4726
  async (c) => {
4662
- const userId = await maybeUserId(c);
4663
- if (!userId) {
4664
- return c.json(
4665
- { success: false, error: "Forbidden", message: "Authentication required" },
4666
- 403
4667
- );
4668
- }
4669
- const { id } = c.req.valid("param");
4670
- const db = c.env.JOB_PLATFORM_DB;
4671
- const [result] = await db.batch([
4672
- db.prepare("DELETE FROM job_states WHERE job_id = ? AND user_id = ?").bind(id, userId),
4673
- db.prepare("DELETE FROM job_events WHERE job_id = ? AND user_id = ?").bind(id, userId)
4674
- ]);
4675
- const deleted = (result.meta?.changes ?? 0) > 0;
4676
- return c.json({ success: true, data: { job_id: id, deleted } }, 200);
4727
+ const { id, eventId } = c.req.valid("param");
4728
+ const { ownerName } = c.req.valid("query");
4729
+ const who = await effectiveUserId(c, ownerName);
4730
+ if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
4731
+ const res = await c.env.JOB_PLATFORM_DB.prepare(
4732
+ `DELETE FROM job_events
4733
+ WHERE id = ? AND job_id = ? AND user_id = ? AND kind != 'state'`
4734
+ ).bind(eventId, id, who.userId).run();
4735
+ return c.json(
4736
+ { success: true, data: { id: eventId, deleted: (res.meta?.changes ?? 0) > 0 } },
4737
+ 200
4738
+ );
4677
4739
  }
4678
4740
  );
4679
4741
  }
4680
- function registerFeedbackRoutes(app2) {
4681
- app2.put("/jobs/:id/feedback", gateAuthed);
4682
- app2.delete("/jobs/:id/feedback", gateAuthed);
4742
+ function registerDetailRoute(app2) {
4683
4743
  app2.openapi(
4684
4744
  createRoute({
4685
- method: "put",
4686
- path: "/jobs/{id}/feedback",
4745
+ method: "get",
4746
+ path: "/jobs/{id}",
4687
4747
  tags: ["Jobs"],
4688
- summary: "Up/downvote a posting with an axis-aligned reason (per-user, upsert)",
4748
+ summary: "Get full job detail",
4689
4749
  request: {
4690
4750
  params: z.object({ id: z.string() }),
4691
- body: { content: { "application/json": { schema: SetJobFeedbackSchema } } }
4751
+ query: z.object({
4752
+ profile_id: z.string().optional().openapi({ description: "Include score for this profile" })
4753
+ })
4692
4754
  },
4693
4755
  responses: {
4694
4756
  200: {
4695
- description: "Feedback recorded",
4696
- content: { "application/json": { schema: JobFeedbackResponseSchema } }
4697
- },
4698
- 403: {
4699
- description: "Forbidden",
4700
- content: { "application/json": { schema: ErrorResponseSchema } }
4757
+ description: "Job detail",
4758
+ content: { "application/json": { schema: JobResponseSchema } }
4701
4759
  },
4702
4760
  404: {
4703
- description: "Job not found",
4761
+ description: "Not found",
4704
4762
  content: { "application/json": { schema: ErrorResponseSchema } }
4705
4763
  }
4706
4764
  }
4707
4765
  }),
4708
4766
  async (c) => {
4709
- const userId = await maybeUserId(c);
4710
- if (!userId) {
4711
- return c.json(
4712
- { success: false, error: "Forbidden", message: "Authentication required" },
4713
- 403
4714
- );
4715
- }
4716
4767
  const { id } = c.req.valid("param");
4717
- const body = c.req.valid("json");
4768
+ const { profile_id } = c.req.valid("query");
4718
4769
  const db = c.env.JOB_PLATFORM_DB;
4719
- const exists = await db.prepare("SELECT id FROM jobs WHERE id = ?").bind(id).first();
4720
- if (!exists) {
4770
+ const row = await db.prepare("SELECT * FROM jobs WHERE id = ?").bind(id).first();
4771
+ if (!row) {
4721
4772
  return c.json(
4722
4773
  { success: false, error: "Not found", message: `Job '${id}' not found` },
4723
4774
  404
4724
4775
  );
4725
4776
  }
4726
- const now = (/* @__PURE__ */ new Date()).toISOString();
4727
- const reasonCsv = body.reasons.length ? body.reasons.join(",") : null;
4728
- await db.prepare(
4729
- `INSERT INTO job_feedback (id, user_id, job_id, vote, reason, created_at, processed_at)
4730
- VALUES (?, ?, ?, ?, ?, ?, NULL)
4731
- ON CONFLICT (user_id, job_id) DO UPDATE SET
4732
- vote = excluded.vote,
4733
- reason = excluded.reason,
4734
- created_at = excluded.created_at,
4735
- processed_at = NULL`
4736
- ).bind(crypto.randomUUID(), userId, id, body.vote, reasonCsv, now).run();
4737
- return c.json(
4738
- {
4739
- success: true,
4740
- data: { job_id: id, vote: body.vote, reasons: body.reasons, updated_at: now }
4777
+ let score = 0;
4778
+ let score_breakdown = { ...ZERO_BREAKDOWN };
4779
+ const roleLevel = asRoleLevel(row.role_level ?? null);
4780
+ if (profile_id) {
4781
+ const profile = await loadScorableProfile(db, profile_id);
4782
+ const result = scoreJob(
4783
+ {
4784
+ title: row.title,
4785
+ description: row.description,
4786
+ location: row.location ?? null,
4787
+ workplace_type: row.workplace_type,
4788
+ salary_max: row.salary_max ?? null,
4789
+ role_level: roleLevel
4790
+ },
4791
+ profile
4792
+ );
4793
+ score = result.score;
4794
+ score_breakdown = result.breakdown;
4795
+ }
4796
+ const userId = await maybeUserId(c);
4797
+ let state = null;
4798
+ let stateUpdatedAt = null;
4799
+ let tracking = null;
4800
+ let applyVerified = false;
4801
+ if (userId && row.ats && row.slug) {
4802
+ const proven = await db.prepare(
4803
+ // CROSS JOIN pins the caller's applications as the outer
4804
+ // loop. Left to choose, SQLite led with jobs' (ats, slug)
4805
+ // index and walked the whole board — 632 rows for toast,
4806
+ // 4,151 for anduril — to find usually nothing: ~3M rows a
4807
+ // day on 2026-10-15, from the apply runner's 5-minute
4808
+ // detail reads. Outer applications reads only the
4809
+ // caller's own rows (zero for a service key).
4810
+ `SELECT 1 FROM applications a
4811
+ CROSS JOIN jobs j ON j.id = a.job_id
4812
+ WHERE a.user_id = ? AND j.ats = ? AND j.slug = ?
4813
+ AND a.status IN ('filled', 'approved', 'submitted')
4814
+ LIMIT 1`
4815
+ ).bind(userId, row.ats, row.slug).first();
4816
+ applyVerified = proven !== null;
4817
+ }
4818
+ let applicationStatus = null;
4819
+ if (userId) {
4820
+ const [stateRes, appRes] = await db.batch([
4821
+ db.prepare(
4822
+ `SELECT state, updated_at, ${TRACKING_COLS} FROM job_states WHERE job_id = ? AND user_id = ?`
4823
+ ).bind(id, userId),
4824
+ db.prepare("SELECT status FROM applications WHERE job_id = ? AND user_id = ?").bind(id, userId)
4825
+ ]);
4826
+ const stateRow = stateRes.results[0];
4827
+ state = asJobStateRead(stateRow?.state);
4828
+ stateUpdatedAt = stateRow?.updated_at ?? null;
4829
+ tracking = stateRow ? toTracking(stateRow) : null;
4830
+ const appRow = appRes.results[0];
4831
+ applicationStatus = appRow?.status ?? null;
4832
+ }
4833
+ const job = {
4834
+ id: row.id,
4835
+ title: row.title,
4836
+ company: row.company,
4837
+ location: row.location,
4838
+ workplace_type: row.workplace_type,
4839
+ job_type: row.job_type,
4840
+ salary_min: row.salary_min,
4841
+ salary_max: row.salary_max,
4842
+ source_site: row.source_site,
4843
+ url: row.url,
4844
+ posted_date: row.posted_date,
4845
+ scraped_at: row.scraped_at,
4846
+ ats: row.ats ?? null,
4847
+ slug: row.slug ?? null,
4848
+ slug_source: asSlugSource(row.slug_source ?? null),
4849
+ apply_tier: applyTier(
4850
+ row.application_url ?? row.url,
4851
+ row.ats ?? null
4852
+ ),
4853
+ apply_verified: applyVerified,
4854
+ role_track: asRoleTrack(row.role_track ?? "unknown"),
4855
+ role_level: roleLevel,
4856
+ description: row.description,
4857
+ application_url: row.application_url,
4858
+ department: row.department,
4859
+ scraper_used: row.scraper_used,
4860
+ run_id: row.run_id,
4861
+ score,
4862
+ score_breakdown,
4863
+ state,
4864
+ application_status: asApplicationStatus(applicationStatus),
4865
+ state_updated_at: stateUpdatedAt,
4866
+ tracking
4867
+ };
4868
+ return c.json({ success: true, data: { job } }, 200);
4869
+ }
4870
+ );
4871
+ }
4872
+ function registerStateRoutes(app2) {
4873
+ app2.put("/jobs/:id/state", gateAuthed);
4874
+ app2.delete("/jobs/:id/state", gateAuthed);
4875
+ app2.openapi(
4876
+ createRoute({
4877
+ method: "put",
4878
+ path: "/jobs/{id}/state",
4879
+ tags: ["Jobs"],
4880
+ summary: "Set triage state for a job (per-user, upsert)",
4881
+ request: {
4882
+ params: z.object({ id: z.string() }),
4883
+ body: { content: { "application/json": { schema: SetJobStateSchema } } }
4884
+ },
4885
+ responses: {
4886
+ 200: {
4887
+ description: "State updated",
4888
+ content: { "application/json": { schema: JobStateResponseSchema } }
4889
+ },
4890
+ 403: {
4891
+ description: "Forbidden",
4892
+ content: { "application/json": { schema: ErrorResponseSchema } }
4893
+ },
4894
+ 404: {
4895
+ description: "Job not found",
4896
+ content: { "application/json": { schema: ErrorResponseSchema } }
4897
+ }
4898
+ }
4899
+ }),
4900
+ async (c) => {
4901
+ const userId = await maybeUserId(c);
4902
+ if (!userId) {
4903
+ return c.json(
4904
+ { success: false, error: "Forbidden", message: "Authentication required" },
4905
+ 403
4906
+ );
4907
+ }
4908
+ const { id } = c.req.valid("param");
4909
+ const body = c.req.valid("json");
4910
+ const db = c.env.JOB_PLATFORM_DB;
4911
+ const exists = await db.prepare(
4912
+ `SELECT j.id, js.state AS prev FROM jobs j
4913
+ LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
4914
+ WHERE j.id = ?`
4915
+ ).bind(userId, id).first();
4916
+ if (!exists) {
4917
+ return c.json(
4918
+ { success: false, error: "Not found", message: `Job '${id}' not found` },
4919
+ 404
4920
+ );
4921
+ }
4922
+ const now = (/* @__PURE__ */ new Date()).toISOString();
4923
+ const variantSlug = body.variant_slug ?? null;
4924
+ const upsert = db.prepare(
4925
+ `INSERT INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug)
4926
+ VALUES (?, ?, ?, NULL, ?, ?)
4927
+ ON CONFLICT (job_id, user_id) DO UPDATE SET
4928
+ state = excluded.state,
4929
+ updated_at = excluded.updated_at,
4930
+ variant_slug = COALESCE(excluded.variant_slug, job_states.variant_slug)`
4931
+ ).bind(id, userId, body.state, now, variantSlug);
4932
+ if (exists.prev === body.state) {
4933
+ await upsert.run();
4934
+ } else {
4935
+ await db.batch([
4936
+ upsert,
4937
+ db.prepare(
4938
+ `INSERT INTO job_events
4939
+ (job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
4940
+ VALUES (?, ?, 'state', ?, ?, NULL, ?, ?)`
4941
+ ).bind(id, userId, exists.prev, body.state, now, now)
4942
+ ]);
4943
+ }
4944
+ return c.json(
4945
+ {
4946
+ success: true,
4947
+ data: { job_id: id, state: body.state, updated_at: now }
4741
4948
  },
4742
4949
  200
4743
4950
  );
@@ -4746,18 +4953,74 @@ function registerFeedbackRoutes(app2) {
4746
4953
  app2.openapi(
4747
4954
  createRoute({
4748
4955
  method: "delete",
4956
+ path: "/jobs/{id}/state",
4957
+ tags: ["Jobs"],
4958
+ summary: 'Clear triage state for a job — returns the row to implicit "new" and drops its timeline',
4959
+ request: {
4960
+ params: z.object({ id: z.string() })
4961
+ },
4962
+ responses: {
4963
+ 200: {
4964
+ description: "State cleared (or already absent)",
4965
+ content: {
4966
+ "application/json": {
4967
+ schema: z.object({
4968
+ success: z.literal(true),
4969
+ data: z.object({ job_id: z.string(), deleted: z.boolean() })
4970
+ }).openapi("ClearJobStateResponse")
4971
+ }
4972
+ }
4973
+ },
4974
+ 403: {
4975
+ description: "Forbidden",
4976
+ content: { "application/json": { schema: ErrorResponseSchema } }
4977
+ }
4978
+ }
4979
+ }),
4980
+ async (c) => {
4981
+ const userId = await maybeUserId(c);
4982
+ if (!userId) {
4983
+ return c.json(
4984
+ { success: false, error: "Forbidden", message: "Authentication required" },
4985
+ 403
4986
+ );
4987
+ }
4988
+ const { id } = c.req.valid("param");
4989
+ const db = c.env.JOB_PLATFORM_DB;
4990
+ const [result] = await db.batch([
4991
+ db.prepare("DELETE FROM job_states WHERE job_id = ? AND user_id = ?").bind(id, userId),
4992
+ db.prepare("DELETE FROM job_events WHERE job_id = ? AND user_id = ?").bind(id, userId)
4993
+ ]);
4994
+ const deleted = (result.meta?.changes ?? 0) > 0;
4995
+ return c.json({ success: true, data: { job_id: id, deleted } }, 200);
4996
+ }
4997
+ );
4998
+ }
4999
+ function registerFeedbackRoutes(app2) {
5000
+ app2.put("/jobs/:id/feedback", gateAuthed);
5001
+ app2.delete("/jobs/:id/feedback", gateAuthed);
5002
+ app2.openapi(
5003
+ createRoute({
5004
+ method: "put",
4749
5005
  path: "/jobs/{id}/feedback",
4750
5006
  tags: ["Jobs"],
4751
- summary: "Clear the vote on a posting — idempotent",
4752
- request: { params: z.object({ id: z.string() }) },
5007
+ summary: "Up/downvote a posting with an axis-aligned reason (per-user, upsert)",
5008
+ request: {
5009
+ params: z.object({ id: z.string() }),
5010
+ body: { content: { "application/json": { schema: SetJobFeedbackSchema } } }
5011
+ },
4753
5012
  responses: {
4754
5013
  200: {
4755
- description: "Feedback cleared",
5014
+ description: "Feedback recorded",
4756
5015
  content: { "application/json": { schema: JobFeedbackResponseSchema } }
4757
5016
  },
4758
5017
  403: {
4759
5018
  description: "Forbidden",
4760
5019
  content: { "application/json": { schema: ErrorResponseSchema } }
5020
+ },
5021
+ 404: {
5022
+ description: "Job not found",
5023
+ content: { "application/json": { schema: ErrorResponseSchema } }
4761
5024
  }
4762
5025
  }
4763
5026
  }),
@@ -4770,22 +5033,78 @@ function registerFeedbackRoutes(app2) {
4770
5033
  );
4771
5034
  }
4772
5035
  const { id } = c.req.valid("param");
4773
- await c.env.JOB_PLATFORM_DB.prepare(
4774
- "DELETE FROM job_feedback WHERE user_id = ? AND job_id = ?"
4775
- ).bind(userId, id).run();
5036
+ const body = c.req.valid("json");
5037
+ const db = c.env.JOB_PLATFORM_DB;
5038
+ const exists = await db.prepare("SELECT id FROM jobs WHERE id = ?").bind(id).first();
5039
+ if (!exists) {
5040
+ return c.json(
5041
+ { success: false, error: "Not found", message: `Job '${id}' not found` },
5042
+ 404
5043
+ );
5044
+ }
5045
+ const now = (/* @__PURE__ */ new Date()).toISOString();
5046
+ const reasonCsv = body.reasons.length ? body.reasons.join(",") : null;
5047
+ await db.prepare(
5048
+ `INSERT INTO job_feedback (id, user_id, job_id, vote, reason, created_at, processed_at)
5049
+ VALUES (?, ?, ?, ?, ?, ?, NULL)
5050
+ ON CONFLICT (user_id, job_id) DO UPDATE SET
5051
+ vote = excluded.vote,
5052
+ reason = excluded.reason,
5053
+ created_at = excluded.created_at,
5054
+ processed_at = NULL`
5055
+ ).bind(crypto.randomUUID(), userId, id, body.vote, reasonCsv, now).run();
4776
5056
  return c.json(
4777
5057
  {
4778
5058
  success: true,
4779
- data: { job_id: id, vote: null, reasons: [], updated_at: (/* @__PURE__ */ new Date()).toISOString() }
5059
+ data: { job_id: id, vote: body.vote, reasons: body.reasons, updated_at: now }
5060
+ },
5061
+ 200
5062
+ );
5063
+ }
5064
+ );
5065
+ app2.openapi(
5066
+ createRoute({
5067
+ method: "delete",
5068
+ path: "/jobs/{id}/feedback",
5069
+ tags: ["Jobs"],
5070
+ summary: "Clear the vote on a posting — idempotent",
5071
+ request: { params: z.object({ id: z.string() }) },
5072
+ responses: {
5073
+ 200: {
5074
+ description: "Feedback cleared",
5075
+ content: { "application/json": { schema: JobFeedbackResponseSchema } }
5076
+ },
5077
+ 403: {
5078
+ description: "Forbidden",
5079
+ content: { "application/json": { schema: ErrorResponseSchema } }
5080
+ }
5081
+ }
5082
+ }),
5083
+ async (c) => {
5084
+ const userId = await maybeUserId(c);
5085
+ if (!userId) {
5086
+ return c.json(
5087
+ { success: false, error: "Forbidden", message: "Authentication required" },
5088
+ 403
5089
+ );
5090
+ }
5091
+ const { id } = c.req.valid("param");
5092
+ await c.env.JOB_PLATFORM_DB.prepare(
5093
+ "DELETE FROM job_feedback WHERE user_id = ? AND job_id = ?"
5094
+ ).bind(userId, id).run();
5095
+ return c.json(
5096
+ {
5097
+ success: true,
5098
+ data: { job_id: id, vote: null, reasons: [], updated_at: (/* @__PURE__ */ new Date()).toISOString() }
4780
5099
  },
4781
5100
  200
4782
5101
  );
4783
5102
  }
4784
5103
  );
4785
5104
  }
4786
- async function loadTailoringFields(db, id) {
4787
- return db.prepare("SELECT title, company, description, location, workplace_type FROM jobs WHERE id = ?").bind(id).first();
4788
- }
5105
+ async function loadTailoringFields(db, id) {
5106
+ return db.prepare("SELECT title, company, description, location, workplace_type FROM jobs WHERE id = ?").bind(id).first();
5107
+ }
4789
5108
  function resumeViaEdge(env, path, init) {
4790
5109
  if (!env.EDGE) throw new Error("EDGE service binding not configured");
4791
5110
  const key = env.HADOKU_SERVICE_KEY?.trim();
@@ -5797,7 +6116,7 @@ async function delistedReason(db, jobId) {
5797
6116
  if (newest - seen <= DELISTED_LAG_MS) return null;
5798
6117
  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
6118
  }
5800
- const FORBIDDEN$1 = {
6119
+ const FORBIDDEN = {
5801
6120
  success: false,
5802
6121
  error: "Forbidden",
5803
6122
  message: "Authentication required"
@@ -6096,7 +6415,7 @@ function registerApplicationRoutes(app2) {
6096
6415
  }),
6097
6416
  async (c) => {
6098
6417
  const userId = await maybeUserId(c);
6099
- if (!userId) return c.json(FORBIDDEN$1, 403);
6418
+ if (!userId) return c.json(FORBIDDEN, 403);
6100
6419
  const { id } = c.req.valid("param");
6101
6420
  const db = c.env.JOB_PLATFORM_DB;
6102
6421
  const existing = await db.prepare("SELECT * FROM applications WHERE id = ? AND user_id = ?").bind(id, userId).first();
@@ -6340,318 +6659,6 @@ function registerFillRoutes(app2) {
6340
6659
  );
6341
6660
  });
6342
6661
  }
6343
- const IDENTITY_ERRORS = {
6344
- 404: {
6345
- description: "No such owner name, or no such job",
6346
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6347
- },
6348
- 409: {
6349
- description: "That owner name has never signed in, or the posting is not in the pipeline",
6350
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6351
- },
6352
- 503: {
6353
- description: "Identity could not be resolved right now — retry",
6354
- content: { "application/json": { schema: IdentityErrorResponseSchema } }
6355
- }
6356
- };
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
6662
  const ERRORS$1 = {
6656
6663
  403: {
6657
6664
  description: "Forbidden",