Add generic eSign portal for all compliance document types
Reusable signing flow: service handler generates document → inserts esign_records row → emails JWT link → client reviews PDF + signs → API stores signature + resumes pipeline. Works for RMD, CPNI, CALEA, 499-A engagement, discontinuance, CRTC, and any future doc types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
37a22cf474
commit
40844b2aff
6 changed files with 879 additions and 0 deletions
48
api/migrations/076_esign_records.sql
Normal file
48
api/migrations/076_esign_records.sql
Normal file
|
|
@ -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');
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
233
api/src/routes/portal-esign-generic.ts
Normal file
233
api/src/routes/portal-esign-generic.ts
Normal file
|
|
@ -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=<jwt>
|
||||
* 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<string | null> {
|
||||
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<string, any>;
|
||||
|
||||
// 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<string, any>;
|
||||
|
||||
// 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
228
scripts/workers/services/telecom/esign_helper.py
Normal file
228
scripts/workers/services/telecom/esign_helper.py
Normal file
|
|
@ -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"""\
|
||||
<div style="font-family:'Inter',system-ui,sans-serif;max-width:600px;margin:0 auto;color:#1f2937">
|
||||
<div style="background:#1e3a5f;color:#fff;padding:24px 28px;border-radius:12px 12px 0 0">
|
||||
<h1 style="margin:0;font-size:20px;font-weight:700">Document Ready for Signature</h1>
|
||||
<p style="margin:6px 0 0;opacity:.8;font-size:14px">{entity_name}</p>
|
||||
</div>
|
||||
|
||||
<div style="background:#fff;border:1px solid #e2e8f0;border-top:none;padding:28px;border-radius:0 0 12px 12px">
|
||||
<p style="margin:0 0 16px;font-size:15px">Hi {to_name},</p>
|
||||
|
||||
<p style="margin:0 0 16px;font-size:15px">
|
||||
Your <strong>{document_title}</strong> is ready for review and signature.
|
||||
Please click the button below to review the document and provide your electronic signature.
|
||||
</p>
|
||||
|
||||
<div style="text-align:center;margin:28px 0">
|
||||
<a href="{sign_url}"
|
||||
style="display:inline-block;background:#1e3a5f;color:#fff;padding:14px 36px;
|
||||
border-radius:10px;font-weight:700;font-size:16px;text-decoration:none">
|
||||
Review & Sign Document
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p style="margin:0 0 12px;font-size:13px;color:#64748b">
|
||||
This link expires in 72 hours. If it expires, contact us and we'll send a new one.
|
||||
</p>
|
||||
|
||||
<hr style="border:none;border-top:1px solid #e2e8f0;margin:20px 0">
|
||||
|
||||
<p style="margin:0;font-size:12px;color:#9ca3af">
|
||||
Performance West Inc. — (888) 411-0383<br>
|
||||
Order: {order_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>"""
|
||||
|
||||
msg = MIMEMultipart("alternative")
|
||||
msg["From"] = "Performance West <noreply@performancewest.net>"
|
||||
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],
|
||||
}
|
||||
313
site/public/portal/esign/index.html
Normal file
313
site/public/portal/esign/index.html
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Review & Sign — Performance West Inc.</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<script>
|
||||
window.__PW_API = (function() {
|
||||
var h = window.location.hostname;
|
||||
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
|
||||
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
|
||||
return "https://api.performancewest.net";
|
||||
})();
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter',system-ui,sans-serif;color:#1f2937;background:#f1f5f9;line-height:1.6}
|
||||
.wrap{max-width:720px;margin:0 auto;padding:2rem 1rem 4rem}
|
||||
.header{background:#1e3a5f;color:#fff;border-radius:12px;padding:1.75rem 1.5rem;margin-bottom:1.5rem}
|
||||
.header h1{margin:0 0 .3rem;font-size:1.35rem;font-weight:700}
|
||||
.header p{margin:0;opacity:.8;font-size:.875rem}
|
||||
.card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:1.5rem;margin-bottom:1.25rem;box-shadow:0 1px 4px rgba(0,0,0,.05)}
|
||||
.card h2{font-size:1rem;font-weight:700;color:#1e3a5f;margin:0 0 .75rem}
|
||||
.hint{font-size:.875rem;color:#475569;margin:0 0 .75rem}
|
||||
.pdf-frame{width:100%;height:520px;border:1px solid #cbd5e1;border-radius:8px;background:#f8fafc}
|
||||
#sig-canvas{width:100%;height:160px;border:2px solid #cbd5e1;border-radius:8px;cursor:crosshair;touch-action:none;background:#fafafa;display:block}
|
||||
#sig-canvas.has-sig{border-color:#1e3a5f}
|
||||
.sig-actions{display:flex;align-items:center;justify-content:space-between;margin-top:.5rem;font-size:.8rem;color:#64748b}
|
||||
.sig-clear{background:none;border:1px solid #e2e8f0;color:#475569;padding:.3rem .75rem;border-radius:6px;cursor:pointer;font-size:.8rem}
|
||||
.sig-clear:hover{background:#f1f5f9}
|
||||
/* Typed signature */
|
||||
.sig-tabs{display:flex;gap:.5rem;margin-bottom:.75rem}
|
||||
.sig-tab{flex:1;padding:.5rem;border:2px solid #e2e8f0;border-radius:8px;background:#fff;cursor:pointer;font-weight:600;font-size:.85rem;text-align:center;color:#475569;font-family:inherit}
|
||||
.sig-tab.active{border-color:#1e3a5f;color:#1e3a5f;background:#eff6ff}
|
||||
#typed-sig{width:100%;padding:.75rem;border:2px solid #cbd5e1;border-radius:8px;font-size:1.5rem;font-family:'Brush Script MT','Segoe Script','Comic Sans MS',cursive;text-align:center;color:#1e3a5f}
|
||||
#typed-sig:focus{outline:none;border-color:#1e3a5f}
|
||||
.typed-preview{text-align:center;font-family:'Brush Script MT','Segoe Script','Comic Sans MS',cursive;font-size:2rem;color:#1e3a5f;padding:1rem;min-height:80px;border:1px dashed #cbd5e1;border-radius:8px;margin-top:.5rem}
|
||||
.confirm-row{display:flex;gap:.75rem;align-items:flex-start;padding:1rem;background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;font-size:.85rem;color:#374151;margin-bottom:.75rem}
|
||||
.confirm-row input[type=checkbox]{margin-top:2px;flex-shrink:0;width:16px;height:16px;accent-color:#1e3a5f}
|
||||
.perjury{font-size:.8rem;color:#6b7280;font-style:italic;padding:.75rem;background:#fefce8;border:1px solid #fde68a;border-radius:8px;margin-bottom:.75rem}
|
||||
.submit-btn{width:100%;background:#1e3a5f;color:#fff;border:none;border-radius:10px;padding:.9rem;font-size:1rem;font-weight:700;cursor:pointer;font-family:inherit}
|
||||
.submit-btn:hover:not(:disabled){background:#162e4d}
|
||||
.submit-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.status{font-size:.875rem;margin-top:.75rem;min-height:1.25rem;text-align:center}
|
||||
.err{color:#dc2626}.ok{color:#16a34a}
|
||||
#success{display:none;text-align:center;padding:3rem 1rem}
|
||||
#success .check{width:56px;height:56px;background:#dcfce7;border-radius:50%;display:flex;align-items:center;justify-content:center;margin:0 auto 1rem}
|
||||
#success h2{color:#1e3a5f;margin:0 0 .5rem}
|
||||
#success p{color:#475569;font-size:.9rem;max-width:400px;margin:0 auto}
|
||||
#loading{text-align:center;padding:4rem 1rem;color:#64748b}
|
||||
#error-screen{display:none;text-align:center;padding:3rem 1rem}
|
||||
.hidden{display:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div id="loading"><p>Loading your document...</p><p style="font-size:.8rem;color:#94a3b8">Verifying your link</p></div>
|
||||
|
||||
<div id="main-ui" class="hidden">
|
||||
<div class="header">
|
||||
<h1 id="hdr-title">Review & Sign</h1>
|
||||
<p id="hdr-subtitle"></p>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: Review -->
|
||||
<div class="card">
|
||||
<h2>Step 1 — Review Your Document</h2>
|
||||
<p class="hint" id="review-hint">Please read the full document before signing.</p>
|
||||
<div id="pdf-container">
|
||||
<div style="display:flex;align-items:center;justify-content:center;height:200px;color:#94a3b8;font-size:.875rem;border:1px dashed #cbd5e1;border-radius:8px">Loading document preview...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Sign -->
|
||||
<div class="card">
|
||||
<h2>Step 2 — Your Signature</h2>
|
||||
<div class="sig-tabs">
|
||||
<button type="button" class="sig-tab active" data-mode="draw">Draw</button>
|
||||
<button type="button" class="sig-tab" data-mode="type">Type</button>
|
||||
</div>
|
||||
|
||||
<div id="draw-mode">
|
||||
<p class="hint">Sign below using your mouse, trackpad, or finger.</p>
|
||||
<canvas id="sig-canvas"></canvas>
|
||||
<div class="sig-actions">
|
||||
<span id="sig-hint" style="font-style:italic">Draw your signature above</span>
|
||||
<button type="button" class="sig-clear" id="sig-clear">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="type-mode" class="hidden">
|
||||
<p class="hint">Type your full legal name below.</p>
|
||||
<input type="text" id="typed-sig" placeholder="Your full name" autocomplete="name">
|
||||
<div class="typed-preview" id="typed-preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Confirm -->
|
||||
<div class="card">
|
||||
<h2>Step 3 — Confirm & Submit</h2>
|
||||
|
||||
<div id="perjury-box" class="perjury hidden">
|
||||
I declare under penalty of perjury under the laws of the United States of America that the foregoing is true and correct. Executed on <span id="perjury-date"></span>.
|
||||
</div>
|
||||
|
||||
<div class="confirm-row">
|
||||
<input type="checkbox" id="agree-chk">
|
||||
<label for="agree-chk" id="agree-label">
|
||||
I confirm that I have reviewed the document above and that my signature constitutes
|
||||
my legal electronic signature. I authorize Performance West Inc. to submit this
|
||||
document on behalf of <strong id="entity-confirm"></strong>.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="submit-btn" id="submit-btn" disabled>Submit Signed Document</button>
|
||||
<p class="status" id="status-msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="success" class="hidden">
|
||||
<div class="check">
|
||||
<svg width="28" height="28" fill="none" stroke="#16a34a" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/></svg>
|
||||
</div>
|
||||
<h2>Document Signed Successfully</h2>
|
||||
<p id="success-msg">Your signed document has been submitted. You'll receive a confirmation email shortly.</p>
|
||||
</div>
|
||||
|
||||
<div id="error-screen" class="hidden">
|
||||
<div class="card" style="border-color:#fca5a5;background:#fef2f2">
|
||||
<h2 style="color:#991b1b">Error</h2>
|
||||
<p style="color:#7f1d1d" id="error-msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var API = window.__PW_API;
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var token = params.get("token");
|
||||
|
||||
if (!token) {
|
||||
document.getElementById("loading").style.display = "none";
|
||||
document.getElementById("error-screen").style.display = "block";
|
||||
document.getElementById("error-msg").textContent = "No signing token provided. Please use the link from your email.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Load document info
|
||||
fetch(API + "/api/v1/portal/esign?token=" + encodeURIComponent(token))
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
document.getElementById("loading").style.display = "none";
|
||||
|
||||
if (data.error) {
|
||||
document.getElementById("error-screen").style.display = "block";
|
||||
document.getElementById("error-msg").textContent = data.error;
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.already_signed) {
|
||||
document.getElementById("success").style.display = "block";
|
||||
var signedDate = data.signed_at ? new Date(data.signed_at).toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric",hour:"numeric",minute:"2-digit"}) : "";
|
||||
document.getElementById("success-msg").textContent = "This document was already signed" + (signedDate ? " on " + signedDate : "") + ". No further action needed.";
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate UI
|
||||
document.getElementById("main-ui").classList.remove("hidden");
|
||||
document.getElementById("hdr-title").textContent = data.document_title || "Review & Sign";
|
||||
document.getElementById("hdr-subtitle").textContent = data.entity_name + (data.order_number ? " — " + data.order_number : "");
|
||||
document.getElementById("entity-confirm").textContent = data.entity_name;
|
||||
document.getElementById("perjury-date").textContent = new Date().toLocaleDateString("en-US", {year:"numeric",month:"long",day:"numeric"});
|
||||
|
||||
if (data.requires_perjury) {
|
||||
document.getElementById("perjury-box").classList.remove("hidden");
|
||||
}
|
||||
|
||||
// PDF preview
|
||||
if (data.document_url) {
|
||||
document.getElementById("pdf-container").innerHTML =
|
||||
'<iframe src="' + data.document_url + '" class="pdf-frame" title="Document preview"></iframe>';
|
||||
} else {
|
||||
document.getElementById("pdf-container").innerHTML =
|
||||
'<div style="display:flex;align-items:center;justify-content:center;height:120px;color:#64748b;font-size:.875rem;border:1px dashed #cbd5e1;border-radius:8px;background:#f8fafc">' +
|
||||
'Document preview not available. Please contact us if you need a copy before signing.</div>';
|
||||
}
|
||||
|
||||
// Store for submit
|
||||
window._esignData = data;
|
||||
})
|
||||
.catch(function() {
|
||||
document.getElementById("loading").style.display = "none";
|
||||
document.getElementById("error-screen").style.display = "block";
|
||||
document.getElementById("error-msg").textContent = "Could not load document. The link may have expired.";
|
||||
});
|
||||
|
||||
// ── Signature canvas ──
|
||||
var canvas = document.getElementById("sig-canvas");
|
||||
var ctx = canvas.getContext("2d");
|
||||
var drawing = false;
|
||||
var hasSig = false;
|
||||
|
||||
function resizeCanvas() {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * 2;
|
||||
canvas.height = rect.height * 2;
|
||||
ctx.scale(2, 2);
|
||||
ctx.strokeStyle = "#1e3a5f";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
}
|
||||
resizeCanvas();
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
|
||||
function getPos(e) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var t = e.touches ? e.touches[0] : e;
|
||||
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
|
||||
}
|
||||
|
||||
canvas.addEventListener("mousedown", function(e) { drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); });
|
||||
canvas.addEventListener("mousemove", function(e) { if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); });
|
||||
canvas.addEventListener("mouseup", function() { drawing = false; });
|
||||
canvas.addEventListener("mouseleave", function() { drawing = false; });
|
||||
canvas.addEventListener("touchstart", function(e) { e.preventDefault(); drawing = true; ctx.beginPath(); var p = getPos(e); ctx.moveTo(p.x, p.y); }, {passive:false});
|
||||
canvas.addEventListener("touchmove", function(e) { e.preventDefault(); if (!drawing) return; var p = getPos(e); ctx.lineTo(p.x, p.y); ctx.stroke(); hasSig = true; canvas.classList.add("has-sig"); document.getElementById("sig-hint").textContent = "Signature captured"; updateSubmit(); }, {passive:false});
|
||||
canvas.addEventListener("touchend", function() { drawing = false; });
|
||||
|
||||
document.getElementById("sig-clear").addEventListener("click", function() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
hasSig = false;
|
||||
canvas.classList.remove("has-sig");
|
||||
document.getElementById("sig-hint").textContent = "Draw your signature above";
|
||||
updateSubmit();
|
||||
});
|
||||
|
||||
// ── Typed signature ──
|
||||
var typedInput = document.getElementById("typed-sig");
|
||||
var typedPreview = document.getElementById("typed-preview");
|
||||
typedInput.addEventListener("input", function() {
|
||||
typedPreview.textContent = this.value;
|
||||
updateSubmit();
|
||||
});
|
||||
|
||||
// ── Tab switching ──
|
||||
var sigMode = "draw";
|
||||
document.querySelectorAll(".sig-tab").forEach(function(tab) {
|
||||
tab.addEventListener("click", function() {
|
||||
document.querySelectorAll(".sig-tab").forEach(function(t) { t.classList.remove("active"); });
|
||||
this.classList.add("active");
|
||||
sigMode = this.dataset.mode;
|
||||
document.getElementById("draw-mode").classList.toggle("hidden", sigMode !== "draw");
|
||||
document.getElementById("type-mode").classList.toggle("hidden", sigMode !== "type");
|
||||
updateSubmit();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Submit logic ──
|
||||
var agreeChk = document.getElementById("agree-chk");
|
||||
agreeChk.addEventListener("change", updateSubmit);
|
||||
|
||||
function updateSubmit() {
|
||||
var hasSignature = sigMode === "draw" ? hasSig : typedInput.value.trim().length >= 2;
|
||||
document.getElementById("submit-btn").disabled = !(hasSignature && agreeChk.checked);
|
||||
}
|
||||
|
||||
document.getElementById("submit-btn").addEventListener("click", async function() {
|
||||
var btn = this;
|
||||
var statusEl = document.getElementById("status-msg");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Submitting...";
|
||||
statusEl.textContent = "";
|
||||
|
||||
var signatureData;
|
||||
if (sigMode === "draw") {
|
||||
signatureData = { type: "drawn", image_b64: canvas.toDataURL("image/png") };
|
||||
} else {
|
||||
signatureData = { type: "typed", name: typedInput.value.trim() };
|
||||
}
|
||||
|
||||
try {
|
||||
var resp = await fetch(API + "/api/v1/portal/esign", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer " + token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
signature: signatureData,
|
||||
agreed_at: new Date().toISOString(),
|
||||
user_agent: navigator.userAgent,
|
||||
}),
|
||||
});
|
||||
var result = await resp.json();
|
||||
if (!resp.ok) throw new Error(result.error || "Submission failed");
|
||||
|
||||
document.getElementById("main-ui").classList.add("hidden");
|
||||
document.getElementById("success").style.display = "block";
|
||||
} catch (err) {
|
||||
statusEl.textContent = err.message;
|
||||
statusEl.className = "status err";
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Submit Signed Document";
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue