Zion Boggan
repos/Docket/lib/feed.ts
zionboggan.com ↗
2037 lines · typescript
History for this file →
1
 
2
import { safeAll, safeGet, safeCount } from "@/lib/db";
3
 
4
type CacheEntry<T> = { value: T; expiry: number };
5
const _cache = new Map<string, CacheEntry<unknown>>();
6
const CACHE_TTL_MS = 5 * 60 * 1000;
7
 
8
function cached<T>(key: string, fn: () => T, ttl = CACHE_TTL_MS): T {
9
  const now = Date.now();
10
  const entry = _cache.get(key) as CacheEntry<T> | undefined;
11
  if (entry && entry.expiry > now) return entry.value;
12
  const value = fn();
13
  _cache.set(key, { value, expiry: now + ttl });
14
  return value;
15
}
16
import { formatUsd } from "@/lib/format";
17
import type { PartyCode } from "@/lib/neutrality";
18
 
19
 
20
export type FeedItem = {
21
  id: string;
22
  kind:
23
    | "bill"
24
    | "vote"
25
    | "award"
26
    | "amendment"
27
    | "rider"
28
    | "procedural"
29
    | "executive"
30
    | "apportionment";
31
  actionDate: string;
32
  chamber: string;
33
  title: string;
34
  summary?: string;
35
  actionType: string;
36
  amount?: number | null;
37
  amountText?: string;
38
  jurisdiction: string;
39
  sourceUrl: string;
40
  href?: string;
41
};
42
 
43
export type Award = {
44
  id: string;
45
  agency: string;
46
  recipient: string;
47
  amount: number;
48
  amountText: string;
49
  actionDate: string;
50
  program: string;
51
  sourceUrl: string;
52
  href: string;
53
};
54
 
55
export type FiscalMetric = {
56
  id: string;
57
  metric: string;
58
  value: number;
59
  valueText: string;
60
  date: string;
61
  sourceUrl: string;
62
  href: string;
63
};
64
 
65
export type Apportionment = {
66
  id: string;
67
  agency: string;
68
  account: string;
69
  appropriated: number | null;
70
  apportioned: number | null;
71
  status: string;
72
  date: string;
73
  sourceUrl: string;
74
  href: string;
75
};
76
 
77
export type Vote = {
78
  id: string;
79
  billId?: number | null;
80
  billNumber?: string;
81
  billTitle?: string;
82
 
83
  billTextRef?: string;
84
  chamber: string;
85
  date: string;
86
  result: string;
87
  yea: number;
88
  nay: number;
89
  present: number;
90
  recorded: boolean;
91
  sourceUrl: string;
92
  href?: string;
93
 
94
  archiveUrl?: string;
95
};
96
 
97
export type ExecutiveAction = {
98
  id: string;
99
  type: string;
100
  title: string;
101
  agency: string;
102
  frDocNumber: string;
103
  date: string;
104
  sourceUrl: string;
105
  href?: string;
106
};
107
 
108
 
109
export function partyCodeFromWord(word: string): PartyCode {
110
  const w = (word || "").toLowerCase();
111
  if (w.startsWith("rep")) return "R";
112
  if (w.startsWith("dem")) return "D";
113
  return "I";
114
}
115
 
116
export type MemberSummary = {
117
  id: string;
118
  numericId: number;
119
  bioguide: string;
120
  name: string;
121
  party: string;
122
  partyCode: PartyCode;
123
  state: string;
124
  district: number | null;
125
  chamber: string;
126
  photoUrl: string;
127
  sourceUrl: string;
128
  href: string;
129
 
130
  isCurrent: boolean;
131
};
132
 
133
export type VotePosition = {
134
  member: string;
135
  party: string;
136
  partyCode: PartyCode;
137
  state: string;
138
  district: number | null;
139
  position: string;
140
  photoUrl: string;
141
  memberHref: string;
142
};
143
 
144
export type VoteDetail = Vote & {
145
  question?: string;
146
  rollNumber?: number;
147
  notVoting?: number;
148
  positions: VotePosition[];
149
};
150
 
151
export type MemberVote = {
152
  voteId: string;
153
  voteHref: string;
154
  date: string;
155
  chamber: string;
156
  question?: string;
157
  result: string;
158
  yea: number;
159
  nay: number;
160
  position: string;
161
  sourceUrl: string;
162
};
163
 
164
 
165
 
166
export type MemberYearGroup = {
167
  year: number | null;
168
  count: number;
169
};
170
 
171
export type MemberSponsorship = {
172
  billId: string;
173
  billHref: string;
174
  number: string;
175
  title: string;
176
  congress: number;
177
  status: string;
178
  updatedAt: string;
179
  sourceUrl: string;
180
};
181
 
182
export type MemberAmendment = {
183
  amendmentId: string;
184
  amendmentHref: string;
185
  number: string;
186
  type: string;
187
  status: string;
188
  billId: number | null;
189
  billHref: string | null;
190
  sourceUrl: string;
191
};
192
 
193
export type MemberDetail = MemberSummary & {
194
  votes: MemberVote[];
195
  sponsorships: MemberSponsorship[];
196
  amendments: MemberAmendment[];
197
  voteCount: number;
198
  votesByYear: MemberYearGroup[];
199
};
200
 
201
 
202
export type BillVersion = {
203
  id: string;
204
  versionCode: string;
205
  versionLabel: string;
206
  versionDate: string;
207
  sourceUrl: string;
208
};
209
 
210
export type BillDiffChange = {
211
  sectionId: string;
212
  change: string;
213
  snippet: string;
214
  riderFlag: boolean;
215
  sourceUrl: string;
216
  fromVersion: string;
217
  toVersion: string;
218
};
219
 
220
export type BillDiff = {
221
  from: string;
222
  to: string;
223
  changes: BillDiffChange[];
224
  note?: string;
225
};
226
 
227
export type BillAmendment = {
228
  id: string;
229
  number: string;
230
  type: string;
231
  status: string;
232
  agreedMethod: string;
233
  billId: number | null;
234
  sponsor: MemberSummary | null;
235
  sourceUrl: string;
236
  href: string;
237
};
238
 
239
export type BillTimelineEntry = {
240
  date: string;
241
  chamber: string;
242
  actionType: string;
243
  text: string;
244
  sourceUrl: string;
245
};
246
 
247
export type BillDetail = {
248
  id: string;
249
  number: string;
250
  title: string;
251
  congress: number;
252
  policyArea: string;
253
  status: string;
254
  updatedAt: string;
255
  sourceUrl: string;
256
  href: string;
257
  sponsor: MemberSummary | null;
258
  sponsorDisplay: string;
259
  versions: BillVersion[];
260
  amendments: BillAmendment[];
261
  diffs: BillDiffChange[];
262
  votes: VoteDetail[];
263
  timeline: BillTimelineEntry[];
264
  subjects: string[];
265
  fullTextUrl: string;
266
  summaryPlain: string;
267
 
268
  summary: string;
269
  summarySource: string;
270
  pdfUrl: string;
271
  archivePdfUrl: string;
272
};
273
 
274
 
275
export type StateCoverage = {
276
  code: string;
277
  name: string;
278
  confidence: "ok" | "stale" | "missing";
279
  lastScrape: string;
280
  missingTypes: string[];
281
};
282
 
283
export type StateDetail = StateCoverage & {
284
  members: MemberSummary[];
285
  bills: BillListItem[];
286
  votes: Vote[];
287
};
288
 
289
 
290
type Rankable = {
291
  actionDate: string;
292
  dollarAmount: number | null;
293
  affectedEstimate: number | null;
294
  actionType: string;
295
};
296
 
297
 
298
export function rankByRule(): (a: Rankable, b: Rankable) => number {
299
  return (a, b) => {
300
    const ad = dateKey(a.actionDate);
301
    const bd = dateKey(b.actionDate);
302
    if (ad !== bd) return bd - ad;
303
    if (ad === 0) {
304
      const da = a.dollarAmount ?? 0;
305
      const db = b.dollarAmount ?? 0;
306
      if (db !== da) return db - da;
307
      return (b.affectedEstimate ?? 0) - (a.affectedEstimate ?? 0);
308
    }
309
    const da = a.dollarAmount ?? 0;
310
    const db = b.dollarAmount ?? 0;
311
    if (db !== da) return db - da;
312
    return (b.affectedEstimate ?? 0) - (a.affectedEstimate ?? 0);
313
  };
314
}
315
 
316
function dateKey(iso: string): number {
317
  if (!iso) return 0;
318
  const t = Date.parse(iso.length === 10 ? iso + "T00:00:00Z" : iso);
319
  return Number.isFinite(t) ? Math.floor(t / 86_400_000) : 0;
320
}
321
 
322
 
323
 
