Skip to content

Commit d270d24

Browse files
ralyodioclaude
andauthored
What the fleet costs and what it returns, in the terminal (#240)
`crawlproof dashboard` (also roi, tui) is a live hqtui dashboard over three feeds that have never been in the same place: the tracker for who arrived, the ad network for what was delivered, and CoinPay for what the bank actually did. Five screens — ROI, Traffic, Ads, Money, Spend — refreshing on a timer, plus --json for the same snapshot without a terminal. Two rules run through the arithmetic, because breaking either produces a flattering number that is false: Self-deal is not revenue. We advertise on our own slots, so ad spend and ad earnings are one dollar moving between two pockets. They are reported under Internal and counted as neither cost nor revenue. Personal money is not fleet cost. The bank feed carries groceries next to servers, so cost is the business scope only, joined from each ledger row's account to that account's effective_scope. Everything is normalised to a monthly rate and prorated onto the traffic window, because burn is a rate and the traffic side can be asked for an hour while the bank side only answers in weeks. Two token-authed reads to feed it: GET /api/ads/v1/earnings — the model /dashboard/ads/earnings already builds. Without it a client wanting fleet totals needs one request per campaign, and the account is past 170 of them. GET /api/tracker/v1/sites — /stats already names every project, but only inside the 400 it returns when the caller does not say which. A client should not have to parse an error message to find the fleet. loadEarnings now filters ad_campaigns, ad_slots, ad_ledger and ad_payouts by owner_id explicitly rather than leaning on RLS. For the dashboard that is a no-op narrowing of what RLS already allows; for the new route, which passes the service client, it is the security boundary. The dashboard reports what it cannot know as loudly as what it can: a site that did not answer is missing rather than zero, a vendor list built from one page of a longer ledger says so, and a fleet whose visits run 200x its pageviews — or whose average is really one busy site — says that too, next to the number. Claude-Session: https://claude.ai/code/session_01HvWJ4336pxTFRdRbvsTQeD Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ef843df commit d270d24

12 files changed

Lines changed: 2522 additions & 8 deletions

File tree

‎app/api/ads/v1/earnings/route.ts‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// /api/ads/v1/earnings — the account's ad money and delivery, for a token caller.
2+
//
3+
// GET ?days=7|30|90|365
4+
//
5+
// The same model /dashboard/ads/earnings renders, for something holding an API
6+
// token. It exists because the alternative for a client that wants fleet totals
7+
// is one /campaigns/[id] request per campaign, and the account is past 170 of
8+
// them — see `crawlproof dashboard`, which polls this on a timer.
9+
//
10+
// Same auth as the rest of /api/ads/v1/*: `Authorization: Bearer crp_…`.
11+
12+
import { NextResponse, type NextRequest } from "next/server";
13+
14+
import { serviceClient } from "@/lib/supabase/service";
15+
import { authenticateBearer } from "@/lib/sp/apiAuth";
16+
import { loadEarnings } from "@/lib/ads/earnings-data";
17+
18+
export const runtime = "nodejs";
19+
export const dynamic = "force-dynamic";
20+
21+
const ALLOWED_DAYS = [7, 30, 90, 365];
22+
23+
/** An unknown window falls back to 30 rather than reaching the query planner. */
24+
export function parseDays(raw: string | null): number {
25+
const n = Number(raw);
26+
return ALLOWED_DAYS.includes(n) ? n : 30;
27+
}
28+
29+
export async function GET(req: NextRequest) {
30+
const auth = await authenticateBearer(req);
31+
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
32+
33+
const days = parseDays(req.nextUrl.searchParams.get("days"));
34+
// The service client has no RLS. loadEarnings filters every table by
35+
// owner_id itself, which is what makes passing it here safe.
36+
const model = await loadEarnings(serviceClient(), auth.userId, days);
37+
return NextResponse.json(model);
38+
}

‎app/api/tracker/v1/sites/route.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// /api/tracker/v1/sites — the projects this token can read stats for.
2+
//
3+
// GET → { sites: [{ id, name, url, tracker_enabled }] }
4+
//
5+
// /stats already names them, but only inside the 400 it returns when the
6+
// account has more than one and the caller did not say which. A client that
7+
// wants the whole fleet should not have to parse an error message to find it.
8+
9+
import { NextResponse, type NextRequest } from "next/server";
10+
11+
import { serviceClient } from "@/lib/supabase/service";
12+
import { authenticateBearer } from "@/lib/sp/apiAuth";
13+
import { listProjects } from "@/lib/tracker/apiStats";
14+
15+
export const runtime = "nodejs";
16+
export const dynamic = "force-dynamic";
17+
18+
export async function GET(req: NextRequest) {
19+
const auth = await authenticateBearer(req);
20+
if (!auth.ok) return NextResponse.json({ error: auth.error }, { status: auth.status });
21+
22+
const listed = await listProjects(serviceClient(), auth.userId);
23+
if (!listed.ok) return NextResponse.json({ error: listed.error }, { status: listed.status });
24+
25+
return NextResponse.json({
26+
sites: listed.projects.map((p) => ({
27+
id: p.id,
28+
name: p.name,
29+
url: p.url,
30+
tracker_enabled: p.tracker_enabled ?? null,
31+
})),
32+
});
33+
}

0 commit comments

Comments
 (0)