sc-elections-mcp 0.5.0

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.
Files changed (41) hide show
  1. package/README.md +187 -0
  2. package/dist/api/ethics-client.d.ts +45 -0
  3. package/dist/api/ethics-client.js +662 -0
  4. package/dist/api/ethics-client.js.map +1 -0
  5. package/dist/api/vrems-client.d.ts +18 -0
  6. package/dist/api/vrems-client.js +93 -0
  7. package/dist/api/vrems-client.js.map +1 -0
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +30 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/parsers/candidate-detail.d.ts +12 -0
  12. package/dist/parsers/candidate-detail.js +112 -0
  13. package/dist/parsers/candidate-detail.js.map +1 -0
  14. package/dist/parsers/candidate-search.d.ts +6 -0
  15. package/dist/parsers/candidate-search.js +39 -0
  16. package/dist/parsers/candidate-search.js.map +1 -0
  17. package/dist/parsers/csv-export.d.ts +5 -0
  18. package/dist/parsers/csv-export.js +81 -0
  19. package/dist/parsers/csv-export.js.map +1 -0
  20. package/dist/tools/campaign.d.ts +2 -0
  21. package/dist/tools/campaign.js +191 -0
  22. package/dist/tools/campaign.js.map +1 -0
  23. package/dist/tools/cross-search.d.ts +26 -0
  24. package/dist/tools/cross-search.js +219 -0
  25. package/dist/tools/cross-search.js.map +1 -0
  26. package/dist/tools/overlap.d.ts +25 -0
  27. package/dist/tools/overlap.js +201 -0
  28. package/dist/tools/overlap.js.map +1 -0
  29. package/dist/tools/search.d.ts +2 -0
  30. package/dist/tools/search.js +146 -0
  31. package/dist/tools/search.js.map +1 -0
  32. package/dist/tools/sei.d.ts +2 -0
  33. package/dist/tools/sei.js +99 -0
  34. package/dist/tools/sei.js.map +1 -0
  35. package/dist/tools/vrems.d.ts +2 -0
  36. package/dist/tools/vrems.js +138 -0
  37. package/dist/tools/vrems.js.map +1 -0
  38. package/dist/types.d.ts +430 -0
  39. package/dist/types.js +5 -0
  40. package/dist/types.js.map +1 -0
  41. package/package.json +46 -0
