diff --git a/api/migrations/076_esign_records.sql b/api/migrations/076_esign_records.sql new file mode 100644 index 0000000..472038f --- /dev/null +++ b/api/migrations/076_esign_records.sql @@ -0,0 +1,48 @@ +-- 076: Generic eSign records — stores signatures for any document type. +-- +-- Instead of adding esign columns to every order table, this table +-- stores one row per signature event, linked by order_number. +-- +-- Supports: RMD, CPNI, CALEA SSI, 499-A engagement, discontinuance, +-- CRTC, and any future document types. +-- +-- The portal token carries { order_id (= order_number), order_type, email }. +-- order_type tells us which table/service the document belongs to. + +CREATE TABLE IF NOT EXISTS esign_records ( + id SERIAL PRIMARY KEY, + order_number TEXT NOT NULL, -- e.g. CO-ABCD1234 + document_type TEXT NOT NULL, -- rmd, cpni, calea, 499a-engagement, discontinuance, crtc, etc. + document_title TEXT NOT NULL DEFAULT '', -- human-readable title shown to signer + entity_name TEXT NOT NULL DEFAULT '', -- company name shown on signing page + + -- Document reference + document_minio_key TEXT, -- MinIO key for the PDF to sign + document_metadata JSONB DEFAULT '{}', -- any extra data (FRN, order details, etc.) + + -- Signature + signature_type TEXT CHECK (signature_type IN ('drawn', 'typed')), + signature_data TEXT, -- base64 PNG (drawn) or typed name + signer_email TEXT, + signer_ip TEXT, + signer_user_agent TEXT, + + -- Perjury declaration + requires_perjury BOOLEAN DEFAULT FALSE, + perjury_agreed BOOLEAN DEFAULT FALSE, + + -- Status + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'signed', 'expired', 'revoked')), + signed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_esign_order ON esign_records(order_number); +CREATE INDEX IF NOT EXISTS idx_esign_status ON esign_records(status) WHERE status = 'pending'; +CREATE UNIQUE INDEX IF NOT EXISTS idx_esign_order_doc ON esign_records(order_number, document_type) + WHERE status IN ('pending', 'signed'); diff --git a/api/src/index.ts b/api/src/index.ts index f5a51dd..f4634aa 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -43,6 +43,7 @@ import adminCryptoRouter from "./routes/admin-crypto.js"; import foreignQualRouter from "./routes/foreign-qualification.js"; import corpStatusRouter from "./routes/corp-status.js"; import portalRmdReviewRouter from "./routes/portal-rmd-review.js"; +import portalEsignGenericRouter from "./routes/portal-esign-generic.js"; import pucRouter from "./routes/puc.js"; import fccCarrierRegRouter from "./routes/fcc-carrier-registration.js"; @@ -101,6 +102,7 @@ app.use("/api/v1/auth", portalAuthRouter); app.use(portalSetupRouter); app.use(portalEsignRouter); app.use(portalRmdReviewRouter); +app.use(portalEsignGenericRouter); app.use("/api/v1/portal", portalRouter); // Must be AFTER specific portal routes (uses catch-all customer-auth) app.use(fccLookupRouter); app.use(corpStatusRouter); diff --git a/api/src/routes/portal-esign-generic.ts b/api/src/routes/portal-esign-generic.ts new file mode 100644 index 0000000..f8e1e20 --- /dev/null +++ b/api/src/routes/portal-esign-generic.ts @@ -0,0 +1,233 @@ +/** + * Generic eSign portal — client signs any compliance document. + * + * GET /api/v1/portal/esign — document info + presigned PDF URL + * POST /api/v1/portal/esign — accept signature, advance pipeline + * + * Supported document types: rmd, cpni, calea, 499a-engagement, + * discontinuance, crtc, and any future types added to esign_records. + * + * Flow: + * 1. Service handler generates document, uploads PDF to MinIO + * 2. Handler inserts row into esign_records (status = 'pending') + * 3. Handler emails client a JWT link: /portal/esign?token= + * 4. Client opens page → GET /portal/esign → sees PDF + signing UI + * 5. Client signs → POST /portal/esign → signature stored, status = 'signed' + * 6. API dispatches resume job to workers so the pipeline continues + * + * The portal JWT carries { order_id, order_type, email }. + * order_id = order_number, order_type = document_type. + */ + +import { Router, type Request, type Response } from "express"; +import { pool } from "../db.js"; +import { requirePortalAuth } from "../middleware/portalAuth.js"; + +const router = Router(); +const WORKER_URL = process.env.WORKER_URL || "http://workers:8090"; + +/** Ask the workers job server for a presigned MinIO GET URL. */ +async function presignGet(key: string, expires = 3600): Promise { + if (!key) return null; + try { + const r = await fetch(`${WORKER_URL}/jobs/presign`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, expires, method: "GET" }), + }); + if (!r.ok) return null; + const data = (await r.json()) as { url?: string }; + return data.url || null; + } catch { + return null; + } +} + + +// ── GET /api/v1/portal/esign ──────────────────────────────────────────────── +// +// Returns document info for the signing page. Token identifies the order + doc type. +// + +router.get("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: Response) => { + const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!; + + try { + const { rows } = await pool.query( + `SELECT id, order_number, document_type, document_title, entity_name, + document_minio_key, document_metadata, requires_perjury, + status, signed_at + FROM esign_records + WHERE order_number = $1 + AND document_type = $2 + AND status IN ('pending', 'signed') + ORDER BY created_at DESC + LIMIT 1`, + [orderNumber, documentType], + ); + + if (!rows.length) { + res.status(404).json({ error: "No document found for this signing link. It may have expired." }); + return; + } + + const rec = rows[0] as Record; + + // Already signed — return confirmation + if (rec.status === "signed") { + res.json({ + already_signed: true, + signed_at: rec.signed_at, + document_title: rec.document_title, + entity_name: rec.entity_name, + }); + return; + } + + // Get presigned URL for PDF preview + let documentUrl: string | null = null; + if (rec.document_minio_key) { + documentUrl = await presignGet(rec.document_minio_key); + } + + // Pull order number for subtitle (e.g. "CO-ABC12345") + const metadata = rec.document_metadata || {}; + + res.json({ + document_title: rec.document_title, + entity_name: rec.entity_name, + order_number: rec.order_number, + document_url: documentUrl, + requires_perjury: rec.requires_perjury || false, + metadata, + }); + } catch (err) { + console.error("[portal/esign GET] Error:", err); + res.status(500).json({ error: "Could not load document. Please try again." }); + } +}); + + +// ── POST /api/v1/portal/esign ─────────────────────────────────────────────── +// +// Body: { token, signature: { type, image_b64|name }, agreed_at, user_agent } +// + +router.post("/api/v1/portal/esign", requirePortalAuth, async (req: Request, res: Response) => { + const { order_id: orderNumber, order_type: documentType, email } = req.portalAuth!; + + const { signature, agreed_at, user_agent } = req.body as { + signature?: { type: "drawn" | "typed"; image_b64?: string; name?: string }; + agreed_at?: string; + user_agent?: string; + }; + + // Validate signature + if (!signature || !signature.type) { + res.status(400).json({ error: "Signature is required." }); + return; + } + + if (signature.type === "drawn") { + if (!signature.image_b64 || signature.image_b64.length < 200) { + res.status(400).json({ error: "Please draw your signature before submitting." }); + return; + } + } else if (signature.type === "typed") { + if (!signature.name || signature.name.trim().length < 2) { + res.status(400).json({ error: "Please type your full name." }); + return; + } + } else { + res.status(400).json({ error: "Invalid signature type." }); + return; + } + + try { + // Find the pending record + const { rows } = await pool.query( + `SELECT id, order_number, document_type, status, requires_perjury + FROM esign_records + WHERE order_number = $1 + AND document_type = $2 + AND status IN ('pending', 'signed') + ORDER BY created_at DESC + LIMIT 1`, + [orderNumber, documentType], + ); + + if (!rows.length) { + res.status(404).json({ error: "No document found for this signing link." }); + return; + } + + const rec = rows[0] as Record; + + // Idempotent — already signed + if (rec.status === "signed") { + res.json({ success: true, already_signed: true, signed_at: rec.signed_at }); + return; + } + + // Store the signature + const sigData = signature.type === "drawn" + ? signature.image_b64!.replace(/^data:image\/png;base64,/, "") + : signature.name!.trim(); + + const clientIp = (req as any).clientIp || req.ip || ""; + const signedAt = new Date().toISOString(); + + await pool.query( + `UPDATE esign_records + SET status = 'signed', + signature_type = $1, + signature_data = $2, + signer_email = $3, + signer_ip = $4, + signer_user_agent = $5, + signed_at = $6, + perjury_agreed = $7, + updated_at = NOW() + WHERE id = $8`, + [ + signature.type, + sigData, + email, + clientIp, + user_agent || "", + signedAt, + rec.requires_perjury ? true : false, + rec.id, + ], + ); + + // Notify workers to resume the pipeline + try { + await fetch(`${WORKER_URL}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "esign_completed", + order_number: orderNumber, + document_type: documentType, + esign_record_id: rec.id, + signer_email: email, + }), + }); + } catch (dispatchErr) { + // Non-fatal — worker will pick up on next scheduled run + console.error("[portal/esign POST] Worker dispatch failed:", dispatchErr); + } + + res.json({ + success: true, + signed_at: signedAt, + message: "Your signature has been recorded. Thank you.", + }); + } catch (err) { + console.error("[portal/esign POST] Error:", err); + res.status(500).json({ error: "Could not record signature. Please try again." }); + } +}); + +export default router; diff --git a/scripts/workers/job_server.py b/scripts/workers/job_server.py index 40bc1bc..52c88a0 100644 --- a/scripts/workers/job_server.py +++ b/scripts/workers/job_server.py @@ -1556,6 +1556,60 @@ def handle_presign(payload: dict) -> dict: return {"error": str(exc)} +def handle_esign_completed(payload: dict) -> dict: + """Generic eSign completion callback — resume the service pipeline. + + Called by portal-esign-generic.ts after a client signs any document. + Payload: { order_number, document_type, esign_record_id, signer_email } + + Looks up the compliance order for this order_number and re-dispatches + the service handler with client_approved=true so it continues past + the signing checkpoint. + """ + order_number = payload.get("order_number", "") + document_type = payload.get("document_type", "") + if not order_number: + return {"error": "order_number required"} + + LOG.info("[esign_completed] Signature received for %s (type=%s)", order_number, document_type) + + try: + import psycopg2 + conn = psycopg2.connect(os.environ.get("DATABASE_URL", "")) + with conn.cursor() as cur: + cur.execute( + "SELECT service_slug FROM compliance_orders WHERE order_number = %s", + (order_number,), + ) + row = cur.fetchone() + conn.close() + + if not row: + LOG.warning("[esign_completed] No compliance_order found for %s", order_number) + return {"warning": f"No compliance order for {order_number}"} + + service_slug = row[0] + + # Re-dispatch the service handler with approval flag + from scripts.workers.services import SERVICE_HANDLERS + handler_cls = SERVICE_HANDLERS.get(service_slug) + if handler_cls: + LOG.info("[esign_completed] Re-dispatching %s for %s", service_slug, order_number) + handler = handler_cls() + handler.process(order_number, { + "client_approved": True, + "esign_document_type": document_type, + "esign_signer_email": payload.get("signer_email", ""), + }) + else: + LOG.warning("[esign_completed] No handler for slug=%s", service_slug) + + return {"success": True, "order_number": order_number, "document_type": document_type} + except Exception as exc: + LOG.error("[esign_completed] Error for %s: %s", order_number, exc) + return {"error": str(exc)} + + def handle_resume_crtc_pipeline(payload: dict) -> dict: """Resume the CRTC pipeline after an async pause (eSign, domain selection, etc.). @@ -1721,6 +1775,7 @@ JOB_HANDLERS = { "purchase_client_selections": handle_purchase_client_selections, # eSign / MinIO helpers "presign": handle_presign, + "esign_completed": handle_esign_completed, "resume_crtc_pipeline": handle_resume_crtc_pipeline, # Compliance calendar renewal "renewal_payment": handle_renewal_payment, diff --git a/scripts/workers/services/telecom/esign_helper.py b/scripts/workers/services/telecom/esign_helper.py new file mode 100644 index 0000000..eda6b6a --- /dev/null +++ b/scripts/workers/services/telecom/esign_helper.py @@ -0,0 +1,228 @@ +"""Generic eSign helper — create signing records and send signing links. + +Usage from any service handler: + + from scripts.workers.services.telecom.esign_helper import request_esign + + request_esign( + conn=conn, + order_number="CO-ABC12345", + document_type="rmd", + document_title="RMD Certification Letter", + entity_name="Acme Telecom LLC", + customer_email="john@example.com", + customer_name="John Smith", + document_minio_key="compliance/CO-ABC12345/rmd_letter.pdf", + requires_perjury=True, + metadata={"frn": "0015341902"}, + ) + +This will: + 1. INSERT a row into esign_records (status = 'pending') + 2. Generate a JWT portal token + 3. Send a signing email with the portal link +""" +import logging +import os +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +logger = logging.getLogger("esign_helper") + + +def request_esign( + conn, + order_number: str, + document_type: str, + document_title: str, + entity_name: str, + customer_email: str, + customer_name: str = "", + document_minio_key: str = "", + requires_perjury: bool = False, + metadata: dict | None = None, + expires_hours: int = 72, +) -> int | None: + """Create an esign record and email the signing link. + + Returns the esign_records.id on success, None on failure. + """ + import json + from datetime import datetime, timedelta, timezone + + try: + import jwt as pyjwt + except ImportError: + try: + import PyJWT as pyjwt # type: ignore + except ImportError: + logger.error("No JWT library available — cannot create esign link") + return None + + secret = os.environ.get("CUSTOMER_JWT_SECRET", "changeme") + domain = os.environ.get("DOMAIN", "performancewest.net") + expires_at = datetime.now(timezone.utc) + timedelta(hours=expires_hours) + + # 1. Upsert into esign_records (replace any existing pending record) + try: + with conn.cursor() as cur: + cur.execute( + """INSERT INTO esign_records + (order_number, document_type, document_title, entity_name, + document_minio_key, document_metadata, requires_perjury, + status, expires_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending', %s) + ON CONFLICT (order_number, document_type) + WHERE status IN ('pending', 'signed') + DO UPDATE SET + document_title = EXCLUDED.document_title, + entity_name = EXCLUDED.entity_name, + document_minio_key = EXCLUDED.document_minio_key, + document_metadata = EXCLUDED.document_metadata, + requires_perjury = EXCLUDED.requires_perjury, + expires_at = EXCLUDED.expires_at, + updated_at = NOW() + RETURNING id""", + ( + order_number, + document_type, + document_title, + entity_name, + document_minio_key, + json.dumps(metadata or {}), + requires_perjury, + expires_at, + ), + ) + row = cur.fetchone() + esign_id = row[0] if row else None + conn.commit() + except Exception as exc: + logger.error("Failed to insert esign record for %s: %s", order_number, exc) + conn.rollback() + return None + + # 2. Generate JWT portal token + token = pyjwt.encode( + { + "order_id": order_number, + "order_type": document_type, + "email": customer_email, + }, + secret, + algorithm="HS256", + ) + sign_url = f"https://{domain}/portal/esign/?token={token}" + + # 3. Send signing email + try: + _send_signing_email( + to_email=customer_email, + to_name=customer_name or entity_name, + entity_name=entity_name, + document_title=document_title, + sign_url=sign_url, + order_number=order_number, + ) + logger.info( + "eSign link sent to %s for %s (%s)", + customer_email, order_number, document_type, + ) + except Exception as exc: + logger.warning("Could not send eSign email for %s: %s", order_number, exc) + # Non-fatal — record exists, admin can resend + + return esign_id + + +def _send_signing_email( + to_email: str, + to_name: str, + entity_name: str, + document_title: str, + sign_url: str, + order_number: str, +) -> None: + """Send the signing invitation email.""" + smtp_host = os.environ.get("SMTP_HOST", "co.carrierone.com") + smtp_port = int(os.environ.get("SMTP_PORT", "587")) + smtp_user = os.environ.get("SMTP_USER", "") + smtp_pass = os.environ.get("SMTP_PASS", "") + + if not smtp_user or not smtp_pass: + logger.warning("SMTP credentials not configured — skipping eSign email") + return + + subject = f"Action Required: Sign Your {document_title}" + + body = f"""\ +
+
+

