"""Notification / out-of-band confirmation endpoints — a torch-free router. Isolated from the monolithic `api.routes` (which imports the biometric models/torch) so these endpoints and their API tests run in the lightweight, torch-free environment. Paths, methods and request/response contracts are UNCHANGED from the previous definitions in `api.routes`.""" from __future__ import annotations import time from fastapi import APIRouter, Body from fastapi.responses import JSONResponse from api.services import audit_event, notifications, persist_prefs router = APIRouter() @router.post("/notify/prefs") def notify_set_prefs(body: dict = Body(...)) -> dict: r = notifications.set_prefs(body["user_id"], channels=body.get("channels"), email=body.get("email"), phone=body.get("phone")) persist_prefs() return r @router.get("/notify/prefs") def notify_get_prefs(user_id: str) -> dict: return notifications.get_prefs(user_id) @router.post("/notify/subscribe") def notify_subscribe(body: dict = Body(...)) -> dict: """Store a Web Push subscription (from the browser) for the user.""" return notifications.subscribe_push(body["user_id"], body["subscription"]) @router.post("/notify/request") def notify_request(body: dict = Body(...)) -> dict: """Create a demo out-of-band payment confirmation (dynamic-linked to amount+merchant) using the existing notification service, so the React OOB demo can exercise the request -> respond -> status workflow without going through /wallet/pay.""" return notifications.send_payment_confirmation( body["user_id"], float(body.get("amount", 0.0)), str(body.get("merchant", "")), now=time.time()) @router.get("/notify/status") def notify_status(confirmation_id: str) -> dict: """Lifecycle of an OOB confirmation for UI polling: pending/approved/rejected/expired.""" return {"confirmation_id": confirmation_id, "status": notifications.confirmation_status(confirmation_id, now=time.time())} @router.post("/notify/respond") def notify_respond(body: dict = Body(...)): """Approve/deny an OOB confirmation. Enforces terminal-state immutability: a conflicting response after a terminal state returns HTTP 409 (oob_already_finalized) and leaves the final state unchanged; a same-decision retry is idempotent.""" r = notifications.respond(body["confirmation_id"], bool(body.get("approve", False)), code=body.get("code"), now=time.time()) if r.get("conflict"): # Safe security-audit event (no code/secret) for the rejected conflicting transition. audit_event("oob", "oob_conflict_rejected", {"confirmation_id": r.get("confirmation_id"), "final_state": r.get("status")}) return JSONResponse(status_code=409, content=r) return r