@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/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(),
@@ -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
- (label, work) => runAfterResponse(c, label, work)
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 slug = slugFromHost(host);
1530
- if (slug) return { ats: "greenhouse", slug };
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 slug = board_slug ?? parsed.slug;
2070
- const slugSource = slug === null ? null : board_slug ? "scraped" : "guessed";
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
- slug,
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 && slug === 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, slug, row.id).run();
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
- (label, work) => runAfterResponse(c, label, work)
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, slug, display_name, now).run();
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, slug).first();
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(["interested", "dismissed", "saved", "applied", "new"]).optional().openapi({
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 ?? "new" : null,
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 ?? "new" : null,
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 ? stateRow.state : "new";
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("SELECT id FROM jobs WHERE id = ?").bind(id).first();
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
- await db.prepare(
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).run();
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 result = await c.env.JOB_PLATFORM_DB.prepare(
3931
- "DELETE FROM job_states WHERE job_id = ? AND user_id = ?"
3932
- ).bind(id, userId).run();
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, slug) {
4063
- const res = await resumeViaEdge(env, `/resume/api/resume?v=${encodeURIComponent(slug)}`, {
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
- 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();
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 slug = row?.variant_slug ?? null;
4199
- if (!slug) {
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, slug);
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 ('${slug}') has expired; re-prepare it or post resume_markdown.`
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
- (label, work) => runAfterResponse(c, label, work)
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
- SELECT job_id, ?, state, notes, ?, variant_slug
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 slug = app_?.variant_slug || state?.variant_slug || "";
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: slug } : null,
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: slug ? `${RESUME_PDF_URL}?v=${encodeURIComponent(slug)}` : null
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(/&nbsp;|&#160;/gi, " ").replace(/&amp;/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