From 7b650179e4855f6f8c004f51a327fb168f464707 Mon Sep 17 00:00:00 2001 From: justin Date: Tue, 28 Apr 2026 18:26:20 -0500 Subject: [PATCH] Add Finish button handler + intake save API endpoint - Wizard "Finish" button now submits intake data to the API - New PUT /api/v1/compliance-orders/:id/intake endpoint saves intake data, updates entity, re-dispatches worker, and unpauses batch siblings - Shows success screen with portal link after submission Co-Authored-By: Claude Opus 4.6 (1M context) --- api/src/routes/compliance-orders.ts | 133 ++++++++++++++++++++++++ site/src/components/intake/Wizard.astro | 70 ++++++++++++- 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/api/src/routes/compliance-orders.ts b/api/src/routes/compliance-orders.ts index 4b599a7..5c1c91c 100644 --- a/api/src/routes/compliance-orders.ts +++ b/api/src/routes/compliance-orders.ts @@ -1534,6 +1534,139 @@ router.get("/api/v1/compliance-orders/my-orders", async (req, res) => { * Customer confirms they've completed USAC E-File delegation. * Updates order status and notifies the team to begin filing. */ +/** + * PUT /api/v1/compliance-orders/:id/intake + * Save intake data collected from the wizard. Dispatches the worker + * to process the filing now that intake is complete. + */ +router.put("/api/v1/compliance-orders/:id/intake", async (req, res) => { + const id = req.params.id; + const { intake_data, entity, officers } = req.body ?? {}; + + if (!intake_data) { + res.status(400).json({ error: "intake_data required" }); + return; + } + + try { + // Merge new intake data with existing (don't overwrite source/frn) + const existing = await pool.query( + `SELECT intake_data, batch_id, service_slug, payment_status FROM compliance_orders WHERE order_number = $1`, + [id], + ); + if (existing.rows.length === 0) { + res.status(404).json({ error: "Order not found" }); + return; + } + + const order = existing.rows[0] as Record; + const oldIntake = (typeof order.intake_data === "string" + ? JSON.parse(order.intake_data as string) + : order.intake_data) || {}; + const merged = { ...oldIntake, ...intake_data }; + + // Update the order with merged intake data + await pool.query( + `UPDATE compliance_orders + SET intake_data = $1, + payment_status = CASE WHEN payment_status = 'pending_intake' THEN 'paid' ELSE payment_status END, + intake_data_validated = TRUE + WHERE order_number = $2`, + [JSON.stringify(merged), id], + ); + + // If entity data was provided, update the telecom_entity too + if (entity && entity.frn) { + try { + await pool.query( + `UPDATE telecom_entities SET + legal_name = COALESCE(NULLIF($1, ''), legal_name), + dba_name = COALESCE(NULLIF($2, ''), dba_name), + contact_name = COALESCE(NULLIF($3, ''), contact_name), + contact_email = COALESCE(NULLIF($4, ''), contact_email), + contact_phone = COALESCE(NULLIF($5, ''), contact_phone), + address_street = COALESCE(NULLIF($6, ''), address_street), + address_city = COALESCE(NULLIF($7, ''), address_city), + address_state = COALESCE(NULLIF($8, ''), address_state), + address_zip = COALESCE(NULLIF($9, ''), address_zip) + WHERE frn = $10`, + [ + entity.legal_name || "", entity.dba_name || "", + entity.contact_name || "", entity.contact_email || "", + entity.contact_phone || "", + entity.address_street || "", entity.address_city || "", + entity.address_state || "", entity.address_zip || "", + entity.frn, + ], + ); + } catch {} + } + + // Re-dispatch the worker to process this order now that intake is complete + const workerUrl = process.env.WORKER_URL || "http://workers:8090"; + setImmediate(async () => { + try { + await fetch(`${workerUrl}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "process_compliance_service", + order_name: id, + order_number: id, + service_slug: order.service_slug as string, + }), + }); + console.log(`[compliance-orders] Worker re-dispatched after intake: ${id}`); + } catch (err) { + console.warn(`[compliance-orders] Worker dispatch failed for ${id}:`, err); + } + }); + + // If this is a batch order, check if all orders in the batch have intake + // and dispatch any that were also pending + if (order.batch_id) { + try { + const pending = await pool.query( + `SELECT order_number, service_slug FROM compliance_orders + WHERE batch_id = $1 AND payment_status = 'pending_intake' AND order_number != $2`, + [order.batch_id, id], + ); + for (const po of pending.rows as any[]) { + // Update their intake data too (same entity info) + await pool.query( + `UPDATE compliance_orders + SET intake_data = jsonb_set(COALESCE(intake_data::jsonb, '{}'::jsonb), '{entity_filled}', 'true'::jsonb), + payment_status = 'paid' + WHERE order_number = $1 AND payment_status = 'pending_intake'`, + [po.order_number], + ); + // Dispatch worker + setImmediate(async () => { + try { + await fetch(`${workerUrl}/jobs`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "process_compliance_service", + order_name: po.order_number, + order_number: po.order_number, + service_slug: po.service_slug, + }), + }); + console.log(`[compliance-orders] Batch sibling dispatched: ${po.order_number}`); + } catch {} + }); + } + } catch {} + } + + res.json({ ok: true, order_number: id }); + } catch (err) { + console.error("[compliance-orders] Intake save error:", err); + res.status(500).json({ error: "Failed to save intake data" }); + } +}); + router.post("/api/v1/compliance-orders/:id/usac-delegation", async (req, res) => { const id = req.params.id; diff --git a/site/src/components/intake/Wizard.astro b/site/src/components/intake/Wizard.astro index d811bd8..83cd892 100644 --- a/site/src/components/intake/Wizard.astro +++ b/site/src/components/intake/Wizard.astro @@ -404,9 +404,77 @@ const STEP_LABELS: Record = { window.dispatchEvent(cancelEvt); // Step components call evt.preventDefault() to block. if (cancelEvt.defaultPrevented) return; - if (s.step_index < steps.length - 1) renderStep(s.step_index + 1); + if (s.step_index < steps.length - 1) { + renderStep(s.step_index + 1); + } else { + // Last step — submit the intake data + submitIntake(s); + } }); + async function submitIntake(state: IntakeState) { + const nextBtn = document.getElementById("pw-next") as HTMLButtonElement; + nextBtn.disabled = true; + nextBtn.textContent = "Submitting..."; + + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + const orderNumber = state.intake_data?.order_number || ""; + const API = (window as any).__PW_API || ""; + + if (!orderNumber && !token) { + // No order context — this is a standalone order, go to payment + // (shouldn't happen since payment step was removed for token orders) + nextBtn.disabled = false; + nextBtn.textContent = "Finish"; + return; + } + + try { + // Save intake data to the compliance order + const saveResp = await fetch(`${API}/api/v1/compliance-orders/${orderNumber}/intake`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + ...(token ? { "Authorization": `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + intake_data: state.intake_data, + entity: state.entity, + officers: state.officers, + }), + }); + + if (saveResp.ok) { + // Clear session storage + sessionStorage.removeItem(`pw-intake-${slug}`); + // Show success + const body = document.querySelector(".pw-wizard-body") as HTMLElement; + body.innerHTML = ` +
+
+ +
+

Intake Complete

+

Your information has been submitted. We'll begin processing your filing and email you with updates.

+ Go to Client Portal +
+ `; + document.querySelector(".pw-wizard-stepbar")?.remove(); + document.querySelector(".pw-wizard-footer")?.remove(); + } else { + const err = await saveResp.json().catch(() => ({})); + alert(err.error || "Failed to submit. Please try again."); + nextBtn.disabled = false; + nextBtn.textContent = "Finish"; + } + } catch (e) { + alert("Network error. Please try again."); + nextBtn.disabled = false; + nextBtn.textContent = "Finish"; + } + } + // Kick off renderStep(loadState().step_index || 0);