324
export function getFeedItems(perSource = 400): FeedItem[] {
325
  return cached(`feed:${perSource}`, () => {
326
    const items: FeedItem[] = [];
327
 
328
    items.push(
329
      ...safeAll<FeedItem>(
330
        `SELECT id, agency, recipient, amount, action_date, program, source_url
331
         FROM awards
332
         ORDER BY amount DESC
333
         LIMIT :lim`,
334
        { lim: perSource },
335
        (r) => toFeedAward(r)
336
      )
337
    );
338
 
339
    items.push(
340
      ...safeAll<FeedItem>(
341
        `SELECT id, bill_id, chamber, date, yea, nay, present, result, source_url
342
         FROM votes
343
         ORDER BY date DESC
344
         LIMIT :lim`,
345
        { lim: perSource },
346
        (r) => toFeedVote(r)
347
      )
348
    );
349
 
350
    items.push(
351
      ...safeAll<FeedItem>(
352
        `SELECT id, type, title, agency, fr_doc_number, date, source_url
353
         FROM executive_actions
354
         ORDER BY date DESC
355
         LIMIT :lim`,
356
        { lim: perSource },
357
        (r) => toFeedExecutive(r)
358
      )
359
    );
360
 
361
    items.push(
362
      ...safeAll<FeedItem>(
363
        `SELECT id, number, title, status, updated_at, source_url
364
         FROM bills
365
         WHERE congress > 0
366
         ORDER BY updated_at DESC
367
         LIMIT :lim`,
368
        { lim: perSource },
369
        (r) => toFeedBill(r)
370
      )
371
    );
372
 
373
    return diversifyByKind(items.sort(rankItems));
374
  });
375
}
376
 
377
export function getFeedKindCounts(): Map<string, number> {
378
  return cached("feed:kindcounts", () => {
379
    const map = new Map<string, number>();
380
    const billsCount = safeCount("bills");
381
    const awardsCount = safeCount("awards");
382
    const votesCount = safeCount("votes");
383
    const execCount = safeCount("executive_actions");
384
    if (billsCount) map.set("bill", billsCount);
385
    if (awardsCount) map.set("award", awardsCount);
386
    if (votesCount) map.set("vote", votesCount);
387
    if (execCount) map.set("executive", execCount);
388
    return map;
389
  });
390
}
391
 
392
 
393
function diversifyByKind(items: FeedItem[]): FeedItem[] {
394
  if (items.length <= 1) return items;
395
  const groups: FeedItem[][] = [];
396
  let cur: FeedItem[] = [];
397
  let curKey = "";
398
  for (const it of items) {
399
    const k = it.actionDate?.slice(0, 10) ?? "";
400
    if (k !== curKey) {
401
      if (cur.length) groups.push(cur);
402
      cur = [it];
403
      curKey = k;
404
    } else {
405
      cur.push(it);
406
    }
407
  }
408
  if (cur.length) groups.push(cur);
409
 
410
  const out: FeedItem[] = [];
411
  for (const g of groups) {
412
    if (g.length <= 2) {
413
      out.push(...g);
414
      continue;
415
    }
416
    const byKind = new Map<string, FeedItem[]>();
417
    const order: string[] = [];
418
    for (const it of g) {
419
      const arr = byKind.get(it.kind);
420
      if (arr) arr.push(it);
421
      else {
422
        byKind.set(it.kind, [it]);
423
        order.push(it.kind);
424
      }
425
    }
426
    const maxLen = Math.max(...[...byKind.values()].map((a) => a.length));
427
    for (let i = 0; i < maxLen; i++) {
428
      for (const k of order) {
429
        const arr = byKind.get(k);
430
        if (arr && i < arr.length) out.push(arr[i]);
431
      }
432
    }
433
  }
434
  return out;
435
}
436
 
437
 
438
function toRankable(i: FeedItem): Rankable {
439
  return {
440
    actionDate: i.actionDate,
441
    dollarAmount: i.amount ?? null,
442
    affectedEstimate: null,
443
    actionType: i.actionType,
444
  };
445
}
446
const rankItems = (a: FeedItem, b: FeedItem): number =>
447
  rankByRule()(toRankable(a), toRankable(b));
448
 
449
function toFeedAward(r: Record<string, unknown>): FeedItem {
450
  const amount = num(r.amount);
451
  const id = `award:${r.id}`;
452
  return {
453
    id,
454
    kind: "award",
455
    actionDate: str(r.action_date),
456
    chamber: "USASPENDING",
457
    title: str(r.agency) || "Federal award",
458
    summary: str(r.recipient) || undefined,
459
    actionType: "AWARD",
460
    amount,
461
    amountText: formatUsd(amount, { compact: true }),
462
    jurisdiction: "US",
463
    sourceUrl: ensureSource(r.source_url),
464
    href: "/spending",
465
  };
466
}
467
 
468
function toFeedVote(r: Record<string, unknown>): FeedItem {
469
  const yea = num(r.yea);
470
  const nay = num(r.nay);
471
  const passed = yea > nay;
472
  const voteId = num(r.id);
473
  return {
474
    id: `vote:${r.id}`,
475
    kind: "vote",
476
    actionDate: str(r.date),
477
    chamber: str(r.chamber).toUpperCase() || "SENATE",
478
    title: str(r.result) || "Roll-call vote",
479
    summary: undefined,
480
    actionType: passed ? "PASSED" : "FAILED",
481
    amount: null,
482
    amountText: `${yea}\u2013${nay}`,
483
    jurisdiction: "US",
484
    sourceUrl: ensureSource(r.source_url),
485
    href: `/votes/${voteId}`,
486
  };
487
}
488
 
489
function toFeedExecutive(r: Record<string, unknown>): FeedItem {
490
  const execId = num(r.id);
491
  return {
492
    id: `exec:${r.id}`,
493
    kind: "executive",
494
    actionDate: str(r.date),
495
    chamber: "FEDERAL REGISTER",
496
    title: str(r.title) || "Federal Register action",
497
    summary: str(r.agency) || undefined,
498
    actionType: execTypeLabel(str(r.type)),
499
    amount: null,
500
    amountText: undefined,
501
    jurisdiction: "US",
502
    sourceUrl: ensureSource(r.source_url),
503
    href: `/executive?id=${execId}`,
504
  };
505
}
506
 
507
function toFeedBill(r: Record<string, unknown>): FeedItem {
508
  const billId = num(r.id);
509
  const number = str(r.number) || "Bill";
510
  const status = str(r.status);
511
  return {
512
    id: `bill:${r.id}`,
513
    kind: "bill",
514
    actionDate: str(r.updated_at),
515
    chamber: "CONGRESS",
516
    title: number,
517
    summary: str(r.title) || undefined,
518
    actionType: status ? status.toUpperCase() : "BILL",
519
    amount: null,
520
    amountText: undefined,
521
    jurisdiction: "US",
522
    sourceUrl: ensureSource(r.source_url),
523
    href: `/bill/${billId}`,
524
  };
525
}
526
 
527
function execTypeLabel(t: string): string {
528
  switch (t.toLowerCase()) {
529
    case "eo":
530
      return "EXECUTIVE ORDER";
531
    case "nprm":
532
      return "PROPOSED RULE";
533
    case "final_rule":
534
      return "FINAL RULE";
535
    case "notice":
536
      return "NOTICE";
537
    default:
538
      return t.toUpperCase() || "ACTION";
539
  }
540
}
541
 
542
 
543
export function getAwardsSummary(): { total: number; count: number } {
544
  return cached("awards:summary", () => {
545
    const row = safeGet<{ total: number; count: number }>(
546
      `SELECT COALESCE(SUM(amount), 0) AS total, COUNT(*) AS count FROM awards`
547
    );
548
    return { total: row?.total ?? 0, count: row?.count ?? 0 };
549
  });
550
}
551
 
552
export function getAwards(): Award[] {
553
  return safeAll<Award>(
554
    `SELECT id, agency, recipient, amount, action_date, program, source_url
555
     FROM awards
556
     ORDER BY amount DESC`,
557
    {},
558
    (r) => {
559
      const amount = num(r.amount);
560
      return {
561
        id: `award:${r.id}`,
562
        agency: str(r.agency),
563
        recipient: str(r.recipient),
564
        amount,
565
        amountText: formatUsd(amount),
566
        actionDate: str(r.action_date),
567
        program: str(r.program),
568
        sourceUrl: ensureSource(r.source_url),
569
        href: "/spending",
570
      };
571
    }
572
  );
573
}
574
 