Document Ready for Signature

+

{entity_name}

+
+ +
+

Hi {to_name},

+ +

+ Your {document_title} is ready for review and signature. + Please click the button below to review the document and provide your electronic signature. +

+ + + +

+ This link expires in 72 hours. If it expires, contact us and we'll send a new one. +

+ +
+ +

+ Performance West Inc. — (888) 411-0383
+ Order: {order_number} +

+
+
""" + + msg = MIMEMultipart("alternative") + msg["From"] = "Performance West " + msg["To"] = f"{to_name} <{to_email}>" + msg["Subject"] = subject + msg["Reply-To"] = "info@performancewest.net" + msg.attach(MIMEText(body, "html")) + + with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg) + + +def check_esign_status(conn, order_number: str, document_type: str) -> dict | None: + """Check if a document has been signed. Returns the record dict or None.""" + with conn.cursor() as cur: + cur.execute( + """SELECT id, status, signed_at, signer_email, signature_type + FROM esign_records + WHERE order_number = %s AND document_type = %s + AND status IN ('pending', 'signed') + ORDER BY created_at DESC LIMIT 1""", + (order_number, document_type), + ) + row = cur.fetchone() + if not row: + return None + return { + "id": row[0], + "status": row[1], + "signed_at": row[2], + "signer_email": row[3], + "signature_type": row[4], + } diff --git a/site/public/portal/esign/index.html b/site/public/portal/esign/index.html new file mode 100644 index 0000000..c0ef00c --- /dev/null +++ b/site/public/portal/esign/index.html @@ -0,0 +1,313 @@ + + + + + +Review & Sign — Performance West Inc. + + + + + + +
+

Loading your document...

Verifying your link

+ + + + + + +
+ + + +