| Server IP : 85.155.190.233 / Your IP : 216.73.216.103 Web Server : nginx/1.24.0 System : Linux antigravity-cli 6.8.0-31-generic #31-Ubuntu SMP PREEMPT_DYNAMIC Sat Apr 20 00:40:06 UTC 2024 x86_64 User : wp-moonbloom ( 1001) PHP Version : 8.3.6 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : OFF Directory : /opt/moonbloom-dashboard/sql/ |
Upload File : |
-- MoonBloom Analytics Dashboard — Postgres schema
-- Auto-applied on first container start via /docker-entrypoint-initdb.d/
-- Idempotent: safe to re-run (CREATE TABLE IF NOT EXISTS / CREATE OR REPLACE VIEW).
CREATE TABLE IF NOT EXISTS ad_metrics_daily (
date DATE NOT NULL, source TEXT NOT NULL, campaign_id TEXT NOT NULL DEFAULT '_all', campaign_name TEXT,
impressions BIGINT DEFAULT 0, clicks BIGINT DEFAULT 0,
spend_eur NUMERIC(12,2) DEFAULT 0, conversions NUMERIC(12,2) DEFAULT 0,
PRIMARY KEY (date, source, campaign_id)); -- source: google_ads|pinterest_ads|etsy_ads
CREATE TABLE IF NOT EXISTS traffic_daily (
date DATE NOT NULL, source_medium TEXT NOT NULL, channel TEXT,
sessions BIGINT DEFAULT 0, active_users BIGINT DEFAULT 0, engaged_sessions BIGINT DEFAULT 0,
avg_session_sec NUMERIC(10,2), PRIMARY KEY (date, source_medium));
CREATE TABLE IF NOT EXISTS etsy_orders (
order_id TEXT PRIMARY KEY, order_date DATE, country TEXT, product TEXT,
revenue_eur NUMERIC(12,2), etsy_fee_eur NUMERIC(12,2), profit_eur NUMERIC(12,2));
CREATE TABLE IF NOT EXISTS google_keywords_daily (
date DATE, campaign TEXT, ad_group TEXT, keyword TEXT, match_type TEXT,
impressions BIGINT, clicks BIGINT, spend_eur NUMERIC(12,2),
PRIMARY KEY (date, campaign, ad_group, keyword, match_type));
CREATE TABLE IF NOT EXISTS google_search_terms_daily (
date DATE, search_term TEXT, campaign TEXT, ad_group TEXT,
impressions BIGINT, clicks BIGINT, spend_eur NUMERIC(12,2), conversions NUMERIC(12,2),
PRIMARY KEY (date, search_term, campaign, ad_group)); -- ad_group in PK: same term can hit >1 ad_group/day
CREATE TABLE IF NOT EXISTS pinterest_ads_pins_daily (
date DATE, ad_id TEXT, ad_name TEXT, campaign_name TEXT,
impressions BIGINT, clicks BIGINT, outbound_clicks BIGINT, spend_eur NUMERIC(12,2),
PRIMARY KEY (date, ad_id));
CREATE TABLE IF NOT EXISTS ga4_landing_daily (
date DATE, channel TEXT, landing_page TEXT,
sessions BIGINT, engaged_sessions BIGINT, avg_session_sec NUMERIC(10,2),
PRIMARY KEY (date, channel, landing_page));
CREATE TABLE IF NOT EXISTS pinterest_organic_daily (
date DATE, pin_id TEXT, pin_title TEXT,
impressions BIGINT, saves BIGINT, pin_clicks BIGINT, outbound_clicks BIGINT,
PRIMARY KEY (date, pin_id));
-- Hierarchy + status snapshot for both ad platforms (campaign -> ad_group -> ad).
-- One row per (date, source, level, entity) capturing that entity's status as
-- of the collector run on `date`. Pinterest rows (all 3 levels) and Google
-- Ads ad_group/ad rows are a single "current state" snapshot tagged with the
-- day the collector ran; Google Ads campaign-level rows are a free byproduct
-- of the existing per-day campaign_daily GAQL query, so they carry one row
-- per actually-collected date in the run's window (real historical status),
-- not just today. Both are valid "date" semantics for this table — see
-- v_structure_changes below, which compares per (source, level) independently
-- so this asymmetry does not cause incorrect comparisons.
CREATE TABLE IF NOT EXISTS ad_structure_daily (
date DATE NOT NULL, source TEXT NOT NULL, level TEXT NOT NULL,
entity_id TEXT NOT NULL, entity_name TEXT, parent_id TEXT, status TEXT,
PRIMARY KEY (date, source, level, entity_id));
-- source: google_ads|pinterest_ads. level: campaign|ad_group|ad.
-- parent_id: for level=ad_group -> campaign_id; for level=ad -> ad_group_id; for level=campaign -> NULL.
CREATE TABLE IF NOT EXISTS pinterest_audience_daily (
date DATE NOT NULL, audience_type TEXT NOT NULL, dimension TEXT NOT NULL,
key TEXT NOT NULL, name TEXT, ratio NUMERIC(6,4), audience_size BIGINT,
PRIMARY KEY (date, audience_type, dimension, key));
-- audience_type: YOUR_TOTAL_AUDIENCE | YOUR_ENGAGED_AUDIENCE
-- dimension: age | gender | device | country | metro
-- Etsy Offsite Ads has no CSV/API export at all (unlike Etsy Ads) — this is a
-- hand-maintained monthly snapshot the user types in from the Shop Manager
-- Offsite Ads page ("Dieser Monat" view). Not merged into ad_metrics_daily:
-- that table is daily-grained and drives the Overview combo chart / blended
-- Total Spend KPI, and dropping a whole month's fee onto one day would show
-- as a misleading spike. Kept as its own small, separately-displayed table.
CREATE TABLE IF NOT EXISTS etsy_offsite_ads_monthly (
month DATE NOT NULL, -- first day of the calendar month this snapshot covers
total_revenue_eur NUMERIC(12,2), -- "Gesamtumsatz"
orders INT, -- "Bestellungen" (direct block)
new_buyers INT, -- "Neue Käufer:innen" (direct block)
direct_revenue_eur NUMERIC(12,2), -- "Direkte Einnahmen"
fee_eur NUMERIC(12,2), -- "Gezahlte Anzeigengebühren" — the real out-of-pocket cost
indirect_revenue_eur NUMERIC(12,2), -- "Indirekte Umsätze"
indirect_orders INT, -- indirect "Bestellungen"
indirect_new_buyers INT, -- indirect "Neue Käufer:innen"
indirect_fee_eur NUMERIC(12,2), -- "Indirekte Gebühren"
PRIMARY KEY (month));
-- ---------------------------------------------------------------------------
-- Convenience read views for Grafana panels
-- ---------------------------------------------------------------------------
-- Daily spend/clicks/impressions rolled up per human-readable channel
-- (source -> channel: google_ads -> google, pinterest_ads -> pinterest, etsy_ads -> etsy)
CREATE OR REPLACE VIEW v_spend_by_channel_daily AS
SELECT
date,
CASE source
WHEN 'google_ads' THEN 'google'
WHEN 'pinterest_ads' THEN 'pinterest'
WHEN 'etsy_ads' THEN 'etsy'
ELSE source
END AS channel,
SUM(spend_eur) AS spend_eur,
SUM(clicks) AS clicks,
SUM(impressions) AS impressions
FROM ad_metrics_daily
GROUP BY date, channel;
-- Single-row blended KPI summary across ad spend, site traffic, and Etsy orders
CREATE OR REPLACE VIEW v_blended_kpis AS
SELECT
(SELECT COALESCE(SUM(spend_eur), 0) FROM ad_metrics_daily) AS total_spend_eur,
(SELECT COALESCE(SUM(clicks), 0) FROM ad_metrics_daily) AS total_clicks,
(SELECT COALESCE(SUM(impressions), 0) FROM ad_metrics_daily) AS total_impressions,
(SELECT COALESCE(SUM(conversions), 0) FROM ad_metrics_daily) AS total_conversions,
(SELECT COALESCE(SUM(sessions), 0) FROM traffic_daily) AS total_sessions,
(SELECT COALESCE(SUM(engaged_sessions), 0) FROM traffic_daily) AS total_engaged_sessions,
(SELECT COUNT(*) FROM etsy_orders) AS total_orders,
(SELECT COALESCE(SUM(revenue_eur), 0) FROM etsy_orders) AS total_revenue_eur,
(SELECT COALESCE(SUM(profit_eur), 0) FROM etsy_orders) AS total_profit_eur;
-- New-or-status-changed-or-disappeared entities between the latest snapshot
-- date and the immediately preceding distinct date, computed independently
-- per (source, level) so one source/level missing today's run (e.g. a
-- transient API failure) never corrupts the comparison for the others.
-- change_type: 'new' (no prior row), 'status_changed' (status differs),
-- or 'disappeared' (present in the prior snapshot, absent from the latest —
-- current_status is NULL for these rows since there is no "today" row).
CREATE OR REPLACE VIEW v_structure_changes AS
WITH latest AS (
SELECT source, level, MAX(date) AS latest_date
FROM ad_structure_daily
GROUP BY source, level
),
prev AS (
SELECT a.source, a.level, MAX(a.date) AS prev_date
FROM ad_structure_daily a
JOIN latest l ON a.source = l.source AND a.level = l.level AND a.date < l.latest_date
GROUP BY a.source, a.level
),
today_rows AS (
SELECT t.*
FROM ad_structure_daily t
JOIN latest l ON t.source = l.source AND t.level = l.level AND t.date = l.latest_date
),
prev_rows AS (
SELECT p.*
FROM ad_structure_daily p
JOIN prev pr ON p.source = pr.source AND p.level = pr.level AND p.date = pr.prev_date
)
SELECT
t.date, t.source, t.level, t.entity_id, t.entity_name, t.parent_id,
t.status AS current_status,
pr.status AS previous_status,
CASE WHEN pr.entity_id IS NULL THEN 'new' ELSE 'status_changed' END AS change_type
FROM today_rows t
LEFT JOIN prev_rows pr
ON t.source = pr.source AND t.level = pr.level AND t.entity_id = pr.entity_id
WHERE pr.entity_id IS NULL OR t.status IS DISTINCT FROM pr.status
UNION ALL
SELECT
l.latest_date AS date, pr.source, pr.level, pr.entity_id, pr.entity_name, pr.parent_id,
NULL::text AS current_status,
pr.status AS previous_status,
'disappeared' AS change_type
FROM prev_rows pr
JOIN latest l ON pr.source = l.source AND pr.level = l.level
LEFT JOIN today_rows t ON pr.source = t.source AND pr.level = t.level AND pr.entity_id = t.entity_id
WHERE t.entity_id IS NULL;