diff --git a/scripts/dokploy_apply_ad_daily_link_clicks.py b/scripts/dokploy_apply_ad_daily_link_clicks.py new file mode 100644 index 0000000..48857b6 --- /dev/null +++ b/scripts/dokploy_apply_ad_daily_link_clicks.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Apply sql/ad_daily_link_clicks.sql via Dokploy supabase-db terminal.""" +from __future__ import annotations + +import base64 +import os +import re +import sys +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path("/Users/4ventures/4ventures-client-portal") +BPS = Path("/Users/4ventures/Projects/bps-facturacion") +SQL = ROOT / "sql" / "ad_daily_link_clicks.sql" +BASE = "https://dokploy.4ventures.es" + + +def env(key: str) -> str: + v = os.environ.get(key, "").strip() + if v: + return v + p = BPS / ".env" + if not p.is_file(): + return "" + for line in p.read_text().splitlines(): + if line.startswith(f"{key}="): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return "" + + +def main() -> int: + email = env("DOKPLOY_EMAIL") or "marketing@4ventures.es" + password = env("DOKPLOY_PASSWORD") + if not password: + print("NO_PASSWORD", flush=True) + return 2 + b64 = base64.b64encode(SQL.read_text(encoding="utf-8").encode()).decode() + chunks = [b64[i : i + 180] for i in range(0, len(b64), 180)] + + with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1400, "height": 1000}) + page.goto(BASE + "/", wait_until="domcontentloaded", timeout=90000) + page.wait_for_timeout(800) + if "/dashboard" not in page.url: + page.locator('input[type="email"], input[name="email"]').first.fill(email) + page.locator('input[type="password"]').first.fill(password) + page.locator('button[type="submit"]').first.click() + page.wait_for_url(re.compile(r"/dashboard"), timeout=90000) + print("login ok", flush=True) + page.goto(f"{BASE}/dashboard/docker", wait_until="domcontentloaded", timeout=120000) + page.wait_for_timeout(800) + page.locator("input[placeholder*='ilter' i], input[placeholder*='earch' i]").first.fill( + "supabase-db" + ) + page.wait_for_timeout(1000) + page.keyboard.press("Escape") + page.wait_for_timeout(200) + page.get_by_role("button", name=re.compile(r"Open menu", re.I)).first.click() + page.wait_for_timeout(400) + page.get_by_text("Terminal", exact=True).click() + page.wait_for_timeout(1000) + if page.locator("button", has_text="Bash").count(): + page.locator("button", has_text="Bash").first.click() + page.wait_for_timeout(2800) + if page.locator("textarea.xterm-helper-textarea").count(): + page.locator("textarea.xterm-helper-textarea").first.click() + + def run(cmd: str, wait: int = 800) -> None: + page.keyboard.type(cmd, delay=1) + page.keyboard.press("Enter") + page.wait_for_timeout(wait) + + run("rm -f /tmp/adlc.b64 /tmp/adlc.sql; echo READY", 1200) + for i, ch in enumerate(chunks): + op = ">" if i == 0 else ">>" + run(f"printf '%s' '{ch}' {op} /tmp/adlc.b64", 400) + run( + "base64 -d /tmp/adlc.b64 > /tmp/adlc.sql && " + "psql -U postgres -d postgres -v ON_ERROR_STOP=1 -f /tmp/adlc.sql; echo EXIT:$?", + 12000, + ) + run( + "psql -U postgres -d postgres -c " + "\"SELECT column_name,data_type FROM information_schema.columns " + "WHERE table_schema='public' AND table_name='ad_daily' " + "AND column_name='link_clicks';\" ; echo COL_DONE", + 4000, + ) + text = page.evaluate( + "() => (document.querySelector('.xterm-rows')||{}).innerText||''" + ) + browser.close() + ok = "EXIT:0" in text and "link_clicks" in text + print("SQL_OK", ok, flush=True) + if not ok: + print(text[-2000:], flush=True) + return 0 if ok else 6 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sql/ad_daily_link_clicks.sql b/sql/ad_daily_link_clicks.sql index efdbdfa..16dd0c2 100644 --- a/sql/ad_daily_link_clicks.sql +++ b/sql/ad_daily_link_clicks.sql @@ -1,3 +1,18 @@ -- Meta: clics en el enlace (Ads Manager) aparte de clicks = clics todos. ALTER TABLE public.ad_daily ADD COLUMN IF NOT EXISTS link_clicks numeric(14, 6) NOT NULL DEFAULT 0; + +-- Backfill from legacy Meta marker stored in ad_name (`__lc:N`). +UPDATE public.ad_daily +SET link_clicks = (regexp_match(ad_name, '^__lc:([0-9.]+)$'))[1]::numeric +WHERE platform = 'meta' + AND ad_name ~ '^__lc:' + AND (link_clicks IS NULL OR link_clicks = 0); + +-- Google: link clicks ≈ all clicks at campaign level. +UPDATE public.ad_daily +SET link_clicks = clicks +WHERE platform = 'google' + AND (link_clicks IS NULL OR link_clicks = 0) + AND clicks IS NOT NULL + AND clicks > 0; diff --git a/src/app/api/ad-tracker/creatives/route.ts b/src/app/api/ad-tracker/creatives/route.ts index 6519ee9..eff7295 100644 --- a/src/app/api/ad-tracker/creatives/route.ts +++ b/src/app/api/ad-tracker/creatives/route.ts @@ -76,23 +76,39 @@ export async function GET(req: NextRequest) { const agency = isAgencyRole(userRole(user)); let daily: AdDailyRow[] = []; + const selectWithLc = + 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,link_clicks,spend,platform_leads'; + const selectNoLc = + 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads'; if (agency && ctx.viaView) { const tid = encodeURIComponent(ctx.tenantId); - const d = await restGet( - `/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads&order=date.desc&limit=5000` + let d = await restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectWithLc}&order=date.desc&limit=5000` ); + if (!d.ok && /link_clicks/i.test(String(d.error || ''))) { + d = await restGet( + `/rest/v1/ad_daily?tenant_id=eq.${tid}&date=gte.${since}&date=lte.${until}&select=${selectNoLc}&order=date.desc&limit=5000` + ); + } daily = d.data || []; } else { - const { data } = await supabase + let q = await supabase .from('ad_daily') - .select( - 'date,platform,campaign_name,ad_id,ad_name,impressions,clicks,spend,platform_leads' - ) + .select(selectWithLc) .gte('date', since) .lte('date', until) .order('date', { ascending: false }) .limit(5000); - daily = (data as AdDailyRow[]) || []; + if (q.error && /link_clicks/i.test(q.error.message || '')) { + q = await supabase + .from('ad_daily') + .select(selectNoLc) + .gte('date', since) + .lte('date', until) + .order('date', { ascending: false }) + .limit(5000); + } + daily = (q.data as AdDailyRow[]) || []; } const warnings: string[] = []; diff --git a/src/lib/ads-sync.ts b/src/lib/ads-sync.ts index 47df6f3..ab635d3 100644 --- a/src/lib/ads-sync.ts +++ b/src/lib/ads-sync.ts @@ -365,27 +365,47 @@ async function replaceDaily( ); } - // chunk inserts (link_clicks → ad_name marker until column exists) + // chunk inserts — prefer link_clicks column; fallback to Meta `__lc:N` marker for (let i = 0; i < rows.length; i += 400) { - const chunk = rows.slice(i, i + 400).map((r) => { - const link = Math.round(r.link_clicks ?? r.clicks); - const { link_clicks: _lc, ...rest } = r; - if (r.platform === 'meta') { - return { ...rest, ad_name: `__lc:${link}` }; - } - return rest; - }); - const r = await fetch(`${base}/rest/v1/ad_daily`, { + const full = rows.slice(i, i + 400).map((r) => ({ + ...r, + link_clicks: Math.round(r.link_clicks ?? r.clicks), + ad_name: r.ad_name || '', + })); + let r = await fetch(`${base}/rest/v1/ad_daily`, { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json', Prefer: 'return=minimal', }, - body: JSON.stringify(chunk), + body: JSON.stringify(full), }); if (!r.ok) { - throw new Error(`insert ad_daily: ${(await r.text()).slice(0, 220)}`); + const errText = await r.text(); + if (!/link_clicks/i.test(errText)) { + throw new Error(`insert ad_daily: ${errText.slice(0, 220)}`); + } + const legacy = full.map((row) => { + const link = Math.round(row.link_clicks ?? row.clicks); + const { link_clicks: _lc, ...rest } = row; + if (row.platform === 'meta') { + return { ...rest, ad_name: `__lc:${link}` }; + } + return rest; + }); + r = await fetch(`${base}/rest/v1/ad_daily`, { + method: 'POST', + headers: { + ...headers, + 'Content-Type': 'application/json', + Prefer: 'return=minimal', + }, + body: JSON.stringify(legacy), + }); + if (!r.ok) { + throw new Error(`insert ad_daily: ${(await r.text()).slice(0, 220)}`); + } } } } diff --git a/src/lib/metrics.ts b/src/lib/metrics.ts index 20b3096..a1024dd 100644 --- a/src/lib/metrics.ts +++ b/src/lib/metrics.ts @@ -29,13 +29,13 @@ export type AdDailyRow = { tenant_id?: string; }; -/** Meta link clicks stored as ad_name `__lc:N` until link_clicks column is used. */ +/** Meta link clicks: column first, else `__lc:N` in ad_name (legacy), else all clicks. */ export function linkClicksOf(r: AdDailyRow): number { + const m = String(r.ad_name || '').match(/^__lc:(\d+(?:\.\d+)?)$/); + if (m) return Number(m[1]) || 0; if (r.link_clicks != null && Number.isFinite(Number(r.link_clicks))) { return Number(r.link_clicks) || 0; } - const m = String(r.ad_name || '').match(/^__lc:(\d+(?:\.\d+)?)$/); - if (m) return Number(m[1]) || 0; return Number(r.clicks || 0) || 0; }