575
export function getAwardsFiltered(q?: string, sort?: string): Award[] {
576
  const orderBy =
577
    sort === "date"   ? "action_date DESC, amount DESC" :
578
    sort === "agency" ? "agency ASC, amount DESC" :
579
    "amount DESC";
580
  const mapper = (r: Record<string, unknown>): Award => {
581
    const amount = num(r.amount);
582
    return {
583
      id: `award:${r.id}`,
584
      agency: str(r.agency),
585
      recipient: str(r.recipient),
586
      amount,
587
      amountText: formatUsd(amount),
588
      actionDate: str(r.action_date),
589
      program: str(r.program),
590
      sourceUrl: ensureSource(r.source_url),
591
      href: "/spending",
592
    };
593
  };
594
  if (!q || !q.trim()) {
595
    return safeAll<Award>(
596
      `SELECT id, agency, recipient, amount, action_date, program, source_url
597
       FROM awards
598
       ORDER BY ${orderBy}`,
599
      {},
600
      mapper
601
    );
602
  }
603
  const like = `%${q.trim().replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
604
  return safeAll<Award>(
605
    `SELECT id, agency, recipient, amount, action_date, program, source_url
606
     FROM awards
607
     WHERE (agency LIKE :q ESCAPE '\\' OR recipient LIKE :q ESCAPE '\\' OR program LIKE :q ESCAPE '\\')
608
     ORDER BY ${orderBy}`,
609
    { q: like },
610
    mapper
611
  );
612
}
613
 
614
export function getFiscal(): FiscalMetric[] {
615
  return safeAll<FiscalMetric>(
616
    `SELECT rowid, metric, date, value, source_url FROM fiscal ORDER BY date DESC`,
617
    {},
618
    (r) => {
619
      const value = num(r.value);
620
      return {
621
        id: `fiscal:${r.rowid}`,
622
        metric: str(r.metric),
623
        value,
624
        valueText: formatUsd(value, { compact: true }),
625
        date: str(r.date),
626
        sourceUrl: ensureSource(r.source_url),
627
        href: "/spending",
628
      };
629
    }
630
  );
631
}
632
 
633
export function getApportionments(): Apportionment[] {
634
  return safeAll<Apportionment>(
635
    `SELECT id, agency, account, appropriated, apportioned, status, date, source_url
636
     FROM apportionments
637
     ORDER BY date DESC`,
638
    {},
639
    (r) => ({
640
      id: `appr:${r.id ?? r.rowid}`,
641
      agency: str(r.agency),
642
      account: str(r.account),
643
      appropriated: nullableNum(r.appropriated),
644
      apportioned: nullableNum(r.apportioned),
645
      status: str(r.status) || "released",
646
      date: str(r.date),
647
      sourceUrl: ensureSource(r.source_url),
648
      href: "/spending",
649
    })
650
  );
651
}
652
 
653
 
654
export function getVoteCount(chamber?: string): number {
655
  if (chamber) {
656
    const row = safeGet<{ c: number }>(
657
      `SELECT COUNT(*) AS c FROM votes WHERE LOWER(chamber) = :ch`,
658
      { ch: chamber.toLowerCase() }
659
    );
660
    return row?.c ?? 0;
661
  }
662
  return safeCount("votes");
663
}
664
 
665
export type VoteFilter = {
666
  chamber?: string;
667
  q?: string;
668
  resultFilter?: "passed" | "failed";
669
  sort?: "newest" | "chamber" | "result";
670
};
671
 
672
 
673
function buildVotesWhere(filter: VoteFilter): { where: string; params: Record<string, unknown> } {
674
  const clauses: string[] = [];
675
  const params: Record<string, unknown> = {};
676
  if (filter.chamber) {
677
    clauses.push("LOWER(v.chamber) = :ch");
678
    params.ch = filter.chamber.toLowerCase();
679
  }
680
  if (filter.q) {
681
    const like = `%${filter.q.trim().replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
682
    clauses.push("(v.result LIKE :q ESCAPE '\\' OR v.question LIKE :q ESCAPE '\\')");
683
    params.q = like;
684
  }
685
  if (filter.resultFilter === "passed") {
686
    clauses.push("v.yea > v.nay");
687
  } else if (filter.resultFilter === "failed") {
688
    clauses.push("v.nay >= v.yea AND (v.yea + v.nay) > 0");
689
  }
690
  const where = clauses.length ? " WHERE " + clauses.join(" AND ") : "";
691
  return { where, params };
692
}
693
 
694
function buildVotesOrder(sort?: VoteFilter["sort"]): string {
695
  switch (sort) {
696
    case "chamber": return "v.chamber, v.date DESC, v.id DESC";
697
    case "result":  return "v.result, v.date DESC, v.id DESC";
698
    default:        return "v.date DESC, v.id DESC";
699
  }
700
}
701
 
702
export function getVoteCountFiltered(filter: VoteFilter = {}): number {
703
  const { where, params } = buildVotesWhere(filter);
704
  const row = safeGet<{ c: number }>(
705
    `SELECT COUNT(*) AS c FROM votes v${where}`,
706
    params
707
  );
708
  return row?.c ?? 0;
709
}
710
 
711
export function getVotes(limit = 100, offset = 0, chamber?: string): Vote[] {
712
  return getVotesFiltered(chamber ? { chamber } : {}, limit, offset);
713
}
714
 
715
 
716
export function getVotesFiltered(filter: VoteFilter = {}, limit = 100, offset = 0): Vote[] {
717
  const { where, params } = buildVotesWhere(filter);
718
  const orderBy = buildVotesOrder(filter.sort);
719
  return safeAll<Vote>(
720
    `SELECT v.id, v.bill_id, v.bill_text_ref, v.chamber, v.date,
721
            v.yea, v.nay, v.present, v.result, v.source_url, v.archive_url,
722
            b.number AS bill_number, b.title AS bill_title
723
     FROM votes v
724
     LEFT JOIN bills b ON b.id = v.bill_id${where}
725
     ORDER BY ${orderBy}
726
     LIMIT :limit OFFSET :offset`,
727
    { ...params, limit, offset },
728
    (r) => toVote(r)
729
  );
730
}
731
 
732
 
733
export function getVoteById(id: number): VoteDetail | null {
734
  const r = safeGet<Record<string, unknown>>(
735
    `SELECT v.id, v.bill_id, v.bill_text_ref, v.chamber, v.date,
736
            v.yea, v.nay, v.present, v.not_voting, v.roll_number,
737
            v.question, v.recorded, v.result, v.source_url, v.archive_url,
738
            b.number AS bill_number, b.title AS bill_title
739
     FROM votes v
740
     LEFT JOIN bills b ON b.id = v.bill_id
741
     WHERE v.id = :id`,
742
    { id }
743
  );
744
  if (!r) return null;
745
 
746
  const yea = num(r.yea);
747
  const nay = num(r.nay);
748
  const recorded =
749
    (r.recorded != null ? !!num(r.recorded) : true) && yea + nay > 0;
750
 
751
  const positions: VotePosition[] = recorded
752
    ? safeAll<VotePosition>(
753
        `SELECT vp.position, m.full_name, m.party, m.state, m.district, m.photo_url, m.id
754
         FROM vote_positions vp
755
         LEFT JOIN members m ON m.id = vp.member_id
756
         WHERE vp.vote_id = :id`,
757
        { id },
758
        (p) => ({
759
          member: str(p.full_name) || str(p.member_bioguide) || "Member",
760
          party: str(p.party),
761
          partyCode: partyCodeFromWord(str(p.party)),
762
          state: str(p.state),
763
          district: nullableNum(p.district),
764
          position: str(p.position) || "Not Voting",
765
          photoUrl: str(p.photo_url),
766
          memberHref: p.id ? `/members/${num(p.id)}` : "",
767
        })
768
      )
769
    : [];
770
 
771
  return {
772
    id: `vote:${r.id}`,
773
    billId: nullableNum(r.bill_id),
774
    billNumber: str(r.bill_number) || undefined,
775
    billTitle: str(r.bill_title) || undefined,
776
    billTextRef: str(r.bill_text_ref) || undefined,
777
    chamber: str(r.chamber).toUpperCase() || "SENATE",
778
    date: str(r.date),
779
    result: str(r.result) || (yea > nay ? "Passed" : "Failed"),
780
    yea,
781
    nay,
782
    present: num(r.present),
783
    recorded,
784
    sourceUrl: ensureSource(r.source_url),
785
    href: undefined,
786
    archiveUrl: str(r.archive_url) || undefined,
787
    question: str(r.question) || undefined,
788
    rollNumber: nullableNum(r.roll_number) ?? undefined,
789
    notVoting: nullableNum(r.not_voting) ?? undefined,
790
    positions,
791
  };
792
}
793
 
794
function toVote(r: Record<string, unknown>): Vote {
795
  const yea = num(r.yea);
796
  const nay = num(r.nay);
797
  const recorded =
798
    (r.recorded != null ? !!num(r.recorded) : true) && yea + nay > 0;
799
  const voteId = num(r.id);
800
  return {
801
    id: `vote:${r.id}`,
802
    billId: nullableNum(r.bill_id),
803
    billNumber: str(r.bill_number) || undefined,
804
    billTitle: str(r.bill_title) || undefined,
805
    billTextRef: str(r.bill_text_ref) || undefined,
806
    chamber: str(r.chamber).toUpperCase() || "SENATE",
807
    date: str(r.date),
808
    result: str(r.result) || (yea > nay ? "Passed" : "Failed"),
809
    yea,
810
    nay,
811
    present: num(r.present),
812
    recorded,
813
    sourceUrl: ensureSource(r.source_url),
814
    href: `/votes/${voteId}`,
815
    archiveUrl: str(r.archive_url) || undefined,
816
  };
817
}
818
 
819
 
820
export function getExecutive(): ExecutiveAction[] {
821
  return safeAll<ExecutiveAction>(
822
    `SELECT id, type, title, agency, fr_doc_number, date, source_url
823
     FROM executive_actions
824
     ORDER BY date DESC`,
825
    {},
826
    (r) => ({
827
      id: `exec:${r.id}`,
828
      type: str(r.type),
829
      title: str(r.title),
830
      agency: str(r.agency),
831
      frDocNumber: str(r.fr_doc_number),
832
      date: str(r.date),
833
      sourceUrl: ensureSource(r.source_url),
834
      href: undefined,
835
    })
836
  );
837
}
838
 
839
 
840
export type BillListItem = {
841
  id: string;
842
  number: string;
843
  title: string;
844
  congress: number;
845
  policyArea: string;
846
  status: string;
847
  updatedAt: string;
848
  sourceUrl: string;
849
  href: string;
850
};
851
 
