@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/clients/scraper.d.ts +15 -0
- package/dist/clients/scraper.d.ts.map +1 -1
- package/dist/index.js +632 -426
- package/dist/ingestWrite.d.ts +41 -0
- package/dist/ingestWrite.d.ts.map +1 -0
- package/dist/routes/ingest.d.ts.map +1 -1
- package/dist/routes/jobs/detail.d.ts.map +1 -1
- package/dist/routes/jobs/importUrl.d.ts +3 -0
- package/dist/routes/jobs/importUrl.d.ts.map +1 -0
- package/dist/routes/jobs/index.d.ts.map +1 -1
- package/dist/routes/jobs/pipeline.d.ts +15 -0
- package/dist/routes/jobs/pipeline.d.ts.map +1 -1
- package/dist/schemas.d.ts +24 -8
- package/dist/schemas.d.ts.map +1 -1
- package/package.json +2 -2
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
|
-
|
|
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: "/
|
|
4433
|
-
tags: ["
|
|
4434
|
-
summary: "
|
|
4531
|
+
path: "/pipeline",
|
|
4532
|
+
tags: ["Pipeline"],
|
|
4533
|
+
summary: "Every posting in the caller's pipeline, most recently changed first",
|
|
4435
4534
|
request: {
|
|
4436
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
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: "
|
|
4444
|
-
content: { "application/json": { schema:
|
|
4543
|
+
description: "Pipeline",
|
|
4544
|
+
content: { "application/json": { schema: PipelineResponseSchema } }
|
|
4445
4545
|
},
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
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 {
|
|
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
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
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;
|