← back to Ventura Claw Leads
db/migrations/001_init.sql
121 lines
-- Ventura Claw Leads v0.1 — directory + lead-routing for unregulated brick-and-
-- mortar businesses on Ventura Blvd. Mirrors the NPH schema where the shape
-- works; diverges where the licensed-trade specifics don't apply.
--
-- HARD RULES this schema enforces:
-- - businesses.vertical is constrained to the 8-vertical green list. Adding
-- any "regulated" vertical (medical / legal / financial / real-estate /
-- contracting) requires a new migration that explicitly amends the CHECK,
-- forcing a code review where someone sees the regulatory issue.
-- - No application_fee_amount / Stripe Connect tables. Consumers never pay
-- this platform; businesses pay subscription only. Per-lead billing is
-- metered against the subscription, not collected from the consumer.
CREATE TABLE IF NOT EXISTS businesses (
id BIGSERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
business_name TEXT NOT NULL,
vertical TEXT NOT NULL CHECK (vertical IN (
'food', -- restaurants, cafes, bakeries, delis, food trucks
'beauty', -- hair, nails, brows/lashes, barbers, makeup, tanning, non-medical spa
'retail', -- boutiques, jewelry, shoes, accessories, home goods, gift shops
'fitness', -- yoga, pilates, gyms, dance, martial arts, personal trainers
'pet_services', -- groomers, walkers, daycare, pet sitters, training, supply
'auto_detail', -- detailing, hand car wash, window tint, mobile detailers
'cleaning', -- house cleaners, organizers, residential moving, staging
'creative' -- photographers, DJs, event planners, florists, designers
)),
description TEXT,
headline TEXT,
-- Address (Ventura Blvd corridor)
street TEXT,
city TEXT, -- Sherman Oaks, Studio City, Encino, Tarzana, Woodland Hills
state TEXT DEFAULT 'CA',
zip TEXT,
neighborhood TEXT, -- finer cut than city
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
-- Contact + socials (no consumer payment surface)
phone TEXT,
email TEXT,
website TEXT,
instagram_handle TEXT,
-- Subscription tier (business-side billing). 'free' = directory shell only.
tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free','starter','standard','premier')),
subscription_status TEXT NOT NULL DEFAULT 'inactive',
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
-- Claim flow (lifted from NPH).
claim_status TEXT NOT NULL DEFAULT 'unclaimed' CHECK (claim_status IN ('unclaimed','self','claimed')),
claimed_by_email TEXT,
claimed_at TIMESTAMPTZ,
-- Lifecycle
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active','pending','hidden','removed')),
verified BOOLEAN NOT NULL DEFAULT false,
source TEXT NOT NULL DEFAULT 'seed', -- 'seed', 'public_records', 'self_signup'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS businesses_vertical_idx ON businesses (vertical) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS businesses_city_idx ON businesses (city) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS businesses_neighborhood_idx ON businesses (neighborhood) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS businesses_geo_idx ON businesses (latitude, longitude) WHERE latitude IS NOT NULL;
-- Lead-capture queue per business. The lead is the actual product — when a
-- consumer fills the contact form, the business pays a per-lead fee against
-- their subscription (charged at month-end, not at capture time).
CREATE TABLE IF NOT EXISTS business_interest (
id BIGSERIAL PRIMARY KEY,
business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
consumer_name TEXT,
consumer_email TEXT NOT NULL,
consumer_phone TEXT,
message TEXT,
zip TEXT,
source TEXT NOT NULL DEFAULT 'profile_form', -- 'profile_form', 'home_search', 'map_pin'
ip_hash TEXT,
user_agent TEXT,
-- Per-vertical extras: party_size for restaurants, service_kind for beauty,
-- preferred_date, etc. Schema-light JSON so we don't migrate per vertical.
extra_fields JSONB DEFAULT '{}'::jsonb,
-- Lifecycle
delivered_at TIMESTAMPTZ DEFAULT now(), -- when we routed it to the business
delivery_email TEXT, -- the email we sent the lead to
delivery_msg_id TEXT, -- SMTP message-id for audit
billed_at TIMESTAMPTZ, -- when we metered the per-lead fee
bill_amount_cents INTEGER,
consumer_unsubscribed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS business_interest_business_idx ON business_interest (business_id);
CREATE INDEX IF NOT EXISTS business_interest_unbilled_idx ON business_interest (billed_at) WHERE billed_at IS NULL;
CREATE INDEX IF NOT EXISTS business_interest_email_idx ON business_interest (consumer_email);
-- Express session store. connect-pg-simple expects this exact shape.
CREATE TABLE IF NOT EXISTS "session" (
"sid" varchar NOT NULL COLLATE "default",
"sess" json NOT NULL,
"expire" timestamp(6) NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("sid")
);
CREATE INDEX IF NOT EXISTS "IDX_session_expire" ON "session" ("expire");
-- updated_at autotouch for businesses.
CREATE OR REPLACE FUNCTION vcl_touch_updated_at() RETURNS TRIGGER AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS businesses_updated_at ON businesses;
CREATE TRIGGER businesses_updated_at BEFORE UPDATE ON businesses
FOR EACH ROW EXECUTE FUNCTION vcl_touch_updated_at();