852
export type BillFilter = {
853
  q?: string;
854
  state?: string;
855
  status?: string;
856
  congress?: number;
857
  policyArea?: string;
858
  sort?: "updated" | "action" | "congress" | "status";
859
};
860
 
861
 
862
function buildBillsWhere(filter: BillFilter): { where: string; params: Record<string, unknown> } {
863
  const clauses: string[] = [];
864
  const params: Record<string, unknown> = {};
865
 
866
  if (filter.q) {
867
    const like = `%${filter.q.trim().replace(/[%_\\]/g, (m) => "\\" + m)}%`;
868
    clauses.push(
869
      "(b.number LIKE :q ESCAPE '\\' OR b.title LIKE :q ESCAPE '\\' OR b.policy_area LIKE :q ESCAPE '\\')"
870
    );
871
    params.q = like;
872
  }
873
  if (filter.congress) {
874
    clauses.push("b.congress = :congress");
875
    params.congress = filter.congress;
876
  }
877
  if (filter.policyArea) {
878
    clauses.push("b.policy_area = :policyArea");
879
    params.policyArea = filter.policyArea;
880
  }
881
  if (filter.status) {
882
    clauses.push("b.status LIKE :status ESCAPE '\\'");
883
    params.status = `%${filter.status.replace(/[%_\\]/g, (m) => "\\" + m)}%`;
884
  }
885
  if (filter.state) {
886
    params.sponsorState = filter.state.toUpperCase();
887
  }
888
 
889
  const hasState = !!filter.state;
890
  const from = hasState
891
    ? "bills b JOIN bills_ext e ON e.bill_id = b.id"
892
    : "bills b";
893
  if (hasState) {
894
    clauses.push("UPPER(e.sponsor_state) = :sponsorState");
895
  }
896
 
897
  const where = clauses.length ? " WHERE " + clauses.join(" AND ") : "";
898
  return { where: `FROM ${from}${where}`, params };
899
}
900
 
901
export function getBillsCount(filter: BillFilter = {}): number {
902
  const { where, params } = buildBillsWhere(filter);
903
  const row = safeGet<{ c: number }>(
904
    `SELECT COUNT(*) AS c ${where}`,
905
    params
906
  );
907
  return row?.c ?? 0;
908
}
909
 
910
export function getBills(filter: BillFilter = {}, limit = 100, offset = 0): BillListItem[] {
911
  const { where, params } = buildBillsWhere(filter);
912
  const orderBy =
913
    filter.sort === "congress" ? "b.congress DESC, b.updated_at DESC" :
914
    filter.sort === "status"   ? "b.status ASC, b.updated_at DESC" :
915
    "b.updated_at DESC";
916
  return safeAll<BillListItem>(
917
    `SELECT b.id, b.congress, b.number, b.title, b.status, b.policy_area,
918
            b.updated_at, b.source_url
919
     ${where}
920
     ORDER BY ${orderBy}
921
     LIMIT :limit OFFSET :offset`,
922
    { ...params, limit, offset },
923
    (r) => ({
924
      id: `bill:${r.id}`,
925
      number: str(r.number),
926
      title: str(r.title),
927
      congress: num(r.congress),
928
      policyArea: str(r.policy_area),
929
      status: str(r.status),
930
      updatedAt: str(r.updated_at),
931
      sourceUrl: ensureSource(r.source_url),
932
      href: `/bill/${r.id}`,
933
    })
934
  );
935
}
936
 
937
export function getBillPolicyAreas(): string[] {
938
  const rows = safeAll<{ policy_area: string }>(
939
    `SELECT DISTINCT policy_area FROM bills
940
     WHERE policy_area IS NOT NULL AND policy_area != ''
941
     ORDER BY policy_area`,
942
    {},
943
    (r) => ({ policy_area: str(r.policy_area) })
944
  );
945
  return rows.map((r) => r.policy_area);
946
}
947
 
948
export function getBillCongresses(): number[] {
949
  return cached("bills:congresses", () =>
950
    safeAll<{ congress: number }>(
951
      `SELECT DISTINCT congress FROM bills WHERE congress IS NOT NULL ORDER BY congress DESC`,
952
      {},
953
      (r) => ({ congress: num(r.congress) })
954
    ).map((r) => r.congress)
955
  );
956
}
957
 
958
 
959
export type LawListItem = {
960
  id: string;
961
  billHref: string;
962
  billNumber: string;
963
  plNumber: string;
964
  plCongress: number;
965
  plSequence: number;
966
  title: string;
967
  congress: number;
968
  status: string;
969
  summary: string;
970
  summarySource: string;
971
  sourceUrl: string;
972
  pdfUrl: string;
973
  archivePdfUrl: string;
974
};
975
 
976
function parsePlNumber(status: string): { plNumber: string; congress: number; seq: number } {
977
  const m = /Public Law No:\s*(\d+)\s*-\s*(\d+)/i.exec(status || "");
978
  if (!m) return { plNumber: "", congress: 0, seq: 0 };
979
  return { plNumber: `${m[1]}-${m[2]}`, congress: parseInt(m[1], 10), seq: parseInt(m[2], 10) };
980
}
981
 
982
export function getPrivateLawsCount(): number {
983
  const row = safeGet<{ c: number }>(
984
    `SELECT COUNT(*) AS c FROM bills WHERE status LIKE 'Became Private Law No:%'`
985
  );
986
  return row?.c ?? 0;
987
}
988
 
989
 
990
function getPublicLawRows(q?: string): LawListItem[] {
991
  const params: Record<string, unknown> = {};
992
  let where = "WHERE b.status LIKE 'Became Public Law No:%'";
993
  if (q && q.trim()) {
994
    const like = `%${q.trim().replace(/[%_\\]/g, (m) => `\\${m}`)}%`;
995
    where +=
996
      " AND (b.number LIKE :q ESCAPE '\\' OR b.title LIKE :q ESCAPE '\\' OR b.status LIKE :q ESCAPE '\\')";
997
    params.q = like;
998
  }
999
  const rows = safeAll<Record<string, unknown>>(
1000
    `SELECT b.id, b.congress, b.number, b.title, b.status, b.source_url,
1001
            b.summary, b.summary_source, b.pdf_url, b.archive_pdf_url
1002
     FROM bills b ${where}`,
1003
    params
1004
  );
1005
  return rows.map((r) => {
1006
    const status = str(r.status);
1007
    const parsed = parsePlNumber(status);
1008
    return {
1009
      id: `bill:${r.id}`,
1010
      billHref: `/bill/${r.id}`,
1011
      billNumber: str(r.number),
1012
      plNumber: parsed.plNumber,
1013
      plCongress: parsed.congress || num(r.congress),
1014
      plSequence: parsed.seq,
1015
      title: str(r.title),
1016
      congress: num(r.congress),
1017
      status,
1018
      summary: str(r.summary),
1019
      summarySource: str(r.summary_source),
1020
      sourceUrl: ensureSource(r.source_url),
1021
      pdfUrl: str(r.pdf_url),
1022
      archivePdfUrl: str(r.archive_pdf_url),
1023
    };
1024
  });
1025
}
1026
 
1027
function sortLaws(items: LawListItem[], sort?: "recent" | "oldest" | "number"): LawListItem[] {
1028
  const byRecent = (a: LawListItem, b: LawListItem) =>
1029
    b.plCongress - a.plCongress || b.plSequence - a.plSequence;
1030
  switch (sort) {
1031
    case "oldest":
1032
      return [...items].sort((a, b) => -byRecent(a, b));
1033
    case "number":
1034
      return [...items].sort(
1035
        (a, b) => b.plCongress - a.plCongress || a.plSequence - b.plSequence
1036
      );
1037
    default:
1038
      return [...items].sort(byRecent);
1039
  }
1040
}
1041
 
1042
export type LawSort = "recent" | "oldest" | "number";
1043
 
1044
export function getPublicLaws(
1045
  opts: { q?: string; sort?: LawSort } = {},
1046
  limit = 200,
1047
  offset = 0
1048
): LawListItem[] {
1049
  const sorted = sortLaws(getPublicLawRows(opts.q), opts.sort);
1050
  return sorted.slice(offset, offset + limit);
1051
}
1052
 
1053
export function getPublicLawsCount(q?: string): number {
1054
  return getPublicLawRows(q).length;
1055
}
1056
 
