← back to Professional Directory
db/migrations/003_users_auth.sql
105 lines
-- Phase 1 — Auth, users, doctor claims, subscriptions, claim_status
-- Apply with: psql -d doctor_professional_directory -f db/migrations/003_users_auth.sql
BEGIN;
-- ─── users ─────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS users (
id bigserial PRIMARY KEY,
email citext NOT NULL UNIQUE,
email_verified_at timestamptz,
google_sub text UNIQUE,
display_name text,
avatar_url text,
role text NOT NULL DEFAULT 'patient'
CHECK (role IN ('guest','patient','doctor','admin')),
tier text NOT NULL DEFAULT 'free'
CHECK (tier IN ('free','paid')),
comments_disabled boolean NOT NULL DEFAULT false,
claimed_professional_id bigint REFERENCES professionals(id) ON DELETE SET NULL,
claimed_organization_id bigint REFERENCES organizations(id) ON DELETE SET NULL,
stripe_customer_id text UNIQUE,
last_login_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_users_role_tier ON users (role, tier);
CREATE INDEX IF NOT EXISTS idx_users_claimed_pro ON users (claimed_professional_id) WHERE claimed_professional_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_claimed_org ON users (claimed_organization_id) WHERE claimed_organization_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users (deleted_at) WHERE deleted_at IS NOT NULL;
-- ─── doctor_claims ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS doctor_claims (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
target_professional_id bigint REFERENCES professionals(id) ON DELETE CASCADE,
target_organization_id bigint REFERENCES organizations(id) ON DELETE CASCADE,
npi_provided varchar(10),
license_provided text,
email_verification_token text NOT NULL,
email_verified_at timestamptz,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','approved','rejected','expired')),
status_reason text,
decided_at timestamptz,
decided_by_user_id bigint REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT either_target CHECK (
(target_professional_id IS NOT NULL AND target_organization_id IS NULL) OR
(target_professional_id IS NULL AND target_organization_id IS NOT NULL)
)
);
CREATE INDEX IF NOT EXISTS idx_claims_status ON doctor_claims (status);
CREATE INDEX IF NOT EXISTS idx_claims_user ON doctor_claims (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_one_pending_pro
ON doctor_claims (target_professional_id) WHERE status = 'pending' AND target_professional_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_claims_one_pending_org
ON doctor_claims (target_organization_id) WHERE status = 'pending' AND target_organization_id IS NOT NULL;
-- ─── subscriptions ─────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS subscriptions (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
stripe_subscription_id text NOT NULL UNIQUE,
stripe_price_id text NOT NULL,
plan text NOT NULL, -- doctor_pro_monthly | patient_plus_monthly
status text NOT NULL, -- active|past_due|canceled|unpaid|trialing
current_period_end timestamptz,
cancel_at_period_end boolean NOT NULL DEFAULT false,
started_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz,
raw_json jsonb
);
CREATE INDEX IF NOT EXISTS idx_subs_user ON subscriptions (user_id);
CREATE INDEX IF NOT EXISTS idx_subs_status ON subscriptions (status);
-- ─── express-session table (connect-pg-simple expected schema) ────────────
CREATE TABLE IF NOT EXISTS user_sessions (
sid varchar NOT NULL COLLATE "default" PRIMARY KEY,
sess json NOT NULL,
expire timestamp(6) NOT NULL
) WITH (OIDS=FALSE);
CREATE INDEX IF NOT EXISTS IDX_user_sessions_expire ON user_sessions (expire);
-- ─── claim_status on existing tables ───────────────────────────────────────
ALTER TABLE professionals
ADD COLUMN IF NOT EXISTS claim_status text NOT NULL DEFAULT 'unclaimed'
CHECK (claim_status IN ('unclaimed','claimed','verified'));
ALTER TABLE organizations
ADD COLUMN IF NOT EXISTS claim_status text NOT NULL DEFAULT 'unclaimed'
CHECK (claim_status IN ('unclaimed','claimed','verified'));
CREATE INDEX IF NOT EXISTS idx_pros_claim ON professionals (claim_status);
CREATE INDEX IF NOT EXISTS idx_orgs_claim ON organizations (claim_status);
-- ─── updated_at trigger function (idempotent) ─────────────────────────────
CREATE OR REPLACE FUNCTION trigger_set_timestamp() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS users_set_updated_at ON users;
CREATE TRIGGER users_set_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
COMMIT;