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.
- package/README.md +187 -0
- package/dist/api/ethics-client.d.ts +45 -0
- package/dist/api/ethics-client.js +662 -0
- package/dist/api/ethics-client.js.map +1 -0
- package/dist/api/vrems-client.d.ts +18 -0
- package/dist/api/vrems-client.js +93 -0
- package/dist/api/vrems-client.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/candidate-detail.d.ts +12 -0
- package/dist/parsers/candidate-detail.js +112 -0
- package/dist/parsers/candidate-detail.js.map +1 -0
- package/dist/parsers/candidate-search.d.ts +6 -0
- package/dist/parsers/candidate-search.js +39 -0
- package/dist/parsers/candidate-search.js.map +1 -0
- package/dist/parsers/csv-export.d.ts +5 -0
- package/dist/parsers/csv-export.js +81 -0
- package/dist/parsers/csv-export.js.map +1 -0
- package/dist/tools/campaign.d.ts +2 -0
- package/dist/tools/campaign.js +191 -0
- package/dist/tools/campaign.js.map +1 -0
- package/dist/tools/cross-search.d.ts +26 -0
- package/dist/tools/cross-search.js +219 -0
- package/dist/tools/cross-search.js.map +1 -0
- package/dist/tools/overlap.d.ts +25 -0
- package/dist/tools/overlap.js +201 -0
- package/dist/tools/overlap.js.map +1 -0
- package/dist/tools/search.d.ts +2 -0
- package/dist/tools/search.js +146 -0
- package/dist/tools/search.js.map +1 -0
- package/dist/tools/sei.d.ts +2 -0
- package/dist/tools/sei.js +99 -0
- package/dist/tools/sei.js.map +1 -0
- package/dist/tools/vrems.d.ts +2 -0
- package/dist/tools/vrems.js +138 -0
- package/dist/tools/vrems.js.map +1 -0
- package/dist/types.d.ts +430 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- 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
|