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." });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 bcrypt from "bcryptjs";
|
||||
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";
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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.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">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}`;
|
||||
const da = $("drawer-approve");
|
||||
if (da) da.addEventListener("click", () => approveOrder(order.order_number, order.service_name || order.service_slug));
|
||||
const dr = $("drawer-rearm");
|
||||
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>`; }
|
||||
}
|
||||
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-backdrop").addEventListener("click", () => $("drawer").classList.add("hidden"));
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue