@hadoku/jobplatform-worker 2.32.34 → 2.32.36
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 +1594 -55
- 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 +3 -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 +367 -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(),
|
|
@@ -958,6 +970,122 @@ const AddProfileCompanyResponseSchema = S(
|
|
|
958
970
|
const DeleteCompanyResponseSchema = S(
|
|
959
971
|
z.object({ deleted: z.literal(true), id: z.string() })
|
|
960
972
|
).openapi("DeleteCompanyResponse");
|
|
973
|
+
const PERSON_EVENT_KINDS = ["note", "interview", "contact", "follow_up"];
|
|
974
|
+
const JobEventKindSchema = z.enum(["state", ...PERSON_EVENT_KINDS]).openapi("JobEventKind");
|
|
975
|
+
const JobEventSchema = z.object({
|
|
976
|
+
id: z.number().int(),
|
|
977
|
+
job_id: z.string(),
|
|
978
|
+
kind: JobEventKindSchema,
|
|
979
|
+
from_state: JobStateSchema.nullable(),
|
|
980
|
+
to_state: JobStateSchema.nullable(),
|
|
981
|
+
note: z.string().nullable(),
|
|
982
|
+
occurred_at: z.string(),
|
|
983
|
+
created_at: z.string()
|
|
984
|
+
}).openapi("JobEvent");
|
|
985
|
+
const JobEventsResponseSchema = S(z.object({ events: z.array(JobEventSchema) })).openapi(
|
|
986
|
+
"JobEventsResponse"
|
|
987
|
+
);
|
|
988
|
+
const JobEventResponseSchema = S(z.object({ event: JobEventSchema })).openapi(
|
|
989
|
+
"JobEventResponse"
|
|
990
|
+
);
|
|
991
|
+
const NOTE_MAX = 5e3;
|
|
992
|
+
const WhenSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/, {
|
|
993
|
+
message: "a date (YYYY-MM-DD) or an ISO timestamp"
|
|
994
|
+
});
|
|
995
|
+
const AddJobEventSchema = z.object({
|
|
996
|
+
kind: z.enum(PERSON_EVENT_KINDS),
|
|
997
|
+
note: z.string().trim().min(1).max(NOTE_MAX),
|
|
998
|
+
/** When it happened; defaults to now. */
|
|
999
|
+
occurred_at: WhenSchema.optional()
|
|
1000
|
+
}).openapi("AddJobEvent");
|
|
1001
|
+
const PrioritySchema = z.union([z.literal(1), z.literal(2), z.literal(3)]);
|
|
1002
|
+
const SetTrackingSchema = z.object({
|
|
1003
|
+
notes: z.string().max(NOTE_MAX).nullable().optional(),
|
|
1004
|
+
priority: PrioritySchema.nullable().optional(),
|
|
1005
|
+
follow_up_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: "a date, YYYY-MM-DD" }).nullable().optional(),
|
|
1006
|
+
contact_name: z.string().trim().max(200).nullable().optional(),
|
|
1007
|
+
contact_email: z.string().trim().email().max(320).nullable().optional()
|
|
1008
|
+
}).refine((o) => Object.values(o).some((v) => v !== void 0), {
|
|
1009
|
+
message: "name at least one field to change"
|
|
1010
|
+
}).openapi("SetTracking");
|
|
1011
|
+
const TrackingSchema = z.object({
|
|
1012
|
+
notes: z.string().nullable(),
|
|
1013
|
+
priority: PrioritySchema.nullable(),
|
|
1014
|
+
follow_up_at: z.string().nullable(),
|
|
1015
|
+
contact_name: z.string().nullable(),
|
|
1016
|
+
contact_email: z.string().nullable()
|
|
1017
|
+
}).openapi("Tracking");
|
|
1018
|
+
const TrackingResponseSchema = S(
|
|
1019
|
+
z.object({ job_id: z.string(), tracking: TrackingSchema })
|
|
1020
|
+
).openapi("TrackingResponse");
|
|
1021
|
+
const PipelineItemSchema = z.object({
|
|
1022
|
+
job_id: z.string(),
|
|
1023
|
+
title: z.string(),
|
|
1024
|
+
company: z.string(),
|
|
1025
|
+
location: z.string(),
|
|
1026
|
+
workplace_type: z.string(),
|
|
1027
|
+
salary_min: z.number().nullable(),
|
|
1028
|
+
salary_max: z.number().nullable(),
|
|
1029
|
+
url: z.string(),
|
|
1030
|
+
posted_date: z.string().nullable(),
|
|
1031
|
+
state: JobStateSchema,
|
|
1032
|
+
updated_at: z.string(),
|
|
1033
|
+
tracking: TrackingSchema,
|
|
1034
|
+
variant_slug: z.string().nullable(),
|
|
1035
|
+
/** The apply queue's view of the same posting, when it has been queued. */
|
|
1036
|
+
application_status: ApplicationStatusSchema.nullable(),
|
|
1037
|
+
/** First time it moved to 'applied', from the timeline. */
|
|
1038
|
+
applied_at: z.string().nullable(),
|
|
1039
|
+
/** Newest timeline row, of any kind. */
|
|
1040
|
+
last_event_at: z.string().nullable()
|
|
1041
|
+
}).openapi("PipelineItem");
|
|
1042
|
+
const PipelineResponseSchema = S(z.object({ items: z.array(PipelineItemSchema) })).openapi(
|
|
1043
|
+
"PipelineResponse"
|
|
1044
|
+
);
|
|
1045
|
+
const BlockedCompanySchema = z.object({ company: z.string(), company_key: z.string(), created_at: z.string() }).openapi("BlockedCompany");
|
|
1046
|
+
const BlockedCompaniesResponseSchema = S(
|
|
1047
|
+
z.object({ companies: z.array(BlockedCompanySchema) })
|
|
1048
|
+
).openapi("BlockedCompaniesResponse");
|
|
1049
|
+
const BlockCompanySchema = z.object({ company: z.string().trim().min(1).max(200) }).openapi("BlockCompany");
|
|
1050
|
+
const BlockedCompanyResponseSchema = S(z.object({ company: BlockedCompanySchema })).openapi(
|
|
1051
|
+
"BlockedCompanyResponse"
|
|
1052
|
+
);
|
|
1053
|
+
const UnblockCompanyResponseSchema = S(
|
|
1054
|
+
z.object({ company_key: z.string(), deleted: z.boolean() })
|
|
1055
|
+
).openapi("UnblockCompanyResponse");
|
|
1056
|
+
const InsightsSchema = z.object({
|
|
1057
|
+
/** Postings in each state right now. */
|
|
1058
|
+
states: z.record(z.string(), z.number().int()),
|
|
1059
|
+
/** Postings that have EVER reached each stage, from the timeline, the apply queue and the state. */
|
|
1060
|
+
reached: z.object({
|
|
1061
|
+
applied: z.number().int(),
|
|
1062
|
+
responded: z.number().int(),
|
|
1063
|
+
interviewed: z.number().int(),
|
|
1064
|
+
offered: z.number().int()
|
|
1065
|
+
}),
|
|
1066
|
+
/** Shares of `reached.applied`; null until something has been applied to. */
|
|
1067
|
+
rates: z.object({
|
|
1068
|
+
response: z.number().nullable(),
|
|
1069
|
+
interview: z.number().nullable(),
|
|
1070
|
+
offer: z.number().nullable()
|
|
1071
|
+
}),
|
|
1072
|
+
/** Applications per ISO week (Monday, UTC), oldest first, the last 12 weeks, zeros included. */
|
|
1073
|
+
weekly_applied: z.array(z.object({ week: z.string(), count: z.number().int() })),
|
|
1074
|
+
/** What the applied-to postings are, by workplace type. */
|
|
1075
|
+
workplace: z.array(z.object({ workplace_type: z.string(), count: z.number().int() })),
|
|
1076
|
+
/** Most-asked skills across the pipeline's postings (dismissed excluded). */
|
|
1077
|
+
skills: z.array(
|
|
1078
|
+
z.object({
|
|
1079
|
+
skill: z.string(),
|
|
1080
|
+
postings: z.number().int(),
|
|
1081
|
+
/** Named in the stack of any of the caller's scoring profiles. */
|
|
1082
|
+
in_stack: z.boolean()
|
|
1083
|
+
})
|
|
1084
|
+
),
|
|
1085
|
+
/** How many postings the skill counts were taken over. */
|
|
1086
|
+
skill_postings: z.number().int()
|
|
1087
|
+
}).openapi("Insights");
|
|
1088
|
+
const InsightsResponseSchema = S(InsightsSchema).openapi("InsightsResponse");
|
|
961
1089
|
const app$5 = new OpenAPIHono();
|
|
962
1090
|
const healthRoute = createRoute({
|
|
963
1091
|
method: "get",
|
|
@@ -1466,7 +1594,7 @@ async function effectiveUserId(c, ownerName) {
|
|
|
1466
1594
|
c.env.HADOKU_SERVICE_KEY ?? "",
|
|
1467
1595
|
named,
|
|
1468
1596
|
Date.now(),
|
|
1469
|
-
(
|
|
1597
|
+
(label2, work) => runAfterResponse(c, label2, work)
|
|
1470
1598
|
);
|
|
1471
1599
|
if (isIdentityError(resolved)) {
|
|
1472
1600
|
const status = [404, 409, 503].includes(resolved.status) ? resolved.status : 503;
|
|
@@ -1526,13 +1654,492 @@ function parseAtsSlug(url) {
|
|
|
1526
1654
|
if (url.includes("gh_jid=")) {
|
|
1527
1655
|
try {
|
|
1528
1656
|
const host = new URL(url).hostname;
|
|
1529
|
-
const
|
|
1530
|
-
if (
|
|
1657
|
+
const slug2 = slugFromHost(host);
|
|
1658
|
+
if (slug2) return { ats: "greenhouse", slug: slug2 };
|
|
1531
1659
|
} catch {
|
|
1532
1660
|
}
|
|
1533
1661
|
}
|
|
1534
1662
|
return { ats: null, slug: null };
|
|
1535
1663
|
}
|
|
1664
|
+
const US = [
|
|
1665
|
+
["arlington", "va", 38.8799, -77.1068],
|
|
1666
|
+
["austin", "tx", 30.2672, -97.7431],
|
|
1667
|
+
["round rock", "tx", 30.5083, -97.6789],
|
|
1668
|
+
["cedar park", "tx", 30.5052, -97.8203],
|
|
1669
|
+
["georgetown", "tx", 30.6333, -97.6778],
|
|
1670
|
+
["san marcos", "tx", 29.8833, -97.9414],
|
|
1671
|
+
["dallas", "tx", 32.7767, -96.797],
|
|
1672
|
+
["fort worth", "tx", 32.7555, -97.3308],
|
|
1673
|
+
["plano", "tx", 33.0198, -96.6989],
|
|
1674
|
+
["irving", "tx", 32.814, -96.9489],
|
|
1675
|
+
["frisco", "tx", 33.1507, -96.8236],
|
|
1676
|
+
["richardson", "tx", 32.9483, -96.7299],
|
|
1677
|
+
["arlington", "tx", 32.7357, -97.1081],
|
|
1678
|
+
["houston", "tx", 29.7604, -95.3698],
|
|
1679
|
+
["the woodlands", "tx", 30.1588, -95.4613],
|
|
1680
|
+
["sugar land", "tx", 29.6197, -95.6349],
|
|
1681
|
+
["san antonio", "tx", 29.4241, -98.4936],
|
|
1682
|
+
["el paso", "tx", 31.7619, -106.485],
|
|
1683
|
+
["lubbock", "tx", 33.5779, -101.8552],
|
|
1684
|
+
["midland", "tx", 31.9973, -102.0779],
|
|
1685
|
+
["san francisco", "ca", 37.7749, -122.4194],
|
|
1686
|
+
["oakland", "ca", 37.8044, -122.2712],
|
|
1687
|
+
["berkeley", "ca", 37.8715, -122.273],
|
|
1688
|
+
["san jose", "ca", 37.3382, -121.8863],
|
|
1689
|
+
["sunnyvale", "ca", 37.3688, -122.0363],
|
|
1690
|
+
["santa clara", "ca", 37.3541, -121.9552],
|
|
1691
|
+
["mountain view", "ca", 37.3861, -122.0839],
|
|
1692
|
+
["palo alto", "ca", 37.4419, -122.143],
|
|
1693
|
+
["menlo park", "ca", 37.453, -122.1817],
|
|
1694
|
+
["redwood city", "ca", 37.4852, -122.2364],
|
|
1695
|
+
["san mateo", "ca", 37.563, -122.3255],
|
|
1696
|
+
["cupertino", "ca", 37.323, -122.0322],
|
|
1697
|
+
["fremont", "ca", 37.5485, -121.9886],
|
|
1698
|
+
["pleasanton", "ca", 37.6624, -121.8747],
|
|
1699
|
+
["san ramon", "ca", 37.7799, -121.978],
|
|
1700
|
+
["walnut creek", "ca", 37.9101, -122.0652],
|
|
1701
|
+
["san rafael", "ca", 37.9735, -122.5311],
|
|
1702
|
+
["sacramento", "ca", 38.5816, -121.4944],
|
|
1703
|
+
["fresno", "ca", 36.7468, -119.7726],
|
|
1704
|
+
["los angeles", "ca", 34.0522, -118.2437],
|
|
1705
|
+
["santa monica", "ca", 34.0195, -118.4912],
|
|
1706
|
+
["culver city", "ca", 34.0211, -118.3965],
|
|
1707
|
+
["pasadena", "ca", 34.1478, -118.1445],
|
|
1708
|
+
["burbank", "ca", 34.1808, -118.309],
|
|
1709
|
+
["glendale", "ca", 34.1425, -118.2551],
|
|
1710
|
+
["long beach", "ca", 33.7701, -118.1937],
|
|
1711
|
+
["irvine", "ca", 33.6846, -117.8265],
|
|
1712
|
+
["newport beach", "ca", 33.6189, -117.9298],
|
|
1713
|
+
["anaheim", "ca", 33.8366, -117.9143],
|
|
1714
|
+
["san diego", "ca", 32.7157, -117.1611],
|
|
1715
|
+
["la jolla", "ca", 32.8328, -117.2713],
|
|
1716
|
+
["carlsbad", "ca", 33.1581, -117.3506],
|
|
1717
|
+
["riverside", "ca", 33.9806, -117.3755],
|
|
1718
|
+
["seattle", "wa", 47.6062, -122.3321],
|
|
1719
|
+
["bellevue", "wa", 47.6101, -122.2015],
|
|
1720
|
+
["redmond", "wa", 47.674, -122.1215],
|
|
1721
|
+
["kirkland", "wa", 47.6769, -122.206],
|
|
1722
|
+
["renton", "wa", 47.4829, -122.2171],
|
|
1723
|
+
["bothell", "wa", 47.7601, -122.2054],
|
|
1724
|
+
["tacoma", "wa", 47.2529, -122.4443],
|
|
1725
|
+
["olympia", "wa", 47.0379, -122.9007],
|
|
1726
|
+
["spokane", "wa", 47.6588, -117.426],
|
|
1727
|
+
["new york", "ny", 40.7128, -74.006],
|
|
1728
|
+
["manhattan", "ny", 40.7831, -73.9712],
|
|
1729
|
+
["brooklyn", "ny", 40.6782, -73.9442],
|
|
1730
|
+
["queens", "ny", 40.7282, -73.7949],
|
|
1731
|
+
["bronx", "ny", 40.8448, -73.8648],
|
|
1732
|
+
["staten island", "ny", 40.5795, -74.1502],
|
|
1733
|
+
["white plains", "ny", 41.034, -73.7629],
|
|
1734
|
+
["buffalo", "ny", 42.8864, -78.8784],
|
|
1735
|
+
["rochester", "ny", 43.1566, -77.6088],
|
|
1736
|
+
["albany", "ny", 42.6526, -73.7562],
|
|
1737
|
+
["jersey city", "nj", 40.7178, -74.0431],
|
|
1738
|
+
["hoboken", "nj", 40.744, -74.0324],
|
|
1739
|
+
["newark", "nj", 40.7357, -74.1724],
|
|
1740
|
+
["princeton", "nj", 40.3573, -74.6672],
|
|
1741
|
+
["boston", "ma", 42.3601, -71.0589],
|
|
1742
|
+
["cambridge", "ma", 42.3736, -71.1097],
|
|
1743
|
+
["somerville", "ma", 42.3876, -71.0995],
|
|
1744
|
+
["waltham", "ma", 42.3765, -71.2356],
|
|
1745
|
+
["burlington", "ma", 42.5048, -71.1956],
|
|
1746
|
+
["newton", "ma", 42.337, -71.2092],
|
|
1747
|
+
["quincy", "ma", 42.2529, -71.0023],
|
|
1748
|
+
["worcester", "ma", 42.2626, -71.8023],
|
|
1749
|
+
["providence", "ri", 41.824, -71.4128],
|
|
1750
|
+
["hartford", "ct", 41.7658, -72.6734],
|
|
1751
|
+
["stamford", "ct", 41.0534, -73.5387],
|
|
1752
|
+
["chicago", "il", 41.8781, -87.6298],
|
|
1753
|
+
["evanston", "il", 42.0451, -87.6877],
|
|
1754
|
+
["naperville", "il", 41.7508, -88.1535],
|
|
1755
|
+
["schaumburg", "il", 42.0334, -88.0834],
|
|
1756
|
+
["minneapolis", "mn", 44.9778, -93.265],
|
|
1757
|
+
["st. paul", "mn", 44.9537, -93.09],
|
|
1758
|
+
["bloomington", "mn", 44.8408, -93.2983],
|
|
1759
|
+
["detroit", "mi", 42.3314, -83.0458],
|
|
1760
|
+
["ann arbor", "mi", 42.2808, -83.743],
|
|
1761
|
+
["grand rapids", "mi", 42.9634, -85.6681],
|
|
1762
|
+
["columbus", "oh", 39.9612, -82.9988],
|
|
1763
|
+
["cleveland", "oh", 41.4993, -81.6944],
|
|
1764
|
+
["cincinnati", "oh", 39.1031, -84.512],
|
|
1765
|
+
["indianapolis", "in", 39.7684, -86.1581],
|
|
1766
|
+
["milwaukee", "wi", 43.0389, -87.9065],
|
|
1767
|
+
["madison", "wi", 43.0731, -89.4012],
|
|
1768
|
+
["st. louis", "mo", 38.627, -90.1994],
|
|
1769
|
+
["kansas city", "mo", 39.0997, -94.5786],
|
|
1770
|
+
["denver", "co", 39.7392, -104.9903],
|
|
1771
|
+
["boulder", "co", 40.015, -105.2705],
|
|
1772
|
+
["colorado springs", "co", 38.8339, -104.8214],
|
|
1773
|
+
["fort collins", "co", 40.5853, -105.0844],
|
|
1774
|
+
["broomfield", "co", 39.9205, -105.0867],
|
|
1775
|
+
["salt lake city", "ut", 40.7608, -111.891],
|
|
1776
|
+
["lehi", "ut", 40.3892, -111.8508],
|
|
1777
|
+
["provo", "ut", 40.2338, -111.6585],
|
|
1778
|
+
["draper", "ut", 40.5247, -111.8638],
|
|
1779
|
+
["phoenix", "az", 33.4484, -112.074],
|
|
1780
|
+
["scottsdale", "az", 33.4942, -111.9261],
|
|
1781
|
+
["tempe", "az", 33.4255, -111.94],
|
|
1782
|
+
["chandler", "az", 33.3062, -111.8413],
|
|
1783
|
+
["tucson", "az", 32.2226, -110.9747],
|
|
1784
|
+
["las vegas", "nv", 36.1699, -115.1398],
|
|
1785
|
+
["reno", "nv", 39.5296, -119.8138],
|
|
1786
|
+
["atlanta", "ga", 33.749, -84.388],
|
|
1787
|
+
["alpharetta", "ga", 34.0754, -84.2941],
|
|
1788
|
+
["marietta", "ga", 33.9526, -84.5499],
|
|
1789
|
+
["raleigh", "nc", 35.7796, -78.6382],
|
|
1790
|
+
["durham", "nc", 35.994, -78.8986],
|
|
1791
|
+
["chapel hill", "nc", 35.9132, -79.0558],
|
|
1792
|
+
["cary", "nc", 35.7915, -78.7811],
|
|
1793
|
+
["charlotte", "nc", 35.2271, -80.8431],
|
|
1794
|
+
["washington", "dc", 38.9072, -77.0369],
|
|
1795
|
+
["alexandria", "va", 38.8048, -77.0469],
|
|
1796
|
+
["mclean", "va", 38.9339, -77.1773],
|
|
1797
|
+
["tysons", "va", 38.9187, -77.2311],
|
|
1798
|
+
["reston", "va", 38.9586, -77.357],
|
|
1799
|
+
["herndon", "va", 38.9696, -77.3861],
|
|
1800
|
+
["richmond", "va", 37.5407, -77.436],
|
|
1801
|
+
["bethesda", "md", 38.9847, -77.0947],
|
|
1802
|
+
["baltimore", "md", 39.2904, -76.6122],
|
|
1803
|
+
["rockville", "md", 39.084, -77.1528],
|
|
1804
|
+
["philadelphia", "pa", 39.9526, -75.1652],
|
|
1805
|
+
["pittsburgh", "pa", 40.4406, -79.9959],
|
|
1806
|
+
["miami", "fl", 25.7617, -80.1918],
|
|
1807
|
+
["fort lauderdale", "fl", 26.1224, -80.1373],
|
|
1808
|
+
["tampa", "fl", 27.9506, -82.4572],
|
|
1809
|
+
["orlando", "fl", 28.5383, -81.3792],
|
|
1810
|
+
["jacksonville", "fl", 30.3322, -81.6557],
|
|
1811
|
+
["nashville", "tn", 36.1627, -86.7816],
|
|
1812
|
+
["memphis", "tn", 35.1495, -90.049],
|
|
1813
|
+
["portland", "or", 45.5152, -122.6784],
|
|
1814
|
+
["beaverton", "or", 45.4871, -122.8037],
|
|
1815
|
+
["hillsboro", "or", 45.5229, -122.9898],
|
|
1816
|
+
["boise", "id", 43.615, -116.2023],
|
|
1817
|
+
// Added: suburbs and hubs the live corpus names that the port did not have.
|
|
1818
|
+
["foster city", "ca", 37.5585, -122.2711],
|
|
1819
|
+
["south san francisco", "ca", 37.6547, -122.4077],
|
|
1820
|
+
["emeryville", "ca", 37.8313, -122.2852],
|
|
1821
|
+
["milpitas", "ca", 37.4323, -121.8996],
|
|
1822
|
+
["los gatos", "ca", 37.2358, -121.9624],
|
|
1823
|
+
["san bruno", "ca", 37.6305, -122.4111],
|
|
1824
|
+
["hayward", "ca", 37.6688, -122.0808],
|
|
1825
|
+
["el segundo", "ca", 33.9192, -118.4165],
|
|
1826
|
+
["torrance", "ca", 33.8358, -118.3406],
|
|
1827
|
+
["hollywood", "fl", 26.0112, -80.1495],
|
|
1828
|
+
["boca raton", "fl", 26.3683, -80.1289],
|
|
1829
|
+
["new orleans", "la", 29.9511, -90.0715],
|
|
1830
|
+
["louisville", "ky", 38.2527, -85.7585],
|
|
1831
|
+
["oklahoma city", "ok", 35.4676, -97.5164],
|
|
1832
|
+
["omaha", "ne", 41.2565, -95.9345],
|
|
1833
|
+
["des moines", "ia", 41.5868, -93.625],
|
|
1834
|
+
["albuquerque", "nm", 35.0844, -106.6504],
|
|
1835
|
+
["honolulu", "hi", 21.3099, -157.8581],
|
|
1836
|
+
["anchorage", "ak", 61.2181, -149.9003],
|
|
1837
|
+
["burlington", "vt", 44.4759, -73.2121],
|
|
1838
|
+
["portland", "me", 43.6591, -70.2568]
|
|
1839
|
+
];
|
|
1840
|
+
const WORLD = [
|
|
1841
|
+
// Canada
|
|
1842
|
+
["toronto", "ca-on", 43.6532, -79.3832],
|
|
1843
|
+
["etobicoke", "ca-on", 43.6205, -79.5132],
|
|
1844
|
+
["mississauga", "ca-on", 43.589, -79.6441],
|
|
1845
|
+
["waterloo", "ca-on", 43.4643, -80.5204],
|
|
1846
|
+
["ottawa", "ca-on", 45.4215, -75.6972],
|
|
1847
|
+
["montreal", "ca-qc", 45.5017, -73.5673],
|
|
1848
|
+
["vancouver", "ca-bc", 49.2827, -123.1207],
|
|
1849
|
+
["calgary", "ca-ab", 51.0447, -114.0719],
|
|
1850
|
+
// Latin America
|
|
1851
|
+
["mexico city", "mx", 19.4326, -99.1332],
|
|
1852
|
+
["guadalajara", "mx", 20.6597, -103.3496],
|
|
1853
|
+
["sao paulo", "br", -23.5505, -46.6333],
|
|
1854
|
+
["rio de janeiro", "br", -22.9068, -43.1729],
|
|
1855
|
+
["buenos aires", "ar", -34.6037, -58.3816],
|
|
1856
|
+
["bogota", "co", 4.711, -74.0721],
|
|
1857
|
+
["santiago", "cl", -33.4489, -70.6693],
|
|
1858
|
+
// Europe
|
|
1859
|
+
["london", "gb", 51.5074, -0.1278],
|
|
1860
|
+
["cambridge", "gb", 52.2053, 0.1218],
|
|
1861
|
+
["manchester", "gb", 53.4808, -2.2426],
|
|
1862
|
+
["edinburgh", "gb", 55.9533, -3.1883],
|
|
1863
|
+
["dublin", "ie", 53.3498, -6.2603],
|
|
1864
|
+
["cork", "ie", 51.8985, -8.4756],
|
|
1865
|
+
["berlin", "de", 52.52, 13.405],
|
|
1866
|
+
["munich", "de", 48.1351, 11.582],
|
|
1867
|
+
["hamburg", "de", 53.5511, 9.9937],
|
|
1868
|
+
["frankfurt", "de", 50.1109, 8.6821],
|
|
1869
|
+
["amsterdam", "nl", 52.3676, 4.9041],
|
|
1870
|
+
["paris", "fr", 48.8566, 2.3522],
|
|
1871
|
+
["brussels", "be", 50.8503, 4.3517],
|
|
1872
|
+
["zurich", "ch", 47.3769, 8.5417],
|
|
1873
|
+
["geneva", "ch", 46.2044, 6.1432],
|
|
1874
|
+
["vienna", "at", 48.2082, 16.3738],
|
|
1875
|
+
["stockholm", "se", 59.3293, 18.0686],
|
|
1876
|
+
["copenhagen", "dk", 55.6761, 12.5683],
|
|
1877
|
+
["oslo", "no", 59.9139, 10.7522],
|
|
1878
|
+
["helsinki", "fi", 60.1699, 24.9384],
|
|
1879
|
+
["madrid", "es", 40.4168, -3.7038],
|
|
1880
|
+
["barcelona", "es", 41.3874, 2.1686],
|
|
1881
|
+
["lisbon", "pt", 38.7223, -9.1393],
|
|
1882
|
+
["warsaw", "pl", 52.2297, 21.0122],
|
|
1883
|
+
["krakow", "pl", 50.0647, 19.945],
|
|
1884
|
+
["prague", "cz", 50.0755, 14.4378],
|
|
1885
|
+
["bucharest", "ro", 44.4268, 26.1025],
|
|
1886
|
+
["athens", "gr", 37.9838, 23.7275],
|
|
1887
|
+
["istanbul", "tr", 41.0082, 28.9784],
|
|
1888
|
+
// Middle East and Africa
|
|
1889
|
+
["tel aviv", "il", 32.0853, 34.7818],
|
|
1890
|
+
["dubai", "ae", 25.2048, 55.2708],
|
|
1891
|
+
["riyadh", "sa", 24.7136, 46.6753],
|
|
1892
|
+
["abu dhabi", "ae", 24.4539, 54.3773],
|
|
1893
|
+
["cairo", "eg", 30.0444, 31.2357],
|
|
1894
|
+
["lagos", "ng", 6.5244, 3.3792],
|
|
1895
|
+
["nairobi", "ke", -1.2921, 36.8219],
|
|
1896
|
+
["cape town", "za", -33.9249, 18.4241],
|
|
1897
|
+
// Asia-Pacific
|
|
1898
|
+
["bengaluru", "in", 12.9716, 77.5946],
|
|
1899
|
+
["hyderabad", "in", 17.385, 78.4867],
|
|
1900
|
+
["pune", "in", 18.5204, 73.8567],
|
|
1901
|
+
["mumbai", "in", 19.076, 72.8777],
|
|
1902
|
+
["chennai", "in", 13.0827, 80.2707],
|
|
1903
|
+
["gurugram", "in", 28.4595, 77.0266],
|
|
1904
|
+
["noida", "in", 28.5355, 77.391],
|
|
1905
|
+
["new delhi", "in", 28.6139, 77.209],
|
|
1906
|
+
["singapore", "sg", 1.3521, 103.8198],
|
|
1907
|
+
["kuala lumpur", "my", 3.139, 101.6869],
|
|
1908
|
+
["jakarta", "id", -6.2088, 106.8456],
|
|
1909
|
+
["manila", "ph", 14.5995, 120.9842],
|
|
1910
|
+
["tokyo", "jp", 35.6762, 139.6503],
|
|
1911
|
+
["osaka", "jp", 34.6937, 135.5023],
|
|
1912
|
+
["bangkok", "th", 13.7563, 100.5018],
|
|
1913
|
+
["seoul", "kr", 37.5665, 126.978],
|
|
1914
|
+
["taipei", "tw", 25.033, 121.5654],
|
|
1915
|
+
["taoyuan", "tw", 24.9936, 121.301],
|
|
1916
|
+
["hong kong", "hk", 22.3193, 114.1694],
|
|
1917
|
+
["shanghai", "cn", 31.2304, 121.4737],
|
|
1918
|
+
["beijing", "cn", 39.9042, 116.4074],
|
|
1919
|
+
["shenzhen", "cn", 22.5431, 114.0579],
|
|
1920
|
+
["sydney", "au", -33.8688, 151.2093],
|
|
1921
|
+
["melbourne", "au", -37.8136, 144.9631],
|
|
1922
|
+
["auckland", "nz", -36.8485, 174.7633],
|
|
1923
|
+
// US places that share a name with one above, declared after it so the
|
|
1924
|
+
// bare name keeps meaning the other: "Dublin" alone is Ireland.
|
|
1925
|
+
["dublin", "ca", 37.7022, -121.9358]
|
|
1926
|
+
];
|
|
1927
|
+
const ALIASES = {
|
|
1928
|
+
"new york city": "new york|ny",
|
|
1929
|
+
nyc: "new york|ny",
|
|
1930
|
+
"st paul": "st. paul|mn",
|
|
1931
|
+
"saint paul": "st. paul|mn",
|
|
1932
|
+
"saint louis": "st. louis|mo",
|
|
1933
|
+
"st louis": "st. louis|mo",
|
|
1934
|
+
"washington dc": "washington|dc",
|
|
1935
|
+
"washington d c": "washington|dc",
|
|
1936
|
+
bangalore: "bengaluru|in",
|
|
1937
|
+
gurgaon: "gurugram|in",
|
|
1938
|
+
delhi: "new delhi|in",
|
|
1939
|
+
munchen: "munich|de",
|
|
1940
|
+
"tel aviv yafo": "tel aviv|il"
|
|
1941
|
+
};
|
|
1942
|
+
const US_STATES = {
|
|
1943
|
+
al: "alabama",
|
|
1944
|
+
ak: "alaska",
|
|
1945
|
+
az: "arizona",
|
|
1946
|
+
ar: "arkansas",
|
|
1947
|
+
ca: "california",
|
|
1948
|
+
co: "colorado",
|
|
1949
|
+
ct: "connecticut",
|
|
1950
|
+
de: "delaware",
|
|
1951
|
+
fl: "florida",
|
|
1952
|
+
ga: "georgia",
|
|
1953
|
+
hi: "hawaii",
|
|
1954
|
+
id: "idaho",
|
|
1955
|
+
il: "illinois",
|
|
1956
|
+
in: "indiana",
|
|
1957
|
+
ia: "iowa",
|
|
1958
|
+
ks: "kansas",
|
|
1959
|
+
ky: "kentucky",
|
|
1960
|
+
la: "louisiana",
|
|
1961
|
+
me: "maine",
|
|
1962
|
+
md: "maryland",
|
|
1963
|
+
ma: "massachusetts",
|
|
1964
|
+
mi: "michigan",
|
|
1965
|
+
mn: "minnesota",
|
|
1966
|
+
ms: "mississippi",
|
|
1967
|
+
mo: "missouri",
|
|
1968
|
+
mt: "montana",
|
|
1969
|
+
ne: "nebraska",
|
|
1970
|
+
nv: "nevada",
|
|
1971
|
+
nh: "new hampshire",
|
|
1972
|
+
nj: "new jersey",
|
|
1973
|
+
nm: "new mexico",
|
|
1974
|
+
ny: "new york",
|
|
1975
|
+
nc: "north carolina",
|
|
1976
|
+
nd: "north dakota",
|
|
1977
|
+
oh: "ohio",
|
|
1978
|
+
ok: "oklahoma",
|
|
1979
|
+
or: "oregon",
|
|
1980
|
+
pa: "pennsylvania",
|
|
1981
|
+
ri: "rhode island",
|
|
1982
|
+
sc: "south carolina",
|
|
1983
|
+
sd: "south dakota",
|
|
1984
|
+
tn: "tennessee",
|
|
1985
|
+
tx: "texas",
|
|
1986
|
+
ut: "utah",
|
|
1987
|
+
vt: "vermont",
|
|
1988
|
+
va: "virginia",
|
|
1989
|
+
wa: "washington",
|
|
1990
|
+
wv: "west virginia",
|
|
1991
|
+
wi: "wisconsin",
|
|
1992
|
+
wy: "wyoming",
|
|
1993
|
+
dc: "district of columbia"
|
|
1994
|
+
};
|
|
1995
|
+
const COUNTRY_WORDS = {
|
|
1996
|
+
"ca-on": ["canada", "ontario"],
|
|
1997
|
+
"ca-qc": ["canada", "quebec"],
|
|
1998
|
+
"ca-bc": ["canada", "british columbia"],
|
|
1999
|
+
"ca-ab": ["canada", "alberta"],
|
|
2000
|
+
mx: ["mexico"],
|
|
2001
|
+
br: ["brazil", "brasil"],
|
|
2002
|
+
ar: ["argentina"],
|
|
2003
|
+
co: ["colombia"],
|
|
2004
|
+
cl: ["chile"],
|
|
2005
|
+
gb: ["uk", "united kingdom", "england", "scotland", "great britain"],
|
|
2006
|
+
ie: ["ireland"],
|
|
2007
|
+
de: ["germany", "deutschland"],
|
|
2008
|
+
nl: ["netherlands"],
|
|
2009
|
+
fr: ["france"],
|
|
2010
|
+
be: ["belgium"],
|
|
2011
|
+
ch: ["switzerland"],
|
|
2012
|
+
at: ["austria"],
|
|
2013
|
+
se: ["sweden"],
|
|
2014
|
+
dk: ["denmark"],
|
|
2015
|
+
no: ["norway"],
|
|
2016
|
+
fi: ["finland"],
|
|
2017
|
+
es: ["spain"],
|
|
2018
|
+
pt: ["portugal"],
|
|
2019
|
+
pl: ["poland"],
|
|
2020
|
+
cz: ["czechia", "czech republic"],
|
|
2021
|
+
ro: ["romania"],
|
|
2022
|
+
gr: ["greece"],
|
|
2023
|
+
tr: ["turkey", "turkiye"],
|
|
2024
|
+
il: ["israel"],
|
|
2025
|
+
ae: ["uae", "united arab emirates"],
|
|
2026
|
+
eg: ["egypt"],
|
|
2027
|
+
ng: ["nigeria"],
|
|
2028
|
+
ke: ["kenya"],
|
|
2029
|
+
za: ["south africa"],
|
|
2030
|
+
in: ["india"],
|
|
2031
|
+
sg: ["singapore"],
|
|
2032
|
+
my: ["malaysia"],
|
|
2033
|
+
id: ["indonesia"],
|
|
2034
|
+
ph: ["philippines"],
|
|
2035
|
+
jp: ["japan"],
|
|
2036
|
+
kr: ["korea", "south korea"],
|
|
2037
|
+
tw: ["taiwan"],
|
|
2038
|
+
hk: ["hong kong"],
|
|
2039
|
+
cn: ["china"],
|
|
2040
|
+
au: ["australia"],
|
|
2041
|
+
nz: ["new zealand"],
|
|
2042
|
+
th: ["thailand"],
|
|
2043
|
+
sa: ["saudi arabia"]
|
|
2044
|
+
};
|
|
2045
|
+
function fold(s) {
|
|
2046
|
+
return ` ${s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()} `;
|
|
2047
|
+
}
|
|
2048
|
+
function slug(name) {
|
|
2049
|
+
return fold(name).trim().replace(/ /g, "-");
|
|
2050
|
+
}
|
|
2051
|
+
const PLACES = [...US, ...WORLD].map(([name, region, lat, lng]) => ({
|
|
2052
|
+
key: `${slug(name)}-${region}`,
|
|
2053
|
+
name,
|
|
2054
|
+
region,
|
|
2055
|
+
lat,
|
|
2056
|
+
lng
|
|
2057
|
+
}));
|
|
2058
|
+
const BY_KEY = new Map(PLACES.map((p) => [p.key, p]));
|
|
2059
|
+
const BY_PHRASE = /* @__PURE__ */ new Map();
|
|
2060
|
+
function addPhrase(phrase, place) {
|
|
2061
|
+
const f = fold(phrase);
|
|
2062
|
+
const list = BY_PHRASE.get(f) ?? [];
|
|
2063
|
+
if (!list.includes(place)) list.push(place);
|
|
2064
|
+
BY_PHRASE.set(f, list);
|
|
2065
|
+
}
|
|
2066
|
+
for (const p of PLACES) addPhrase(p.name, p);
|
|
2067
|
+
for (const [alias, target] of Object.entries(ALIASES)) {
|
|
2068
|
+
const [name, region] = target.split("|");
|
|
2069
|
+
const p = BY_KEY.get(`${slug(name)}-${region}`);
|
|
2070
|
+
if (!p) throw new Error(`geo alias '${alias}' names no place (${target})`);
|
|
2071
|
+
addPhrase(alias, p);
|
|
2072
|
+
}
|
|
2073
|
+
const PHRASES = [...BY_PHRASE.keys()].sort((a, b) => b.length - a.length);
|
|
2074
|
+
const STATE_NAMES = new Set(Object.values(US_STATES));
|
|
2075
|
+
const ALL_REGIONS = [...Object.keys(US_STATES), ...Object.keys(COUNTRY_WORDS)];
|
|
2076
|
+
function regionNamed(raw, folded, region) {
|
|
2077
|
+
const stateName = US_STATES[region];
|
|
2078
|
+
if (stateName) {
|
|
2079
|
+
const code = region.toUpperCase();
|
|
2080
|
+
if (new RegExp(`(?:^|[^A-Za-z])${code}(?:[^A-Za-z]|$)`).test(raw)) return true;
|
|
2081
|
+
if (region === "dc" && /\bD\.C\.?/.test(raw)) return true;
|
|
2082
|
+
return folded.includes(` ${stateName} `);
|
|
2083
|
+
}
|
|
2084
|
+
return (COUNTRY_WORDS[region] ?? []).some((w) => folded.includes(fold(w)));
|
|
2085
|
+
}
|
|
2086
|
+
function soleRegion(raw, folded) {
|
|
2087
|
+
const named = ALL_REGIONS.filter((r) => regionNamed(raw, folded, r));
|
|
2088
|
+
return named.length === 1 ? named[0] : null;
|
|
2089
|
+
}
|
|
2090
|
+
const SEGMENT = /\s*[|;/]\s*|\s+or\s+|\s+and\s+/;
|
|
2091
|
+
function placesInSegment(raw, found) {
|
|
2092
|
+
const evidenceText = fold(raw);
|
|
2093
|
+
let folded = evidenceText;
|
|
2094
|
+
const sole = soleRegion(raw, evidenceText);
|
|
2095
|
+
for (const phrase of PHRASES) {
|
|
2096
|
+
if (!folded.includes(phrase)) continue;
|
|
2097
|
+
const candidates = BY_PHRASE.get(phrase);
|
|
2098
|
+
const evidenced = candidates.filter((p) => regionNamed(raw, evidenceText, p.region));
|
|
2099
|
+
let pick = evidenced[0];
|
|
2100
|
+
if (!pick) {
|
|
2101
|
+
const first = candidates[0];
|
|
2102
|
+
const isOtherState = STATE_NAMES.has(first.name) && US_STATES[first.region] !== first.name;
|
|
2103
|
+
const elsewhere = sole !== null && !candidates.some((p) => p.region === sole);
|
|
2104
|
+
if (!isOtherState && !elsewhere) pick = first;
|
|
2105
|
+
}
|
|
2106
|
+
if (pick && !found.includes(pick)) found.push(pick);
|
|
2107
|
+
folded = folded.split(phrase).join(" | ");
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
function findPlaces(location) {
|
|
2111
|
+
if (!location) return [];
|
|
2112
|
+
const found = [];
|
|
2113
|
+
for (const segment of location.split(SEGMENT)) {
|
|
2114
|
+
if (segment.trim()) placesInSegment(segment, found);
|
|
2115
|
+
}
|
|
2116
|
+
return found;
|
|
2117
|
+
}
|
|
2118
|
+
function geoKeys(location) {
|
|
2119
|
+
const keys = findPlaces(location).map((p) => p.key);
|
|
2120
|
+
return keys.length ? `|${keys.join("|")}|` : "";
|
|
2121
|
+
}
|
|
2122
|
+
const EARTH_RADIUS_MILES = 3958.8;
|
|
2123
|
+
function milesBetween(a, b) {
|
|
2124
|
+
const rad = (d) => d * Math.PI / 180;
|
|
2125
|
+
const dLat = rad(b.lat - a.lat);
|
|
2126
|
+
const dLng = rad(b.lng - a.lng);
|
|
2127
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
|
2128
|
+
return 2 * EARTH_RADIUS_MILES * Math.asin(Math.sqrt(h));
|
|
2129
|
+
}
|
|
2130
|
+
function resolvePlace(q) {
|
|
2131
|
+
return BY_KEY.get(q.trim().toLowerCase()) ?? findPlaces(q)[0] ?? null;
|
|
2132
|
+
}
|
|
2133
|
+
function placesWithin(center, miles) {
|
|
2134
|
+
return PLACES.map((p) => ({ p, d: milesBetween(center, p) })).filter(({ d }) => d <= miles).sort((a, b) => a.d - b.d).map(({ p }) => p);
|
|
2135
|
+
}
|
|
2136
|
+
function searchPlaces(q, limit = 10) {
|
|
2137
|
+
const f = fold(q).trim();
|
|
2138
|
+
if (!f) return [];
|
|
2139
|
+
const starts = PLACES.filter((p) => fold(p.name).trim().startsWith(f));
|
|
2140
|
+
const contains = PLACES.filter((p) => !starts.includes(p) && fold(p.name).includes(f));
|
|
2141
|
+
return [...starts, ...contains].slice(0, limit);
|
|
2142
|
+
}
|
|
1536
2143
|
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
2144
|
const RANGE_RE = new RegExp(`${AMOUNT}(?:\\s*USD)?\\s*(?:[-–—]|to)\\s*${AMOUNT}(?:\\s*USD)?`, "g");
|
|
1538
2145
|
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 +2632,7 @@ async function rankNewJobs(db) {
|
|
|
2025
2632
|
app$4.use("/ingest", requireMinTier("friend"));
|
|
2026
2633
|
app$4.use("/ingest/backfill-slugs", requireMinTier("friend"));
|
|
2027
2634
|
app$4.use("/ingest/backfill-roles", requireMinTier("friend"));
|
|
2635
|
+
app$4.use("/ingest/backfill-geo", requireMinTier("friend"));
|
|
2028
2636
|
app$4.use("/ingest/backfill-salary", requireMinTier("friend"));
|
|
2029
2637
|
app$4.use("/ingest/cull", requireMinTier("friend"));
|
|
2030
2638
|
app$4.use("/ingest/rebuild-rank", requireMinTier("friend"));
|
|
@@ -2066,8 +2674,8 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2066
2674
|
const salaryMax = structuredMax ?? prose?.max ?? null;
|
|
2067
2675
|
const parsed = parseAtsSlug(job.url);
|
|
2068
2676
|
const ats = parsed.ats ?? (board_slug ? source : null);
|
|
2069
|
-
const
|
|
2070
|
-
const slugSource =
|
|
2677
|
+
const slug2 = board_slug ?? parsed.slug;
|
|
2678
|
+
const slugSource = slug2 === null ? null : board_slug ? "scraped" : "guessed";
|
|
2071
2679
|
const role = classifyRole(job.title, job.description);
|
|
2072
2680
|
return db.prepare(
|
|
2073
2681
|
`INSERT OR IGNORE INTO jobs (
|
|
@@ -2075,8 +2683,8 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2075
2683
|
job_type, workplace_type, salary_min, salary_max,
|
|
2076
2684
|
description, posted_date, application_url, department,
|
|
2077
2685
|
scraper_used, raw, scraped_at, last_seen_at, ats, slug,
|
|
2078
|
-
role_track, role_level, slug_source
|
|
2079
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2686
|
+
role_track, role_level, slug_source, geo_keys
|
|
2687
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
2080
2688
|
).bind(
|
|
2081
2689
|
job.id,
|
|
2082
2690
|
job.url,
|
|
@@ -2097,10 +2705,11 @@ app$4.openapi(ingestRoute, async (c) => {
|
|
|
2097
2705
|
now,
|
|
2098
2706
|
now,
|
|
2099
2707
|
ats,
|
|
2100
|
-
|
|
2708
|
+
slug2,
|
|
2101
2709
|
role.track,
|
|
2102
2710
|
role.level,
|
|
2103
|
-
slugSource
|
|
2711
|
+
slugSource,
|
|
2712
|
+
geoKeys(job.location)
|
|
2104
2713
|
);
|
|
2105
2714
|
});
|
|
2106
2715
|
const inserted = [];
|
|
@@ -2201,12 +2810,12 @@ app$4.openapi(backfillRoute, async (c) => {
|
|
|
2201
2810
|
let updated = 0;
|
|
2202
2811
|
let unmatched = 0;
|
|
2203
2812
|
for (const row of rows.results) {
|
|
2204
|
-
const { ats, slug } = parseAtsSlug(row.url);
|
|
2205
|
-
if (ats === null &&
|
|
2813
|
+
const { ats, slug: slug2 } = parseAtsSlug(row.url);
|
|
2814
|
+
if (ats === null && slug2 === null) {
|
|
2206
2815
|
unmatched++;
|
|
2207
2816
|
continue;
|
|
2208
2817
|
}
|
|
2209
|
-
await db.prepare("UPDATE jobs SET ats = ?, slug = ? WHERE id = ?").bind(ats,
|
|
2818
|
+
await db.prepare("UPDATE jobs SET ats = ?, slug = ? WHERE id = ?").bind(ats, slug2, row.id).run();
|
|
2210
2819
|
updated++;
|
|
2211
2820
|
}
|
|
2212
2821
|
const remainingRow = await db.prepare("SELECT COUNT(*) as n FROM jobs WHERE ats IS NULL OR slug IS NULL").first();
|
|
@@ -2339,6 +2948,81 @@ app$4.openapi(backfillRolesRoute, async (c) => {
|
|
|
2339
2948
|
}
|
|
2340
2949
|
});
|
|
2341
2950
|
});
|
|
2951
|
+
const backfillGeoRoute = createRoute({
|
|
2952
|
+
method: "post",
|
|
2953
|
+
path: "/ingest/backfill-geo",
|
|
2954
|
+
tags: ["Ingest"],
|
|
2955
|
+
summary: "Populate jobs.geo_keys from each posting's location",
|
|
2956
|
+
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.",
|
|
2957
|
+
request: {
|
|
2958
|
+
query: z.object({
|
|
2959
|
+
limit: z.coerce.number().int().min(1).max(2e3).default(1e3),
|
|
2960
|
+
reread: z.enum(["true", "false"]).default("false"),
|
|
2961
|
+
after: z.string().default("").openapi({ description: "reread only: the id to resume after" })
|
|
2962
|
+
})
|
|
2963
|
+
},
|
|
2964
|
+
responses: {
|
|
2965
|
+
200: {
|
|
2966
|
+
description: "Backfill batch complete",
|
|
2967
|
+
content: {
|
|
2968
|
+
"application/json": {
|
|
2969
|
+
schema: z.object({
|
|
2970
|
+
success: z.literal(true),
|
|
2971
|
+
data: z.object({
|
|
2972
|
+
scanned: z.number(),
|
|
2973
|
+
placed: z.number(),
|
|
2974
|
+
remaining: z.number().nullable(),
|
|
2975
|
+
next_after: z.string().nullable(),
|
|
2976
|
+
has_more: z.boolean()
|
|
2977
|
+
})
|
|
2978
|
+
}).openapi("BackfillGeoResponse")
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
});
|
|
2984
|
+
app$4.openapi(backfillGeoRoute, async (c) => {
|
|
2985
|
+
const { limit, reread, after } = c.req.valid("query");
|
|
2986
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
2987
|
+
const all = reread === "true";
|
|
2988
|
+
const rows = await db.prepare(
|
|
2989
|
+
all ? "SELECT id, location FROM jobs WHERE id > ? ORDER BY id LIMIT ?" : "SELECT id, location FROM jobs WHERE geo_keys IS NULL LIMIT ?"
|
|
2990
|
+
).bind(...all ? [after, limit] : [limit]).all();
|
|
2991
|
+
let placed = 0;
|
|
2992
|
+
const stmts = rows.results.map((row) => {
|
|
2993
|
+
const keys = geoKeys(row.location ?? "");
|
|
2994
|
+
if (keys) placed++;
|
|
2995
|
+
return db.prepare("UPDATE jobs SET geo_keys = ? WHERE id = ?").bind(keys, row.id);
|
|
2996
|
+
});
|
|
2997
|
+
for (let i = 0; i < stmts.length; i += INGEST_BATCH_CHUNK) {
|
|
2998
|
+
await db.batch(stmts.slice(i, i + INGEST_BATCH_CHUNK));
|
|
2999
|
+
}
|
|
3000
|
+
if (all) {
|
|
3001
|
+
const last = rows.results.at(-1)?.id ?? null;
|
|
3002
|
+
return c.json({
|
|
3003
|
+
success: true,
|
|
3004
|
+
data: {
|
|
3005
|
+
scanned: rows.results.length,
|
|
3006
|
+
placed,
|
|
3007
|
+
remaining: null,
|
|
3008
|
+
next_after: last,
|
|
3009
|
+
has_more: rows.results.length === limit
|
|
3010
|
+
}
|
|
3011
|
+
});
|
|
3012
|
+
}
|
|
3013
|
+
const remainingRow = await db.prepare("SELECT COUNT(*) AS n FROM jobs WHERE geo_keys IS NULL").first();
|
|
3014
|
+
const remaining = remainingRow?.n ?? 0;
|
|
3015
|
+
return c.json({
|
|
3016
|
+
success: true,
|
|
3017
|
+
data: {
|
|
3018
|
+
scanned: rows.results.length,
|
|
3019
|
+
placed,
|
|
3020
|
+
remaining,
|
|
3021
|
+
next_after: null,
|
|
3022
|
+
has_more: remaining > 0
|
|
3023
|
+
}
|
|
3024
|
+
});
|
|
3025
|
+
});
|
|
2342
3026
|
const directivesRoute = createRoute({
|
|
2343
3027
|
method: "get",
|
|
2344
3028
|
path: "/directives",
|
|
@@ -2743,7 +3427,7 @@ async function requireIdentity(c, next) {
|
|
|
2743
3427
|
c.env.HADOKU_SERVICE_KEY ?? "",
|
|
2744
3428
|
owner,
|
|
2745
3429
|
Date.now(),
|
|
2746
|
-
(
|
|
3430
|
+
(label2, work) => runAfterResponse(c, label2, work)
|
|
2747
3431
|
);
|
|
2748
3432
|
if (isIdentityError(resolved)) {
|
|
2749
3433
|
const status = [404, 409, 503].includes(resolved.status) ? resolved.status : 503;
|
|
@@ -3036,7 +3720,7 @@ app$3.openapi(
|
|
|
3036
3720
|
const userId = await currentUserId(c);
|
|
3037
3721
|
const db = c.env.JOB_PLATFORM_DB;
|
|
3038
3722
|
const { profileId } = c.req.valid("param");
|
|
3039
|
-
const { ats, slug, display_name } = c.req.valid("json");
|
|
3723
|
+
const { ats, slug: slug2, display_name } = c.req.valid("json");
|
|
3040
3724
|
if (!await ownedProfile(db, userId, profileId)) {
|
|
3041
3725
|
return c.json(
|
|
3042
3726
|
{
|
|
@@ -3052,10 +3736,10 @@ app$3.openapi(
|
|
|
3052
3736
|
await db.prepare(
|
|
3053
3737
|
`INSERT OR IGNORE INTO profile_companies (id, profile_id, ats, slug, display_name, target_id, added_at)
|
|
3054
3738
|
VALUES (?, ?, ?, ?, ?, NULL, ?)`
|
|
3055
|
-
).bind(id, profileId, ats,
|
|
3739
|
+
).bind(id, profileId, ats, slug2, display_name, now).run();
|
|
3056
3740
|
const row = await db.prepare(
|
|
3057
3741
|
"SELECT id, ats, slug, display_name, added_at FROM profile_companies WHERE profile_id = ? AND ats = ? AND slug = ?"
|
|
3058
|
-
).bind(profileId, ats,
|
|
3742
|
+
).bind(profileId, ats, slug2).first();
|
|
3059
3743
|
let searchTriggered = false;
|
|
3060
3744
|
try {
|
|
3061
3745
|
await triggerSearch(c.env);
|
|
@@ -3156,6 +3840,7 @@ function deserializeProfile(r) {
|
|
|
3156
3840
|
};
|
|
3157
3841
|
}
|
|
3158
3842
|
const profileRoutes = app$3;
|
|
3843
|
+
const MAX_NEARBY = 80;
|
|
3159
3844
|
const LIGHT_CANDIDATE_CAP = 5e4;
|
|
3160
3845
|
const FULL_SCORE_CAP = 800;
|
|
3161
3846
|
const ID_CHUNK = 100;
|
|
@@ -3226,7 +3911,7 @@ function registerFeedRoute(app2) {
|
|
|
3226
3911
|
query: z.object({
|
|
3227
3912
|
ownerName: ownerNameQuery.shape.ownerName,
|
|
3228
3913
|
profile_id: z.string().optional().openapi({ description: "Filter/score by profile. Omit to list all jobs unscored." }),
|
|
3229
|
-
state: z.enum(["
|
|
3914
|
+
state: z.enum(["new", ...JOB_STATES]).optional().openapi({
|
|
3230
3915
|
description: 'Filter to a single triage state for the authed user. Requires admin/friend auth. "new" filters to jobs without any state row.'
|
|
3231
3916
|
}),
|
|
3232
3917
|
hide_dismissed: z.enum(["true", "false"]).optional().openapi({
|
|
@@ -3244,6 +3929,13 @@ function registerFeedRoute(app2) {
|
|
|
3244
3929
|
description: "Filter to one workplace type (e.g. full-remote only)."
|
|
3245
3930
|
}),
|
|
3246
3931
|
min_score: z.coerce.number().min(0).max(1).default(0).openapi({ description: "Minimum score filter" }),
|
|
3932
|
+
near: z.string().trim().min(1).max(100).optional().openapi({
|
|
3933
|
+
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.'
|
|
3934
|
+
}),
|
|
3935
|
+
radius_miles: z.coerce.number().int().min(1).max(500).default(25).openapi({ description: "Radius for near=, in miles." }),
|
|
3936
|
+
include_remote: z.enum(["true", "false"]).default("true").openapi({
|
|
3937
|
+
description: "With near=, also keep remote postings wherever they are."
|
|
3938
|
+
}),
|
|
3247
3939
|
min_salary: z.coerce.number().min(0).optional().openapi({
|
|
3248
3940
|
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
3941
|
})
|
|
@@ -3254,6 +3946,10 @@ function registerFeedRoute(app2) {
|
|
|
3254
3946
|
description: "Job list",
|
|
3255
3947
|
content: { "application/json": { schema: JobsResponseSchema } }
|
|
3256
3948
|
},
|
|
3949
|
+
400: {
|
|
3950
|
+
description: "near= names no known place",
|
|
3951
|
+
content: { "application/json": { schema: ErrorResponseSchema } }
|
|
3952
|
+
},
|
|
3257
3953
|
401: {
|
|
3258
3954
|
description: "state= requires auth",
|
|
3259
3955
|
content: { "application/json": { schema: ErrorResponseSchema } }
|
|
@@ -3288,7 +3984,10 @@ function registerFeedRoute(app2) {
|
|
|
3288
3984
|
sort,
|
|
3289
3985
|
workplace,
|
|
3290
3986
|
min_score,
|
|
3291
|
-
min_salary
|
|
3987
|
+
min_salary,
|
|
3988
|
+
near,
|
|
3989
|
+
radius_miles,
|
|
3990
|
+
include_remote
|
|
3292
3991
|
} = c.req.valid("query");
|
|
3293
3992
|
const hideDismissed = hideDismissedRaw === "true";
|
|
3294
3993
|
const hideQueued = hideQueuedRaw === "true";
|
|
@@ -3338,6 +4037,24 @@ function registerFeedRoute(app2) {
|
|
|
3338
4037
|
wheres.push("(j.salary_max IS NULL OR j.salary_max >= ?)");
|
|
3339
4038
|
whereBinds.push(min_salary);
|
|
3340
4039
|
}
|
|
4040
|
+
if (near) {
|
|
4041
|
+
const center = resolvePlace(near);
|
|
4042
|
+
if (!center) {
|
|
4043
|
+
return c.json(
|
|
4044
|
+
{
|
|
4045
|
+
success: false,
|
|
4046
|
+
error: "Bad request",
|
|
4047
|
+
message: `No known place matches '${near}' — try GET /locations?q=`
|
|
4048
|
+
},
|
|
4049
|
+
400
|
|
4050
|
+
);
|
|
4051
|
+
}
|
|
4052
|
+
const nearby = placesWithin(center, radius_miles).slice(0, MAX_NEARBY);
|
|
4053
|
+
const terms = nearby.map(() => "j.geo_keys LIKE ?");
|
|
4054
|
+
if (include_remote === "true") terms.unshift("j.workplace_type = 'remote'");
|
|
4055
|
+
wheres.push(`(${terms.join(" OR ")})`);
|
|
4056
|
+
whereBinds.push(...nearby.map((p) => `%|${p.key}|%`));
|
|
4057
|
+
}
|
|
3341
4058
|
if (userId) {
|
|
3342
4059
|
if (stateFilter === "new") {
|
|
3343
4060
|
wheres.push("js.state IS NULL");
|
|
@@ -3348,6 +4065,13 @@ function registerFeedRoute(app2) {
|
|
|
3348
4065
|
wheres.push("(js.state IS NULL OR js.state != 'dismissed')");
|
|
3349
4066
|
}
|
|
3350
4067
|
if (hideQueued && !stateFilter) wheres.push("ap.id IS NULL");
|
|
4068
|
+
if (!stateFilter) {
|
|
4069
|
+
wheres.push(
|
|
4070
|
+
`NOT EXISTS (SELECT 1 FROM blocked_companies b
|
|
4071
|
+
WHERE b.user_id = ? AND b.company_key = LOWER(TRIM(j.company)))`
|
|
4072
|
+
);
|
|
4073
|
+
whereBinds.push(userId);
|
|
4074
|
+
}
|
|
3351
4075
|
}
|
|
3352
4076
|
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
|
|
3353
4077
|
const andWheres = wheres.length > 0 ? ` AND ${wheres.join(" AND ")}` : "";
|
|
@@ -3471,7 +4195,7 @@ function registerFeedRoute(app2) {
|
|
|
3471
4195
|
r.row_vote
|
|
3472
4196
|
),
|
|
3473
4197
|
score_breakdown: breakdown,
|
|
3474
|
-
state: userId ? r.row_state
|
|
4198
|
+
state: userId ? asJobStateRead(r.row_state) : null,
|
|
3475
4199
|
application_status: asApplicationStatus(r.row_application),
|
|
3476
4200
|
vote: r.row_vote ?? null,
|
|
3477
4201
|
vote_reasons: r.row_vote_reasons ? String(r.row_vote_reasons).split(",") : []
|
|
@@ -3547,7 +4271,7 @@ function registerFeedRoute(app2) {
|
|
|
3547
4271
|
role_level: asRoleLevel(r.role_level),
|
|
3548
4272
|
score: 0,
|
|
3549
4273
|
score_breakdown: ZERO_BREAKDOWN,
|
|
3550
|
-
state: userId ? r.row_state
|
|
4274
|
+
state: userId ? asJobStateRead(r.row_state) : null,
|
|
3551
4275
|
application_status: asApplicationStatus(r.row_application)
|
|
3552
4276
|
}));
|
|
3553
4277
|
return c.json(
|
|
@@ -3654,7 +4378,7 @@ function registerPacketsRoute(app2) {
|
|
|
3654
4378
|
title: r.title,
|
|
3655
4379
|
company: r.company,
|
|
3656
4380
|
location: r.location,
|
|
3657
|
-
state: r.state,
|
|
4381
|
+
state: asJobStateRead(r.state),
|
|
3658
4382
|
variant_slug: r.variant_slug,
|
|
3659
4383
|
updated_at: r.updated_at
|
|
3660
4384
|
}));
|
|
@@ -3783,7 +4507,7 @@ function registerDetailRoute(app2) {
|
|
|
3783
4507
|
db.prepare("SELECT status FROM applications WHERE job_id = ? AND user_id = ?").bind(id, userId)
|
|
3784
4508
|
]);
|
|
3785
4509
|
const stateRow = stateRes.results[0];
|
|
3786
|
-
state = stateRow
|
|
4510
|
+
state = asJobStateRead(stateRow?.state);
|
|
3787
4511
|
stateUpdatedAt = stateRow?.updated_at ?? null;
|
|
3788
4512
|
const appRow = appRes.results[0];
|
|
3789
4513
|
applicationStatus = appRow?.status ?? null;
|
|
@@ -3865,7 +4589,11 @@ function registerStateRoutes(app2) {
|
|
|
3865
4589
|
const { id } = c.req.valid("param");
|
|
3866
4590
|
const body = c.req.valid("json");
|
|
3867
4591
|
const db = c.env.JOB_PLATFORM_DB;
|
|
3868
|
-
const exists = await db.prepare(
|
|
4592
|
+
const exists = await db.prepare(
|
|
4593
|
+
`SELECT j.id, js.state AS prev FROM jobs j
|
|
4594
|
+
LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
|
|
4595
|
+
WHERE j.id = ?`
|
|
4596
|
+
).bind(userId, id).first();
|
|
3869
4597
|
if (!exists) {
|
|
3870
4598
|
return c.json(
|
|
3871
4599
|
{ success: false, error: "Not found", message: `Job '${id}' not found` },
|
|
@@ -3874,14 +4602,26 @@ function registerStateRoutes(app2) {
|
|
|
3874
4602
|
}
|
|
3875
4603
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3876
4604
|
const variantSlug = body.variant_slug ?? null;
|
|
3877
|
-
|
|
4605
|
+
const upsert = db.prepare(
|
|
3878
4606
|
`INSERT INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug)
|
|
3879
4607
|
VALUES (?, ?, ?, NULL, ?, ?)
|
|
3880
4608
|
ON CONFLICT (job_id, user_id) DO UPDATE SET
|
|
3881
4609
|
state = excluded.state,
|
|
3882
4610
|
updated_at = excluded.updated_at,
|
|
3883
4611
|
variant_slug = COALESCE(excluded.variant_slug, job_states.variant_slug)`
|
|
3884
|
-
).bind(id, userId, body.state, now, variantSlug)
|
|
4612
|
+
).bind(id, userId, body.state, now, variantSlug);
|
|
4613
|
+
if (exists.prev === body.state) {
|
|
4614
|
+
await upsert.run();
|
|
4615
|
+
} else {
|
|
4616
|
+
await db.batch([
|
|
4617
|
+
upsert,
|
|
4618
|
+
db.prepare(
|
|
4619
|
+
`INSERT INTO job_events
|
|
4620
|
+
(job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
|
|
4621
|
+
VALUES (?, ?, 'state', ?, ?, NULL, ?, ?)`
|
|
4622
|
+
).bind(id, userId, exists.prev, body.state, now, now)
|
|
4623
|
+
]);
|
|
4624
|
+
}
|
|
3885
4625
|
return c.json(
|
|
3886
4626
|
{
|
|
3887
4627
|
success: true,
|
|
@@ -3896,7 +4636,7 @@ function registerStateRoutes(app2) {
|
|
|
3896
4636
|
method: "delete",
|
|
3897
4637
|
path: "/jobs/{id}/state",
|
|
3898
4638
|
tags: ["Jobs"],
|
|
3899
|
-
summary: 'Clear triage state for a job — returns the row to implicit "new"',
|
|
4639
|
+
summary: 'Clear triage state for a job — returns the row to implicit "new" and drops its timeline',
|
|
3900
4640
|
request: {
|
|
3901
4641
|
params: z.object({ id: z.string() })
|
|
3902
4642
|
},
|
|
@@ -3927,9 +4667,11 @@ function registerStateRoutes(app2) {
|
|
|
3927
4667
|
);
|
|
3928
4668
|
}
|
|
3929
4669
|
const { id } = c.req.valid("param");
|
|
3930
|
-
const
|
|
3931
|
-
|
|
3932
|
-
|
|
4670
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
4671
|
+
const [result] = await db.batch([
|
|
4672
|
+
db.prepare("DELETE FROM job_states WHERE job_id = ? AND user_id = ?").bind(id, userId),
|
|
4673
|
+
db.prepare("DELETE FROM job_events WHERE job_id = ? AND user_id = ?").bind(id, userId)
|
|
4674
|
+
]);
|
|
3933
4675
|
const deleted = (result.meta?.changes ?? 0) > 0;
|
|
3934
4676
|
return c.json({ success: true, data: { job_id: id, deleted } }, 200);
|
|
3935
4677
|
}
|
|
@@ -4059,8 +4801,8 @@ async function callResumeBinding(env, path, payload) {
|
|
|
4059
4801
|
body: JSON.stringify(payload)
|
|
4060
4802
|
});
|
|
4061
4803
|
}
|
|
4062
|
-
async function fetchMintedResume(env,
|
|
4063
|
-
const res = await resumeViaEdge(env, `/resume/api/resume?v=${encodeURIComponent(
|
|
4804
|
+
async function fetchMintedResume(env, slug2) {
|
|
4805
|
+
const res = await resumeViaEdge(env, `/resume/api/resume?v=${encodeURIComponent(slug2)}`, {
|
|
4064
4806
|
method: "GET"
|
|
4065
4807
|
});
|
|
4066
4808
|
if (!res.ok) return { expired: true };
|
|
@@ -4173,13 +4915,22 @@ function registerTailoringRoutes(app2) {
|
|
|
4173
4915
|
);
|
|
4174
4916
|
}
|
|
4175
4917
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4918
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
4919
|
+
await db.batch([
|
|
4920
|
+
db.prepare(
|
|
4921
|
+
`INSERT INTO job_events
|
|
4922
|
+
(job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
|
|
4923
|
+
SELECT ?1, ?2, 'state', NULL, 'saved', NULL, ?3, ?3
|
|
4924
|
+
WHERE NOT EXISTS (SELECT 1 FROM job_states WHERE job_id = ?1 AND user_id = ?2)`
|
|
4925
|
+
).bind(id, who.userId, now),
|
|
4926
|
+
db.prepare(
|
|
4927
|
+
`INSERT INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug)
|
|
4928
|
+
VALUES (?, ?, 'saved', NULL, ?, ?)
|
|
4929
|
+
ON CONFLICT (job_id, user_id) DO UPDATE SET
|
|
4930
|
+
variant_slug = excluded.variant_slug,
|
|
4931
|
+
updated_at = excluded.updated_at`
|
|
4932
|
+
).bind(id, who.userId, now, variant.slug)
|
|
4933
|
+
]);
|
|
4183
4934
|
const url = `https://hadoku.me/resume?v=${encodeURIComponent(variant.slug)}`;
|
|
4184
4935
|
return c.json({ success: true, data: { slug: variant.slug, url } }, 200);
|
|
4185
4936
|
});
|
|
@@ -4195,8 +4946,8 @@ function registerTailoringRoutes(app2) {
|
|
|
4195
4946
|
const row = userId ? await c.env.JOB_PLATFORM_DB.prepare(
|
|
4196
4947
|
"SELECT variant_slug FROM job_states WHERE job_id = ? AND user_id = ?"
|
|
4197
4948
|
).bind(id, userId).first() : null;
|
|
4198
|
-
const
|
|
4199
|
-
if (!
|
|
4949
|
+
const slug2 = row?.variant_slug ?? null;
|
|
4950
|
+
if (!slug2) {
|
|
4200
4951
|
return c.json(
|
|
4201
4952
|
{
|
|
4202
4953
|
success: false,
|
|
@@ -4206,13 +4957,13 @@ function registerTailoringRoutes(app2) {
|
|
|
4206
4957
|
400
|
|
4207
4958
|
);
|
|
4208
4959
|
}
|
|
4209
|
-
const minted = await fetchMintedResume(c.env,
|
|
4960
|
+
const minted = await fetchMintedResume(c.env, slug2);
|
|
4210
4961
|
if ("expired" in minted) {
|
|
4211
4962
|
return c.json(
|
|
4212
4963
|
{
|
|
4213
4964
|
success: false,
|
|
4214
4965
|
error: "Bad request",
|
|
4215
|
-
message: `The packet for this job ('${
|
|
4966
|
+
message: `The packet for this job ('${slug2}') has expired; re-prepare it or post resume_markdown.`
|
|
4216
4967
|
},
|
|
4217
4968
|
400
|
|
4218
4969
|
);
|
|
@@ -4936,7 +5687,7 @@ function registerOwnerRoutes(app2) {
|
|
|
4936
5687
|
c.env.HADOKU_SERVICE_KEY ?? "",
|
|
4937
5688
|
name,
|
|
4938
5689
|
Date.now(),
|
|
4939
|
-
(
|
|
5690
|
+
(label2, work) => runAfterResponse(c, label2, work)
|
|
4940
5691
|
);
|
|
4941
5692
|
if (isIdentityError(resolved)) {
|
|
4942
5693
|
const status = [400, 404, 409, 503].includes(resolved.status) ? resolved.status : 503;
|
|
@@ -4954,8 +5705,10 @@ function registerOwnerRoutes(app2) {
|
|
|
4954
5705
|
await db.batch([
|
|
4955
5706
|
db.prepare("UPDATE applications SET user_id = ?, updated_at = ? WHERE id = ?").bind(resolved.userId, now, id),
|
|
4956
5707
|
db.prepare(
|
|
4957
|
-
`INSERT OR REPLACE INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug
|
|
4958
|
-
|
|
5708
|
+
`INSERT OR REPLACE INTO job_states (job_id, user_id, state, notes, updated_at, variant_slug,
|
|
5709
|
+
priority, follow_up_at, contact_name, contact_email)
|
|
5710
|
+
SELECT job_id, ?, state, notes, ?, variant_slug,
|
|
5711
|
+
priority, follow_up_at, contact_name, contact_email
|
|
4959
5712
|
FROM job_states WHERE job_id = ? AND user_id = ?`
|
|
4960
5713
|
).bind(resolved.userId, now, row.job_id, row.user_id)
|
|
4961
5714
|
]);
|
|
@@ -5044,7 +5797,7 @@ async function delistedReason(db, jobId) {
|
|
|
5044
5797
|
if (newest - seen <= DELISTED_LAG_MS) return null;
|
|
5045
5798
|
return `this posting has not appeared in a scrape of the ${job.ats} board '${job.slug}' since ${job.last_seen_at}, while other jobs on that board were seen as recently as ${board.newest} — it looks taken down`;
|
|
5046
5799
|
}
|
|
5047
|
-
const FORBIDDEN = {
|
|
5800
|
+
const FORBIDDEN$1 = {
|
|
5048
5801
|
success: false,
|
|
5049
5802
|
error: "Forbidden",
|
|
5050
5803
|
message: "Authentication required"
|
|
@@ -5343,7 +6096,7 @@ function registerApplicationRoutes(app2) {
|
|
|
5343
6096
|
}),
|
|
5344
6097
|
async (c) => {
|
|
5345
6098
|
const userId = await maybeUserId(c);
|
|
5346
|
-
if (!userId) return c.json(FORBIDDEN, 403);
|
|
6099
|
+
if (!userId) return c.json(FORBIDDEN$1, 403);
|
|
5347
6100
|
const { id } = c.req.valid("param");
|
|
5348
6101
|
const db = c.env.JOB_PLATFORM_DB;
|
|
5349
6102
|
const existing = await db.prepare("SELECT * FROM applications WHERE id = ? AND user_id = ?").bind(id, userId).first();
|
|
@@ -5558,7 +6311,7 @@ function registerFillRoutes(app2) {
|
|
|
5558
6311
|
WHERE user_id = ? AND job_id = ?`
|
|
5559
6312
|
).bind(who.userId, id).first();
|
|
5560
6313
|
const state = await db.prepare("SELECT variant_slug FROM job_states WHERE user_id = ? AND job_id = ?").bind(who.userId, id).first();
|
|
5561
|
-
const
|
|
6314
|
+
const slug2 = app_?.variant_slug || state?.variant_slug || "";
|
|
5562
6315
|
const evidence = parseEvidence(app_?.evidence ?? null) ?? {};
|
|
5563
6316
|
const answers = typeof evidence.answers === "object" && evidence.answers !== null ? evidence.answers : {};
|
|
5564
6317
|
const options = typeof evidence.options === "object" && evidence.options !== null ? evidence.options : {};
|
|
@@ -5570,7 +6323,7 @@ function registerFillRoutes(app2) {
|
|
|
5570
6323
|
success: true,
|
|
5571
6324
|
data: {
|
|
5572
6325
|
job,
|
|
5573
|
-
application: app_ ? { id: app_.id, status: app_.status, variant_slug:
|
|
6326
|
+
application: app_ ? { id: app_.id, status: app_.status, variant_slug: slug2 } : null,
|
|
5574
6327
|
profile: profile?.profile ?? null,
|
|
5575
6328
|
// What the approved fill entered, keyed by the runner's normalised
|
|
5576
6329
|
// question — the primary source, because it is what was reviewed.
|
|
@@ -5580,24 +6333,787 @@ function registerFillRoutes(app2) {
|
|
|
5580
6333
|
// Every saved answer by its question, for anything the fill did
|
|
5581
6334
|
// not record (a field added since, a structured block).
|
|
5582
6335
|
standing: Object.fromEntries(standing.results.map((r) => [r.question, r.answer])),
|
|
5583
|
-
resume_pdf_url:
|
|
6336
|
+
resume_pdf_url: slug2 ? `${RESUME_PDF_URL}?v=${encodeURIComponent(slug2)}` : null
|
|
5584
6337
|
}
|
|
5585
6338
|
},
|
|
5586
6339
|
200
|
|
5587
6340
|
);
|
|
5588
6341
|
});
|
|
5589
6342
|
}
|
|
6343
|
+
const IDENTITY_ERRORS = {
|
|
6344
|
+
404: {
|
|
6345
|
+
description: "No such owner name, or no such job",
|
|
6346
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6347
|
+
},
|
|
6348
|
+
409: {
|
|
6349
|
+
description: "That owner name has never signed in, or the posting is not in the pipeline",
|
|
6350
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6351
|
+
},
|
|
6352
|
+
503: {
|
|
6353
|
+
description: "Identity could not be resolved right now — retry",
|
|
6354
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6355
|
+
}
|
|
6356
|
+
};
|
|
6357
|
+
const FORBIDDEN = {
|
|
6358
|
+
description: "Forbidden",
|
|
6359
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6360
|
+
};
|
|
6361
|
+
const EVENT_COLS = "id, job_id, kind, from_state, to_state, note, occurred_at, created_at";
|
|
6362
|
+
const TRACKING_COLS = "notes, priority, follow_up_at, contact_name, contact_email";
|
|
6363
|
+
function stateOrNull(v) {
|
|
6364
|
+
const s = asJobStateRead(v);
|
|
6365
|
+
return s === "new" ? null : s;
|
|
6366
|
+
}
|
|
6367
|
+
function toEvent(r) {
|
|
6368
|
+
return {
|
|
6369
|
+
...r,
|
|
6370
|
+
kind: r.kind,
|
|
6371
|
+
from_state: stateOrNull(r.from_state),
|
|
6372
|
+
to_state: stateOrNull(r.to_state)
|
|
6373
|
+
};
|
|
6374
|
+
}
|
|
6375
|
+
function asPriority(p) {
|
|
6376
|
+
return p === 1 || p === 2 || p === 3 ? p : null;
|
|
6377
|
+
}
|
|
6378
|
+
function toTracking(r) {
|
|
6379
|
+
return {
|
|
6380
|
+
notes: r.notes,
|
|
6381
|
+
priority: asPriority(r.priority),
|
|
6382
|
+
follow_up_at: r.follow_up_at,
|
|
6383
|
+
contact_name: r.contact_name,
|
|
6384
|
+
contact_email: r.contact_email
|
|
6385
|
+
};
|
|
6386
|
+
}
|
|
6387
|
+
function notInPipeline(id) {
|
|
6388
|
+
return {
|
|
6389
|
+
success: false,
|
|
6390
|
+
error: "Conflict",
|
|
6391
|
+
message: `Job '${id}' is not in your pipeline — set its state first (PUT /jobs/${id}/state)`
|
|
6392
|
+
};
|
|
6393
|
+
}
|
|
6394
|
+
function jobNotFound(id) {
|
|
6395
|
+
return { success: false, error: "Not found", message: `Job '${id}' not found` };
|
|
6396
|
+
}
|
|
6397
|
+
async function stateOf(db, jobId, userId) {
|
|
6398
|
+
const row = await db.prepare(
|
|
6399
|
+
`SELECT j.id, js.state FROM jobs j
|
|
6400
|
+
LEFT JOIN job_states js ON js.job_id = j.id AND js.user_id = ?
|
|
6401
|
+
WHERE j.id = ?`
|
|
6402
|
+
).bind(userId, jobId).first();
|
|
6403
|
+
if (!row) return "missing";
|
|
6404
|
+
return row.state ? "tracked" : "untracked";
|
|
6405
|
+
}
|
|
6406
|
+
function registerPipelineRoutes(app2) {
|
|
6407
|
+
app2.get("/pipeline", gateAuthed);
|
|
6408
|
+
app2.patch("/jobs/:id/tracking", gateAuthed);
|
|
6409
|
+
app2.get("/jobs/:id/events", gateAuthed);
|
|
6410
|
+
app2.post("/jobs/:id/events", gateAuthed);
|
|
6411
|
+
app2.delete("/jobs/:id/events/:eventId", gateAuthed);
|
|
6412
|
+
app2.openapi(
|
|
6413
|
+
createRoute({
|
|
6414
|
+
method: "get",
|
|
6415
|
+
path: "/pipeline",
|
|
6416
|
+
tags: ["Pipeline"],
|
|
6417
|
+
summary: "Every posting in the caller's pipeline, most recently changed first",
|
|
6418
|
+
request: {
|
|
6419
|
+
query: ownerNameQuery.extend({
|
|
6420
|
+
include_dismissed: z.enum(["true", "false"]).optional().openapi({
|
|
6421
|
+
description: "Dismissed postings are left out unless this is true."
|
|
6422
|
+
})
|
|
6423
|
+
})
|
|
6424
|
+
},
|
|
6425
|
+
responses: {
|
|
6426
|
+
200: {
|
|
6427
|
+
description: "Pipeline",
|
|
6428
|
+
content: { "application/json": { schema: PipelineResponseSchema } }
|
|
6429
|
+
},
|
|
6430
|
+
403: FORBIDDEN,
|
|
6431
|
+
...IDENTITY_ERRORS
|
|
6432
|
+
}
|
|
6433
|
+
}),
|
|
6434
|
+
async (c) => {
|
|
6435
|
+
const { ownerName, include_dismissed } = c.req.valid("query");
|
|
6436
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6437
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6438
|
+
const dismissed = include_dismissed === "true" ? "" : "AND js.state != 'dismissed'";
|
|
6439
|
+
const rows = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6440
|
+
`SELECT js.job_id, j.title, j.company, j.location, j.workplace_type,
|
|
6441
|
+
j.salary_min, j.salary_max, j.url, j.posted_date,
|
|
6442
|
+
js.state, js.updated_at, js.variant_slug,
|
|
6443
|
+
js.notes, js.priority, js.follow_up_at, js.contact_name, js.contact_email,
|
|
6444
|
+
ap.status AS application_status,
|
|
6445
|
+
(SELECT MIN(e.occurred_at) FROM job_events e
|
|
6446
|
+
WHERE e.job_id = js.job_id AND e.user_id = js.user_id
|
|
6447
|
+
AND e.kind = 'state' AND e.to_state = 'applied') AS applied_at,
|
|
6448
|
+
(SELECT MAX(e.occurred_at) FROM job_events e
|
|
6449
|
+
WHERE e.job_id = js.job_id AND e.user_id = js.user_id) AS last_event_at
|
|
6450
|
+
FROM job_states js
|
|
6451
|
+
JOIN jobs j ON j.id = js.job_id
|
|
6452
|
+
LEFT JOIN applications ap ON ap.job_id = js.job_id AND ap.user_id = js.user_id
|
|
6453
|
+
WHERE js.user_id = ? ${dismissed}
|
|
6454
|
+
ORDER BY js.updated_at DESC`
|
|
6455
|
+
).bind(who.userId).all();
|
|
6456
|
+
const items = rows.results.flatMap((r) => {
|
|
6457
|
+
const state = stateOrNull(r.state);
|
|
6458
|
+
if (!state) return [];
|
|
6459
|
+
return [
|
|
6460
|
+
{
|
|
6461
|
+
job_id: r.job_id,
|
|
6462
|
+
title: r.title,
|
|
6463
|
+
company: r.company,
|
|
6464
|
+
location: r.location,
|
|
6465
|
+
workplace_type: r.workplace_type,
|
|
6466
|
+
salary_min: r.salary_min,
|
|
6467
|
+
salary_max: r.salary_max,
|
|
6468
|
+
url: r.url,
|
|
6469
|
+
posted_date: r.posted_date,
|
|
6470
|
+
state,
|
|
6471
|
+
updated_at: r.updated_at,
|
|
6472
|
+
tracking: toTracking(r),
|
|
6473
|
+
variant_slug: r.variant_slug,
|
|
6474
|
+
application_status: asApplicationStatus(r.application_status),
|
|
6475
|
+
applied_at: r.applied_at,
|
|
6476
|
+
last_event_at: r.last_event_at
|
|
6477
|
+
}
|
|
6478
|
+
];
|
|
6479
|
+
});
|
|
6480
|
+
return c.json({ success: true, data: { items } }, 200);
|
|
6481
|
+
}
|
|
6482
|
+
);
|
|
6483
|
+
app2.openapi(
|
|
6484
|
+
createRoute({
|
|
6485
|
+
method: "patch",
|
|
6486
|
+
path: "/jobs/{id}/tracking",
|
|
6487
|
+
tags: ["Pipeline"],
|
|
6488
|
+
summary: "Change what the caller tracks about one application; null clears a field",
|
|
6489
|
+
request: {
|
|
6490
|
+
params: z.object({ id: z.string() }),
|
|
6491
|
+
query: ownerNameQuery,
|
|
6492
|
+
body: { content: { "application/json": { schema: SetTrackingSchema } } }
|
|
6493
|
+
},
|
|
6494
|
+
responses: {
|
|
6495
|
+
200: {
|
|
6496
|
+
description: "Tracking after the change",
|
|
6497
|
+
content: { "application/json": { schema: TrackingResponseSchema } }
|
|
6498
|
+
},
|
|
6499
|
+
403: FORBIDDEN,
|
|
6500
|
+
...IDENTITY_ERRORS
|
|
6501
|
+
}
|
|
6502
|
+
}),
|
|
6503
|
+
async (c) => {
|
|
6504
|
+
const { id } = c.req.valid("param");
|
|
6505
|
+
const { ownerName } = c.req.valid("query");
|
|
6506
|
+
const body = c.req.valid("json");
|
|
6507
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6508
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6509
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
6510
|
+
const sets = [];
|
|
6511
|
+
const binds = [];
|
|
6512
|
+
for (const col of [
|
|
6513
|
+
"notes",
|
|
6514
|
+
"priority",
|
|
6515
|
+
"follow_up_at",
|
|
6516
|
+
"contact_name",
|
|
6517
|
+
"contact_email"
|
|
6518
|
+
]) {
|
|
6519
|
+
const v = body[col];
|
|
6520
|
+
if (v === void 0) continue;
|
|
6521
|
+
sets.push(`${col} = ?`);
|
|
6522
|
+
binds.push(typeof v === "string" && v.trim() === "" ? null : v);
|
|
6523
|
+
}
|
|
6524
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6525
|
+
const row = await db.prepare(
|
|
6526
|
+
`UPDATE job_states SET ${sets.join(", ")}, updated_at = ?
|
|
6527
|
+
WHERE job_id = ? AND user_id = ?
|
|
6528
|
+
RETURNING ${TRACKING_COLS}`
|
|
6529
|
+
).bind(...binds, now, id, who.userId).first();
|
|
6530
|
+
if (!row) {
|
|
6531
|
+
const s = await stateOf(db, id, who.userId);
|
|
6532
|
+
return s === "missing" ? c.json(jobNotFound(id), 404) : c.json(notInPipeline(id), 409);
|
|
6533
|
+
}
|
|
6534
|
+
return c.json(
|
|
6535
|
+
{ success: true, data: { job_id: id, tracking: toTracking(row) } },
|
|
6536
|
+
200
|
|
6537
|
+
);
|
|
6538
|
+
}
|
|
6539
|
+
);
|
|
6540
|
+
app2.openapi(
|
|
6541
|
+
createRoute({
|
|
6542
|
+
method: "get",
|
|
6543
|
+
path: "/jobs/{id}/events",
|
|
6544
|
+
tags: ["Pipeline"],
|
|
6545
|
+
summary: "One posting's timeline for the caller, oldest first",
|
|
6546
|
+
request: { params: z.object({ id: z.string() }), query: ownerNameQuery },
|
|
6547
|
+
responses: {
|
|
6548
|
+
200: {
|
|
6549
|
+
description: "Timeline (empty for a posting with none)",
|
|
6550
|
+
content: { "application/json": { schema: JobEventsResponseSchema } }
|
|
6551
|
+
},
|
|
6552
|
+
403: FORBIDDEN,
|
|
6553
|
+
...IDENTITY_ERRORS
|
|
6554
|
+
}
|
|
6555
|
+
}),
|
|
6556
|
+
async (c) => {
|
|
6557
|
+
const { id } = c.req.valid("param");
|
|
6558
|
+
const { ownerName } = c.req.valid("query");
|
|
6559
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6560
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6561
|
+
const rows = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6562
|
+
`SELECT ${EVENT_COLS} FROM job_events
|
|
6563
|
+
WHERE job_id = ? AND user_id = ?
|
|
6564
|
+
ORDER BY occurred_at, id`
|
|
6565
|
+
).bind(id, who.userId).all();
|
|
6566
|
+
return c.json({ success: true, data: { events: rows.results.map(toEvent) } }, 200);
|
|
6567
|
+
}
|
|
6568
|
+
);
|
|
6569
|
+
app2.openapi(
|
|
6570
|
+
createRoute({
|
|
6571
|
+
method: "post",
|
|
6572
|
+
path: "/jobs/{id}/events",
|
|
6573
|
+
tags: ["Pipeline"],
|
|
6574
|
+
summary: "Log something that happened on one application",
|
|
6575
|
+
request: {
|
|
6576
|
+
params: z.object({ id: z.string() }),
|
|
6577
|
+
query: ownerNameQuery,
|
|
6578
|
+
body: { content: { "application/json": { schema: AddJobEventSchema } } }
|
|
6579
|
+
},
|
|
6580
|
+
responses: {
|
|
6581
|
+
201: {
|
|
6582
|
+
description: "Logged",
|
|
6583
|
+
content: { "application/json": { schema: JobEventResponseSchema } }
|
|
6584
|
+
},
|
|
6585
|
+
403: FORBIDDEN,
|
|
6586
|
+
...IDENTITY_ERRORS
|
|
6587
|
+
}
|
|
6588
|
+
}),
|
|
6589
|
+
async (c) => {
|
|
6590
|
+
const { id } = c.req.valid("param");
|
|
6591
|
+
const { ownerName } = c.req.valid("query");
|
|
6592
|
+
const body = c.req.valid("json");
|
|
6593
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6594
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6595
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
6596
|
+
const s = await stateOf(db, id, who.userId);
|
|
6597
|
+
if (s === "missing") return c.json(jobNotFound(id), 404);
|
|
6598
|
+
if (s === "untracked") return c.json(notInPipeline(id), 409);
|
|
6599
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6600
|
+
const occurred = body.occurred_at ? new Date(
|
|
6601
|
+
body.occurred_at.length === 10 ? `${body.occurred_at}T00:00:00Z` : body.occurred_at
|
|
6602
|
+
).toISOString() : now;
|
|
6603
|
+
const row = await db.prepare(
|
|
6604
|
+
`INSERT INTO job_events
|
|
6605
|
+
(job_id, user_id, kind, from_state, to_state, note, occurred_at, created_at)
|
|
6606
|
+
VALUES (?, ?, ?, NULL, NULL, ?, ?, ?)
|
|
6607
|
+
RETURNING ${EVENT_COLS}`
|
|
6608
|
+
).bind(id, who.userId, body.kind, body.note, occurred, now).first();
|
|
6609
|
+
if (!row) throw new Error("job_events INSERT … RETURNING returned no row");
|
|
6610
|
+
return c.json({ success: true, data: { event: toEvent(row) } }, 201);
|
|
6611
|
+
}
|
|
6612
|
+
);
|
|
6613
|
+
app2.openapi(
|
|
6614
|
+
createRoute({
|
|
6615
|
+
method: "delete",
|
|
6616
|
+
path: "/jobs/{id}/events/{eventId}",
|
|
6617
|
+
tags: ["Pipeline"],
|
|
6618
|
+
summary: "Remove a note/interview/contact/follow-up the caller logged",
|
|
6619
|
+
request: {
|
|
6620
|
+
params: z.object({ id: z.string(), eventId: z.coerce.number().int().positive() }),
|
|
6621
|
+
query: ownerNameQuery
|
|
6622
|
+
},
|
|
6623
|
+
responses: {
|
|
6624
|
+
200: {
|
|
6625
|
+
description: "Removed (or already absent)",
|
|
6626
|
+
content: {
|
|
6627
|
+
"application/json": {
|
|
6628
|
+
schema: z.object({
|
|
6629
|
+
success: z.literal(true),
|
|
6630
|
+
data: z.object({ id: z.number().int(), deleted: z.boolean() })
|
|
6631
|
+
}).openapi("DeleteJobEventResponse")
|
|
6632
|
+
}
|
|
6633
|
+
}
|
|
6634
|
+
},
|
|
6635
|
+
403: FORBIDDEN,
|
|
6636
|
+
...IDENTITY_ERRORS
|
|
6637
|
+
}
|
|
6638
|
+
}),
|
|
6639
|
+
async (c) => {
|
|
6640
|
+
const { id, eventId } = c.req.valid("param");
|
|
6641
|
+
const { ownerName } = c.req.valid("query");
|
|
6642
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6643
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6644
|
+
const res = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6645
|
+
`DELETE FROM job_events
|
|
6646
|
+
WHERE id = ? AND job_id = ? AND user_id = ? AND kind != 'state'`
|
|
6647
|
+
).bind(eventId, id, who.userId).run();
|
|
6648
|
+
return c.json(
|
|
6649
|
+
{ success: true, data: { id: eventId, deleted: (res.meta?.changes ?? 0) > 0 } },
|
|
6650
|
+
200
|
|
6651
|
+
);
|
|
6652
|
+
}
|
|
6653
|
+
);
|
|
6654
|
+
}
|
|
6655
|
+
const ERRORS$1 = {
|
|
6656
|
+
403: {
|
|
6657
|
+
description: "Forbidden",
|
|
6658
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6659
|
+
},
|
|
6660
|
+
404: {
|
|
6661
|
+
description: "No such owner name",
|
|
6662
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6663
|
+
},
|
|
6664
|
+
409: {
|
|
6665
|
+
description: "That owner name has never signed in",
|
|
6666
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6667
|
+
},
|
|
6668
|
+
503: {
|
|
6669
|
+
description: "Identity could not be resolved right now — retry",
|
|
6670
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6671
|
+
}
|
|
6672
|
+
};
|
|
6673
|
+
function registerBlockedRoutes(app2) {
|
|
6674
|
+
app2.get("/blocked-companies", gateAuthed);
|
|
6675
|
+
app2.put("/blocked-companies", gateAuthed);
|
|
6676
|
+
app2.delete("/blocked-companies", gateAuthed);
|
|
6677
|
+
app2.openapi(
|
|
6678
|
+
createRoute({
|
|
6679
|
+
method: "get",
|
|
6680
|
+
path: "/blocked-companies",
|
|
6681
|
+
tags: ["Feed"],
|
|
6682
|
+
summary: "The companies the caller has blocked from their feed",
|
|
6683
|
+
request: { query: ownerNameQuery },
|
|
6684
|
+
responses: {
|
|
6685
|
+
200: {
|
|
6686
|
+
description: "Blocked companies, newest first",
|
|
6687
|
+
content: { "application/json": { schema: BlockedCompaniesResponseSchema } }
|
|
6688
|
+
},
|
|
6689
|
+
...ERRORS$1
|
|
6690
|
+
}
|
|
6691
|
+
}),
|
|
6692
|
+
async (c) => {
|
|
6693
|
+
const who = await effectiveUserId(c, c.req.valid("query").ownerName);
|
|
6694
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6695
|
+
const rows = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6696
|
+
`SELECT company, company_key, created_at FROM blocked_companies
|
|
6697
|
+
WHERE user_id = ? ORDER BY created_at DESC`
|
|
6698
|
+
).bind(who.userId).all();
|
|
6699
|
+
return c.json({ success: true, data: { companies: rows.results } }, 200);
|
|
6700
|
+
}
|
|
6701
|
+
);
|
|
6702
|
+
app2.openapi(
|
|
6703
|
+
createRoute({
|
|
6704
|
+
method: "put",
|
|
6705
|
+
path: "/blocked-companies",
|
|
6706
|
+
tags: ["Feed"],
|
|
6707
|
+
summary: "Block a company from the caller's feed (idempotent)",
|
|
6708
|
+
request: {
|
|
6709
|
+
query: ownerNameQuery,
|
|
6710
|
+
body: { content: { "application/json": { schema: BlockCompanySchema } } }
|
|
6711
|
+
},
|
|
6712
|
+
responses: {
|
|
6713
|
+
200: {
|
|
6714
|
+
description: "Blocked (or already was — the first spelling is kept)",
|
|
6715
|
+
content: { "application/json": { schema: BlockedCompanyResponseSchema } }
|
|
6716
|
+
},
|
|
6717
|
+
...ERRORS$1
|
|
6718
|
+
}
|
|
6719
|
+
}),
|
|
6720
|
+
async (c) => {
|
|
6721
|
+
const who = await effectiveUserId(c, c.req.valid("query").ownerName);
|
|
6722
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6723
|
+
const { company } = c.req.valid("json");
|
|
6724
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
6725
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
6726
|
+
await db.prepare(
|
|
6727
|
+
`INSERT INTO blocked_companies (user_id, company_key, company, created_at)
|
|
6728
|
+
VALUES (?1, LOWER(TRIM(?2)), TRIM(?2), ?3)
|
|
6729
|
+
ON CONFLICT (user_id, company_key) DO NOTHING`
|
|
6730
|
+
).bind(who.userId, company, now).run();
|
|
6731
|
+
const row = await db.prepare(
|
|
6732
|
+
`SELECT company, company_key, created_at FROM blocked_companies
|
|
6733
|
+
WHERE user_id = ? AND company_key = LOWER(TRIM(?))`
|
|
6734
|
+
).bind(who.userId, company).first();
|
|
6735
|
+
if (!row) throw new Error("blocked_companies row missing straight after its insert");
|
|
6736
|
+
return c.json({ success: true, data: { company: row } }, 200);
|
|
6737
|
+
}
|
|
6738
|
+
);
|
|
6739
|
+
app2.openapi(
|
|
6740
|
+
createRoute({
|
|
6741
|
+
method: "delete",
|
|
6742
|
+
path: "/blocked-companies",
|
|
6743
|
+
tags: ["Feed"],
|
|
6744
|
+
summary: "Unblock a company",
|
|
6745
|
+
request: {
|
|
6746
|
+
query: ownerNameQuery.extend({
|
|
6747
|
+
company: z.string().trim().min(1).max(200).openapi({
|
|
6748
|
+
description: "The company, in any case — matched the way it was blocked."
|
|
6749
|
+
})
|
|
6750
|
+
})
|
|
6751
|
+
},
|
|
6752
|
+
responses: {
|
|
6753
|
+
200: {
|
|
6754
|
+
description: "Unblocked (or was not blocked)",
|
|
6755
|
+
content: { "application/json": { schema: UnblockCompanyResponseSchema } }
|
|
6756
|
+
},
|
|
6757
|
+
...ERRORS$1
|
|
6758
|
+
}
|
|
6759
|
+
}),
|
|
6760
|
+
async (c) => {
|
|
6761
|
+
const { ownerName, company } = c.req.valid("query");
|
|
6762
|
+
const who = await effectiveUserId(c, ownerName);
|
|
6763
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6764
|
+
const row = await c.env.JOB_PLATFORM_DB.prepare(
|
|
6765
|
+
`DELETE FROM blocked_companies WHERE user_id = ? AND company_key = LOWER(TRIM(?))
|
|
6766
|
+
RETURNING company_key`
|
|
6767
|
+
).bind(who.userId, company).first();
|
|
6768
|
+
return c.json(
|
|
6769
|
+
{
|
|
6770
|
+
success: true,
|
|
6771
|
+
data: { company_key: row?.company_key ?? company.toLowerCase(), deleted: !!row }
|
|
6772
|
+
},
|
|
6773
|
+
200
|
|
6774
|
+
);
|
|
6775
|
+
}
|
|
6776
|
+
);
|
|
6777
|
+
}
|
|
6778
|
+
const SKILLS = [
|
|
6779
|
+
// Languages
|
|
6780
|
+
{ name: "python", aliases: ["python"] },
|
|
6781
|
+
{ name: "javascript", aliases: ["javascript"] },
|
|
6782
|
+
{ name: "typescript", aliases: ["typescript"] },
|
|
6783
|
+
{ name: "java", aliases: ["java"] },
|
|
6784
|
+
{ name: "kotlin", aliases: ["kotlin"] },
|
|
6785
|
+
{ name: "c++", aliases: ["c++"] },
|
|
6786
|
+
{ name: "c#", aliases: ["c#"] },
|
|
6787
|
+
{ name: "rust", aliases: ["rust"] },
|
|
6788
|
+
{ name: "ruby", aliases: ["ruby"] },
|
|
6789
|
+
{ name: "php", aliases: ["php"] },
|
|
6790
|
+
{ name: "swift", aliases: ["swift"] },
|
|
6791
|
+
{ name: "scala", aliases: ["scala"] },
|
|
6792
|
+
{ name: "sql", aliases: ["sql"] },
|
|
6793
|
+
{ name: "bash", aliases: ["bash", "shell scripting"] },
|
|
6794
|
+
// Frontend
|
|
6795
|
+
{ name: "react", aliases: ["react", "reactjs", "react.js"] },
|
|
6796
|
+
{ name: "vue", aliases: ["vue", "vue.js", "vuejs"] },
|
|
6797
|
+
{ name: "angular", aliases: ["angular"] },
|
|
6798
|
+
{ name: "svelte", aliases: ["svelte"] },
|
|
6799
|
+
{ name: "next.js", aliases: ["next.js", "nextjs"] },
|
|
6800
|
+
{ name: "redux", aliases: ["redux"] },
|
|
6801
|
+
{ name: "tailwind", aliases: ["tailwind", "tailwindcss"] },
|
|
6802
|
+
{ name: "graphql", aliases: ["graphql"] },
|
|
6803
|
+
// Backend
|
|
6804
|
+
{ name: "node.js", aliases: ["node.js", "nodejs", "node"] },
|
|
6805
|
+
{ name: "django", aliases: ["django"] },
|
|
6806
|
+
{ name: "flask", aliases: ["flask"] },
|
|
6807
|
+
{ name: "fastapi", aliases: ["fastapi"] },
|
|
6808
|
+
{ name: "spring", aliases: ["spring", "spring boot"] },
|
|
6809
|
+
{ name: "rails", aliases: ["rails", "ruby on rails"] },
|
|
6810
|
+
{ name: ".net", aliases: [".net", "asp.net"] },
|
|
6811
|
+
{ name: "grpc", aliases: ["grpc"] },
|
|
6812
|
+
{ name: "rest", aliases: ["rest", "restful", "rest api", "rest apis"] },
|
|
6813
|
+
{ name: "microservices", aliases: ["microservices", "microservice"] },
|
|
6814
|
+
// Data stores
|
|
6815
|
+
{ name: "postgresql", aliases: ["postgresql", "postgres"] },
|
|
6816
|
+
{ name: "mysql", aliases: ["mysql"] },
|
|
6817
|
+
{ name: "mongodb", aliases: ["mongodb", "mongo"] },
|
|
6818
|
+
{ name: "redis", aliases: ["redis"] },
|
|
6819
|
+
{ name: "elasticsearch", aliases: ["elasticsearch", "opensearch"] },
|
|
6820
|
+
{ name: "dynamodb", aliases: ["dynamodb"] },
|
|
6821
|
+
{ name: "cassandra", aliases: ["cassandra"] },
|
|
6822
|
+
{ name: "snowflake", aliases: ["snowflake"] },
|
|
6823
|
+
// Cloud and infrastructure
|
|
6824
|
+
{ name: "aws", aliases: ["aws", "amazon web services"] },
|
|
6825
|
+
{ name: "gcp", aliases: ["gcp", "google cloud"] },
|
|
6826
|
+
{ name: "azure", aliases: ["azure"] },
|
|
6827
|
+
{ name: "docker", aliases: ["docker"] },
|
|
6828
|
+
{ name: "kubernetes", aliases: ["kubernetes", "k8s"] },
|
|
6829
|
+
{ name: "terraform", aliases: ["terraform"] },
|
|
6830
|
+
{ name: "ansible", aliases: ["ansible"] },
|
|
6831
|
+
{ name: "helm", aliases: ["helm"] },
|
|
6832
|
+
{ name: "ci/cd", aliases: ["ci/cd", "continuous integration"] },
|
|
6833
|
+
{ name: "github actions", aliases: ["github actions"] },
|
|
6834
|
+
{ name: "linux", aliases: ["linux"] },
|
|
6835
|
+
// Streaming and distributed systems
|
|
6836
|
+
{ name: "kafka", aliases: ["kafka"] },
|
|
6837
|
+
{ name: "rabbitmq", aliases: ["rabbitmq"] },
|
|
6838
|
+
{ name: "spark", aliases: ["spark", "pyspark"] },
|
|
6839
|
+
{ name: "airflow", aliases: ["airflow"] },
|
|
6840
|
+
{ name: "distributed systems", aliases: ["distributed systems"] },
|
|
6841
|
+
// ML and data
|
|
6842
|
+
{ name: "machine learning", aliases: ["machine learning"] },
|
|
6843
|
+
{ name: "deep learning", aliases: ["deep learning"] },
|
|
6844
|
+
{ name: "llm", aliases: ["llm", "llms", "large language model", "large language models"] },
|
|
6845
|
+
{ name: "pytorch", aliases: ["pytorch"] },
|
|
6846
|
+
{ name: "tensorflow", aliases: ["tensorflow"] },
|
|
6847
|
+
{ name: "pandas", aliases: ["pandas"] },
|
|
6848
|
+
{ name: "etl", aliases: ["etl"] },
|
|
6849
|
+
// Security and identity
|
|
6850
|
+
{ name: "security", aliases: ["cybersecurity", "information security", "application security"] },
|
|
6851
|
+
{ name: "oauth", aliases: ["oauth", "oauth2", "oidc", "openid connect"] }
|
|
6852
|
+
];
|
|
6853
|
+
const GO_RE = new RegExp(
|
|
6854
|
+
[
|
|
6855
|
+
String.raw`\b(?:golang|go\s*lang(?:uage)?)\b`,
|
|
6856
|
+
String.raw`\bgo\s+(?:developer|engineer|dev|code|programming|backend)\b`,
|
|
6857
|
+
String.raw`\b(?:built|written|implemented|develop(?:ed)?|experience|coding|proficient|fluency|programming|looking for|seeking)\s+(?:in|with|for)?\s*go\b`,
|
|
6858
|
+
String.raw`(?:\bpython|\bjava|\bc\+\+|\brust|\bc#|\bkubernetes|\bdocker|\btypescript|\breact|\bkafka|\baws)[,\s/]+go\b`,
|
|
6859
|
+
String.raw`\bgo[,\s/]+(?:python|java|rust|c\+\+|c#|kubernetes|docker|typescript|react|kafka|aws)\b`
|
|
6860
|
+
].join("|"),
|
|
6861
|
+
"i"
|
|
6862
|
+
);
|
|
6863
|
+
function escape(s) {
|
|
6864
|
+
return s.replace(/[.*+?^${}()|[\]\\/#]/g, "\\$&");
|
|
6865
|
+
}
|
|
6866
|
+
const MATCHERS = SKILLS.map(({ name, aliases }) => [
|
|
6867
|
+
new RegExp(
|
|
6868
|
+
`(?:^|[^a-z0-9+#.])(?:${aliases.map(escape).join("|")})(?=$|[^a-z0-9+#]|\\.(?:[^a-z0-9]|$))`,
|
|
6869
|
+
"i"
|
|
6870
|
+
),
|
|
6871
|
+
name
|
|
6872
|
+
]);
|
|
6873
|
+
function plain(text) {
|
|
6874
|
+
return text.replace(/<[^>]*>/g, " ").replace(/ | /gi, " ").replace(/&/gi, "&");
|
|
6875
|
+
}
|
|
6876
|
+
function extractSkills(text) {
|
|
6877
|
+
const t = plain(text);
|
|
6878
|
+
const found = [];
|
|
6879
|
+
for (const [re, name] of MATCHERS) if (re.test(t)) found.push(name);
|
|
6880
|
+
if (GO_RE.test(t)) found.push("go");
|
|
6881
|
+
return found;
|
|
6882
|
+
}
|
|
6883
|
+
function canonicalSkill(term) {
|
|
6884
|
+
const t = term.trim().toLowerCase();
|
|
6885
|
+
if (t === "go" || t === "golang") return "go";
|
|
6886
|
+
for (const { name, aliases } of SKILLS) if (aliases.includes(t)) return name;
|
|
6887
|
+
return t;
|
|
6888
|
+
}
|
|
6889
|
+
const WEEKS = 12;
|
|
6890
|
+
const TOP_SKILLS = 15;
|
|
6891
|
+
const APPLIED_OR_LATER = /* @__PURE__ */ new Set(["applied", "interviewing", "offered", "rejected"]);
|
|
6892
|
+
const INTERVIEWED = /* @__PURE__ */ new Set(["interviewing", "offered"]);
|
|
6893
|
+
function weekOf(iso) {
|
|
6894
|
+
const d = new Date(iso);
|
|
6895
|
+
const sinceMonday = (d.getUTCDay() + 6) % 7;
|
|
6896
|
+
d.setUTCDate(d.getUTCDate() - sinceMonday);
|
|
6897
|
+
return d.toISOString().slice(0, 10);
|
|
6898
|
+
}
|
|
6899
|
+
function parseList(raw) {
|
|
6900
|
+
try {
|
|
6901
|
+
const v = JSON.parse(raw);
|
|
6902
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
6903
|
+
} catch {
|
|
6904
|
+
return [];
|
|
6905
|
+
}
|
|
6906
|
+
}
|
|
6907
|
+
const ERRORS = {
|
|
6908
|
+
403: {
|
|
6909
|
+
description: "Forbidden",
|
|
6910
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6911
|
+
},
|
|
6912
|
+
404: {
|
|
6913
|
+
description: "No such owner name",
|
|
6914
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6915
|
+
},
|
|
6916
|
+
409: {
|
|
6917
|
+
description: "That owner name has never signed in",
|
|
6918
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6919
|
+
},
|
|
6920
|
+
503: {
|
|
6921
|
+
description: "Identity could not be resolved right now — retry",
|
|
6922
|
+
content: { "application/json": { schema: IdentityErrorResponseSchema } }
|
|
6923
|
+
}
|
|
6924
|
+
};
|
|
6925
|
+
function registerInsightsRoute(app2) {
|
|
6926
|
+
app2.get("/insights", gateAuthed);
|
|
6927
|
+
app2.openapi(
|
|
6928
|
+
createRoute({
|
|
6929
|
+
method: "get",
|
|
6930
|
+
path: "/insights",
|
|
6931
|
+
tags: ["Pipeline"],
|
|
6932
|
+
summary: "The caller's funnel, response rates, weekly pace, workplace mix and skill demand",
|
|
6933
|
+
request: { query: ownerNameQuery },
|
|
6934
|
+
responses: {
|
|
6935
|
+
200: {
|
|
6936
|
+
description: "Insights",
|
|
6937
|
+
content: { "application/json": { schema: InsightsResponseSchema } }
|
|
6938
|
+
},
|
|
6939
|
+
...ERRORS
|
|
6940
|
+
}
|
|
6941
|
+
}),
|
|
6942
|
+
async (c) => {
|
|
6943
|
+
const who = await effectiveUserId(c, c.req.valid("query").ownerName);
|
|
6944
|
+
if (isEffectiveUserError(who)) return c.json(who.error.body, who.error.status);
|
|
6945
|
+
const db = c.env.JOB_PLATFORM_DB;
|
|
6946
|
+
const [stateCounts, rows, transitions, submitted, profiles] = await db.batch([
|
|
6947
|
+
db.prepare("SELECT state, COUNT(*) AS n FROM job_states WHERE user_id = ? GROUP BY state").bind(who.userId),
|
|
6948
|
+
// Dismissed postings are counted above and nowhere else: they are not
|
|
6949
|
+
// part of the search, and there can be thousands of them.
|
|
6950
|
+
db.prepare(
|
|
6951
|
+
`SELECT js.job_id, js.state, js.updated_at, j.workplace_type, j.description
|
|
6952
|
+
FROM job_states js JOIN jobs j ON j.id = js.job_id
|
|
6953
|
+
WHERE js.user_id = ? AND js.state != 'dismissed'`
|
|
6954
|
+
).bind(who.userId),
|
|
6955
|
+
db.prepare(
|
|
6956
|
+
`SELECT job_id, to_state, occurred_at FROM job_events
|
|
6957
|
+
WHERE user_id = ? AND kind = 'state'`
|
|
6958
|
+
).bind(who.userId),
|
|
6959
|
+
db.prepare(
|
|
6960
|
+
`SELECT job_id, updated_at FROM applications
|
|
6961
|
+
WHERE user_id = ? AND status = 'submitted'`
|
|
6962
|
+
).bind(who.userId),
|
|
6963
|
+
db.prepare("SELECT stack FROM profiles WHERE user_id = ?").bind(who.userId)
|
|
6964
|
+
]);
|
|
6965
|
+
const states = {};
|
|
6966
|
+
for (const r of stateCounts.results) states[r.state] = r.n;
|
|
6967
|
+
const been = /* @__PURE__ */ new Map();
|
|
6968
|
+
const appliedAt = /* @__PURE__ */ new Map();
|
|
6969
|
+
const earliest = (id, at) => {
|
|
6970
|
+
const cur = appliedAt.get(id);
|
|
6971
|
+
if (!cur || at < cur) appliedAt.set(id, at);
|
|
6972
|
+
};
|
|
6973
|
+
const mark = (id, state) => {
|
|
6974
|
+
let s = been.get(id);
|
|
6975
|
+
if (!s) been.set(id, s = /* @__PURE__ */ new Set());
|
|
6976
|
+
s.add(state);
|
|
6977
|
+
};
|
|
6978
|
+
for (const t of transitions.results) {
|
|
6979
|
+
if (!t.to_state) continue;
|
|
6980
|
+
mark(t.job_id, t.to_state);
|
|
6981
|
+
if (t.to_state === "applied") earliest(t.job_id, t.occurred_at);
|
|
6982
|
+
}
|
|
6983
|
+
for (const a of submitted.results) {
|
|
6984
|
+
mark(a.job_id, "applied");
|
|
6985
|
+
earliest(a.job_id, a.updated_at);
|
|
6986
|
+
}
|
|
6987
|
+
const pipeline = rows.results;
|
|
6988
|
+
const workplaceById = /* @__PURE__ */ new Map();
|
|
6989
|
+
for (const r of pipeline) {
|
|
6990
|
+
mark(r.job_id, r.state);
|
|
6991
|
+
workplaceById.set(r.job_id, r.workplace_type || "unknown");
|
|
6992
|
+
if (APPLIED_OR_LATER.has(r.state)) earliest(r.job_id, r.updated_at);
|
|
6993
|
+
}
|
|
6994
|
+
let applied = 0;
|
|
6995
|
+
let interviewed = 0;
|
|
6996
|
+
let offered = 0;
|
|
6997
|
+
let responded = 0;
|
|
6998
|
+
const workplace = /* @__PURE__ */ new Map();
|
|
6999
|
+
for (const [id, s] of been) {
|
|
7000
|
+
const isApplied = [...s].some((x) => APPLIED_OR_LATER.has(x));
|
|
7001
|
+
if (!isApplied) continue;
|
|
7002
|
+
applied++;
|
|
7003
|
+
const iv = [...s].some((x) => INTERVIEWED.has(x));
|
|
7004
|
+
if (iv) interviewed++;
|
|
7005
|
+
if (s.has("offered")) offered++;
|
|
7006
|
+
if (iv || s.has("rejected")) responded++;
|
|
7007
|
+
const w = workplaceById.get(id);
|
|
7008
|
+
if (w) workplace.set(w, (workplace.get(w) ?? 0) + 1);
|
|
7009
|
+
}
|
|
7010
|
+
const share = (n) => applied ? n / applied : null;
|
|
7011
|
+
const thisWeek = weekOf((/* @__PURE__ */ new Date()).toISOString());
|
|
7012
|
+
const weeks = [];
|
|
7013
|
+
for (let i = WEEKS - 1; i >= 0; i--) {
|
|
7014
|
+
const d = /* @__PURE__ */ new Date(`${thisWeek}T00:00:00Z`);
|
|
7015
|
+
d.setUTCDate(d.getUTCDate() - 7 * i);
|
|
7016
|
+
weeks.push(d.toISOString().slice(0, 10));
|
|
7017
|
+
}
|
|
7018
|
+
const perWeek = new Map(weeks.map((w) => [w, 0]));
|
|
7019
|
+
for (const at of appliedAt.values()) {
|
|
7020
|
+
const w = weekOf(at);
|
|
7021
|
+
if (perWeek.has(w)) perWeek.set(w, perWeek.get(w) + 1);
|
|
7022
|
+
}
|
|
7023
|
+
const stack = new Set(
|
|
7024
|
+
profiles.results.flatMap(
|
|
7025
|
+
(p) => parseList(p.stack).map(canonicalSkill)
|
|
7026
|
+
)
|
|
7027
|
+
);
|
|
7028
|
+
const demand = /* @__PURE__ */ new Map();
|
|
7029
|
+
let skillPostings = 0;
|
|
7030
|
+
for (const r of pipeline) {
|
|
7031
|
+
if (!r.description) continue;
|
|
7032
|
+
skillPostings++;
|
|
7033
|
+
for (const s of extractSkills(r.description)) demand.set(s, (demand.get(s) ?? 0) + 1);
|
|
7034
|
+
}
|
|
7035
|
+
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) }));
|
|
7036
|
+
return c.json(
|
|
7037
|
+
{
|
|
7038
|
+
success: true,
|
|
7039
|
+
data: {
|
|
7040
|
+
states,
|
|
7041
|
+
reached: { applied, responded, interviewed, offered },
|
|
7042
|
+
rates: {
|
|
7043
|
+
response: share(responded),
|
|
7044
|
+
interview: share(interviewed),
|
|
7045
|
+
offer: share(offered)
|
|
7046
|
+
},
|
|
7047
|
+
weekly_applied: weeks.map((week) => ({ week, count: perWeek.get(week) })),
|
|
7048
|
+
workplace: [...workplace].sort((a, b) => b[1] - a[1]).map(([workplace_type, count]) => ({ workplace_type, count })),
|
|
7049
|
+
skills,
|
|
7050
|
+
skill_postings: skillPostings
|
|
7051
|
+
}
|
|
7052
|
+
},
|
|
7053
|
+
200
|
|
7054
|
+
);
|
|
7055
|
+
}
|
|
7056
|
+
);
|
|
7057
|
+
}
|
|
7058
|
+
const LocationSchema = z.object({ key: z.string(), label: z.string(), lat: z.number(), lng: z.number() }).openapi("Location");
|
|
7059
|
+
function label(name, region) {
|
|
7060
|
+
const title = name.replace(/\b[a-z]/g, (ch) => ch.toUpperCase());
|
|
7061
|
+
return `${title}, ${region.toUpperCase()}`;
|
|
7062
|
+
}
|
|
7063
|
+
function registerLocationsRoute(app2) {
|
|
7064
|
+
app2.openapi(
|
|
7065
|
+
createRoute({
|
|
7066
|
+
method: "get",
|
|
7067
|
+
path: "/locations",
|
|
7068
|
+
tags: ["Feed"],
|
|
7069
|
+
summary: "Known places matching a prefix, for the feed’s near= filter",
|
|
7070
|
+
request: {
|
|
7071
|
+
query: z.object({
|
|
7072
|
+
q: z.string().trim().min(1).max(100),
|
|
7073
|
+
limit: z.coerce.number().int().min(1).max(25).default(10)
|
|
7074
|
+
})
|
|
7075
|
+
},
|
|
7076
|
+
responses: {
|
|
7077
|
+
200: {
|
|
7078
|
+
description: "Matches, prefix matches first",
|
|
7079
|
+
content: {
|
|
7080
|
+
"application/json": {
|
|
7081
|
+
schema: z.object({
|
|
7082
|
+
success: z.literal(true),
|
|
7083
|
+
data: z.object({ locations: z.array(LocationSchema) })
|
|
7084
|
+
}).openapi("LocationsResponse")
|
|
7085
|
+
}
|
|
7086
|
+
}
|
|
7087
|
+
}
|
|
7088
|
+
}
|
|
7089
|
+
}),
|
|
7090
|
+
(c) => {
|
|
7091
|
+
const { q, limit } = c.req.valid("query");
|
|
7092
|
+
const locations = searchPlaces(q, limit).map((p) => ({
|
|
7093
|
+
key: p.key,
|
|
7094
|
+
label: label(p.name, p.region),
|
|
7095
|
+
lat: p.lat,
|
|
7096
|
+
lng: p.lng
|
|
7097
|
+
}));
|
|
7098
|
+
return c.json({ success: true, data: { locations } }, 200);
|
|
7099
|
+
}
|
|
7100
|
+
);
|
|
7101
|
+
}
|
|
5590
7102
|
const app$2 = new OpenAPIHono();
|
|
5591
7103
|
registerFeedRoute(app$2);
|
|
5592
7104
|
registerPreflightRoute(app$2);
|
|
5593
7105
|
registerPacketsRoute(app$2);
|
|
5594
7106
|
registerDetailRoute(app$2);
|
|
5595
7107
|
registerStateRoutes(app$2);
|
|
7108
|
+
registerPipelineRoutes(app$2);
|
|
5596
7109
|
registerFeedbackRoutes(app$2);
|
|
5597
7110
|
registerTailoringRoutes(app$2);
|
|
5598
7111
|
registerApplicationRoutes(app$2);
|
|
5599
7112
|
registerVerificationRoutes(app$2);
|
|
5600
7113
|
registerFillRoutes(app$2);
|
|
7114
|
+
registerBlockedRoutes(app$2);
|
|
7115
|
+
registerInsightsRoute(app$2);
|
|
7116
|
+
registerLocationsRoute(app$2);
|
|
5601
7117
|
registerAnswerRoutes(app$2);
|
|
5602
7118
|
registerOwnerRoutes(app$2);
|
|
5603
7119
|
const jobRoutes = app$2;
|
|
@@ -5703,12 +7219,14 @@ const ERASED_TABLES = [
|
|
|
5703
7219
|
{ table: "profiles", sql: "DELETE FROM profiles WHERE user_id = ?1" },
|
|
5704
7220
|
{ table: "profile_tombstones", sql: "DELETE FROM profile_tombstones WHERE user_id = ?1" },
|
|
5705
7221
|
{ table: "job_states", sql: "DELETE FROM job_states WHERE user_id = ?1" },
|
|
7222
|
+
{ table: "job_events", sql: "DELETE FROM job_events WHERE user_id = ?1" },
|
|
5706
7223
|
{ table: "job_feedback", sql: "DELETE FROM job_feedback WHERE user_id = ?1" },
|
|
5707
7224
|
{ table: "applications", sql: "DELETE FROM applications WHERE user_id = ?1" },
|
|
5708
7225
|
{ table: "application_answers", sql: "DELETE FROM application_answers WHERE user_id = ?1" },
|
|
5709
7226
|
{ table: "applicant_profiles", sql: "DELETE FROM applicant_profiles WHERE user_id = ?1" },
|
|
5710
7227
|
{ table: "mail_reconcile_state", sql: "DELETE FROM mail_reconcile_state WHERE user_id = ?1" },
|
|
5711
7228
|
{ table: "mail_unmatched", sql: "DELETE FROM mail_unmatched WHERE user_id = ?1" },
|
|
7229
|
+
{ table: "blocked_companies", sql: "DELETE FROM blocked_companies WHERE user_id = ?1" },
|
|
5712
7230
|
{ table: "owner_name_cache", sql: "DELETE FROM owner_name_cache WHERE user_id = ?1" }
|
|
5713
7231
|
];
|
|
5714
7232
|
async function eraseUser(db, userId) {
|
|
@@ -5793,6 +7311,7 @@ function createScheduledHandler(_env) {
|
|
|
5793
7311
|
};
|
|
5794
7312
|
}
|
|
5795
7313
|
export {
|
|
7314
|
+
AddJobEventSchema,
|
|
5796
7315
|
AddProfileCompanyResponseSchema,
|
|
5797
7316
|
AddProfileCompanySchema,
|
|
5798
7317
|
AnswerResponseSchema,
|
|
@@ -5805,6 +7324,10 @@ export {
|
|
|
5805
7324
|
ApplicationSummarySchema,
|
|
5806
7325
|
ApplicationsResponseSchema,
|
|
5807
7326
|
ApplyRequestSchema,
|
|
7327
|
+
BlockCompanySchema,
|
|
7328
|
+
BlockedCompaniesResponseSchema,
|
|
7329
|
+
BlockedCompanyResponseSchema,
|
|
7330
|
+
BlockedCompanySchema,
|
|
5808
7331
|
CompanyMatchSchema,
|
|
5809
7332
|
CreateProfileSchema,
|
|
5810
7333
|
DeleteCompanyResponseSchema,
|
|
@@ -5820,7 +7343,14 @@ export {
|
|
|
5820
7343
|
IngestJobSchema,
|
|
5821
7344
|
IngestPayloadSchema,
|
|
5822
7345
|
IngestResponseSchema,
|
|
7346
|
+
InsightsResponseSchema,
|
|
7347
|
+
InsightsSchema,
|
|
7348
|
+
JOB_STATES,
|
|
5823
7349
|
JobDetailSchema,
|
|
7350
|
+
JobEventKindSchema,
|
|
7351
|
+
JobEventResponseSchema,
|
|
7352
|
+
JobEventSchema,
|
|
7353
|
+
JobEventsResponseSchema,
|
|
5824
7354
|
JobFeedbackResponseSchema,
|
|
5825
7355
|
JobResponseSchema,
|
|
5826
7356
|
JobStateReadSchema,
|
|
@@ -5830,8 +7360,12 @@ export {
|
|
|
5830
7360
|
JobsResponseSchema,
|
|
5831
7361
|
MatchCompanyResponseSchema,
|
|
5832
7362
|
MatchCompanySchema,
|
|
7363
|
+
PERSON_EVENT_KINDS,
|
|
5833
7364
|
PacketSummarySchema,
|
|
5834
7365
|
PacketsResponseSchema,
|
|
7366
|
+
PipelineItemSchema,
|
|
7367
|
+
PipelineResponseSchema,
|
|
7368
|
+
PrioritySchema,
|
|
5835
7369
|
ProbeCompanyResponseSchema,
|
|
5836
7370
|
ProbeCompanySchema,
|
|
5837
7371
|
ProfileCompaniesResponseSchema,
|
|
@@ -5850,9 +7384,14 @@ export {
|
|
|
5850
7384
|
SetApplicationStatusSchema,
|
|
5851
7385
|
SetJobFeedbackSchema,
|
|
5852
7386
|
SetJobStateSchema,
|
|
7387
|
+
SetTrackingSchema,
|
|
7388
|
+
TrackingResponseSchema,
|
|
7389
|
+
TrackingSchema,
|
|
5853
7390
|
UnansweredQuestionSchema,
|
|
5854
7391
|
UnansweredResponseSchema,
|
|
7392
|
+
UnblockCompanyResponseSchema,
|
|
5855
7393
|
UpdateProfileSchema,
|
|
7394
|
+
asJobStateRead,
|
|
5856
7395
|
createFetchHandler,
|
|
5857
7396
|
createJobPlatformHandler,
|
|
5858
7397
|
createScheduledHandler
|