diff --git a/app/(app)/ads/earnings/page.tsx b/app/(app)/ads/earnings/page.tsx new file mode 100644 index 00000000..bdc6e5d6 --- /dev/null +++ b/app/(app)/ads/earnings/page.tsx @@ -0,0 +1,252 @@ +import Link from "next/link"; +import dynamic from "next/dynamic"; +import { createClient } from "@/lib/supabase/server"; +import { loadEarnings, dollars, type EarningsModel } from "@/lib/ads/earnings-data"; +import { EarningsPdfButton } from "@/components/ads/earnings-pdf-button"; + +// recharts is client-only; keep it out of the server bundle. +const MoneyTrend = dynamic(() => import("@/components/ads/money-trend").then((m) => m.MoneyTrend)); + +export const metadata = { title: "Earnings & spend" }; + +const RANGE = 30; + +function ctr(clicks: number, impressions: number): string { + return impressions ? `${((clicks / impressions) * 100).toFixed(1)}%` : "—"; +} + +const EMPTY: EarningsModel = { + rangeDays: RANGE, + totals: { + spentCents: 0, + earnedCents: 0, + netCents: 0, + availableCents: 0, + withdrawnCents: 0, + spendTodayCents: 0, + earnedTodayCents: 0, + advImpressions: 0, + advClicks: 0, + pubImpressions: 0, + pubClicks: 0, + }, + campaigns: [], + slots: [], + payouts: [], + daily: [], +}; + +export default async function EarningsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + const model = user ? await loadEarnings(supabase, user.id, RANGE) : EMPTY; + const t = model.totals; + + return ( +
+ + ← Ad campaigns + +
+

Earnings & spend

+ +
+

+ Your CrawlProof ad money across both sides — what you earn as a publisher and what + you spend as an advertiser. Last {RANGE} days. Download a PDF report for your + accountant or team. +

+ +
+ + + = 0} danger={t.netCents < 0} /> + +
+ +
+ +
+ +
+ + + + +
+ + {/* Publisher earnings */} +

Earnings by site

+ {model.slots.length === 0 ? ( +
+ No monetized sites yet.{" "} + + Monetize a site + + . +
+ ) : ( +
+ + + + + + + + + + + + + {model.slots.map((s) => ( + + + + + + + + + ))} + +
SiteStatusImpr.ClicksCTREarned
{s.name} + {s.status} + {s.impressions.toLocaleString()}{s.clicks.toLocaleString()}{ctr(s.clicks, s.impressions)} + {dollars(s.earnedCents)} +
+
+ )} + + {/* Advertiser spend */} +

Spend by campaign

+ {model.campaigns.length === 0 ? ( +
+ No ad campaigns yet.{" "} + + Create an ad + + . +
+ ) : ( +
+ + + + + + + + + + + + + {model.campaigns.map((c) => ( + + + + + + + + + ))} + +
CampaignStatusImpr.ClicksCTRSpent
{c.name} + {c.status} + {c.impressions.toLocaleString()}{c.clicks.toLocaleString()}{ctr(c.clicks, c.impressions)} + {dollars(c.spentCents)} +
+
+ )} + + {model.payouts.length > 0 && ( + <> +

Payout history

+
+ + + + + + + + + + + {model.payouts.map((p, i) => ( + + + + + + + ))} + +
DateAmountCurrencyStatus
{new Date(p.createdAt).toLocaleDateString()} + {dollars(p.amountCents)} + {p.currency.toUpperCase()}{p.status}
+
+ + )} +
+ ); +} + +function Stat({ + label, + value, + accent = false, + danger = false, +}: { + label: string; + value: string; + accent?: boolean; + danger?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function MiniStat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Th({ children, right = false }: { children: React.ReactNode; right?: boolean }) { + return {children}; +} + +function Td({ + children, + right = false, + mono = false, +}: { + children: React.ReactNode; + right?: boolean; + mono?: boolean; +}) { + return ( + + {children} + + ); +} diff --git a/app/(app)/ads/page.tsx b/app/(app)/ads/page.tsx index d7c29706..fb976f95 100644 --- a/app/(app)/ads/page.tsx +++ b/app/(app)/ads/page.tsx @@ -76,6 +76,9 @@ export default async function AdsPage() {

Ad campaigns

+ + Earnings & reports + Monetize a site diff --git a/app/(app)/ads/slots/page.tsx b/app/(app)/ads/slots/page.tsx index febdb2a5..62fe1523 100644 --- a/app/(app)/ads/slots/page.tsx +++ b/app/(app)/ads/slots/page.tsx @@ -95,14 +95,19 @@ export default async function SlotsPage() { ← Ad campaigns -
+

Monetize your site

- {/* Always-visible way to add a site to monetize — a site is a project, - so this starts the project-creation flow. Previously the only link - to it was hidden inside the empty state. */} - - Monetize a new site - +
+ + Earnings & reports + + {/* Always-visible way to add a site to monetize — a site is a project, + so this starts the project-creation flow. Previously the only link + to it was hidden inside the empty state. */} + + Monetize a new site + +

Each site you monetize is a CrawlProof project. Add a site to diff --git a/app/api/ads/earnings/pdf/route.ts b/app/api/ads/earnings/pdf/route.ts new file mode 100644 index 00000000..1ac7d7c4 --- /dev/null +++ b/app/api/ads/earnings/pdf/route.ts @@ -0,0 +1,50 @@ +// PDF earnings/spend report for the logged-in account. Runs the same +// RLS-scoped queries as /ads/earnings, renders a self-contained HTML report, +// and hands it to the worker's Playwright /pdf endpoint (html branch). + +import { NextResponse } from "next/server"; +import { createClient } from "@/lib/supabase/server"; +import { env } from "@/lib/env"; +import { loadEarnings } from "@/lib/ads/earnings-data"; +import { buildEarningsReportHtml } from "@/lib/ads/earnings-report"; + +export const runtime = "nodejs"; + +export async function GET(req: Request) { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "Not authenticated" }, { status: 401 }); + + if (!env.workerUrl) { + return NextResponse.json({ error: "Worker not configured" }, { status: 500 }); + } + + const daysParam = Number(new URL(req.url).searchParams.get("days")); + const days = Number.isFinite(daysParam) && daysParam >= 1 && daysParam <= 365 ? Math.floor(daysParam) : 30; + + const model = await loadEarnings(supabase, user.id, days); + const html = buildEarningsReportHtml({ + model, + account: user.email ?? user.id, + generatedAt: new Date().toISOString(), + }); + + const workerRes = await fetch(`${env.workerUrl}/pdf`, { + method: "POST", + headers: { "content-type": "application/json", "x-worker-secret": env.workerSecret }, + body: JSON.stringify({ html }), + }); + if (!workerRes.ok) { + return NextResponse.json({ error: "PDF render failed" }, { status: 502 }); + } + const buf = await workerRes.arrayBuffer(); + const stamp = new Date().toISOString().slice(0, 10); + return new NextResponse(buf, { + headers: { + "content-type": "application/pdf", + "content-disposition": `attachment; filename="crawlproof-earnings-${stamp}.pdf"`, + }, + }); +} diff --git a/components/ads/earnings-pdf-button.tsx b/components/ads/earnings-pdf-button.tsx new file mode 100644 index 00000000..1a53c80f --- /dev/null +++ b/components/ads/earnings-pdf-button.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useState } from "react"; + +export function EarningsPdfButton({ days = 30 }: { days?: number }) { + const [busy, setBusy] = useState(false); + return ( + { + setBusy(true); + setTimeout(() => setBusy(false), 4000); + }} + > + {busy ? "Preparing PDF…" : "Download PDF report"} + + ); +} diff --git a/components/ads/money-trend.tsx b/components/ads/money-trend.tsx new file mode 100644 index 00000000..abd4b93e --- /dev/null +++ b/components/ads/money-trend.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { + CartesianGrid, + Legend, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { MoneyDailyPoint } from "@/lib/ads/earnings-data"; + +// Spend vs. earnings over time, in dollars. Mirrors the styling of +// CampaignTrend but plots money on a single $ axis. Loaded via next/dynamic +// (ssr:false) so recharts stays out of the server bundle. +export function MoneyTrend({ data }: { data: MoneyDailyPoint[] }) { + const total = data.reduce((s, p) => s + p.spentCents + p.earnedCents, 0); + if (total === 0) { + return ( +

+ No spend or earnings recorded yet. +
+ ); + } + + const chartData = data.map((p) => ({ + date: p.date, + spent: p.spentCents / 100, + earned: p.earnedCents / 100, + })); + + return ( +
+
+

Spend & earnings over time

+ Last {data.length} days +
+
+ + + + + new Date(v).toLocaleDateString(undefined, { month: "short", day: "numeric" }) + } + /> + `$${v}`} + width={48} + /> + [`$${Number(v).toFixed(2)}`, name]) as any} + labelFormatter={(v) => (typeof v === "string" ? new Date(v).toLocaleDateString() : "")} + /> + + + + + +
+
+ ); +} diff --git a/lib/ads/earnings-data.ts b/lib/ads/earnings-data.ts new file mode 100644 index 00000000..228f5399 --- /dev/null +++ b/lib/ads/earnings-data.ts @@ -0,0 +1,199 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { + getCampaignDailySeries, + getSlotDailySeries, + mergeMoneySeries, +} from "./series"; + +// Unified money model for one account — a single user is both an advertiser +// (ad_campaigns.owner_id) and a publisher (ad_slots.owner_id), so the earnings +// page shows BOTH spend and earnings. Everything is RLS-scoped to the caller; +// pass a request-scoped Supabase client whose auth.uid() is the account. + +export type MoneyDailyPoint = { date: string; spentCents: number; earnedCents: number }; + +export type EarningsCampaignRow = { + id: string; + name: string; + status: string; + impressions: number; + clicks: number; + spentCents: number; +}; + +export type EarningsSlotRow = { + id: string; + name: string; + status: string; + impressions: number; + clicks: number; + earnedCents: number; +}; + +export type EarningsPayoutRow = { + amountCents: number; + currency: string; + status: string; + txHash: string | null; + createdAt: string; +}; + +export type EarningsModel = { + rangeDays: number; + totals: { + spentCents: number; + earnedCents: number; + netCents: number; + availableCents: number; + withdrawnCents: number; + spendTodayCents: number; + earnedTodayCents: number; + advImpressions: number; + advClicks: number; + pubImpressions: number; + pubClicks: number; + }; + campaigns: EarningsCampaignRow[]; + slots: EarningsSlotRow[]; + payouts: EarningsPayoutRow[]; + daily: MoneyDailyPoint[]; +}; + +type CampaignRow = { + id: string; + name: string; + status: string; + total_spent_cents: number | null; + spend_today_cents: number | null; + spend_date: string | null; +}; +type CampaignStat = { campaign_id: string; impressions: number; clicks: number; spent_cents: number }; +type Project = { id: string; name: string }; +type Slot = { id: string; project_id: string; status: string }; +type SlotStat = { slot_id: string; impressions: number; clicks: number; earned_cents: number }; +type LedgerRow = { slot_id: string | null; amount_cents: number | null }; +type PayoutRow = { + amount_cents: number | null; + currency: string; + status: string; + tx_hash: string | null; + created_at: string; +}; + +export async function loadEarnings( + supabase: SupabaseClient, + userId: string, + days = 30, +): Promise { + const [ + { data: campaignsData }, + { data: campaignStatsData }, + { data: projectsData }, + { data: slotsData }, + { data: slotStatsData }, + { data: ledgerData }, + { data: payoutsData }, + ] = await Promise.all([ + supabase.from("ad_campaigns").select("id, name, status, total_spent_cents, spend_today_cents, spend_date"), + supabase.from("ad_campaign_stats").select("campaign_id, impressions, clicks, spent_cents"), + // Monetization is owner-only (payouts go to the slot owner), like /ads/slots. + supabase.from("projects").select("id, name").eq("owner_id", userId), + supabase.from("ad_slots").select("id, project_id, status"), + supabase.from("ad_slot_stats").select("slot_id, impressions, clicks, earned_cents"), + supabase.from("ad_ledger").select("slot_id, amount_cents").eq("kind", "publisher_accrual"), + supabase + .from("ad_payouts") + .select("amount_cents, currency, status, tx_hash, created_at") + .order("created_at", { ascending: false }), + ]); + + const campaigns = (campaignsData as CampaignRow[]) ?? []; + const campaignStats = new Map(); + for (const s of (campaignStatsData as CampaignStat[]) ?? []) campaignStats.set(s.campaign_id, s); + const projectsById = new Map(); + for (const p of (projectsData as Project[]) ?? []) projectsById.set(p.id, p); + // Only slots for projects the user owns (mirrors the /ads/slots scoping). + const slots = ((slotsData as Slot[]) ?? []).filter((s) => projectsById.has(s.project_id)); + const slotStats = new Map(); + for (const s of (slotStatsData as SlotStat[]) ?? []) slotStats.set(s.slot_id, s); + const earnedBySlot = new Map(); + for (const row of (ledgerData as LedgerRow[]) ?? []) { + if (row.slot_id) earnedBySlot.set(row.slot_id, (earnedBySlot.get(row.slot_id) ?? 0) + (row.amount_cents ?? 0)); + } + const payouts = (payoutsData as PayoutRow[]) ?? []; + + const todayUtc = new Date().toISOString().slice(0, 10); + + const campaignRows: EarningsCampaignRow[] = campaigns.map((c) => { + const s = campaignStats.get(c.id); + return { + id: c.id, + name: c.name, + status: c.status, + impressions: s?.impressions ?? 0, + clicks: s?.clicks ?? 0, + spentCents: c.total_spent_cents ?? 0, + }; + }); + + const slotRows: EarningsSlotRow[] = slots.map((sl) => { + const s = slotStats.get(sl.id); + return { + id: sl.id, + name: projectsById.get(sl.project_id)?.name ?? "Site", + status: sl.status, + impressions: s?.impressions ?? 0, + clicks: s?.clicks ?? 0, + earnedCents: earnedBySlot.get(sl.id) ?? 0, + }; + }); + + const spentCents = campaignRows.reduce((a, c) => a + c.spentCents, 0); + const earnedCents = [...earnedBySlot.values()].reduce((a, v) => a + v, 0); + const withdrawnCents = payouts + .filter((p) => p.status !== "failed") + .reduce((a, p) => a + (p.amount_cents ?? 0), 0); + const spendTodayCents = campaigns + .filter((c) => c.spend_date === todayUtc) + .reduce((a, c) => a + (c.spend_today_cents ?? 0), 0); + + // Daily series (spend from campaigns, earnings from slots), merged by day. + const [campaignSeries, slotSeries] = await Promise.all([ + getCampaignDailySeries(supabase, campaignRows.map((c) => c.id), days), + getSlotDailySeries(supabase, slotRows.map((s) => s.id), days), + ]); + const daily = mergeMoneySeries(campaignSeries, slotSeries, days); + const earnedTodayCents = daily.length ? daily[daily.length - 1].earnedCents : 0; + + return { + rangeDays: days, + totals: { + spentCents, + earnedCents, + netCents: earnedCents - spentCents, + availableCents: Math.max(0, earnedCents - withdrawnCents), + withdrawnCents, + spendTodayCents, + earnedTodayCents, + advImpressions: campaignRows.reduce((a, c) => a + c.impressions, 0), + advClicks: campaignRows.reduce((a, c) => a + c.clicks, 0), + pubImpressions: slotRows.reduce((a, s) => a + s.impressions, 0), + pubClicks: slotRows.reduce((a, s) => a + s.clicks, 0), + }, + campaigns: campaignRows, + slots: slotRows, + payouts: payouts.map((p) => ({ + amountCents: p.amount_cents ?? 0, + currency: p.currency, + status: p.status, + txHash: p.tx_hash, + createdAt: p.created_at, + })), + daily, + }; +} + +export function dollars(cents: number): string { + const neg = cents < 0; + return `${neg ? "-" : ""}$${(Math.abs(cents) / 100).toFixed(2)}`; +} diff --git a/lib/ads/earnings-report.ts b/lib/ads/earnings-report.ts new file mode 100644 index 00000000..19736897 --- /dev/null +++ b/lib/ads/earnings-report.ts @@ -0,0 +1,183 @@ +import { dollars, type EarningsModel } from "./earnings-data"; + +// Build a self-contained, print-friendly HTML report of ad spend & earnings. +// Rendered to PDF by the worker's Playwright /pdf endpoint (html branch), so it +// must be a complete document with inline styles and no external assets. The +// chart is inline SVG (no recharts) so it prints reliably. + +function esc(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function ctr(clicks: number, impressions: number): string { + return impressions ? `${((clicks / impressions) * 100).toFixed(1)}%` : "—"; +} + +// Two-line inline-SVG chart (earnings vs spend) over the daily series. +function chartSvg(daily: EarningsModel["daily"]): string { + const w = 720; + const h = 200; + const pad = { top: 10, right: 10, bottom: 22, left: 44 }; + const iw = w - pad.left - pad.right; + const ih = h - pad.top - pad.bottom; + const n = daily.length; + const max = Math.max(1, ...daily.map((d) => Math.max(d.spentCents, d.earnedCents))); + const x = (i: number) => pad.left + (n <= 1 ? 0 : (i / (n - 1)) * iw); + const y = (cents: number) => pad.top + ih - (cents / max) * ih; + const line = (key: "spentCents" | "earnedCents") => + daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i).toFixed(1)},${y(d[key]).toFixed(1)}`).join(" "); + const gridY = [0, 0.5, 1].map((f) => { + const yy = pad.top + ih - f * ih; + const val = (max * f) / 100; + return ` + $${val.toFixed(0)}`; + }); + return ` + ${gridY.join("")} + + + ${esc(daily[0]?.date ?? "")} + ${esc( + daily[n - 1]?.date ?? "", + )} + `; +} + +export function buildEarningsReportHtml(input: { + model: EarningsModel; + account: string; + generatedAt: string; // ISO +}): string { + const { model, account, generatedAt } = input; + const t = model.totals; + const gen = new Date(generatedAt); + const from = model.daily[0]?.date ?? ""; + const to = model.daily[model.daily.length - 1]?.date ?? ""; + + const summaryRow = (label: string, value: string, strong = false) => + `${esc(label)}${esc(value)}`; + + const campaignRows = + model.campaigns.length === 0 + ? `No ad campaigns.` + : model.campaigns + .map( + (c) => + `${esc(c.name)}${esc(c.status)}${c.impressions.toLocaleString()}${c.clicks.toLocaleString()}${esc( + dollars(c.spentCents), + )}`, + ) + .join(""); + + const slotRows = + model.slots.length === 0 + ? `No monetized sites.` + : model.slots + .map( + (s) => + `${esc(s.name)}${esc(s.status)}${s.impressions.toLocaleString()}${s.clicks.toLocaleString()}${esc( + dollars(s.earnedCents), + )}`, + ) + .join(""); + + const payoutRows = + model.payouts.length === 0 + ? `No payouts yet.` + : model.payouts + .map( + (p) => + `${esc(new Date(p.createdAt).toLocaleDateString())}${esc( + dollars(p.amountCents), + )}${esc(p.currency.toUpperCase())}${esc(p.status)}${ + p.txHash ? ` · ${esc(p.txHash.slice(0, 12))}…` : "" + }`, + ) + .join(""); + + return `CrawlProof Ads — Earnings & Spend + +

CrawlProof Ads — Earnings & Spend

+
Account: ${esc(account)} · Period: ${esc(from)} → ${esc(to)} (${model.rangeDays} days) · Generated ${esc( + gen.toLocaleString(), + )}
+ +
+
Total earned
${esc(dollars(t.earnedCents))}
+
Total spend
${esc(dollars(t.spentCents))}
+
Net
${esc( + dollars(t.netCents), + )}
+
Available to withdraw
${esc( + dollars(t.availableCents), + )}
+
+ +

Spend & earnings over time

+
+ ${chartSvg(model.daily)} +
EarningsSpend
+
+ +

Summary

+ + + ${summaryRow("Total earned (publisher)", dollars(t.earnedCents), true)} + ${summaryRow("Total spend (advertiser)", dollars(t.spentCents), true)} + ${summaryRow("Net position", dollars(t.netCents), true)} + ${summaryRow("Withdrawn to date", dollars(t.withdrawnCents))} + ${summaryRow("Available to withdraw", dollars(t.availableCents))} + ${summaryRow("Earned today", dollars(t.earnedTodayCents))} + ${summaryRow("Spent today", dollars(t.spendTodayCents))} + ${summaryRow("Publisher impressions / clicks", `${t.pubImpressions.toLocaleString()} / ${t.pubClicks.toLocaleString()} (CTR ${ctr(t.pubClicks, t.pubImpressions)})`)} + ${summaryRow("Advertiser impressions / clicks", `${t.advImpressions.toLocaleString()} / ${t.advClicks.toLocaleString()} (CTR ${ctr(t.advClicks, t.advImpressions)})`)} + +
+ +

