← back to Lawyer Directory Builder
migrations/001_initial_schema.sql
234 lines
-- Lawyer Professional Directory — Initial Schema
-- Compliance-first design: every fact must be traceable to source_url; opt-out flag respected on every public surface.
BEGIN;
-- ─── Reference / dictionary tables ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS sources (
id BIGSERIAL PRIMARY KEY,
source_name TEXT NOT NULL UNIQUE,
source_type TEXT NOT NULL CHECK (source_type IN (
'official_registry', 'government', 'court', 'bar_association',
'firm_website', 'api', 'directory'
)),
base_url TEXT NOT NULL,
terms_notes TEXT,
allowed_method TEXT NOT NULL CHECK (allowed_method IN ('api', 'crawl', 'manual', 'denied')),
rate_limit_rps NUMERIC(6,2),
robots_txt_url TEXT,
last_checked_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS practice_areas (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
parent_area TEXT
);
-- ─── Core entities ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS organizations (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL CHECK (type IN (
'law_firm', 'solo_practice', 'legal_aid',
'government_office', 'court', 'bar_association'
)),
address TEXT,
city TEXT,
state TEXT DEFAULT 'CA',
zip TEXT,
county TEXT,
phone TEXT,
website TEXT,
google_place_id TEXT UNIQUE,
rating NUMERIC(3,1),
review_count INTEGER,
source_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations (LOWER(name));
CREATE INDEX IF NOT EXISTS idx_organizations_zip ON organizations (zip);
CREATE INDEX IF NOT EXISTS idx_organizations_county ON organizations (county);
CREATE INDEX IF NOT EXISTS idx_organizations_website ON organizations (LOWER(website));
CREATE TABLE IF NOT EXISTS professionals (
id BIGSERIAL PRIMARY KEY,
full_name TEXT NOT NULL,
first_name TEXT,
last_name TEXT,
middle_name TEXT,
suffix TEXT,
title TEXT,
bar_number TEXT UNIQUE,
license_status TEXT,
license_status_date DATE,
admission_date DATE,
years_experience_estimate INTEGER,
law_school TEXT,
graduation_year INTEGER,
primary_practice_area TEXT,
secondary_practice_areas TEXT[],
bio TEXT,
languages TEXT[],
profile_image_url TEXT,
source_confidence_score NUMERIC(4,3) NOT NULL DEFAULT 0.000,
confidence_tier TEXT CHECK (confidence_tier IN ('high', 'medium', 'low', 'unverified')),
opt_out_flag BOOLEAN NOT NULL DEFAULT FALSE,
opt_out_reason TEXT,
opt_out_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_professionals_last_first ON professionals (LOWER(last_name), LOWER(first_name));
CREATE INDEX IF NOT EXISTS idx_professionals_status ON professionals (license_status);
CREATE INDEX IF NOT EXISTS idx_professionals_practice ON professionals (primary_practice_area);
CREATE INDEX IF NOT EXISTS idx_professionals_opt_out ON professionals (opt_out_flag);
-- ─── Relationships / multi-valued attributes ────────────────────────────────
CREATE TABLE IF NOT EXISTS professional_locations (
id BIGSERIAL PRIMARY KEY,
professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
organization_id BIGINT REFERENCES organizations(id) ON DELETE SET NULL,
role TEXT,
address TEXT,
phone TEXT,
email TEXT,
appointment_url TEXT,
source_url TEXT,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_prof_loc_professional ON professional_locations (professional_id);
CREATE INDEX IF NOT EXISTS idx_prof_loc_organization ON professional_locations (organization_id);
CREATE TABLE IF NOT EXISTS professional_practice_areas (
professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
practice_area_id BIGINT NOT NULL REFERENCES practice_areas(id) ON DELETE CASCADE,
source TEXT,
confidence_score NUMERIC(4,3) NOT NULL DEFAULT 0.000,
PRIMARY KEY (professional_id, practice_area_id)
);
CREATE TABLE IF NOT EXISTS emails (
id BIGSERIAL PRIMARY KEY,
professional_id BIGINT REFERENCES professionals(id) ON DELETE CASCADE,
organization_id BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
email TEXT NOT NULL,
email_type TEXT NOT NULL CHECK (email_type IN ('public_direct', 'office', 'intake', 'admin', 'unknown')),
source_url TEXT,
discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verification_status TEXT NOT NULL DEFAULT 'unverified'
CHECK (verification_status IN ('unverified', 'syntactically_valid', 'mx_valid', 'bounced', 'opted_out')),
CHECK (professional_id IS NOT NULL OR organization_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_emails_email ON emails (LOWER(email));
CREATE INDEX IF NOT EXISTS idx_emails_professional ON emails (professional_id);
CREATE INDEX IF NOT EXISTS idx_emails_organization ON emails (organization_id);
CREATE TABLE IF NOT EXISTS phones (
id BIGSERIAL PRIMARY KEY,
professional_id BIGINT REFERENCES professionals(id) ON DELETE CASCADE,
organization_id BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
phone TEXT NOT NULL,
phone_type TEXT CHECK (phone_type IN ('office', 'direct', 'mobile', 'fax', 'intake', 'unknown')),
source_url TEXT,
last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CHECK (professional_id IS NOT NULL OR organization_id IS NOT NULL)
);
CREATE INDEX IF NOT EXISTS idx_phones_professional ON phones (professional_id);
CREATE INDEX IF NOT EXISTS idx_phones_organization ON phones (organization_id);
CREATE TABLE IF NOT EXISTS education (
id BIGSERIAL PRIMARY KEY,
professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
school_name TEXT NOT NULL,
degree TEXT,
graduation_year INTEGER,
source_url TEXT
);
CREATE INDEX IF NOT EXISTS idx_education_professional ON education (professional_id);
CREATE TABLE IF NOT EXISTS bar_admissions (
id BIGSERIAL PRIMARY KEY,
professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
jurisdiction TEXT NOT NULL,
bar_number TEXT,
admission_date DATE,
status TEXT,
source_url TEXT,
UNIQUE (professional_id, jurisdiction, bar_number)
);
CREATE INDEX IF NOT EXISTS idx_bar_admissions_professional ON bar_admissions (professional_id);
-- ─── Audit / provenance tables ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS raw_records (
id BIGSERIAL PRIMARY KEY,
source_id BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
source_url TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT,
raw_json JSONB,
raw_html_path TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
http_status INTEGER,
hash TEXT NOT NULL,
UNIQUE (source_id, hash)
);
CREATE INDEX IF NOT EXISTS idx_raw_records_source ON raw_records (source_id);
CREATE INDEX IF NOT EXISTS idx_raw_records_url ON raw_records (source_url);
CREATE INDEX IF NOT EXISTS idx_raw_records_entity ON raw_records (entity_type, entity_id);
CREATE TABLE IF NOT EXISTS scrape_jobs (
id BIGSERIAL PRIMARY KEY,
source_id BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
job_label TEXT,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'completed', 'failed', 'aborted_compliance')),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
records_found INTEGER NOT NULL DEFAULT 0,
records_inserted INTEGER NOT NULL DEFAULT 0,
records_updated INTEGER NOT NULL DEFAULT 0,
records_skipped INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
checkpoint JSONB
);
CREATE INDEX IF NOT EXISTS idx_scrape_jobs_source ON scrape_jobs (source_id);
CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status ON scrape_jobs (status);
-- ─── Trigger: auto-update updated_at ───────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_professionals_updated ON professionals;
CREATE TRIGGER trg_professionals_updated
BEFORE UPDATE ON professionals
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
DROP TRIGGER IF EXISTS trg_organizations_updated ON organizations;
CREATE TRIGGER trg_organizations_updated
BEFORE UPDATE ON organizations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
COMMIT;