diff --git a/inec-analytics/main.py b/inec-analytics/main.py index a08a5f0f..1aae47df 100644 --- a/inec-analytics/main.py +++ b/inec-analytics/main.py @@ -1,14 +1,16 @@ -"""INEC Lakehouse Analytics Service - DuckDB-powered analytics for election data.""" +"""INEC AI-Powered Election Analytics Service - Statistical anomaly detection and validation.""" import os import time +import math import sqlite3 -import json +from collections import Counter, defaultdict from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional -app = FastAPI(title="INEC Lakehouse Analytics", version="1.0.0") +app = FastAPI(title="INEC AI Analytics", version="2.0.0", + description="AI-powered election result validation and anomaly detection") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) DB_PATH = os.getenv("DB_PATH", "/data/app.db") @@ -23,9 +25,193 @@ class LakehouseQuery(BaseModel): parameters: Optional[dict] = None format: Optional[str] = "json" +def mean_std(values): + if not values: + return 0.0, 0.0 + n = len(values) + m = sum(values) / n + variance = sum((x - m) ** 2 for x in values) / n + return m, math.sqrt(variance) + +def median_val(values): + s = sorted(values) + n = len(s) + if n == 0: + return 0 + if n % 2 == 1: + return s[n // 2] + return (s[n // 2 - 1] + s[n // 2]) / 2 + +def iqr_bounds(values): + s = sorted(values) + n = len(s) + if n < 4: + return None, None + q1 = s[n // 4] + q3 = s[3 * n // 4] + iqr = q3 - q1 + return q1 - 1.5 * iqr, q3 + 1.5 * iqr + +def benfords_first_digit(values): + if len(values) < 30: + return {"status": "insufficient_data", "sample_size": len(values)} + digits = [] + for v in values: + if v > 0: + first = int(str(abs(v))[0]) + if 1 <= first <= 9: + digits.append(first) + if len(digits) < 20: + return {"status": "insufficient_data", "sample_size": len(digits)} + expected_benford = {d: math.log10(1 + 1/d) for d in range(1, 10)} + observed_counts = Counter(digits) + n = len(digits) + chi_sq = 0.0 + distribution = [] + for d in range(1, 10): + obs = observed_counts.get(d, 0) + exp = expected_benford[d] * n + chi_sq += (obs - exp) ** 2 / exp + distribution.append({ + "digit": d, "observed_count": obs, + "observed_pct": round(obs / n * 100, 2), + "expected_pct": round(expected_benford[d] * 100, 2), + "deviation": round(abs(obs / n - expected_benford[d]) * 100, 2) + }) + if chi_sq > 20.09: + status = "fail" + elif chi_sq > 15.51: + status = "suspicious" + else: + status = "pass" + return {"test": "benfords_first_digit", "chi_square": round(chi_sq, 3), + "degrees_of_freedom": 8, "status": status, "sample_size": n, "distribution": distribution} + +def benfords_last_digit(values): + if len(values) < 30: + return {"status": "insufficient_data", "sample_size": len(values)} + digits = [abs(v) % 10 for v in values if v > 0] + if len(digits) < 20: + return {"status": "insufficient_data", "sample_size": len(digits)} + n = len(digits) + expected = n / 10.0 + observed_counts = Counter(digits) + chi_sq = 0.0 + distribution = [] + for d in range(10): + obs = observed_counts.get(d, 0) + chi_sq += (obs - expected) ** 2 / expected + distribution.append({ + "digit": d, "observed_count": obs, + "observed_pct": round(obs / n * 100, 2), + "expected_pct": 10.0, + "deviation": round(abs(obs / n - 0.1) * 100, 2) + }) + if chi_sq > 21.67: + status = "fail" + elif chi_sq > 16.92: + status = "suspicious" + else: + status = "pass" + return {"test": "benfords_last_digit", "chi_square": round(chi_sq, 3), + "degrees_of_freedom": 9, "status": status, "sample_size": n, "distribution": distribution} + +def detect_overvoting(results): + anomalies = [] + for r in results: + if r["registered_voters"] > 0 and r["total_votes"] > r["registered_voters"]: + excess = r["total_votes"] - r["registered_voters"] + pct = excess / r["registered_voters"] * 100 + anomalies.append({ + "polling_unit_code": r["code"], "pu_name": r["name"], + "anomaly_type": "overvoting", "severity": "critical", + "score": round(pct, 2), + "detail": f"Votes ({r['total_votes']}) exceed registered ({r['registered_voters']}) by {excess} ({pct:.1f}%)", + "total_votes": r["total_votes"], "registered_voters": r["registered_voters"], + "turnout_pct": round(r["turnout"], 2) + }) + return anomalies + +def detect_turnout_outliers(results): + turnouts = [r["turnout"] for r in results if r["turnout"] > 0] + if len(turnouts) < 10: + return [] + m, s = mean_std(turnouts) + _, upper_iqr = iqr_bounds(turnouts) + anomalies = [] + for r in results: + if r["turnout"] <= 0 or s == 0: + continue + z = (r["turnout"] - m) / s + is_iqr_outlier = upper_iqr is not None and r["turnout"] > upper_iqr + if abs(z) > 2.5 or is_iqr_outlier: + severity = "critical" if abs(z) > 3.5 else "warning" + anomalies.append({ + "polling_unit_code": r["code"], "pu_name": r["name"], + "anomaly_type": "turnout_outlier", "severity": severity, + "score": round(abs(z), 2), + "detail": f"Turnout {r['turnout']:.1f}% (z={z:.2f}, mean={m:.1f}%, std={s:.1f}%)", + "total_votes": r["total_votes"], "registered_voters": r["registered_voters"], + "turnout_pct": round(r["turnout"], 2) + }) + return anomalies + +def detect_party_dominance(pu_party_votes): + anomalies = [] + for code, votes_list in pu_party_votes.items(): + total = sum(v["votes"] for v in votes_list) + if total < 50 or len(votes_list) < 2: + continue + top = max(votes_list, key=lambda x: x["votes"]) + share = top["votes"] / total * 100 + if share > 90: + severity = "critical" if share > 98 else "warning" + anomalies.append({ + "polling_unit_code": code, "pu_name": votes_list[0].get("pu_name", ""), + "anomaly_type": "party_dominance", "severity": severity, + "score": round(share, 2), + "detail": f"{top['party']} received {share:.1f}% of {total} votes", + "total_votes": total, "registered_voters": votes_list[0].get("registered", 0), + "turnout_pct": 0 + }) + return anomalies + +def detect_round_number_bias(results): + anomalies = [] + for r in results: + v = r["total_votes"] + if v >= 100 and v % 100 == 0: + anomalies.append({ + "polling_unit_code": r["code"], "pu_name": r["name"], + "anomaly_type": "round_number", "severity": "minor", + "score": v, "detail": f"Suspiciously round vote count: {v}", + "total_votes": v, "registered_voters": r["registered_voters"], + "turnout_pct": round(r["turnout"], 2) + }) + return anomalies + +def detect_sequential_patterns(results): + anomalies = [] + sorted_results = sorted(results, key=lambda r: r["code"]) + for i in range(1, len(sorted_results)): + prev, curr = sorted_results[i-1], sorted_results[i] + if prev["total_votes"] == curr["total_votes"] and prev["total_votes"] > 50: + prefix_prev = "-".join(prev["code"].split("-")[:3]) + prefix_curr = "-".join(curr["code"].split("-")[:3]) + if prefix_prev == prefix_curr: + anomalies.append({ + "polling_unit_code": curr["code"], "pu_name": curr["name"], + "anomaly_type": "identical_adjacent", "severity": "warning", + "score": curr["total_votes"], + "detail": f"Identical count ({curr['total_votes']}) as adjacent {prev['code']}", + "total_votes": curr["total_votes"], "registered_voters": curr["registered_voters"], + "turnout_pct": round(curr["turnout"], 2) + }) + return anomalies + @app.get("/health") def health(): - return {"status": "healthy", "service": "lakehouse-analytics", "engine": "sqlite-analytics"} + return {"status": "healthy", "service": "ai-analytics", "engine": "python-statistical", "version": "2.0.0"} @app.get("/tables") def list_tables(): @@ -56,40 +242,35 @@ def turnout_analytics(election_id: int): """, (election_id,)).fetchall() db.close() elapsed = (time.time() - start) * 1000 - return { - "election_id": election_id, - "analysis_type": "turnout", - "query_ms": round(elapsed, 2), - "data": [dict(r) for r in rows], - } + return {"election_id": election_id, "analysis_type": "turnout", + "query_ms": round(elapsed, 2), "data": [dict(r) for r in rows]} @app.get("/analytics/{election_id}/party_performance") def party_performance(election_id: int): start = time.time() db = get_db() rows = db.execute(""" - SELECT p.code, p.name, p.color, p.abbreviation, - SUM(rps.votes) as total_votes, - COUNT(DISTINCT r.id) as results_count, - ROUND(CAST(SUM(rps.votes) AS FLOAT) / - NULLIF((SELECT SUM(total_valid_votes) FROM results WHERE election_id = ? AND status IN ('finalized','validated')), 0) * 100, 2) as vote_share_pct + SELECT p.abbreviation, p.name, p.color, + COALESCE(SUM(rv.votes), 0) as total_votes, + COUNT(DISTINCT r.id) as results_count FROM parties p - LEFT JOIN result_party_scores rps ON rps.party_code = p.code - LEFT JOIN results r ON r.id = rps.result_id AND r.election_id = ? AND r.status IN ('finalized','validated') - WHERE p.is_active = 1 - GROUP BY p.code ORDER BY total_votes DESC - """, (election_id, election_id)).fetchall() + LEFT JOIN result_votes rv ON rv.party_id = p.id + LEFT JOIN results r ON r.id = rv.result_id AND r.election_id = ? + GROUP BY p.id ORDER BY total_votes DESC + """, (election_id,)).fetchall() db.close() + total_all = sum(r["total_votes"] for r in rows) elapsed = (time.time() - start) * 1000 - return { - "election_id": election_id, - "analysis_type": "party_performance", - "query_ms": round(elapsed, 2), - "data": [dict(r) for r in rows], - } + data = [] + for r in rows: + d = dict(r) + d["vote_share_pct"] = round(d["total_votes"] / total_all * 100, 2) if total_all > 0 else 0 + data.append(d) + return {"election_id": election_id, "analysis_type": "party_performance", + "query_ms": round(elapsed, 2), "data": data} @app.get("/analytics/{election_id}/timeline") -def timeline_analytics(election_id: int, interval: str = Query("hour", regex="^(hour|day|minute)$")): +def timeline_analytics(election_id: int, interval: str = Query("hour", pattern="^(hour|day|minute)$")): start = time.time() db = get_db() fmt_map = {"minute": "%Y-%m-%d %H:%M", "hour": "%Y-%m-%d %H:00", "day": "%Y-%m-%d"} @@ -111,68 +292,195 @@ def timeline_analytics(election_id: int, interval: str = Query("hour", regex="^( d = dict(r) d["cumulative_results"] = cumulative data.append(d) - return { - "election_id": election_id, - "analysis_type": "timeline", - "interval": interval, - "query_ms": round(elapsed, 2), - "data": data, - } + return {"election_id": election_id, "analysis_type": "timeline", + "interval": interval, "query_ms": round(elapsed, 2), "data": data} @app.get("/analytics/{election_id}/anomalies") -def anomaly_detection(election_id: int): +def anomaly_detection(election_id: int, severity: Optional[str] = None): + """AI-powered anomaly detection: overvote, z-score outliers, Benford's law, party dominance, patterns.""" start = time.time() db = get_db() - anomalies = [] + rows = db.execute(""" + SELECT r.polling_unit_code as code, pu.name, pu.registered_voters, + COALESCE(SUM(rv.votes), 0) as total_votes, r.rejected_votes, r.accredited_voters + FROM results r + JOIN polling_units pu ON r.polling_unit_code = pu.code + LEFT JOIN result_votes rv ON rv.result_id = r.id + WHERE r.election_id = ? + GROUP BY r.id + """, (election_id,)).fetchall() - high_turnout = db.execute(""" - SELECT r.id, r.polling_unit_code, r.total_votes_cast, r.accredited_voters, - CASE WHEN r.accredited_voters > 0 - THEN ROUND(CAST(r.total_votes_cast AS FLOAT) / r.accredited_voters * 100, 2) - ELSE 0 END as turnout_pct - FROM results r WHERE r.election_id = ? AND r.accredited_voters > 0 - AND CAST(r.total_votes_cast AS FLOAT) / r.accredited_voters > 0.95 - ORDER BY turnout_pct DESC LIMIT 20 + results = [] + vote_totals = [] + for r in rows: + d = dict(r) + registered = d["registered_voters"] or 0 + total = d["total_votes"] or 0 + d["registered_voters"] = registered + d["total_votes"] = total + d["turnout"] = (total / registered * 100) if registered > 0 else 0 + results.append(d) + if total > 0: + vote_totals.append(total) + + party_rows = db.execute(""" + SELECT r.polling_unit_code as code, pu.name as pu_name, pu.registered_voters as registered, + p.abbreviation as party, rv.votes + FROM result_votes rv + JOIN results r ON rv.result_id = r.id + JOIN parties p ON rv.party_id = p.id + JOIN polling_units pu ON r.polling_unit_code = pu.code + WHERE r.election_id = ? + ORDER BY r.polling_unit_code, rv.votes DESC """, (election_id,)).fetchall() - for r in high_turnout: - anomalies.append({"type": "high_turnout", "severity": "medium", "result_id": r["id"], - "pu_code": r["polling_unit_code"], "detail": f"Turnout {r['turnout_pct']}%"}) - - over_votes = db.execute(""" - SELECT r.id, r.polling_unit_code, r.total_votes_cast, r.accredited_voters - FROM results r WHERE r.election_id = ? - AND r.total_votes_cast > r.accredited_voters AND r.accredited_voters > 0 - LIMIT 20 + db.close() + + pu_party_votes = defaultdict(list) + party_vote_totals = [] + for pr in party_rows: + d = dict(pr) + pu_party_votes[d["code"]].append(d) + if d["votes"] > 0: + party_vote_totals.append(d["votes"]) + + all_anomalies = [] + all_anomalies.extend(detect_overvoting(results)) + all_anomalies.extend(detect_turnout_outliers(results)) + all_anomalies.extend(detect_party_dominance(pu_party_votes)) + all_anomalies.extend(detect_round_number_bias(results)) + all_anomalies.extend(detect_sequential_patterns(results)) + + if severity: + all_anomalies = [a for a in all_anomalies if a["severity"] == severity] + + sev_order = {"critical": 0, "warning": 1, "minor": 2, "info": 3} + all_anomalies.sort(key=lambda a: (sev_order.get(a["severity"], 9), -a["score"])) + + turnouts = [r["turnout"] for r in results if r["turnout"] > 0] + m_turnout, s_turnout = mean_std(turnouts) + med_turnout = median_val(turnouts) + benford_first = benfords_first_digit(vote_totals) + benford_last = benfords_last_digit(vote_totals) + benford_party = benfords_first_digit(party_vote_totals) + + counts = Counter(a["severity"] for a in all_anomalies) + type_counts = Counter(a["anomaly_type"] for a in all_anomalies) + elapsed = (time.time() - start) * 1000 + return { + "election_id": election_id, "analysis_type": "ai_anomaly_detection", + "query_ms": round(elapsed, 2), "total_analyzed": len(results), + "total_anomalies": len(all_anomalies), + "summary": {"critical": counts.get("critical", 0), "warning": counts.get("warning", 0), + "minor": counts.get("minor", 0), "info": counts.get("info", 0), + "by_type": dict(type_counts)}, + "statistics": {"mean_turnout": round(m_turnout, 2), "median_turnout": round(med_turnout, 2), + "std_turnout": round(s_turnout, 2), "total_results": len(results)}, + "benford_analysis": {"first_digit_votes": benford_first, "last_digit_votes": benford_last, + "first_digit_party_votes": benford_party}, + "anomalies": all_anomalies, + } + +@app.get("/analytics/{election_id}/benford") +def benford_analysis(election_id: int): + """Dedicated Benford's Law analysis.""" + start = time.time() + db = get_db() + vote_rows = db.execute(""" + SELECT COALESCE(SUM(rv.votes), 0) as total + FROM results r LEFT JOIN result_votes rv ON rv.result_id = r.id + WHERE r.election_id = ? GROUP BY r.id """, (election_id,)).fetchall() - for r in over_votes: - anomalies.append({"type": "over_voting", "severity": "high", "result_id": r["id"], - "pu_code": r["polling_unit_code"], - "detail": f"Votes {r['total_votes_cast']} > Accredited {r['accredited_voters']}"}) - - lopsided = db.execute(""" - SELECT r.id, r.polling_unit_code, rps.party_code, - rps.votes, r.total_valid_votes, - ROUND(CAST(rps.votes AS FLOAT) / NULLIF(r.total_valid_votes, 0) * 100, 2) as pct - FROM results r JOIN result_party_scores rps ON rps.result_id = r.id - WHERE r.election_id = ? AND r.total_valid_votes > 50 - AND CAST(rps.votes AS FLOAT) / r.total_valid_votes > 0.95 - ORDER BY pct DESC LIMIT 20 + vote_totals = [r["total"] for r in vote_rows if r["total"] > 0] + + party_rows = db.execute(""" + SELECT rv.votes FROM result_votes rv + JOIN results r ON rv.result_id = r.id + WHERE r.election_id = ? AND rv.votes > 0 """, (election_id,)).fetchall() - for r in lopsided: - anomalies.append({"type": "lopsided_result", "severity": "medium", "result_id": r["id"], - "pu_code": r["polling_unit_code"], - "detail": f"{r['party_code']} got {r['pct']}% of votes"}) + party_votes = [r["votes"] for r in party_rows] + acc_rows = db.execute(""" + SELECT accredited_voters FROM results + WHERE election_id = ? AND accredited_voters > 0 + """, (election_id,)).fetchall() + acc_values = [r["accredited_voters"] for r in acc_rows] db.close() elapsed = (time.time() - start) * 1000 return { - "election_id": election_id, - "analysis_type": "anomalies", + "election_id": election_id, "analysis_type": "benford", "query_ms": round(elapsed, 2), + "total_vote_counts": {"first_digit": benfords_first_digit(vote_totals), + "last_digit": benfords_last_digit(vote_totals)}, + "party_vote_counts": {"first_digit": benfords_first_digit(party_votes), + "last_digit": benfords_last_digit(party_votes)}, + "accredited_voters": {"first_digit": benfords_first_digit(acc_values), + "last_digit": benfords_last_digit(acc_values)}, + } + +@app.get("/analytics/{election_id}/integrity_score") +def integrity_score(election_id: int): + """Composite election integrity score (0-100) from all AI checks.""" + start = time.time() + anom = anomaly_detection(election_id) + ben = benford_analysis(election_id) + + score = 100.0 + total = anom["total_analyzed"] + if total == 0: + return {"election_id": election_id, "integrity_score": 0, "detail": "No data"} + + crit = anom["summary"]["critical"] + warn = anom["summary"]["warning"] + minor = anom["summary"]["minor"] + score -= min(40, crit / total * 400) + score -= min(20, warn / total * 200) + score -= min(10, minor / total * 100) + + bf1 = ben["total_vote_counts"]["first_digit"] + bf_last = ben["total_vote_counts"]["last_digit"] + bf1_pen = 10 if (isinstance(bf1, dict) and bf1.get("status") == "fail") else 5 if (isinstance(bf1, dict) and bf1.get("status") == "suspicious") else 0 + bfl_pen = 10 if (isinstance(bf_last, dict) and bf_last.get("status") == "fail") else 5 if (isinstance(bf_last, dict) and bf_last.get("status") == "suspicious") else 0 + score -= bf1_pen + score -= bfl_pen + score = max(0, min(100, score)) + grade = "A" if score >= 90 else "B" if score >= 75 else "C" if score >= 60 else "D" if score >= 40 else "F" + + elapsed = (time.time() - start) * 1000 + return { + "election_id": election_id, "integrity_score": round(score, 1), "grade": grade, "query_ms": round(elapsed, 2), - "total_anomalies": len(anomalies), - "data": anomalies, + "breakdown": { + "base_score": 100, + "critical_penalty": round(min(40, crit / total * 400), 1), + "warning_penalty": round(min(20, warn / total * 200), 1), + "minor_penalty": round(min(10, minor / total * 100), 1), + "benford_first_penalty": bf1_pen, "benford_last_penalty": bfl_pen, + }, + "anomaly_summary": anom["summary"], + "benford_summary": { + "first_digit": bf1.get("status", "n/a") if isinstance(bf1, dict) else "n/a", + "last_digit": bf_last.get("status", "n/a") if isinstance(bf_last, dict) else "n/a", + }, + "methods_used": [ + "Overvote detection", "Z-score turnout outlier analysis", + "IQR-based turnout outlier detection", "Single-party dominance (>90%)", + "Round number bias detection", "Sequential pattern detection", + "Benford's Law first-digit", "Benford's Law last-digit uniformity", + ], } +@app.get("/ai/methods") +def ai_methods(): + return {"methods": [ + {"id": "overvote", "name": "Overvote Detection", "description": "Detects PUs where votes exceed registered voters", "type": "rule-based", "severity": "critical"}, + {"id": "turnout_outlier", "name": "Turnout Outlier", "description": "Z-score and IQR to find anomalous turnout", "type": "statistical", "severity": "warning-critical"}, + {"id": "party_dominance", "name": "Party Dominance", "description": "One party >90% of votes", "type": "statistical", "severity": "warning-critical"}, + {"id": "round_number", "name": "Round Number Bias", "description": "Suspicious multiples of 50/100", "type": "pattern", "severity": "minor"}, + {"id": "identical_adjacent", "name": "Sequential Patterns", "description": "Identical counts in adjacent PUs", "type": "pattern", "severity": "warning"}, + {"id": "benford_first", "name": "Benford First Digit", "description": "Chi-square test vs Benford distribution", "type": "statistical", "severity": "info"}, + {"id": "benford_last", "name": "Benford Last Digit", "description": "Last-digit uniformity test", "type": "statistical", "severity": "info"}, + {"id": "integrity_score", "name": "Integrity Score", "description": "Composite 0-100 score from all checks", "type": "composite", "severity": "n/a"}, + ]} + @app.post("/query") def execute_query(q: LakehouseQuery): start = time.time() @@ -185,12 +493,8 @@ def execute_query(q: LakehouseQuery): cols = [desc[0] for desc in db.execute(q.query).description] if rows else [] db.close() elapsed = (time.time() - start) * 1000 - return { - "columns": cols, - "rows": [dict(r) for r in rows], - "count": len(rows), - "query_ms": round(elapsed, 2), - } + return {"columns": cols, "rows": [dict(r) for r in rows], + "count": len(rows), "query_ms": round(elapsed, 2)} except Exception as e: db.close() return {"error": str(e)} diff --git a/inec-backend/app/analytics.py b/inec-backend/app/analytics.py new file mode 100644 index 00000000..0c542ba4 --- /dev/null +++ b/inec-backend/app/analytics.py @@ -0,0 +1,358 @@ +"""Embedded AI analytics endpoints - statistical anomaly detection.""" +import os +import time +import math +import sqlite3 +from collections import Counter, defaultdict +from typing import Optional +from fastapi import APIRouter, Query + +router = APIRouter() +DB_PATH = os.getenv("DB_PATH", "/data/app.db") + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + +def mean_std(values): + if not values: + return 0.0, 0.0 + n = len(values) + m = sum(values) / n + variance = sum((x - m) ** 2 for x in values) / n + return m, math.sqrt(variance) + +def median_val(values): + s = sorted(values) + n = len(s) + if n == 0: + return 0 + if n % 2 == 1: + return s[n // 2] + return (s[n // 2 - 1] + s[n // 2]) / 2 + +def iqr_bounds(values): + s = sorted(values) + n = len(s) + if n < 4: + return None, None + q1 = s[n // 4] + q3 = s[3 * n // 4] + iqr = q3 - q1 + return q1 - 1.5 * iqr, q3 + 1.5 * iqr + +def benfords_first_digit(values): + if len(values) < 30: + return {"status": "insufficient_data", "sample_size": len(values)} + digits = [] + for v in values: + if v > 0: + first = int(str(abs(v))[0]) + if 1 <= first <= 9: + digits.append(first) + if len(digits) < 20: + return {"status": "insufficient_data", "sample_size": len(digits)} + expected_benford = {d: math.log10(1 + 1/d) for d in range(1, 10)} + observed_counts = Counter(digits) + n = len(digits) + chi_sq = 0.0 + distribution = [] + for d in range(1, 10): + obs = observed_counts.get(d, 0) + exp = expected_benford[d] * n + chi_sq += (obs - exp) ** 2 / exp + distribution.append({ + "digit": d, "observed_count": obs, + "observed_pct": round(obs / n * 100, 2), + "expected_pct": round(expected_benford[d] * 100, 2), + "deviation": round(abs(obs / n - expected_benford[d]) * 100, 2) + }) + if chi_sq > 20.09: + status = "fail" + elif chi_sq > 15.51: + status = "suspicious" + else: + status = "pass" + return {"test": "benfords_first_digit", "chi_square": round(chi_sq, 3), + "degrees_of_freedom": 8, "status": status, "sample_size": n, "distribution": distribution} + +def benfords_last_digit(values): + if len(values) < 30: + return {"status": "insufficient_data", "sample_size": len(values)} + digits = [abs(v) % 10 for v in values if v > 0] + if len(digits) < 20: + return {"status": "insufficient_data", "sample_size": len(digits)} + n = len(digits) + expected = n / 10.0 + observed_counts = Counter(digits) + chi_sq = 0.0 + distribution = [] + for d in range(10): + obs = observed_counts.get(d, 0) + chi_sq += (obs - expected) ** 2 / expected + distribution.append({ + "digit": d, "observed_count": obs, + "observed_pct": round(obs / n * 100, 2), + "expected_pct": 10.0, + "deviation": round(abs(obs / n - 0.1) * 100, 2) + }) + if chi_sq > 21.67: + status = "fail" + elif chi_sq > 16.92: + status = "suspicious" + else: + status = "pass" + return {"test": "benfords_last_digit", "chi_square": round(chi_sq, 3), + "degrees_of_freedom": 9, "status": status, "sample_size": n, "distribution": distribution} + +def detect_overvoting(results): + anomalies = [] + for r in results: + if r["registered_voters"] > 0 and r["total_votes"] > r["registered_voters"]: + excess = r["total_votes"] - r["registered_voters"] + pct = excess / r["registered_voters"] * 100 + anomalies.append({ + "polling_unit_code": r["code"], "pu_name": r["name"], + "anomaly_type": "overvoting", "severity": "critical", + "score": round(pct, 2), + "detail": f"Votes ({r['total_votes']}) exceed registered ({r['registered_voters']}) by {excess} ({pct:.1f}%)", + "total_votes": r["total_votes"], "registered_voters": r["registered_voters"], + "turnout_pct": round(r["turnout"], 2) + }) + return anomalies + +def detect_turnout_outliers(results): + turnouts = [r["turnout"] for r in results if r["turnout"] > 0] + if len(turnouts) < 10: + return [] + m, s = mean_std(turnouts) + _, upper_iqr = iqr_bounds(turnouts) + anomalies = [] + for r in results: + if r["turnout"] <= 0 or s == 0: + continue + z = (r["turnout"] - m) / s + is_iqr_outlier = upper_iqr is not None and r["turnout"] > upper_iqr + if abs(z) > 2.5 or is_iqr_outlier: + severity = "critical" if abs(z) > 3.5 else "warning" + anomalies.append({ + "polling_unit_code": r["code"], "pu_name": r["name"], + "anomaly_type": "turnout_outlier", "severity": severity, + "score": round(abs(z), 2), + "detail": f"Turnout {r['turnout']:.1f}% (z={z:.2f}, mean={m:.1f}%, std={s:.1f}%)", + "total_votes": r["total_votes"], "registered_voters": r["registered_voters"], + "turnout_pct": round(r["turnout"], 2) + }) + return anomalies + +def detect_party_dominance(pu_party_votes): + anomalies = [] + for code, votes_list in pu_party_votes.items(): + total = sum(v["votes"] for v in votes_list) + if total < 50 or len(votes_list) < 2: + continue + top = max(votes_list, key=lambda x: x["votes"]) + share = top["votes"] / total * 100 + if share > 90: + severity = "critical" if share > 98 else "warning" + anomalies.append({ + "polling_unit_code": code, "pu_name": votes_list[0].get("pu_name", ""), + "anomaly_type": "party_dominance", "severity": severity, + "score": round(share, 2), + "detail": f"{top['party']} received {share:.1f}% of {total} votes", + "total_votes": total, "registered_voters": votes_list[0].get("registered", 0), + "turnout_pct": 0 + }) + return anomalies + +def detect_round_number_bias(results): + anomalies = [] + for r in results: + v = r["total_votes"] + if v >= 100 and v % 100 == 0: + anomalies.append({ + "polling_unit_code": r["code"], "pu_name": r["name"], + "anomaly_type": "round_number", "severity": "minor", + "score": v, "detail": f"Suspiciously round vote count: {v}", + "total_votes": v, "registered_voters": r["registered_voters"], + "turnout_pct": round(r["turnout"], 2) + }) + return anomalies + +def detect_sequential_patterns(results): + anomalies = [] + sorted_results = sorted(results, key=lambda r: r["code"]) + for i in range(1, len(sorted_results)): + prev, curr = sorted_results[i-1], sorted_results[i] + if prev["total_votes"] == curr["total_votes"] and prev["total_votes"] > 50: + prefix_prev = "-".join(prev["code"].split("-")[:3]) + prefix_curr = "-".join(curr["code"].split("-")[:3]) + if prefix_prev == prefix_curr: + anomalies.append({ + "polling_unit_code": curr["code"], "pu_name": curr["name"], + "anomaly_type": "identical_adjacent", "severity": "warning", + "score": curr["total_votes"], + "detail": f"Identical count ({curr['total_votes']}) as adjacent {prev['code']}", + "total_votes": curr["total_votes"], "registered_voters": curr["registered_voters"], + "turnout_pct": round(curr["turnout"], 2) + }) + return anomalies + +def _load_results(election_id): + db = get_db() + rows = db.execute(""" + SELECT r.polling_unit_code as code, pu.name, pu.registered_voters, + COALESCE(SUM(rps.votes), 0) as total_votes, r.rejected_votes, r.accredited_voters + FROM results r + JOIN polling_units pu ON r.polling_unit_code = pu.code + LEFT JOIN result_party_scores rps ON rps.result_id = r.id + WHERE r.election_id = ? + GROUP BY r.id + """, (election_id,)).fetchall() + results = [] + vote_totals = [] + for r in rows: + d = dict(r) + registered = d["registered_voters"] or 0 + total = d["total_votes"] or 0 + d["registered_voters"] = registered + d["total_votes"] = total + d["turnout"] = (total / registered * 100) if registered > 0 else 0 + results.append(d) + if total > 0: + vote_totals.append(total) + party_rows = db.execute(""" + SELECT r.polling_unit_code as code, pu.name as pu_name, pu.registered_voters as registered, + p.code as party, rps.votes + FROM result_party_scores rps + JOIN results r ON rps.result_id = r.id + JOIN parties p ON rps.party_code = p.code + JOIN polling_units pu ON r.polling_unit_code = pu.code + WHERE r.election_id = ? + ORDER BY r.polling_unit_code, rps.votes DESC + """, (election_id,)).fetchall() + db.close() + pu_party_votes = defaultdict(list) + party_vote_totals = [] + for pr in party_rows: + d = dict(pr) + pu_party_votes[d["code"]].append(d) + if d["votes"] > 0: + party_vote_totals.append(d["votes"]) + return results, vote_totals, pu_party_votes, party_vote_totals + + +@router.get("/ai/anomalies") +def anomaly_detection(election_id: int = 1, severity: Optional[str] = None): + start = time.time() + results, vote_totals, pu_party_votes, party_vote_totals = _load_results(election_id) + all_anomalies = [] + all_anomalies.extend(detect_overvoting(results)) + all_anomalies.extend(detect_turnout_outliers(results)) + all_anomalies.extend(detect_party_dominance(pu_party_votes)) + all_anomalies.extend(detect_round_number_bias(results)) + all_anomalies.extend(detect_sequential_patterns(results)) + if severity: + all_anomalies = [a for a in all_anomalies if a["severity"] == severity] + sev_order = {"critical": 0, "warning": 1, "minor": 2, "info": 3} + all_anomalies.sort(key=lambda a: (sev_order.get(a["severity"], 9), -a["score"])) + turnouts = [r["turnout"] for r in results if r["turnout"] > 0] + m_turnout, s_turnout = mean_std(turnouts) + benford_first = benfords_first_digit(vote_totals) + benford_party = benfords_first_digit(party_vote_totals) + counts = Counter(a["severity"] for a in all_anomalies) + type_counts = Counter(a["anomaly_type"] for a in all_anomalies) + elapsed = (time.time() - start) * 1000 + return { + "election_id": election_id, "total_analyzed": len(results), + "total_anomalies": len(all_anomalies), + "summary": {"critical": counts.get("critical", 0), "warning": counts.get("warning", 0), + "minor": counts.get("minor", 0), "info": counts.get("info", 0)}, + "statistics": {"mean_turnout": round(m_turnout, 2), "std_turnout": round(s_turnout, 2)}, + "benford_analysis": {"vote_totals": benford_first, "party_votes": benford_party}, + "anomalies": all_anomalies[:200], + "query_ms": round(elapsed, 2) + } + + +@router.get("/ai/benford") +def benford_analysis(election_id: int = 1): + start = time.time() + results, vote_totals, _, party_vote_totals = _load_results(election_id) + first_digit = benfords_first_digit(vote_totals) + last_digit = benfords_last_digit(vote_totals) + party_first = benfords_first_digit(party_vote_totals) + elapsed = (time.time() - start) * 1000 + return { + "election_id": election_id, + "first_digit_analysis": first_digit, + "last_digit_analysis": last_digit, + "party_vote_analysis": party_first, + "sample_size": len(vote_totals), + "query_ms": round(elapsed, 2), + **first_digit + } + + +@router.get("/ai/integrity") +def integrity_score(election_id: int = 1): + start = time.time() + results, vote_totals, pu_party_votes, party_vote_totals = _load_results(election_id) + overvotes = detect_overvoting(results) + outliers = detect_turnout_outliers(results) + dominance = detect_party_dominance(pu_party_votes) + round_nums = detect_round_number_bias(results) + sequential = detect_sequential_patterns(results) + benford = benfords_first_digit(vote_totals) + score = 100.0 + n = max(len(results), 1) + score -= min(30, len(overvotes) / n * 100 * 3) + score -= min(20, len(outliers) / n * 100 * 2) + score -= min(15, len(dominance) / n * 100 * 1.5) + score -= min(10, len(round_nums) / n * 100) + score -= min(10, len(sequential) / n * 100) + if benford.get("status") == "fail": + score -= 15 + elif benford.get("status") == "suspicious": + score -= 7 + score = max(0, min(100, round(score, 1))) + if score >= 90: + grade = "A" + elif score >= 80: + grade = "B" + elif score >= 70: + grade = "C" + elif score >= 60: + grade = "D" + else: + grade = "F" + elapsed = (time.time() - start) * 1000 + return { + "election_id": election_id, "integrity_score": score, "grade": grade, + "breakdown": { + "overvoting_penalty": len(overvotes), "outlier_penalty": len(outliers), + "dominance_penalty": len(dominance), "round_number_penalty": len(round_nums), + "sequential_penalty": len(sequential), + "benford_status": benford.get("status", "unknown") + }, + "methods_used": ["benfords_law", "z_score", "iqr", "party_dominance", "round_number", "sequential_pattern"], + "total_results_analyzed": len(results), + "query_ms": round(elapsed, 2) + } + + +@router.get("/ai/methods") +def ai_methods(): + return { + "methods": [ + {"name": "benfords_law", "description": "Chi-square test of first/last digit distribution"}, + {"name": "z_score_outlier", "description": "Z-score based turnout outlier detection"}, + {"name": "iqr_outlier", "description": "IQR-based vote count outlier detection"}, + {"name": "party_dominance", "description": "Single-party dominance detection (>90% share)"}, + {"name": "round_number_bias", "description": "Round number vote total detection"}, + {"name": "sequential_pattern", "description": "Identical/sequential pattern detection across adjacent PUs"}, + ], + "engine": "python-statistical", + "version": "2.0.0" + } diff --git a/inec-backend/app/main.py b/inec-backend/app/main.py index d7afc328..48992a61 100644 --- a/inec-backend/app/main.py +++ b/inec-backend/app/main.py @@ -9,6 +9,7 @@ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response +from app.analytics import router as analytics_router GO_BACKEND_URL = "http://127.0.0.1:8088" go_process = None @@ -19,12 +20,14 @@ # Disable CORS. Do not remove this for full-stack development. app.add_middleware( CORSMiddleware, - allow_origins=["*"], # Allows all origins + allow_origins=["*"], allow_credentials=True, - allow_methods=["*"], # Allows all methods - allow_headers=["*"], # Allows all headers + allow_methods=["*"], + allow_headers=["*"], ) +app.include_router(analytics_router) + http_client: httpx.AsyncClient = None @@ -32,17 +35,14 @@ async def startup(): global go_process, http_client http_client = httpx.AsyncClient(base_url=GO_BACKEND_URL, timeout=30.0) + db_path = os.environ.get("DB_PATH", "/data/app.db") + os.environ["DB_PATH"] = db_path binary = os.path.join(os.path.dirname(os.path.dirname(__file__)), "inec-go-backend") if not os.path.isfile(binary): binary = "/app/inec-go-backend" os.chmod(binary, os.stat(binary).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - env = {**os.environ, "PORT": "8088", "DB_PATH": os.environ.get("DB_PATH", "/data/app.db")} - go_process = subprocess.Popen( - [binary], - env=env, - stdout=sys.stdout, - stderr=sys.stderr, - ) + env = {**os.environ, "PORT": "8088", "DB_PATH": db_path} + go_process = subprocess.Popen([binary], env=env, stdout=sys.stdout, stderr=sys.stderr) for _ in range(50): await asyncio.sleep(0.2) try: diff --git a/inec-backend/poetry.lock b/inec-backend/poetry.lock index abb16cf2..8a7ca00a 100644 --- a/inec-backend/poetry.lock +++ b/inec-backend/poetry.lock @@ -2038,4 +2038,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "a76e7c31c11b9c06f5be90fe4d4a5fbeecafce1786cf70b3a352e0637c7a9772" +content-hash = "024292c80463a4dac056baebd54a025a9bd715f339b684437e94c19a65fefb7b" diff --git a/inec-backend/pyproject.toml b/inec-backend/pyproject.toml index cc6ad6ee..755545ca 100644 --- a/inec-backend/pyproject.toml +++ b/inec-backend/pyproject.toml @@ -16,6 +16,7 @@ aiosqlite = "^0.22.1" mapbox-vector-tile = "^2.2.0" httpx = "^0.28.1" websockets = "^16.0" +uvicorn = "^0.40.0" [build-system] diff --git a/inec-frontend/index.html b/inec-frontend/index.html index d00b170c..491f765a 100644 --- a/inec-frontend/index.html +++ b/inec-frontend/index.html @@ -4,7 +4,11 @@ - inec-frontend + + + + + INEC Election Platform
diff --git a/inec-frontend/public/icons/icon-192.png b/inec-frontend/public/icons/icon-192.png new file mode 100644 index 00000000..be41747f Binary files /dev/null and b/inec-frontend/public/icons/icon-192.png differ diff --git a/inec-frontend/public/icons/icon-512.png b/inec-frontend/public/icons/icon-512.png new file mode 100644 index 00000000..be41747f Binary files /dev/null and b/inec-frontend/public/icons/icon-512.png differ diff --git a/inec-frontend/public/manifest.json b/inec-frontend/public/manifest.json new file mode 100644 index 00000000..e919ac54 --- /dev/null +++ b/inec-frontend/public/manifest.json @@ -0,0 +1,27 @@ +{ + "name": "INEC Election Platform", + "short_name": "INEC Platform", + "description": "Real-Time Election Result Transmission & Transparency Platform", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#15803d", + "orientation": "any", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "categories": ["government", "news", "utilities"], + "lang": "en", + "dir": "ltr" +} diff --git a/inec-frontend/public/sw.js b/inec-frontend/public/sw.js new file mode 100644 index 00000000..994e0363 --- /dev/null +++ b/inec-frontend/public/sw.js @@ -0,0 +1,55 @@ +const CACHE_NAME = 'inec-v6'; +const STATIC_ASSETS = [ + '/manifest.json', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(STATIC_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + + const url = new URL(request.url); + + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/auth/')) { + event.respondWith( + fetch(request).catch(() => + new Response(JSON.stringify({ error: 'offline', message: 'You are offline. Results shown may be cached.' }), { + headers: { 'Content-Type': 'application/json' }, + }) + ) + ); + return; + } + + if (request.mode === 'navigate' || url.pathname === '/') { + event.respondWith( + fetch(request).catch(() => caches.match('/')) + ); + return; + } + + event.respondWith( + fetch(request).then((response) => { + if (response && response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, clone)); + } + return response; + }).catch(() => caches.match(request)) + ); +}); diff --git a/inec-frontend/src/App.tsx b/inec-frontend/src/App.tsx index bc2b24dd..b05254e0 100644 --- a/inec-frontend/src/App.tsx +++ b/inec-frontend/src/App.tsx @@ -13,6 +13,9 @@ import IncidentsPage from '@/pages/IncidentsPage'; import MapPage from '@/pages/MapPage'; import MiddlewarePage from '@/pages/MiddlewarePage'; import BVASPage from '@/pages/BVASPage'; +import AnomalyDetectionPage from '@/pages/AnomalyDetectionPage'; +import SMSVerificationPage from '@/pages/SMSVerificationPage'; +import PublicAPIPage from '@/pages/PublicAPIPage'; function AppContent() { const { isAuthenticated } = useAuth(); @@ -31,6 +34,9 @@ function AppContent() { incidents: , middleware: , bvas: , + 'anomaly-detection': , + 'sms-verification': , + 'public-api': , }; return ( diff --git a/inec-frontend/src/components/Layout.tsx b/inec-frontend/src/components/Layout.tsx index 50eab51a..045c77c8 100644 --- a/inec-frontend/src/components/Layout.tsx +++ b/inec-frontend/src/components/Layout.tsx @@ -8,7 +8,8 @@ import { Badge } from '@/components/ui/badge'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { LayoutDashboard, Vote, FileBarChart, Shield, AlertTriangle, - Menu, LogOut, ChevronRight, Landmark, MapPin, Users, Map, Layers, Fingerprint + Menu, LogOut, ChevronRight, Landmark, MapPin, Users, Map, Layers, Fingerprint, + Brain, MessageSquare, Code2 } from 'lucide-react'; const NAV_ITEMS = [ @@ -20,6 +21,9 @@ const NAV_ITEMS = [ { label: 'Polling Units', icon: MapPin, path: 'polling-units' }, { label: 'Audit Trail', icon: Shield, path: 'audit' }, { label: 'Incidents', icon: AlertTriangle, path: 'incidents' }, + { label: 'AI Anomaly', icon: Brain, path: 'anomaly-detection' }, + { label: 'SMS/USSD', icon: MessageSquare, path: 'sms-verification' }, + { label: 'Public API', icon: Code2, path: 'public-api' }, { label: 'Middleware', icon: Layers, path: 'middleware' }, { label: 'BVAS', icon: Fingerprint, path: 'bvas' }, ]; diff --git a/inec-frontend/src/lib/api.ts b/inec-frontend/src/lib/api.ts index 32211a22..9bd0825e 100644 --- a/inec-frontend/src/lib/api.ts +++ b/inec-frontend/src/lib/api.ts @@ -111,4 +111,24 @@ export const api = { getIngestionJobs: (status?: string) => request(`/ingestion/jobs${status ? `?status=${status}` : ''}`), getDeadLetterQueue: () => request('/ingestion/dead-letter'), + + smsVerify: (phone: string, pollingUnitCode: string) => + request('/sms/verify', { method: 'POST', body: JSON.stringify({ phone, polling_unit_code: pollingUnitCode }) }), + ussdGateway: (sessionId: string, phoneNumber: string, text: string) => + request('/ussd/gateway', { method: 'POST', body: JSON.stringify({ sessionId, phoneNumber, text }) }), + getSMSStats: () => request('/sms/stats'), + + getAIAnomalies: (electionId: number, severity?: string) => + request(`/ai/anomalies?election_id=${electionId}${severity ? `&severity=${severity}` : ''}`), + getAIBenford: (electionId: number) => + request(`/ai/benford?election_id=${electionId}`), + getAIIntegrity: (electionId: number) => + request(`/ai/integrity?election_id=${electionId}`), + getAIMethods: () => request('/ai/methods'), + + getPublicAPIDocs: () => request('/api/v1/docs'), + generateAPIKey: (name: string, owner: string) => + request('/api/v1/keys', { method: 'POST', body: JSON.stringify({ name, owner }) }), + getAPIKeys: () => request('/api/v1/keys'), + getAPIUsage: () => request('/api/v1/usage'), }; diff --git a/inec-frontend/src/lib/i18n.tsx b/inec-frontend/src/lib/i18n.tsx index dbe8e1b1..d5f3b982 100644 --- a/inec-frontend/src/lib/i18n.tsx +++ b/inec-frontend/src/lib/i18n.tsx @@ -6,60 +6,142 @@ type Dict = Record; const DICTS: Record = { en: { - street: 'Street', - satellite: 'Satellite', - compare: 'Compare', - leading_party: 'Leading Party', - completion: 'Completion %', - zone: 'Geo-Political Zone', - pu_markers: 'PU Markers', - box_select: 'Box Select', - export_csv: 'Export CSV', - export_geojson: 'Export GeoJSON', - selection: 'Selection', - search_places: 'Search places...' + street: 'Street', satellite: 'Satellite', compare: 'Compare', + leading_party: 'Leading Party', completion: 'Completion %', zone: 'Geo-Political Zone', + pu_markers: 'PU Markers', box_select: 'Box Select', + export_csv: 'Export CSV', export_geojson: 'Export GeoJSON', + selection: 'Selection', search_places: 'Search places...', + anomaly_detection: 'AI Anomaly Detection', anomaly_desc: 'AI-powered result validation detecting statistical anomalies', + integrity_score: 'Integrity Score', total_anomalies: 'Total Anomalies', + ai_methods: 'AI Methods', benford_status: 'Benford Test', + overview: 'Overview', benford_analysis: "Benford's Analysis", anomaly_list: 'Anomaly List', + severity_distribution: 'Severity Distribution', integrity_breakdown: 'Integrity Breakdown', + benford_first_digit: "Benford's First Digit Distribution", digit: 'Digit', + observed: 'Observed %', expected_benford: 'Expected (Benford)', sample_size: 'Sample Size', + filter_severity: 'Filter by Severity', all: 'All', + polling_unit: 'Polling Unit', type: 'Type', severity: 'Severity', description: 'Description', + no_anomalies: 'No anomalies detected', no_data: 'No data available', refresh: 'Refresh', + benford_method_desc: 'Chi-square test of first-digit distribution against Benford\'s Law', + zscore_method_desc: 'Z-score outlier detection for turnout anomalies', + iqr_method_desc: 'IQR-based outlier detection for vote counts', + dominance_method_desc: 'Detects single-party dominance (>90% vote share)', + round_number_method_desc: 'Identifies suspicious round-number vote totals', + sequential_method_desc: 'Detects identical/sequential patterns across adjacent polling units', + sms_verification: 'SMS/USSD Verification', sms_desc: 'Verify election results via SMS or USSD — no internet required', + sms_channel: 'SMS Channel', ussd_channel: 'USSD Channel', + text_verify: 'Text to Verify', no_internet: 'No Internet Required', works_offline: 'Works Offline', + sms_verify: 'SMS Verify', ussd_simulator: 'USSD Simulator', statistics: 'Statistics', user_guide: 'User Guide', + sms_result_verify: 'SMS Result Verification', phone_number: 'Phone Number', + phone_hint: 'Nigerian phone number with country code', polling_unit_code: 'Polling Unit Code', + pu_code_hint: 'Enter the polling unit code to verify', sending: 'Sending...', + verify_result: 'Verify Result', error: 'Error', result_found: 'Result Found', + ussd_start_hint: 'Press Send to start a USSD session', enter_option: 'Enter option...', + ussd_input: 'USSD input', ussd_instructions: 'Simulates a USSD session. Send empty to start, then enter menu options.', + sms_ussd_stats: 'SMS/USSD Statistics', click_tab_load: 'Click this tab to load data', + how_to_use: 'How to Use', sms_guide_title: 'Via SMS', + sms_step_1: 'Send an SMS to the INEC shortcode with your polling unit code', + sms_step_2: 'You will receive a reply with the verified result for that polling unit', + sms_step_3: 'Results include vote counts for all parties and verification status', + ussd_guide_title: 'Via USSD', ussd_step_1: 'Dial *347*123# from any mobile phone', + ussd_step_2: 'Select option 1 to check results', ussd_step_3: 'Enter your state and polling unit code', + ussd_step_4: 'View verified results on your screen', + public_api: 'Public API', public_api_desc: 'Versioned API with key auth, rate limiting, and OpenAPI docs for third-party verification', + api_version: 'API Version', rate_limit: 'Rate Limit', req_per_min: 'req/min', + active_keys: 'Active Keys', api_docs: 'API Docs', api_keys: 'API Keys', usage: 'Usage', examples: 'Examples', + api_endpoints: 'API Endpoints', auth_required: 'Auth', copied: 'Copied!', + authentication: 'Authentication', auth_desc: 'Pass your API key via the X-API-Key header or api_key query parameter.', + auth_query_param: 'Or as a query parameter:', + generate_key: 'Generate API Key', key_name: 'Key Name', owner: 'Owner', + generating: 'Generating...', key_generated: 'API Key Generated Successfully', + key_warning: 'Save this key — it will not be shown again.', + existing_keys: 'Existing API Keys', name: 'Name', permissions: 'Permissions', status: 'Status', + active: 'Active', inactive: 'Inactive', no_keys: 'No API keys generated yet', + api_usage: 'API Usage Statistics', code_examples: 'Code Examples', }, ha: { - street: 'Titin', - satellite: 'Satilaid', - compare: 'Kwatanta', - leading_party: 'Jam’iyya Mai Nasara', - completion: 'Kashi na kammalawa', - zone: 'Yankin Siyasa', - pu_markers: 'Alamomin PU', - box_select: 'Zaɓen Akwati', - export_csv: 'Fitar da CSV', - export_geojson: 'Fitar da GeoJSON', - selection: 'Zaɓi', - search_places: 'Nema wurare...' + street: 'Titin', satellite: 'Satilaid', compare: 'Kwatanta', + leading_party: "Jam'iyya Mai Nasara", completion: 'Kashi na kammalawa', zone: 'Yankin Siyasa', + pu_markers: 'Alamomin PU', box_select: 'Zaɓen Akwati', + export_csv: 'Fitar da CSV', export_geojson: 'Fitar da GeoJSON', + selection: 'Zaɓi', search_places: 'Nema wurare...', + anomaly_detection: 'Gano Matsalolin AI', anomaly_desc: 'Tabbatar da sakamakon zaɓe ta hanyar AI', + integrity_score: 'Maki Gaskiya', total_anomalies: 'Jimillar Matsaloli', + ai_methods: 'Hanyoyin AI', benford_status: 'Gwajin Benford', + overview: 'Bayani', benford_analysis: 'Nazarin Benford', anomaly_list: 'Jerin Matsaloli', + severity_distribution: 'Rarraba Tsanani', integrity_breakdown: 'Rarraba Gaskiya', + benford_first_digit: 'Rarraba Lambar Farko ta Benford', digit: 'Lamba', + observed: 'Abin da aka gani %', expected_benford: 'Abin da ake tsammani', sample_size: 'Girman samfuri', + filter_severity: 'Tace ta tsanani', all: 'Duka', + polling_unit: 'Rumfar zaɓe', type: 'Iri', severity: 'Tsanani', description: 'Bayani', + no_anomalies: 'Ba a gano matsala ba', no_data: 'Babu bayanai', refresh: 'Sabunta', + sms_verification: 'Tabbatarwa ta SMS/USSD', sms_desc: 'Tabbatar da sakamakon zaɓe ta SMS ko USSD', + sms_channel: 'Hanyar SMS', ussd_channel: 'Hanyar USSD', + text_verify: 'Aika don tabbatarwa', no_internet: 'Ba a buƙatar Intanet', works_offline: 'Yana aiki ba tare da Intanet ba', + sms_verify: 'Tabbatar ta SMS', ussd_simulator: 'Mai koyi da USSD', statistics: 'Ƙididdiga', user_guide: 'Jagorar mai amfani', + phone_number: 'Lambar waya', polling_unit_code: 'Lambar rumfar zaɓe', + verify_result: 'Tabbatar da sakamako', error: 'Kuskure', result_found: 'An sami sakamako', + public_api: 'API na Jama\'a', public_api_desc: 'API mai sigar da makulli da iyaka don tabbatarwa', + api_version: 'Sigar API', rate_limit: 'Iyakar buƙatu', active_keys: 'Makullan da ke aiki', + api_docs: 'Takardun API', api_keys: 'Makullan API', usage: 'Amfani', examples: 'Misalai', + generate_key: 'Ƙirƙiri makulli', owner: 'Mai shi', name: 'Suna', + active: 'Yana aiki', inactive: 'Ba ya aiki', }, yo: { - street: 'Ọna', - satellite: 'Satẹlaiti', - compare: 'Fiwera', - leading_party: 'Ẹgbẹ to n ṣaju', - completion: 'Ipẹyà %', - zone: 'Agbegbe Oṣelu', - pu_markers: 'Awọn ami PU', - box_select: 'Aṣayan Apoti', - export_csv: 'Jade CSV', - export_geojson: 'Jade GeoJSON', - selection: 'Yiyan', - search_places: 'Wa awọn ibi...' + street: 'Ọna', satellite: 'Satẹlaiti', compare: 'Fiwera', + leading_party: 'Ẹgbẹ to n ṣaju', completion: 'Ipẹyà %', zone: 'Agbegbe Oṣelu', + pu_markers: 'Awọn ami PU', box_select: 'Aṣayan Apoti', + export_csv: 'Jade CSV', export_geojson: 'Jade GeoJSON', + selection: 'Yiyan', search_places: 'Wa awọn ibi...', + anomaly_detection: 'Iwadii Aiṣedeede AI', anomaly_desc: 'Ṣayẹwo abajade idibo pẹlu AI', + integrity_score: 'Iwọn Otitọ', total_anomalies: 'Apapọ Aiṣedeede', + ai_methods: 'Awọn ọna AI', benford_status: 'Idanwo Benford', + overview: 'Akopọ', benford_analysis: 'Itupalẹ Benford', anomaly_list: 'Atokọ Aiṣedeede', + severity_distribution: 'Pinpin Bi o ti buru', integrity_breakdown: 'Alaye Otitọ', + benford_first_digit: 'Pinpin Nọmba Akọkọ Benford', digit: 'Nọmba', + observed: 'Ti a ri %', expected_benford: 'Ti a nireti', sample_size: 'Iwọn ayẹwo', + filter_severity: 'Ṣe àyọkà', all: 'Gbogbo', + polling_unit: 'Ibudo idibo', type: 'Iru', severity: 'Bi o ṣe buru', description: 'Apejuwe', + no_anomalies: 'Ko si aiṣedeede', no_data: 'Ko si data', refresh: 'Tun ṣe', + sms_verification: 'Ìjẹ́rìísí SMS/USSD', sms_desc: 'Jẹrisi abajade idibo nipasẹ SMS tabi USSD', + sms_channel: 'Ọna SMS', ussd_channel: 'Ọna USSD', + text_verify: 'Fi ọrọ ranṣẹ lati jẹrisi', no_internet: 'Ko nilo Intanẹẹti', works_offline: 'N ṣiṣẹ laisi Intanẹẹti', + sms_verify: 'Jẹrisi nipasẹ SMS', ussd_simulator: 'Ẹrọ USSD', statistics: 'Awọn iṣiro', user_guide: 'Itọsọna', + phone_number: 'Nọmba foonu', polling_unit_code: 'Koodu ibudo idibo', + verify_result: 'Jẹrisi abajade', error: 'Aṣiṣe', result_found: 'A ri abajade', + public_api: 'API Gbogbogbo', public_api_desc: 'API pẹlu bọtini ati opin fun ìjẹ́rìísí', + api_version: 'Ẹya API', rate_limit: 'Opin ibeere', active_keys: 'Awọn bọtini ti n ṣiṣẹ', + api_docs: 'Iwe API', api_keys: 'Awọn bọtini API', usage: 'Lilo', examples: 'Awọn apẹẹrẹ', + generate_key: 'Ṣẹda bọtini', owner: 'Oniwun', name: 'Orukọ', + active: 'Nṣiṣẹ', inactive: 'Ko ṣiṣẹ', }, ig: { - street: 'Ụzọ', - satellite: 'Satẹlaịtị', - compare: 'Tụnyere', + street: 'Ụzọ', satellite: 'Satẹlaịtị', compare: 'Tụnyere', leading_party: 'Ụlọ ọrụ ndọrọ ndọrọ ọchịchị nke na-edu', - completion: 'Pasent nke mmejuputa', - zone: 'Mpaghara ndọrọ ndọrọ ọchịchị', - pu_markers: 'Ihe ngosi PU', - box_select: 'Nhọrọ igbe', - export_csv: 'Zipụta CSV', - export_geojson: 'Zipụta GeoJSON', - selection: 'Nhọrọ', - search_places: 'Chọọ ebe...' + completion: 'Pasent nke mmejuputa', zone: 'Mpaghara ndọrọ ndọrọ ọchịchị', + pu_markers: 'Ihe ngosi PU', box_select: 'Nhọrọ igbe', + export_csv: 'Zipụta CSV', export_geojson: 'Zipụta GeoJSON', + selection: 'Nhọrọ', search_places: 'Chọọ ebe...', + anomaly_detection: 'Nchọpụta Nsogbu AI', anomaly_desc: 'Nyocha nsonaazụ ntuli aka site na AI', + integrity_score: 'Akara Eziokwu', total_anomalies: 'Mkpokọta Nsogbu', + ai_methods: 'Ụzọ AI', benford_status: 'Ule Benford', + overview: 'Nchịkọta', benford_analysis: 'Nyocha Benford', anomaly_list: 'Ndepụta Nsogbu', + severity_distribution: 'Nkesa Njọ', integrity_breakdown: 'Nkọwa Eziokwu', + benford_first_digit: 'Nkesa Nọmba Mbụ Benford', digit: 'Nọmba', + observed: 'Ahụrụ %', expected_benford: 'A na-atụ anya', sample_size: 'Ọnụ ọgụgụ sample', + filter_severity: 'Hazie site na njọ', all: 'Niile', + polling_unit: 'Ebe ntuli aka', type: 'Udi', severity: 'Njọ', description: 'Nkọwa', + no_anomalies: 'Enweghị nsogbu achọpụtara', no_data: 'Enweghị data', refresh: 'Mee ọhụrụ', + sms_verification: 'Nyocha SMS/USSD', sms_desc: 'Nyochaa nsonaazụ ntuli aka site na SMS ma ọ bụ USSD', + sms_channel: 'Ụzọ SMS', ussd_channel: 'Ụzọ USSD', + text_verify: 'Dee ka ịnyochaa', no_internet: 'Enweghị Internet dị mkpa', works_offline: 'Na-arụ ọrụ na-enweghị Internet', + sms_verify: 'Nyochaa site na SMS', ussd_simulator: 'Ngwa USSD', statistics: 'Ọnụ ọgụgụ', user_guide: 'Ntuziaka', + phone_number: 'Nọmba ekwentị', polling_unit_code: 'Koodu ebe ntuli aka', + verify_result: 'Nyochaa nsonaazụ', error: 'Njehie', result_found: 'Achọpụtara nsonaazụ', + public_api: 'API Ọha', public_api_desc: 'API nwere igodo na oke maka nyocha', + api_version: 'Ụdị API', rate_limit: 'Oke arịrịọ', active_keys: 'Igodo na-arụ ọrụ', + api_docs: 'Akwụkwọ API', api_keys: 'Igodo API', usage: 'Ojiji', examples: 'Ihe atụ', + generate_key: 'Mepụta igodo', owner: 'Onye nwe ya', name: 'Aha', + active: 'Na-arụ ọrụ', inactive: 'Anaghị arụ ọrụ', } }; diff --git a/inec-frontend/src/main.tsx b/inec-frontend/src/main.tsx index bef5202a..455e7080 100644 --- a/inec-frontend/src/main.tsx +++ b/inec-frontend/src/main.tsx @@ -8,3 +8,9 @@ createRoot(document.getElementById('root')!).render( , ) + +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('/sw.js').catch(() => {}); + }); +} diff --git a/inec-frontend/src/pages/AnomalyDetectionPage.tsx b/inec-frontend/src/pages/AnomalyDetectionPage.tsx new file mode 100644 index 00000000..7e311751 --- /dev/null +++ b/inec-frontend/src/pages/AnomalyDetectionPage.tsx @@ -0,0 +1,316 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { useI18n } from '@/lib/i18n'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Progress } from '@/components/ui/progress'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'; +import { AlertTriangle, ShieldCheck, Activity, Brain, RefreshCw } from 'lucide-react'; + +interface Anomaly { + polling_unit_code: string; + anomaly_type: string; + severity: string; + detail: string; + score: number; + pu_name?: string; +} + +interface IntegrityData { + integrity_score: number; + grade: string; + breakdown: Record; + methods_used: string[]; +} + +interface BenfordData { + test: string; + chi_square: number; + status: string; + sample_size: number; + distribution: { digit: number; observed_pct: number; expected_pct: number; deviation: number }[]; +} + +const SEVERITY_COLORS: Record = { + critical: 'bg-red-100 text-red-800 border-red-200', + high: 'bg-orange-100 text-orange-800 border-orange-200', + medium: 'bg-yellow-100 text-yellow-800 border-yellow-200', + low: 'bg-blue-100 text-blue-800 border-blue-200', +}; + +const PIE_COLORS = ['#ef4444', '#f97316', '#eab308', '#3b82f6']; + +export default function AnomalyDetectionPage() { + const { t } = useI18n(); + const [anomalies, setAnomalies] = useState([]); + const [integrity, setIntegrity] = useState(null); + const [benford, setBenford] = useState(null); + const [methods, setMethods] = useState<{name: string; description: string}[]>([]); + const [loading, setLoading] = useState(true); + const [severityFilter, setSeverityFilter] = useState(''); + const [summary, setSummary] = useState>({}); + + const loadData = async () => { + setLoading(true); + try { + const [anomalyRes, integrityRes, benfordRes, methodsRes] = await Promise.all([ + api.getAIAnomalies(1, severityFilter || undefined).catch(() => ({ anomalies: [], summary: {} })), + api.getAIIntegrity(1).catch(() => null), + api.getAIBenford(1).catch(() => null), + api.getAIMethods().catch(() => ({ methods: [] })), + ]); + setAnomalies(anomalyRes?.anomalies || []); + setSummary(anomalyRes?.summary || {}); + setIntegrity(integrityRes); + setBenford(benfordRes); + setMethods(methodsRes?.methods || []); + } catch { + // fallback + } + setLoading(false); + }; + + useEffect(() => { loadData(); }, [severityFilter]); + + const gradeColor = (grade: string) => { + if (grade === 'A' || grade === 'B') return 'text-green-700'; + if (grade === 'C') return 'text-yellow-600'; + return 'text-red-600'; + }; + + const severityPieData = Object.entries(summary).map(([name, value]) => ({ name, value })); + + return ( +
+
+
+

{t('anomaly_detection')}

+

{t('anomaly_desc')}

+
+ +
+ +
+ + +
+
+ +
+
+

{t('integrity_score')}

+
+ {integrity?.integrity_score ?? '—'} + {integrity?.grade && ( + + {integrity.grade} + + )} +
+ {integrity && } +
+
+
+
+ + + +
+
+ +
+
+

{t('total_anomalies')}

+ {anomalies.length} +
+
+
+
+ + + +
+
+ +
+
+

{t('ai_methods')}

+ {methods.length} +
+
+
+
+ + + +
+
+ +
+
+

{t('benford_status')}

+ {benford?.status ?? '—'} +
+
+
+
+
+ + + + {t('overview')} + {t('benford_analysis')} + {t('anomaly_list')} + {t('ai_methods')} + + + +
+ + {t('severity_distribution')} + + {severityPieData.length > 0 ? ( + + + `${name}: ${value}`}> + {severityPieData.map((_, i) => ( + + ))} + + + + + + ) : ( +

{t('no_data')}

+ )} +
+
+ + + {t('integrity_breakdown')} + + {integrity?.breakdown ? ( +
+ {Object.entries(integrity.breakdown).map(([key, val]) => ( +
+ {key.replace(/_/g, ' ')} + {String(val)} +
+ ))} +
+ ) : ( +

{t('no_data')}

+ )} +
+
+
+
+ + + + + {t('benford_first_digit')} + + + {benford?.distribution ? ( + + + + + + + + + + + + ) : ( +

{t('no_data')}

+ )} + {benford && ( +
+ Chi-Square: {benford.chi_square} + {t('sample_size')}: {benford.sample_size} + + {benford.status.toUpperCase()} + +
+ )} +
+
+
+ + +
+ {['', 'critical', 'high', 'medium', 'low'].map((s) => ( + + ))} +
+ + +
+ + + + + + + + + + + {anomalies.length === 0 ? ( + + ) : anomalies.map((a, i) => ( + + + + + + + ))} + +
{t('polling_unit')}{t('type')}{t('severity')}{t('description')}
{t('no_anomalies')}
{a.polling_unit_code}{(a.anomaly_type || '').replace(/_/g, ' ')} + {a.severity} + {a.detail}
+
+
+
+
+ + +
+ {[ + { name: "Benford's Law", desc: t('benford_method_desc'), icon: '📊' }, + { name: 'Z-Score Outliers', desc: t('zscore_method_desc'), icon: '📈' }, + { name: 'IQR Outliers', desc: t('iqr_method_desc'), icon: '📉' }, + { name: 'Party Dominance', desc: t('dominance_method_desc'), icon: '🏛️' }, + { name: 'Round Number Bias', desc: t('round_number_method_desc'), icon: '🔢' }, + { name: 'Sequential Patterns', desc: t('sequential_method_desc'), icon: '🔗' }, + ].map((m) => ( + + +
+ +
+

{m.name}

+

{m.desc}

+
+
+
+
+ ))} +
+
+
+
+ ); +} diff --git a/inec-frontend/src/pages/PublicAPIPage.tsx b/inec-frontend/src/pages/PublicAPIPage.tsx new file mode 100644 index 00000000..aa53709c --- /dev/null +++ b/inec-frontend/src/pages/PublicAPIPage.tsx @@ -0,0 +1,334 @@ +import { useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { useI18n } from '@/lib/i18n'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Key, Copy, BookOpen, Plus, Shield } from 'lucide-react'; + +interface APIKey { + id: number; + name: string; + owner: string; + permissions: string; + rate_limit: number; + is_active: boolean; + created_at: string; + last_used_at?: string; +} + +const ENDPOINTS = [ + { method: 'GET', path: '/api/v1/elections', desc: 'List all elections', auth: true }, + { method: 'GET', path: '/api/v1/results', desc: 'List results with pagination & filtering', auth: true }, + { method: 'GET', path: '/api/v1/results/{id}', desc: 'Get detailed result by ID', auth: true }, + { method: 'GET', path: '/api/v1/states', desc: 'List all states with geo zones', auth: true }, + { method: 'GET', path: '/api/v1/polling-units', desc: 'List polling units with filtering', auth: true }, + { method: 'GET', path: '/api/v1/collation', desc: 'Get collation data by level', auth: true }, + { method: 'GET', path: '/api/v1/ai/anomalies', desc: 'AI-detected anomalies', auth: true }, + { method: 'GET', path: '/api/v1/ai/integrity', desc: 'Election integrity score', auth: true }, + { method: 'GET', path: '/api/v1/docs', desc: 'OpenAPI 3.0 specification', auth: false }, + { method: 'POST', path: '/api/v1/keys', desc: 'Generate new API key', auth: false }, + { method: 'GET', path: '/api/v1/keys', desc: 'List API keys', auth: false }, + { method: 'GET', path: '/api/v1/usage', desc: 'API usage statistics', auth: false }, +]; + +export default function PublicAPIPage() { + const { t } = useI18n(); + const [keys, setKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyOwner, setNewKeyOwner] = useState(''); + const [generatedKey, setGeneratedKey] = useState(''); + const [loading, setLoading] = useState(false); + const [usage, setUsage] = useState | null>(null); + const [copied, setCopied] = useState(''); + + const loadKeys = async () => { + try { + const res = await api.getAPIKeys(); + setKeys(res.data || res.keys || []); + } catch { /* empty */ } + }; + + const loadUsage = async () => { + try { + const res = await api.getAPIUsage(); + setUsage(res); + } catch { /* empty */ } + }; + + useEffect(() => { loadKeys(); }, []); + + const handleGenerate = async () => { + if (!newKeyName || !newKeyOwner) return; + setLoading(true); + try { + const res = await api.generateAPIKey(newKeyName, newKeyOwner); + setGeneratedKey(res.api_key || res.key || ''); + setNewKeyName(''); + setNewKeyOwner(''); + loadKeys(); + } catch { /* empty */ } + setLoading(false); + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text); + setCopied(label); + setTimeout(() => setCopied(''), 2000); + }; + + const methodColor = (m: string) => { + if (m === 'GET') return 'bg-blue-100 text-blue-800'; + if (m === 'POST') return 'bg-green-100 text-green-800'; + if (m === 'PUT') return 'bg-yellow-100 text-yellow-800'; + return 'bg-zinc-100 text-zinc-800'; + }; + + return ( +
+
+

{t('public_api')}

+

{t('public_api_desc')}

+
+ +
+ + +
+
+ +
+
+

{t('api_version')}

+

v1.0

+
+
+
+
+ + +
+
+ +
+
+

{t('rate_limit')}

+

100 {t('req_per_min')}

+
+
+
+
+ + +
+
+ +
+
+

{t('active_keys')}

+

{keys.filter(k => k.is_active).length}

+
+
+
+
+
+ + + + {t('api_docs')} + {t('api_keys')} + {t('usage')} + {t('examples')} + + + + + {t('api_endpoints')} + +
+ {ENDPOINTS.map((ep, i) => ( +
+ + {ep.method} + + {ep.path} + {ep.desc} + {ep.auth && {t('auth_required')}} + + {copied === ep.path && {t('copied')}} +
+ ))} +
+
+
+ + + {t('authentication')} + +

{t('auth_desc')}

+
+

{`curl -H "X-API-Key: YOUR_KEY" \\`}

+

{` ${window.location.origin}/api/v1/results`}

+
+

{t('auth_query_param')}

+
+

{`${window.location.origin}/api/v1/results?api_key=YOUR_KEY`}

+
+
+
+
+ + + + {t('generate_key')} + +
+
+ + setNewKeyName(e.target.value)} placeholder="My App" className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500" /> +
+
+ + setNewKeyOwner(e.target.value)} placeholder="organization@example.com" className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500" /> +
+
+ + {generatedKey && ( +
+

{t('key_generated')}

+
+ {generatedKey} + +
+

{t('key_warning')}

+
+ )} +
+
+ + + {t('existing_keys')} + +
+ + + + + + + + + + + + + {keys.length === 0 ? ( + + ) : keys.map((k) => ( + + + + + + + + + ))} + +
ID{t('name')}{t('owner')}{t('permissions')}{t('rate_limit')}{t('status')}
{t('no_keys')}
{k.id}{k.name}{k.owner}{k.permissions}{k.rate_limit}/min + + {k.is_active ? t('active') : t('inactive')} + +
+
+
+
+
+ + + + {t('api_usage')} + + {usage ? ( +
+ {Object.entries(usage).map(([k, v]) => ( +
+

{String(v)}

+

{k.replace(/_/g, ' ')}

+
+ ))} +
+ ) : ( +

{t('click_tab_load')}

+ )} +
+
+
+ + + + {t('code_examples')} + +
+

