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:
parent
d65f5ea279
commit
bce5db4a09
3 changed files with 192 additions and 1 deletions
|
|
@ -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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -363,13 +363,40 @@
|
||||||
(order.fulfillment_status === "ready_to_file" ? `<button id="drawer-approve" class="btn btn-blue wfull" style="margin-top:16px;">Approve & file this order</button>` : "") +
|
(order.fulfillment_status === "ready_to_file" ? `<button id="drawer-approve" class="btn btn-blue wfull" style="margin-top:16px;">Approve & 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"));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue