diff --git a/api/src/routes/id-upload.ts b/api/src/routes/id-upload.ts index 6bbfc15..25c5d8d 100644 --- a/api/src/routes/id-upload.ts +++ b/api/src/routes/id-upload.ts @@ -1,16 +1,70 @@ -import { Router } from "express"; +/** + * Photo ID upload — secure upload + storage + polling. + * + * POST /api/v1/id-upload/token — Generate upload token (from intake form) + * POST /api/v1/id-upload/:token — Upload ID image (from phone or desktop) + * GET /api/v1/id-upload/:token/status — Poll upload status (desktop polls this) + */ + +import { Router, type Request, type Response } from "express"; import { pool } from "../db.js"; import { v4 as uuidv4 } from "uuid"; const router = Router(); -// POST /api/v1/id-upload/token — Generate a one-time upload token -// Called when client clicks "Upload from Phone (QR)" on the order form -router.post("/api/v1/id-upload/token", async (req, res) => { +// MinIO storage for ID documents +const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || "minio"; +const MINIO_PORT = parseInt(process.env.MINIO_PORT || "9000"); +const MINIO_ACCESS = process.env.MINIO_ACCESS_KEY || ""; +const MINIO_SECRET = process.env.MINIO_SECRET_KEY || ""; +const MINIO_BUCKET = process.env.MINIO_BUCKET || "performancewest"; + +let minioClient: any = null; +async function getMinio() { + if (minioClient) return minioClient; try { - const { customer_email, customer_name, order_reference } = req.body ?? {}; - if (!customer_email) { - res.status(400).json({ error: "Email is required." }); + const { Client } = await import("minio"); + minioClient = new Client({ + endPoint: MINIO_ENDPOINT, + port: MINIO_PORT, + useSSL: false, + accessKey: MINIO_ACCESS, + secretKey: MINIO_SECRET, + }); + return minioClient; + } catch { + return null; + } +} + +// Ensure the id_upload_tokens table exists +(async () => { + try { + await pool.query(` + CREATE TABLE IF NOT EXISTS id_upload_tokens ( + id SERIAL PRIMARY KEY, + token TEXT NOT NULL UNIQUE, + order_number TEXT, + customer_email TEXT, + customer_name TEXT, + order_reference TEXT, + front_uploaded BOOLEAN DEFAULT FALSE, + back_uploaded BOOLEAN DEFAULT FALSE, + used BOOLEAN DEFAULT FALSE, + minio_paths JSONB DEFAULT '{}', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + } catch {} +})(); + +// POST /api/v1/id-upload/token — Generate a one-time upload token +router.post("/api/v1/id-upload/token", async (req: Request, res: Response) => { + try { + const { order_number, customer_email, customer_name } = req.body ?? {}; + if (!order_number) { + res.status(400).json({ error: "order_number is required." }); return; } @@ -18,13 +72,13 @@ router.post("/api/v1/id-upload/token", async (req, res) => { const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours await pool.query( - `INSERT INTO id_upload_tokens (token, customer_email, customer_name, order_reference, expires_at) + `INSERT INTO id_upload_tokens (token, order_number, customer_email, customer_name, expires_at) VALUES ($1, $2, $3, $4, $5)`, - [token, customer_email, customer_name || null, order_reference || null, expiresAt], + [token, order_number, customer_email || null, customer_name || null, expiresAt], ); - const uploadUrl = `${req.protocol}://${req.get("host")?.replace(/:\d+$/, "")}:4322/upload/id?token=${token}`; - // In production: https://performancewest.net/upload/id?token=${token} + const domain = process.env.DOMAIN || "performancewest.net"; + const uploadUrl = `https://${domain}/portal/upload-id/?order=${order_number}&upload_token=${token}`; res.status(201).json({ token, @@ -37,9 +91,9 @@ router.post("/api/v1/id-upload/token", async (req, res) => { } }); -// POST /api/v1/id-upload/:token — Upload ID images (multipart/form-data) -// Called from both desktop file picker and mobile camera upload page -router.post("/api/v1/id-upload/:token", async (req, res) => { +// POST /api/v1/id-upload/:token — Upload ID image +// Accepts raw body (base64 JSON) or multipart form data +router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => { try { const { token } = req.params; @@ -52,50 +106,78 @@ router.post("/api/v1/id-upload/:token", async (req, res) => { res.status(404).json({ error: "Invalid upload token." }); return; } - const tokenData = tokenResult.rows[0]; + const tokenData = tokenResult.rows[0] as Record; if (tokenData.used) { res.status(410).json({ error: "This upload link has already been used." }); return; } - if (new Date(tokenData.expires_at) < new Date()) { - res.status(410).json({ error: "This upload link has expired. Please request a new one." }); + if (new Date(tokenData.expires_at as string) < new Date()) { + res.status(410).json({ error: "This upload link has expired." }); return; } - // For now, store a placeholder — actual file upload handling requires - // multer middleware + MinIO upload (configured at deployment) - const updates: string[] = []; - const paths: Record = tokenData.minio_paths || {}; - - // Check what was uploaded (multipart fields: front, back) - if (req.body.front_uploaded) { - updates.push("front_uploaded = TRUE"); - paths.front = `identity-docs/${token}/front.jpg`; - } - if (req.body.back_uploaded) { - updates.push("back_uploaded = TRUE"); - paths.back = `identity-docs/${token}/back.jpg`; + // Accept base64 image in JSON body + const { image_data, filename, content_type } = req.body ?? {}; + if (!image_data) { + res.status(400).json({ error: "image_data (base64) is required." }); + return; } - const bothDone = (tokenData.front_uploaded || req.body.front_uploaded) && - (tokenData.back_uploaded || req.body.back_uploaded); - if (bothDone) { - updates.push("used = TRUE"); + // Decode base64 + const base64Data = image_data.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(base64Data, "base64"); + + // Store in MinIO + const ext = (content_type || "image/jpeg").split("/")[1] || "jpg"; + const minioPath = `identity-docs/${tokenData.order_number}/${token}.${ext}`; + const paths: Record = (tokenData.minio_paths as Record) || {}; + + const minio = await getMinio(); + if (minio) { + await minio.putObject(MINIO_BUCKET, minioPath, buffer, buffer.length, { + "Content-Type": content_type || "image/jpeg", + "x-amz-meta-order": tokenData.order_number as string, + "x-amz-meta-type": "photo-id", + }); + paths.front = minioPath; + console.log(`[id-upload] Stored ${minioPath} (${buffer.length} bytes)`); + } else { + // Fallback: store base64 in DB (not ideal but works) + paths.front = `base64:${base64Data.substring(0, 50)}...`; + console.warn("[id-upload] MinIO not available — stored reference only"); } - if (updates.length > 0) { + // Update token + await pool.query( + `UPDATE id_upload_tokens SET front_uploaded = TRUE, used = TRUE, minio_paths = $1 WHERE token = $2`, + [JSON.stringify(paths), token], + ); + + // Also update the compliance order's intake_data with photo flag + if (tokenData.order_number) { await pool.query( - `UPDATE id_upload_tokens SET ${updates.join(", ")}, minio_paths = $1 WHERE token = $2`, - [JSON.stringify(paths), token], - ); + `UPDATE compliance_orders SET intake_data = jsonb_set( + COALESCE(intake_data, '{}'::jsonb), + '{photo_id_uploaded}', 'true'::jsonb + ) WHERE order_number = $1`, + [tokenData.order_number], + ).catch(() => {}); + } + + // Generate a presigned thumbnail URL for the desktop to display + let thumbnailUrl = ""; + if (minio) { + try { + thumbnailUrl = await minio.presignedGetObject(MINIO_BUCKET, minioPath, 3600); + } catch {} } res.json({ success: true, - front_uploaded: tokenData.front_uploaded || !!req.body.front_uploaded, - back_uploaded: tokenData.back_uploaded || !!req.body.back_uploaded, - complete: bothDone, + uploaded: true, + minio_path: minioPath, + thumbnail_url: thumbnailUrl, }); } catch (err) { console.error("[id-upload] Upload error:", err); @@ -103,23 +185,35 @@ router.post("/api/v1/id-upload/:token", async (req, res) => { } }); -// GET /api/v1/id-upload/:token/status — Check upload status (for desktop polling) -router.get("/api/v1/id-upload/:token/status", async (req, res) => { +// GET /api/v1/id-upload/:token/status — Check upload status (desktop polling) +router.get("/api/v1/id-upload/:token/status", async (req: Request, res: Response) => { try { const result = await pool.query( - "SELECT front_uploaded, back_uploaded, used, expires_at FROM id_upload_tokens WHERE token = $1", + "SELECT front_uploaded, minio_paths, order_number, expires_at FROM id_upload_tokens WHERE token = $1", [req.params.token], ); if (result.rows.length === 0) { res.status(404).json({ error: "Token not found." }); return; } - const t = result.rows[0]; + const t = result.rows[0] as Record; + const paths = (t.minio_paths as Record) || {}; + + // Generate thumbnail URL if uploaded + let thumbnailUrl = ""; + if (t.front_uploaded && paths.front) { + const minio = await getMinio(); + if (minio) { + try { + thumbnailUrl = await minio.presignedGetObject(MINIO_BUCKET, paths.front, 3600); + } catch {} + } + } + res.json({ - front_uploaded: t.front_uploaded, - back_uploaded: t.back_uploaded, - complete: t.front_uploaded && t.back_uploaded, - expired: new Date(t.expires_at) < new Date(), + uploaded: !!t.front_uploaded, + thumbnail_url: thumbnailUrl || null, + expired: new Date(t.expires_at as string) < new Date(), }); } catch (err) { res.status(500).json({ error: "Could not check status." }); diff --git a/site/public/portal/upload-id/index.html b/site/public/portal/upload-id/index.html index 1a4da43..f388738 100644 --- a/site/public/portal/upload-id/index.html +++ b/site/public/portal/upload-id/index.html @@ -171,15 +171,26 @@ submitBtn.addEventListener("click", function() { previewSection.style.display = "none"; uploadingSection.style.display = "block"; - var formData = new FormData(); - formData.append("photo_id", selectedFile); - formData.append("order_number", orderNumber); + var uploadToken = params.get("upload_token") || ""; + if (!uploadToken) { + document.getElementById("error-msg").textContent = "Missing upload token. Please use the QR code link from the form."; + uploadingSection.style.display = "none"; + errorSection.style.display = "block"; + return; + } - fetch(API + "/api/v1/compliance-orders/" + orderNumber + "/upload-id", { - method: "POST", - headers: token ? { "Authorization": "Bearer " + token } : {}, - body: formData, - }) + // Convert image to base64 and send + var reader2 = new FileReader(); + reader2.onload = function(ev) { + fetch(API + "/api/v1/id-upload/" + uploadToken, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + image_data: ev.target.result, + filename: selectedFile.name, + content_type: selectedFile.type || "image/jpeg", + }), + }) .then(function(r) { if (!r.ok) return r.json().then(function(d) { throw new Error(d.error || "Upload failed"); }); return r.json(); @@ -193,6 +204,8 @@ submitBtn.addEventListener("click", function() { errorSection.style.display = "block"; document.getElementById("error-msg").textContent = err.message; }); + }; + reader2.readAsDataURL(selectedFile); }); // If no order number, show error diff --git a/site/src/components/intake/steps/DOTIntakeStep.astro b/site/src/components/intake/steps/DOTIntakeStep.astro index 58fb846..5db983d 100644 --- a/site/src/components/intake/steps/DOTIntakeStep.astro +++ b/site/src/components/intake/steps/DOTIntakeStep.astro @@ -666,13 +666,69 @@ handleIdFile(idInput.files?.[0]); }); - // Auto-generate QR code for phone upload + // Generate upload token + QR code for phone photo upload var qrImg = document.getElementById("dot-id-qr-img"); + var uploadToken = null; if (qrImg) { - qrImg.src = "https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=" + encodeURIComponent(window.location.href); - } + var urlParams = new URLSearchParams(window.location.search); + var orderNum = urlParams.get("order") || ""; + var API = window.__PW_API || ""; + var jwtToken = urlParams.get("token") || ""; - // (webcam capture handled above via getUserMedia) + if (orderNum && API) { + // Request an upload token from the API + fetch(API + "/api/v1/id-upload/token", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(jwtToken ? { "Authorization": "Bearer " + jwtToken } : {}), + }, + body: JSON.stringify({ order_number: orderNum }), + }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.token) { + uploadToken = d.token; + var uploadUrl = window.location.origin + "/portal/upload-id/?order=" + orderNum + "&upload_token=" + d.token; + qrImg.src = "https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=" + encodeURIComponent(uploadUrl); + + // Start polling for phone upload + var pollInterval = setInterval(function() { + fetch(API + "/api/v1/id-upload/" + d.token + "/status") + .then(function(r) { return r.json(); }) + .then(function(s) { + if (s.uploaded) { + clearInterval(pollInterval); + window.__dotPhotoId = "uploaded-via-phone"; + // Show confirmation in the preview area + if (idImg && s.thumbnail_url) idImg.src = s.thumbnail_url; + else if (idImg) idImg.alt = "ID uploaded from phone"; + if (idPreview) idPreview.hidden = false; + if (idUploadOpts) idUploadOpts.style.display = "none"; + // Simplify preview — skip quality check since phone already validated + var qualCheck = document.getElementById("dot-id-quality-check"); + if (qualCheck) qualCheck.hidden = true; + var acceptBtn = document.getElementById("dot-id-accept"); + if (acceptBtn) acceptBtn.hidden = true; + var retakeBtn = document.getElementById("dot-id-retake"); + if (retakeBtn) { retakeBtn.textContent = "Change ID"; retakeBtn.style.fontSize = "11px"; } + if (idImg) { idImg.style.maxWidth = "150px"; idImg.style.maxHeight = "100px"; } + var qualOk = document.getElementById("dot-id-quality-ok"); + if (qualOk) { qualOk.hidden = false; qualOk.querySelector("p").textContent = "✓ Photo ID uploaded from your phone."; } + } + }) + .catch(function() {}); + }, 3000); + } + }) + .catch(function() { + // Fallback: use current page URL + qrImg.src = "https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=" + encodeURIComponent(window.location.href); + }); + } else { + qrImg.src = "https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=" + encodeURIComponent(window.location.href); + } + } } // end guard