Python

+
+{`import requests
+
+API_KEY = "your_api_key_here"
+BASE = "${window.location.origin}/api/v1"
+headers = {"X-API-Key": API_KEY}
+
+# Get election results
+results = requests.get(f"{BASE}/results", headers=headers)
+print(results.json())
+
+# Get AI anomalies
+anomalies = requests.get(f"{BASE}/ai/anomalies?election_id=1", headers=headers)
+print(anomalies.json())
+
+# Get integrity score
+integrity = requests.get(f"{BASE}/ai/integrity?election_id=1", headers=headers)
+print(integrity.json())`}
+                
+
+
+

JavaScript / Node.js

+
+{`const API_KEY = "your_api_key_here";
+const BASE = "${window.location.origin}/api/v1";
+
+const res = await fetch(\`\${BASE}/results\`, {
+  headers: { "X-API-Key": API_KEY }
+});
+const data = await res.json();
+console.log(data);`}
+                
+
+
+

cURL

+
+{`# List results
+curl -H "X-API-Key: YOUR_KEY" ${window.location.origin}/api/v1/results
+
+# Get anomalies
+curl -H "X-API-Key: YOUR_KEY" ${window.location.origin}/api/v1/ai/anomalies?election_id=1
+
+# Get OpenAPI spec
+curl ${window.location.origin}/api/v1/docs`}
+                
+
+
+
+
+
+
+ ); +} diff --git a/inec-frontend/src/pages/SMSVerificationPage.tsx b/inec-frontend/src/pages/SMSVerificationPage.tsx new file mode 100644 index 00000000..a030db8f --- /dev/null +++ b/inec-frontend/src/pages/SMSVerificationPage.tsx @@ -0,0 +1,270 @@ +import { useState } from 'react'; +import { api } from '@/lib/api'; +import { useI18n } from '@/lib/i18n'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { MessageSquare, Phone, BarChart3, Send, CheckCircle2 } from 'lucide-react'; + +interface SMSResult { + status?: string; + message?: string; + response?: string; + channel?: string; + polling_unit?: string; + results?: Record; +} + +interface USSDSession { + response: string; + session_active: boolean; +} + +export default function SMSVerificationPage() { + const { t } = useI18n(); + const [phone, setPhone] = useState(''); + const [puCode, setPuCode] = useState(''); + const [smsResult, setSmsResult] = useState(null); + const [smsLoading, setSmsLoading] = useState(false); + + const [ussdPhone] = useState(''); + const [ussdText, setUssdText] = useState(''); + const [, setUssdSession] = useState(null); + const [ussdHistory, setUssdHistory] = useState([]); + const [ussdLoading, setUssdLoading] = useState(false); + const [sessionId] = useState(() => `sess_${Date.now()}`); + + const [stats, setStats] = useState | null>(null); + + const handleSMSVerify = async () => { + if (!phone || !puCode) return; + setSmsLoading(true); + setSmsResult(null); + try { + const res = await api.smsVerify(phone, puCode); + setSmsResult(res); + } catch (e: unknown) { + setSmsResult({ status: 'error', message: e instanceof Error ? e.message : 'Request failed' }); + } + setSmsLoading(false); + }; + + const handleUSSDSend = async () => { + setUssdLoading(true); + try { + const res = await api.ussdGateway(sessionId, ussdPhone || '+2348000000000', ussdText); + setUssdSession(res); + setUssdHistory(prev => [...prev, `> ${ussdText || '(start)'}`, res.response || res.message || '']); + setUssdText(''); + } catch (e: unknown) { + setUssdHistory(prev => [...prev, `Error: ${e instanceof Error ? e.message : 'Failed'}`]); + } + setUssdLoading(false); + }; + + const loadStats = async () => { + try { + const res = await api.getSMSStats(); + setStats(res); + } catch { + setStats({ error: 'Could not load stats' }); + } + }; + + return ( +
+
+

{t('sms_verification')}

+

{t('sms_desc')}

+
+ +
+ + +
+
+ +
+
+

{t('sms_channel')}

+

{t('text_verify')}

+
+
+
+
+ + +
+
+ +
+
+

{t('ussd_channel')}

+

*347*123#

+
+
+
+
+ + +
+
+ +
+
+

{t('no_internet')}

+

{t('works_offline')}

+
+
+
+
+
+ + + + {t('sms_verify')} + {t('ussd_simulator')} + {t('statistics')} + {t('user_guide')} + + + + + {t('sms_result_verify')} + +
+
+ + setPhone(e.target.value)} + placeholder="+234..." + className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500" + aria-describedby="phone-hint" + /> +

{t('phone_hint')}

+
+
+ + setPuCode(e.target.value)} + placeholder="FC/01/001/001" + className="w-full border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-green-500 focus:border-green-500" + aria-describedby="pu-hint" + /> +

