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:
@@ -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__}")
|
||||
Reference in New Issue
Block a user