admin: view order PDFs from MinIO (signed forms, prepared filings, evidence)

Adds a Documents section to the compliance-order detail drawer so you can
review the actual filing PDFs before approving an order:
  GET /api/v1/admin/compliance-orders/:id/documents  list viewable objects
  GET /api/v1/admin/compliance-orders/:id/document?key=&token=  stream one

Key discovery pulls from esign_records (unsigned + signed docs per order),
intake_data.filing_status (pdf_minio_path, attested_pdf, evidence/*), and the
order's engagement_letter / rmd_packet columns.

Rather than hand out presigned URLs (MinIO's public host is IP-allowlisted to a
few office IPs, so links break elsewhere), the API streams the object through
itself from internal minio:9000, gated by the admin JWT. The stream endpoint
accepts the token via ?token= (new middleware requireAdminQueryOrHeader) so a
PDF opens in a new tab, and refuses any key that isn't one of the order's own
documents.
This commit is contained in:
justin 2026-06-16 00:20:15 -05:00
parent d65f5ea279
commit bce5db4a09
3 changed files with 192 additions and 1 deletions

View file

@ -39,3 +39,26 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction): v
res.status(401).json({ error: "Invalid or expired token." }); res.status(401).json({ error: "Invalid or expired token." });
} }
} }
/**
* Verify admin JWT from EITHER the Authorization header OR a `?token=` query
* param. Needed for endpoints opened directly by the browser (e.g. a PDF in a
* new tab / <iframe>), where custom request headers cannot be set. The token is
* the same short-lived admin JWT; only use this for read-only document streams.
*/
export function requireAdminQueryOrHeader(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization;
const headerTok = header && header.startsWith("Bearer ") ? header.slice(7) : null;
const queryTok = typeof req.query.token === "string" ? req.query.token : null;
const token = headerTok || queryTok;
if (!token) {
res.status(401).json({ error: "Authentication required." });
return;
}
try {
req.admin = jwt.verify(token, JWT_SECRET) as AdminPayload;
next();
} catch {
res.status(401).json({ error: "Invalid or expired token." });
}
}

View file

@ -1,7 +1,7 @@
import { Router } from "express"; import { Router } from "express";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { pool } from "../db.js"; import { pool } from "../db.js";
import { requireAdmin, signAdminToken } from "../middleware/admin-auth.js"; import { requireAdmin, requireAdminQueryOrHeader, signAdminToken } from "../middleware/admin-auth.js";
import { submitLimiter } from "../middleware/rate-limit.js"; import { submitLimiter } from "../middleware/rate-limit.js";
const router = Router(); const router = Router();
@ -580,4 +580,145 @@ router.post("/api/v1/admin/compliance-orders/:order_number/rearm-intake", requir
} }
}); });
// ── Document discovery + viewing ─────────────────────────────────────────────
//
// Every prepared/signed filing PDF lives in MinIO. We surface them so you can
// review the actual document (e.g. the signed MCS-150 / authorization) before
// approving an order for government submission. Keys come from three places:
// 1. esign_records — the unsigned + signed e-sign PDFs per order
// 2. intake_data.filing_status — pdf_minio_path / attested_pdf / evidence/*
// 3. compliance_orders cols — engagement_letter_minio_key, rmd_packet_minio_paths
//
// Browsers can't open MinIO's IP-allowlisted public host, so instead of handing
// out presigned URLs we STREAM the object through the API (JWT-gated). The
// stream endpoint accepts the token via ?token= so it works in a new tab.
const WORKER_URL_ADMIN = process.env.WORKER_URL || "http://workers:8090";
/** Ask the worker for a presigned (internal minio:9000) GET URL. */
async function presignInternal(key: string): Promise<string | null> {
if (!key) return null;
try {
const r = await fetch(`${WORKER_URL_ADMIN}/jobs/presign`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, expires: 600, method: "GET" }),
});
if (!r.ok) return null;
const data = (await r.json()) as { url?: string };
return data.url || null;
} catch {
return null;
}
}
/** Collect every known MinIO document key for an order (deduped, labeled). */
async function collectOrderDocuments(orderNumber: string): Promise<Array<{ label: string; key: string }>> {
const docs: Array<{ label: string; key: string }> = [];
const seen = new Set<string>();
const add = (label: string, key: unknown) => {
if (typeof key === "string" && key && !seen.has(key)) {
seen.add(key);
docs.push({ label, key });
}
};
// 1. Order row: intake_data filing artifacts + per-order document columns.
const ord = await pool.query(
`SELECT intake_data, engagement_letter_minio_key, rmd_packet_minio_paths
FROM compliance_orders WHERE order_number = $1`,
[orderNumber],
);
if (ord.rows.length) {
const o = ord.rows[0];
const intake = (o.intake_data && typeof o.intake_data === "object" ? o.intake_data : {}) as Record<string, any>;
const fs = (intake.filing_status && typeof intake.filing_status === "object" ? intake.filing_status : {}) as Record<string, any>;
add("Prepared filing PDF", fs.pdf_minio_path);
add("Attested PDF (faxed)", fs.attested_pdf);
add("Confirmation screenshot", fs.screenshot_path);
if (fs.evidence && typeof fs.evidence === "object") {
for (const [k, v] of Object.entries(fs.evidence as Record<string, any>)) {
add("Evidence: " + k.replace(/_/g, " "), v);
}
}
add("Engagement letter", o.engagement_letter_minio_key);
const rmd = o.rmd_packet_minio_paths;
if (Array.isArray(rmd)) rmd.forEach((k, i) => add(`RMD packet ${i + 1}`, k));
else if (rmd && typeof rmd === "object") for (const [k, v] of Object.entries(rmd)) add("RMD: " + k, v);
}
// 2. e-sign records: the unsigned doc and (once signed) the signed doc.
const es = await pool.query(
`SELECT document_type, status, document_minio_key, signed_document_minio_key
FROM esign_records WHERE order_number = $1 ORDER BY created_at`,
[orderNumber],
);
for (const r of es.rows as any[]) {
const t = (r.document_type || "document").replace(/[-_]/g, " ");
if (r.signed_document_minio_key) add(`Signed ${t}`, r.signed_document_minio_key);
add(`${r.status === "signed" ? "Unsigned" : "Pending"} ${t}`, r.document_minio_key);
}
return docs;
}
/** GET /api/v1/admin/compliance-orders/:order_number/documents — list viewable PDFs. */
router.get("/api/v1/admin/compliance-orders/:order_number/documents", requireAdmin, async (req, res) => {
try {
const exists = await pool.query("SELECT 1 FROM compliance_orders WHERE order_number = $1", [req.params.order_number]);
if (exists.rows.length === 0) { res.status(404).json({ error: "Order not found." }); return; }
const docs = await collectOrderDocuments(req.params.order_number);
res.json({ order_number: req.params.order_number, documents: docs });
} catch (err) {
console.error("[admin/compliance-orders/documents] Error:", err);
res.status(500).json({ error: "Could not list documents." });
}
});
/**
* GET /api/v1/admin/compliance-orders/:order_number/document?key=...&token=...
* Stream a single MinIO object through the API (JWT-gated via header or ?token).
* The key MUST be one we discovered for this order prevents arbitrary-object
* reads from a valid admin token.
*/
router.get("/api/v1/admin/compliance-orders/:order_number/document", requireAdminQueryOrHeader, async (req, res) => {
try {
const key = typeof req.query.key === "string" ? req.query.key : "";
if (!key) { res.status(400).json({ error: "key required" }); return; }
const docs = await collectOrderDocuments(req.params.order_number);
if (!docs.some((d) => d.key === key)) {
res.status(403).json({ error: "Key does not belong to this order." });
return;
}
const url = await presignInternal(key);
if (!url) { res.status(502).json({ error: "Could not generate object URL." }); return; }
const upstream = await fetch(url);
if (!upstream.ok || !upstream.body) {
res.status(502).json({ error: `Object fetch failed (${upstream.status}).` });
return;
}
// Infer a sensible content type; default to PDF (almost all are).
const lower = key.toLowerCase();
const ct = lower.endsWith(".pdf") ? "application/pdf"
: lower.endsWith(".png") ? "image/png"
: lower.endsWith(".jpg") || lower.endsWith(".jpeg") ? "image/jpeg"
: lower.endsWith(".xlsx") ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
: upstream.headers.get("content-type") || "application/octet-stream";
res.setHeader("Content-Type", ct);
res.setHeader("Content-Disposition", `inline; filename="${key.split("/").pop() || "document"}"`);
const len = upstream.headers.get("content-length");
if (len) res.setHeader("Content-Length", len);
const buf = Buffer.from(await upstream.arrayBuffer());
res.end(buf);
} catch (err) {
console.error("[admin/compliance-orders/document] Error:", err);
res.status(500).json({ error: "Could not stream document." });
}
});
export default router; export default router;