1057
export function getBillDetail(idNum: number): BillDetail | null {
1058
  const r = safeGet<Record<string, unknown>>(
1059
    `SELECT b.id, b.congress, b.number, b.title, b.status, b.policy_area,
1060
            b.updated_at, b.source_url, b.sponsor_id,
1061
            b.summary, b.summary_source, b.pdf_url, b.archive_pdf_url,
1062
            (SELECT e.sponsor_name FROM bills_ext e WHERE e.bill_id = b.id) AS sponsor_name
1063
     FROM bills b WHERE b.id = :id`,
1064
    { id: idNum }
1065
  );
1066
  if (!r) return null;
1067
 
1068
  const sponsor = getMemberSummaryByBioguide(str(r.sponsor_id));
1069
 
1070
  const versions = safeAll<BillVersion>(
1071
    `SELECT version_code, version_label, version_date, source_url
1072
     FROM bill_versions WHERE bill_id = :id
1073
     ORDER BY version_date DESC`,
1074
    { id: idNum },
1075
    (v) => ({
1076
      id: `bv:${idNum}:${str(v.version_code)}`,
1077
      versionCode: str(v.version_code),
1078
      versionLabel: str(v.version_label),
1079
      versionDate: str(v.version_date),
1080
      sourceUrl: ensureSource(v.source_url),
1081
    })
1082
  );
1083
 
1084
  const amendments = safeAll<BillAmendment>(
1085
    `SELECT id, congress, type, number, bill_id, sponsor_id, status, agreed_method, source_url
1086
     FROM amendments WHERE bill_id = :id
1087
     ORDER BY number DESC`,
1088
    { id: idNum },
1089
    (a) => ({
1090
      id: `amdt:${a.id}`,
1091
      number: str(a.number),
1092
      type: str(a.type),
1093
      status: str(a.status),
1094
      agreedMethod: str(a.agreed_method),
1095
      billId: nullableNum(a.bill_id),
1096
      sponsor: getMemberSummaryByBioguide(str(a.sponsor_id)),
1097
      sourceUrl: ensureSource(a.source_url),
1098
      href: ensureSource(a.source_url),
1099
    })
1100
  );
1101
 
1102
  const diffs = safeAll<BillDiffChange>(
1103
    `SELECT section_id, change, snippet, rider_flag, source_url,
1104
            from_version, to_version
1105
     FROM bill_diffs WHERE bill_id = :id
1106
     ORDER BY rider_flag DESC, section_id`,
1107
    { id: idNum },
1108
    (d) => ({
1109
      sectionId: str(d.section_id),
1110
      change: str(d.change),
1111
      snippet: str(d.snippet),
1112
      riderFlag: !!num(d.rider_flag),
1113
      sourceUrl: ensureSource(d.source_url),
1114
      fromVersion: str(d.from_version),
1115
      toVersion: str(d.to_version),
1116
    })
1117
  );
1118
 
1119
  const timeline = safeAll<BillTimelineEntry>(
1120
    `SELECT action_date, chamber, action_type, text, source_url
1121
     FROM actions WHERE bill_id = :id
1122
     ORDER BY action_date DESC, id DESC`,
1123
    { id: idNum },
1124
    (t) => ({
1125
      date: str(t.action_date),
1126
      chamber: str(t.chamber),
1127
      actionType: str(t.action_type),
1128
      text: str(t.text),
1129
      sourceUrl: ensureSource(t.source_url),
1130
    })
1131
  );
1132
 
1133
  return {
1134
    id: `bill:${r.id}`,
1135
    number: str(r.number),
1136
    congress: num(r.congress),
1137
    title: str(r.title),
1138
    status: str(r.status),
1139
    policyArea: str(r.policy_area),
1140
    updatedAt: str(r.updated_at),
1141
    sourceUrl: ensureSource(r.source_url),
1142
    href: `/bill/${r.id}`,
1143
    sponsor,
1144
    sponsorDisplay: str(r.sponsor_name),
1145
    versions,
1146
    amendments,
1147
    diffs,
1148
    votes: [],
1149
    timeline,
1150
    subjects: getBillSubjects(idNum),
1151
    fullTextUrl: ensureSource(r.source_url),
1152
    summaryPlain: str(r.summary),
1153
    summary: str(r.summary),
1154
    summarySource: str(r.summary_source),
1155
    pdfUrl: str(r.pdf_url),
1156
    archivePdfUrl: str(r.archive_pdf_url),
1157
  };
1158
}
1159
 
1160
 
1161
export function getBillDiff(
1162
  billId: number,
1163
  fromV: string,
1164
  toV: string
1165
): BillDiff {
1166
  const hasVersionFilter = !!fromV && !!toV;
1167
  const changes = safeAll<BillDiffChange>(
1168
    hasVersionFilter
1169
      ? `SELECT section_id, change, snippet, rider_flag, source_url,
1170
                from_version, to_version
1171
         FROM bill_diffs
1172
         WHERE bill_id = :billId
1173
           AND from_version = :fromV AND to_version = :toV
1174
         ORDER BY rider_flag DESC, section_id`
1175
      : `SELECT section_id, change, snippet, rider_flag, source_url,
1176
                from_version, to_version
1177
         FROM bill_diffs
1178
         WHERE bill_id = :billId
1179
         ORDER BY rider_flag DESC, section_id`,
1180
    hasVersionFilter ? { billId, fromV, toV } : { billId },
1181
    (c) => ({
1182
      sectionId: str(c.section_id),
1183
      change: str(c.change),
1184
      snippet: str(c.snippet),
1185
      riderFlag: !!num(c.rider_flag),
1186
      sourceUrl: ensureSource(c.source_url),
1187
      fromVersion: str(c.from_version),
1188
      toVersion: str(c.to_version),
1189
    })
1190
  );
1191
  return {
1192
    from: fromV,
1193
    to: toV,
1194
    changes,
1195
    note: undefined,
1196
  };
1197
}
1198
 
1199
 
1200
export type MemberFilter = {
1201
  q?: string;
1202
  state?: string;
1203
  chamber?: string;
1204
  party?: PartyCode;
1205
};
1206
 
1207
function getAllMembersRaw(): MemberSummary[] {
1208
  return cached("members:all", () =>
1209
    safeAll<MemberSummary>(
1210
      `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url, is_current
1211
       FROM members
1212
       ORDER BY chamber, state, full_name`,
1213
      {},
1214
      (r) => toMemberSummary(r)
1215
    )
1216
  );
1217
}
1218
 
1219
 
1220
export function getMembersDefault(): MemberSummary[] {
1221
  return cached("members:current", () => {
1222
    const all = getAllMembersRaw();
1223
    return all.filter((m) => m.isCurrent);
1224
  });
1225
}
1226
 
1227
 
1228
export function getMemberCounts(): {
1229
  total: number;
1230
  senate: number;
1231
  house: number;
1232
  currentTotal: number;
1233
  currentSenate: number;
1234
  currentHouse: number;
1235
} {
1236
  return cached("members:counts", () => {
1237
    const total = safeCount("members");
1238
    const senateRow = safeGet<{ c: number }>(
1239
      `SELECT COUNT(*) AS c FROM members WHERE LOWER(chamber) = 'senate'`
1240
    );
1241
    const houseRow = safeGet<{ c: number }>(
1242
      `SELECT COUNT(*) AS c FROM members WHERE LOWER(chamber) = 'house'`
1243
    );
1244
    const curTotalRow = safeGet<{ c: number }>(
1245
      `SELECT COUNT(*) AS c FROM members WHERE is_current = 1`
1246
    );
1247
    const curSenateRow = safeGet<{ c: number }>(
1248
      `SELECT COUNT(*) AS c FROM members WHERE is_current = 1 AND LOWER(chamber) = 'senate'`
1249
    );
1250
    const curHouseRow = safeGet<{ c: number }>(
1251
      `SELECT COUNT(*) AS c FROM members WHERE is_current = 1 AND LOWER(chamber) = 'house'`
1252
    );
1253
    return {
1254
      total,
1255
      senate: senateRow?.c ?? 0,
1256
      house: houseRow?.c ?? 0,
1257
      currentTotal: curTotalRow?.c ?? 0,
1258
      currentSenate: curSenateRow?.c ?? 0,
1259
      currentHouse: curHouseRow?.c ?? 0,
1260
    };
1261
  });
1262
}
1263
 
1264
export function getMembers(filter: MemberFilter = {}): MemberSummary[] {
1265
  const items = getAllMembersRaw();
1266
 
1267
  if (!filter.q && !filter.state && !filter.chamber && !filter.party) {
1268
    return items;
1269
  }
1270
 
1271
  let out = items;
1272
  const q = filter.q?.trim().toLowerCase();
1273
  if (q) {
1274
    out = out.filter(
1275
      (m) =>
1276
        m.name.toLowerCase().includes(q) ||
1277
        m.bioguide.toLowerCase().includes(q) ||
1278
        m.state.toLowerCase().includes(q)
1279
    );
1280
  }
1281
  if (filter.state) {
1282
    const st = filter.state.toUpperCase();
1283
    out = out.filter((m) => m.state === st);
1284
  }
1285
  if (filter.chamber) {
1286
    const ch = filter.chamber.toLowerCase();
1287
    out = out.filter((m) => m.chamber.toLowerCase() === ch);
1288
  }
1289
  if (filter.party) {
1290
    out = out.filter((m) => m.partyCode === filter.party);
1291
  }
1292
  return out;
1293
}
1294
 
1295
export function getMemberStates(): string[] {
1296
  return safeAll<{ state: string }>(
1297
    `SELECT DISTINCT state FROM members WHERE state IS NOT NULL AND state != ''
1298
     ORDER BY state`,
1299
    {},
1300
    (r) => ({ state: str(r.state) })
1301
  ).map((r) => r.state);
1302
}
1303
 
1304
export function getMember(rawId: string): MemberDetail | null {
1305
  return cached(`member:${rawId}`, () => _getMemberImpl(rawId));
1306
}
1307
 