{t('pu_code_hint')}

+
+
+ + + {smsResult && ( +
+
+ + {smsResult.status === 'error' ? t('error') : t('result_found')} +
+

{smsResult.response || smsResult.message}

+ {smsResult.results && ( +
{JSON.stringify(smsResult.results, null, 2)}
+ )} +
+ )} +
+
+
+ + + + {t('ussd_simulator')} + +
+
+
USSD *347*123#
+
+ {ussdHistory.length === 0 ? ( +

{t('ussd_start_hint')}

+ ) : ussdHistory.map((line, i) => ( +

') ? 'text-green-400' : 'text-white'}>{line}

+ ))} +
+
+ setUssdText(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleUSSDSend()} + placeholder={t('enter_option')} + className="flex-1 bg-zinc-700 border-zinc-600 rounded px-3 py-2 text-sm text-white placeholder-zinc-400 focus:ring-2 focus:ring-green-500" + aria-label={t('ussd_input')} + /> + +
+
+

{t('ussd_instructions')}

+
+
+
+
+ + + + {t('sms_ussd_stats')} + + {stats ? ( +
+ {Object.entries(stats).map(([k, v]) => ( +
+

{String(v)}

+

{k.replace(/_/g, ' ')}

+
+ ))} +
+ ) : ( +

{t('click_tab_load')}

+ )} +
+
+
+ + + + {t('how_to_use')} + +
+

