# Fraud Report Module

A standalone FastAPI app + premium dashboard for `data/fraud_log.json`.
Runs independently of the main People Hub backend — flip in a database
later by swapping a single class.

## Folder structure

```
people_hub_candidate/
├── data/
│   └── fraud_log.json              ← read-only source (already exists)
└── fraud_report/                   ← this module
    ├── __init__.py
    ├── fraudreport.py              ← FastAPI app (entry point)
    ├── data_source.py              ← FraudRepository abstraction + JSON impl
    ├── models.py                   ← Pydantic schemas
    ├── stage_map.py                ← round-id → stage-name table
    ├── README.md
    └── static/
        ├── fraudreport.html        ← dashboard markup
        ├── fraudreport.css         ← theme-aware styles
        └── fraudreport.js          ← controller (no frameworks, no build)
```

## Run

```bash
# from people_hub_candidate/
uvicorn fraud_report.fraudreport:app --port 8001 --reload
```

Then open <http://localhost:8001/>.

## Endpoints

| Method | Path                              | Returns                          |
| ------ | --------------------------------- | -------------------------------- |
| GET    | `/`                               | Dashboard HTML                   |
| GET    | `/api/fraud/health`               | `{ok, service, version}`         |
| GET    | `/api/fraud/stages`               | Canonical stage track (static)   |
| GET    | `/api/fraud/access-keys`          | `AccessKeyList`                  |
| GET    | `/api/fraud/report/{access_key}`  | `FraudReport` (404 if no events) |

### Example — `GET /api/fraud/access-keys`

```json
{
  "count": 1,
  "items": [
    {
      "access_key":    "VT7W2Q",
      "candidate_id":  "VT7W2Q",
      "event_count":   86,
      "last_event_at": "2026-05-19T18:53:24.025573+00:00",
      "risk_score":    "CRITICAL"
    }
  ]
}
```

### Example — `GET /api/fraud/report/VT7W2Q`

```json
{
  "summary": {
    "access_key":      "VT7W2Q",
    "candidate_id":    "VT7W2Q",
    "total_events":    86,
    "by_event_type":   {"WINDOW_BLUR": 42, "TAB_SWITCH": 10, "INACTIVITY": 21, "STORAGE_TAMPER": 8, "IDLE": 4, "DISQUALIFIED": 1},
    "by_severity":     {"low": 22, "medium": 25, "high": 37, "critical": 2},
    "by_round":        {"0": 17, "1": 5, "2": 54, "3": 3, "5": 1, "6": 4, "7": 1, "9": 1},
    "sessions_seen":   ["0d171048-7c1", "1980d4cd-85c", "..."],
    "first_event_at":  "2026-05-16T12:36:00.900843+00:00",
    "last_event_at":   "2026-05-19T18:53:24.025573+00:00",
    "risk_score":      "CRITICAL",
    "fraud_status":    "DISQUALIFIED",
    "session_status":  "DISQUALIFIED",
    "is_disqualified": true
  },
  "stage_track": [
    {
      "stage":        {"round_id":0,"short_label":"Pre-Session","full_name":"Current Stage After Login","category":"lobby","accent":"#94a3b8","known":true},
      "event_count":  17,
      "severity_max": "critical",
      "actions":      ["disqualify","none"],
      "flagged":      true,
      "disqualified": true
    },
    {"stage": {"round_id":1,"short_label":"Round 1","full_name":"Round 1 — AI Screening Assessment", "category":"assessment","accent":"#3b82f6","known":true}, "event_count": 5, "severity_max":"medium","actions":["none","warn"], "flagged": true, "disqualified": false}
    /* … one entry per canonical round, plus any extended rounds present in the data … */
  ],
  "events": [
    {
      "id":               "605c77b0-d0de-4cca-bd76-0921eacee51a",
      "candidate_id":     "VT7W2Q",
      "access_key":       "VT7W2Q",
      "event_type":       "WINDOW_BLUR",
      "message":          "Window lost focus",
      "timestamp":        "2026-05-16T12:36:00.896Z",
      "round":            0,
      "action":           "none",
      "metadata":         { /* original metadata, untouched */ },
      "created_at":       "2026-05-16T12:36:00.900843+00:00",
      "updated_at":       "2026-05-16T12:36:00.902020+00:00",
      "severity":         "low",
      "stage":            {"round_id":0,"short_label":"Pre-Session","full_name":"Current Stage After Login","category":"lobby","accent":"#94a3b8","known":true},
      "detection_source": "client-monitor",
      "session_id":       "fca25d96-76a",
      "scenario_id":      "13",
      "screenshot_url":   null,
      "page":             null,
      "user_agent":       null
    }
    /* … chronological list … */
  ]
}
```

## Configuration

| Env var          | Default                                  | Purpose                                  |
| ---------------- | ---------------------------------------- | ---------------------------------------- |
| `FRAUD_LOG_PATH` | `<repo>/data/fraud_log.json`             | Path to the JSON source                  |

## Swap JSON → database

1. Add a new class in `data_source.py`:

   ```python
   class PostgresFraudRepository(FraudRepository):
       def list_access_keys(self) -> list[str]: ...
       def fetch_events(self, access_key=None) -> list[FraudEvent]: ...
   ```

2. Wire it in `get_repository()`:

   ```python
   def get_repository() -> FraudRepository:
       backend = os.getenv("FRAUD_BACKEND", "json")
       if backend == "postgres":
           return PostgresFraudRepository(dsn=os.getenv("FRAUD_PG_DSN"))
       return JsonFraudRepository(path=...)
   ```

That's it — the routes, the models, the OpenAPI schema, and the
frontend are untouched.

## Error model

All domain errors map to JSON responses (no stack traces leak):

| Exception                  | HTTP | Body                                  |
| -------------------------- | ---- | ------------------------------------- |
| `FraudDataNotFoundError`   | 503  | `{error:"fraud_data_missing", ...}`   |
| `FraudDataCorruptError`    | 500  | `{error:"fraud_data_corrupt", ...}`   |
| `FraudDataEmptyError`      | 200  | `{error:"fraud_data_empty", count:0}` |
| `AccessKeyNotFoundError`   | 404  | `{error:"access_key_not_found", ...}` |

## Frontend features

- Dropdown of access keys (shows event count + risk badge inline)
- Live filters: free-text search, severity, click-to-toggle event-type chips
- Reset button clears all filters and selection
- Stat widgets: total events, critical/high count, sessions seen,
  stages flagged, distinct event types
- Risk dial with conic gradient + animated arc
- Stage stepper: every canonical round (Pre-Session → HR Round) plus
  any extended rounds, with per-stage event count, severity badge and
  a "DISQUALIFIED" pin when applicable
- Event timeline: vertical, animated, with severity-colored markers,
  round badges, action pills (`warn` / `disqualify`), screenshot
  thumbnail when present, and a metadata grid that surfaces the most
  useful keys from `metadata` (`blur_count`, `tab_switches`,
  `total_violations`, etc.)
- Empty / loading / error states with retry
- Dark/light theme with localStorage persistence
- Responsive down to ~520 px