Earnings by site (publisher)

+ + + ${slotRows} +
SiteStatusImpressionsClicksEarned
+ +

Spend by campaign (advertiser)

+ + + ${campaignRows} +
CampaignStatusImpressionsClicksSpent
+ +

Payout history

+ + + ${payoutRows} +
DateAmountCurrencyStatus
+ +
Generated by CrawlProof · crawlproof.com · Figures are point-in-time and for informational purposes.
+ `; +} diff --git a/lib/ads/series.ts b/lib/ads/series.ts index 075228fa..1c5c34c3 100644 --- a/lib/ads/series.ts +++ b/lib/ads/series.ts @@ -8,6 +8,13 @@ export type CampaignDailyPoint = { spentCents: number; }; +export type SlotDailyPoint = { + /** UTC calendar day, YYYY-MM-DD */ + date: string; + clicks: number; + earnedCents: number; +}; + function dayKey(iso: string): string { return iso.slice(0, 10); } @@ -80,3 +87,70 @@ export async function getCampaignDailySeries( return result; } + +type SlotSeriesRow = { + slot_id: string; + day: string; // YYYY-MM-DD (UTC) + clicks: number | string; + earned_cents: number | string; +}; + +/** + * Per-slot daily clicks / publisher earnings for the last `days`. + * Server-side aggregate via the ad_slot_daily_series RPC (security_invoker → + * RLS scopes to the caller's own slots). Same 1000-row-cap rationale as + * getCampaignDailySeries. See migration 20260717150000_ad_slot_daily_series_rpc.sql. + */ +export async function getSlotDailySeries( + supabase: SupabaseClient, + slotIds: string[], + days = 30, +): Promise> { + const axis = dayAxis(days); + const result = new Map(); + const emptyFor = () => axis.map((date) => ({ date, clicks: 0, earnedCents: 0 })); + for (const id of slotIds) result.set(id, emptyFor()); + if (slotIds.length === 0) return result; + + const index = new Map>(); + for (const id of slotIds) { + const byDay = new Map(); + for (const p of result.get(id)!) byDay.set(p.date, p); + index.set(id, byDay); + } + + const { data, error } = await supabase.rpc("ad_slot_daily_series", { days }); + if (error) return result; + + for (const row of (data as SlotSeriesRow[]) ?? []) { + const point = index.get(row.slot_id)?.get(dayKey(row.day)); + if (point) { + point.clicks += Number(row.clicks) || 0; + point.earnedCents += Number(row.earned_cents) || 0; + } + } + + return result; +} + +/** Merge campaign spend series + slot earnings series into one account-wide + * daily spend/earn axis (zero-filled, oldest first). */ +export function mergeMoneySeries( + campaign: Map, + slot: Map, + days = 30, +): { date: string; spentCents: number; earnedCents: number }[] { + const byDate = new Map(); + for (const date of dayAxis(days)) byDate.set(date, { date, spentCents: 0, earnedCents: 0 }); + for (const points of campaign.values()) + for (const p of points) { + const d = byDate.get(p.date); + if (d) d.spentCents += p.spentCents; + } + for (const points of slot.values()) + for (const p of points) { + const d = byDate.get(p.date); + if (d) d.earnedCents += p.earnedCents; + } + return [...byDate.values()]; +} diff --git a/supabase/migrations/20260717150000_ad_slot_daily_series_rpc.sql b/supabase/migrations/20260717150000_ad_slot_daily_series_rpc.sql new file mode 100644 index 00000000..be8615bb --- /dev/null +++ b/supabase/migrations/20260717150000_ad_slot_daily_series_rpc.sql @@ -0,0 +1,49 @@ +-- Ad network: per-slot daily PUBLISHER EARNINGS as a server-side aggregate. +-- +-- Mirror of ad_campaign_daily_series (advertiser spend) for the publisher side, +-- powering the /ads/earnings page's earnings-over-time chart and PDF report. +-- Same rationale: bucketing raw ad_clicks in JS would hit PostgREST's 1000-row +-- cap once a slot is busy, so aggregate in Postgres (at most slots * days rows). +-- +-- security_invoker: RLS on ad_slots/ad_clicks applies to the caller, and we +-- additionally scope to slots they OWN so nothing leaks across accounts. +-- Earnings come from ad_clicks.publisher_earn_cents (floor rate), matching the +-- ad_slot_stats view and the publisher_accrual ledger. +-- +-- Days are UTC calendar days to match dayAxis() in lib/ads/series.ts. +-- Apply via psql over the pooler / MCP (prod migration history diverged). + +create or replace function public.ad_slot_daily_series(days integer default 30) +returns table ( + slot_id uuid, + day date, + clicks bigint, + earned_cents bigint +) +language sql +stable +security invoker +set search_path = public +as $$ + with bounds as ( + select greatest(coalesce(days, 30), 1) as n + ), + since as ( + select ((now() at time zone 'UTC')::date - (n - 1))::timestamptz as from_ts + from bounds + ), + owned as ( + select id from public.ad_slots where owner_id = auth.uid() + ) + select cl.slot_id, + (cl.ts at time zone 'UTC')::date as day, + count(*)::bigint as clicks, + coalesce(sum(cl.publisher_earn_cents), 0)::bigint as earned_cents + from public.ad_clicks cl + where cl.valid + and cl.slot_id in (select id from owned) + and cl.ts >= (select from_ts from since) + group by 1, 2; +$$; + +grant execute on function public.ad_slot_daily_series(integer) to authenticated, service_role; diff --git a/worker/index.ts b/worker/index.ts index b8a287cc..8b6f0f12 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -831,12 +831,17 @@ const server = http.createServer(async (req, res) => { const payload = JSON.parse(body || "{}") as { token?: string; markdown?: string; + html?: string; title?: string; target?: string; score?: number; }; let pdf: Buffer; - if (payload.markdown) { + if (payload.html) { + // Caller supplies a complete, self-contained HTML document (e.g. the + // ads earnings report). Render it as-is — no markdown step. + pdf = await renderPdfFromHtml(payload.html); + } else if (payload.markdown) { const bodyHtml = await markdownToHtml(payload.markdown); const html = htmlDocument({ title: payload.title ?? "CrawlProof audit",