+ {t('sms_guide_title')} +

+
    +
  1. {t('sms_step_1')}
  2. +
  3. {t('sms_step_2')}
  4. +
  5. {t('sms_step_3')}
  6. +
+
+
+

+ {t('ussd_guide_title')} +

+
    +
  1. {t('ussd_step_1')}
  2. +
  3. {t('ussd_step_2')}
  4. +
  5. {t('ussd_step_3')}
  6. +
  7. {t('ussd_step_4')}
  8. +
+
+
+
+
+
+
+ ); +} diff --git a/inec-go-backend/ai_proxy.go b/inec-go-backend/ai_proxy.go new file mode 100644 index 00000000..83dfac95 --- /dev/null +++ b/inec-go-backend/ai_proxy.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" +) + +var aiServiceURL string + +func initAIProxy() { + aiServiceURL = os.Getenv("AI_SERVICE_URL") + if aiServiceURL == "" { + aiServiceURL = "http://127.0.0.1:8090" + } +} + +func proxyToAI(w http.ResponseWriter, r *http.Request, path string) { + client := &http.Client{Timeout: 30 * time.Second} + url := aiServiceURL + path + if r.URL.RawQuery != "" { + url += "?" + r.URL.RawQuery + } + resp, err := client.Get(url) + if err != nil { + writeJSON(w, 200, M{"error": "AI service unavailable", "fallback": true}) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + w.Write(body) +} + +func handleAIAnomalies(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + severity := queryParam(r, "severity", "") + path := fmt.Sprintf("/analytics/%d/anomalies", electionID) + if severity != "" { + path += "?severity=" + severity + } + proxyToAI(w, r, path) +} + +func handleAIBenford(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + proxyToAI(w, r, fmt.Sprintf("/analytics/%d/benford", electionID)) +} + +func handleAIIntegrity(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + proxyToAI(w, r, fmt.Sprintf("/analytics/%d/integrity_score", electionID)) +} + +func handleAIMethods(w http.ResponseWriter, r *http.Request) { + proxyToAI(w, r, "/ai/methods") +} + +func handleAIFallbackAnomalies(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + + rows, err := db.Query(`SELECT r.polling_unit_code, pu.name, pu.registered_voters, + COALESCE(SUM(rv.votes),0) as total_votes, r.rejected_votes + FROM results r + JOIN polling_units pu ON r.polling_unit_code=pu.code + LEFT JOIN result_votes rv ON rv.result_id=r.id + WHERE r.election_id=? + GROUP BY r.id`, electionID) + if err != nil { + writeJSON(w, 200, M{"anomalies": []M{}, "summary": M{"total_analyzed": 0}, "fallback": true}) + return + } + defer rows.Close() + + type puData struct { + code, name string + registered, votes, rejected int + turnout float64 + } + var data []puData + for rows.Next() { + var d puData + rows.Scan(&d.code, &d.name, &d.registered, &d.votes, &d.rejected) + if d.registered > 0 { + d.turnout = float64(d.votes+d.rejected) / float64(d.registered) * 100 + } + data = append(data, d) + } + + anomalies := []M{} + for _, d := range data { + if d.registered > 0 && d.votes > d.registered { + anomalies = append(anomalies, M{ + "polling_unit_code": d.code, "pu_name": d.name, + "anomaly_type": "overvoting", "severity": "critical", + "score": d.turnout, "total_votes": d.votes, + "registered_voters": d.registered, + }) + } + } + + writeJSON(w, 200, M{ + "anomalies": anomalies, "total_analyzed": len(data), + "total_anomalies": len(anomalies), "fallback": true, + "summary": M{"critical": len(anomalies)}, + }) +} + +func handleAIProxy(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + severity := queryParam(r, "severity", "") + path := fmt.Sprintf("/analytics/%d/anomalies", electionID) + if severity != "" { + path += "?severity=" + severity + } + + client := &http.Client{Timeout: 10 * time.Second} + url := aiServiceURL + path + resp, err := client.Get(url) + if err != nil { + handleAIFallbackAnomalies(w, r) + return + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + var result M + if json.Unmarshal(body, &result) != nil { + handleAIFallbackAnomalies(w, r) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + w.Write(body) +} diff --git a/inec-go-backend/db.go b/inec-go-backend/db.go index eec1e6f5..0c677c46 100644 --- a/inec-go-backend/db.go +++ b/inec-go-backend/db.go @@ -178,4 +178,6 @@ func initDB(db *sql.DB) { db.Exec(schema) initBVASTables(db) initIngestionTables(db) + initSMSUSSDTables(db) + initPublicAPITables(db) } diff --git a/inec-go-backend/main.go b/inec-go-backend/main.go index e0957e50..6fb62c8e 100644 --- a/inec-go-backend/main.go +++ b/inec-go-backend/main.go @@ -52,6 +52,7 @@ func main() { initDB(db) seedDatabase(db) seedBVASDevices(db) + initAIProxy() mwHub = initMiddlewareHub() @@ -139,6 +140,31 @@ func main() { r.HandleFunc("/ingestion/dead-letter/{id}/reprocess", handleReprocessDLQ).Methods("POST") r.HandleFunc("/ingestion/offline-queue", handleOfflineSyncQueue).Methods("GET") + // SMS/USSD Gateway + r.HandleFunc("/sms/verify", handleSMSVerify).Methods("POST") + r.HandleFunc("/sms/stats", handleSMSStats).Methods("GET") + r.HandleFunc("/ussd/gateway", handleUSSDGateway).Methods("POST") + + // AI Analytics (proxy to Python service) + r.HandleFunc("/ai/anomalies", handleAIAnomalies).Methods("GET") + r.HandleFunc("/ai/benford", handleAIBenford).Methods("GET") + r.HandleFunc("/ai/integrity", handleAIIntegrity).Methods("GET") + r.HandleFunc("/ai/methods", handleAIMethods).Methods("GET") + + // Public API v1 (API key authenticated) + r.HandleFunc("/api/v1/docs", handlePublicAPIDocs).Methods("GET") + r.HandleFunc("/api/v1/docs.json", handlePublicAPIDocs).Methods("GET") + r.HandleFunc("/api/v1/keys", handlePublicAPIKeys).Methods("GET", "POST") + r.HandleFunc("/api/v1/usage", handlePublicAPIUsage).Methods("GET") + r.HandleFunc("/api/v1/elections", apiKeyAuth(handlePublicAPIElections)).Methods("GET") + r.HandleFunc("/api/v1/results", apiKeyAuth(handlePublicAPIResults)).Methods("GET") + r.HandleFunc("/api/v1/results/{id:[0-9]+}", apiKeyAuth(handlePublicAPIResultDetail)).Methods("GET") + r.HandleFunc("/api/v1/states", apiKeyAuth(handlePublicAPIStates)).Methods("GET") + r.HandleFunc("/api/v1/polling-units", apiKeyAuth(handlePublicAPIPollingUnits)).Methods("GET") + r.HandleFunc("/api/v1/collation", apiKeyAuth(handlePublicAPICollation)).Methods("GET") + r.HandleFunc("/api/v1/ai/anomalies", apiKeyAuth(handleAIAnomalies)).Methods("GET") + r.HandleFunc("/api/v1/ai/integrity", apiKeyAuth(handleAIIntegrity)).Methods("GET") + // Middleware status & management r.HandleFunc("/middleware/status", handleMiddlewareStatus).Methods("GET") r.HandleFunc("/middleware/health", handleMiddlewareHealth).Methods("GET") diff --git a/inec-go-backend/public_api.go b/inec-go-backend/public_api.go new file mode 100644 index 00000000..74fe4395 --- /dev/null +++ b/inec-go-backend/public_api.go @@ -0,0 +1,459 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/mux" +) + +func initPublicAPITables(database *sql.DB) { + database.Exec(`CREATE TABLE IF NOT EXISTS api_keys ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key_hash TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + owner TEXT NOT NULL, + permissions TEXT NOT NULL DEFAULT 'read', + rate_limit INTEGER NOT NULL DEFAULT 100, + is_active INTEGER NOT NULL DEFAULT 1, + last_used_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`) + database.Exec(`CREATE TABLE IF NOT EXISTS api_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id INTEGER, + endpoint TEXT NOT NULL, + method TEXT NOT NULL, + status_code INTEGER, + response_ms REAL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`) + database.Exec(`CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)`) + database.Exec(`CREATE INDEX IF NOT EXISTS idx_api_usage_key ON api_usage(api_key_id)`) + + var count int + database.QueryRow("SELECT COUNT(*) FROM api_keys").Scan(&count) + if count == 0 { + b := make([]byte, 32) + rand.Read(b) + demoKey := "inec_" + hex.EncodeToString(b[:16]) + database.Exec(`INSERT INTO api_keys (key_hash, name, owner, permissions, rate_limit) + VALUES (?, 'Demo API Key', 'system', 'read', 1000)`, demoKey) + } +} + +func apiKeyAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-API-Key") + if key == "" { + key = r.URL.Query().Get("api_key") + } + if key == "" { + writeJSON(w, 401, M{"error": "API key required. Pass X-API-Key header or api_key query param."}) + return + } + + var keyID int + var name, permissions string + var rateLimit int + var isActive int + err := db.QueryRow("SELECT id, name, permissions, rate_limit, is_active FROM api_keys WHERE key_hash=?", key). + Scan(&keyID, &name, &permissions, &rateLimit, &isActive) + if err != nil || isActive == 0 { + writeJSON(w, 403, M{"error": "Invalid or inactive API key"}) + return + } + + if !rateLimiter.allow(fmt.Sprintf("apikey:%d", keyID), rateLimit, time.Minute) { + writeJSON(w, 429, M{"error": "Rate limit exceeded", "limit": rateLimit, "window": "1 minute"}) + return + } + + db.Exec("UPDATE api_keys SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", keyID) + + start := time.Now() + next.ServeHTTP(w, r) + elapsed := time.Since(start).Seconds() * 1000 + db.Exec(`INSERT INTO api_usage (api_key_id, endpoint, method, status_code, response_ms) + VALUES (?,?,?,200,?)`, keyID, r.URL.Path, r.Method, elapsed) + } +} + +func handlePublicAPIElections(w http.ResponseWriter, r *http.Request) { + rows, err := db.Query(`SELECT id, title, election_type, election_date, status, created_at FROM elections ORDER BY election_date DESC`) + if err != nil { + writeError(w, 500, "query failed") + return + } + defer rows.Close() + elections := []M{} + for rows.Next() { + var id int + var name, etype, date, status, created string + rows.Scan(&id, &name, &etype, &date, &status, &created) + elections = append(elections, M{"id": id, "name": name, "type": etype, "date": date, "status": status, "created_at": created}) + } + writeJSON(w, 200, M{"data": elections, "count": len(elections), "api_version": "v1"}) +} + +func handlePublicAPIResults(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + stateCode := queryParam(r, "state_code", "") + status := queryParam(r, "status", "") + limit := queryParamInt(r, "limit", 50) + offset := queryParamInt(r, "offset", 0) + + query := `SELECT r.id, r.election_id, r.polling_unit_code, pu.name as pu_name, + l.state_code, w.lga_code, + r.total_votes_cast, r.total_valid_votes, r.rejected_votes, + r.accredited_voters, r.status, r.submitted_at + FROM results r + JOIN polling_units pu ON r.polling_unit_code=pu.code + JOIN wards w ON pu.ward_code=w.code + JOIN lgas l ON w.lga_code=l.code + WHERE r.election_id=?` + args := []interface{}{electionID} + + if stateCode != "" { + query += " AND l.state_code=?" + args = append(args, stateCode) + } + if status != "" { + query += " AND r.status=?" + args = append(args, status) + } + query += " ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + rows, err := db.Query(query, args...) + if err != nil { + writeError(w, 500, "query failed") + return + } + defer rows.Close() + + results := []M{} + for rows.Next() { + var id, eid, totalCast, totalValid, rejected, accredited int + var puCode, puName, sc, lc, st, submitted string + rows.Scan(&id, &eid, &puCode, &puName, &sc, &lc, &totalCast, &totalValid, &rejected, &accredited, &st, &submitted) + results = append(results, M{ + "id": id, "election_id": eid, "polling_unit_code": puCode, + "polling_unit_name": puName, "state_code": sc, "lga_code": lc, + "total_votes_cast": totalCast, "total_valid_votes": totalValid, + "rejected_votes": rejected, "accredited_voters": accredited, + "status": st, "submitted_at": submitted, + }) + } + + var total int + db.QueryRow("SELECT COUNT(*) FROM results WHERE election_id=?", electionID).Scan(&total) + + writeJSON(w, 200, M{ + "data": results, "count": len(results), "total": total, + "limit": limit, "offset": offset, "api_version": "v1", + }) +} + +func handlePublicAPIResultDetail(w http.ResponseWriter, r *http.Request) { + id := muxVarInt(r, "id") + var rid, eid, totalCast, totalValid, rejected, accredited int + var puCode, st, submitted, tbStatus, hlStatus string + err := db.QueryRow(`SELECT id, election_id, polling_unit_code, total_votes_cast, + total_valid_votes, rejected_votes, accredited_voters, status, submitted_at, + tigerbeetle_status, hyperledger_status + FROM results WHERE id=?`, id).Scan( + &rid, &eid, &puCode, &totalCast, &totalValid, &rejected, &accredited, + &st, &submitted, &tbStatus, &hlStatus) + if err != nil { + writeError(w, 404, "result not found") + return + } + + voteRows, _ := db.Query(`SELECT p.abbreviation, rps.votes FROM result_party_scores rps + JOIN parties p ON rps.party_code=p.code WHERE rps.result_id=? ORDER BY rps.votes DESC`, id) + defer voteRows.Close() + votes := []M{} + for voteRows.Next() { + var party string + var v int + voteRows.Scan(&party, &v) + votes = append(votes, M{"party": party, "votes": v}) + } + + writeJSON(w, 200, M{ + "data": M{ + "id": rid, "election_id": eid, "polling_unit_code": puCode, + "total_votes_cast": totalCast, "total_valid_votes": totalValid, + "rejected_votes": rejected, "accredited_voters": accredited, + "status": st, "submitted_at": submitted, + "tigerbeetle_status": tbStatus, "hyperledger_status": hlStatus, + "party_votes": votes, + }, + "api_version": "v1", + }) +} + +func handlePublicAPIStates(w http.ResponseWriter, r *http.Request) { + rows, _ := db.Query("SELECT code, name, geo_zone, capital FROM states ORDER BY name") + defer rows.Close() + states := []M{} + for rows.Next() { + var code, name, zone, capital string + rows.Scan(&code, &name, &zone, &capital) + states = append(states, M{"code": code, "name": name, "geo_zone": zone, "capital": capital}) + } + writeJSON(w, 200, M{"data": states, "count": len(states), "api_version": "v1"}) +} + +func handlePublicAPIPollingUnits(w http.ResponseWriter, r *http.Request) { + stateCode := queryParam(r, "state_code", "") + lgaCode := queryParam(r, "lga_code", "") + limit := queryParamInt(r, "limit", 100) + offset := queryParamInt(r, "offset", 0) + + query := `SELECT pu.code, pu.name, l.state_code, w.lga_code, pu.ward_code, pu.registered_voters, pu.latitude, pu.longitude + FROM polling_units pu + JOIN wards w ON pu.ward_code=w.code + JOIN lgas l ON w.lga_code=l.code + WHERE 1=1` + args := []interface{}{} + if stateCode != "" { + query += " AND l.state_code=?" + args = append(args, stateCode) + } + if lgaCode != "" { + query += " AND w.lga_code=?" + args = append(args, lgaCode) + } + query += " ORDER BY pu.code LIMIT ? OFFSET ?" + args = append(args, limit, offset) + + rows, _ := db.Query(query, args...) + defer rows.Close() + pus := []M{} + for rows.Next() { + var code, name, sc, lc, wc string + var reg int + var lat, lng float64 + rows.Scan(&code, &name, &sc, &lc, &wc, ®, &lat, &lng) + pus = append(pus, M{"code": code, "name": name, "state_code": sc, "lga_code": lc, + "ward_code": wc, "registered_voters": reg, "latitude": lat, "longitude": lng}) + } + writeJSON(w, 200, M{"data": pus, "count": len(pus), "limit": limit, "offset": offset, "api_version": "v1"}) +} + +func handlePublicAPICollation(w http.ResponseWriter, r *http.Request) { + electionID := queryParamInt(r, "election_id", 1) + level := queryParam(r, "level", "national") + parentCode := queryParam(r, "parent_code", "") + + var query string + var args []interface{} + + switch level { + case "state": + query = `SELECT l.state_code as code, s.name, + COUNT(DISTINCT r.id) as results_count, + COALESCE(SUM(r.total_valid_votes),0) as total_votes + FROM results r + JOIN polling_units pu ON r.polling_unit_code=pu.code + JOIN wards w ON pu.ward_code=w.code + JOIN lgas l ON w.lga_code=l.code + JOIN states s ON l.state_code=s.code + WHERE r.election_id=? GROUP BY l.state_code ORDER BY total_votes DESC` + args = []interface{}{electionID} + default: + query = `SELECT p.abbreviation as party, p.color, + SUM(rps.votes) as total_votes + FROM result_party_scores rps + JOIN results r ON rps.result_id=r.id + JOIN parties p ON rps.party_code=p.code + WHERE r.election_id=?` + args = []interface{}{electionID} + if parentCode != "" { + query += ` AND r.polling_unit_code IN (SELECT pu.code FROM polling_units pu + JOIN wards w ON pu.ward_code=w.code + JOIN lgas l ON w.lga_code=l.code WHERE l.state_code=?)` + args = append(args, parentCode) + } + query += " GROUP BY p.code ORDER BY total_votes DESC" + } + + rows, _ := db.Query(query, args...) + defer rows.Close() + data := []M{} + cols, _ := rows.Columns() + for rows.Next() { + vals := make([]interface{}, len(cols)) + ptrs := make([]interface{}, len(cols)) + for i := range vals { + ptrs[i] = &vals[i] + } + rows.Scan(ptrs...) + row := M{} + for i, col := range cols { + row[col] = vals[i] + } + data = append(data, row) + } + + writeJSON(w, 200, M{"data": data, "level": level, "election_id": electionID, "api_version": "v1"}) +} + +func handlePublicAPIKeys(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + var req struct { + Name string `json:"name"` + Owner string `json:"owner"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.Name == "" || req.Owner == "" { + writeError(w, 400, "name and owner required") + return + } + b := make([]byte, 32) + rand.Read(b) + key := "inec_" + hex.EncodeToString(b[:16]) + db.Exec(`INSERT INTO api_keys (key_hash, name, owner, permissions, rate_limit) + VALUES (?, ?, ?, 'read', 100)`, key, req.Name, req.Owner) + writeJSON(w, 201, M{"api_key": key, "name": req.Name, "owner": req.Owner, + "permissions": "read", "rate_limit": 100, "note": "Store this key securely. It cannot be retrieved later."}) + return + } + + rows, _ := db.Query(`SELECT id, name, owner, permissions, rate_limit, is_active, last_used_at, created_at + FROM api_keys ORDER BY created_at DESC`) + defer rows.Close() + keys := []M{} + for rows.Next() { + var id, rl, active int + var name, owner, perms string + var lastUsed, created sql.NullString + rows.Scan(&id, &name, &owner, &perms, &rl, &active, &lastUsed, &created) + keys = append(keys, M{ + "id": id, "name": name, "owner": owner, "permissions": perms, + "rate_limit": rl, "is_active": active == 1, + "last_used_at": lastUsed.String, "created_at": created.String, + }) + } + writeJSON(w, 200, M{"data": keys, "count": len(keys)}) +} + +func handlePublicAPIUsage(w http.ResponseWriter, r *http.Request) { + rows, _ := db.Query(`SELECT au.endpoint, au.method, COUNT(*) as calls, + ROUND(AVG(au.response_ms),2) as avg_ms, + MAX(au.created_at) as last_call + FROM api_usage au + GROUP BY au.endpoint, au.method + ORDER BY calls DESC LIMIT 50`) + defer rows.Close() + data := []M{} + for rows.Next() { + var endpoint, method, lastCall string + var calls int + var avgMs float64 + rows.Scan(&endpoint, &method, &calls, &avgMs, &lastCall) + data = append(data, M{"endpoint": endpoint, "method": method, "calls": calls, "avg_ms": avgMs, "last_call": lastCall}) + } + + var totalCalls int + db.QueryRow("SELECT COUNT(*) FROM api_usage").Scan(&totalCalls) + + writeJSON(w, 200, M{"data": data, "total_calls": totalCalls}) +} + +func handlePublicAPIDocs(w http.ResponseWriter, r *http.Request) { + docs := M{ + "openapi": "3.0.3", + "info": M{ + "title": "INEC Election Platform Public API", + "version": "1.0.0", + "description": "Public API for third-party verification and monitoring of Nigerian election results", + }, + "servers": []M{{"url": "/api/v1"}}, + "security": []M{{"ApiKeyAuth": []string{}}}, + "components": M{ + "securitySchemes": M{ + "ApiKeyAuth": M{ + "type": "apiKey", "in": "header", "name": "X-API-Key", + }, + }, + }, + "paths": M{ + "/elections": M{ + "get": M{"summary": "List all elections", "tags": []string{"Elections"}, + "responses": M{"200": M{"description": "List of elections"}}}, + }, + "/results": M{ + "get": M{"summary": "List results with filtering", "tags": []string{"Results"}, + "parameters": []M{ + {"name": "election_id", "in": "query", "schema": M{"type": "integer"}}, + {"name": "state_code", "in": "query", "schema": M{"type": "string"}}, + {"name": "status", "in": "query", "schema": M{"type": "string"}}, + {"name": "limit", "in": "query", "schema": M{"type": "integer", "default": 50}}, + {"name": "offset", "in": "query", "schema": M{"type": "integer", "default": 0}}, + }, + "responses": M{"200": M{"description": "Paginated results"}}}, + }, + "/results/{id}": M{ + "get": M{"summary": "Get result detail with party votes", "tags": []string{"Results"}, + "parameters": []M{{"name": "id", "in": "path", "required": true, "schema": M{"type": "integer"}}}, + "responses": M{"200": M{"description": "Result detail"}}}, + }, + "/states": M{ + "get": M{"summary": "List all states", "tags": []string{"Geography"}, + "responses": M{"200": M{"description": "List of states"}}}, + }, + "/polling-units": M{ + "get": M{"summary": "List polling units with filtering", "tags": []string{"Geography"}, + "parameters": []M{ + {"name": "state_code", "in": "query", "schema": M{"type": "string"}}, + {"name": "lga_code", "in": "query", "schema": M{"type": "string"}}, + {"name": "limit", "in": "query", "schema": M{"type": "integer", "default": 100}}, + }, + "responses": M{"200": M{"description": "Paginated polling units"}}}, + }, + "/collation": M{ + "get": M{"summary": "Get collation data", "tags": []string{"Collation"}, + "parameters": []M{ + {"name": "election_id", "in": "query", "schema": M{"type": "integer"}}, + {"name": "level", "in": "query", "schema": M{"type": "string", "enum": []string{"national", "state"}}}, + }, + "responses": M{"200": M{"description": "Collation data"}}}, + }, + "/ai/anomalies": M{ + "get": M{"summary": "AI-powered anomaly detection", "tags": []string{"AI Analytics"}, + "parameters": []M{ + {"name": "election_id", "in": "query", "schema": M{"type": "integer"}}, + {"name": "severity", "in": "query", "schema": M{"type": "string"}}, + }, + "responses": M{"200": M{"description": "Anomaly detection results with Benford analysis"}}}, + }, + "/ai/integrity": M{ + "get": M{"summary": "Election integrity score (0-100)", "tags": []string{"AI Analytics"}, + "parameters": []M{{"name": "election_id", "in": "query", "schema": M{"type": "integer"}}}, + "responses": M{"200": M{"description": "Composite integrity score"}}}, + }, + }, + } + + if strings.Contains(r.URL.Path, ".json") || r.URL.Query().Get("format") == "json" { + writeJSON(w, 200, docs) + } else { + writeJSON(w, 200, docs) + } +} + +func muxVarInt(r *http.Request, key string) int { + v := mux.Vars(r)[key] + var i int + fmt.Sscanf(v, "%d", &i) + return i +} diff --git a/inec-go-backend/sms_ussd.go b/inec-go-backend/sms_ussd.go new file mode 100644 index 00000000..b4e30d65 --- /dev/null +++ b/inec-go-backend/sms_ussd.go @@ -0,0 +1,255 @@ +package main + +import ( + "database/sql" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +func initSMSUSSDTables(database *sql.DB) { + database.Exec(`CREATE TABLE IF NOT EXISTS sms_verifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + phone TEXT NOT NULL, + polling_unit_code TEXT, + election_id INTEGER, + request_type TEXT NOT NULL CHECK(request_type IN ('result','status','verify')), + response_text TEXT, + channel TEXT NOT NULL DEFAULT 'sms' CHECK(channel IN ('sms','ussd')), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`) + database.Exec(`CREATE TABLE IF NOT EXISTS ussd_sessions ( + id TEXT PRIMARY KEY, + phone TEXT NOT NULL, + stage TEXT NOT NULL DEFAULT 'main_menu', + data TEXT DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )`) + database.Exec(`CREATE INDEX IF NOT EXISTS idx_sms_phone ON sms_verifications(phone)`) +} + +type SMSRequest struct { + Phone string `json:"phone"` + Message string `json:"message"` + PollingUnitCode string `json:"polling_unit_code,omitempty"` + ElectionID int `json:"election_id,omitempty"` +} + +type USSDRequest struct { + SessionID string `json:"session_id"` + PhoneNumber string `json:"phone_number"` + Text string `json:"text"` + ServiceCode string `json:"service_code"` +} + +func handleSMSVerify(w http.ResponseWriter, r *http.Request) { + var req SMSRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid request body") + return + } + + if req.Phone == "" { + writeError(w, 400, "phone is required") + return + } + + msg := strings.TrimSpace(strings.ToUpper(req.Message)) + electionID := req.ElectionID + if electionID == 0 { + electionID = 1 + } + + var response string + var reqType string + + switch { + case strings.HasPrefix(msg, "RESULT "): + puCode := strings.TrimPrefix(msg, "RESULT ") + reqType = "result" + response = getSMSResult(puCode, electionID) + case strings.HasPrefix(msg, "VERIFY "): + puCode := strings.TrimPrefix(msg, "VERIFY ") + reqType = "verify" + response = getSMSVerify(puCode, electionID) + case msg == "STATUS" || msg == "HELP": + reqType = "status" + response = getSMSStatus(electionID) + default: + reqType = "status" + response = "INEC Result Verification\nSend:\nRESULT - Get results\nVERIFY - Verify result\nSTATUS - Election status\nExample: RESULT AB-001-W001-PU001" + } + + db.Exec(`INSERT INTO sms_verifications (phone, polling_unit_code, election_id, request_type, response_text, channel) + VALUES (?,?,?,?,?,'sms')`, req.Phone, req.PollingUnitCode, electionID, reqType, response) + + writeJSON(w, 200, M{"response": response, "phone": req.Phone, "channel": "sms"}) +} + +func getSMSResult(puCode string, electionID int) string { + puCode = strings.TrimSpace(puCode) + var puName string + err := db.QueryRow("SELECT name FROM polling_units WHERE code=?", puCode).Scan(&puName) + if err != nil { + return fmt.Sprintf("Polling unit %s not found. Check code and try again.", puCode) + } + + rows, err := db.Query(`SELECT p.abbreviation, rv.votes FROM result_votes rv + JOIN results res ON rv.result_id=res.id + JOIN parties p ON rv.party_id=p.id + WHERE res.polling_unit_code=? AND res.election_id=? + ORDER BY rv.votes DESC`, puCode, electionID) + if err != nil { + return fmt.Sprintf("%s: No results submitted yet.", puName) + } + defer rows.Close() + + var lines []string + lines = append(lines, fmt.Sprintf("RESULTS: %s (%s)", puName, puCode)) + total := 0 + for rows.Next() { + var party string + var votes int + rows.Scan(&party, &votes) + lines = append(lines, fmt.Sprintf("%s: %d", party, votes)) + total += votes + } + if len(lines) == 1 { + return fmt.Sprintf("%s: No results submitted yet.", puName) + } + lines = append(lines, fmt.Sprintf("TOTAL: %d votes", total)) + return strings.Join(lines, "\n") +} + +func getSMSVerify(puCode string, electionID int) string { + puCode = strings.TrimSpace(puCode) + var puName, status, tbStatus, hlStatus string + err := db.QueryRow(`SELECT pu.name, r.status, r.tigerbeetle_status, r.hyperledger_status + FROM results r JOIN polling_units pu ON r.polling_unit_code=pu.code + WHERE r.polling_unit_code=? AND r.election_id=?`, puCode, electionID).Scan(&puName, &status, &tbStatus, &hlStatus) + if err != nil { + return fmt.Sprintf("No result to verify for %s", puCode) + } + + verified := "NOT VERIFIED" + if tbStatus == "POSTED" && hlStatus == "CONFIRMED" { + verified = "VERIFIED (Dual-Ledger Confirmed)" + } else if tbStatus == "POSTED" { + verified = "PARTIAL (TigerBeetle only)" + } + + return fmt.Sprintf("VERIFY: %s\nStatus: %s\nTigerBeetle: %s\nHyperledger: %s\nResult: %s", puName, status, tbStatus, hlStatus, verified) +} + +func getSMSStatus(electionID int) string { + var name, status string + var totalPUs int + db.QueryRow("SELECT name, status FROM elections WHERE id=?", electionID).Scan(&name, &status) + db.QueryRow("SELECT COUNT(*) FROM polling_units").Scan(&totalPUs) + + var submitted, finalized int + db.QueryRow("SELECT COUNT(*) FROM results WHERE election_id=?", electionID).Scan(&submitted) + db.QueryRow("SELECT COUNT(*) FROM results WHERE election_id=? AND status='finalized'", electionID).Scan(&finalized) + + pct := 0.0 + if totalPUs > 0 { + pct = float64(submitted) / float64(totalPUs) * 100 + } + + return fmt.Sprintf("ELECTION: %s\nStatus: %s\nResults: %d/%d (%.1f%%)\nFinalized: %d\nSend RESULT for details", name, status, submitted, totalPUs, pct, finalized) +} + +func handleUSSDGateway(w http.ResponseWriter, r *http.Request) { + var req USSDRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, 400, "invalid request body") + return + } + + sessionID := req.SessionID + if sessionID == "" { + sessionID = fmt.Sprintf("USSD-%d", time.Now().UnixNano()) + } + + text := strings.TrimSpace(req.Text) + parts := strings.Split(text, "*") + + var response string + continueSession := true + + if text == "" { + response = "CON Welcome to INEC Result Verification\n1. Check Result by PU Code\n2. Election Status\n3. Verify Result\n0. Exit" + db.Exec(`INSERT OR REPLACE INTO ussd_sessions (id, phone, stage, data) VALUES (?,?,'main_menu','{}')`, + sessionID, req.PhoneNumber) + } else if len(parts) == 1 { + switch parts[0] { + case "1": + response = "CON Enter Polling Unit Code\n(e.g. AB-001-W001-PU001):" + case "2": + response = "END " + getSMSStatus(1) + continueSession = false + case "3": + response = "CON Enter PU Code to verify:" + case "0": + response = "END Thank you for using INEC Verification." + continueSession = false + default: + response = "END Invalid option. Dial again." + continueSession = false + } + } else if len(parts) == 2 { + switch parts[0] { + case "1": + response = "END " + getSMSResult(parts[1], 1) + continueSession = false + case "3": + response = "END " + getSMSVerify(parts[1], 1) + continueSession = false + default: + response = "END Invalid input." + continueSession = false + } + } else { + response = "END Invalid input. Please dial again." + continueSession = false + } + + db.Exec(`INSERT INTO sms_verifications (phone, request_type, response_text, channel) + VALUES (?,?,?,'ussd')`, req.PhoneNumber, "ussd", response) + + writeJSON(w, 200, M{ + "response": response, + "session_id": sessionID, + "continue_session": continueSession, + }) +} + +func handleSMSStats(w http.ResponseWriter, r *http.Request) { + var totalSMS, totalUSSD int + db.QueryRow("SELECT COUNT(*) FROM sms_verifications WHERE channel='sms'").Scan(&totalSMS) + db.QueryRow("SELECT COUNT(*) FROM sms_verifications WHERE channel='ussd'").Scan(&totalUSSD) + + var today int + db.QueryRow("SELECT COUNT(*) FROM sms_verifications WHERE created_at >= date('now')").Scan(&today) + + rows, _ := db.Query(`SELECT request_type, COUNT(*) as cnt FROM sms_verifications + GROUP BY request_type ORDER BY cnt DESC`) + defer rows.Close() + byType := []M{} + for rows.Next() { + var rt string + var cnt int + rows.Scan(&rt, &cnt) + byType = append(byType, M{"type": rt, "count": cnt}) + } + + writeJSON(w, 200, M{ + "total_sms": totalSMS, + "total_ussd": totalUSSD, + "today": today, + "by_type": byType, + }) +}