Files
dre/backend/letters.py
T
root 7a62b0b340 Backend Aug 22-25: AI analysis, welcome-packet templating, LetterStream, DocuSeal, staff RBAC + tier gate
- analysis.py: deterministic claim scorer + /analyze /approve /letter /advance-tier endpoints (auto-runs on intake)
- packet.py + packet_fields.json: welcome-packet templating engine (6 onboarding docs, field catalog)
- letterstream.py + letters.py: certified-mail send pipeline + letter lifecycle (webhook verified)
- docuseal.py: DocuSeal signing integration
- staff.py/models.py/schema.sql/auth.py: approval actor from staff key, tier gate (APPROVED+ACTIVE+onboarding docs), onboarding_docs table
- frontend/: dependency-free static portal (intake, magic-link login/verify, dashboard)
- landing-mockups/: 4 design-stance mockups + favicons
- legal/: aup/privacy/sms-terms/terms HTML
- docs/: letter-queue scope, letterstream API contract, 6 welcome-packet templates
- review-dre-landing-2026-08-21.md: 3-variant landing feedback sprint
- compliance/DRE_Compliance_Manual.md: updated

Source synced from deployed /opt/dre-portal/app/ (was 4 days ahead of git).
2026-08-26 02:26:33 -04:00

598 lines
25 KiB
Python

