[object Object]

← back to NationalPaperHangers

Add measure_jobs schema (migration 023) — Council Idea #5 lead-handoff capture table

5ea9d112bd1ad1da6f81a5d4247d5433bc51f448 · 2026-06-18 18:45:07 -0700 · SteveStudio2

Files touched

Diff

commit 5ea9d112bd1ad1da6f81a5d4247d5433bc51f448
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Thu Jun 18 18:45:07 2026 -0700

    Add measure_jobs schema (migration 023) — Council Idea #5 lead-handoff capture table
---
 db/migrations/023_measure_jobs.sql | 52 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 52 insertions(+)

diff --git a/db/migrations/023_measure_jobs.sql b/db/migrations/023_measure_jobs.sql
new file mode 100644
index 0000000..7c8fa7d
--- /dev/null
+++ b/db/migrations/023_measure_jobs.sql
@@ -0,0 +1,52 @@
+-- Council Idea #5 — Measure→Installer Lead Handoff (SAFE subset, BUNDLE-A).
+-- Captures a structured installer-lead from the /measure result page when a
+-- homeowner explicitly opts in ("Get this quoted by a local installer"), and
+-- routes it to the single NEAREST CLAIMED installer who services that ZIP.
+--
+-- Why a DEDICATED table (not a reuse of installer_interest):
+--   installer_interest has UNIQUE(installer_id, email) — an email-signup row
+--   semantically — which structurally breaks one-email-many-jobs (a homeowner
+--   can measure several projects). A job-spec is a different record than a
+--   "notify me when live" email capture, and we want sqft/rolls queryable.
+--
+-- Channel (a) in-app ONLY at this stage: a routed row IS the installer's inbox.
+-- The installer dashboard reads measure_jobs WHERE routed_to = <me>. NOTHING is
+-- emailed/sent from here — outbound email (channel b) + the prod deploy are
+-- separate Steve-gated steps (drafted to pending-approval).
+--
+-- Routing is claimed-only by design: pushing a lead to an UNCLAIMED (scraped)
+-- installer would be unsolicited B2B contact = CAN-SPAM exposure. route_status
+-- 'no_installer' is the graceful path when no claimed installer is in range —
+-- the homeowner is shown the standard /find browse list instead of a dead end.
+
+CREATE TABLE IF NOT EXISTS measure_jobs (
+  id             BIGSERIAL PRIMARY KEY,
+  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
+  zip            TEXT,
+  sqft           NUMERIC,
+  rolls          INTEGER,
+  wall_count     INTEGER,
+  material       TEXT,              -- product/material context from the form, if any
+  product_sku    TEXT,             -- when launched from a DW/wallco PDP deep-link
+  product_name   TEXT,
+  customer_name  TEXT,
+  customer_email TEXT,
+  customer_phone TEXT,
+  notes          TEXT,
+  ip_hash        TEXT,              -- sha256 prefix, same pattern as installer_interest (never raw IP)
+  routed_to      BIGINT REFERENCES installers(id) ON DELETE SET NULL,  -- NULL until routed
+  routed_at      TIMESTAMPTZ,
+  route_status   TEXT NOT NULL DEFAULT 'captured'
+                 CHECK (route_status IN ('captured','routed','delivered','declined','no_installer')),
+  -- in-app read receipt: set when the routed installer first views the lead in
+  -- their dashboard (channel a "delivery"). Distinct from any future email send.
+  viewed_at      TIMESTAMPTZ
+);
+
+-- The installer's "inbox" query: their routed, not-yet-actioned leads, newest first.
+CREATE INDEX IF NOT EXISTS measure_jobs_routed_to_idx
+  ON measure_jobs (routed_to, created_at DESC) WHERE routed_to IS NOT NULL;
+
+-- Ops/funnel slicing by status + recency.
+CREATE INDEX IF NOT EXISTS measure_jobs_status_idx
+  ON measure_jobs (route_status, created_at DESC);

← 8c10bac Add P3 order-handoff to /measure: PDP deep-link prefill + ho  ·  back to NationalPaperHangers  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 365532c →