1308
function _getMemberImpl(rawId: string): MemberDetail | null {
1309
  const cleaned = rawId.replace(/^member:/, "");
1310
  const numeric = parseInt(cleaned, 10);
1311
  const byId = Number.isFinite(numeric)
1312
    ? safeGet<Record<string, unknown>>(
1313
        `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url, is_current
1314
         FROM members WHERE id = :id`,
1315
        { id: numeric }
1316
      )
1317
    : null;
1318
  const row =
1319
    byId ??
1320
    safeGet<Record<string, unknown>>(
1321
      `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url, is_current
1322
       FROM members WHERE bioguide_id = :bg`,
1323
      { bg: cleaned }
1324
    );
1325
  if (!row) return null;
1326
 
1327
  const summary = toMemberSummary(row);
1328
  const bioguide = summary.bioguide;
1329
 
1330
  const votes = safeAll<MemberVote>(
1331
    `SELECT v.id, v.date, v.chamber, v.question, v.result, v.yea, v.nay, v.source_url,
1332
            vp.position
1333
     FROM vote_positions vp
1334
     JOIN votes v ON v.id = vp.vote_id
1335
     WHERE vp.member_id = :mid
1336
     ORDER BY v.date DESC, v.id DESC
1337
     LIMIT 40`,
1338
    { mid: summary.numericId },
1339
    (v) => ({
1340
      voteId: `vote:${v.id}`,
1341
      voteHref: `/votes/${num(v.id)}`,
1342
      date: str(v.date),
1343
      chamber: str(v.chamber).toUpperCase() || "SENATE",
1344
      question: str(v.question) || undefined,
1345
      result: str(v.result),
1346
      yea: num(v.yea),
1347
      nay: num(v.nay),
1348
      position: str(v.position) || "Not Voting",
1349
      sourceUrl: ensureSource(v.source_url),
1350
    })
1351
  );
1352
 
1353
  const sponsorships = safeAll<MemberSponsorship>(
1354
    `SELECT id, number, title, congress, status, updated_at, source_url
1355
     FROM bills WHERE sponsor_id = :bg
1356
     ORDER BY updated_at DESC`,
1357
    { bg: bioguide },
1358
    (b) => ({
1359
      billId: `bill:${b.id}`,
1360
      billHref: `/bill/${b.id}`,
1361
      number: str(b.number),
1362
      title: str(b.title),
1363
      congress: num(b.congress),
1364
      status: str(b.status),
1365
      updatedAt: str(b.updated_at),
1366
      sourceUrl: ensureSource(b.source_url),
1367
    })
1368
  );
1369
 
1370
  const amendments = safeAll<MemberAmendment>(
1371
    `SELECT id, number, type, status, bill_id, source_url
1372
     FROM amendments WHERE sponsor_id = :bg
1373
     ORDER BY id DESC`,
1374
    { bg: bioguide },
1375
    (a) => ({
1376
      amendmentId: `amdt:${a.id}`,
1377
      amendmentHref: ensureSource(a.source_url),
1378
      number: str(a.number),
1379
      type: str(a.type),
1380
      status: str(a.status),
1381
      billId: nullableNum(a.bill_id),
1382
      billHref: a.bill_id ? `/bill/${a.bill_id}` : null,
1383
      sourceUrl: ensureSource(a.source_url),
1384
    })
1385
  );
1386
 
1387
  const voteCount = safeGet<{ c: number }>(
1388
    `SELECT COUNT(*) AS c FROM vote_positions WHERE member_id = :mid`,
1389
    { mid: summary.numericId }
1390
  )?.c ?? votes.length;
1391
 
1392
  const votesByYear = safeAll<MemberYearGroup>(
1393
    `SELECT v.year, COUNT(*) AS count
1394
     FROM vote_positions vp
1395
     JOIN votes v ON v.id = vp.vote_id
1396
     WHERE vp.member_id = :mid
1397
     GROUP BY v.year
1398
     ORDER BY v.year IS NULL ASC, v.year DESC`,
1399
    { mid: summary.numericId },
1400
    (r) => ({ year: nullableNum(r.year), count: num(r.count) })
1401
  );
1402
 
1403
  return {
1404
    ...summary,
1405
    votes,
1406
    sponsorships,
1407
    amendments,
1408
    voteCount,
1409
    votesByYear,
1410
  };
1411
}
1412
 
1413
 
1414
export function getMemberVotesForYear(
1415
  bioguide: string,
1416
  year: number | null,
1417
  limit = 600
1418
): MemberVote[] {
1419
  const cacheKey = `member-votes:${bioguide}:${year ?? "null"}`;
1420
  return cached(cacheKey, () => {
1421
    const memberRow = safeGet<{ id: number }>(
1422
      `SELECT id FROM members WHERE bioguide_id = :bg`,
1423
      { bg: bioguide }
1424
    );
1425
    if (!memberRow) return [];
1426
    const mid = memberRow.id;
1427
    const yearClause = year == null ? `v.year IS NULL` : `v.year = :year`;
1428
    const params: Record<string, unknown> = year == null
1429
      ? { mid, lim: limit }
1430
      : { mid, year, lim: limit };
1431
    return safeAll<MemberVote>(
1432
      `SELECT v.id, v.date, v.chamber, v.question, v.result, v.yea, v.nay,
1433
              v.source_url, vp.position
1434
       FROM vote_positions vp
1435
       JOIN votes v ON v.id = vp.vote_id
1436
       WHERE vp.member_id = :mid AND ${yearClause}
1437
       ORDER BY v.date DESC, v.id DESC
1438
       LIMIT :lim`,
1439
      params,
1440
      (v) => ({
1441
        voteId: `vote:${num(v.id)}`,
1442
        voteHref: `/votes/${num(v.id)}`,
1443
        date: str(v.date),
1444
        chamber: str(v.chamber).toUpperCase() || "SENATE",
1445
        question: str(v.question) || undefined,
1446
        result: str(v.result),
1447
        yea: num(v.yea),
1448
        nay: num(v.nay),
1449
        position: str(v.position) || "Not Voting",
1450
        sourceUrl: ensureSource(v.source_url),
1451
      })
1452
    );
1453
  });
1454
}
1455
 
1456
function toMemberSummary(r: Record<string, unknown>): MemberSummary {
1457
  const idNum = num(r.id);
1458
  return {
1459
    id: `member:${idNum}`,
1460
    numericId: idNum,
1461
    bioguide: str(r.bioguide_id),
1462
    name: str(r.full_name),
1463
    party: str(r.party),
1464
    partyCode: partyCodeFromWord(str(r.party)),
1465
    state: str(r.state),
1466
    district: nullableNum(r.district),
1467
    chamber: str(r.chamber).toLowerCase() || "house",
1468
    photoUrl: str(r.photo_url),
1469
    sourceUrl: ensureSource(r.source_url),
1470
    href: `/members/${idNum}`,
1471
    isCurrent: num(r.is_current) === 1,
1472
  };
1473
}
1474
 
1475
function getMemberSummaryByBioguide(bioguide: string): MemberSummary | null {
1476
  if (!bioguide) return null;
1477
  const r = safeGet<Record<string, unknown>>(
1478
    `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url, is_current
1479
     FROM members WHERE bioguide_id = :bg`,
1480
    { bg: bioguide }
1481
  );
1482
  return r ? toMemberSummary(r) : null;
1483
}
1484
 
1485
 
1486
const STATE_NAMES: Record<string, string> = {
1487
  AL: "Alabama", AK: "Alaska", AZ: "Arizona", AR: "Arkansas", CA: "California",
1488
  CO: "Colorado", CT: "Connecticut", DE: "Delaware", FL: "Florida", GA: "Georgia",
1489
  HI: "Hawaii", ID: "Idaho", IL: "Illinois", IN: "Indiana", IA: "Iowa",
1490
  KS: "Kansas", KY: "Kentucky", LA: "Louisiana", ME: "Maine", MD: "Maryland",
1491
  MA: "Massachusetts", MI: "Michigan", MN: "Minnesota", MS: "Mississippi",
1492
  MO: "Missouri", MT: "Montana", NE: "Nebraska", NV: "Nevada", NH: "New Hampshire",
1493
  NJ: "New Jersey", NM: "New Mexico", NY: "New York", NC: "North Carolina",
1494
  ND: "North Dakota", OH: "Ohio", OK: "Oklahoma", OR: "Oregon", PA: "Pennsylvania",
1495
  RI: "Rhode Island", SC: "South Carolina", SD: "South Dakota", TN: "Tennessee",
1496
  TX: "Texas", UT: "Utah", VT: "Vermont", VA: "Virginia", WA: "Washington",
1497
  WV: "West Virginia", WI: "Wisconsin", WY: "Wyoming",
1498
  DC: "District of Columbia", PR: "Puerto Rico", AS: "American Samoa",
1499
  GU: "Guam", MP: "Northern Mariana Islands", VI: "U.S. Virgin Islands",
1500
};
1501
 
1502
function stateName(code: string): string {
1503
  return STATE_NAMES[code] || code;
1504
}
1505
 
1506
function rollupConfidence(values: string[]): "ok" | "stale" | "missing" {
1507
  const set = new Set(values.map((v) => (v || "").toLowerCase()));
1508
  if (set.size === 0 || set.has("missing")) return "missing";
1509
  if (set.has("stale")) return "stale";
1510
  return "ok";
1511
}
1512
 
