Phase 6a of the dashboard overhaul (plan:
~/.claude/plans/thsi-is-a-app-zippy-reef.md). Closes the
white-labelling story — every brief gets its own accent, heading font,
background preset, and agency label, inherited by every report
generated from it. Logo upload lands in Phase 6b.
Brief schema (v2/server/schemas/brief.ts):
- New BRIEF_THEME Zod object: accent_hex (#rrggbb), accent_2_hex
(optional, auto-derived), heading_font (fraunces|playfair|inter|
space-grotesk), background (cream|paper|ink), agency_name (≤40 chars),
logo_path (Phase 6b placeholder).
- Brief row gets a new theme JSONB column. Idempotent boot-time
ALTER TABLE IF NOT EXISTS picks up the column on existing prod DBs;
init.sql also updated for fresh installs.
Server (v2/server/):
- DAO: setBriefTheme(id, theme | null), BriefRow.theme typed.
- New endpoints (editor role required):
PUT /api/briefs/:id/theme — write theme JSON
DELETE /api/briefs/:id/theme — reset to defaults (NULL)
- publicBrief() exposes theme so the operator-app prefills the editor.
Pipeline (v2/pipeline/):
- New lib/colors.ts: deriveAccent2(hex) — HSL math to compute a darker
companion accent when the picker only specified one. Mirrors the
Original-project relationship between sienna #c2602a and oxblood
#8a3a1a.
- Stage 10 takes a third optional param (theme: BriefTheme | null) and
injects dataset.theme into dataset_v2.json with accent_2 always
populated. Cli.ts call sites pass briefRow.theme.
Dashboard SPA (v2/templates/dashboard_template/):
- Types extended with DatasetTheme.
- App.tsx applies theme at boot via document.documentElement.style
.setProperty before first render — avoids a colour-flash on any
non-default theme. FONT_STACKS map heads_font enum to the actual
CSS font stack (fonts already preloaded in index.html).
- Background preset 'paper' lightens; 'ink' flips the surface/text
axis for dark-deck reports (still picks up the brand accent).
- Topbar renders agency_name + logo placeholder. Falls back to
"SOCIAL LISTENING" eyebrow when no theme is set.
Operator app (v2/operator-app/):
- New ThemeEditor component on the brief edit page:
- 8 accent preset swatches (Sienna/Oxblood/Forest/Slate/Olive/
Wine/Plum/Ink) + custom hex input.
- 4 heading-font tiles each rendering "The Branded Glass Moment"
in the candidate stack — WYSIWYG without a separate preview.
- 3 background presets (Cream/Paper/Ink) shown as colour swatches.
- Agency name text input (≤40 chars).
- Save / Reset to defaults.
- New hooks: useUpdateBriefTheme, useResetBriefTheme.
What's intentionally NOT in this phase:
- Logo upload (Phase 6b — needs multipart parser, sharp downscaling,
SVG sanitisation).
- Free-form CSS textarea (out of scope by design — maintenance trap).
- Custom body / line / format / maturity colours (categorical signals;
changing them breaks comparability across reports).
Both Vite builds pass. tsc --noEmit clean. mom_compare unit test
fixture untouched (no schema break).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
217 lines
8.8 KiB
SQL
217 lines
8.8 KiB
SQL
-- Social Reporting V2 — fresh schema
|
|
-- Coexists with V1 in a separate database (`social_reporting_v2`).
|
|
-- Forward-only migrations under v2/db/migrations/.
|
|
|
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
|
CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive email
|
|
|
|
-- ─── Identity ───────────────────────────────────────────────────────────
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
azure_oid TEXT UNIQUE NOT NULL,
|
|
email CITEXT UNIQUE NOT NULL,
|
|
display_name TEXT NOT NULL,
|
|
is_super_admin BOOLEAN NOT NULL DEFAULT FALSE,
|
|
password_hash TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_login_at TIMESTAMPTZ
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_users_azure_oid ON users(azure_oid);
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
|
|
CREATE TABLE IF NOT EXISTS teams (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
slug TEXT UNIQUE NOT NULL,
|
|
name TEXT NOT NULL,
|
|
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
DO $$ BEGIN
|
|
CREATE TYPE team_role AS ENUM ('owner','admin','editor','viewer');
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
CREATE TABLE IF NOT EXISTS team_memberships (
|
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
role team_role NOT NULL,
|
|
added_by UUID REFERENCES users(id),
|
|
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (team_id, user_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_memberships_user ON team_memberships(user_id);
|
|
|
|
-- ─── Briefs / Reports ───────────────────────────────────────────────────
|
|
|
|
CREATE TABLE IF NOT EXISTS briefs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE RESTRICT,
|
|
owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
|
slug TEXT NOT NULL,
|
|
client_name TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
business_question TEXT NOT NULL,
|
|
date_window_days INTEGER NOT NULL DEFAULT 30,
|
|
budget_usd NUMERIC(10,2) NOT NULL,
|
|
platforms TEXT[] NOT NULL DEFAULT ARRAY['tiktok'],
|
|
positioning JSONB,
|
|
kpis JSONB,
|
|
context_vision TEXT,
|
|
-- Hashtag engagement floor (the V2 quality knob).
|
|
-- Defaults calibrated for niche-category scrapes; raise for broader categories.
|
|
min_likes INTEGER NOT NULL DEFAULT 100,
|
|
min_plays INTEGER NOT NULL DEFAULT 1000,
|
|
min_stl_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
|
|
prior_report_id UUID,
|
|
brief_yaml JSONB NOT NULL,
|
|
-- Per-report dashboard theme (Phase 6 of the dashboard overhaul).
|
|
-- Nullable; NULL means use the cream + Sienna + Fraunces defaults.
|
|
theme JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (team_id, slug)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_briefs_team ON briefs(team_id);
|
|
CREATE INDEX IF NOT EXISTS idx_briefs_owner ON briefs(owner_id);
|
|
|
|
DO $$ BEGIN
|
|
CREATE TYPE report_status AS ENUM (
|
|
'pending','seeds','pass1','select','pass2','validate',
|
|
'analyse','insights','trends','qa','build','completed','failed'
|
|
);
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
CREATE TABLE IF NOT EXISTS reports (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
brief_id UUID NOT NULL REFERENCES briefs(id) ON DELETE CASCADE,
|
|
team_id UUID NOT NULL REFERENCES teams(id),
|
|
triggered_by UUID NOT NULL REFERENCES users(id),
|
|
status report_status NOT NULL DEFAULT 'pending',
|
|
current_stage INTEGER NOT NULL DEFAULT 0,
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
finished_at TIMESTAMPTZ,
|
|
apify_cost_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
|
|
claude_cost_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
|
|
total_cost_usd NUMERIC(10,4) NOT NULL DEFAULT 0,
|
|
fs_root TEXT NOT NULL,
|
|
manifest_passed_at TIMESTAMPTZ,
|
|
error_message TEXT
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_reports_team ON reports(team_id, started_at DESC);
|
|
CREATE INDEX IF NOT EXISTS idx_reports_brief ON reports(brief_id, started_at DESC);
|
|
|
|
ALTER TABLE briefs
|
|
ADD CONSTRAINT briefs_prior_report_fk
|
|
FOREIGN KEY (prior_report_id) REFERENCES reports(id) ON DELETE SET NULL
|
|
DEFERRABLE INITIALLY DEFERRED;
|
|
|
|
CREATE TABLE IF NOT EXISTS cost_events (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
stage INTEGER NOT NULL,
|
|
stage_name TEXT NOT NULL,
|
|
source TEXT NOT NULL CHECK (source IN ('claude','apify')),
|
|
label TEXT NOT NULL,
|
|
model TEXT,
|
|
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
cost_usd NUMERIC(10,6) NOT NULL DEFAULT 0,
|
|
metadata JSONB
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_cost_report ON cost_events(report_id, created_at);
|
|
|
|
-- ─── Videos / Assets / Manifest (THE LINKING FIX) ───────────────────────
|
|
|
|
-- TikTok numeric id is the canonical key. URL is presentation, not key.
|
|
CREATE TABLE IF NOT EXISTS videos (
|
|
id TEXT PRIMARY KEY,
|
|
platform TEXT NOT NULL DEFAULT 'tiktok',
|
|
handle TEXT NOT NULL,
|
|
url_canonical TEXT NOT NULL,
|
|
caption TEXT,
|
|
hashtags TEXT[],
|
|
plays BIGINT,
|
|
likes BIGINT,
|
|
saves BIGINT,
|
|
comments_count INTEGER,
|
|
shares BIGINT,
|
|
stl_pct NUMERIC(5,2),
|
|
duration_sec INTEGER,
|
|
posted_at TIMESTAMPTZ,
|
|
cover_url TEXT,
|
|
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_videos_handle ON videos(handle);
|
|
CREATE INDEX IF NOT EXISTS idx_videos_posted ON videos(posted_at DESC);
|
|
|
|
DO $$ BEGIN
|
|
CREATE TYPE asset_kind AS ENUM ('metadata','cover','transcript','comments','frames','bundle');
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
DO $$ BEGIN
|
|
CREATE TYPE asset_status AS ENUM ('pending','ok','failed','dropped');
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
CREATE TABLE IF NOT EXISTS video_assets (
|
|
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
video_id TEXT NOT NULL REFERENCES videos(id),
|
|
asset_kind asset_kind NOT NULL,
|
|
status asset_status NOT NULL DEFAULT 'pending',
|
|
fs_path TEXT,
|
|
byte_size BIGINT,
|
|
error TEXT,
|
|
source_url TEXT,
|
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
PRIMARY KEY (report_id, video_id, asset_kind)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_assets_status ON video_assets(report_id, status);
|
|
|
|
CREATE TABLE IF NOT EXISTS manifest_checks (
|
|
report_id UUID PRIMARY KEY REFERENCES reports(id) ON DELETE CASCADE,
|
|
selected_count INTEGER NOT NULL,
|
|
metadata_ok INTEGER NOT NULL DEFAULT 0,
|
|
transcript_ok INTEGER NOT NULL DEFAULT 0,
|
|
comments_ok INTEGER NOT NULL DEFAULT 0,
|
|
frames_ok INTEGER NOT NULL DEFAULT 0,
|
|
cover_ok INTEGER NOT NULL DEFAULT 0,
|
|
all_ok_count INTEGER NOT NULL DEFAULT 0,
|
|
coverage_pct NUMERIC(5,2) NOT NULL DEFAULT 0,
|
|
passed BOOLEAN NOT NULL DEFAULT FALSE,
|
|
missing JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
built_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS selected_videos (
|
|
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
video_id TEXT NOT NULL REFERENCES videos(id),
|
|
rank_score NUMERIC(10,4),
|
|
recipe_label TEXT NOT NULL,
|
|
is_backfill BOOLEAN NOT NULL DEFAULT FALSE,
|
|
PRIMARY KEY (report_id, video_id)
|
|
);
|
|
|
|
-- ─── Trends (junction table — the only place trend↔video lives) ─────────
|
|
|
|
CREATE TABLE IF NOT EXISTS trends (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
report_id UUID NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
|
slug TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL,
|
|
relevance_tier TEXT NOT NULL CHECK (relevance_tier IN ('core','peripheral')),
|
|
velocity NUMERIC(6,3),
|
|
description TEXT,
|
|
body_jsonb JSONB NOT NULL,
|
|
UNIQUE (report_id, slug)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_trends_report ON trends(report_id);
|
|
|
|
CREATE TABLE IF NOT EXISTS trend_videos (
|
|
trend_id UUID NOT NULL REFERENCES trends(id) ON DELETE CASCADE,
|
|
video_id TEXT NOT NULL REFERENCES videos(id),
|
|
rank INTEGER,
|
|
PRIMARY KEY (trend_id, video_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_trend_videos_video ON trend_videos(video_id);
|