@hadoku/jobplatform-worker 2.32.35 → 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/geo.d.ts +43 -0
- package/dist/geo.d.ts.map +1 -0
- package/dist/index.js +1602 -56
- package/dist/routes/account.d.ts +3 -0
- package/dist/routes/account.d.ts.map +1 -1
- package/dist/routes/ingest.d.ts.map +1 -1
- package/dist/routes/jobs/blocked.d.ts +3 -0
- package/dist/routes/jobs/blocked.d.ts.map +1 -0
- package/dist/routes/jobs/detail.d.ts.map +1 -1
- package/dist/routes/jobs/feed.d.ts.map +1 -1
- package/dist/routes/jobs/index.d.ts.map +1 -1
- package/dist/routes/jobs/insights.d.ts +5 -0
- package/dist/routes/jobs/insights.d.ts.map +1 -0
- package/dist/routes/jobs/locations.d.ts +3 -0
- package/dist/routes/jobs/locations.d.ts.map +1 -0
- package/dist/routes/jobs/owner.d.ts.map +1 -1
- package/dist/routes/jobs/pipeline.d.ts +18 -0
- package/dist/routes/jobs/pipeline.d.ts.map +1 -0
- package/dist/routes/jobs/state.d.ts +3 -0
- package/dist/routes/jobs/state.d.ts.map +1 -1
- package/dist/routes/jobs/tailoring.d.ts.map +1 -1
- package/dist/schemas.d.ts +383 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/skills.d.ts +23 -0
- package/dist/skills.d.ts.map +1 -0
- package/migrations/0028_pipeline.sql +51 -0
- package/migrations/0029_blocked_companies.sql +18 -0
- package/migrations/0030_jobs_geo_keys.sql +13 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -216,10 +216,10 @@ async function postServiceEvent(env, ev) {
|
|
|
216
216
|
});
|
|
217
217
|
if (!res.ok) throw new Error(`monitoring-api answered ${res.status}`);
|
|
218
218
|
}
|
|
219
|
-
function runAfterResponse(c,
|
|
219
|
+
function runAfterResponse(c, label2, work) {
|
|
220
220
|
const guarded = work.catch((error) => {
|
|
221
221
|
logger.error("background work failed", {
|
|
222
|
-
task:
|
|
222
|
+
task: label2,
|
|
223
223
|
error: error instanceof Error ? error.message : String(error)
|
|
224
224
|
});
|
|
225
225
|
});
|
|
@@ -231,7 +231,7 @@ function runAfterResponse(c, label, work) {
|
|
|
231
231
|
}
|
|
232
232
|
if (!ctx) {
|
|
233
233
|
logger.error("no ExecutionContext — background work will not survive the response", {
|
|
234
|
-
task:
|
|
234
|
+
task: label2,
|
|
235
235
|
hint: "the host worker must forward ctx: fetch(request, env, ctx) -> createFetchHandler(env)(request, ctx)"
|
|
236
236
|
});
|
|
237
237
|
return;
|
|
@@ -478,8 +478,20 @@ const ScoreBreakdownSchema = z.object({
|
|
|
478
478
|
discipline_factor: z.number()
|
|
479
479
|
}).openapi("ScoreBreakdown");
|
|
480
480
|
const RoleTrackSchema = z.enum(["ic", "manager", "unknown"]).openapi("RoleTrack");
|
|
481
|
-
const
|
|
482
|
-
|
|
481
|
+
const JOB_STATES = [
|
|
482
|
+
"interested",
|
|
483
|
+
"dismissed",
|
|
484
|
+
"saved",
|
|
485
|
+
"applied",
|
|
486
|
+
"interviewing",
|
|
487
|
+
"offered",
|
|
488
|
+
"rejected"
|
|
489
|
+
];
|
|
490
|
+
const JobStateSchema = z.enum(JOB_STATES).openapi("JobState");
|
|
491
|
+
const JobStateReadSchema = z.enum(["new", ...JOB_STATES]).openapi("JobStateRead");
|
|
492
|
+
function asJobStateRead(v) {
|
|
493
|
+
return v && JOB_STATES.includes(v) ? v : "new";
|
|
494
|
+
}
|
|
483
495
|
const ApplicationStatusSchema = z.enum(["queued", "filled", "approved", "submitted", "needs_manual", "failed", "job_closed"]).openapi("ApplicationStatus");
|
|
484
496
|
const JobSummarySchema = z.object({
|
|
485
497
|
id: z.string(),
|
|
@@ -520,6 +532,14 @@ const JobSummarySchema = z.object({
|
|
|
520
532
|
vote: z.union([z.literal(1), z.literal(-1)]).nullable().optional(),
|
|
521
533
|
vote_reasons: z.array(z.string()).optional()
|
|
522
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");
|
|
523
543
|
const JobDetailSchema = JobSummarySchema.extend({
|
|
524
544
|
/**
|
|
525
545
|
* Whether the form runner has an adapter for this posting. A PREDICTION
|
|
@@ -550,7 +570,9 @@ const JobDetailSchema = JobSummarySchema.extend({
|
|
|
550
570
|
department: z.string().nullable(),
|
|
551
571
|
scraper_used: z.string().nullable(),
|
|
552
572
|
run_id: z.string().nullable(),
|
|
553
|
-
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()
|
|
554
576
|
}).openapi("JobDetail");
|
|
555
577
|
const FEEDBACK_REASONS = [
|
|
556
578
|
// downvote-flavored
|
|
@@ -958,6 +980,114 @@ const AddProfileCompanyResponseSchema = S(
|
|
|
958
980
|
const DeleteCompanyResponseSchema = S(
|
|
959
981
|
z.object({ deleted: z.literal(true), id: z.string() })
|
|
960
982
|
).openapi("DeleteCompanyResponse");
|
|
983
|
+
const PERSON_EVENT_KINDS = ["note", "interview", "contact", "follow_up"];
|
|
984
|
+
const JobEventKindSchema = z.enum(["state", ...PERSON_EVENT_KINDS]).openapi("JobEventKind");
|
|
985
|
+
const JobEventSchema = z.object({
|
|
986
|
+
id: z.number().int(),
|
|
987
|
+
job_id: z.string(),
|
|
988
|
+
kind: JobEventKindSchema,
|
|
989
|
+
from_state: JobStateSchema.nullable(),
|
|
990
|
+
to_state: JobStateSchema.nullable(),
|
|
991
|
+
note: z.string().nullable(),
|
|
992
|
+
occurred_at: z.string(),
|
|
993
|
+
created_at: z.string()
|
|
994
|
+
}).openapi("JobEvent");
|
|
995
|
+
const JobEventsResponseSchema = S(z.object({ events: z.array(JobEventSchema) })).openapi(
|
|
996
|
+
"JobEventsResponse"
|
|
997
|
+
);
|
|
998
|
+
const JobEventResponseSchema = S(z.object({ event: JobEventSchema })).openapi(
|
|
999
|
+
"JobEventResponse"
|
|
1000
|
+
);
|
|
1001
|
+
const NOTE_MAX = 5e3;
|
|
1002
|
+
const WhenSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/, {
|
|
1003
|
+
message: "a date (YYYY-MM-DD) or an ISO timestamp"
|
|
1004
|
+
});
|
|
1005
|
+
const AddJobEventSchema = z.object({
|
|
1006
|
+
kind: z.enum(PERSON_EVENT_KINDS),
|
|
1007
|
+
note: z.string().trim().min(1).max(NOTE_MAX),
|
|
1008
|
+
/** When it happened; defaults to now. */
|
|
1009
|
+
occurred_at: WhenSchema.optional()
|
|
1010
|
+
}).openapi("AddJobEvent");
|
|
1011
|
+
const SetTrackingSchema = z.object({
|
|
1012
|
+
notes: z.string().max(NOTE_MAX).nullable().optional(),
|
|
1013
|
+
priority: PrioritySchema.nullable().optional(),
|
|
1014
|
+
follow_up_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: "a date, YYYY-MM-DD" }).nullable().optional(),
|
|
1015
|
+
contact_name: z.string().trim().max(200).nullable().optional(),
|
|
1016
|
+
contact_email: z.string().trim().email().max(320).nullable().optional()
|
|
1017
|
+
}).refine((o) => Object.values(o).some((v) => v !== void 0), {
|
|
1018
|
+
message: "name at least one field to change"
|
|
1019
|
+
}).openapi("SetTracking");
|
|
1020
|
+
const TrackingResponseSchema = S(
|
|
1021
|
+
z.object({ job_id: z.string(), tracking: TrackingSchema })
|
|
1022
|
+
).openapi("TrackingResponse");
|
|
1023
|
+
const PipelineItemSchema = z.object({
|
|
1024
|
+
job_id: z.string(),
|
|
1025
|
+
title: z.string(),
|
|
1026
|
+
company: z.string(),
|
|
1027
|
+
location: z.string(),
|
|
1028
|
+
workplace_type: z.string(),
|
|
1029
|
+
salary_min: z.number().nullable(),
|
|
1030
|
+
salary_max: z.number().nullable(),
|
|
1031
|
+
url: z.string(),
|
|
1032
|
+
posted_date: z.string().nullable(),
|
|
1033
|
+
state: JobStateSchema,
|
|
1034
|
+
updated_at: z.string(),
|
|
1035
|
+
tracking: TrackingSchema,
|
|
1036
|
+
variant_slug: z.string().nullable(),
|
|
1037
|
+
/** The apply queue's view of the same posting, when it has been queued. */
|
|
1038
|
+
application_status: ApplicationStatusSchema.nullable(),
|
|
1039
|
+
/** First time it moved to 'applied', from the timeline. */
|
|
1040
|
+
applied_at: z.string().nullable(),
|
|
1041
|
+
/** Newest timeline row, of any kind. */
|
|
1042
|
+
last_event_at: z.string().nullable()
|
|
1043
|
+
}).openapi("PipelineItem");
|
|
1044
|
+
const PipelineResponseSchema = S(z.object({ items: z.array(PipelineItemSchema) })).openapi(
|
|
1045
|
+
"PipelineResponse"
|
|
1046
|
+
);
|
|
1047
|
+
const BlockedCompanySchema = z.object({ company: z.string(), company_key: z.string(), created_at: z.string() }).openapi("BlockedCompany");
|
|
1048
|
+
const BlockedCompaniesResponseSchema = S(
|
|
1049
|
+
z.object({ companies: z.array(BlockedCompanySchema) })
|
|
1050
|
+
).openapi("BlockedCompaniesResponse");
|
|
1051
|
+
const BlockCompanySchema = z.object({ company: z.string().trim().min(1).max(200) }).openapi("BlockCompany");
|
|
1052
|
+
const BlockedCompanyResponseSchema = S(z.object({ company: BlockedCompanySchema })).openapi(
|
|
1053
|
+
"BlockedCompanyResponse"
|
|
1054
|
+
);
|
|
1055
|
+
const UnblockCompanyResponseSchema = S(
|
|
1056
|
+
z.object({ company_key: z.string(), deleted: z.boolean() })
|
|
1057
|
+
).openapi("UnblockCompanyResponse");
|
|
1058
|
+
const InsightsSchema = z.object({
|
|
1059
|
+
/** Postings in each state right now. */
|
|
1060
|
+
states: z.record(z.string(), z.number().int()),
|
|
1061
|
+
/** Postings that have EVER reached each stage, from the timeline, the apply queue and the state. */
|
|
1062
|
+
reached: z.object({
|
|
1063
|
+
applied: z.number().int(),
|
|
1064
|
+
responded: z.number().int(),
|
|
1065
|
+
interviewed: z.number().int(),
|
|
1066
|
+
offered: z.number().int()
|
|
1067
|
+
}),
|
|
1068
|
+
/** Shares of `reached.applied`; null until something has been applied to. */
|
|
1069
|
+
rates: z.object({
|
|
1070
|
+
response: z.number().nullable(),
|
|
1071
|
+
interview: z.number().nullable(),
|
|
1072
|
+
offer: z.number().nullable()
|
|
1073
|
+
}),
|
|
1074
|
+
/** Applications per ISO week (Monday, UTC), oldest first, the last 12 weeks, zeros included. */
|
|
1075
|
+
weekly_applied: z.array(z.object({ week: z.string(), count: z.number().int() })),
|
|
1076
|
+
/** What the applied-to postings are, by workplace type. */
|
|
1077
|
+
workplace: z.array(z.object({ workplace_type: z.string(), count: z.number().int() })),
|
|
1078
|
+
/** Most-asked skills across the pipeline's postings (dismissed excluded). */
|
|
1079
|
+
skills: z.array(
|
|
1080
|
+
z.object({
|
|
1081
|
+
skill: z.string(),
|
|
1082
|
+
postings: z.number().int(),
|
|
1083
|
+
/** Named in the stack of any of the caller's scoring profiles. */
|
|
1084
|
+
in_stack: z.boolean()
|
|
1085
|
+
})
|
|
1086
|
+
),
|
|
1087
|
+
/** How many postings the skill counts were taken over. */
|
|
1088
|
+
skill_postings: z.number().int()
|
|
1089
|
+
}).openapi("Insights");
|
|
1090
|
+
const InsightsResponseSchema = S(InsightsSchema).openapi("InsightsResponse");
|
|
961
1091
|
const app$5 = new OpenAPIHono();
|
|
962
1092
|
const healthRoute = createRoute({
|
|
963
1093
|
method: "get",
|
|
@@ -1466,7 +1596,7 @@ async function effectiveUserId(c, ownerName) {
|
|
|
1466
1596
|
c.env.HADOKU_SERVICE_KEY ?? "",
|
|
1467
1597
|
named,
|
|
1468
1598
|
Date.now(),
|
|
1469
|
-
(
|
|
1599
|
+
(label2, work) => runAfterResponse(c, label2, work)
|
|
1470
1600
|
);
|
|
1471
1601
|
if (isIdentityError(resolved)) {
|
|
1472
1602
|
const status = [404, 409, 503].includes(resolved.status) ? resolved.status : 503;
|
|
@@ -1526,13 +1656,492 @@ function parseAtsSlug(url) {
|
|
|
1526
1656
|
if (url.includes("gh_jid=")) {
|
|
1527
1657
|
try {
|
|
1528
1658
|
const host = new URL(url).hostname;
|
|
1529
|
-
const
|
|
1530
|
-
if (
|
|
1659
|
+
const slug2 = slugFromHost(host);
|
|
1660
|
+
if (slug2) return { ats: "greenhouse", slug: slug2 };
|
|
1531
1661
|
} catch {
|
|
1532
1662
|
}
|
|
1533
1663
|
}
|
|
1534
1664
|
return { ats: null, slug: null };
|
|
1535
1665
|
}
|
|
1666
|
+
const US = [
|
|
1667
|
+
["arlington", "va", 38.8799, -77.1068],
|
|
1668
|
+
["austin", "tx", 30.2672, -97.7431],
|
|
1669
|
+
["round rock", "tx", 30.5083, -97.6789],
|
|
1670
|
+
["cedar park", "tx", 30.5052, -97.8203],
|
|
1671
|
+
["georgetown", "tx", 30.6333, -97.6778],
|
|
1672
|
+
["san marcos", "tx", 29.8833, -97.9414],
|
|
1673
|
+
["dallas", "tx", 32.7767, -96.797],
|
|
1674
|
+
["fort worth", "tx", 32.7555, -97.3308],
|
|
1675
|
+
["plano", "tx", 33.0198, -96.6989],
|
|
1676
|
+
["irving", "tx", 32.814, -96.9489],
|
|
1677
|
+
["frisco", "tx", 33.1507, -96.8236],
|
|
1678
|
+
["richardson", "tx", 32.9483, -96.7299],
|
|
1679
|
+
["arlington", "tx", 32.7357, -97.1081],
|
|
1680
|
+
["houston", "tx", 29.7604, -95.3698],
|
|
1681
|
+
["the woodlands", "tx", 30.1588, -95.4613],
|
|
1682
|
+
["sugar land", "tx", 29.6197, -95.6349],
|
|
1683
|
+
["san antonio", "tx", 29.4241, -98.4936],
|
|
1684
|
+
["el paso", "tx", 31.7619, -106.485],
|
|
1685
|
+
["lubbock", "tx", 33.5779, -101.8552],
|
|
1686
|
+
["midland", "tx", 31.9973, -102.0779],
|
|
1687
|
+
["san francisco", "ca", 37.7749, -122.4194],
|
|
1688
|
+
["oakland", "ca", 37.8044, -122.2712],
|
|
1689
|
+
["berkeley", "ca", 37.8715, -122.273],
|
|
1690
|
+
["san jose", "ca", 37.3382, -121.8863],
|
|
1691
|
+
["sunnyvale", "ca", 37.3688, -122.0363],
|
|
1692
|
+
["santa clara", "ca", 37.3541, -121.9552],
|
|
1693
|
+
["mountain view", "ca", 37.3861, -122.0839],
|
|
1694
|
+
["palo alto", "ca", 37.4419, -122.143],
|
|
1695
|
+
["menlo park", "ca", 37.453, -122.1817],
|
|
1696
|
+
["redwood city", "ca", 37.4852, -122.2364],
|
|
1697
|
+
["san mateo", "ca", 37.563, -122.3255],
|
|
1698
|
+
["cupertino", "ca", 37.323, -122.0322],
|
|
1699
|
+
["fremont", "ca", 37.5485, -121.9886],
|
|
1700
|
+
["pleasanton", "ca", 37.6624, -121.8747],
|
|
1701
|
+
["san ramon", "ca", 37.7799, -121.978],
|
|
1702
|
+
["walnut creek", "ca", 37.9101, -122.0652],
|
|
1703
|
+
["san rafael", "ca", 37.9735, -122.5311],
|
|
1704
|
+
["sacramento", "ca", 38.5816, -121.4944],
|
|
1705
|
+
["fresno", "ca", 36.7468, -119.7726],
|
|
1706
|
+
["los angeles", "ca", 34.0522, -118.2437],
|
|
1707
|
+
["santa monica", "ca", 34.0195, -118.4912],
|
|
1708
|
+
["culver city", "ca", 34.0211, -118.3965],
|
|
1709
|
+
["pasadena", "ca", 34.1478, -118.1445],
|
|
1710
|
+
["burbank", "ca", 34.1808, -118.309],
|
|
1711
|
+
["glendale", "ca", 34.1425, -118.2551],
|
|
1712
|
+
["long beach", "ca", 33.7701, -118.1937],
|
|
1713
|
+
["irvine", "ca", 33.6846, -117.8265],
|
|
1714
|
+
["newport beach", "ca", 33.6189, -117.9298],
|
|
1715
|
+
["anaheim", "ca", 33.8366, -117.9143],
|
|
1716
|
+
["san diego", "ca", 32.7157, -117.1611],
|
|
1717
|
+
["la jolla", "ca", 32.8328, -117.2713],
|
|
1718
|
+
["carlsbad", "ca", 33.1581, -117.3506],
|
|
1719
|
+
["riverside", "ca", 33.9806, -117.3755],
|
|
1720
|
+
["seattle", "wa", 47.6062, -122.3321],
|
|
1721
|
+
["bellevue", "wa", 47.6101, -122.2015],
|
|
1722
|
+
["redmond", "wa", 47.674, -122.1215],
|
|
1723
|
+
["kirkland", "wa", 47.6769, -122.206],
|
|
1724
|
+
["renton", "wa", 47.4829, -122.2171],
|
|
1725
|
+
["bothell", "wa", 47.7601, -122.2054],
|
|
1726
|
+
["tacoma", "wa", 47.2529, -122.4443],
|
|
1727
|
+
["olympia", "wa", 47.0379, -122.9007],
|
|
1728
|
+
["spokane", "wa", 47.6588, -117.426],
|
|
1729
|
+
["new york", "ny", 40.7128, -74.006],
|
|
1730
|
+
["manhattan", "ny", 40.7831, -73.9712],
|
|
1731
|
+
["brooklyn", "ny", 40.6782, -73.9442],
|
|
1732
|
+
["queens", "ny", 40.7282, -73.7949],
|
|
1733
|
+
["bronx", "ny", 40.8448, -73.8648],
|
|
1734
|
+
["staten island", "ny", 40.5795, -74.1502],
|
|
1735
|
+
["white plains", "ny", 41.034, -73.7629],
|
|
1736
|
+
["buffalo", "ny", 42.8864, -78.8784],
|
|
1737
|
+
["rochester", "ny", 43.1566, -77.6088],
|
|
1738
|
+
["albany", "ny", 42.6526, -73.7562],
|
|
1739
|
+
["jersey city", "nj", 40.7178, -74.0431],
|
|
1740
|
+
["hoboken", "nj", 40.744, -74.0324],
|
|
1741
|
+
["newark", "nj", 40.7357, -74.1724],
|
|
1742
|
+
["princeton", "nj", 40.3573, -74.6672],
|
|
1743
|
+
["boston", "ma", 42.3601, -71.0589],
|
|
1744
|
+
["cambridge", "ma", 42.3736, -71.1097],
|
|
1745
|
+
["somerville", "ma", 42.3876, -71.0995],
|
|
1746
|
+
["waltham", "ma", 42.3765, -71.2356],
|
|
1747
|
+
["burlington", "ma", 42.5048, -71.1956],
|
|
1748
|
+
["newton", "ma", 42.337, -71.2092],
|
|
1749
|
+
["quincy", "ma", 42.2529, -71.0023],
|
|
1750
|
+
["worcester", "ma", 42.2626, -71.8023],
|
|
1751
|
+
["providence", "ri", 41.824, -71.4128],
|
|
1752
|
+
["hartford", "ct", 41.7658, -72.6734],
|
|
1753
|
+
["stamford", "ct", 41.0534, -73.5387],
|
|
1754
|
+
["chicago", "il", 41.8781, -87.6298],
|
|
1755
|
+
["evanston", "il", 42.0451, -87.6877],
|
|
1756
|
+
["naperville", "il", 41.7508, -88.1535],
|
|
1757
|
+
["schaumburg", "il", 42.0334, -88.0834],
|
|
1758
|
+
["minneapolis", "mn", 44.9778, -93.265],
|
|
1759
|
+
["st. paul", "mn", 44.9537, -93.09],
|
|
1760
|
+
["bloomington", "mn", 44.8408, -93.2983],
|
|
1761
|
+
["detroit", "mi", 42.3314, -83.0458],
|
|
1762
|
+
["ann arbor", "mi", 42.2808, -83.743],
|
|
1763
|
+
["grand rapids", "mi", 42.9634, -85.6681],
|
|
1764
|
+
["columbus", "oh", 39.9612, -82.9988],
|
|
1765
|
+
["cleveland", "oh", 41.4993, -81.6944],
|
|
1766
|
+
["cincinnati", "oh", 39.1031, -84.512],
|
|
1767
|
+
["indianapolis", "in", 39.7684, -86.1581],
|
|
1768
|
+
["milwaukee", "wi", 43.0389, -87.9065],
|
|
1769
|
+
["madison", "wi", 43.0731, -89.4012],
|
|
1770
|
+
["st. louis", "mo", 38.627, -90.1994],
|
|
1771
|
+
["kansas city", "mo", 39.0997, -94.5786],
|
|
1772
|
+
["denver", "co", 39.7392, -104.9903],
|
|
1773
|
+
["boulder", "co", 40.015, -105.2705],
|
|
1774
|
+
["colorado springs", "co", 38.8339, -104.8214],
|
|
1775
|
+
["fort collins", "co", 40.5853, -105.0844],
|
|
1776
|
+
["broomfield", "co", 39.9205, -105.0867],
|
|
1777
|
+
["salt lake city", "ut", 40.7608, -111.891],
|
|
1778
|
+
["lehi", "ut", 40.3892, -111.8508],
|
|
1779
|
+
["provo", "ut", 40.2338, -111.6585],
|
|
1780
|
+
["draper", "ut", 40.5247, -111.8638],
|
|
1781
|
+
["phoenix", "az", 33.4484, -112.074],
|
|
1782
|
+
["scottsdale", "az", 33.4942, -111.9261],
|
|
1783
|
+
["tempe", "az", 33.4255, -111.94],
|
|
1784
|
+
["chandler", "az", 33.3062, -111.8413],
|
|
1785
|
+
["tucson", "az", 32.2226, -110.9747],
|
|
1786
|
+
["las vegas", "nv", 36.1699, -115.1398],
|
|
1787
|
+
["reno", "nv", 39.5296, -119.8138],
|
|
1788
|
+
["atlanta", "ga", 33.749, -84.388],
|
|
1789
|
+
["alpharetta", "ga", 34.0754, -84.2941],
|
|
1790
|
+
["marietta", "ga", 33.9526, -84.5499],
|
|
1791
|
+
["raleigh", "nc", 35.7796, -78.6382],
|
|
1792
|
+
["durham", "nc", 35.994, -78.8986],
|
|
1793
|
+
["chapel hill", "nc", 35.9132, -79.0558],
|
|
1794
|
+
["cary", "nc", 35.7915, -78.7811],
|
|
1795
|
+
["charlotte", "nc", 35.2271, -80.8431],
|
|
1796
|
+
["washington", "dc", 38.9072, -77.0369],
|
|
1797
|
+
["alexandria", "va", 38.8048, -77.0469],
|
|
1798
|
+
["mclean", "va", 38.9339, -77.1773],
|
|
1799
|
+
["tysons", "va", 38.9187, -77.2311],
|
|
1800
|
+
["reston", "va", 38.9586, -77.357],
|
|
1801
|
+
["herndon", "va", 38.9696, -77.3861],
|
|
1802
|
+
["richmond", "va", 37.5407, -77.436],
|
|
1803
|
+
["bethesda", "md", 38.9847, -77.0947],
|
|
1804
|
+
["baltimore", "md", 39.2904, -76.6122],
|
|
1805
|
+
["rockville", "md", 39.084, -77.1528],
|
|
1806
|
+
["philadelphia", "pa", 39.9526, -75.1652],
|
|
1807
|
+
["pittsburgh", "pa", 40.4406, -79.9959],
|
|
1808
|
+
["miami", "fl", 25.7617, -80.1918],
|
|
1809
|
+
["fort lauderdale", "fl", 26.1224, -80.1373],
|
|
1810
|
+
["tampa", "fl", 27.9506, -82.4572],
|
|
1811
|
+
["orlando", "fl", 28.5383, -81.3792],
|
|
1812
|
+
["jacksonville", "fl", 30.3322, -81.6557],
|
|
1813
|
+
["nashville", "tn", 36.1627, -86.7816],
|
|
1814
|
+
["memphis", "tn", 35.1495, -90.049],
|
|
1815
|
+
["portland", "or", 45.5152, -122.6784],
|
|
1816
|
+
["beaverton", "or", 45.4871, -122.8037],
|
|
1817
|
+
["hillsboro", "or", 45.5229, -122.9898],
|
|
1818
|
+
["boise", "id", 43.615, -116.2023],
|
|
1819
|
+
// Added: suburbs and hubs the live corpus names that the port did not have.
|
|
1820
|
+
["foster city", "ca", 37.5585, -122.2711],
|
|
1821
|
+
["south san francisco", "ca", 37.6547, -122.4077],
|
|
1822
|
+
["emeryville", "ca", 37.8313, -122.2852],
|
|
1823
|
+
["milpitas", "ca", 37.4323, -121.8996],
|
|
1824
|
+
["los gatos", "ca", 37.2358, -121.9624],
|
|
1825
|
+
["san bruno", "ca", 37.6305, -122.4111],
|
|
1826
|
+
["hayward", "ca", 37.6688, -122.0808],
|
|
1827
|
+
["el segundo", "ca", 33.9192, -118.4165],
|
|
1828
|
+
["torrance", "ca", 33.8358, -118.3406],
|
|
1829
|
+
["hollywood", "fl", 26.0112, -80.1495],
|
|
1830
|
+
["boca raton", "fl", 26.3683, -80.1289],
|
|
1831
|
+
["new orleans", "la", 29.9511, -90.0715],
|
|
1832
|
+
["louisville", "ky", 38.2527, -85.7585],
|
|
1833
|
+
["oklahoma city", "ok", 35.4676, -97.5164],
|
|
1834
|
+
["omaha", "ne", 41.2565, -95.9345],
|
|
1835
|
+
["des moines", "ia", 41.5868, -93.625],
|
|
1836
|
+
["albuquerque", "nm", 35.0844, -106.6504],
|
|
1837
|
+
["honolulu", "hi", 21.3099, -157.8581],
|
|
1838
|
+
["anchorage", "ak", 61.2181, -149.9003],
|
|
1839
|
+
["burlington", "vt", 44.4759, -73.2121],
|
|
1840
|
+
["portland", "me", 43.6591, -70.2568]
|
|
1841
|
+
];
|
|
1842
|
+
const WORLD = [
|
|
1843
|
+
// Canada
|
|
1844
|
+
["toronto", "ca-on", 43.6532, -79.3832],
|
|
1845
|
+
["etobicoke", "ca-on", 43.6205, -79.5132],
|
|
1846
|
+
["mississauga", "ca-on", 43.589, -79.6441],
|
|
1847
|
+
["waterloo", "ca-on", 43.4643, -80.5204],
|
|
1848
|
+
["ottawa", "ca-on", 45.4215, -75.6972],
|
|
1849
|
+
["montreal", "ca-qc", 45.5017, -73.5673],
|
|
1850
|
+
["vancouver", "ca-bc", 49.2827, -123.1207],
|
|
1851
|
+
["calgary", "ca-ab", 51.0447, -114.0719],
|
|
1852
|
+
// Latin America
|
|
1853
|
+
["mexico city", "mx", 19.4326, -99.1332],
|
|
1854
|
+
["guadalajara", "mx", 20.6597, -103.3496],
|
|
1855
|
+
["sao paulo", "br", -23.5505, -46.6333],
|
|
1856
|
+
["rio de janeiro", "br", -22.9068, -43.1729],
|
|
1857
|
+
["buenos aires", "ar", -34.6037, -58.3816],
|
|
1858
|
+
["bogota", "co", 4.711, -74.0721],
|
|
1859
|
+
["santiago", "cl", -33.4489, -70.6693],
|
|
1860
|
+
// Europe
|
|
1861
|
+
["london", "gb", 51.5074, -0.1278],
|
|
1862
|
+
["cambridge", "gb", 52.2053, 0.1218],
|
|
1863
|
+
["manchester", "gb", 53.4808, -2.2426],
|
|
1864
|
+
["edinburgh", "gb", 55.9533, -3.1883],
|
|
1865
|
+
["dublin", "ie", 53.3498, -6.2603],
|
|
1866
|
+
["cork", "ie", 51.8985, -8.4756],
|
|
1867
|
+
["berlin", "de", 52.52, 13.405],
|
|
1868
|
+
["munich", "de", 48.1351, 11.582],
|
|
1869
|
+
["hamburg", "de", 53.5511, 9.9937],
|
|
1870
|
+
["frankfurt", "de", 50.1109, 8.6821],
|
|
1871
|
+
["amsterdam", "nl", 52.3676, 4.9041],
|
|
1872
|
+
["paris", "fr", 48.8566, 2.3522],
|
|
1873
|
+
["brussels", "be", 50.8503, 4.3517],
|
|
1874
|
+
["zurich", "ch", 47.3769, 8.5417],
|
|
1875
|
+
["geneva", "ch", 46.2044, 6.1432],
|
|
1876
|
+
["vienna", "at", 48.2082, 16.3738],
|
|
1877
|
+
["stockholm", "se", 59.3293, 18.0686],
|
|
1878
|
+
["copenhagen", "dk", 55.6761, 12.5683],
|
|
1879
|
+
["oslo", "no", 59.9139, 10.7522],
|
|
1880
|
+
["helsinki", "fi", 60.1699, 24.9384],
|
|
1881
|
+
["madrid", "es", 40.4168, -3.7038],
|
|
1882
|
+
["barcelona", "es", 41.3874, 2.1686],
|
|
1883
|
+
["lisbon", "pt", 38.7223, -9.1393],
|
|
1884
|
+
["warsaw", "pl", 52.2297, 21.0122],
|
|
1885
|
+
["krakow", "pl", 50.0647, 19.945],
|
|
1886
|
+
["prague", "cz", 50.0755, 14.4378],
|
|
1887
|
+
["bucharest", "ro", 44.4268, 26.1025],
|
|
1888
|
+
["athens", "gr", 37.9838, 23.7275],
|
|
1889
|
+
["istanbul", "tr", 41.0082, 28.9784],
|
|
1890
|
+
// Middle East and Africa
|
|
1891
|
+
["tel aviv", "il", 32.0853, 34.7818],
|
|
1892
|
+
["dubai", "ae", 25.2048, 55.2708],
|
|
1893
|
+
["riyadh", "sa", 24.7136, 46.6753],
|
|
1894
|
+
["abu dhabi", "ae", 24.4539, 54.3773],
|
|
1895
|
+
["cairo", "eg", 30.0444, 31.2357],
|
|
1896
|
+
["lagos", "ng", 6.5244, 3.3792],
|
|
1897
|
+
["nairobi", "ke", -1.2921, 36.8219],
|
|
1898
|
+
["cape town", "za", -33.9249, 18.4241],
|
|
1899
|
+
// Asia-Pacific
|
|
1900
|
+
["bengaluru", "in", 12.9716, 77.5946],
|
|
1901
|
+
["hyderabad", "in", 17.385, 78.4867],
|
|
1902
|
+
["pune", "in", 18.5204, 73.8567],
|
|
1903
|
+
["mumbai", "in", 19.076, 72.8777],
|
|
1904
|
+
["chennai", "in", 13.0827, 80.2707],
|
|
1905
|
+
["gurugram", "in", 28.4595, 77.0266],
|
|
1906
|
+
["noida", "in", 28.5355, 77.391],
|
|
1907
|
+
["new delhi", "in", 28.6139, 77.209],
|
|
1908
|
+
["singapore", "sg", 1.3521, 103.8198],
|
|
1909
|
+
["kuala lumpur", "my", 3.139, 101.6869],
|
|
1910
|
+
["jakarta", "id", -6.2088, 106.8456],
|
|
1911
|
+
["manila", "ph", 14.5995, 120.9842],
|
|
1912
|
+
["tokyo", "jp", 35.6762, 139.6503],
|
|
1913
|
+
["osaka", "jp", 34.6937, 135.5023],
|
|
1914
|
+
["bangkok", "th", 13.7563, 100.5018],
|
|
1915
|
+
["seoul", "kr", 37.5665, 126.978],
|
|
1916
|
+
["taipei", "tw", 25.033, 121.5654],
|
|
1917
|
+
["taoyuan", "tw", 24.9936, 121.301],
|
|
1918
|
+
["hong kong", "hk", 22.3193, 114.1694],
|
|
1919
|
+
["shanghai", "cn", 31.2304, 121.4737],
|
|
1920
|
+
["beijing", "cn", 39.9042, 116.4074],
|
|
1921
|
+
["shenzhen", "cn", 22.5431, 114.0579],
|
|
1922
|
+
["sydney", "au", -33.8688, 151.2093],
|
|
1923
|
+
["melbourne", "au", -37.8136, 144.9631],
|
|
1924
|
+
["auckland", "nz", -36.8485, 174.7633],
|
|
1925
|
+
// US places that share a name with one above, declared after it so the
|
|
1926
|
+
// bare name keeps meaning the other: "Dublin" alone is Ireland.
|
|
1927
|
+
["dublin", "ca", 37.7022, -121.9358]
|
|
1928
|
+
];
|
|
1929
|
+
const ALIASES = {
|
|
1930
|
+
"new york city": "new york|ny",
|
|
1931
|
+
nyc: "new york|ny",
|
|
1932
|
+
"st paul": "st. paul|mn",
|
|
1933
|
+
"saint paul": "st. paul|mn",
|
|
1934
|
+
"saint louis": "st. louis|mo",
|
|
1935
|
+
"st louis": "st. louis|mo",
|
|
1936
|
+
"washington dc": "washington|dc",
|
|
1937
|
+
"washington d c": "washington|dc",
|
|
1938
|
+
bangalore: "bengaluru|in",
|
|
1939
|
+
gurgaon: "gurugram|in",
|
|
1940
|
+
delhi: "new delhi|in",
|
|
1941
|
+
munchen: "munich|de",
|
|
1942
|
+
"tel aviv yafo": "tel aviv|il"
|
|
1943
|
+
};
|
|
1944
|
+
const US_STATES = {
|
|
1945
|
+
al: "alabama",
|
|
1946
|
+
ak: "alaska",
|
|
1947
|
+
az: "arizona",
|
|
1948
|
+
ar: "arkansas",
|
|
1949
|
+
ca: "california",
|
|
1950
|
+
co: "colorado",
|
|
1951
|
+
ct: "connecticut",
|
|
1952
|
+
de: "delaware",
|
|
1953
|
+
fl: "florida",
|
|
1954
|
+
ga: "georgia",
|
|
1955
|
+
hi: "hawaii",
|
|
1956
|
+
id: "idaho",
|
|
1957
|
+
il: "illinois",
|
|
1958
|
+
in: "indiana",
|
|
1959
|
+
ia: "iowa",
|
|
1960
|
+
ks: "kansas",
|
|
1961
|
+
ky: "kentucky",
|
|
1962
|
+
la: "louisiana",
|
|
1963
|
+
me: "maine",
|
|
1964
|
+
md: "maryland",
|
|
1965
|
+
ma: "massachusetts",
|
|
1966
|
+
mi: "michigan",
|
|
1967
|
+
mn: "minnesota",
|
|
1968
|
+
ms: "mississippi",
|
|
1969
|
+
mo: "missouri",
|
|
1970
|
+
mt: "montana",
|
|
1971
|
+
ne: "nebraska",
|
|
1972
|
+
nv: "nevada",
|
|
1973
|
+
nh: "new hampshire",
|
|
1974
|
+
nj: "new jersey",
|
|
1975
|
+
nm: "new mexico",
|
|
1976
|
+
ny: "new york",
|
|
1977
|
+
nc: "north carolina",
|
|
1978
|
+
nd: "north dakota",
|
|
1979
|
+
oh: "ohio",
|
|
1980
|
+
ok: "oklahoma",
|
|
1981
|
+
or: "oregon",
|
|
1982
|
+
pa: "pennsylvania",
|
|
1983
|
+
ri: "rhode island",
|
|
1984
|
+
sc: "south carolina",
|
|
1985
|
+
sd: "south dakota",
|
|
1986
|
+
tn: "tennessee",
|
|
1987
|
+
tx: "texas",
|
|
1988
|
+
ut: "utah",
|
|
1989
|
+
vt: "vermont",
|
|
1990
|
+
va: "virginia",
|
|
1991
|
+
wa: "washington",
|
|
1992
|
+
wv: "west virginia",
|
|
1993
|
+
wi: "wisconsin",
|
|
1994
|
+
wy: "wyoming",
|
|
1995
|
+
dc: "district of columbia"
|
|
1996
|
+
};
|
|
1997
|
+
const COUNTRY_WORDS = {
|
|
1998
|
+
"ca-on": ["canada", "ontario"],
|
|
1999
|
+
"ca-qc": ["canada", "quebec"],
|
|
2000
|
+
"ca-bc": ["canada", "british columbia"],
|
|
2001
|
+
"ca-ab": ["canada", "alberta"],
|
|
2002
|
+
mx: ["mexico"],
|
|
2003
|
+
br: ["brazil", "brasil"],
|
|
2004
|
+
ar: ["argentina"],
|
|
2005
|
+
co: ["colombia"],
|
|
2006
|
+
cl: ["chile"],
|
|
2007
|
+
gb: ["uk", "united kingdom", "england", "scotland", "great britain"],
|
|
2008
|
+
ie: ["ireland"],
|
|
2009
|
+
de: ["germany", "deutschland"],
|
|
2010
|
+
nl: ["netherlands"],
|
|
2011
|
+
fr: ["france"],
|
|
2012
|
+
be: ["belgium"],
|
|
2013
|
+
ch: ["switzerland"],
|
|
2014
|
+
at: ["austria"],
|
|
2015
|
+
se: ["sweden"],
|
|
2016
|
+
dk: ["denmark"],
|
|
2017
|
+
no: ["norway"],
|
|
2018
|
+
fi: ["finland"],
|
|
2019
|
+
es: ["spain"],
|
|
2020
|
+
pt: ["portugal"],
|
|
2021
|
+
pl: ["poland"],
|
|
2022
|
+
cz: ["czechia", "czech republic"],
|
|
2023
|
+
ro: ["romania"],
|
|
2024
|
+
gr: ["greece"],
|
|
2025
|
+
tr: ["turkey", "turkiye"],
|
|
2026
|
+
il: ["israel"],
|
|
2027
|
+
ae: ["uae", "united arab emirates"],
|
|
2028
|
+
eg: ["egypt"],
|
|
2029
|
+
ng: ["nigeria"],
|
|
2030
|
+
ke: ["kenya"],
|
|
2031
|
+
za: ["south africa"],
|
|
2032
|
+
in: ["india"],
|
|
2033
|
+
sg: ["singapore"],
|
|
2034
|
+
my: ["malaysia"],
|
|
2035
|
+
id: ["indonesia"],
|
|
2036
|
+
ph: ["philippines"],
|
|
2037
|
+
jp: ["japan"],
|
|
2038
|
+
kr: ["korea", "south korea"],
|
|
2039
|
+
tw: ["taiwan"],
|
|
2040
|
+
hk: ["hong kong"],
|
|
2041
|
+
cn: ["china"],
|
|
2042
|
+
au: ["australia"],
|
|
2043
|
+
nz: ["new zealand"],
|
|
2044
|
+
th: ["thailand"],
|
|
2045
|
+
sa: ["saudi arabia"]
|
|
2046
|
+
};
|
|
2047
|
+
function fold(s) {
|
|
2048
|
+
return ` ${s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `;
|
|
2049
|
+
}
|
|
2050
|
+
function slug(name) {
|
|
2051
|
+
return fold(name).trim().replace(/ /g, "-");
|
|
2052
|
+
}
|
|
2053
|
+
const PLACES = [...US, ...WORLD].map(([name, region, lat, lng]) => ({
|
|
2054
|
+
key: `${slug(name)}-${region}`,
|
|
2055
|
+
name,
|
|
2056
|
+
region,
|
|
2057
|
+
lat,
|
|
2058
|
+
lng
|
|
2059
|
+
}));
|
|
2060
|
+
const BY_KEY = new Map(PLACES.map((p) => [p.key, p]));
|
|
2061
|
+
const BY_PHRASE = /* @__PURE__ */ new Map();
|
|
2062
|
+
function addPhrase(phrase, place) {
|
|
2063
|
+
const f = fold(phrase);
|
|
2064
|
+
const list = BY_PHRASE.get(f) ?? [];
|
|
2065
|
+
if (!list.includes(place)) list.push(place);
|
|
2066
|
+
BY_PHRASE.set(f, list);
|
|
2067
|
+
}
|
|
2068
|
+
for (const p of PLACES) addPhrase(p.name, p);
|
|
2069
|
+
for (const [alias, target] of Object.entries(ALIASES)) {
|
|
2070
|
+
const [name, region] = target.split("|");
|
|
2071
|
+
const p = BY_KEY.get(`${slug(name)}-${region}`);
|
|
2072
|
+
if (!p) throw new Error(`geo alias '${alias}' names no place (${target})`);
|
|
2073
|
+
addPhrase(alias, p);
|
|
2074
|
+
}
|
|
2075
|
+
const PHRASES = [...BY_PHRASE.keys()].sort((a, b) => b.length - a.length);
|
|
2076
|
+
const STATE_NAMES = new Set(Object.values(US_STATES));
|
|
2077
|
+
const ALL_REGIONS = [...Object.keys(US_STATES), ...Object.keys(COUNTRY_WORDS)];
|
|
2078
|
+
function regionNamed(raw, folded, region) {
|
|
2079
|
+
const stateName = US_STATES[region];
|
|
2080
|
+
if (stateName) {
|
|
2081
|
+
const code = region.toUpperCase();
|
|
2082
|
+
if (new RegExp(`(?:^|[^A-Za-z])${code}(?:[^A-Za-z]|$)`).test(raw)) return true;
|
|
2083
|
+
if (region === "dc" && /\bD\.C\.?/.test(raw)) return true;
|
|
2084
|
+
return folded.includes(` ${stateName} `);
|
|
2085
|
+
}
|
|
2086
|
+
return (COUNTRY_WORDS[region] ?? []).some((w) => folded.includes(fold(w)));
|
|
2087
|
+
}
|
|
2088
|
+
function soleRegion(raw, folded) {
|
|
2089
|
+
const named = ALL_REGIONS.filter((r) => regionNamed(raw, folded, r));
|
|
2090
|
+
return named.length === 1 ? named[0] : null;
|
|
2091
|
+
}
|
|
2092
|
+
const SEGMENT = /\s*[|;/]\s*|\s+or\s+|\s+and\s+/;
|
|
2093
|
+
function placesInSegment(raw, found) {
|
|
2094
|
+
const evidenceText = fold(raw);
|
|
2095
|
+
let folded = evidenceText;
|
|
2096
|
+
const sole = soleRegion(raw, evidenceText);
|
|
2097
|
+
for (const phrase of PHRASES) {
|
|
2098
|
+
if (!folded.includes(phrase)) continue;
|
|
2099
|
+
const candidates = BY_PHRASE.get(phrase);
|
|
2100
|
+
const evidenced = candidates.filter((p) => regionNamed(raw, evidenceText, p.region));
|
|
2101
|
+
let pick = evidenced[0];
|
|
2102
|
+
if (!pick) {
|
|
2103
|
+
const first = candidates[0];
|
|
2104
|
+
const isOtherState = STATE_NAMES.has(first.name) && US_STATES[first.region] !== first.name;
|
|
2105
|
+
const elsewhere = sole !== null && !candidates.some((p) => p.region === sole);
|
|
2106
|
+
if (!isOtherState && !elsewhere) pick = first;
|
|
2107
|
+
}
|
|
2108
|
+
if (pick && !found.includes(pick)) found.push(pick);
|
|
2109
|
+
folded = folded.split(phrase).join(" | ");
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
function findPlaces(location) {
|
|
2113
|
+
if (!location) return [];
|
|
2114
|
+
const found = [];
|
|
2115
|
+
for (const segment of location.split(SEGMENT)) {
|
|
2116
|
+
if (segment.trim()) placesInSegment(segment, found);
|
|
2117
|
+
}
|
|
2118
|
+
return found;
|
|
2119
|
+
}
|
|
2120
|
+
function geoKeys(location) {
|
|
2121
|
+
const keys = findPlaces(location).map((p) => p.key);
|
|
2122
|
+
return keys.length ? `|${keys.join("|")}|` : "";
|
|
2123
|
+
}
|
|
2124
|
+
const EARTH_RADIUS_MILES = 3958.8;
|
|
2125
|
+
function milesBetween(a, b) {
|
|
2126
|
+
const rad = (d) => d * Math.PI / 180;
|
|
2127
|
+
const dLat = rad(b.lat - a.lat);
|
|
2128
|
+
const dLng = rad(b.lng - a.lng);
|
|
2129
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
|
2130
|
+
return 2 * EARTH_RADIUS_MILES * Math.asin(Math.sqrt(h));
|
|
2131
|
+
}
|
|
2132
|
+
function resolvePlace(q) {
|
|
2133
|
+
return BY_KEY.get(q.trim().toLowerCase()) ?? findPlaces(q)[0] ?? null;
|
|
2134
|
+
}
|
|
2135
|
+
function placesWithin(center, miles) {
|
|
2136
|
+
return PLACES.map((p) => ({ p, d: milesBetween(center, p) })).filter(({ d }) => d <= miles).sort((a, b) => a.d - b.d).map(({ p }) => p);
|
|
2137
|
+
}
|
|
2138
|
+
function searchPlaces(q, limit = 10) {
|
|
2139
|
+
const f = fold(q).trim();
|
|
2140
|
+
if (!f) return [];
|
|
2141
|
+
const starts = PLACES.filter((p) => fold(p.name).trim().startsWith(f));
|
|
2142
|
+
const contains = PLACES.filter((p) => !starts.includes(p) && fold(p.name).includes(f));
|
|
2143
|
+
return [...starts, ...contains].slice(0, limit);
|
|
2144
|
+
}
|
|
1536
2145
|
const AMOUNT = String.raw`(?:\$\s*(\d{2,3}(?:,\d{3})+|\d{2,3}(?:\.\d+)?[kK])|(\d{2,3}(?:,\d{3})+|\d{2,3}(?:\.\d+)?[kK])\s*(?=USD))`;
|
|
1537
2146
|
const RANGE_RE = new RegExp(`${AMOUNT}(?:\\s*USD)?\\s*(?:[-–—]|to)\\s*${AMOUNT}(?:\\s*USD)?`, "g");
|
|
1538
2147
|
const NON_ANNUAL_CONTEXT = /(?:per\s+hour|hourly|\/\s*(?:hr|hour)|per\s+month|monthly|per\s+week|weekly|per\s+day)/i;
|
|
@@ -2025,6 +2634,7 @@ async function rankNewJobs(db) {
|
|
|
2025
2634
|
app$4.use("/ingest", requireMinTier("friend"));
|
|
2026
2635
|
app$4.use("/ingest/backfill-slugs", requireMinTier("friend"));
|
|
2027
2636
|
app$4.use("/ingest/backfill-roles", requireMinTier("friend"));
|
|
2637
|
+
app$4.use("/ingest/backfill-geo", requireMinTier("friend"));
|
|
2028
2638
|
app$4.use("/ingest/backfill-salary", requireMinTier("friend"));
|
|
2029
2639
|
app$4.use("/ingest/cull", requireMinTier("friend"));
|
|
2030
2640
|
app$4.use("/ingest/rebuild-rank", requireMinTier("friend"));
|
|
@@ -2066,8 +2676,8 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2066
2676
|
const salaryMax = structuredMax ?? prose?.max ?? null;
|
|
2067
2677
|
const parsed = parseAtsSlug(job.url);
|
|
2068
2678
|
const ats = parsed.ats ?? (board_slug ? source : null);
|
|
2069
|
-
const
|
|
2070
|
-
const slugSource =
|
|
2679
|
+
const slug2 = board_slug ?? parsed.slug;
|
|
2680
|
+
const slugSource = slug2 === null ? null : board_slug ? "scraped" : "guessed";
|
|
2071
2681
|
const role = classifyRole(job.title, job.description);
|
|
2072
2682
|
return db.prepare(
|
|
2073
2683
|
`INSERT OR IGNORE INTO jobs (
|
|
@@ -2075,8 +2685,8 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2075
2685
|
job_type, workplace_type, salary_min, salary_max,
|
|
2076
2686
|
description, posted_date, application_url, department,
|
|
2077
2687
|
scraper_used, raw, scraped_at, last_seen_at, ats, slug,
|
|
2078
|
-
role_track, role_level, slug_source
|
|
2079
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2688
|
+
role_track, role_level, slug_source, geo_keys
|
|
2689
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2080
2690
|
).bind(
|
|
2081
2691
|
job.id,
|
|
2082
2692
|
job.url,
|
|
@@ -2097,10 +2707,11 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2097
2707
|
now,
|
|
2098
2708
|
now,
|
|
2099
2709
|
ats,
|
|
2100
|
-
|
|
2710
|
+
slug2,
|
|
2101
2711
|
role.track,
|
|
2102
2712
|
role.level,
|
|
2103
|
-
slugSource
|
|
2713
|
+
slugSource,
|
|
2714
|
+
geoKeys(job.location)
|
|
2104
2715
|
);
|
|
2105
2716
|
});
|
|
2106
2717
|
const inserted = [];
|
|
@@ -2201,12 +2812,12 @@ app$4.openapi(backfillRoute, async (c) => {
|
|
|
2201
2812
|
let updated = 0;
|
|
2202
2813
|
let unmatched = 0;
|
|
2203
2814
|
for (const row of rows.results) {
|
|
2204
|
-
const { ats, slug } = parseAtsSlug(row.url);
|
|
2205
|
-
if (ats === null &&
|
|
2815
|
+
const { ats, slug: slug2 } = parseAtsSlug(row.url);
|
|
2816
|
+
if (ats === null && slug2 === null) {
|
|
2206
2817
|
unmatched++;
|
|
2207
2818
|
continue;
|
|
2208
2819
|
}
|
|
2209
|
-
await db.prepare("UPDATE jobs SET ats = ?, slug = ? WHERE id = ?").bind(ats,
|
|
2820
|
+
await db.prepare("UPDATE jobs SET ats = ?, slug = ? WHERE id = ?").bind(ats, slug2, row.id).run();
|
|
2210
2821
|
updated++;
|
|
2211
2822
|
}
|
|
2212
2823
|
const remainingRow = await db.prepare("SELECT COUNT(*) as n FROM jobs WHERE ats IS NULL OR slug IS NULL").first();
|
|
@@ -2339,6 +2950,81 @@ app$4.openapi(backfillRolesRoute, async (c) => {
|
|
|
2339
2950
|
}
|
|
2340
2951
|
});
|
|
2341
2952
|
});
|
|
2953
|
+
const backfillGeoRoute = createRoute({
|
|
2954
|
+
method: "post",
|
|
2955
|
+
path: "/ingest/backfill-geo",
|
|
2956
|
+
tags: ["Ingest"],
|
|
2957
|
+
summary: "Populate jobs.geo_keys from each posting's location",
|
|
2958
|
+
description: "Bounded by `limit` (default 1000) per call. Default mode: rows still NULL, loop until `has_more` is false. `reread=true`: every row by id, passing back `next_after` as `after` until `has_more` is false.",
|
|
2959
|
+
request: {
|
|
2960
|
+
query: z.object({
|
|
2961
|
+
limit: z.coerce.number().int().min(1).max(2e3).default(1e3),
|
|
2962
|
+
reread: z.enum(["true", "false"]).default("false"),
|
|
2963
|
+
after: z.string().default("").openapi({ description: "reread only: the id to resume after" })
|
|
2964
|
+
})
|
|
2965
|
+
},
|
|
2966
|
+
responses: {
|
|
2967
|
+
200: {
|
|
2968
|
+
description: "Backfill batch complete",
|
|
2969
|
+
content: {
|
|
2970
|
+
"application/json": {
|
|
2971
|
+
schema: z.object({
|
|
2972
|
+
success: z.literal(true),
|
|
2973
|
+
data: z.object({
|
|
2974
|
+
scanned: z.number(),
|
|
2975
|
+
placed: z.number(),
|
|
2976
|
+
remaining: z.number().nullable(),
|
|
2977
|
+
next_after: z.string().nullable(),
|
|
2978
|
+
has_more: z.boolean()
|
|
2979
|
+
})
|
|
2980
|
+
}).openapi("BackfillGeoResponse")
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
});
|
|
2986
|
+
app$4.openapi(backfillGeoRoute, async (c) => {
|
|
2987
|
+
const { limit, reread, after } = c.req.valid("query");
|
|
2988
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
2989
|
+
const all = reread === "true";
|
|
2990
|
+
const rows = await db.prepare(
|
|
2991
|
+
all ? "SELECT id, location FROM jobs WHERE id > ? ORDER BY id LIMIT ?" : "SELECT id, location FROM jobs WHERE geo_keys IS NULL LIMIT ?"
|
|
2992
|
+
).bind(...all ? [after, limit] : [limit]).all();
|
|
2993
|
+
let placed = 0;
|
|
2994
|
+
const stmts = rows.results.map((row) => {
|
|
2995
|
+
const keys = geoKeys(row.location ?? "");
|
|
2996
|
+
if (keys) placed++;
|
|
2997
|
+
return db.prepare("UPDATE jobs SET geo_keys = ? WHERE id = ?").bind(keys, row.id);
|
|
2998
|
+
});
|
|
2999
|
+
for (let i = 0; i < stmts.length; i += INGEST_BATCH_CHUNK) {
|
|
3000
|
+
await db.batch(stmts.slice(i, i + INGEST_BATCH_CHUNK));
|
|
3001
|
+
}
|
|
3002
|
+
if (all) {
|
|
3003
|
+
const last = rows.results.at(-1)?.id ?? null;
|
|
3004
|
+
return c.json({
|
|
3005
|
+
success: true,
|
|
3006
|
+
data: {
|
|
3007
|
+
scanned: rows.results.length,
|
|
3008
|
+
placed,
|
|
3009
|
+
remaining: null,
|
|
3010
|
+
next_after: last,
|
|
3011
|
+
has_more: rows.results.length === limit
|
|
3012
|
+
}
|
|
3013
|
+
});
|
|
3014
|
+
}
|
|
3015
|
+
const remainingRow = await db.prepare("SELECT COUNT(*) AS n FROM jobs WHERE geo_keys IS NULL").first();
|
|
3016
|
+
const remaining = remainingRow?.n ?? 0;
|
|
3017
|
+
return c.json({
|
|
3018
|
+
success: true,
|
|
3019
|
+
data: {
|
|
3020
|
+
scanned: rows.results.length,
|
|
3021
|
+
placed,
|
|
3022
|
+
remaining,
|
|
3023
|
+
next_after: null,
|
|
3024
|
+
has_more: remaining > 0
|
|
3025
|
+
}
|
|
3026
|
+
});
|
|
3027
|
+
});
|
|
2342
3028
|
const directivesRoute = createRoute({
|
|
2343
3029
|
method: "get",
|
|
2344
3030
|
path: "/directives",
|
|
@@ -2743,7 +3429,7 @@ async function requireIdentity(c, next) {
|
|
|
2743
3429
|
c.env.HADOKU_SERVICE_KEY ?? "",
|
|
2744
3430
|
owner,
|
|
2745
3431
|
Date.now(),
|
|
2746
|
-
(
|
|
3432
|
+
(label2, work) => runAfterResponse(c, label2, work)
|
|
2747
3433
|
);
|
|
2748
3434
|
if (isIdentityError(resolved)) {
|
|
2749
3435
|
const status = [404, 409, 503].includes(resolved.status) ? resolved.status : 503;
|
|
@@ -3036,7 +3722,7 @@ app$3.openapi(
|
|
|
3036
3722
|
const userId = await currentUserId(c);
|
|
3037
3723
|
const db = c.env.JOB_PLATFORM_DB;
|
|
3038
3724
|
const { profileId } = c.req.valid("param");
|
|
3039
|
-
const { ats, slug, display_name } = c.req.valid("json");
|
|
3725
|
+
const { ats, slug: slug2, display_name } = c.req.valid("json");
|
|
3040
3726
|
if (!await ownedProfile(db, userId, profileId)) {
|
|
3041
3727
|
return c.json(
|
|
3042
3728
|
{
|
|
@@ -3052,10 +3738,10 @@ app$3.openapi(
|
|
|
3052
3738
|
await db.prepare(
|
|
3053
3739
|
`INSERT OR IGNORE INTO profile_companies (id, profile_id, ats, slug, display_name, target_id, added_at)
|
|
3054
3740
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`
|
|
3055
|
-
).bind(id, profileId, ats,
|
|
3741
|
+
).bind(id, profileId, ats, slug2, display_name, now).run();
|
|
3056
3742
|
const row = await db.prepare(
|
|
3057
3743
|
"SELECT id, ats, slug, display_name, added_at FROM profile_companies WHERE profile_id = ? AND ats = ? AND slug = ?"
|
|
3058
|
-
).bind(profileId, ats,
|
|
3744
|
+
).bind(profileId, ats, slug2).first();
|
|
3059
3745
|
let searchTriggered = false;
|
|
3060
3746
|
try {
|
|
3061
3747
|
await triggerSearch(c.env);
|
|
@@ -3156,6 +3842,7 @@ function deserializeProfile(r) {
|
|
|
3156
3842
|
};
|
|
3157
3843
|
}
|
|
3158
3844
|
const profileRoutes = app$3;
|
|
3845
|
+
const MAX_NEARBY = 80;
|
|
3159
3846
|
const LIGHT_CANDIDATE_CAP = 5e4;
|
|
3160
3847
|
const FULL_SCORE_CAP = 800;
|
|
3161
3848
|
const ID_CHUNK = 100;
|
|
@@ -3226,7 +3913,7 @@ function registerFeedRoute(app2) {
|
|
|
3226
3913
|
query: z.object({
|
|
3227
3914
|
ownerName: ownerNameQuery.shape.ownerName,
|
|
3228
3915
|
profile_id: z.string().optional().openapi({ description: "Filter/score by profile. Omit to list all jobs unscored." }),
|
|
3229
|
-
state: z.enum(["
|
|
3916
|
+
state: z.enum(["new", ...JOB_STATES]).optional().openapi({
|
|
3230
3917
|
description: 'Filter to a single triage state for the authed user. Requires admin/friend auth. "new" filters to jobs without any state row.'
|
|
3231
3918
|
}),
|
|
3232
3919
|
hide_dismissed: z.enum(["true", "false"]).optional().openapi({
|
|
@@ -3244,6 +3931,13 @@ function registerFeedRoute(app2) {
|
|
|
3244
3931
|
description: "Filter to one workplace type (e.g. full-remote only)."
|
|
3245
3932
|
}),
|
|
3246
3933
|
min_score: z.coerce.number().min(0).max(1).default(0).openapi({ description: "Minimum score filter" }),
|
|
3934
|
+
near: z.string().trim().min(1).max(100).optional().openapi({
|
|
3935
|
+
description: 'Radius filter centre: a place key from GET /locations, or free text ("Seattle, WA"). Keeps postings that name a place within radius_miles of it (and remote ones, unless include_remote=false). A posting whose location names no known place is only kept as remote. 400 when nothing matches.'
|
|
3936
|
+
}),
|
|
3937
|
+
radius_miles: z.coerce.number().int().min(1).max(500).default(25).openapi({ description: "Radius for near=, in miles." }),
|
|
3938
|
+
include_remote: z.enum(["true", "false"]).default("true").openapi({
|
|
3939
|
+
description: "With near=, also keep remote postings wherever they are."
|
|
3940
|
+
}),
|
|
3247
3941
|
min_salary: z.coerce.number().min(0).optional().openapi({
|
|
3248
3942
|
description: "View filter: drop jobs whose salary_max is below this. Jobs with no listed salary are kept — most postings omit it, and hiding them would hide the corpus."
|
|
3249
3943
|
})
|
|
@@ -3254,6 +3948,10 @@ function registerFeedRoute(app2) {
|
|
|
3254
3948
|
description: "Job list",
|
|
3255
3949
|
content: { "application/json": { schema: JobsResponseSchema } }
|
|
3256
3950
|
},
|
|
3951
|
+
400: {
|
|
3952
|
+
description: "near= names no known place",
|
|
3953
|
+
content: { "application/json": { schema: ErrorResponseSchema } }
|
|
3954
|
+
},
|
|
3257
3955
|
401: {
|
|
3258
3956
|
description: "state= requires auth",
|
|
3259
3957
|
content: { "application/json": { schema: ErrorResponseSchema } }
|
|
@@ -3288,7 +3986,10 @@ function registerFeedRoute(app2) {
|
|
|
3288
3986
|
sort,
|
|
3289
3987
|
workplace,
|
|
3290
3988
|
min_score,
|
|
3291
|
-
min_salary
|
|
3989
|
+
min_salary,
|
|
3990
|
+
near,
|
|
3991
|
+
radius_miles,
|
|
3992
|
+
include_remote
|
|
3292
3993
|
} = c.req.valid("query");
|
|
3293
3994
|
const hideDismissed = hideDismissedRaw === "true";
|
|
3294
3995
|
const hideQueued = hideQueuedRaw === "true";
|
|
@@ -3338,6 +4039,24 @@ function registerFeedRoute(app2) {
|
|
|
3338
4039
|
wheres.push("(j.salary_max IS NULL OR j.salary_max >= ?)");
|
|
3339
4040
|
whereBinds.push(min_salary);
|
|
3340
4041
|
}
|
|
4042
|
+
if (near) {
|
|
4043
|
+
const center = resolvePlace(near);
|
|
4044
|
+
if (!center) {
|
|
4045
|
+
return c.json(
|
|
4046
|
+
{
|
|
4047
|
+
success: false,
|
|
4048
|
+
error: "Bad request",
|
|
4049
|
+
message: `No known place matches '${near}' — try GET /locations?q=`
|
|
4050
|
+
},
|
|
4051
|
+
400
|
|
4052
|
+
);
|
|
4053
|
+
}
|
|
4054
|
+
const nearby = placesWithin(center, radius_miles).slice(0, MAX_NEARBY);
|
|
4055
|
+
const terms = nearby.map(() => "j.geo_keys LIKE ?");
|
|
4056
|
+
if (include_remote === "true") terms.unshift("j.workplace_type = 'remote'");
|
|
4057
|
+
wheres.push(`(${terms.join(" OR ")})`);
|
|
4058
|
+
whereBinds.push(...nearby.map((p) => `%|${p.key}|%`));
|
|
4059
|
+
}
|
|
3341
4060
|
if (userId) {
|
|
3342
4061
|
if (stateFilter === "new") {
|
|
3343
4062
|
wheres.push("js.state IS NULL");
|
|
@@ -3348,6 +4067,13 @@ function registerFeedRoute(app2) {
|
|
|
3348
4067
|
wheres.push("(js.state IS NULL OR js.state != 'dismissed')");
|
|
3349
4068
|
}
|
|
3350
4069
|
if (hideQueued && !stateFilter) wheres.push("ap.id IS NULL");
|
|
4070
|
+
if (!stateFilter) {
|
|
4071
|
+
wheres.push(
|
|
4072
|
+
`NOT EXISTS (SELECT 1 FROM blocked_companies b
|
|
4073
|
+
WHERE b.user_id = ? AND b.company_key = LOWER(TRIM(j.company)))`
|
|
4074
|
+
);
|
|
4075
|
+
whereBinds.push(userId);
|
|
4076
|
+
}
|
|
3351
4077
|
}
|
|
3352
4078
|
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
|
|
3353
4079
|
const andWheres = wheres.length > 0 ? ` AND ${wheres.join(" AND ")}` : "";
|
|
@@ -3471,7 +4197,7 @@ function registerFeedRoute(app2) {
|
|
|
3471
4197
|
r.row_vote
|
|
3472
4198
|
),
|
|
3473
4199
|
score_breakdown: breakdown,
|
|
3474
|
-
state: userId ? r.row_state
|
|
4200
|
+
state: userId ? asJobStateRead(r.row_state) : null,
|
|
3475
4201
|
application_status: asApplicationStatus(r.row_application),
|
|
3476
4202
|
vote: r.row_vote ?? null,
|
|
3477
4203
|
vote_reasons: r.row_vote_reasons ? String(r.row_vote_reasons).split(",") : []
|
|
@@ -3547,7 +4273,7 @@ function registerFeedRoute(app2) {
|
|
|
3547
4273
|
role_level: asRoleLevel(r.role_level),
|
|
3548
4274
|
score: 0,
|
|
3549
4275
|
score_breakdown: ZERO_BREAKDOWN,
|
|
3550
|
-
state: userId ? r.row_state
|
|
4276
|
+
state: userId ? asJobStateRead(r.row_state) : null,
|
|
3551
4277
|
application_status: asApplicationStatus(r.row_application)
|
|
3552
4278
|
}));
|
|
3553
4279
|
return c.json(
|
|
@@ -3654,7 +4380,7 @@ function registerPacketsRoute(app2) {
|
|
|
3654
4380
|
title: r.title,
|
|
3655
4381
|
company: r.company,
|
|
3656
4382
|
location: r.location,
|
|
3657
|
-
state: r.state,
|
|
4383
|
+
state: asJobStateRead(r.state),
|
|
3658
4384
|
variant_slug: r.variant_slug,
|
|
3659
4385
|
updated_at: r.updated_at
|
|
3660
4386
|
}));
|
|
@@ -3701,6 +4427,318 @@ function applyTier(applicationUrl, ats) {
|
|
|
3701
4427
|
if (KNOWN_UNSUPPORTED.some((h) => host === h || host.endsWith(`.${h}`))) return "unsupported";
|
|
3702
4428
|
return "unknown";
|
|
3703
4429
|
}
|
|
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);
|
|
4499
|
+
app2.openapi(
|
|
4500
|
+
createRoute({
|
|
4501
|
+
method: "get",
|
|
4502
|
+
path: "/pipeline",
|
|
4503
|
+
tags: ["Pipeline"],
|
|
4504
|
+
summary: "Every posting in the caller's pipeline, most recently changed first",
|
|
4505
|
+
request: {
|
|
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
|
+
})
|
|
4510
|
+
})
|
|
4511
|
+
},
|
|
4512
|
+
responses: {
|
|
4513
|
+
200: {
|
|
4514
|
+
description: "Pipeline",
|
|
4515
|
+
content: { "application/json": { schema: PipelineResponseSchema } }
|
|
4516
|
+
},
|
|
4517
|
+
403: FORBIDDEN$1,
|
|
4518
|
+
...IDENTITY_ERRORS
|
|
4519
|
+
}
|
|
4520
|
+
}),
|
|
4521
|
+
async (c) => {
|
|
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 [
|
|
4547
|
+
{
|
|
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);
|
|
4568
|
+
}
|
|
4569
|
+
);
|
|
4570
|
+
app2.openapi(
|
|
4571
|
+
createRoute({
|
|
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: {
|
|
4577
|
+
params: z.object({ id: z.string() }),
|
|
4578
|
+
query: ownerNameQuery,
|
|
4579
|
+
body: { content: { "application/json": { schema: SetTrackingSchema } } }
|
|
4580
|
+
},
|
|
4581
|
+
responses: {
|
|
4582
|
+
200: {
|
|
4583
|
+
description: "Tracking after the change",
|
|
4584
|
+
content: { "application/json": { schema: TrackingResponseSchema } }
|
|
4585
|
+
},
|
|
4586
|
+
403: FORBIDDEN$1,
|
|
4587
|
+
...IDENTITY_ERRORS
|
|
4588
|
+
}
|
|
4589
|
+
}),
|
|
4590
|
+
async (c) => {
|
|
4591
|
+
const { id } = c.req.valid("param");
|
|
4592
|
+
const { ownerName } = c.req.valid("query");
|
|
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);
|
|
4596
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
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);
|
|
4610
|
+
}
|
|
4611
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
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);
|
|
4620
|
+
}
|
|
4621
|
+
return c.json(
|
|
4622
|
+
{ success: true, data: { job_id: id, tracking: toTracking(row) } },
|
|
4623
|
+
200
|
|
4624
|
+
);
|
|
4625
|
+
}
|
|
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
|
+
);
|
|
4700
|
+
app2.openapi(
|
|
4701
|
+
createRoute({
|
|
4702
|
+
method: "delete",
|
|
4703
|
+
path: "/jobs/{id}/events/{eventId}",
|
|
4704
|
+
tags: ["Pipeline"],
|
|
4705
|
+
summary: "Remove a note/interview/contact/follow-up the caller logged",
|
|
4706
|
+
request: {
|
|
4707
|
+
params: z.object({ id: z.string(), eventId: z.coerce.number().int().positive() }),
|
|
4708
|
+
query: ownerNameQuery
|
|
4709
|
+
},
|
|
4710
|
+
responses: {
|
|
4711
|
+
200: {
|
|
4712
|
+
description: "Removed (or already absent)",
|
|
4713
|
+
content: {
|
|
4714
|
+
"application/json": {
|
|
4715
|
+
schema: z.object({
|
|
4716
|
+
success: z.literal(true),
|
|
4717
|
+
data: z.object({ id: z.number().int(), deleted: z.boolean() })
|
|
4718
|
+
}).openapi("DeleteJobEventResponse")
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
},
|
|
4722
|
+
403: FORBIDDEN$1,
|
|
4723
|
+
...IDENTITY_ERRORS
|
|
4724
|
+
}
|
|
4725
|
+
}),
|
|
4726
|
+
async (c) => {
|
|
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
|
+
);
|
|
4739
|
+
}
|
|
4740
|
+
);
|
|
4741
|
+
}
|
|
3704
4742
|
function registerDetailRoute(app2) {
|
|
3705
4743
|
app2.openapi(
|
|
3706
4744
|
createRoute({
|
|
@@ -3758,6 +4796,7 @@ function registerDetailRoute(app2) {
|
|
|
3758
4796
|
const userId = await maybeUserId(c);
|
|
3759
4797
|
let state = null;
|
|
3760
4798
|
let stateUpdatedAt = null;
|
|
4799
|
+
let tracking = null;
|
|
3761
4800
|
let applyVerified = false;
|
|
3762
4801
|
if (userId && row.ats && row.slug) {
|
|
3763
4802
|
const proven = await db.prepare(
|
|
@@ -3779,12 +4818,15 @@ function registerDetailRoute(app2) {
|
|
|
3779
4818
|
let applicationStatus = null;
|
|
3780
4819
|
if (userId) {
|
|
3781
4820
|
const [stateRes, appRes] = await db.batch([
|
|
3782
|
-
db.prepare(
|
|
4821
|
+
db.prepare(
|
|
4822
|
+
`SELECT state, updated_at, ${TRACKING_COLS} FROM job_states WHERE job_id = ? AND user_id = ?`
|
|
4823
|
+
).bind(id, userId),
|
|
3783
4824
|
db.prepare("SELECT status FROM applications WHERE job_id = ? AND user_id = ?").bind(id, userId)
|
|
3784
4825
|
]);
|
|
3785
4826
|
const stateRow = stateRes.results[0];
|
|
3786
|
-
state = stateRow
|
|
4827
|
+
state = asJobStateRead(stateRow?.state);
|
|
3787
4828
|
stateUpdatedAt = stateRow?.updated_at ?? null;
|
|
4829
|
+
tracking = stateRow ? toTracking(stateRow) : null;
|
|
3788
4830
|
const appRow = appRes.results[0];
|
|
3789
4831
|
applicationStatus = appRow?.status ?? null;
|
|
3790
4832
|
}
|
|
@@ -3820,7 +4862,8 @@ function registerDetailRoute(app2) {
|
|
|
3820
4862
|
score_breakdown,
|
|
3821
4863
|
state,
|
|
3822
4864
|
application_status: asApplicationStatus(applicationStatus),
|
|
3823
|
-
state_updated_at: stateUpdatedAt
|
|
4865
|
+
state_updated_at: stateUpdatedAt,
|
|
4866
|
+
tracking
|
|
3824
4867
|
};
|
|
3825
4868
|
return c.json({ success: true, data: { job } }, 200);
|
|
3826
4869
|
}
|
|
@@ -3865,7 +4908,11 @@ function registerStateRoutes(app2) {
|
|
|
3865
4908
|
const { id } = c.req.valid("param");
|
|
3866
4909
|
const body = c.req.valid("json");
|
|
3867
4910
|
const db = c.env.JOB_PLATFORM_DB;
|
|
3868
|
-
const exists = await db.prepare(
|
|
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();
|
|
3869
4916
|
if (!exists) {
|
|
3870
4917
|
return c.json(
|
|
3871
4918
|
{ success: false, error: "Not found", message: `Job '${id}' not found` },
|
|
@@ -3874,14 +4921,26 @@ function registerStateRoutes(app2) {
|
|
|
3874
4921
|
}
|
|
3875
4922
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3876
4923
|
const variantSlug = body.variant_slug ?? null;
|
|
3877
|
-
|
|
4924
|
+
const upsert = db.prepare(
|
|
3878
4925
|
`INSERT INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug)
|
|
3879
4926
|
VALUES (?, ?, ?, NULL, ?, ?)
|
|
3880
4927
|
ON CONFLICT (job_id, user_id) DO UPDATE SET
|
|
3881
4928
|
state = excluded.state,
|
|
3882
4929
|
updated_at = excluded.updated_at,
|
|
3883
4930
|
variant_slug = COALESCE(excluded.variant_slug, job_states.variant_slug)`
|
|
3884
|
-
).bind(id, userId, body.state, now, variantSlug)
|
|
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
|
+
}
|
|
3885
4944
|
return c.json(
|
|
3886
4945
|
{
|
|
3887
4946
|
success: true,
|
|
@@ -3896,7 +4955,7 @@ function registerStateRoutes(app2) {
|
|
|
3896
4955
|
method: "delete",
|
|
3897
4956
|
path: "/jobs/{id}/state",
|
|
3898
4957
|
tags: ["Jobs"],
|
|
3899
|
-
summary: 'Clear triage state for a job — returns the row to implicit "new"',
|
|
4958
|
+
summary: 'Clear triage state for a job — returns the row to implicit "new" and drops its timeline',
|
|
3900
4959
|
request: {
|
|
3901
4960
|
params: z.object({ id: z.string() })
|
|
3902
4961
|
},
|
|
@@ -3927,9 +4986,11 @@ function registerStateRoutes(app2) {
|
|
|
3927
4986
|
);
|
|
3928
4987
|
}
|
|
3929
4988
|
const { id } = c.req.valid("param");
|
|
3930
|
-
const
|
|
3931
|
-
|
|
3932
|
-
|
|
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
|
+
]);
|
|
3933
4994
|
const deleted = (result.meta?.changes ?? 0) > 0;
|
|
3934
4995
|
return c.json({ success: true, data: { job_id: id, deleted } }, 200);
|
|
3935
4996
|
}
|
|
@@ -4059,8 +5120,8 @@ async function callResumeBinding(env, path, payload) {
|
|
|
4059
5120
|
body: JSON.stringify(payload)
|
|
4060
5121
|
});
|
|
4061
5122
|
}
|
|
4062
|
-
async function fetchMintedResume(env,
|
|
4063
|
-
const res = await resumeViaEdge(env, `/resume/api/resume?v=${encodeURIComponent(
|
|
5123
|
+
async function fetchMintedResume(env, slug2) {
|
|
5124
|
+
const res = await resumeViaEdge(env, `/resume/api/resume?v=${encodeURIComponent(slug2)}`, {
|
|
4064
5125
|
method: "GET"
|
|
4065
5126
|
});
|
|
4066
5127
|
if (!res.ok) return { expired: true };
|
|
@@ -4173,13 +5234,22 @@ function registerTailoringRoutes(app2) {
|
|
|
4173
5234
|
);
|
|
4174
5235
|
}
|
|
4175
5236
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
5237
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
5238
|
+
await db.batch([
|
|
5239
|
+
db.prepare(
|
|
5240
|
+
`INSERT INTO job_events
|
|
5241
|
+
(job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
|
|
5242
|
+
SELECT ?1, ?2, 'state', NULL, 'saved', NULL, ?3, ?3
|
|
5243
|
+
WHERE NOT EXISTS (SELECT 1 FROM job_states WHERE job_id = ?1 AND user_id = ?2)`
|
|
5244
|
+
).bind(id, who.userId, now),
|
|
5245
|
+
db.prepare(
|
|
5246
|
+
`INSERT INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug)
|
|
5247
|
+
VALUES (?, ?, 'saved', NULL, ?, ?)
|
|
5248
|
+
ON CONFLICT (job_id, user_id) DO UPDATE SET
|
|
5249
|
+
variant_slug = excluded.variant_slug,
|
|
5250
|
+
updated_at = excluded.updated_at`
|
|
5251
|
+
).bind(id, who.userId, now, variant.slug)
|
|
5252
|
+
]);
|
|
4183
5253
|
const url = `https://hadoku.me/resume?v=${encodeURIComponent(variant.slug)}`;
|
|
4184
5254
|
return c.json({ success: true, data: { slug: variant.slug, url } }, 200);
|
|
4185
5255
|
});
|
|
@@ -4195,8 +5265,8 @@ function registerTailoringRoutes(app2) {
|
|
|
4195
5265
|
const row = userId ? await c.env.JOB_PLATFORM_DB.prepare(
|
|
4196
5266
|
"SELECT variant_slug FROM job_states WHERE job_id = ? AND user_id = ?"
|
|
4197
5267
|
).bind(id, userId).first() : null;
|
|
4198
|
-
const
|
|
4199
|
-
if (!
|
|
5268
|
+
const slug2 = row?.variant_slug ?? null;
|
|
5269
|
+
if (!slug2) {
|
|
4200
5270
|
return c.json(
|
|
4201
5271
|
{
|
|
4202
5272
|
success: false,
|
|
@@ -4206,13 +5276,13 @@ function registerTailoringRoutes(app2) {
|
|
|
4206
5276
|
400
|
|
4207
5277
|
);
|
|
4208
5278
|
}
|
|
4209
|
-
const minted = await fetchMintedResume(c.env,
|
|
5279
|
+
const minted = await fetchMintedResume(c.env, slug2);
|
|
4210
5280
|
if ("expired" in minted) {
|
|
4211
5281
|
return c.json(
|
|
4212
5282
|
{
|
|
4213
5283
|
success: false,
|
|
4214
5284
|
error: "Bad request",
|
|
4215
|
-
message: `The packet for this job ('${
|
|
5285
|
+
message: `The packet for this job ('${slug2}') has expired; re-prepare it or post resume_markdown.`
|
|
4216
5286
|
},
|
|
4217
5287
|
400
|
|
4218
5288
|
);
|
|
@@ -4936,7 +6006,7 @@ function registerOwnerRoutes(app2) {
|
|
|
4936
6006
|
c.env.HADOKU_SERVICE_KEY ?? "",
|
|
4937
6007
|
name,
|
|
4938
6008
|
Date.now(),
|
|
4939
|
-
(
|
|
6009
|
+
(label2, work) => runAfterResponse(c, label2, work)
|
|
4940
6010
|
);
|
|
4941
6011
|
if (isIdentityError(resolved)) {
|
|
4942
6012
|
const status = [400, 404, 409, 503].includes(resolved.status) ? resolved.status : 503;
|
|
@@ -4954,8 +6024,10 @@ function registerOwnerRoutes(app2) {
|
|
|
4954
6024
|
await db.batch([
|
|
4955
6025
|
db.prepare("UPDATE applications SET user_id = ?, updated_at = ? WHERE id = ?").bind(resolved.userId, now, id),
|
|
4956
6026
|
db.prepare(
|
|
4957
|
-
`INSERT OR REPLACE INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug
|
|
4958
|
-
|
|
6027
|
+
`INSERT OR REPLACE INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug,
|
|
6028
|
+
priority, follow_up_at, contact_name, contact_email)
|
|
6029
|
+
SELECT job_id, ?, state, notes, ?, variant_slug,
|
|
6030
|
+
priority, follow_up_at, contact_name, contact_email
|
|
4959
6031
|
FROM job_states WHERE job_id = ? AND user_id = ?`
|
|
4960
6032
|
).bind(resolved.userId, now, row.job_id, row.user_id)
|
|
4961
6033
|
]);
|
|
@@ -5558,7 +6630,7 @@ function registerFillRoutes(app2) {
|
|
|
5558
6630
|
WHERE user_id = ? AND job_id = ?`
|
|
5559
6631
|
).bind(who.userId, id).first();
|
|
5560
6632
|
const state = await db.prepare("SELECT variant_slug FROM job_states WHERE user_id = ? AND job_id = ?").bind(who.userId, id).first();
|
|
5561
|
-
const
|
|
6633
|
+
const slug2 = app_?.variant_slug || state?.variant_slug || "";
|
|
5562
6634
|
const evidence = parseEvidence(app_?.evidence ?? null) ?? {};
|
|
5563
6635
|
const answers = typeof evidence.answers === "object" && evidence.answers !== null ? evidence.answers : {};
|
|
5564
6636
|
const options = typeof evidence.options === "object" && evidence.options !== null ? evidence.options : {};
|
|
@@ -5570,7 +6642,7 @@ function registerFillRoutes(app2) {
|
|
|
5570
6642
|
success: true,
|
|
5571
6643
|
data: {
|
|
5572
6644
|
job,
|
|
5573
|
-
application: app_ ? { id: app_.id, status: app_.status, variant_slug:
|
|
6645
|
+
application: app_ ? { id: app_.id, status: app_.status, variant_slug: slug2 } : null,
|
|
5574
6646
|
profile: profile?.profile ?? null,
|
|
5575
6647
|
// What the approved fill entered, keyed by the runner's normalised
|
|
5576
6648
|
// question — the primary source, because it is what was reviewed.
|
|
@@ -5580,24 +6652,475 @@ function registerFillRoutes(app2) {
|
|
|
5580
6652
|
// Every saved answer by its question, for anything the fill did
|
|
5581
6653
|
// not record (a field added since, a structured block).
|
|
5582
6654
|
standing: Object.fromEntries(standing.results.map((r) => [r.question, r.answer])),
|
|
5583
|
-
resume_pdf_url:
|
|
6655
|
+
resume_pdf_url: slug2 ? `${RESUME_PDF_URL}?v=${encodeURIComponent(slug2)}` : null
|
|
5584
6656
|
}
|
|
5585
6657
|
},
|
|
5586
6658
|
200
|
|
5587
6659
|
);
|
|
5588
6660
|
});
|
|
5589
6661
|
}
|
|
6662
|
+
const ERRORS$1 = {
|
|
6663
|
+
403: {
|
|
6664
|
+
description: "Forbidden",
|
|
6665
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6666
|
+
},
|
|
6667
|
+
404: {
|
|
6668
|
+
description: "No such owner name",
|
|
6669
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6670
|
+
},
|
|
6671
|
+
409: {
|
|
6672
|
+
description: "That owner name has never signed in",
|
|
6673
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6674
|
+
},
|
|
6675
|
+
503: {
|
|
6676
|
+
description: "Identity could not be resolved right now — retry",
|
|
6677
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6678
|
+
}
|
|
6679
|
+
};
|
|
6680
|
+
function registerBlockedRoutes(app2) {
|
|
6681
|
+
app2.get("/blocked-companies", gateAuthed);
|
|
6682
|
+
app2.put("/blocked-companies", gateAuthed);
|
|
6683
|
+
app2.delete("/blocked-companies", gateAuthed);
|
|
6684
|
+
app2.openapi(
|
|
6685
|
+
createRoute({
|
|
6686
|
+
method: "get",
|
|
6687
|
+
path: "/blocked-companies",
|
|
6688
|
+
tags: ["Feed"],
|
|
6689
|
+
summary: "The companies the caller has blocked from their feed",
|
|
6690
|
+
request: { query: ownerNameQuery },
|
|
6691
|
+
responses: {
|
|
6692
|
+
200: {
|
|
6693
|
+
description: "Blocked companies, newest first",
|
|
6694
|
+
content: { "application/json": { schema: BlockedCompaniesResponseSchema } }
|
|
6695
|
+
},
|
|
6696
|
+
...ERRORS$1
|
|
6697
|
+
}
|
|
6698
|
+
}),
|
|
6699
|
+
async (c) => {
|
|
6700
|
+
const who = await effectiveUserId(c, c.req.valid("query").ownerName);
|
|
6701
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6702
|
+
const rows = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6703
|
+
`SELECT company, company_key, created_at FROM blocked_companies
|
|
6704
|
+
WHERE user_id = ? ORDER BY created_at DESC`
|
|
6705
|
+
).bind(who.userId).all();
|
|
6706
|
+
return c.json({ success: true, data: { companies: rows.results } }, 200);
|
|
6707
|
+
}
|
|
6708
|
+
);
|
|
6709
|
+
app2.openapi(
|
|
6710
|
+
createRoute({
|
|
6711
|
+
method: "put",
|
|
6712
|
+
path: "/blocked-companies",
|
|
6713
|
+
tags: ["Feed"],
|
|
6714
|
+
summary: "Block a company from the caller's feed (idempotent)",
|
|
6715
|
+
request: {
|
|
6716
|
+
query: ownerNameQuery,
|
|
6717
|
+
body: { content: { "application/json": { schema: BlockCompanySchema } } }
|
|
6718
|
+
},
|
|
6719
|
+
responses: {
|
|
6720
|
+
200: {
|
|
6721
|
+
description: "Blocked (or already was — the first spelling is kept)",
|
|
6722
|
+
content: { "application/json": { schema: BlockedCompanyResponseSchema } }
|
|
6723
|
+
},
|
|
6724
|
+
...ERRORS$1
|
|
6725
|
+
}
|
|
6726
|
+
}),
|
|
6727
|
+
async (c) => {
|
|
6728
|
+
const who = await effectiveUserId(c, c.req.valid("query").ownerName);
|
|
6729
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6730
|
+
const { company } = c.req.valid("json");
|
|
6731
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
6732
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6733
|
+
await db.prepare(
|
|
6734
|
+
`INSERT INTO blocked_companies (user_id, company_key, company, created_at)
|
|
6735
|
+
VALUES (?1, LOWER(TRIM(?2)), TRIM(?2), ?3)
|
|
6736
|
+
ON CONFLICT (user_id, company_key) DO NOTHING`
|
|
6737
|
+
).bind(who.userId, company, now).run();
|
|
6738
|
+
const row = await db.prepare(
|
|
6739
|
+
`SELECT company, company_key, created_at FROM blocked_companies
|
|
6740
|
+
WHERE user_id = ? AND company_key = LOWER(TRIM(?))`
|
|
6741
|
+
).bind(who.userId, company).first();
|
|
6742
|
+
if (!row) throw new Error("blocked_companies row missing straight after its insert");
|
|
6743
|
+
return c.json({ success: true, data: { company: row } }, 200);
|
|
6744
|
+
}
|
|
6745
|
+
);
|
|
6746
|
+
app2.openapi(
|
|
6747
|
+
createRoute({
|
|
6748
|
+
method: "delete",
|
|
6749
|
+
path: "/blocked-companies",
|
|
6750
|
+
tags: ["Feed"],
|
|
6751
|
+
summary: "Unblock a company",
|
|
6752
|
+
request: {
|
|
6753
|
+
query: ownerNameQuery.extend({
|
|
6754
|
+
company: z.string().trim().min(1).max(200).openapi({
|
|
6755
|
+
description: "The company, in any case — matched the way it was blocked."
|
|
6756
|
+
})
|
|
6757
|
+
})
|
|
6758
|
+
},
|
|
6759
|
+
responses: {
|
|
6760
|
+
200: {
|
|
6761
|
+
description: "Unblocked (or was not blocked)",
|
|
6762
|
+
content: { "application/json": { schema: UnblockCompanyResponseSchema } }
|
|
6763
|
+
},
|
|
6764
|
+
...ERRORS$1
|
|
6765
|
+
}
|
|
6766
|
+
}),
|
|
6767
|
+
async (c) => {
|
|
6768
|
+
const { ownerName, company } = c.req.valid("query");
|
|
6769
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6770
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6771
|
+
const row = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6772
|
+
`DELETE FROM blocked_companies WHERE user_id = ? AND company_key = LOWER(TRIM(?))
|
|
6773
|
+
RETURNING company_key`
|
|
6774
|
+
).bind(who.userId, company).first();
|
|
6775
|
+
return c.json(
|
|
6776
|
+
{
|
|
6777
|
+
success: true,
|
|
6778
|
+
data: { company_key: row?.company_key ?? company.toLowerCase(), deleted: !!row }
|
|
6779
|
+
},
|
|
6780
|
+
200
|
|
6781
|
+
);
|
|
6782
|
+
}
|
|
6783
|
+
);
|
|
6784
|
+
}
|
|
6785
|
+
const SKILLS = [
|
|
6786
|
+
// Languages
|
|
6787
|
+
{ name: "python", aliases: ["python"] },
|
|
6788
|
+
{ name: "javascript", aliases: ["javascript"] },
|
|
6789
|
+
{ name: "typescript", aliases: ["typescript"] },
|
|
6790
|
+
{ name: "java", aliases: ["java"] },
|
|
6791
|
+
{ name: "kotlin", aliases: ["kotlin"] },
|
|
6792
|
+
{ name: "c++", aliases: ["c++"] },
|
|
6793
|
+
{ name: "c#", aliases: ["c#"] },
|
|
6794
|
+
{ name: "rust", aliases: ["rust"] },
|
|
6795
|
+
{ name: "ruby", aliases: ["ruby"] },
|
|
6796
|
+
{ name: "php", aliases: ["php"] },
|
|
6797
|
+
{ name: "swift", aliases: ["swift"] },
|
|
6798
|
+
{ name: "scala", aliases: ["scala"] },
|
|
6799
|
+
{ name: "sql", aliases: ["sql"] },
|
|
6800
|
+
{ name: "bash", aliases: ["bash", "shell scripting"] },
|
|
6801
|
+
// Frontend
|
|
6802
|
+
{ name: "react", aliases: ["react", "reactjs", "react.js"] },
|
|
6803
|
+
{ name: "vue", aliases: ["vue", "vue.js", "vuejs"] },
|
|
6804
|
+
{ name: "angular", aliases: ["angular"] },
|
|
6805
|
+
{ name: "svelte", aliases: ["svelte"] },
|
|
6806
|
+
{ name: "next.js", aliases: ["next.js", "nextjs"] },
|
|
6807
|
+
{ name: "redux", aliases: ["redux"] },
|
|
6808
|
+
{ name: "tailwind", aliases: ["tailwind", "tailwindcss"] },
|
|
6809
|
+
{ name: "graphql", aliases: ["graphql"] },
|
|
6810
|
+
// Backend
|
|
6811
|
+
{ name: "node.js", aliases: ["node.js", "nodejs", "node"] },
|
|
6812
|
+
{ name: "django", aliases: ["django"] },
|
|
6813
|
+
{ name: "flask", aliases: ["flask"] },
|
|
6814
|
+
{ name: "fastapi", aliases: ["fastapi"] },
|
|
6815
|
+
{ name: "spring", aliases: ["spring", "spring boot"] },
|
|
6816
|
+
{ name: "rails", aliases: ["rails", "ruby on rails"] },
|
|
6817
|
+
{ name: ".net", aliases: [".net", "asp.net"] },
|
|
6818
|
+
{ name: "grpc", aliases: ["grpc"] },
|
|
6819
|
+
{ name: "rest", aliases: ["rest", "restful", "rest api", "rest apis"] },
|
|
6820
|
+
{ name: "microservices", aliases: ["microservices", "microservice"] },
|
|
6821
|
+
// Data stores
|
|
6822
|
+
{ name: "postgresql", aliases: ["postgresql", "postgres"] },
|
|
6823
|
+
{ name: "mysql", aliases: ["mysql"] },
|
|
6824
|
+
{ name: "mongodb", aliases: ["mongodb", "mongo"] },
|
|
6825
|
+
{ name: "redis", aliases: ["redis"] },
|
|
6826
|
+
{ name: "elasticsearch", aliases: ["elasticsearch", "opensearch"] },
|
|
6827
|
+
{ name: "dynamodb", aliases: ["dynamodb"] },
|
|
6828
|
+
{ name: "cassandra", aliases: ["cassandra"] },
|
|
6829
|
+
{ name: "snowflake", aliases: ["snowflake"] },
|
|
6830
|
+
// Cloud and infrastructure
|
|
6831
|
+
{ name: "aws", aliases: ["aws", "amazon web services"] },
|
|
6832
|
+
{ name: "gcp", aliases: ["gcp", "google cloud"] },
|
|
6833
|
+
{ name: "azure", aliases: ["azure"] },
|
|
6834
|
+
{ name: "docker", aliases: ["docker"] },
|
|
6835
|
+
{ name: "kubernetes", aliases: ["kubernetes", "k8s"] },
|
|
6836
|
+
{ name: "terraform", aliases: ["terraform"] },
|
|
6837
|
+
{ name: "ansible", aliases: ["ansible"] },
|
|
6838
|
+
{ name: "helm", aliases: ["helm"] },
|
|
6839
|
+
{ name: "ci/cd", aliases: ["ci/cd", "continuous integration"] },
|
|
6840
|
+
{ name: "github actions", aliases: ["github actions"] },
|
|
6841
|
+
{ name: "linux", aliases: ["linux"] },
|
|
6842
|
+
// Streaming and distributed systems
|
|
6843
|
+
{ name: "kafka", aliases: ["kafka"] },
|
|
6844
|
+
{ name: "rabbitmq", aliases: ["rabbitmq"] },
|
|
6845
|
+
{ name: "spark", aliases: ["spark", "pyspark"] },
|
|
6846
|
+
{ name: "airflow", aliases: ["airflow"] },
|
|
6847
|
+
{ name: "distributed systems", aliases: ["distributed systems"] },
|
|
6848
|
+
// ML and data
|
|
6849
|
+
{ name: "machine learning", aliases: ["machine learning"] },
|
|
6850
|
+
{ name: "deep learning", aliases: ["deep learning"] },
|
|
6851
|
+
{ name: "llm", aliases: ["llm", "llms", "large language model", "large language models"] },
|
|
6852
|
+
{ name: "pytorch", aliases: ["pytorch"] },
|
|
6853
|
+
{ name: "tensorflow", aliases: ["tensorflow"] },
|
|
6854
|
+
{ name: "pandas", aliases: ["pandas"] },
|
|
6855
|
+
{ name: "etl", aliases: ["etl"] },
|
|
6856
|
+
// Security and identity
|
|
6857
|
+
{ name: "security", aliases: ["cybersecurity", "information security", "application security"] },
|
|
6858
|
+
{ name: "oauth", aliases: ["oauth", "oauth2", "oidc", "openid connect"] }
|
|
6859
|
+
];
|
|
6860
|
+
const GO_RE = new RegExp(
|
|
6861
|
+
[
|
|
6862
|
+
String.raw`\b(?:golang|go\s*lang(?:uage)?)\b`,
|
|
6863
|
+
String.raw`\bgo\s+(?:developer|engineer|dev|code|programming|backend)\b`,
|
|
6864
|
+
String.raw`\b(?:built|written|implemented|develop(?:ed)?|experience|coding|proficient|fluency|programming|looking for|seeking)\s+(?:in|with|for)?\s*go\b`,
|
|
6865
|
+
String.raw`(?:\bpython|\bjava|\bc\+\+|\brust|\bc#|\bkubernetes|\bdocker|\btypescript|\breact|\bkafka|\baws)[,\s/]+go\b`,
|
|
6866
|
+
String.raw`\bgo[,\s/]+(?:python|java|rust|c\+\+|c#|kubernetes|docker|typescript|react|kafka|aws)\b`
|
|
6867
|
+
].join("|"),
|
|
6868
|
+
"i"
|
|
6869
|
+
);
|
|
6870
|
+
function escape(s) {
|
|
6871
|
+
return s.replace(/[.*+?^${}()|[\]\\/#]/g, "\\$&");
|
|
6872
|
+
}
|
|
6873
|
+
const MATCHERS = SKILLS.map(({ name, aliases }) => [
|
|
6874
|
+
new RegExp(
|
|
6875
|
+
`(?:^|[^a-z0-9+#.])(?:${aliases.map(escape).join("|")})(?=$|[^a-z0-9+#]|\\.(?:[^a-z0-9]|$))`,
|
|
6876
|
+
"i"
|
|
6877
|
+
),
|
|
6878
|
+
name
|
|
6879
|
+
]);
|
|
6880
|
+
function plain(text) {
|
|
6881
|
+
return text.replace(/<[^>]*>/g, " ").replace(/ | /gi, " ").replace(/&/gi, "&");
|
|
6882
|
+
}
|
|
6883
|
+
function extractSkills(text) {
|
|
6884
|
+
const t = plain(text);
|
|
6885
|
+
const found = [];
|
|
6886
|
+
for (const [re, name] of MATCHERS) if (re.test(t)) found.push(name);
|
|
6887
|
+
if (GO_RE.test(t)) found.push("go");
|
|
6888
|
+
return found;
|
|
6889
|
+
}
|
|
6890
|
+
function canonicalSkill(term) {
|
|
6891
|
+
const t = term.trim().toLowerCase();
|
|
6892
|
+
if (t === "go" || t === "golang") return "go";
|
|
6893
|
+
for (const { name, aliases } of SKILLS) if (aliases.includes(t)) return name;
|
|
6894
|
+
return t;
|
|
6895
|
+
}
|
|
6896
|
+
const WEEKS = 12;
|
|
6897
|
+
const TOP_SKILLS = 15;
|
|
6898
|
+
const APPLIED_OR_LATER = /* @__PURE__ */ new Set(["applied", "interviewing", "offered", "rejected"]);
|
|
6899
|
+
const INTERVIEWED = /* @__PURE__ */ new Set(["interviewing", "offered"]);
|
|
6900
|
+
function weekOf(iso) {
|
|
6901
|
+
const d = new Date(iso);
|
|
6902
|
+
const sinceMonday = (d.getUTCDay() + 6) % 7;
|
|
6903
|
+
d.setUTCDate(d.getUTCDate() - sinceMonday);
|
|
6904
|
+
return d.toISOString().slice(0, 10);
|
|
6905
|
+
}
|
|
6906
|
+
function parseList(raw) {
|
|
6907
|
+
try {
|
|
6908
|
+
const v = JSON.parse(raw);
|
|
6909
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
6910
|
+
} catch {
|
|
6911
|
+
return [];
|
|
6912
|
+
}
|
|
6913
|
+
}
|
|
6914
|
+
const ERRORS = {
|
|
6915
|
+
403: {
|
|
6916
|
+
description: "Forbidden",
|
|
6917
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6918
|
+
},
|
|
6919
|
+
404: {
|
|
6920
|
+
description: "No such owner name",
|
|
6921
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6922
|
+
},
|
|
6923
|
+
409: {
|
|
6924
|
+
description: "That owner name has never signed in",
|
|
6925
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6926
|
+
},
|
|
6927
|
+
503: {
|
|
6928
|
+
description: "Identity could not be resolved right now — retry",
|
|
6929
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6930
|
+
}
|
|
6931
|
+
};
|
|
6932
|
+
function registerInsightsRoute(app2) {
|
|
6933
|
+
app2.get("/insights", gateAuthed);
|
|
6934
|
+
app2.openapi(
|
|
6935
|
+
createRoute({
|
|
6936
|
+
method: "get",
|
|
6937
|
+
path: "/insights",
|
|
6938
|
+
tags: ["Pipeline"],
|
|
6939
|
+
summary: "The caller's funnel, response rates, weekly pace, workplace mix and skill demand",
|
|
6940
|
+
request: { query: ownerNameQuery },
|
|
6941
|
+
responses: {
|
|
6942
|
+
200: {
|
|
6943
|
+
description: "Insights",
|
|
6944
|
+
content: { "application/json": { schema: InsightsResponseSchema } }
|
|
6945
|
+
},
|
|
6946
|
+
...ERRORS
|
|
6947
|
+
}
|
|
6948
|
+
}),
|
|
6949
|
+
async (c) => {
|
|
6950
|
+
const who = await effectiveUserId(c, c.req.valid("query").ownerName);
|
|
6951
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6952
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
6953
|
+
const [stateCounts, rows, transitions, submitted, profiles] = await db.batch([
|
|
6954
|
+
db.prepare("SELECT state, COUNT(*) AS n FROM job_states WHERE user_id = ? GROUP BY state").bind(who.userId),
|
|
6955
|
+
// Dismissed postings are counted above and nowhere else: they are not
|
|
6956
|
+
// part of the search, and there can be thousands of them.
|
|
6957
|
+
db.prepare(
|
|
6958
|
+
`SELECT js.job_id, js.state, js.updated_at, j.workplace_type, j.description
|
|
6959
|
+
FROM job_states js JOIN jobs j ON j.id = js.job_id
|
|
6960
|
+
WHERE js.user_id = ? AND js.state != 'dismissed'`
|
|
6961
|
+
).bind(who.userId),
|
|
6962
|
+
db.prepare(
|
|
6963
|
+
`SELECT job_id, to_state, occurred_at FROM job_events
|
|
6964
|
+
WHERE user_id = ? AND kind = 'state'`
|
|
6965
|
+
).bind(who.userId),
|
|
6966
|
+
db.prepare(
|
|
6967
|
+
`SELECT job_id, updated_at FROM applications
|
|
6968
|
+
WHERE user_id = ? AND status = 'submitted'`
|
|
6969
|
+
).bind(who.userId),
|
|
6970
|
+
db.prepare("SELECT stack FROM profiles WHERE user_id = ?").bind(who.userId)
|
|
6971
|
+
]);
|
|
6972
|
+
const states = {};
|
|
6973
|
+
for (const r of stateCounts.results) states[r.state] = r.n;
|
|
6974
|
+
const been = /* @__PURE__ */ new Map();
|
|
6975
|
+
const appliedAt = /* @__PURE__ */ new Map();
|
|
6976
|
+
const earliest = (id, at) => {
|
|
6977
|
+
const cur = appliedAt.get(id);
|
|
6978
|
+
if (!cur || at < cur) appliedAt.set(id, at);
|
|
6979
|
+
};
|
|
6980
|
+
const mark = (id, state) => {
|
|
6981
|
+
let s = been.get(id);
|
|
6982
|
+
if (!s) been.set(id, s = /* @__PURE__ */ new Set());
|
|
6983
|
+
s.add(state);
|
|
6984
|
+
};
|
|
6985
|
+
for (const t of transitions.results) {
|
|
6986
|
+
if (!t.to_state) continue;
|
|
6987
|
+
mark(t.job_id, t.to_state);
|
|
6988
|
+
if (t.to_state === "applied") earliest(t.job_id, t.occurred_at);
|
|
6989
|
+
}
|
|
6990
|
+
for (const a of submitted.results) {
|
|
6991
|
+
mark(a.job_id, "applied");
|
|
6992
|
+
earliest(a.job_id, a.updated_at);
|
|
6993
|
+
}
|
|
6994
|
+
const pipeline = rows.results;
|
|
6995
|
+
const workplaceById = /* @__PURE__ */ new Map();
|
|
6996
|
+
for (const r of pipeline) {
|
|
6997
|
+
mark(r.job_id, r.state);
|
|
6998
|
+
workplaceById.set(r.job_id, r.workplace_type || "unknown");
|
|
6999
|
+
if (APPLIED_OR_LATER.has(r.state)) earliest(r.job_id, r.updated_at);
|
|
7000
|
+
}
|
|
7001
|
+
let applied = 0;
|
|
7002
|
+
let interviewed = 0;
|
|
7003
|
+
let offered = 0;
|
|
7004
|
+
let responded = 0;
|
|
7005
|
+
const workplace = /* @__PURE__ */ new Map();
|
|
7006
|
+
for (const [id, s] of been) {
|
|
7007
|
+
const isApplied = [...s].some((x) => APPLIED_OR_LATER.has(x));
|
|
7008
|
+
if (!isApplied) continue;
|
|
7009
|
+
applied++;
|
|
7010
|
+
const iv = [...s].some((x) => INTERVIEWED.has(x));
|
|
7011
|
+
if (iv) interviewed++;
|
|
7012
|
+
if (s.has("offered")) offered++;
|
|
7013
|
+
if (iv || s.has("rejected")) responded++;
|
|
7014
|
+
const w = workplaceById.get(id);
|
|
7015
|
+
if (w) workplace.set(w, (workplace.get(w) ?? 0) + 1);
|
|
7016
|
+
}
|
|
7017
|
+
const share = (n) => applied ? n / applied : null;
|
|
7018
|
+
const thisWeek = weekOf((/* @__PURE__ */ new Date()).toISOString());
|
|
7019
|
+
const weeks = [];
|
|
7020
|
+
for (let i = WEEKS - 1; i >= 0; i--) {
|
|
7021
|
+
const d = /* @__PURE__ */ new Date(`${thisWeek}T00:00:00Z`);
|
|
7022
|
+
d.setUTCDate(d.getUTCDate() - 7 * i);
|
|
7023
|
+
weeks.push(d.toISOString().slice(0, 10));
|
|
7024
|
+
}
|
|
7025
|
+
const perWeek = new Map(weeks.map((w) => [w, 0]));
|
|
7026
|
+
for (const at of appliedAt.values()) {
|
|
7027
|
+
const w = weekOf(at);
|
|
7028
|
+
if (perWeek.has(w)) perWeek.set(w, perWeek.get(w) + 1);
|
|
7029
|
+
}
|
|
7030
|
+
const stack = new Set(
|
|
7031
|
+
profiles.results.flatMap(
|
|
7032
|
+
(p) => parseList(p.stack).map(canonicalSkill)
|
|
7033
|
+
)
|
|
7034
|
+
);
|
|
7035
|
+
const demand = /* @__PURE__ */ new Map();
|
|
7036
|
+
let skillPostings = 0;
|
|
7037
|
+
for (const r of pipeline) {
|
|
7038
|
+
if (!r.description) continue;
|
|
7039
|
+
skillPostings++;
|
|
7040
|
+
for (const s of extractSkills(r.description)) demand.set(s, (demand.get(s) ?? 0) + 1);
|
|
7041
|
+
}
|
|
7042
|
+
const skills = [...demand].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, TOP_SKILLS).map(([skill, postings]) => ({ skill, postings, in_stack: stack.has(skill) }));
|
|
7043
|
+
return c.json(
|
|
7044
|
+
{
|
|
7045
|
+
success: true,
|
|
7046
|
+
data: {
|
|
7047
|
+
states,
|
|
7048
|
+
reached: { applied, responded, interviewed, offered },
|
|
7049
|
+
rates: {
|
|
7050
|
+
response: share(responded),
|
|
7051
|
+
interview: share(interviewed),
|
|
7052
|
+
offer: share(offered)
|
|
7053
|
+
},
|
|
7054
|
+
weekly_applied: weeks.map((week) => ({ week, count: perWeek.get(week) })),
|
|
7055
|
+
workplace: [...workplace].sort((a, b) => b[1] - a[1]).map(([workplace_type, count]) => ({ workplace_type, count })),
|
|
7056
|
+
skills,
|
|
7057
|
+
skill_postings: skillPostings
|
|
7058
|
+
}
|
|
7059
|
+
},
|
|
7060
|
+
200
|
|
7061
|
+
);
|
|
7062
|
+
}
|
|
7063
|
+
);
|
|
7064
|
+
}
|
|
7065
|
+
const LocationSchema = z.object({ key: z.string(), label: z.string(), lat: z.number(), lng: z.number() }).openapi("Location");
|
|
7066
|
+
function label(name, region) {
|
|
7067
|
+
const title = name.replace(/\b[a-z]/g, (ch) => ch.toUpperCase());
|
|
7068
|
+
return `${title}, ${region.toUpperCase()}`;
|
|
7069
|
+
}
|
|
7070
|
+
function registerLocationsRoute(app2) {
|
|
7071
|
+
app2.openapi(
|
|
7072
|
+
createRoute({
|
|
7073
|
+
method: "get",
|
|
7074
|
+
path: "/locations",
|
|
7075
|
+
tags: ["Feed"],
|
|
7076
|
+
summary: "Known places matching a prefix, for the feed’s near= filter",
|
|
7077
|
+
request: {
|
|
7078
|
+
query: z.object({
|
|
7079
|
+
q: z.string().trim().min(1).max(100),
|
|
7080
|
+
limit: z.coerce.number().int().min(1).max(25).default(10)
|
|
7081
|
+
})
|
|
7082
|
+
},
|
|
7083
|
+
responses: {
|
|
7084
|
+
200: {
|
|
7085
|
+
description: "Matches, prefix matches first",
|
|
7086
|
+
content: {
|
|
7087
|
+
"application/json": {
|
|
7088
|
+
schema: z.object({
|
|
7089
|
+
success: z.literal(true),
|
|
7090
|
+
data: z.object({ locations: z.array(LocationSchema) })
|
|
7091
|
+
}).openapi("LocationsResponse")
|
|
7092
|
+
}
|
|
7093
|
+
}
|
|
7094
|
+
}
|
|
7095
|
+
}
|
|
7096
|
+
}),
|
|
7097
|
+
(c) => {
|
|
7098
|
+
const { q, limit } = c.req.valid("query");
|
|
7099
|
+
const locations = searchPlaces(q, limit).map((p) => ({
|
|
7100
|
+
key: p.key,
|
|
7101
|
+
label: label(p.name, p.region),
|
|
7102
|
+
lat: p.lat,
|
|
7103
|
+
lng: p.lng
|
|
7104
|
+
}));
|
|
7105
|
+
return c.json({ success: true, data: { locations } }, 200);
|
|
7106
|
+
}
|
|
7107
|
+
);
|
|
7108
|
+
}
|
|
5590
7109
|
const app$2 = new OpenAPIHono();
|
|
5591
7110
|
registerFeedRoute(app$2);
|
|
5592
7111
|
registerPreflightRoute(app$2);
|
|
5593
7112
|
registerPacketsRoute(app$2);
|
|
5594
7113
|
registerDetailRoute(app$2);
|
|
5595
7114
|
registerStateRoutes(app$2);
|
|
7115
|
+
registerPipelineRoutes(app$2);
|
|
5596
7116
|
registerFeedbackRoutes(app$2);
|
|
5597
7117
|
registerTailoringRoutes(app$2);
|
|
5598
7118
|
registerApplicationRoutes(app$2);
|
|
5599
7119
|
registerVerificationRoutes(app$2);
|
|
5600
7120
|
registerFillRoutes(app$2);
|
|
7121
|
+
registerBlockedRoutes(app$2);
|
|
7122
|
+
registerInsightsRoute(app$2);
|
|
7123
|
+
registerLocationsRoute(app$2);
|
|
5601
7124
|
registerAnswerRoutes(app$2);
|
|
5602
7125
|
registerOwnerRoutes(app$2);
|
|
5603
7126
|
const jobRoutes = app$2;
|
|
@@ -5703,12 +7226,14 @@ const ERASED_TABLES = [
|
|
|
5703
7226
|
{ table: "profiles", sql: "DELETE FROM profiles WHERE user_id = ?1" },
|
|
5704
7227
|
{ table: "profile_tombstones", sql: "DELETE FROM profile_tombstones WHERE user_id = ?1" },
|
|
5705
7228
|
{ table: "job_states", sql: "DELETE FROM job_states WHERE user_id = ?1" },
|
|
7229
|
+
{ table: "job_events", sql: "DELETE FROM job_events WHERE user_id = ?1" },
|
|
5706
7230
|
{ table: "job_feedback", sql: "DELETE FROM job_feedback WHERE user_id = ?1" },
|
|
5707
7231
|
{ table: "applications", sql: "DELETE FROM applications WHERE user_id = ?1" },
|
|
5708
7232
|
{ table: "application_answers", sql: "DELETE FROM application_answers WHERE user_id = ?1" },
|
|
5709
7233
|
{ table: "applicant_profiles", sql: "DELETE FROM applicant_profiles WHERE user_id = ?1" },
|
|
5710
7234
|
{ table: "mail_reconcile_state", sql: "DELETE FROM mail_reconcile_state WHERE user_id = ?1" },
|
|
5711
7235
|
{ table: "mail_unmatched", sql: "DELETE FROM mail_unmatched WHERE user_id = ?1" },
|
|
7236
|
+
{ table: "blocked_companies", sql: "DELETE FROM blocked_companies WHERE user_id = ?1" },
|
|
5712
7237
|
{ table: "owner_name_cache", sql: "DELETE FROM owner_name_cache WHERE user_id = ?1" }
|
|
5713
7238
|
];
|
|
5714
7239
|
async function eraseUser(db, userId) {
|
|
@@ -5793,6 +7318,7 @@ function createScheduledHandler(_env) {
|
|
|
5793
7318
|
};
|
|
5794
7319
|
}
|
|
5795
7320
|
export {
|
|
7321
|
+
AddJobEventSchema,
|
|
5796
7322
|
AddProfileCompanyResponseSchema,
|
|
5797
7323
|
AddProfileCompanySchema,
|
|
5798
7324
|
AnswerResponseSchema,
|
|
@@ -5805,6 +7331,10 @@ export {
|
|
|
5805
7331
|
ApplicationSummarySchema,
|
|
5806
7332
|
ApplicationsResponseSchema,
|
|
5807
7333
|
ApplyRequestSchema,
|
|
7334
|
+
BlockCompanySchema,
|
|
7335
|
+
BlockedCompaniesResponseSchema,
|
|
7336
|
+
BlockedCompanyResponseSchema,
|
|
7337
|
+
BlockedCompanySchema,
|
|
5808
7338
|
CompanyMatchSchema,
|
|
5809
7339
|
CreateProfileSchema,
|
|
5810
7340
|
DeleteCompanyResponseSchema,
|
|
@@ -5820,7 +7350,14 @@ export {
|
|
|
5820
7350
|
IngestJobSchema,
|
|
5821
7351
|
IngestPayloadSchema,
|
|
5822
7352
|
IngestResponseSchema,
|
|
7353
|
+
InsightsResponseSchema,
|
|
7354
|
+
InsightsSchema,
|
|
7355
|
+
JOB_STATES,
|
|
5823
7356
|
JobDetailSchema,
|
|
7357
|
+
JobEventKindSchema,
|
|
7358
|
+
JobEventResponseSchema,
|
|
7359
|
+
JobEventSchema,
|
|
7360
|
+
JobEventsResponseSchema,
|
|
5824
7361
|
JobFeedbackResponseSchema,
|
|
5825
7362
|
JobResponseSchema,
|
|
5826
7363
|
JobStateReadSchema,
|
|
@@ -5830,8 +7367,12 @@ export {
|
|
|
5830
7367
|
JobsResponseSchema,
|
|
5831
7368
|
MatchCompanyResponseSchema,
|
|
5832
7369
|
MatchCompanySchema,
|
|
7370
|
+
PERSON_EVENT_KINDS,
|
|
5833
7371
|
PacketSummarySchema,
|
|
5834
7372
|
PacketsResponseSchema,
|
|
7373
|
+
PipelineItemSchema,
|
|
7374
|
+
PipelineResponseSchema,
|
|
7375
|
+
PrioritySchema,
|
|
5835
7376
|
ProbeCompanyResponseSchema,
|
|
5836
7377
|
ProbeCompanySchema,
|
|
5837
7378
|
ProfileCompaniesResponseSchema,
|
|
@@ -5850,9 +7391,14 @@ export {
|
|
|
5850
7391
|
SetApplicationStatusSchema,
|
|
5851
7392
|
SetJobFeedbackSchema,
|
|
5852
7393
|
SetJobStateSchema,
|
|
7394
|
+
SetTrackingSchema,
|
|
7395
|
+
TrackingResponseSchema,
|
|
7396
|
+
TrackingSchema,
|
|
5853
7397
|
UnansweredQuestionSchema,
|
|
5854
7398
|
UnansweredResponseSchema,
|
|
7399
|
+
UnblockCompanyResponseSchema,
|
|
5855
7400
|
UpdateProfileSchema,
|
|
7401
|
+
asJobStateRead,
|
|
5856
7402
|
createFetchHandler,
|
|
5857
7403
|
createJobPlatformHandler,
|
|
5858
7404
|
createScheduledHandler
|