@hadoku/jobplatform-worker 2.32.35 → 2.32.37

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