1513
export function getStates(): StateCoverage[] {
1514
  const rows = safeAll<{
1515
    state: string;
1516
    confidence: string;
1517
    data_type: string;
1518
    last_scrape: string;
1519
  }>(
1520
    `SELECT state, confidence, data_type, last_scrape
1521
     FROM state_coverage
1522
     ORDER BY state`,
1523
    {},
1524
    (r) => ({
1525
      state: str(r.state).toUpperCase(),
1526
      confidence: str(r.confidence),
1527
      data_type: str(r.data_type),
1528
      last_scrape: str(r.last_scrape),
1529
    })
1530
  );
1531
 
1532
  const byState = new Map<
1533
    string,
1534
    {
1535
      confidences: string[];
1536
      missingTypes: string[];
1537
      lastScrape: string;
1538
    }
1539
  >();
1540
  for (const r of rows) {
1541
    if (!r.state) continue;
1542
    const entry = byState.get(r.state) ?? {
1543
      confidences: [],
1544
      missingTypes: [],
1545
      lastScrape: "",
1546
    };
1547
    entry.confidences.push(r.confidence);
1548
    if (
1549
      (r.confidence || "").toLowerCase() === "missing" &&
1550
      r.data_type
1551
    ) {
1552
      entry.missingTypes.push(r.data_type);
1553
    }
1554
    if (r.last_scrape > entry.lastScrape) entry.lastScrape = r.last_scrape;
1555
    byState.set(r.state, entry);
1556
  }
1557
 
1558
  const seen = new Set(byState.keys());
1559
  for (const code of Object.keys(STATE_NAMES)) {
1560
    if (!seen.has(code)) {
1561
      byState.set(code, {
1562
        confidences: ["missing"],
1563
        missingTypes: ["bills", "votes", "sponsors"],
1564
        lastScrape: "",
1565
      });
1566
    }
1567
  }
1568
 
1569
  return Array.from(byState.entries())
1570
    .map(([code, e]) => ({
1571
      code,
1572
      name: stateName(code),
1573
      confidence: rollupConfidence(e.confidences),
1574
      lastScrape: e.lastScrape,
1575
      missingTypes: Array.from(new Set(e.missingTypes)),
1576
    }))
1577
    .sort((a, b) => a.code.localeCompare(b.code));
1578
}
1579
 
1580
export function getStateDetail(code: string): StateDetail | null {
1581
  const upper = (code || "").toUpperCase();
1582
  if (!Object.prototype.hasOwnProperty.call(STATE_NAMES, upper)) return null;
1583
  const states = getStates();
1584
  const coverage =
1585
    states.find((s) => s.code === upper) ?? {
1586
      code: upper,
1587
      name: stateName(upper),
1588
      confidence: "missing" as const,
1589
      lastScrape: "",
1590
      missingTypes: ["bills", "votes", "sponsors"],
1591
    };
1592
 
1593
  const members = getMembers({ state: upper }).filter((m) => m.isCurrent);
1594
  const bills = safeAll<BillListItem>(
1595
    `SELECT b.id, b.congress, b.number, b.title, b.status, b.policy_area,
1596
            b.updated_at, b.source_url
1597
     FROM bills b
1598
     JOIN bills_ext e ON e.bill_id = b.id
1599
     WHERE UPPER(e.sponsor_state) = :st
1600
     ORDER BY b.updated_at DESC`,
1601
    { st: upper },
1602
    (r) => ({
1603
      id: `bill:${r.id}`,
1604
      number: str(r.number),
1605
      title: str(r.title),
1606
      congress: num(r.congress),
1607
      policyArea: str(r.policy_area),
1608
      status: str(r.status),
1609
      updatedAt: str(r.updated_at),
1610
      sourceUrl: ensureSource(r.source_url),
1611
      href: `/bill/${r.id}`,
1612
    })
1613
  );
1614
 
1615
  return {
1616
    ...coverage,
1617
    members,
1618
    bills,
1619
    votes: [],
1620
  };
1621
}
1622
 
1623
 
1624
export type SearchResults = {
1625
  bills: BillListItem[];
1626
  awards: Array<{
1627
    id: string;
1628
    agency: string;
1629
    recipient: string;
1630
    amount: number;
1631
    sourceUrl: string;
1632
  }>;
1633
  members: MemberSummary[];
1634
  executive: ExecutiveAction[];
1635
  states: StateCoverage[];
1636
};
1637
 
1638
export function globalSearch(q: string): SearchResults {
1639
  const query = q.trim();
1640
  if (!query) {
1641
    return { bills: [], awards: [], members: [], executive: [], states: [] };
1642
  }
1643
  const like = `%${query.replace(/[%_]/g, (m) => "\\" + m)}%`;
1644
 
1645
  const bills = safeAll<BillListItem>(
1646
    `SELECT id, congress, number, title, status, policy_area, updated_at, source_url
1647
     FROM bills
1648
     WHERE number LIKE :q ESCAPE '\\' OR title LIKE :q ESCAPE '\\'
1649
            OR policy_area LIKE :q ESCAPE '\\'
1650
     ORDER BY updated_at DESC`,
1651
    { q: like },
1652
    (r) => ({
1653
      id: `bill:${r.id}`,
1654
      number: str(r.number),
1655
      title: str(r.title),
1656
      congress: num(r.congress),
1657
      policyArea: str(r.policy_area),
1658
      status: str(r.status),
1659
      updatedAt: str(r.updated_at),
1660
      sourceUrl: ensureSource(r.source_url),
1661
      href: `/bill/${r.id}`,
1662
    })
1663
  );
1664
 
1665
  const awards = safeAll(
1666
    `SELECT id, agency, recipient, amount, source_url FROM awards
1667
     WHERE agency LIKE :q ESCAPE '\\' OR recipient LIKE :q ESCAPE '\\'
1668
     ORDER BY amount DESC`,
1669
    { q: like },
1670
    (r) => ({
1671
      id: `award:${r.id}`,
1672
      agency: str(r.agency),
1673
      recipient: str(r.recipient),
1674
      amount: num(r.amount),
1675
      sourceUrl: ensureSource(r.source_url),
1676
    })
1677
  );
1678
 
1679
  const members = safeAll<MemberSummary>(
1680
    `SELECT id, bioguide_id, full_name, party, state, district, chamber, photo_url, source_url, is_current
1681
     FROM members
1682
     WHERE full_name LIKE :q ESCAPE '\\' OR bioguide_id LIKE :q ESCAPE '\\'
1683
            OR state LIKE :q ESCAPE '\\'
1684
     ORDER BY full_name`,
1685
    { q: like },
1686
    (r) => toMemberSummary(r)
1687
  );
1688
 
1689
  const executive = safeAll<ExecutiveAction>(
1690
    `SELECT id, type, title, agency, fr_doc_number, date, source_url
1691
     FROM executive_actions
1692
     WHERE title LIKE :q ESCAPE '\\' OR agency LIKE :q ESCAPE '\\'
1693
     ORDER BY date DESC`,
1694
    { q: like },
1695
    (r) => ({
1696
      id: `exec:${r.id}`,
1697
      type: str(r.type),
1698
      title: str(r.title),
1699
      agency: str(r.agency),
1700
      frDocNumber: str(r.fr_doc_number),
1701
      date: str(r.date),
1702
      sourceUrl: ensureSource(r.source_url),
1703
      href: undefined,
1704
    })
1705
  );
1706
 
1707
  const states = getStates().filter(
1708
    (s) =>
1709
      s.code.toLowerCase() === query.toLowerCase() ||
1710
      s.name.toLowerCase().includes(query.toLowerCase())
1711
  );
1712
 
1713
  return { bills, awards, members, executive, states };
1714
}
1715
 
1716
 
1717
export type CalendarKind =
1718
  | "effective"
1719
  | "enacted"
1720
  | "vote"
1721
  | "introduced"
1722
  | "committee"
1723
  | "floor"
1724
  | "apportionment"
1725
  | "effective_parsed";
1726
 
1727
export type CalendarEvent = {
1728
  id: string;
1729
  date: string;
1730
  kind: CalendarKind;
1731
  refType: string;
1732
  refId: number | null;
1733
  title: string;
1734
  jurisdiction: string;
1735
  sourceUrl: string;
1736
  href: string | null;
1737
  confidence: number;
1738
};
1739
 
1740
 
1741
export type CalendarDayKind = { date: string; kind: CalendarKind; count: number };
1742
 
1743
export function getCalendarDaySummary(from: string, to: string): CalendarDayKind[] {
1744
  return safeAll<CalendarDayKind>(
1745
    `SELECT date, kind, COUNT(*) AS count
1746
     FROM calendar_events
1747
     WHERE date >= :from AND date <= :to
1748
     GROUP BY date, kind
1749
     ORDER BY date ASC, kind ASC`,
1750
    { from, to },
1751
    (r) => ({
1752
      date: str(r.date),
1753
      kind: str(r.kind) as CalendarKind,
1754
      count: num(r.count),
1755
    })
1756
  );
1757
}
1758
 
1759
 
