← back to Professional Directory
db/migrations/004_reviews.sql
104 lines
-- Phase 3 — reviews, ratings, aggregation cache.
-- Apply: psql -d doctor_professional_directory -f db/migrations/004_reviews.sql
BEGIN;
-- ─── reviews ───────────────────────────────────────────────────────────────
-- One review per (reviewer, target). Patients post under role='patient'+tier='free'+;
-- doctors can also post under specific guardrails (no self-review). Reddit/Yelp/HG
-- seeded reviews use source != 'user' and have no reviewer_user_id.
CREATE TABLE IF NOT EXISTS reviews (
id bigserial PRIMARY KEY,
target_professional_id bigint REFERENCES professionals(id) ON DELETE CASCADE,
target_organization_id bigint REFERENCES organizations(id) ON DELETE CASCADE,
reviewer_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
reviewer_display text, -- snapshot for seeded reviews
service_score smallint CHECK (service_score IS NULL OR service_score BETWEEN 1 AND 5),
price_score smallint CHECK (price_score IS NULL OR price_score BETWEEN 1 AND 5),
quality_score smallint CHECK (quality_score IS NULL OR quality_score BETWEEN 1 AND 5),
overall_score smallint CHECK (overall_score IS NULL OR overall_score BETWEEN 1 AND 5),
title text,
body text NOT NULL,
source text NOT NULL DEFAULT 'user'
CHECK (source IN ('user','reddit','yelp_seed','healthgrades_seed','nextdoor')),
source_url text,
source_posted_at timestamptz,
suppressed_by_target boolean NOT NULL DEFAULT false, -- patient claims "not me" / opts out their profile
hidden_by_admin boolean NOT NULL DEFAULT false,
hidden_reason text,
go_live_at timestamptz, -- 24h delay before public view (anti-brigading)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT either_target_review 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_reviews_target_pro ON reviews (target_professional_id) WHERE target_professional_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_reviews_target_org ON reviews (target_organization_id) WHERE target_organization_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_reviews_source ON reviews (source);
CREATE INDEX IF NOT EXISTS idx_reviews_reviewer ON reviews (reviewer_user_id) WHERE reviewer_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_reviews_visible ON reviews (target_professional_id, target_organization_id)
WHERE suppressed_by_target = false AND hidden_by_admin = false;
-- Anti-double-review (only one user-source review per reviewer/target pair).
CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_one_per_user_pro
ON reviews (reviewer_user_id, target_professional_id)
WHERE source='user' AND reviewer_user_id IS NOT NULL AND target_professional_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_one_per_user_org
ON reviews (reviewer_user_id, target_organization_id)
WHERE source='user' AND reviewer_user_id IS NOT NULL AND target_organization_id IS NOT NULL;
-- Dedup of seeded reviews by source URL.
CREATE UNIQUE INDEX IF NOT EXISTS idx_reviews_source_url
ON reviews (source_url) WHERE source_url IS NOT NULL;
DROP TRIGGER IF EXISTS reviews_set_updated_at ON reviews;
CREATE TRIGGER reviews_set_updated_at BEFORE UPDATE ON reviews
FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
-- ─── review_responses ─────────────────────────────────────────────────────
-- Doctor's official reply on a review. One per review.
CREATE TABLE IF NOT EXISTS review_responses (
id bigserial PRIMARY KEY,
review_id bigint NOT NULL UNIQUE REFERENCES reviews(id) ON DELETE CASCADE,
author_user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
DROP TRIGGER IF EXISTS review_responses_set_updated_at ON review_responses;
CREATE TRIGGER review_responses_set_updated_at BEFORE UPDATE ON review_responses
FOR EACH ROW EXECUTE FUNCTION trigger_set_timestamp();
-- ─── review_flags (community moderation) ──────────────────────────────────
CREATE TABLE IF NOT EXISTS review_flags (
id bigserial PRIMARY KEY,
review_id bigint NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
flagger_user_id bigint REFERENCES users(id) ON DELETE SET NULL,
reason text NOT NULL CHECK (reason IN ('spam','fake','off_topic','medical_advice','personal_attack','other')),
notes text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_review_flags_review ON review_flags (review_id);
-- ─── aggregated_ratings (denormalized) ────────────────────────────────────
-- Recomputed nightly by scripts/aggregate-ratings.js. Honors suppression + hidden.
CREATE TABLE IF NOT EXISTS aggregated_ratings (
id bigserial PRIMARY KEY,
target_professional_id bigint UNIQUE REFERENCES professionals(id) ON DELETE CASCADE,
target_organization_id bigint UNIQUE REFERENCES organizations(id) ON DELETE CASCADE,
service_avg numeric(3,2),
price_avg numeric(3,2),
quality_avg numeric(3,2),
overall_avg numeric(3,2),
n_reviews integer NOT NULL DEFAULT 0,
n_user_reviews integer NOT NULL DEFAULT 0,
n_seeded_reviews integer NOT NULL DEFAULT 0,
last_recomputed_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT either_target_agg 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)
)
);
COMMIT;