|
|
|
@@ -0,0 +1,534 @@
|
|
|
|
|
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;
|