Files
leadcast/backend/migrations/0001_initial.sql
T
2026-07-26 21:17:57 -03:00

535 lines
26 KiB
PL/PgSQL

BEGIN;
CREATE TABLE IF NOT EXISTS schema_migrations (
version text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE OR REPLACE FUNCTION touch_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
CREATE TABLE media_assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN (
'channel_logo', 'video_thumbnail', 'interviewee_professional',
'interviewee_personal', 'origin_icon', 'other'
)),
source_url text,
storage_path text NOT NULL,
sha256 text NOT NULL,
mime_type text NOT NULL,
size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
width integer CHECK (width IS NULL OR width > 0),
height integer CHECK (height IS NULL OR height > 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT media_assets_sha256_not_blank CHECK (length(trim(sha256)) > 0),
CONSTRAINT media_assets_storage_path_not_blank CHECK (length(trim(storage_path)) > 0),
CONSTRAINT media_assets_sha256_unique UNIQUE (sha256)
);
CREATE TABLE podcast_channels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
youtube_channel_id text NOT NULL,
name text NOT NULL,
canonical_url text NOT NULL,
logo_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'candidate'
CHECK (status IN ('candidate', 'selected', 'active', 'removed', 'failed')),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
discovered_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT podcast_channels_youtube_id_not_blank CHECK (length(trim(youtube_channel_id)) > 0),
CONSTRAINT podcast_channels_name_not_blank CHECK (length(trim(name)) > 0),
CONSTRAINT podcast_channels_url_not_blank CHECK (length(trim(canonical_url)) > 0),
CONSTRAINT podcast_channels_youtube_id_unique UNIQUE (youtube_channel_id),
CONSTRAINT podcast_channels_canonical_url_unique UNIQUE (canonical_url)
);
CREATE TABLE videos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id uuid NOT NULL REFERENCES podcast_channels(id) ON DELETE CASCADE,
youtube_video_id text NOT NULL,
canonical_url text NOT NULL,
title text NOT NULL,
description text NOT NULL DEFAULT '',
published_at timestamptz,
duration_seconds integer CHECK (duration_seconds IS NULL OR duration_seconds >= 0),
thumbnail_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL,
processing_status text NOT NULL DEFAULT 'pending'
CHECK (processing_status IN ('pending', 'processing', 'processed', 'skipped', 'failed')),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT videos_youtube_id_not_blank CHECK (length(trim(youtube_video_id)) > 0),
CONSTRAINT videos_title_not_blank CHECK (length(trim(title)) > 0),
CONSTRAINT videos_youtube_id_unique UNIQUE (youtube_video_id),
CONSTRAINT videos_canonical_url_unique UNIQUE (canonical_url)
);
CREATE TABLE transcripts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
video_id uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
language text NOT NULL,
source text NOT NULL CHECK (source IN ('youtube', 'translated', 'speech_to_text', 'manual')),
text_content text NOT NULL DEFAULT '',
content_hash text NOT NULL,
status text NOT NULL DEFAULT 'ready'
CHECK (status IN ('pending', 'ready', 'unavailable', 'failed')),
is_generated boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT transcripts_language_not_blank CHECK (length(trim(language)) > 0),
CONSTRAINT transcripts_hash_not_blank CHECK (length(trim(content_hash)) > 0),
CONSTRAINT transcripts_video_language_source_unique UNIQUE (video_id, language, source)
);
CREATE TABLE categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
display_name text NOT NULL,
normalized_name text NOT NULL,
description text NOT NULL DEFAULT '',
created_by text NOT NULL DEFAULT 'ai' CHECK (created_by IN ('ai', 'manual', 'system')),
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT categories_display_name_not_blank CHECK (length(trim(display_name)) > 0),
CONSTRAINT categories_normalized_name_not_blank CHECK (length(trim(normalized_name)) > 0),
CONSTRAINT categories_normalized_name_unique UNIQUE (normalized_name)
);
CREATE TABLE pipeline_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN (
'podcast_discovery', 'interviewee_extraction', 'contact_extraction'
)),
mode text NOT NULL DEFAULT 'manual' CHECK (mode IN ('manual', 'automatic')),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'running', 'paused', 'cancelling', 'cancelled', 'completed', 'failed')),
idempotency_key text,
requested_by uuid,
input jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(input) = 'object'),
progress_current bigint NOT NULL DEFAULT 0 CHECK (progress_current >= 0),
progress_total bigint CHECK (progress_total IS NULL OR progress_total >= 0),
stats jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(stats) = 'object'),
error_code text,
error_message text,
started_at timestamptz,
finished_at timestamptz,
cancel_requested_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pipeline_runs_idempotency_key_unique UNIQUE (idempotency_key),
CONSTRAINT pipeline_runs_progress_valid CHECK (
progress_total IS NULL OR progress_current <= progress_total
)
);
CREATE TABLE interviewees (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
primary_category_id uuid REFERENCES categories(id) ON DELETE RESTRICT,
display_name text NOT NULL,
real_name text,
brand_name text,
normalized_display_name text NOT NULL,
normalized_real_name text,
normalized_brand_name text,
professional_summary text NOT NULL DEFAULT '',
public_bio text NOT NULL DEFAULT '',
creator_content_type text,
creator_audience text,
professional_image_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL,
personal_image_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL,
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'merged', 'archived')),
dedup_review_status text NOT NULL DEFAULT 'unreviewed'
CHECK (dedup_review_status IN ('unreviewed', 'confirmed', 'needs_review')),
created_in_run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT interviewees_display_name_not_blank CHECK (length(trim(display_name)) > 0),
CONSTRAINT interviewees_normalized_name_not_blank CHECK (length(trim(normalized_display_name)) > 0)
);
CREATE TABLE interviewee_aliases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE,
alias text NOT NULL,
normalized_alias text NOT NULL,
kind text NOT NULL DEFAULT 'other'
CHECK (kind IN ('real_name', 'brand_name', 'stage_name', 'social_handle', 'other')),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT interviewee_aliases_alias_not_blank CHECK (length(trim(alias)) > 0),
CONSTRAINT interviewee_aliases_normalized_not_blank CHECK (length(trim(normalized_alias)) > 0),
CONSTRAINT interviewee_aliases_unique UNIQUE (interviewee_id, kind, normalized_alias)
);
CREATE TABLE interviewee_redirects (
old_interviewee_id uuid PRIMARY KEY REFERENCES interviewees(id) ON DELETE CASCADE,
canonical_interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE RESTRICT,
reason text NOT NULL DEFAULT '',
merged_by text NOT NULL DEFAULT 'manual' CHECK (merged_by IN ('manual', 'system')),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT interviewee_redirects_not_self CHECK (old_interviewee_id <> canonical_interviewee_id)
);
CREATE TABLE appearances (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE,
video_id uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
evidence text NOT NULL DEFAULT '',
evidence_hash text,
extraction_source text NOT NULL DEFAULT 'ai' CHECK (extraction_source IN ('ai', 'manual', 'metadata')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT appearances_interviewee_video_unique UNIQUE (interviewee_id, video_id)
);
CREATE TABLE origins (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
display_name text NOT NULL,
canonical_url text NOT NULL,
domain text NOT NULL,
source_type text NOT NULL DEFAULT 'website'
CHECK (source_type IN ('youtube', 'instagram', 'linktree', 'website', 'google', 'other')),
icon_asset_id uuid REFERENCES media_assets(id) ON DELETE SET NULL,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT origins_name_not_blank CHECK (length(trim(display_name)) > 0),
CONSTRAINT origins_url_not_blank CHECK (length(trim(canonical_url)) > 0),
CONSTRAINT origins_domain_not_blank CHECK (length(trim(domain)) > 0),
CONSTRAINT origins_canonical_url_unique UNIQUE (canonical_url)
);
CREATE TABLE contacts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE,
primary_origin_id uuid NOT NULL REFERENCES origins(id) ON DELETE RESTRICT,
contact_type text NOT NULL
CHECK (contact_type IN (
'email', 'phone', 'whatsapp', 'instagram', 'linkedin', 'facebook',
'tiktok', 'x', 'telegram', 'youtube', 'website', 'other'
)),
raw_value text NOT NULL,
normalized_value text NOT NULL,
relationship_kind text NOT NULL CHECK (relationship_kind IN ('personal', 'commercial')),
label text,
confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'stale', 'suppressed', 'deleted')),
discovered_in_run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL,
first_seen_at timestamptz NOT NULL DEFAULT now(),
last_seen_at timestamptz NOT NULL DEFAULT now(),
last_verified_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT contacts_raw_value_not_blank CHECK (length(trim(raw_value)) > 0),
CONSTRAINT contacts_normalized_value_not_blank CHECK (length(trim(normalized_value)) > 0)
);
CREATE UNIQUE INDEX contacts_active_identity_unique
ON contacts (interviewee_id, contact_type, normalized_value)
WHERE deleted_at IS NULL AND status <> 'deleted';
CREATE TABLE contact_evidence (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
contact_id uuid NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
origin_id uuid NOT NULL REFERENCES origins(id) ON DELETE RESTRICT,
page_url text NOT NULL,
evidence_text text NOT NULL DEFAULT '',
evidence_hash text,
confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
collected_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX contact_evidence_unique
ON contact_evidence (contact_id, origin_id, page_url, COALESCE(evidence_hash, ''));
CREATE TABLE jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid REFERENCES pipeline_runs(id) ON DELETE CASCADE,
parent_job_id uuid REFERENCES jobs(id) ON DELETE SET NULL,
kind text NOT NULL,
payload jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(payload) = 'object'),
result jsonb CHECK (result IS NULL OR jsonb_typeof(result) = 'object'),
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'running', 'retry_scheduled', 'succeeded', 'failed', 'cancelled')),
priority integer NOT NULL DEFAULT 0,
idempotency_key text,
attempt_count integer NOT NULL DEFAULT 0 CHECK (attempt_count >= 0),
max_attempts integer NOT NULL DEFAULT 5 CHECK (max_attempts > 0),
available_at timestamptz NOT NULL DEFAULT now(),
locked_at timestamptz,
locked_by text,
heartbeat_at timestamptz,
started_at timestamptz,
finished_at timestamptz,
cancel_requested_at timestamptz,
last_error_code text,
last_error_message text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT jobs_kind_not_blank CHECK (length(trim(kind)) > 0),
CONSTRAINT jobs_idempotency_key_unique UNIQUE (idempotency_key),
CONSTRAINT jobs_attempt_bounds CHECK (attempt_count <= max_attempts)
);
CREATE TABLE job_attempts (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_id uuid NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
attempt_no integer NOT NULL CHECK (attempt_no > 0),
worker_id text NOT NULL,
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'succeeded', 'failed', 'cancelled')),
error_code text,
error_message text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
metrics jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metrics) = 'object'),
CONSTRAINT job_attempts_job_attempt_unique UNIQUE (job_id, attempt_no)
);
CREATE TABLE crawl_pages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE,
interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE,
origin_id uuid REFERENCES origins(id) ON DELETE SET NULL,
canonical_url text NOT NULL,
depth smallint NOT NULL CHECK (depth >= 0 AND depth <= 5),
status text NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'fetching', 'fetched', 'skipped', 'failed', 'blocked')),
relevance real CHECK (relevance IS NULL OR (relevance >= 0 AND relevance <= 1)),
http_status integer CHECK (http_status IS NULL OR (http_status >= 100 AND http_status <= 599)),
content_hash text,
title text,
extracted_text text,
content_bytes bigint CHECK (content_bytes IS NULL OR content_bytes >= 0),
error_code text,
error_message text,
queued_at timestamptz NOT NULL DEFAULT now(),
fetched_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT crawl_pages_url_not_blank CHECK (length(trim(canonical_url)) > 0),
CONSTRAINT crawl_pages_run_url_unique UNIQUE (run_id, canonical_url)
);
CREATE TABLE crawl_edges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE,
from_page_id uuid REFERENCES crawl_pages(id) ON DELETE CASCADE,
to_page_id uuid REFERENCES crawl_pages(id) ON DELETE SET NULL,
discovered_url text NOT NULL,
anchor_text text,
relationship text NOT NULL DEFAULT 'link' CHECK (relationship IN ('seed', 'link', 'redirect', 'canonical')),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT crawl_edges_url_not_blank CHECK (length(trim(discovered_url)) > 0)
);
CREATE UNIQUE INDEX crawl_edges_unique
ON crawl_edges (run_id, COALESCE(from_page_id, '00000000-0000-0000-0000-000000000000'::uuid), discovered_url, relationship);
CREATE TABLE interviewee_candidates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE,
video_id uuid NOT NULL REFERENCES videos(id) ON DELETE CASCADE,
proposed_name text NOT NULL,
normalized_name text NOT NULL,
proposed_real_name text,
proposed_brand_name text,
professional_summary text NOT NULL DEFAULT '',
evidence text NOT NULL DEFAULT '',
evidence_hash text,
confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'matched', 'created', 'rejected')),
matched_interviewee_id uuid REFERENCES interviewees(id) ON DELETE SET NULL,
ai_call_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT interviewee_candidates_name_not_blank CHECK (length(trim(proposed_name)) > 0),
CONSTRAINT interviewee_candidates_normalized_not_blank CHECK (length(trim(normalized_name)) > 0)
);
CREATE UNIQUE INDEX interviewee_candidates_unique
ON interviewee_candidates (run_id, video_id, normalized_name, COALESCE(evidence_hash, ''));
CREATE TABLE contact_candidates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid NOT NULL REFERENCES pipeline_runs(id) ON DELETE CASCADE,
interviewee_id uuid NOT NULL REFERENCES interviewees(id) ON DELETE CASCADE,
origin_id uuid NOT NULL REFERENCES origins(id) ON DELETE CASCADE,
crawl_page_id uuid REFERENCES crawl_pages(id) ON DELETE SET NULL,
contact_type text NOT NULL
CHECK (contact_type IN (
'email', 'phone', 'whatsapp', 'instagram', 'linkedin', 'facebook',
'tiktok', 'x', 'telegram', 'youtube', 'website', 'other'
)),
raw_value text NOT NULL,
normalized_value text NOT NULL,
proposed_relationship_kind text CHECK (proposed_relationship_kind IN ('personal', 'commercial')),
proposed_label text,
evidence text NOT NULL DEFAULT '',
confidence real NOT NULL CHECK (confidence >= 0 AND confidence <= 1),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'rejected', 'needs_review')),
rejection_reason text,
accepted_contact_id uuid REFERENCES contacts(id) ON DELETE SET NULL,
ai_call_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT contact_candidates_raw_not_blank CHECK (length(trim(raw_value)) > 0),
CONSTRAINT contact_candidates_normalized_not_blank CHECK (length(trim(normalized_value)) > 0),
CONSTRAINT contact_candidates_unique UNIQUE (
run_id, interviewee_id, origin_id, contact_type, normalized_value
)
);
CREATE TABLE ai_calls (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL,
job_id uuid REFERENCES jobs(id) ON DELETE SET NULL,
purpose text NOT NULL CHECK (purpose IN (
'guest_extraction', 'contact_extraction', 'categorization',
'identity_resolution', 'page_relevance', 'summary', 'other'
)),
model text NOT NULL,
prompt_version text NOT NULL,
schema_version text NOT NULL,
input_hash text NOT NULL,
input_payload jsonb,
output_payload jsonb,
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'succeeded', 'failed', 'invalid_output')),
prompt_tokens integer CHECK (prompt_tokens IS NULL OR prompt_tokens >= 0),
completion_tokens integer CHECK (completion_tokens IS NULL OR completion_tokens >= 0),
cost_micros bigint CHECK (cost_micros IS NULL OR cost_micros >= 0),
latency_ms bigint CHECK (latency_ms IS NULL OR latency_ms >= 0),
error_code text,
error_message text,
retain_until timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
CONSTRAINT ai_calls_model_not_blank CHECK (length(trim(model)) > 0),
CONSTRAINT ai_calls_input_hash_not_blank CHECK (length(trim(input_hash)) > 0)
);
ALTER TABLE interviewee_candidates
ADD CONSTRAINT interviewee_candidates_ai_call_fk
FOREIGN KEY (ai_call_id) REFERENCES ai_calls(id) ON DELETE SET NULL;
ALTER TABLE contact_candidates
ADD CONSTRAINT contact_candidates_ai_call_fk
FOREIGN KEY (ai_call_id) REFERENCES ai_calls(id) ON DELETE SET NULL;
CREATE TABLE audit_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
run_id uuid REFERENCES pipeline_runs(id) ON DELETE SET NULL,
actor_type text NOT NULL DEFAULT 'system' CHECK (actor_type IN ('system', 'ai', 'user', 'worker')),
actor_id uuid,
action text NOT NULL,
entity_type text NOT NULL,
entity_id uuid,
before_data jsonb,
after_data jsonb,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb CHECK (jsonb_typeof(metadata) = 'object'),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT audit_events_action_not_blank CHECK (length(trim(action)) > 0),
CONSTRAINT audit_events_entity_type_not_blank CHECK (length(trim(entity_type)) > 0)
);
CREATE TABLE suppression_entries (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scope_kind text NOT NULL CHECK (scope_kind IN ('contact', 'interviewee', 'domain')),
contact_type text,
normalized_hash text NOT NULL,
reason text NOT NULL DEFAULT '',
created_by uuid,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT suppression_entries_hash_not_blank CHECK (length(trim(normalized_hash)) > 0),
CONSTRAINT suppression_entries_unique UNIQUE NULLS NOT DISTINCT (scope_kind, contact_type, normalized_hash)
);
CREATE INDEX podcast_channels_name_trgm_idx ON podcast_channels USING gin (name gin_trgm_ops);
CREATE INDEX podcast_channels_status_idx ON podcast_channels (status) WHERE deleted_at IS NULL;
CREATE INDEX videos_channel_published_idx ON videos (channel_id, published_at DESC) WHERE deleted_at IS NULL;
CREATE INDEX transcripts_video_idx ON transcripts (video_id);
CREATE INDEX categories_name_trgm_idx ON categories USING gin (normalized_name gin_trgm_ops) WHERE active;
CREATE INDEX interviewees_display_name_trgm_idx ON interviewees USING gin (normalized_display_name gin_trgm_ops)
WHERE deleted_at IS NULL AND status = 'active';
CREATE INDEX interviewees_category_idx ON interviewees (primary_category_id) WHERE deleted_at IS NULL;
CREATE INDEX interviewee_aliases_name_trgm_idx ON interviewee_aliases USING gin (normalized_alias gin_trgm_ops);
CREATE INDEX appearances_video_idx ON appearances (video_id);
CREATE INDEX origins_domain_idx ON origins (domain);
CREATE INDEX contacts_interviewee_idx ON contacts (interviewee_id) WHERE deleted_at IS NULL;
CREATE INDEX contacts_type_value_idx ON contacts (contact_type, normalized_value) WHERE deleted_at IS NULL;
CREATE INDEX contact_evidence_contact_idx ON contact_evidence (contact_id, collected_at DESC);
CREATE INDEX pipeline_runs_status_created_idx ON pipeline_runs (status, created_at DESC);
CREATE INDEX jobs_claim_idx ON jobs (priority DESC, available_at, created_at)
WHERE status IN ('queued', 'retry_scheduled') AND cancel_requested_at IS NULL;
CREATE INDEX jobs_run_status_idx ON jobs (run_id, status);
CREATE INDEX jobs_locked_idx ON jobs (locked_at) WHERE status = 'running';
CREATE INDEX job_attempts_job_idx ON job_attempts (job_id, attempt_no DESC);
CREATE INDEX crawl_pages_frontier_idx ON crawl_pages (run_id, depth, queued_at) WHERE status = 'queued';
CREATE INDEX crawl_pages_interviewee_idx ON crawl_pages (interviewee_id, status);
CREATE INDEX crawl_edges_from_idx ON crawl_edges (from_page_id);
CREATE INDEX interviewee_candidates_status_idx ON interviewee_candidates (run_id, status);
CREATE INDEX contact_candidates_status_idx ON contact_candidates (run_id, status);
CREATE INDEX ai_calls_run_created_idx ON ai_calls (run_id, created_at DESC);
CREATE INDEX ai_calls_purpose_status_idx ON ai_calls (purpose, status);
CREATE INDEX audit_events_entity_idx ON audit_events (entity_type, entity_id, created_at DESC);
CREATE INDEX suppression_entries_lookup_idx ON suppression_entries (normalized_hash, scope_kind);
CREATE TRIGGER media_assets_touch_updated_at BEFORE UPDATE ON media_assets
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER podcast_channels_touch_updated_at BEFORE UPDATE ON podcast_channels
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER videos_touch_updated_at BEFORE UPDATE ON videos
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER transcripts_touch_updated_at BEFORE UPDATE ON transcripts
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER categories_touch_updated_at BEFORE UPDATE ON categories
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER pipeline_runs_touch_updated_at BEFORE UPDATE ON pipeline_runs
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER interviewees_touch_updated_at BEFORE UPDATE ON interviewees
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER appearances_touch_updated_at BEFORE UPDATE ON appearances
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER origins_touch_updated_at BEFORE UPDATE ON origins
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER contacts_touch_updated_at BEFORE UPDATE ON contacts
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER jobs_touch_updated_at BEFORE UPDATE ON jobs
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER crawl_pages_touch_updated_at BEFORE UPDATE ON crawl_pages
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER interviewee_candidates_touch_updated_at BEFORE UPDATE ON interviewee_candidates
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
CREATE TRIGGER contact_candidates_touch_updated_at BEFORE UPDATE ON contact_candidates
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
INSERT INTO schema_migrations(version)
VALUES ('0001_initial')
ON CONFLICT (version) DO NOTHING;
COMMIT;