feat: PocketVeto v1.0.0 — initial public release
Self-hosted US Congress monitoring platform with AI policy briefs, bill/member/topic follows, ntfy + RSS + email notifications, alignment scoring, collections, and draft-letter generator. Authored by: Jack Levy
This commit is contained in:
322
frontend/lib/api.ts
Normal file
322
frontend/lib/api.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import axios from "axios";
|
||||
import type {
|
||||
AlignmentData,
|
||||
Bill,
|
||||
BillAction,
|
||||
BillDetail,
|
||||
BillVote,
|
||||
BriefSchema,
|
||||
Collection,
|
||||
CollectionDetail,
|
||||
DashboardData,
|
||||
Follow,
|
||||
Member,
|
||||
MemberTrendScore,
|
||||
MemberNewsArticle,
|
||||
NewsArticle,
|
||||
NotificationEvent,
|
||||
NotificationSettings,
|
||||
NotificationSettingsUpdate,
|
||||
PaginatedResponse,
|
||||
SettingsData,
|
||||
TrendScore,
|
||||
} from "./types";
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Attach JWT from localStorage on every request
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const stored = localStorage.getItem("pocketveto-auth");
|
||||
if (stored) {
|
||||
const { state } = JSON.parse(stored);
|
||||
if (state?.token) {
|
||||
config.headers.Authorization = `Bearer ${state.token}`;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
interface AuthUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
notification_prefs: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const authAPI = {
|
||||
register: (email: string, password: string) =>
|
||||
apiClient.post<TokenResponse>("/api/auth/register", { email, password }).then((r) => r.data),
|
||||
login: (email: string, password: string) =>
|
||||
apiClient.post<TokenResponse>("/api/auth/login", { email, password }).then((r) => r.data),
|
||||
me: () =>
|
||||
apiClient.get<AuthUser>("/api/auth/me").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Bills
|
||||
export const billsAPI = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
apiClient.get<PaginatedResponse<Bill>>("/api/bills", { params }).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
apiClient.get<BillDetail>(`/api/bills/${id}`).then((r) => r.data),
|
||||
getActions: (id: string) =>
|
||||
apiClient.get<BillAction[]>(`/api/bills/${id}/actions`).then((r) => r.data),
|
||||
getNews: (id: string) =>
|
||||
apiClient.get<NewsArticle[]>(`/api/bills/${id}/news`).then((r) => r.data),
|
||||
getTrend: (id: string, days?: number) =>
|
||||
apiClient.get<TrendScore[]>(`/api/bills/${id}/trend`, { params: { days } }).then((r) => r.data),
|
||||
getVotes: (id: string) =>
|
||||
apiClient.get<BillVote[]>(`/api/bills/${id}/votes`).then((r) => r.data),
|
||||
generateDraft: (id: string, body: {
|
||||
stance: string;
|
||||
recipient: string;
|
||||
tone: string;
|
||||
selected_points: string[];
|
||||
include_citations: boolean;
|
||||
zip_code?: string;
|
||||
rep_name?: string;
|
||||
}) =>
|
||||
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Collections
|
||||
export const collectionsAPI = {
|
||||
list: () =>
|
||||
apiClient.get<Collection[]>("/api/collections").then((r) => r.data),
|
||||
create: (name: string, is_public: boolean) =>
|
||||
apiClient.post<Collection>("/api/collections", { name, is_public }).then((r) => r.data),
|
||||
get: (id: number) =>
|
||||
apiClient.get<CollectionDetail>(`/api/collections/${id}`).then((r) => r.data),
|
||||
update: (id: number, data: { name?: string; is_public?: boolean }) =>
|
||||
apiClient.patch<Collection>(`/api/collections/${id}`, data).then((r) => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/api/collections/${id}`),
|
||||
addBill: (id: number, bill_id: string) =>
|
||||
apiClient.post(`/api/collections/${id}/bills/${bill_id}`).then((r) => r.data),
|
||||
removeBill: (id: number, bill_id: string) =>
|
||||
apiClient.delete(`/api/collections/${id}/bills/${bill_id}`),
|
||||
getByShareToken: (token: string) =>
|
||||
apiClient.get<CollectionDetail>(`/api/collections/share/${token}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Share (public)
|
||||
export const shareAPI = {
|
||||
getBrief: (token: string) =>
|
||||
apiClient.get<{ brief: BriefSchema; bill: Bill }>(`/api/share/brief/${token}`).then((r) => r.data),
|
||||
getCollection: (token: string) =>
|
||||
apiClient.get<CollectionDetail>(`/api/share/collection/${token}`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Notes
|
||||
export const notesAPI = {
|
||||
get: (billId: string) =>
|
||||
apiClient.get<import("./types").BillNote>(`/api/notes/${billId}`).then((r) => r.data),
|
||||
upsert: (billId: string, content: string, pinned: boolean) =>
|
||||
apiClient.put<import("./types").BillNote>(`/api/notes/${billId}`, { content, pinned }).then((r) => r.data),
|
||||
delete: (billId: string) =>
|
||||
apiClient.delete(`/api/notes/${billId}`),
|
||||
};
|
||||
|
||||
// Members
|
||||
export const membersAPI = {
|
||||
list: (params?: Record<string, unknown>) =>
|
||||
apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data),
|
||||
get: (id: string) =>
|
||||
apiClient.get<Member>(`/api/members/${id}`).then((r) => r.data),
|
||||
byZip: (zip: string) =>
|
||||
apiClient.get<Member[]>(`/api/members/by-zip/${zip}`).then((r) => r.data),
|
||||
getBills: (id: string, params?: Record<string, unknown>) =>
|
||||
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
|
||||
getTrend: (id: string, days?: number) =>
|
||||
apiClient.get<MemberTrendScore[]>(`/api/members/${id}/trend`, { params: { days } }).then((r) => r.data),
|
||||
getNews: (id: string) =>
|
||||
apiClient.get<MemberNewsArticle[]>(`/api/members/${id}/news`).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Follows
|
||||
export const followsAPI = {
|
||||
list: () =>
|
||||
apiClient.get<Follow[]>("/api/follows").then((r) => r.data),
|
||||
add: (follow_type: string, follow_value: string) =>
|
||||
apiClient.post<Follow>("/api/follows", { follow_type, follow_value }).then((r) => r.data),
|
||||
remove: (id: number) =>
|
||||
apiClient.delete(`/api/follows/${id}`),
|
||||
updateMode: (id: number, mode: string) =>
|
||||
apiClient.patch<Follow>(`/api/follows/${id}/mode`, { follow_mode: mode }).then((r) => r.data),
|
||||
};
|
||||
|
||||
// Dashboard
|
||||
export const dashboardAPI = {
|
||||
get: () =>
|
||||
apiClient.get<DashboardData>("/api/dashboard").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Search
|
||||
export const searchAPI = {
|
||||
search: (q: string) =>
|
||||
apiClient.get<{ bills: Bill[]; members: Member[] }>("/api/search", { params: { q } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface LLMModel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Settings
|
||||
export const settingsAPI = {
|
||||
get: () =>
|
||||
apiClient.get<SettingsData>("/api/settings").then((r) => r.data),
|
||||
update: (key: string, value: string) =>
|
||||
apiClient.put("/api/settings", { key, value }).then((r) => r.data),
|
||||
testLLM: () =>
|
||||
apiClient.post("/api/settings/test-llm").then((r) => r.data),
|
||||
listModels: (provider: string) =>
|
||||
apiClient.get<{ models: LLMModel[]; error?: string }>("/api/settings/llm-models", { params: { provider } }).then((r) => r.data),
|
||||
};
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
follow_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiHealthResult {
|
||||
status: "ok" | "error" | "skipped";
|
||||
detail: string;
|
||||
latency_ms?: number;
|
||||
}
|
||||
|
||||
export interface ApiHealth {
|
||||
congress_gov: ApiHealthResult;
|
||||
govinfo: ApiHealthResult;
|
||||
newsapi: ApiHealthResult;
|
||||
google_news: ApiHealthResult;
|
||||
}
|
||||
|
||||
export interface AnalysisStats {
|
||||
total_bills: number;
|
||||
docs_fetched: number;
|
||||
briefs_generated: number;
|
||||
full_briefs: number;
|
||||
amendment_briefs: number;
|
||||
uncited_briefs: number;
|
||||
no_text_bills: number;
|
||||
pending_llm: number;
|
||||
bills_missing_sponsor: number;
|
||||
bills_missing_metadata: number;
|
||||
bills_missing_actions: number;
|
||||
unlabeled_briefs: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface NotificationTestResult {
|
||||
status: "ok" | "error";
|
||||
detail: string;
|
||||
event_count?: number;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
export const notificationsAPI = {
|
||||
getSettings: () =>
|
||||
apiClient.get<NotificationSettings>("/api/notifications/settings").then((r) => r.data),
|
||||
updateSettings: (data: NotificationSettingsUpdate) =>
|
||||
apiClient.put<NotificationSettings>("/api/notifications/settings", data).then((r) => r.data),
|
||||
resetRssToken: () =>
|
||||
apiClient.post<NotificationSettings>("/api/notifications/settings/rss-reset").then((r) => r.data),
|
||||
testNtfy: (data: {
|
||||
ntfy_topic_url: string;
|
||||
ntfy_auth_method: string;
|
||||
ntfy_token: string;
|
||||
ntfy_username: string;
|
||||
ntfy_password: string;
|
||||
}) =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/ntfy", data).then((r) => r.data),
|
||||
testRss: () =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/rss").then((r) => r.data),
|
||||
testEmail: () =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/email").then((r) => r.data),
|
||||
testFollowMode: (mode: string, event_type: string) =>
|
||||
apiClient.post<NotificationTestResult>("/api/notifications/test/follow-mode", { mode, event_type }).then((r) => r.data),
|
||||
getHistory: () =>
|
||||
apiClient.get<NotificationEvent[]>("/api/notifications/history").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Admin
|
||||
export const adminAPI = {
|
||||
// Stats
|
||||
getStats: () =>
|
||||
apiClient.get<AnalysisStats>("/api/admin/stats").then((r) => r.data),
|
||||
// Users
|
||||
listUsers: () =>
|
||||
apiClient.get<AdminUser[]>("/api/admin/users").then((r) => r.data),
|
||||
deleteUser: (id: number) =>
|
||||
apiClient.delete(`/api/admin/users/${id}`),
|
||||
toggleAdmin: (id: number) =>
|
||||
apiClient.patch<AdminUser>(`/api/admin/users/${id}/toggle-admin`).then((r) => r.data),
|
||||
// Tasks
|
||||
triggerPoll: () =>
|
||||
apiClient.post("/api/admin/trigger-poll").then((r) => r.data),
|
||||
triggerMemberSync: () =>
|
||||
apiClient.post("/api/admin/trigger-member-sync").then((r) => r.data),
|
||||
triggerTrendScores: () =>
|
||||
apiClient.post("/api/admin/trigger-trend-scores").then((r) => r.data),
|
||||
backfillSponsors: () =>
|
||||
apiClient.post("/api/admin/backfill-sponsors").then((r) => r.data),
|
||||
backfillCitations: () =>
|
||||
apiClient.post("/api/admin/backfill-citations").then((r) => r.data),
|
||||
triggerFetchActions: () =>
|
||||
apiClient.post("/api/admin/trigger-fetch-actions").then((r) => r.data),
|
||||
backfillAllActions: () =>
|
||||
apiClient.post("/api/admin/backfill-all-actions").then((r) => r.data),
|
||||
backfillMetadata: () =>
|
||||
apiClient.post("/api/admin/backfill-metadata").then((r) => r.data),
|
||||
backfillLabels: () =>
|
||||
apiClient.post("/api/admin/backfill-labels").then((r) => r.data),
|
||||
resumeAnalysis: () =>
|
||||
apiClient.post("/api/admin/resume-analysis").then((r) => r.data),
|
||||
triggerWeeklyDigest: () =>
|
||||
apiClient.post("/api/admin/trigger-weekly-digest").then((r) => r.data),
|
||||
getApiHealth: () =>
|
||||
apiClient.get<ApiHealth>("/api/admin/api-health").then((r) => r.data),
|
||||
getTaskStatus: (taskId: string) =>
|
||||
apiClient.get(`/api/admin/task-status/${taskId}`).then((r) => r.data),
|
||||
getNewsApiQuota: () =>
|
||||
apiClient.get<{ remaining: number; limit: number }>("/api/admin/newsapi-quota").then((r) => r.data),
|
||||
clearGnewsCache: () =>
|
||||
apiClient.post<{ cleared: number }>("/api/admin/clear-gnews-cache").then((r) => r.data),
|
||||
submitLlmBatch: () =>
|
||||
apiClient.post("/api/admin/submit-llm-batch").then((r) => r.data),
|
||||
getLlmBatchStatus: () =>
|
||||
apiClient.get<{ status: string; batch_id?: string; doc_count?: number; submitted_at?: string }>(
|
||||
"/api/admin/llm-batch-status"
|
||||
).then((r) => r.data),
|
||||
backfillCosponsors: () =>
|
||||
apiClient.post("/api/admin/backfill-cosponsors").then((r) => r.data),
|
||||
backfillCategories: () =>
|
||||
apiClient.post("/api/admin/backfill-categories").then((r) => r.data),
|
||||
calculateEffectiveness: () =>
|
||||
apiClient.post("/api/admin/calculate-effectiveness").then((r) => r.data),
|
||||
};
|
||||
|
||||
// Alignment
|
||||
export const alignmentAPI = {
|
||||
get: () =>
|
||||
apiClient.get<AlignmentData>("/api/alignment").then((r) => r.data),
|
||||
};
|
||||
Reference in New Issue
Block a user