diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index e9c8685..e386ae5 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -1277,251 +1277,6 @@ def _serialize_ta1_from_row(row: db.Ta1Ack) -> str: # --------------------------------------------------------------------------- # -@app.get("/api/inbox/lanes", dependencies=[Depends(matrix_gate)]) -def inbox_lanes(request: Request): - """Return all Inbox lanes in one call. - - Uses ``request.app.state`` rather than the module-level ``app`` - global so the endpoint is robust against ``importlib.reload`` of - this module (some tests do this to mutate the CORS allow-list). - After a reload, the module-level ``app`` rebinds to a new - FastAPI instance; ``request.app`` always points at the instance - that is actually serving the current request, so per-request - state stays consistent with the test's TestClient target. - """ - dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set()) - with db.SessionLocal()() as session: - from cyclone.inbox_lanes import compute_lanes - lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) - return { - "rejected": lanes.rejected, - # SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from - # the 999 envelope rejection in ``rejected`` above. - "payer_rejected": lanes.payer_rejected, - "candidates": lanes.candidates, - "unmatched": lanes.unmatched, - "done_today": lanes.done_today, - } - - -@app.post("/api/inbox/candidates/{remit_id}/match", dependencies=[Depends(matrix_gate)]) -def inbox_match_candidate(remit_id: str, body: dict): - """Manually link a remit to a claim.""" - claim_id = body.get("claim_id") - if not claim_id: - raise HTTPException(400, "claim_id required") - with db.SessionLocal()() as s: - claim = s.get(Claim, claim_id) - remit = s.get(Remittance, remit_id) - if claim is None or remit is None: - raise HTTPException(404, "claim or remit not found") - if claim.matched_remittance_id and claim.matched_remittance_id != remit_id: - raise HTTPException( - 409, - detail={ - "error": "claim_already_matched", - "current_state": ( - claim.state.value if hasattr(claim.state, "value") - else str(claim.state) - ), - "matched_remittance_id": claim.matched_remittance_id, - }, - ) - claim.matched_remittance_id = remit_id - remit.claim_id = claim_id - s.commit() - return {"ok": True, "claim_id": claim_id, "remit_id": remit_id} - - -@app.post("/api/inbox/candidates/dismiss", dependencies=[Depends(matrix_gate)]) -def inbox_dismiss_candidates(body: dict, request: Request): - """Add candidate pairs to the session-scoped dismissed set. - - Uses ``request.app.state`` rather than the module-level ``app`` - global so the endpoint is robust against ``importlib.reload`` of - this module (some tests do this to mutate the CORS allow-list). - After a reload, the module-level ``app`` rebinds to a new - FastAPI instance; ``request.app`` always points at the instance - that is actually serving the current request, so the test's - TestClient target is the one whose state we mutate. - """ - pairs = body.get("pairs") or [] - if not hasattr(request.app.state, "dismissed_pairs"): - request.app.state.dismissed_pairs = set() - for p in pairs: - cid = p.get("claim_id") - rid = p.get("remit_id") - if cid and rid: - request.app.state.dismissed_pairs.add(frozenset({cid, rid})) - return {"ok": True, "dismissed_count": len(pairs)} - - -# --------------------------------------------------------------------------- # -# SP14: Payer-Rejected acknowledge -# -# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear -# the claim from the working surface. We don't delete the rejection -# (the original payer_rejected_* fields stay for SP11 audit), we just -# set payer_rejected_acknowledged_at so the lane query filters it out. -# -# Idempotent: re-acknowledging an already-acknowledged claim is a noop -# (the timestamp is not bumped). Returns the count actually transitioned -# so the UI can show "3 of 5 were already acknowledged". -# --------------------------------------------------------------------------- # -@app.post("/api/inbox/payer-rejected/acknowledge", dependencies=[Depends(matrix_gate)]) -def inbox_acknowledge_payer_rejected(body: dict): - """Mark Payer-Rejected claims as acknowledged by the operator.""" - claim_ids = body.get("claim_ids") or [] - actor = body.get("actor") or "operator" - if not isinstance(claim_ids, list) or not claim_ids: - raise HTTPException(400, "claim_ids must be a non-empty list") - if not all(isinstance(c, str) for c in claim_ids): - raise HTTPException(400, "claim_ids must be a list of strings") - - with db.SessionLocal()() as session: - from cyclone.db import Claim - now = datetime.now(timezone.utc) - transitioned = 0 - already_acked = 0 - not_found = 0 - not_rejected = 0 - for cid in claim_ids: - claim = session.get(Claim, cid) - if claim is None: - not_found += 1 - continue - if claim.payer_rejected_at is None: - not_rejected += 1 - continue - if claim.payer_rejected_acknowledged_at is not None: - already_acked += 1 - continue - claim.payer_rejected_acknowledged_at = now - claim.payer_rejected_acknowledged_actor = actor - transitioned += 1 - # SP11: audit event for the acknowledge action. - try: - from cyclone.audit_log import append_event, AuditEvent - append_event(session, AuditEvent( - event_type="claim.payer_rejected_acknowledged", - entity_type="claim", - entity_id=claim.id, - actor=actor, - payload={ - "payer_rejected_status_code": claim.payer_rejected_status_code, - "payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id, - }, - )) - except Exception: # noqa: BLE001 - # Audit append is best-effort; don't block the operator's - # acknowledge action on an audit-log failure. - pass - if transitioned: - session.commit() - - return { - "ok": True, - "transitioned": transitioned, - "already_acked": already_acked, - "not_found": not_found, - "not_rejected": not_rejected, - } - - -@app.post("/api/inbox/rejected/resubmit", dependencies=[Depends(matrix_gate)]) -def inbox_resubmit_rejected( - request: Request, - body: dict, - download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."), -): - """Bulk move REJECTED claims back to SUBMITTED. - - With ``?download=true``, the response is a ``application/zip`` archive - containing one ``claim-{id}.x12`` per successfully resubmitted claim - (regenerated via ``serialize_837_for_resubmit`` so each file gets a - unique interchange/group control number). Conflicts are omitted from - the ZIP — they remain visible to the caller via the JSON shape of the - non-download path. Empty resubmit + download → 200 with an empty zip - so the UI can still hand the user a downloadable artifact. - """ - ids = body.get("claim_ids") or [] - if not ids: - raise HTTPException(400, "claim_ids required") - accepted: list[str] = [] - conflicts: list[dict] = [] - # Track which claims are about to be resubmitted (and their index in - # the bundle) so the download path can serialize them with unique - # control numbers — back-to-back resubmits in the same file would - # otherwise all share ISA13/GS06 = "000000001". - accepted_with_rows: list[tuple[str, "Claim"]] = [] - with db.SessionLocal()() as s: - for cid in ids: - c = s.get(Claim, cid) - if c is None: - continue - if c.state != ClaimState.REJECTED: - conflicts.append({ - "claim_id": cid, - "current_state": ( - c.state.value if hasattr(c.state, "value") - else str(c.state) - ), - }) - continue - c.state = ClaimState.SUBMITTED - c.state_changed_at = datetime.now(timezone.utc) - c.rejection_reason = None - c.rejected_at = None - c.resubmit_count = (c.resubmit_count or 0) + 1 - accepted.append(cid) - accepted_with_rows.append((cid, c)) - s.commit() - - if not download: - return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} - - # Build a ZIP of regenerated 837s for the accepted claims. Conflicts - # and missing ids are deliberately excluded — the user already saw - # them in the JSON response on prior actions; the download is the - # "give me the files I asked for" payload. - import zipfile - buf = io.BytesIO() - serialize_errors: list[dict] = [] - with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: - for idx, (cid, c) in enumerate(accepted_with_rows, start=1): - if not c.raw_json: - serialize_errors.append({"claim_id": cid, "reason": "no raw_json"}) - continue - try: - claim_obj = ClaimOutput.model_validate(c.raw_json) - except Exception as exc: - serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"}) - continue - try: - text = serialize_837_for_resubmit(claim_obj, interchange_index=idx) - except SerializeError837 as exc: - serialize_errors.append({"claim_id": cid, "reason": str(exc)}) - continue - zf.writestr(f"claim-{cid}.x12", text) - buf.seek(0) - headers = { - "Content-Disposition": ( - f'attachment; filename="resubmit-{len(accepted)}-claims.zip"' - ), - } - # Surface per-claim serialization failures as a custom response header - # so the UI can show "10 resubmitted, 2 couldn't be regenerated" without - # parsing the binary. The header value is JSON-encoded; the UI is - # expected to JSON.parse it after a fetch with response.ok. - if serialize_errors: - headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors) - return Response( - content=buf.getvalue(), - media_type="application/zip", - headers=headers, - ) - - @app.post("/api/batches/{batch_id}/export-837", dependencies=[Depends(matrix_gate)]) def export_batch_837(request: Request, batch_id: str, body: dict): """Download a ZIP of regenerated X12 837 files for the requested claim_ids. @@ -1731,52 +1486,6 @@ def export_batch_837(request: Request, batch_id: str, body: dict): ) -@app.get("/api/inbox/export.csv", dependencies=[Depends(matrix_gate)]) -def inbox_export_csv(lane: str, request: Request): - """Stream a CSV for a single lane. - - Uses ``request.app.state`` rather than the module-level ``app`` - global so the endpoint is robust against ``importlib.reload`` of - this module (see ``inbox_dismiss_candidates`` for context). - """ - if lane not in {"rejected", "candidates", "unmatched", "done_today"}: - raise HTTPException(400, f"unknown lane: {lane}") - dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set()) - with db.SessionLocal()() as session: - from cyclone.inbox_lanes import compute_lanes - lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) - rows = getattr(lanes, lane) - - buf = io.StringIO() - writer = csv.writer(buf) - writer.writerow([ - "id", "kind", "patient_control_number", "charge_amount", - "payer_id", "provider_npi", "state", "rejection_reason", - "service_date", "score", - ]) - for r in rows: - writer.writerow([ - r.get("id") or r.get("payer_claim_control_number"), - r.get("kind"), - r.get("patient_control_number"), - r.get("charge_amount"), - r.get("payer_id"), - r.get("provider_npi") or r.get("rendering_provider_npi"), - r.get("state"), - r.get("rejection_reason"), - r.get("service_date_from") or r.get("service_date"), - r.get("score"), - ]) - buf.seek(0) - return StreamingResponse( - iter([buf.getvalue()]), - media_type="text/csv", - headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'}, - ) - - -# --------------------------------------------------------------------------- # -# GET endpoints (read views over the in-memory store) # --------------------------------------------------------------------------- # diff --git a/backend/src/cyclone/api_routers/__init__.py b/backend/src/cyclone/api_routers/__init__.py index 2f0c3e3..6905649 100644 --- a/backend/src/cyclone/api_routers/__init__.py +++ b/backend/src/cyclone/api_routers/__init__.py @@ -20,6 +20,7 @@ from cyclone.api_routers import ( dashboard, eligibility, health, + inbox, payers, providers, reconciliation, @@ -37,6 +38,7 @@ routers: list[APIRouter] = [ dashboard.router, # gated eligibility.router, # gated health.router, # public — health probes must work pre-auth + inbox.router, # gated payers.router, # gated providers.router, # gated reconciliation.router, # gated diff --git a/backend/src/cyclone/api_routers/inbox.py b/backend/src/cyclone/api_routers/inbox.py new file mode 100644 index 0000000..e72bd26 --- /dev/null +++ b/backend/src/cyclone/api_routers/inbox.py @@ -0,0 +1,342 @@ +"""``/api/inbox*`` — operator-facing Inbox surface (SP6 + SP14). + +Six endpoints, all gated by ``matrix_gate``: + +- ``GET /api/inbox/lanes`` — all lanes in one + call (``compute_lanes`` from :mod:`cyclone.inbox_lanes`). +- ``POST /api/inbox/candidates/{remit_id}/match`` — manually link a + remit to a claim; surfaces 409 with the current state when the + claim is already matched. +- ``POST /api/inbox/candidates/dismiss`` — add candidate + pairs to the session-scoped dismissed set (mutates + ``request.app.state.dismissed_pairs``). +- ``POST /api/inbox/payer-rejected/acknowledge`` — SP14: mark + Payer-Rejected claims as acknowledged. Idempotent; returns the + count actually transitioned vs. already-acked / not-found / + not-rejected. +- ``POST /api/inbox/rejected/resubmit`` — bulk move + REJECTED claims back to SUBMITTED. With + ``?download=true`` returns a ZIP of regenerated 837 files + (``serialize_837_for_resubmit`` with per-claim ``interchange_index`` + for unique control numbers). Conflicts are omitted from the ZIP + and surfaced via the ``X-Cyclone-Serialize-Errors`` header. +- ``GET /api/inbox/export.csv`` — stream a CSV for + a single lane (rejected / candidates / unmatched / done_today). + +All endpoints use ``request.app.state`` rather than the module-level +``app`` global so they're robust against ``importlib.reload`` of +the api module — the reload rebinds ``app`` to a new instance, but +``request.app`` always points at the instance actually serving the +current request. + +SP36 Task 13: this block moved here from ``api.py:1280`` (the 6 +``/api/inbox*`` routes, with the SP14 comment block preserved +verbatim). +""" +from __future__ import annotations + +import csv +import io +import json +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response +from fastapi.responses import StreamingResponse + +from cyclone import db +from cyclone.auth.deps import matrix_gate +from cyclone.db import Claim, ClaimState, Remittance +from cyclone.parsers.models import ClaimOutput +from cyclone.parsers.serialize_837 import SerializeError as SerializeError837 +from cyclone.parsers.serialize_837 import serialize_837_for_resubmit + +router = APIRouter(dependencies=[Depends(matrix_gate)]) + + +@router.get("/api/inbox/lanes") +def inbox_lanes(request: Request): + """Return all Inbox lanes in one call. + + Uses ``request.app.state`` rather than the module-level ``app`` + global so the endpoint is robust against ``importlib.reload`` of + this module (some tests do this to mutate the CORS allow-list). + After a reload, the module-level ``app`` rebinds to a new + FastAPI instance; ``request.app`` always points at the instance + that is actually serving the current request, so per-request + state stays consistent with the test's TestClient target. + """ + dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set()) + with db.SessionLocal()() as session: + from cyclone.inbox_lanes import compute_lanes + lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) + return { + "rejected": lanes.rejected, + # SP10: payer-rejected lane (277CA STC A4/A6/A7). Distinct from + # the 999 envelope rejection in ``rejected`` above. + "payer_rejected": lanes.payer_rejected, + "candidates": lanes.candidates, + "unmatched": lanes.unmatched, + "done_today": lanes.done_today, + } + + +@router.post("/api/inbox/candidates/{remit_id}/match") +def inbox_match_candidate(remit_id: str, body: dict): + """Manually link a remit to a claim.""" + claim_id = body.get("claim_id") + if not claim_id: + raise HTTPException(400, "claim_id required") + with db.SessionLocal()() as s: + claim = s.get(Claim, claim_id) + remit = s.get(Remittance, remit_id) + if claim is None or remit is None: + raise HTTPException(404, "claim or remit not found") + if claim.matched_remittance_id and claim.matched_remittance_id != remit_id: + raise HTTPException( + 409, + detail={ + "error": "claim_already_matched", + "current_state": ( + claim.state.value if hasattr(claim.state, "value") + else str(claim.state) + ), + "matched_remittance_id": claim.matched_remittance_id, + }, + ) + claim.matched_remittance_id = remit_id + remit.claim_id = claim_id + s.commit() + return {"ok": True, "claim_id": claim_id, "remit_id": remit_id} + + +@router.post("/api/inbox/candidates/dismiss") +def inbox_dismiss_candidates(body: dict, request: Request): + """Add candidate pairs to the session-scoped dismissed set. + + Uses ``request.app.state`` rather than the module-level ``app`` + global so the endpoint is robust against ``importlib.reload`` of + this module (some tests do this to mutate the CORS allow-list). + After a reload, the module-level ``app`` rebinds to a new + FastAPI instance; ``request.app`` always points at the instance + that is actually serving the current request, so the test's + TestClient target is the one whose state we mutate. + """ + pairs = body.get("pairs") or [] + if not hasattr(request.app.state, "dismissed_pairs"): + request.app.state.dismissed_pairs = set() + for p in pairs: + cid = p.get("claim_id") + rid = p.get("remit_id") + if cid and rid: + request.app.state.dismissed_pairs.add(frozenset({cid, rid})) + return {"ok": True, "dismissed_count": len(pairs)} + + +# --------------------------------------------------------------------------- # +# SP14: Payer-Rejected acknowledge +# +# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear +# the claim from the working surface. We don't delete the rejection +# (the original payer_rejected_* fields stay for SP11 audit), we just +# set payer_rejected_acknowledged_at so the lane query filters it out. +# +# Idempotent: re-acknowledging an already-acknowledged claim is a noop +# (the timestamp is not bumped). Returns the count actually transitioned +# so the UI can show "3 of 5 were already acknowledged". +# --------------------------------------------------------------------------- # +@router.post("/api/inbox/payer-rejected/acknowledge") +def inbox_acknowledge_payer_rejected(body: dict): + """Mark Payer-Rejected claims as acknowledged by the operator.""" + claim_ids = body.get("claim_ids") or [] + actor = body.get("actor") or "operator" + if not isinstance(claim_ids, list) or not claim_ids: + raise HTTPException(400, "claim_ids must be a non-empty list") + if not all(isinstance(c, str) for c in claim_ids): + raise HTTPException(400, "claim_ids must be a list of strings") + + with db.SessionLocal()() as session: + from cyclone.db import Claim + now = datetime.now(timezone.utc) + transitioned = 0 + already_acked = 0 + not_found = 0 + not_rejected = 0 + for cid in claim_ids: + claim = session.get(Claim, cid) + if claim is None: + not_found += 1 + continue + if claim.payer_rejected_at is None: + not_rejected += 1 + continue + if claim.payer_rejected_acknowledged_at is not None: + already_acked += 1 + continue + claim.payer_rejected_acknowledged_at = now + claim.payer_rejected_acknowledged_actor = actor + transitioned += 1 + # SP11: audit event for the acknowledge action. + try: + from cyclone.audit_log import append_event, AuditEvent + append_event(session, AuditEvent( + event_type="claim.payer_rejected_acknowledged", + entity_type="claim", + entity_id=claim.id, + actor=actor, + payload={ + "payer_rejected_status_code": claim.payer_rejected_status_code, + "payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id, + }, + )) + except Exception: # noqa: BLE001 + # Audit append is best-effort; don't block the operator's + # acknowledge action on an audit-log failure. + pass + if transitioned: + session.commit() + + return { + "ok": True, + "transitioned": transitioned, + "already_acked": already_acked, + "not_found": not_found, + "not_rejected": not_rejected, + } + + +@router.post("/api/inbox/rejected/resubmit") +def inbox_resubmit_rejected( + request: Request, + body: dict, + download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."), +): + """Bulk move REJECTED claims back to SUBMITTED. + + With ``?download=true``, the response is a ``application/zip`` archive + containing one ``claim-{id}.x12`` per successfully resubmitted claim + (regenerated via ``serialize_837_for_resubmit`` so each file gets a + unique interchange/group control number). Conflicts are omitted from + the ZIP — they remain visible to the caller via the JSON shape of the + non-download path. Empty resubmit + download → 200 with an empty zip + so the UI can still hand the user a downloadable artifact. + """ + ids = body.get("claim_ids") or [] + if not ids: + raise HTTPException(400, "claim_ids required") + accepted: list[str] = [] + conflicts: list[dict] = [] + # Track which claims are about to be resubmitted (and their index in + # the bundle) so the download path can serialize them with unique + # control numbers — back-to-back resubmits in the same file would + # otherwise all share ISA13/GS06 = "000000001". + accepted_with_rows: list[tuple[str, "Claim"]] = [] + with db.SessionLocal()() as s: + for cid in ids: + c = s.get(Claim, cid) + if c is None: + continue + if c.state != ClaimState.REJECTED: + conflicts.append({ + "claim_id": cid, + "current_state": ( + c.state.value if hasattr(c.state, "value") + else str(c.state) + ), + }) + continue + c.state = ClaimState.SUBMITTED + c.state_changed_at = datetime.now(timezone.utc) + c.rejection_reason = None + c.rejected_at = None + c.resubmit_count = (c.resubmit_count or 0) + 1 + accepted.append(cid) + accepted_with_rows.append((cid, c)) + s.commit() + + if not download: + return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} + + # Build a ZIP of regenerated 837s for the accepted claims. Conflicts + # and missing ids are deliberately excluded — the user already saw + # them in the JSON response on prior actions; the download is the + # "give me the files I asked for" payload. + import zipfile + buf = io.BytesIO() + serialize_errors: list[dict] = [] + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for idx, (cid, c) in enumerate(accepted_with_rows, start=1): + if not c.raw_json: + serialize_errors.append({"claim_id": cid, "reason": "no raw_json"}) + continue + try: + claim_obj = ClaimOutput.model_validate(c.raw_json) + except Exception as exc: + serialize_errors.append({"claim_id": cid, "reason": f"raw_json invalid: {exc}"}) + continue + try: + text = serialize_837_for_resubmit(claim_obj, interchange_index=idx) + except SerializeError837 as exc: + serialize_errors.append({"claim_id": cid, "reason": str(exc)}) + continue + zf.writestr(f"claim-{cid}.x12", text) + buf.seek(0) + headers = { + "Content-Disposition": ( + f'attachment; filename="resubmit-{len(accepted)}-claims.zip"' + ), + } + # Surface per-claim serialization failures as a custom response header + # so the UI can show "10 resubmitted, 2 couldn't be regenerated" without + # parsing the binary. The header value is JSON-encoded; the UI is + # expected to JSON.parse it after a fetch with response.ok. + if serialize_errors: + headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors) + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers=headers, + ) + + +@router.get("/api/inbox/export.csv") +def inbox_export_csv(lane: str, request: Request): + """Stream a CSV for a single lane. + + Uses ``request.app.state`` rather than the module-level ``app`` + global so the endpoint is robust against ``importlib.reload`` of + this module (see ``inbox_dismiss_candidates`` for context). + """ + if lane not in {"rejected", "candidates", "unmatched", "done_today"}: + raise HTTPException(400, f"unknown lane: {lane}") + dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set()) + with db.SessionLocal()() as session: + from cyclone.inbox_lanes import compute_lanes + lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) + rows = getattr(lanes, lane) + + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow([ + "id", "kind", "patient_control_number", "charge_amount", + "payer_id", "provider_npi", "state", "rejection_reason", + "service_date", "score", + ]) + for r in rows: + writer.writerow([ + r.get("id") or r.get("payer_claim_control_number"), + r.get("kind"), + r.get("patient_control_number"), + r.get("charge_amount"), + r.get("payer_id"), + r.get("provider_npi") or r.get("rendering_provider_npi"), + r.get("state"), + r.get("rejection_reason"), + r.get("service_date_from") or r.get("service_date"), + r.get("score"), + ]) + buf.seek(0) + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'}, + )