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).
This commit is contained in:
root
2026-08-26 02:26:33 -04:00
parent 7a5603b495
commit 7a62b0b340
46 changed files with 11004 additions and 35 deletions
+140
View File
@@ -0,0 +1,140 @@
"""DocuSeal e-signature integration.
Self-hosted DocuSeal: sign.debtrecoveryexperts.com (loopback 127.0.0.1:8094).
Auth: X-Auth-Token header. We only CREATE submissions against the 6 pre-authored
templates — template creation is not supported on self-hosted via the API, so the
templates are authored once in the DocuSeal UI and referenced by name here.
Pre-fill: the portal's merged field values (packet.merged_values) map onto the
DocuSeal text-field tokens via packet.DOC_PLACEHOLDERS. Signature/date/print-name
fields are intentionally left blank — they are completed by the human at signing.
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.request
from . import packet
BASE_URL = os.environ.get("DOCUSEAL_BASE_URL", "http://127.0.0.1:8094").rstrip("/")
API_TOKEN = os.environ.get("DOCUSEAL_API_TOKEN", "")
SIGN_HOST = os.environ.get("DOCUSEAL_SIGN_HOST", "https://sign.debtrecoveryexperts.com").rstrip("/")
# doc_key -> (DocuSeal template name, markdown source filename in docs/welcome-packet/).
# Keys mirror packet.ONBOARDING_DOCS; names mirror packet.DOC_TITLES exactly.
DOC_MAP = {
"LPOA": ("LPOA - Limited Power of Attorney", "01-LPOA.md"),
"TOS": ("Terms of Service", "02-Terms-of-Service.md"),
"FEE_SCHEDULE": ("Fee Schedule (Schedule A)", "03-Fee-Schedule.md"),
"THIRD_PARTY_CONSENT": ("Third-Party Sharing Consent", "04-Third-Party-Consent.md"),
"DEBTOR_INFO": ("Debtor Information Sheet", "05-Debtor-Info-Sheet.md"),
"ACH": ("ACH / Disbursement Authorization", "06-ACH-Authorization.md"),
}
class DocuSealError(Exception):
"""Raised when the DocuSeal API call fails or a template is missing."""
def _request(method: str, path: str, payload: dict | None = None):
url = f"{BASE_URL}{path}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
url,
data=data,
headers={
"X-Auth-Token": API_TOKEN,
"Content-Type": "application/json",
"Accept": "application/json",
},
method=method,
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
body = resp.read().decode()
return json.loads(body) if body else None
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
raise DocuSealError(f"DocuSeal HTTP {e.code}: {body[:800]}") from e
except urllib.error.URLError as e:
raise DocuSealError(f"DocuSeal unreachable: {e.reason}") from e
_template_cache: dict[str, int] = {}
def template_id(doc_key: str) -> int:
"""Resolve a doc_key to its DocuSeal template id (cached per process)."""
if not API_TOKEN:
raise DocuSealError("DOCUSEAL_API_TOKEN is not configured")
if doc_key in _template_cache:
return _template_cache[doc_key]
tmpl_name = DOC_MAP[doc_key][0]
resp = _request("GET", "/api/templates")
data = resp.get("data", []) if isinstance(resp, dict) else resp
for t in data:
if t.get("name") == tmpl_name:
_template_cache[doc_key] = t["id"]
return t["id"]
raise DocuSealError(f"No DocuSeal template named: {tmpl_name}")
def build_prefill(doc_key: str, values: dict) -> tuple[dict, list[dict]]:
"""Map portal merged field values to DocuSeal pre-fill tokens for one doc.
Returns (values, fields) for the submission payload. Only non-empty values
are pre-filled; signature/date/print-name blocks stay blank for the signer.
"""
_name, filename = DOC_MAP[doc_key]
out: dict[str, str] = {}
for token, profile_key in packet.DOC_PLACEHOLDERS.get(filename, []):
val = values.get(profile_key, "")
if val:
out[token] = str(val)
fields = [{"name": k, "default_value": v} for k, v in out.items()]
return out, fields
def signing_url(slug: str | None, embed_src: str | None = None) -> str | None:
"""Public signing URL for a submitter. Prefers the canonical /s/<slug> path."""
if slug:
return f"{SIGN_HOST}/s/{slug}"
return embed_src
def create_submission(doc_key: str, email: str, name: str, values: dict,
send_email: bool = True, message: dict | None = None) -> dict:
"""Create one signature request for the client. Returns the submitter object."""
tid = template_id(doc_key)
vals, fields = build_prefill(doc_key, values)
tmpl_name = DOC_MAP[doc_key][0]
payload = {
"template_id": tid,
"send_email": send_email,
"message": message or {
"subject": f"Please review and sign: {tmpl_name}",
"body": (
"Hi {{submitter.name}},\n\n"
"Please open the link below to review the prefilled details and "
"complete your signature.\n\n{{submitter.link}}\n\n"
"Thank you,\nDebt Recovery Experts, LLC"
),
},
"submitters": [
{
"role": "Client",
"email": email,
"name": name,
"values": vals,
"fields": fields,
},
],
}
resp = _request("POST", "/api/submissions", payload)
if isinstance(resp, list) and resp:
return resp[0]
if isinstance(resp, dict):
return resp
raise DocuSealError(f"Unexpected DocuSeal response type: {type(resp).__name__}")