← back to NationalPaperHangers
db/schema.sql
299 lines
-- NationalPaperHangers.com schema
-- Standalone PG database `national_paper_hangers` (NOT in dw_unified)
BEGIN;
-- =======================================================================
-- INSTALLERS
-- =======================================================================
CREATE TABLE IF NOT EXISTS installers (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
business_name TEXT NOT NULL,
contact_name TEXT,
phone TEXT,
bio TEXT,
headline TEXT,
city TEXT,
state TEXT,
zip TEXT,
country TEXT DEFAULT 'US',
service_radius_miles INTEGER DEFAULT 50,
travel_available BOOLEAN DEFAULT false,
team_size INTEGER,
founded_year INTEGER,
website TEXT,
-- specialties / segments
market_segments TEXT[] DEFAULT '{}', -- e.g. {luxury_residential, hospitality, retail, museum}
materials TEXT[] DEFAULT '{}', -- e.g. {grasscloth, silk, hand_painted, mural, vinyl}
brands_handled TEXT[] DEFAULT '{}', -- e.g. {Maya Romanoff, Fromental, de Gournay}
accreditations TEXT[] DEFAULT '{}', -- WIA, manufacturer certs, etc.
-- trust / verification
verified BOOLEAN DEFAULT false,
verified_at TIMESTAMPTZ,
verified_by TEXT,
insurance_on_file BOOLEAN DEFAULT false,
insurance_expires DATE,
license_number TEXT,
license_state TEXT,
-- subscription
tier TEXT DEFAULT 'basic', -- basic | pro | signature | enterprise
subscription_status TEXT DEFAULT 'inactive', -- inactive | active | past_due | canceled
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
current_period_end TIMESTAMPTZ,
-- meta
response_time_hours INTEGER DEFAULT 24,
status TEXT DEFAULT 'pending', -- pending | active | suspended | archived
profile_complete BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_installers_slug ON installers(slug);
CREATE INDEX IF NOT EXISTS idx_installers_email ON installers(email);
CREATE INDEX IF NOT EXISTS idx_installers_status ON installers(status);
CREATE INDEX IF NOT EXISTS idx_installers_tier ON installers(tier);
CREATE INDEX IF NOT EXISTS idx_installers_zip ON installers(zip);
CREATE INDEX IF NOT EXISTS idx_installers_state ON installers(state);
CREATE INDEX IF NOT EXISTS idx_installers_segments ON installers USING GIN(market_segments);
CREATE INDEX IF NOT EXISTS idx_installers_materials ON installers USING GIN(materials);
-- =======================================================================
-- PORTFOLIO (project case studies per installer)
-- =======================================================================
CREATE TABLE IF NOT EXISTS installer_portfolio (
id SERIAL PRIMARY KEY,
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
image_url TEXT NOT NULL,
caption TEXT,
market_segment TEXT,
material TEXT,
brand TEXT,
city TEXT,
state TEXT,
year INTEGER,
display_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_portfolio_installer ON installer_portfolio(installer_id);
-- =======================================================================
-- AVAILABILITY (recurring weekly schedule)
-- =======================================================================
CREATE TABLE IF NOT EXISTS installer_availability (
id SERIAL PRIMARY KEY,
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sun ... 6=Sat
start_time TIME NOT NULL,
end_time TIME NOT NULL,
timezone TEXT DEFAULT 'America/Los_Angeles',
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT now(),
CHECK (end_time > start_time)
);
CREATE INDEX IF NOT EXISTS idx_availability_installer ON installer_availability(installer_id);
CREATE INDEX IF NOT EXISTS idx_availability_day ON installer_availability(installer_id, day_of_week);
-- =======================================================================
-- TIME OFF (blocked dates / vacations / one-off blocks)
-- =======================================================================
CREATE TABLE IF NOT EXISTS installer_time_off (
id SERIAL PRIMARY KEY,
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
reason TEXT,
all_day BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
CHECK (end_at > start_at)
);
CREATE INDEX IF NOT EXISTS idx_time_off_installer ON installer_time_off(installer_id);
CREATE INDEX IF NOT EXISTS idx_time_off_range ON installer_time_off(installer_id, start_at, end_at);
-- =======================================================================
-- BOOKINGS (consumer-scheduled installs / consultations)
-- =======================================================================
CREATE TABLE IF NOT EXISTS bookings (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE RESTRICT,
-- consumer identity
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT,
-- project basics
project_type TEXT, -- consultation | install | site_visit | quote
market_segment TEXT, -- luxury_residential | hospitality | retail | etc.
material TEXT,
brand TEXT,
surfaces TEXT, -- free text: walls, ceiling, etc.
rooms TEXT,
square_feet INTEGER,
budget_band TEXT, -- under_5k | 5k_15k | 15k_50k | 50k_plus
-- location
address_line1 TEXT,
address_line2 TEXT,
city TEXT,
state TEXT,
zip TEXT,
-- schedule
scheduled_start TIMESTAMPTZ NOT NULL,
scheduled_end TIMESTAMPTZ NOT NULL,
timezone TEXT DEFAULT 'America/Los_Angeles',
-- status
status TEXT NOT NULL DEFAULT 'pending', -- pending | confirmed | declined | completed | canceled | no_show
installer_notes TEXT,
customer_notes TEXT,
cancel_reason TEXT,
-- payment (deposits — phase 4)
deposit_amount_cents INTEGER,
deposit_status TEXT, -- none | requires_payment | paid | refunded
stripe_payment_intent_id TEXT,
-- meta
source TEXT DEFAULT 'web', -- web | concierge | partner
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
confirmed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
canceled_at TIMESTAMPTZ,
CHECK (scheduled_end > scheduled_start)
);
CREATE INDEX IF NOT EXISTS idx_bookings_installer ON bookings(installer_id);
CREATE INDEX IF NOT EXISTS idx_bookings_status ON bookings(status);
CREATE INDEX IF NOT EXISTS idx_bookings_scheduled ON bookings(installer_id, scheduled_start, scheduled_end);
CREATE INDEX IF NOT EXISTS idx_bookings_customer_email ON bookings(customer_email);
CREATE INDEX IF NOT EXISTS idx_bookings_uuid ON bookings(uuid);
-- =======================================================================
-- REVIEWS (post-completion, gated on completed booking)
-- =======================================================================
CREATE TABLE IF NOT EXISTS installer_reviews (
id SERIAL PRIMARY KEY,
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
booking_id INTEGER REFERENCES bookings(id) ON DELETE SET NULL,
customer_name TEXT,
customer_email TEXT,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
title TEXT,
body TEXT,
verified BOOLEAN DEFAULT false, -- true if booking_id is non-null AND completed
published BOOLEAN DEFAULT false, -- moderation gate
moderation_note TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
published_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_reviews_installer ON installer_reviews(installer_id);
CREATE INDEX IF NOT EXISTS idx_reviews_published ON installer_reviews(installer_id, published);
-- =======================================================================
-- CONSUMER LEADS (pre-booking briefs from concierge intake)
-- =======================================================================
CREATE TABLE IF NOT EXISTS consumer_leads (
id SERIAL PRIMARY KEY,
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
customer_name TEXT NOT NULL,
customer_email TEXT NOT NULL,
customer_phone TEXT,
customer_role TEXT, -- homeowner | designer | architect | hospitality | other
company TEXT,
project_name TEXT,
market_segment TEXT,
material TEXT,
brand TEXT,
surfaces TEXT,
rooms TEXT,
square_feet INTEGER,
budget_band TEXT,
timeline TEXT,
city TEXT,
state TEXT,
zip TEXT,
product_sourced BOOLEAN,
notes TEXT,
status TEXT DEFAULT 'new', -- new | shortlisted | matched | closed
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_leads_status ON consumer_leads(status);
CREATE INDEX IF NOT EXISTS idx_leads_zip ON consumer_leads(zip);
-- =======================================================================
-- LEAD ↔ INSTALLER OFFERS (when a lead is routed to N installers)
-- =======================================================================
CREATE TABLE IF NOT EXISTS lead_offers (
id SERIAL PRIMARY KEY,
lead_id INTEGER NOT NULL REFERENCES consumer_leads(id) ON DELETE CASCADE,
installer_id INTEGER NOT NULL REFERENCES installers(id) ON DELETE CASCADE,
status TEXT DEFAULT 'pending', -- pending | accepted | declined | expired
expires_at TIMESTAMPTZ,
responded_at TIMESTAMPTZ,
installer_response TEXT,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE (lead_id, installer_id)
);
CREATE INDEX IF NOT EXISTS idx_offers_installer ON lead_offers(installer_id, status);
-- =======================================================================
-- SUBSCRIPTION EVENTS (audit trail from Stripe webhooks)
-- =======================================================================
CREATE TABLE IF NOT EXISTS subscription_events (
id SERIAL PRIMARY KEY,
installer_id INTEGER REFERENCES installers(id) ON DELETE SET NULL,
stripe_event_id TEXT UNIQUE,
event_type TEXT NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sub_events_installer ON subscription_events(installer_id);
-- =======================================================================
-- SESSIONS (express-session via connect-pg-simple)
-- =======================================================================
CREATE TABLE IF NOT EXISTS session (
sid VARCHAR NOT NULL COLLATE "default" PRIMARY KEY,
sess JSON NOT NULL,
expire TIMESTAMP(6) NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_expire ON session(expire);
-- =======================================================================
-- updated_at trigger
-- =======================================================================
CREATE OR REPLACE FUNCTION touch_updated_at() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_installers_updated ON installers;
CREATE TRIGGER trg_installers_updated BEFORE UPDATE ON installers
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_bookings_updated ON bookings;
CREATE TRIGGER trg_bookings_updated BEFORE UPDATE ON bookings
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
COMMIT;