"""Physical mail queue + LetterStream send integration.
Letters are the paper collection notices mailed to debtors. One row per mailed
letter in `letters` (lifecycle DRAFT -> APPROVED -> PREAUTH -> SENT, plus
REJECTED / CANCELLED / ERROR), with tracking scan events in `letter_events`
pushed by LetterStream's callback.
Send flow is human-in-the-loop on cost (production mode, real money):
1. staff creates a draft letter (content defaults to claims.letter_*, recipient
address supplied by staff after client-data + Super Search verification)
2. staff approves -> APPROVED
3. staff clicks "price & queue" -> LetterStream preauth=1, store authcode + cost
(status PREAUTH); nothing is released or mailed yet
4. staff confirms -> LetterStream doauth, status SENT, tracking begins
Signatory: Debt Recovery Experts LLC (LETTERSTREAM_SIGNATORY).
Return address: LETTERSTREAM_RETURN_ADDRESS (colon-delimited addr1:addr2:city:state:zip).
"""
from __future__ import annotations
import json
import logging
import os
import uuid
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
from . import auth as authmod
from . import letterstream
from .db import get_conn, new_uuid, utcnow_iso
logger = logging.getLogger("dre.letters")
router = APIRouter()
SIGNATORY = os.environ.get("LETTERSTREAM_SIGNATORY", "Debt Recovery Experts LLC")
RETURN_ADDR = os.environ.get("LETTERSTREAM_RETURN_ADDRESS", "") # addr1:addr2:city:state:zip
CALLBACK_KEY = os.environ.get("LETTERSTREAM_CALLBACK_KEY", "")
LETTERS_DIR = os.environ.get("DRE_LETTERS_DIR", "/opt/dre-portal/data/letters")
MAILTYPES = ("firstclass", "firstclass_hse", "certified", "certnoerr",
"postcard", "flat", "propostcard")
LETTER_STATUSES = ("DRAFT", "APPROVED", "PREAUTH", "SENT",
"REJECTED", "CANCELLED", "ERROR")
class LetterStreamError(Exception):
"""Raised on any LetterStream or letter-lifecycle failure."""
# ---------------------------------------------------------------
# Request models (letter endpoints only — kept local to this router)
# ---------------------------------------------------------------
class RecipientAddress(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(..., min_length=1, max_length=200)
name2: str | None = Field(None, max_length=200)
addr1: str = Field(..., min_length=1, max_length=200)
addr2: str | None = Field(None, max_length=200)
city: str = Field(..., min_length=1, max_length=100)
state: str = Field(..., min_length=2, max_length=2)
zip: str = Field(..., min_length=5, max_length=10)
@field_validator("name", "name2", "addr1", "addr2", "city", "state", "zip")
@classmethod
def _v(cls, v):
return v
class LetterCreate(BaseModel):
model_config = ConfigDict(extra="forbid")
recipient: RecipientAddress
letter_type: str = Field("demand", max_length=40)
subject: str | None = Field(None, max_length=200)
body: str | None = Field(None, max_length=20000)
mailtype: str = "firstclass"
coversheet: bool = True
@field_validator("mailtype")
@classmethod
def _mt(cls, v):
if v not in MAILTYPES:
raise ValueError(f"mailtype must be one of {MAILTYPES}")
return v
class LetterAction(BaseModel):
model_config = ConfigDict(extra="forbid")
reason: str | None = Field(None, max_length=1000)
# ---------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------
def sender_parts() -> list[str]:
"""Return [name1, name2, addr1, addr2, city, state, zip] for the `from` field."""
if not RETURN_ADDR:
raise LetterStreamError("LETTERSTREAM_RETURN_ADDRESS is not configured")
parts = RETURN_ADDR.split(":")
if len(parts) != 5:
raise LetterStreamError(
"LETTERSTREAM_RETURN_ADDRESS must be addr1:addr2:city:state:zip")
addr1, addr2, city, state, zipc = parts
return [SIGNATORY, "", addr1, addr2, city, state.upper(), zipc]
def sender_string() -> str:
return ":".join(sender_parts())
def recipient_string(doc_id: str, r: dict) -> str:
return ":".join([
doc_id, r.get("name", ""), r.get("name2") or "", r.get("addr1", ""),
r.get("addr2") or "", r.get("city", ""), r.get("state", "").upper(),
r.get("zip", ""),
])
def render_letter_pdf(*, sender_block: list[str], recipient_block: list[str],
date_str: str, subject: str, body: str) -> tuple[bytes, int]:
"""Render a single-page (or multi-page) letter PDF. Returns (bytes, page_count).
Address blocks are placed in the standard #10 double-window positions
(verified via LetterStream preflight before first production send).
"""
from fpdf import FPDF
def _safe(s: str) -> str:
return (s or "").encode("latin-1", "replace").decode("latin-1")
pdf = FPDF(unit="mm", format="Letter")
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
# Return-address window (top-left)
pdf.set_font("Helvetica", size=10)
y = 14.0
for line in sender_block:
pdf.set_xy(14.3, y)
pdf.cell(0, 4.2, _safe(line))
y += 4.2
# Recipient window
y = 50.8
for line in recipient_block:
pdf.set_xy(14.3, y)
pdf.cell(0, 4.2, _safe(line))
y += 4.2
# Date
pdf.set_xy(14.3, 76.0)
pdf.cell(0, 5, _safe(date_str))
# Subject (bold)
pdf.set_font("Helvetica", "B", size=12)
pdf.set_xy(14.3, 84.0)
pdf.multi_cell(180, 6, _safe(subject))
# Body
pdf.set_font("Helvetica", size=10)
pdf.set_xy(14.3, 94.0)
pdf.multi_cell(180, 4.6, _safe(body))
out = pdf.output(dest="S")
return out, len(pdf.pages)
def _sender_block() -> list[str]:
parts = sender_parts()
name1, _name2, addr1, addr2, city, state, zipc = parts
lines = [name1]
if addr1:
lines.append(addr1)
if addr2:
lines.append(addr2)
lines.append(f"{city}, {state} {zipc}")
return lines
def _recipient_block(r: dict) -> list[str]:
lines = [r.get("name", "")]
if r.get("name2"):
lines.append(r["name2"])
lines.append(r.get("addr1", ""))
if r.get("addr2"):
lines.append(r["addr2"])
lines.append(f"{r.get('city', '')}, {r.get('state', '')} {r.get('zip', '')}")
return lines
def _letter_row(conn, letter_id: str):
return conn.execute(
"SELECT l.*, c.claim_number FROM letters l "
"JOIN claims c ON c.id = l.claim_id WHERE l.id = ?",
(letter_id,),
).fetchone()
def _serialize(row) -> dict:
return {
"id": row["id"],
"claim_id": row["claim_id"],
"claim_number": row["claim_number"],
"letter_type": row["letter_type"],
"subject": row["subject"],
"body": row["body"],
"recipient": json.loads(row["recipient_json"]),
"sender": json.loads(row["sender_json"]),
"mailtype": row["mailtype"],
"status": row["status"],
"job_id": row["job_id"],
"batch_id": row["batch_id"],
"doc_id": row["doc_id"],
"tracking_no": row["tracking_no"],
"cost_cents": row["cost_cents"],
"pages": row["pages"],
"error": row["error"],
"note": row["note"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"sent_at": row["sent_at"],
}
# ---------------------------------------------------------------
# POST /api/staff/claims/{claim_number}/letters (create draft)
# ---------------------------------------------------------------
@router.post("/api/staff/claims/{claim_number}/letters")
async def create_letter(claim_number: str, request: Request,
staff_name: str = Depends(authmod.require_staff)):
try:
body = await request.json()
except Exception:
return _err("validation_error", "Invalid JSON body.", status.HTTP_422_UNPROCESSABLE_ENTITY)
try:
lc = LetterCreate.model_validate(body)
except ValidationError as exc:
parts = [f"{'.'.join(str(x) for x in e['loc'])}: {e['msg']}" for e in exc.errors()]
return _err("validation_error", "; ".join(parts), status.HTTP_422_UNPROCESSABLE_ENTITY)
with get_conn() as conn:
row = conn.execute(
"SELECT c.id, c.letter_subject, c.letter_body, d.name AS debtor_name "
"FROM claims c JOIN debtors d ON d.id = c.debtor_id WHERE c.claim_number = ?",
(claim_number,),
).fetchone()
if row is None:
return _err("not_found", "Claim not found.", status.HTTP_404_NOT_FOUND)
subject = lc.subject or row["letter_subject"] or "Notice from Debt Recovery Experts, LLC"
body_text = lc.body or row["letter_body"] or ""
if not body_text.strip():
return _err("validation_error",
"Claim has no letter body; generate a letter first.",
status.HTTP_422_UNPROCESSABLE_ENTITY)
recipient = lc.recipient.model_dump()
# Sender address is global config (LETTERSTREAM_RETURN_ADDRESS), resolved at
# send time. A draft records only the signatory; drafting must not block on
# the return address, which may not be configured yet.
sender = {"name": SIGNATORY, "name2": "", "addr1": "", "addr2": "",
"city": "", "state": "", "zip": ""}
now = utcnow_iso()
letter_id = new_uuid()
conn.execute(
"INSERT INTO letters (id, claim_id, letter_type, subject, body, recipient_json, "
"sender_json, mailtype, status, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', ?, ?)",
(letter_id, row["id"], lc.letter_type, subject, body_text,
json.dumps(recipient), json.dumps(sender), lc.mailtype, now, now),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
"VALUES (?, 'letter', ?, 'create', 'status', NULL, 'DRAFT', ?, ?)",
(new_uuid(), letter_id, staff_name, now),
)
conn.commit()
return await _get_letter(letter_id)
# ---------------------------------------------------------------
# GET /api/staff/letters
# ---------------------------------------------------------------
@router.get("/api/staff/letters")
async def list_letters(request: Request, _staff=Depends(authmod.require_staff)):
status_filter = request.query_params.get("status")
limit = min(int(request.query_params.get("limit", "50")), 200)
offset = max(int(request.query_params.get("offset", "0")), 0)
where = ""
params: list = []
if status_filter:
where = "WHERE l.status = ?"
params.append(status_filter)
from_clause = "FROM letters l JOIN claims c ON c.id = l.claim_id"
with get_conn() as conn:
rows = conn.execute(
f"SELECT l.*, c.claim_number {from_clause} {where} "
f"ORDER BY l.created_at DESC LIMIT ? OFFSET ?",
tuple(params + [limit, offset]),
).fetchall()
total = conn.execute(
f"SELECT COUNT(*) AS n {from_clause} {where}", tuple(params),
).fetchone()["n"]
return {"letters": [_serialize(r) for r in rows], "total": total,
"limit": limit, "offset": offset}
# ---------------------------------------------------------------
# GET /api/staff/letters/{letter_id}
# ---------------------------------------------------------------
@router.get("/api/staff/letters/{letter_id}")
async def get_letter(letter_id: str, _staff=Depends(authmod.require_staff)):
return await _get_letter(letter_id)
async def _get_letter(letter_id: str):
with get_conn() as conn:
row = _letter_row(conn, letter_id)
if row is None:
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
events = conn.execute(
"SELECT scan_code, scan_status, scan_date, scan_zip, scan_facility, tracking_id, "
"created_at FROM letter_events WHERE letter_id = ? ORDER BY created_at ASC",
(letter_id,),
).fetchall()
data = _serialize(row)
data["events"] = [dict(e) for e in events]
return data
# ---------------------------------------------------------------
# POST /api/staff/letters/{letter_id}/approve (DRAFT -> APPROVED)
# ---------------------------------------------------------------
@router.post("/api/staff/letters/{letter_id}/approve")
async def approve_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
with get_conn() as conn:
row = _letter_row(conn, letter_id)
if row is None:
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
if row["status"] != "DRAFT":
return _err("conflict", f"Letter is {row['status']}, not DRAFT.",
status.HTTP_409_CONFLICT)
now = utcnow_iso()
conn.execute("UPDATE letters SET status = 'APPROVED', updated_at = ? WHERE id = ?",
(now, letter_id))
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
"VALUES (?, 'letter', ?, 'approve', 'status', 'DRAFT', 'APPROVED', ?, ?)",
(new_uuid(), letter_id, staff_name, now),
)
conn.commit()
return await _get_letter(letter_id)
# ---------------------------------------------------------------
# POST /api/staff/letters/{letter_id}/send (preauth — price only)
# ---------------------------------------------------------------
@router.post("/api/staff/letters/{letter_id}/send")
async def send_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
with get_conn() as conn:
row = _letter_row(conn, letter_id)
if row is None:
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
if row["status"] not in ("APPROVED", "PREAUTH", "ERROR"):
return _err("conflict", f"Letter is {row['status']}; approve it first.",
status.HTTP_409_CONFLICT)
recipient = json.loads(row["recipient_json"])
subject = row["subject"] or ""
body = row["body"] or ""
try:
sender = {k: v for k, v in zip(
("name", "name2", "addr1", "addr2", "city", "state", "zip"), sender_parts())}
except LetterStreamError as exc:
return _err("config_error", str(exc), status.HTTP_409_CONFLICT)
# Render + persist PDF
doc_id = new_uuid().replace("-", "")[:16]
job = "DRE" + doc_id
try:
pdf_bytes, pages = render_letter_pdf(
sender_block=_sender_block(), recipient_block=_recipient_block(recipient),
date_str=utcnow_iso()[:10], subject=subject, body=body,
)
except Exception as exc:
return _err("render_error", f"PDF render failed: {exc}", status.HTTP_500_INTERNAL_SERVER_ERROR)
os.makedirs(LETTERS_DIR, exist_ok=True)
pdf_path = os.path.join(LETTERS_DIR, f"{letter_id}.pdf")
with open(pdf_path, "wb") as fh:
fh.write(pdf_bytes)
try:
resp = letterstream.send_single(
pdf_bytes, f"{letter_id}.pdf", job, sender_string(),
[recipient_string(doc_id, recipient)], pages,
mailtype=row["mailtype"], preauth=True,
)
except letterstream.LetterStreamError as exc:
now = utcnow_iso()
conn.execute(
"UPDATE letters SET status = 'ERROR', error = ?, updated_at = ? WHERE id = ?",
(str(exc), now, letter_id),
)
conn.commit()
return _err("letterstream_error", str(exc), status.HTTP_502_BAD_GATEWAY)
m = letterstream._first(resp) if resp.get("messages") else {}
authcode = m.get("authcode", "")
cost = m.get("cost")
batch = m.get("batch")
docs = m.get("docs", [])
ls_doc = docs[0].get("id") if docs else None
cost_cents = int(round(float(cost) * 100)) if cost else None
now = utcnow_iso()
conn.execute(
"UPDATE letters SET status = 'PREAUTH', job_id = ?, batch_id = ?, doc_id = ?, "
"sender_json = ?, authcode = ?, cost_cents = ?, pages = ?, pdf_path = ?, error = NULL, updated_at = ? "
"WHERE id = ?",
(job, batch, ls_doc, json.dumps(sender), authcode, cost_cents, pages, pdf_path, now, letter_id),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
"VALUES (?, 'letter', ?, 'preauth', 'status', NULL, 'PREAUTH', ?, ?)",
(new_uuid(), letter_id, staff_name, now),
)
conn.commit()
return await _get_letter(letter_id)
# ---------------------------------------------------------------
# POST /api/staff/letters/{letter_id}/confirm (doauth — release)
# ---------------------------------------------------------------
@router.post("/api/staff/letters/{letter_id}/confirm")
async def confirm_letter(letter_id: str, staff_name: str = Depends(authmod.require_staff)):
with get_conn() as conn:
row = _letter_row(conn, letter_id)
if row is None:
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
if row["status"] != "PREAUTH" or not row["authcode"]:
return _err("conflict", "Letter has no pending preauth to confirm.",
status.HTTP_409_CONFLICT)
try:
letterstream.doauth(row["authcode"])
except letterstream.LetterStreamError as exc:
now = utcnow_iso()
conn.execute(
"UPDATE letters SET status = 'ERROR', error = ?, updated_at = ? WHERE id = ?",
(str(exc), now, letter_id),
)
conn.commit()
return _err("letterstream_error", str(exc), status.HTTP_502_BAD_GATEWAY)
now = utcnow_iso()
conn.execute(
"UPDATE letters SET status = 'SENT', sent_at = ?, updated_at = ? WHERE id = ?",
(now, now, letter_id),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
"VALUES (?, 'letter', ?, 'confirm', 'status', 'PREAUTH', 'SENT', ?, ?)",
(new_uuid(), letter_id, staff_name, now),
)
conn.commit()
return await _get_letter(letter_id)
# ---------------------------------------------------------------
# POST /api/staff/letters/{letter_id}/reject (terminal — REJECTED)
# ---------------------------------------------------------------
@router.post("/api/staff/letters/{letter_id}/reject")
async def reject_letter(letter_id: str, request: Request,
staff_name: str = Depends(authmod.require_staff)):
reason = ""
try:
body = await request.json()
if isinstance(body, dict):
reason = str(body.get("reason") or "").strip()
except Exception:
pass
if not reason:
return _err("validation_error", "A reason is required to reject a letter.",
status.HTTP_422_UNPROCESSABLE_ENTITY)
reason = reason[:1000]
with get_conn() as conn:
row = _letter_row(conn, letter_id)
if row is None:
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
if row["status"] in ("SENT", "REJECTED", "CANCELLED"):
return _err("conflict", f"Letter is {row['status']}; cannot reject.",
status.HTTP_409_CONFLICT)
old = row["status"]
now = utcnow_iso()
conn.execute(
"UPDATE letters SET status = 'REJECTED', note = ?, error = NULL, updated_at = ? WHERE id = ?",
(reason, now, letter_id),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
"VALUES (?, 'letter', ?, 'reject', 'status', ?, 'REJECTED', ?, ?)",
(new_uuid(), letter_id, old, staff_name, now),
)
conn.commit()
return await _get_letter(letter_id)
# ---------------------------------------------------------------
# POST /api/staff/letters/{letter_id}/cancel (terminal — CANCELLED)
# ---------------------------------------------------------------
@router.post("/api/staff/letters/{letter_id}/cancel")
async def cancel_letter(letter_id: str, request: Request,
staff_name: str = Depends(authmod.require_staff)):
reason = ""
try:
body = await request.json()
if isinstance(body, dict):
reason = str(body.get("reason") or "").strip()
except Exception:
pass
reason = reason[:1000]
with get_conn() as conn:
row = _letter_row(conn, letter_id)
if row is None:
return _err("not_found", "Letter not found.", status.HTTP_404_NOT_FOUND)
if row["status"] in ("SENT", "REJECTED", "CANCELLED"):
return _err("conflict", f"Letter is {row['status']}; cannot cancel.",
status.HTTP_409_CONFLICT)
old = row["status"]
now = utcnow_iso()
conn.execute(
"UPDATE letters SET status = 'CANCELLED', note = ?, error = NULL, updated_at = ? WHERE id = ?",
(reason, now, letter_id),
)
conn.execute(
"INSERT INTO audit_log (id, entity_type, entity_id, action, field, old_value, new_value, actor, created_at) "
"VALUES (?, 'letter', ?, 'cancel', 'status', ?, 'CANCELLED', ?, ?)",
(new_uuid(), letter_id, old, staff_name, now),
)
conn.commit()
return await _get_letter(letter_id)
# ---------------------------------------------------------------
# POST /api/letters/webhook (PUBLIC — LetterStream tracking callback)
# ---------------------------------------------------------------
@router.post("/api/letters/webhook")
async def letter_webhook(request: Request):
ctype = request.headers.get("content-type", "")
data: dict = {}
if "application/json" in ctype:
try:
data = await request.json()
except Exception:
data = {}
else:
try:
form = await request.form()
data = {k: v for k, v in form.items()}
except Exception:
data = {}
key = data.get("key", "")
if not CALLBACK_KEY or key != CALLBACK_KEY:
return JSONResponse(status_code=401,
content={"success": False, "reason": "Invalid key"})
raw_json = data.get("json", "")
payload = None
try:
payload = json.loads(raw_json) if isinstance(raw_json, str) else raw_json
except (ValueError, TypeError):
payload = None
events: list[dict] = []
if isinstance(payload, list):
events = [e for e in payload if isinstance(e, dict)]
elif isinstance(payload, dict):
if isinstance(payload.get("data"), list):
events = [e for e in payload["data"] if isinstance(e, dict)]
elif payload:
events = [payload]
with get_conn() as conn:
for ev in events:
doc_id = str(ev.get("doc_id", ""))
job_id = str(ev.get("job_id", ""))
letter = None
if doc_id:
letter = conn.execute("SELECT id FROM letters WHERE doc_id = ?", (doc_id,)).fetchone()
if letter is None and job_id:
letter = conn.execute("SELECT id FROM letters WHERE job_id = ?", (job_id,)).fetchone()
if letter is None:
logger.warning("letter webhook: no letter for doc_id=%s job_id=%s", doc_id, job_id)
continue
conn.execute(
"INSERT INTO letter_events (id, letter_id, scan_code, scan_status, scan_date, "
"scan_zip, scan_facility, tracking_id, batch_id, job_id, doc_id, raw_json, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(new_uuid(), letter["id"], ev.get("scan_code"), ev.get("scan_status"),
ev.get("scan_date"), ev.get("scan_zip"), ev.get("scan_facility"),
ev.get("tracking_id"), ev.get("batch_id"), job_id, doc_id,
json.dumps(ev), utcnow_iso()),
)
if ev.get("tracking_id"):
conn.execute(
"UPDATE letters SET tracking_no = COALESCE(tracking_no, ?), updated_at = ? WHERE id = ?",
(str(ev["tracking_id"]), utcnow_iso(), letter["id"]),
)
conn.commit()
return {"success": True, "reason": "Received data"}
def _err(code: str, message: str, status_code: int):
return JSONResponse(status_code=status_code,
content={"error": {"code": code, "message": message}})