1760
export function getCalendarEvents(opts: {
1761
  from?: string;
1762
  to?: string;
1763
  kind?: string;
1764
}): CalendarEvent[] {
1765
  const where: string[] = [];
1766
  const params: Record<string, unknown> = {};
1767
  if (opts.from) {
1768
    where.push("date >= :from");
1769
    params.from = opts.from;
1770
  }
1771
  if (opts.to) {
1772
    where.push("date <= :to");
1773
    params.to = opts.to;
1774
  }
1775
  if (opts.kind) {
1776
    where.push("kind = :kind");
1777
    params.kind = opts.kind;
1778
  }
1779
  const sql = `SELECT id, date, kind, ref_type, ref_id, title, jurisdiction, source_url, confidence
1780
               FROM calendar_events${where.length ? " WHERE " + where.join(" AND ") : ""}
1781
               ORDER BY date ASC, kind ASC`;
1782
  return safeAll<CalendarEvent>(sql, params, (r) => {
1783
    const refId = nullableNum(r.ref_id);
1784
    return {
1785
      id: `cal:${r.id}`,
1786
      date: str(r.date),
1787
      kind: str(r.kind) as CalendarKind,
1788
      refType: str(r.ref_type),
1789
      refId,
1790
      title: str(r.title),
1791
      jurisdiction: str(r.jurisdiction) || "US",
1792
      sourceUrl: ensureSource(r.source_url),
1793
      href: calendarHref(str(r.ref_type), refId),
1794
      confidence: nullableNum(r.confidence) ?? 1,
1795
    };
1796
  });
1797
}
1798
 
1799
function calendarHref(refType: string, refId: number | null): string | null {
1800
  if (refId == null) return null;
1801
  switch (refType) {
1802
    case "bill":
1803
      return `/bill/${refId}`;
1804
    case "vote":
1805
      return `/votes/${refId}`;
1806
    case "executive_action":
1807
      return "/executive";
1808
    case "apportionment":
1809
      return "/spending";
1810
    default:
1811
      return null;
1812
  }
1813
}
1814
 
1815
 
1816
export type MeetingType =
1817
  | "hearing"
1818
  | "markup"
1819
  | "business_meeting"
1820
  | "floor"
1821
  | string;
1822
 
1823
export type Meeting = {
1824
  id: string;
1825
  chamber: string;
1826
  committee: string;
1827
  date: string;
1828
  time: string;
1829
  datetimeIso: string;
1830
  title: string;
1831
  subject: string;
1832
  type: MeetingType;
1833
  location: string;
1834
  status: string;
1835
  sourceUrl: string;
1836
};
1837
 
1838
export type MeetingFilter = {
1839
  from?: string;
1840
  to?: string;
1841
  chamber?: string;
1842
  committee?: string;
1843
  type?: string;
1844
};
1845
 
1846
 
1847
export function getMeetings(filter: MeetingFilter = {}): Meeting[] {
1848
  const where: string[] = [];
1849
  const params: Record<string, unknown> = {};
1850
  if (filter.from) {
1851
    where.push("date >= :from");
1852
    params.from = filter.from;
1853
  }
1854
  if (filter.to) {
1855
    where.push("date <= :to");
1856
    params.to = filter.to;
1857
  }
1858
  if (filter.chamber) {
1859
    where.push("LOWER(chamber) = :chamber");
1860
    params.chamber = filter.chamber.toLowerCase();
1861
  }
1862
  if (filter.committee) {
1863
    where.push("committee LIKE :committee ESCAPE '\\'");
1864
    params.committee = `%${filter.committee.replace(/[%_\\]/g, (m) => "\\" + m)}%`;
1865
  }
1866
  if (filter.type) {
1867
    where.push("LOWER(type) = :type");
1868
    params.type = filter.type.toLowerCase();
1869
  }
1870
  const sql = `SELECT id, chamber, committee, date, time, datetime_iso, title,
1871
                      subject, type, location, status, source_url
1872
               FROM meetings${where.length ? " WHERE " + where.join(" AND ") : ""}
1873
               ORDER BY datetime_iso DESC, date DESC, id DESC`;
1874
  return safeAll<Meeting>(sql, params, (r) => ({
1875
    id: `meeting:${r.id}`,
1876
    chamber: str(r.chamber),
1877
    committee: str(r.committee),
1878
    date: str(r.date),
1879
    time: str(r.time),
1880
    datetimeIso: str(r.datetime_iso),
1881
    title: str(r.title),
1882
    subject: str(r.subject),
1883
    type: str(r.type) as MeetingType,
1884
    location: str(r.location),
1885
    status: str(r.status),
1886
    sourceUrl: ensureSource(r.source_url),
1887
  }));
1888
}
1889
 
1890
export function getMeetingCommittees(): string[] {
1891
  return safeAll<{ committee: string }>(
1892
    `SELECT DISTINCT committee FROM meetings
1893
     WHERE committee IS NOT NULL AND committee != ''
1894
     ORDER BY committee`,
1895
    {},
1896
    (r) => ({ committee: str(r.committee) })
1897
  ).map((r) => r.committee);
1898
}
1899
 
1900
 
1901
 
1902
export type CategorySummary = {
1903
  slug: string;
1904
  label: string;
1905
  kind: string;
1906
  billCount: number;
1907
  awardCount: number;
1908
  total: number;
1909
};
1910
 
1911
 
1912
export function getCategories(): CategorySummary[] {
1913
  return cached("categories:all", () =>
1914
    safeAll<CategorySummary>(
1915
      `SELECT c.slug, c.label, c.kind,
1916
              (SELECT COUNT(*) FROM bills b WHERE b.policy_area = c.label) AS bill_count,
1917
              (SELECT COUNT(*) FROM awards a WHERE a.category = c.label) AS award_count
1918
       FROM categories c
1919
       ORDER BY c.kind, c.label`,
1920
      {},
1921
      (r) => {
1922
        const billCount = num(r.bill_count);
1923
        const awardCount = num(r.award_count);
1924
        return {
1925
          slug: str(r.slug),
1926
          label: str(r.label),
1927
          kind: str(r.kind),
1928
          billCount,
1929
          awardCount,
1930
          total: billCount + awardCount,
1931
        };
1932
      }
1933
    )
1934
  );
1935
}
1936
 
1937
export function getPolicyAreaLabels(): string[] {
1938
  return cached("bills:policyareas", () =>
1939
    safeAll<{ label: string }>(
1940
      `SELECT DISTINCT policy_area AS label FROM bills
1941
       WHERE policy_area IS NOT NULL AND policy_area != ''
1942
       ORDER BY policy_area`,
1943
      {},
1944
      (r) => ({ label: str(r.label) })
1945
    ).map((r) => r.label)
1946
  );
1947
}
1948
 
1949
export function getBillSubjects(billId: number, max = 8): string[] {
1950
  return safeAll<{ subject: string }>(
1951
    `SELECT subject FROM bill_subjects
1952
     WHERE bill_id = :id AND subject IS NOT NULL AND subject != ''
1953
     ORDER BY subject
1954
     LIMIT :max`,
1955
    { id: billId, max },
1956
    (r) => ({ subject: str(r.subject) })
1957
  ).map((r) => r.subject);
1958
}
1959
 
1960
 
1961
export type ChangeKind = "added" | "updated" | "removed";
1962
 
1963
export type ChangeEvent = {
1964
  id: string;
1965
  ts: string;
1966
  source: string;
1967
  refType: string;
1968
  refId: string;
1969
  change: ChangeKind;
1970
  summary: string;
1971
  sourceUrl: string;
1972
  href: string | null;
1973
};
1974
 
1975
export function getChangeEvents(limit = 25): ChangeEvent[] {
1976
  return safeAll<ChangeEvent>(
1977
    `SELECT id, ts, source, ref_type, ref_id, change, summary, source_url
1978
     FROM change_events
1979
     ORDER BY ts DESC, id DESC
1980
     LIMIT :limit`,
1981
    { limit },
1982
    (r) => {
1983
      const refType = str(r.ref_type);
1984
      const refId = str(r.ref_id);
1985
      return {
1986
        id: `chg:${r.id}`,
1987
        ts: str(r.ts),
1988
        source: str(r.source),
1989
        refType,
1990
        refId,
1991
        change: str(r.change) as ChangeKind,
1992
        summary: str(r.summary),
1993
        sourceUrl: ensureSource(r.source_url),
1994
        href: changeHref(refType, refId),
1995
      };
1996
    }
1997
  );
1998
}
1999
 
2000
function changeHref(refType: string, refId: string): string | null {
2001
  switch (refType) {
2002
    case "member":
2003
      return refId ? `/members/${refId}` : null;
2004
    case "bill": {
2005
      const n = parseInt(refId, 10);
2006
      return Number.isFinite(n) ? `/bill/${n}` : null;
2007
    }
2008
    case "vote": {
2009
      const n = parseInt(refId, 10);
2010
      return Number.isFinite(n) ? `/votes/${n}` : null;
2011
    }
2012
    case "executive_action":
2013
      return "/executive";
2014
    default:
2015
      return null;
2016
  }
2017
}
2018
 
2019
 
2020
function str(v: unknown): string {
2021
  return v == null ? "" : String(v);
2022
}
2023
function num(v: unknown): number {
2024
  const n = typeof v === "number" ? v : parseFloat(String(v ?? ""));
2025
  return Number.isFinite(n) ? n : 0;
2026
}
2027
function nullableNum(v: unknown): number | null {
2028
  if (v == null || v === "") return null;
2029
  const n = typeof v === "number" ? v : parseFloat(String(v));
2030
  return Number.isFinite(n) ? n : null;
2031
}
2032
 
2033
 
2034
function ensureSource(v: unknown): string {
2035
  const s = str(v);
2036
  return s || "https://www.usaspending.gov/";
2037
}