View file

@ -363,13 +363,40 @@
(order.fulfillment_status === "ready_to_file" ? `<button id="drawer-approve" class="btn btn-blue wfull" style="margin-top:16px;">Approve &amp; file this order</button>` : "") + (order.fulfillment_status === "ready_to_file" ? `<button id="drawer-approve" class="btn btn-blue wfull" style="margin-top:16px;">Approve &amp; file this order</button>` : "") +
((order.payment_status === "paid" && !order.intake_data_validated) ? `<button id="drawer-rearm" class="btn btn-amber wfull" style="margin-top:8px;">Re-arm intake reminder</button>` : "") + ((order.payment_status === "paid" && !order.intake_data_validated) ? `<button id="drawer-rearm" class="btn btn-amber wfull" style="margin-top:8px;">Re-arm intake reminder</button>` : "") +
`<div class="section-h">Intake data</div><pre>${esc(JSON.stringify(intake, null, 2))}</pre>` + `<div class="section-h">Intake data</div><pre>${esc(JSON.stringify(intake, null, 2))}</pre>` +
`<div class="section-h">Documents</div><div id="drawer-docs"><div class="muted" style="font-size:12px;">Loading documents…</div></div>` +
`<div class="section-h">Audit log</div>${auditHtml}`; `<div class="section-h">Audit log</div>${auditHtml}`;
const da = $("drawer-approve"); const da = $("drawer-approve");
if (da) da.addEventListener("click", () => approveOrder(order.order_number, order.service_name || order.service_slug)); if (da) da.addEventListener("click", () => approveOrder(order.order_number, order.service_name || order.service_slug));
const dr = $("drawer-rearm"); const dr = $("drawer-rearm");
if (dr) dr.addEventListener("click", () => rearmIntake(order.order_number)); if (dr) dr.addEventListener("click", () => rearmIntake(order.order_number));
loadDocuments(order.order_number);
} catch (e) { $("drawer-body").innerHTML = `<div style="color:#b91c1c;">Failed to load: ${esc(e.message)}</div>`; } } catch (e) { $("drawer-body").innerHTML = `<div style="color:#b91c1c;">Failed to load: ${esc(e.message)}</div>`; }
} }
async function loadDocuments(orderNumber) {
const box = $("drawer-docs");
if (!box) return;
try {
const { documents } = await api("/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber) + "/documents");
if (!documents || !documents.length) {
box.innerHTML = '<div class="muted" style="font-size:12px;">No documents on file yet.</div>';
return;
}
// Stream endpoint takes the JWT via ?token= so it opens in a new tab.
const tok = encodeURIComponent(token());
box.innerHTML = documents.map((d) => {
const u = API + "/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber)
+ "/document?key=" + encodeURIComponent(d.key) + "&token=" + tok;
return `<div class="svc-row"><div class="svc-left">
<span style="font-size:13px;">${esc(d.label)}</span>
<span class="small">${esc(d.key.split("/").pop())}</span>
</div><div class="svc-right">
<a href="${u}" target="_blank" rel="noopener" class="btn btn-blue btn-sm" style="text-decoration:none;">View</a>
</div></div>`;
}).join("");
} catch (e) {
box.innerHTML = `<div style="color:#b91c1c;font-size:12px;">Could not load documents: ${esc(e.message)}</div>`;
}
}
$("drawer-close").addEventListener("click", () => $("drawer").classList.add("hidden")); $("drawer-close").addEventListener("click", () => $("drawer").classList.add("hidden"));
$("drawer-backdrop").addEventListener("click", () => $("drawer").classList.add("hidden")); $("drawer-backdrop").addEventListener("click", () => $("drawer").classList.add("hidden"));