@@ -0,0 +1,662 @@
1
+ const BASE = 'https://ethicsfiling.sc.gov/api';
2
+ const HEADERS = {
3
+ 'Content-Type': 'application/json',
4
+ 'Accept': 'application/json',
5
+ };
6
+ // ============================================================
7
+ // Search & Lookup
8
+ // ============================================================
9
+ // Maps candidateFilerId → seiFilerId, populated by searches and sweeps
10
+ const filerIdCache = new Map();
11
+ function populateFilerIdCache(filers) {
12
+ for (const f of filers) {
13
+ if (f.candidateFilerId && f.seiFilerId) {
14
+ filerIdCache.set(f.candidateFilerId, f.seiFilerId);
15
+ }
16
+ }
17
+ }
18
+ export async function searchFilers(name) {
19
+ const response = await fetch(`${BASE}/Ethics/Get/Public/Search/By/Filer/Name/`, {
20
+ method: 'POST',
21
+ headers: HEADERS,
22
+ body: JSON.stringify(name.trim()),
23
+ });
24
+ if (!response.ok)
25
+ return [];
26
+ const data = await response.json();
27
+ const results = data.result || [];
28
+ populateFilerIdCache(results);
29
+ return results;
30
+ }
31
+ export async function getFilerProfile(candidateFilerId, seiFilerId) {
32
+ const response = await fetch(`${BASE}/Candidate/Campaign/Get/Personal/Profile`, {
33
+ method: 'POST',
34
+ headers: HEADERS,
35
+ body: JSON.stringify({ candidateFilerId, seiFilerId }),
36
+ });
37
+ if (!response.ok)
38
+ throw new Error(`Profile request failed: ${response.status}`);
39
+ return response.json();
40
+ }
41
+ function parseDate(dateStr) {
42
+ if (!dateStr)
43
+ return 0;
44
+ const [month, day, year] = dateStr.split('/');
45
+ return new Date(+year, +month - 1, +day).getTime();
46
+ }
47
+ export function dedupeKey(filer) {
48
+ if (filer.universalUserId > 0)
49
+ return `u-${filer.universalUserId}`;
50
+ const namePart = filer.candidate?.toLowerCase().trim() || '';
51
+ // Prefer seiFilerId when available (more stable than address)
52
+ if (filer.seiFilerId > 0)
53
+ return `ns-${namePart}-${filer.seiFilerId}`;
54
+ // Last resort: name + address prefix
55
+ const addrPart = filer.address?.toLowerCase().trim().slice(0, 20) || '';
56
+ return `na-${namePart}-${addrPart}`;
57
+ }
58
+ // Office name cache — populated as side effect of sweepAllFilers()
59
+ const officeNameCache = new Set();
60
+ let sweepCache = null;
61
+ const SWEEP_CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
62
+ /** @internal — exported for testing */
63
+ export function isTestAccount(filer) {
64
+ const name = filer.candidate?.toLowerCase() || '';
65
+ if (name.includes('test, test'))
66
+ return true;
67
+ if (name.includes('testing,'))
68
+ return true;
69
+ // Filer records with 50+ offices are test scaffolding
70
+ const officeCount = filer.officeName ? filer.officeName.split(',').length : 0;
71
+ if (officeCount >= 50)
72
+ return true;
73
+ return false;
74
+ }
75
+ async function sweepAllFilers() {
76
+ // Return cached sweep if fresh
77
+ if (sweepCache && Date.now() - sweepCache.timestamp < SWEEP_CACHE_TTL_MS) {
78
+ return { allResults: sweepCache.allResults, failed: sweepCache.failed };
79
+ }
80
+ const letters = 'abcdefghijklmnopqrstuvwxyz'.split('');
81
+ const BATCH_SIZE = 6;
82
+ const TIMEOUT_MS = 10_000;
83
+ const rawResults = [];
84
+ let failed = 0;
85
+ for (let i = 0; i < letters.length; i += BATCH_SIZE) {
86
+ const batch = letters.slice(i, i + BATCH_SIZE);
87
+ const settled = await Promise.allSettled(batch.map(async (letter) => {
88
+ const controller = new AbortController();
89
+ const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS);
90
+ try {
91
+ const response = await fetch(`${BASE}/Ethics/Get/Public/Search/By/Filer/Name/`, {
92
+ method: 'POST',
93
+ headers: HEADERS,
94
+ body: JSON.stringify(letter),
95
+ signal: controller.signal,
96
+ });
97
+ if (!response.ok)
98
+ return [];
99
+ const data = await response.json();
100
+ return (data.result || []);
101
+ }
102
+ finally {
103
+ clearTimeout(timeout);
104
+ }
105
+ }));
106
+ for (const result of settled) {
107
+ if (result.status === 'fulfilled') {
108
+ rawResults.push(...result.value);
109
+ }
110
+ else {
111
+ failed++;
112
+ }
113
+ }
114
+ }
115
+ // Filter test/junk accounts
116
+ const allResults = rawResults.filter(filer => !isTestAccount(filer));
117
+ // Re-populate office name cache from filtered results
118
+ officeNameCache.clear();
119
+ for (const filer of allResults) {
120
+ if (filer.officeName) {
121
+ for (const part of filer.officeName.split(',')) {
122
+ const trimmed = part.trim();
123
+ if (trimmed)
124
+ officeNameCache.add(trimmed);
125
+ }
126
+ }
127
+ }
128
+ // Populate filerIdCache from sweep results
129
+ populateFilerIdCache(allResults);
130
+ // Store in cache
131
+ sweepCache = { allResults, failed, timestamp: Date.now() };
132
+ return { allResults, failed };
133
+ }
134
+ export async function searchFilersByOffice(officeName, activeSince) {
135
+ const { allResults, failed } = await sweepAllFilers();
136
+ // Filter by office name (case-insensitive partial match)
137
+ const needle = officeName.toLowerCase();
138
+ const matching = allResults.filter(f => f.officeName?.toLowerCase().includes(needle));
139
+ // Filter by active_since year
140
+ const activeSinceFiltered = activeSince
141
+ ? matching.filter(f => {
142
+ if (!f.lastSubmission)
143
+ return false;
144
+ const [, , year] = f.lastSubmission.split('/');
145
+ return parseInt(year, 10) >= activeSince;
146
+ })
147
+ : matching;
148
+ // Deduplicate: prefer record with most recent submission per person
149
+ const seen = new Map();
150
+ for (const filer of activeSinceFiltered) {
151
+ const key = dedupeKey(filer);
152
+ const existing = seen.get(key);
153
+ if (!existing || parseDate(filer.lastSubmission) > parseDate(existing.lastSubmission)) {
154
+ seen.set(key, filer);
155
+ }
156
+ }
157
+ // Sort by most recent submission descending
158
+ const filers = [...seen.values()].sort((a, b) => parseDate(b.lastSubmission) - parseDate(a.lastSubmission));
159
+ return { filers, totalSearched: 26, totalFailed: failed };
160
+ }
161
+ export function groupFilersByPerson(filers) {
162
+ const grouped = new Map();
163
+ for (const filer of filers) {
164
+ const key = dedupeKey(filer);
165
+ const existing = grouped.get(key);
166
+ const officeEntry = {
167
+ officeName: filer.officeName || '',
168
+ officeId: filer.officeId,
169
+ lastSubmission: filer.lastSubmission,
170
+ };
171
+ if (existing) {
172
+ const alreadyHas = existing.offices.some(o => o.officeId === filer.officeId);
173
+ if (!alreadyHas)
174
+ existing.offices.push(officeEntry);
175
+ if (parseDate(filer.lastSubmission) > parseDate(existing.lastSubmission)) {
176
+ existing.lastSubmission = filer.lastSubmission;
177
+ existing.candidateFilerId = filer.candidateFilerId;
178
+ existing.seiFilerId = filer.seiFilerId;
179
+ }
180
+ }
181
+ else {
182
+ grouped.set(key, {
183
+ candidate: filer.candidate,
184
+ address: filer.address,
185
+ universalUserId: filer.universalUserId,
186
+ candidateFilerId: filer.candidateFilerId,
187
+ seiFilerId: filer.seiFilerId,
188
+ lastSubmission: filer.lastSubmission,
189
+ offices: [officeEntry],
190
+ });
191
+ }
192
+ }
193
+ return [...grouped.values()].sort((a, b) => parseDate(b.lastSubmission) - parseDate(a.lastSubmission));
194
+ }
195
+ export async function listOfficeNames(keyword) {
196
+ // sweepAllFilers() handles caching internally (30 min TTL)
197
+ await sweepAllFilers();
198
+ let names = [...officeNameCache].sort();
199
+ if (keyword) {
200
+ const needle = keyword.toLowerCase();
201
+ names = names.filter(n => n.toLowerCase().includes(needle));
202
+ }
203
+ return names;
204
+ }
205
+ // ============================================================
206
+ // Campaign Finance — Per-Candidate
207
+ // ============================================================
208
+ /** Try to resolve a candidate's display name when summary.name is null */
209
+ export async function resolveCandidateName(candidateFilerId) {
210
+ const seiFilerId = filerIdCache.get(candidateFilerId);
211
+ if (!seiFilerId)
212
+ return undefined;
213
+ try {
214
+ const profile = await getFilerProfile(candidateFilerId, seiFilerId);
215
+ return profile.name || undefined;
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
221
+ export async function getCampaignSummary(candidateFilerId) {
222
+ const response = await fetch(`${BASE}/Ethics/Get/Public/Candidate/Report/Summary/${candidateFilerId}`, { headers: HEADERS });
223
+ if (!response.ok)
224
+ throw new Error(`Campaign summary request failed: ${response.status}`);
225
+ return response.json();
226
+ }
227
+ export async function getCampaignReports(campaignId, candidateFilerId) {
228
+ const response = await fetch(`${BASE}/Ethics/Get/Public/Candidate/Reports`, {
229
+ method: 'POST',
230
+ headers: HEADERS,
231
+ body: JSON.stringify({ campaignId, candidateFilerId }),
232
+ });
233
+ if (!response.ok)
234
+ return [];
235
+ const data = await response.json();
236
+ return data.results || data || [];
237
+ }
238
+ export async function getCampaignReportDetails(reportId) {
239
+ const response = await fetch(`${BASE}/Ethics/Get/Public/Candidate/Report/Details/${reportId}`, { headers: HEADERS });
240
+ if (!response.ok)
241
+ throw new Error(`Report details request failed: ${response.status}`);
242
+ return response.json();
243
+ }
244
+ export async function getContributions(campaignId, candidateFilerId) {
245
+ const response = await fetch(`${BASE}/Candidate/Contribution/Get/All/Campaign/Grid`, {
246
+ method: 'POST',
247
+ headers: HEADERS,
248
+ body: JSON.stringify({
249
+ campaignId: String(campaignId),
250
+ candidateFilerId: String(candidateFilerId),
251
+ isFiled: true,
252
+ }),
253
+ });
254
+ if (!response.ok)
255
+ return [];
256
+ return response.json();
257
+ }
258
+ export async function getExpenditures(campaignId, candidateFilerId) {
259
+ const response = await fetch(`${BASE}/Candidate/Expenditure/Get/All/Campaign/Grid`, {
260
+ method: 'POST',
261
+ headers: HEADERS,
262
+ body: JSON.stringify({
263
+ campaignId: String(campaignId),
264
+ candidateFilerId: String(candidateFilerId),
265
+ isFiled: true,
266
+ }),
267
+ });
268
+ if (!response.ok)
269
+ return [];
270
+ return response.json();
271
+ }
272
+ // ============================================================
273
+ // Campaign Finance — Helpers & Cache
274
+ // ============================================================
275
+ const campaignSummaryCache = new Map();
276
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
277
+ export async function cachedGetCampaignSummary(candidateFilerId) {
278
+ const cached = campaignSummaryCache.get(candidateFilerId);
279
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
280
+ return cached.data;
281
+ }
282
+ const data = await getCampaignSummary(candidateFilerId);
283
+ campaignSummaryCache.set(candidateFilerId, { data, timestamp: Date.now() });
284
+ return data;
285
+ }
286
+ export function normalizeOfficeName(raw) {
287
+ if (!raw)
288
+ return { raw, normalized: raw };
289
+ let district;
290
+ let body;
291
+ // Extract district number
292
+ const distMatch = raw.match(/District\s+(\d+)/i) ||
293
+ raw.match(/Dist\.?\s*(\d+)/i) ||
294
+ raw.match(/Seat\s+(\d+)/i);
295
+ if (distMatch) {
296
+ district = distMatch[1];
297
+ }
298
+ // Extract body
299
+ const bodyPatterns = [
300
+ [/County\s+Council/i, 'County Council'],
301
+ [/State\s+House/i, 'State House'],
302
+ [/State\s+Senate/i, 'State Senate'],
303
+ [/Governor/i, 'Governor'],
304
+ [/Sheriff/i, 'Sheriff'],
305
+ [/Solicitor/i, 'Solicitor'],
306
+ [/Attorney\s+General/i, 'Attorney General'],
307
+ [/Lt\.?\s*Governor/i, 'Lt Governor'],
308
+ [/Secretary\s+of\s+State/i, 'Secretary of State'],
309
+ [/Comptroller/i, 'Comptroller General'],
310
+ [/Treasurer/i, 'State Treasurer'],
311
+ [/Superintendent/i, 'Superintendent of Education'],
312
+ [/School\s+Board/i, 'School Board'],
313
+ [/City\s+Council/i, 'City Council'],
314
+ [/Mayor/i, 'Mayor'],
315
+ [/Probate\s+Judge/i, 'Probate Judge'],
316
+ [/Auditor/i, 'Auditor'],
317
+ [/Coroner/i, 'Coroner'],
318
+ [/Clerk\s+of\s+Court/i, 'Clerk of Court'],
319
+ ];
320
+ for (const [pattern, name] of bodyPatterns) {
321
+ if (pattern.test(raw)) {
322
+ body = name;
323
+ break;
324
+ }
325
+ }
326
+ // Build normalized name
327
+ let normalized = raw
328
+ .replace(/^Other\s+Office,?\s*/i, '')
329
+ .replace(/^District\s+\d+\s*,?\s*/i, '')
330
+ .trim();
331
+ if (body && district) {
332
+ // Check if county name is present
333
+ const countyMatch = raw.match(/(\w+)\s+County/i);
334
+ if (countyMatch) {
335
+ normalized = `${countyMatch[1]} ${body} District ${district}`;
336
+ }
337
+ else {
338
+ normalized = `${body} District ${district}`;
339
+ }
340
+ }
341
+ else if (!body) {
342
+ normalized = raw;
343
+ }
344
+ return { raw, normalized, district, body };
345
+ }
346
+ function extractDistrictNumber(text) {
347
+ const m = text.match(/District\s+(\d+)/i) ||
348
+ text.match(/Dist\.?\s*(\d+)/i) ||
349
+ text.match(/Seat\s+(\d+)/i);
350
+ return m?.[1];
351
+ }
352
+ export function resolveCampaignContext(summary, candidateFilerId, campaignId, officeHint, candidateNameOverride) {
353
+ const allOffices = [
354
+ ...summary.openReports.map(r => ({ ...r, status: 'open' })),
355
+ ...summary.closedReports.map(r => ({ ...r, status: 'closed' })),
356
+ ];
357
+ const candidateName = candidateNameOverride || summary.name || 'Unknown';
358
+ function makeContext(office) {
359
+ return {
360
+ context: {
361
+ candidateName,
362
+ officeName: office.officeName,
363
+ campaignId: office.officeId,
364
+ candidateFilerId,
365
+ campaignStatus: office.status,
366
+ },
367
+ resolvedCampaignId: office.officeId,
368
+ };
369
+ }
370
+ function officeListError(offices) {
371
+ const lines = offices.map(o => ` - ${o.officeName} (campaignId: ${o.officeId}, ${o.status}, balance: $${o.balance.toFixed(2)})`);
372
+ return `Multiple campaigns found for this candidate. Specify campaign_id or office hint:\n${lines.join('\n')}`;
373
+ }
374
+ // 1. If campaignId provided, validate it exists
375
+ if (campaignId !== undefined) {
376
+ const match = allOffices.find(o => o.officeId === campaignId);
377
+ if (!match) {
378
+ const lines = allOffices.map(o => ` - ${o.officeName} (campaignId: ${o.officeId}, ${o.status})`);
379
+ return {
380
+ error: `Campaign ID ${campaignId} not found for this candidate. Available campaigns:\n${lines.join('\n')}`,
381
+ };
382
+ }
383
+ return makeContext(match);
384
+ }
385
+ // 2. If officeHint provided, try to narrow
386
+ if (officeHint) {
387
+ const hint = officeHint.toLowerCase();
388
+ let matches = allOffices.filter(o => o.officeName.toLowerCase().includes(hint));
389
+ if (matches.length === 1)
390
+ return makeContext(matches[0]);
391
+ if (matches.length > 1) {
392
+ // Try district extraction to disambiguate
393
+ const hintDistrict = extractDistrictNumber(officeHint);
394
+ if (hintDistrict) {
395
+ const districtMatches = matches.filter(o => extractDistrictNumber(o.officeName) === hintDistrict);
396
+ if (districtMatches.length === 1)
397
+ return makeContext(districtMatches[0]);
398
+ }
399
+ // Prefer open over closed
400
+ const openMatches = matches.filter(o => o.status === 'open');
401
+ if (openMatches.length === 1)
402
+ return makeContext(openMatches[0]);
403
+ if (openMatches.length > 1)
404
+ return { error: officeListError(openMatches) };
405
+ // All closed — fall through to no-hint logic with filtered set
406
+ matches = matches.sort((a, b) => new Date(b.initialReportFiledDate).getTime() - new Date(a.initialReportFiledDate).getTime());
407
+ return makeContext(matches[0]);
408
+ }
409
+ // Zero matches — fall through to no-hint logic
410
+ }
411
+ // 3. No campaignId, no usable hint — auto-resolve
412
+ const open = allOffices.filter(o => o.status === 'open');
413
+ const closed = allOffices.filter(o => o.status === 'closed');
414
+ if (open.length === 1)
415
+ return makeContext(open[0]);
416
+ if (open.length === 0 && closed.length === 1)
417
+ return makeContext(closed[0]);
418
+ if (open.length === 0 && closed.length > 1) {
419
+ // Use most recent by initialReportFiledDate
420
+ const sorted = [...closed].sort((a, b) => new Date(b.initialReportFiledDate).getTime() - new Date(a.initialReportFiledDate).getTime());
421
+ return makeContext(sorted[0]);
422
+ }
423
+ if (open.length > 1)
424
+ return { error: officeListError(open) };
425
+ // No offices at all
426
+ return { error: 'No campaign offices found for this candidate.' };
427
+ }
428
+ const SELF_FUNDING_TYPES = ['personal contribution', 'candidate loan', 'personal loan'];
429
+ export function buildContributionSummary(contributions, context) {
430
+ const byDonor = new Map();
431
+ const byType = {};
432
+ let totalAmount = 0;
433
+ let selfFundingTotal = 0;
434
+ const dates = [];
435
+ for (const c of contributions) {
436
+ totalAmount += c.credit;
437
+ if (c.date)
438
+ dates.push(c.date);
439
+ // Aggregate by donor
440
+ const donorKey = c.paidBy.trim().toLowerCase();
441
+ const existing = byDonor.get(donorKey);
442
+ if (existing) {
443
+ existing.totalAmount += c.credit;
444
+ existing.count++;
445
+ }
446
+ else {
447
+ byDonor.set(donorKey, { totalAmount: c.credit, count: 1 });
448
+ }
449
+ // Aggregate by type
450
+ const typeKey = c.type || 'Unknown';
451
+ if (!byType[typeKey])
452
+ byType[typeKey] = { count: 0, amount: 0 };
453
+ byType[typeKey].count++;
454
+ byType[typeKey].amount += c.credit;
455
+ // Self-funding detection
456
+ if (SELF_FUNDING_TYPES.includes(c.type.toLowerCase())) {
457
+ selfFundingTotal += c.credit;
458
+ }
459
+ }
460
+ // Sort donors by total, take top 20
461
+ const topDonors = [...byDonor.entries()]
462
+ .map(([name, data]) => ({
463
+ name: contributions.find(c => c.paidBy.trim().toLowerCase() === name)?.paidBy.trim() || name,
464
+ totalAmount: data.totalAmount,
465
+ count: data.count,
466
+ }))
467
+ .sort((a, b) => b.totalAmount - a.totalAmount)
468
+ .slice(0, 20);
469
+ // Date range
470
+ let dateRange = null;
471
+ if (dates.length > 0) {
472
+ const sorted = dates.sort();
473
+ dateRange = { earliest: sorted[0], latest: sorted[sorted.length - 1] };
474
+ }
475
+ return {
476
+ context,
477
+ totalCount: contributions.length,
478
+ totalAmount,
479
+ dateRange,
480
+ byType,
481
+ selfFundingTotal,
482
+ topDonors,
483
+ };
484
+ }
485
+ export function buildExpenditureSummary(expenditures, context) {
486
+ const byVendor = new Map();
487
+ const byType = {};
488
+ let totalAmount = 0;
489
+ const dates = [];
490
+ for (const e of expenditures) {
491
+ totalAmount += e.debit;
492
+ if (e.date)
493
+ dates.push(e.date);
494
+ // Aggregate by vendor
495
+ const vendorKey = e.paidTo.trim().toLowerCase();
496
+ const existing = byVendor.get(vendorKey);
497
+ if (existing) {
498
+ existing.totalAmount += e.debit;
499
+ existing.count++;
500
+ }
501
+ else {
502
+ byVendor.set(vendorKey, { totalAmount: e.debit, count: 1 });
503
+ }
504
+ // Aggregate by type
505
+ const typeKey = e.type || 'Unknown';
506
+ if (!byType[typeKey])
507
+ byType[typeKey] = { count: 0, amount: 0 };
508
+ byType[typeKey].count++;
509
+ byType[typeKey].amount += e.debit;
510
+ }
511
+ // Sort vendors by total, take top 20
512
+ const topVendors = [...byVendor.entries()]
513
+ .map(([name, data]) => ({
514
+ name: expenditures.find(e => e.paidTo.trim().toLowerCase() === name)?.paidTo.trim() || name,
515
+ totalAmount: data.totalAmount,
516
+ count: data.count,
517
+ }))
518
+ .sort((a, b) => b.totalAmount - a.totalAmount)
519
+ .slice(0, 20);
520
+ // Date range
521
+ let dateRange = null;
522
+ if (dates.length > 0) {
523
+ const sorted = dates.sort();
524
+ dateRange = { earliest: sorted[0], latest: sorted[sorted.length - 1] };
525
+ }
526
+ return {
527
+ context,
528
+ totalCount: expenditures.length,
529
+ totalAmount,
530
+ dateRange,
531
+ byType,
532
+ topVendors,
533
+ };
534
+ }
535
+ // ============================================================
536
+ // Campaign Finance — Cross-Candidate Search
537
+ // ============================================================
538
+ export async function searchExpenditures(filters) {
539
+ const response = await fetch(`${BASE}/Candidate/Expenditure/Public/Get/All/Campaign/Expenditures`, {
540
+ method: 'POST',
541
+ headers: HEADERS,
542
+ body: JSON.stringify({
543
+ candidate: filters.candidate || '',
544
+ office: filters.office || '',
545
+ vendorName: filters.vendorName || '',
546
+ expenditureYear: filters.expenditureYear || 0,
547
+ vendorLoc: filters.vendorLoc || 'Any',
548
+ amount: filters.amount || 0,
549
+ expDesc: filters.expDesc || '',
550
+ }),
551
+ });
552
+ if (!response.ok)
553
+ return [];
554
+ return response.json();
555
+ }
556
+ export async function searchContributions(filters) {
557
+ const response = await fetch(`${BASE}/Candidate/Contribution/Search/`, {
558
+ method: 'POST',
559
+ headers: HEADERS,
560
+ body: JSON.stringify({
561
+ candidate: filters.candidate || '',
562
+ office: filters.office || '',
563
+ contributorName: filters.contributorName || '',
564
+ contributionYear: filters.contributionYear || 0,
565
+ contributorLoc: filters.contributorLoc || 'Any',
566
+ amount: filters.amount || 0,
567
+ }),
568
+ });
569
+ if (!response.ok)
570
+ return [];
571
+ return response.json();
572
+ }
573
+ // ============================================================
574
+ // Statement of Economic Interest (SEI)
575
+ // ============================================================
576
+ export async function getSeiReportVersions(seiFilerId) {
577
+ // Use the overview endpoint which returns { gridRows: [...] }
578
+ const response = await fetch(`${BASE}/Sei/Report/Get/Filed/Overview/${seiFilerId}`, { headers: HEADERS });
579
+ if (!response.ok)
580
+ return [];
581
+ const data = await response.json();
582
+ const rows = data.gridRows || [];
583
+ return rows.map((row) => ({
584
+ seiFilerId,
585
+ seiReportId: row.reportId,
586
+ year: parseInt(row.filingYear, 10),
587
+ reportType: row.seiReport,
588
+ dateSubmitted: row.submittedString || row.submitted,
589
+ status: row.status,
590
+ }));
591
+ }
592
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
593
+ async function seiPost(path, body) {
594
+ const response = await fetch(`${BASE}${path}`, {
595
+ method: 'POST',
596
+ headers: HEADERS,
597
+ body: JSON.stringify(body),
598
+ });
599
+ if (!response.ok)
600
+ return [];
601
+ const data = await response.json();
602
+ return Array.isArray(data) ? data.filter((item) => !item.isDeleted) : [];
603
+ }
604
+ export async function getSeiDetails(seiFilerId, reportYear) {
605
+ const reports = await getSeiReportVersions(seiFilerId);
606
+ if (reports.length === 0)
607
+ return null;
608
+ const sorted = reports.sort((a, b) => b.year - a.year);
609
+ const report = reportYear
610
+ ? sorted.find(r => r.year === reportYear)
611
+ : sorted[0];
612
+ if (!report)
613
+ return null;
614
+ const body = {
615
+ seiFilerId,
616
+ seiReportId: report.seiReportId,
617
+ getUnfiled: false,
618
+ };
619
+ const [positions, businessInterests, privateIncome, governmentIncome, familyPrivateIncome, familyGovernmentIncome, gifts, travel, governmentContracts, creditors, lobbyistFamily, lobbyistPurchases, regulatedBusinessAssociations, propertyTransactions, propertyImprovements, propertyConflicts, additionalInformation,] = await Promise.all([
620
+ seiPost('/Sei/Filer/Position/Get/All/Report/Positions', body),
621
+ seiPost('/Sei/Business/Interests/Get/Many/For/Report', body),
622
+ seiPost('/Sei/Income/And/Benefits/Get/Private/IncomeAndBenefits/For/Report', body),
623
+ seiPost('/Sei/Income/And/Benefits/Get/Government/IncomeAndBenefits/For/Report', body),
624
+ seiPost('/Sei/Family/Income/And/Benefits/Get/All/Private/Income/For/Report/', body),
625
+ seiPost('/Sei/Family/Income/And/Benefits/Get/All/Government/Income/For/Report/', body),
626
+ seiPost('/Sei/Report/Get/Gifts', body),
627
+ seiPost('/Sei/Travel/Get/All/Travel/Records', body),
628
+ seiPost('/Sei/Report/Get/Gov/Contracts/Records', body),
629
+ seiPost('/Sei/Creditors/Get/Report/SeiCreditors', body),
630
+ seiPost('/Sei/Lobbyist/Get/Many/LobbyistFamily', body),
631
+ seiPost('/Sei/Lobbyist/Get/Many/LobbyistPurchase', body),
632
+ seiPost('/Sei/Regulated/Business/Assoc/Get/Many/For/Report', body),
633
+ seiPost('/Sei/Property/Sold/Leased/Rented/Get/Report/Property/Transactions', body),
634
+ seiPost('/Sei/Property/Sold/Leased/Rented/Get/Report/Property/Improvements', body),
635
+ seiPost('/Sei/Property/Sold/Leased/Rented/Get/Report/Property/Conflicts', body),
636
+ seiPost('/Sei/Additional/Information/Get/Many/AdditionalInfo', body),
637
+ ]);
638
+ return {
639
+ seiFilerId,
640
+ seiReportId: report.seiReportId,
641
+ reportYear: report.year,
642
+ dateSubmitted: report.dateSubmitted,
643
+ positions,
644
+ businessInterests,
645
+ privateIncome,
646
+ governmentIncome,
647
+ familyPrivateIncome,
648
+ familyGovernmentIncome,
649
+ gifts,
650
+ travel,
651
+ governmentContracts,
652
+ creditors,
653
+ lobbyistFamily,
654
+ lobbyistPurchases,
655
+ regulatedBusinessAssociations,
656
+ propertyTransactions,
657
+ propertyImprovements,
658
+ propertyConflicts,
659
+ additionalInformation,
660
+ };
661
+ }
662
+ //# sourceMappingURL=ethics-client.js.map