diff --git a/cli/dashboard.ts b/cli/dashboard.ts index eace7f9..39ab2be 100644 --- a/cli/dashboard.ts +++ b/cli/dashboard.ts @@ -10,6 +10,7 @@ import type { Container, RenderArgs, Theme } from "@profullstack/hqtui"; import { markdownText } from "@profullstack/hqtui"; +import { listEmailTracking, setEmailTracking, type EmailTrackingRow } from "../lib/dashboard/collect"; import { collectDashboard, type CoinPayAuth, type DashboardSnapshot, type SiteStats, type FeedName, type FeedProgress } from "../lib/dashboard/collect"; import { AD_TARGET_CTR, AD_TARGET_IMPRESSIONS, adTargets } from "../lib/dashboard/roi"; import { buildSiteDetail, type SiteDetail } from "../lib/dashboard/site"; @@ -20,15 +21,7 @@ export const TABS = ["ROI", "Traffic", "Ads", "Money", "Spend", "Email"] as cons /** The Email tab's index: its data is not in the snapshot, so it draws without one. */ export const EMAIL_TAB = 5; -/** One project's email tracking, as GET /api/v1/email-tracking answers it. */ -export type EmailTrackingRow = { - project_id: string; - site: string; - role: string; - tracking_id: string; - enabled: boolean; - events_24h?: { open: number; click: number; unsubscribe: number }; -}; +export type { EmailTrackingRow }; export type EmailState = { rows: EmailTrackingRow[]; loading: boolean; error: string | null; note: string | null }; export const RANGES = ["1h", "4h", "1d", "1w", "1m"] as const; @@ -1388,18 +1381,13 @@ export function createEmailController( state: State, opts: Pick, invalidate: () => void, - fetcher: typeof fetch = fetch, + api: { list: typeof listEmailTracking; set: typeof setEmailTracking } = { list: listEmailTracking, set: setEmailTracking }, ): { load: () => Promise; toggle: (row: EmailTrackingRow) => Promise } { - const base = opts.baseUrl.replace(/\/$/, ""); - const headers = { authorization: `Bearer ${opts.token}`, accept: "application/json" }; const load = async (): Promise => { state.email.loading = true; invalidate(); try { - const res = await fetcher(`${base}/api/v1/email-tracking`, { headers }); - const json = (await res.json().catch(() => ({}))) as { projects?: EmailTrackingRow[]; error?: string }; - if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); - state.email.rows = json.projects ?? []; + state.email.rows = await api.list(opts.baseUrl, opts.token); state.email.error = null; } catch (e) { state.email.error = e instanceof Error ? e.message : String(e); @@ -1409,12 +1397,10 @@ export function createEmailController( } }; const toggle = async (row: EmailTrackingRow): Promise => { - const action = row.enabled ? "disable" : "enable"; + const on = !row.enabled; try { - const res = await fetcher(`${base}/api/v1/email-tracking/${encodeURIComponent(row.project_id)}/${action}`, { method: "POST", headers }); - const json = (await res.json().catch(() => ({}))) as { error?: string }; - if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); - state.email.note = `${row.site}: email tracking ${action === "enable" ? "on" : "off"}.`; + await api.set(opts.baseUrl, opts.token, row.project_id, on); + state.email.note = `${row.site}: email tracking ${on ? "on" : "off"}.`; state.email.error = null; } catch (e) { state.email.error = e instanceof Error ? e.message : String(e); diff --git a/lib/dashboard/collect.ts b/lib/dashboard/collect.ts index 961f552..486a197 100644 --- a/lib/dashboard/collect.ts +++ b/lib/dashboard/collect.ts @@ -74,11 +74,12 @@ class FeedError extends Error { } } -async function fetchJson(url: string, token: string, timeoutMs = TIMEOUT_MS): Promise { +async function fetchJson(url: string, token: string, timeoutMs = TIMEOUT_MS, method: "GET" | "POST" = "GET"): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { + method, headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, signal: controller.signal, }); @@ -102,6 +103,28 @@ async function fetchJson(url: string, token: string, timeoutMs = TIMEOUT_MS): } } +/** One project's email tracking, as GET /api/v1/email-tracking answers it. */ +export type EmailTrackingRow = { + project_id: string; + site: string; + role: string; + tracking_id: string; + enabled: boolean; + events_24h?: { open: number; click: number; unsubscribe: number }; +}; + +/** Every project's email tracking, for the TUI's Email tab. */ +export async function listEmailTracking(baseUrl: string, token: string): Promise { + const body = await fetchJson<{ projects?: EmailTrackingRow[] }>(`${baseUrl.replace(/\/$/, "")}/api/v1/email-tracking`, token); + return body.projects ?? []; +} + +/** Turn one project's email tracking on or off. */ +export async function setEmailTracking(baseUrl: string, token: string, projectId: string, on: boolean): Promise { + const path = `/api/v1/email-tracking/${encodeURIComponent(projectId)}/${on ? "enable" : "disable"}`; + return fetchJson(`${baseUrl.replace(/\/$/, "")}${path}`, token, TIMEOUT_MS, "POST"); +} + /** Ads aggregate hundreds of campaigns. Retry one transient or partial read. */ export async function collectAds(baseUrl: string, token: string, days: number, progress?: (value: FeedProgress) => void): Promise { const url = `${baseUrl}/api/ads/v1/earnings?days=${encodeURIComponent(String(days))}`; diff --git a/tests/email-tracking-surfaces.test.ts b/tests/email-tracking-surfaces.test.ts index d72da74..81e5ab4 100644 --- a/tests/email-tracking-surfaces.test.ts +++ b/tests/email-tracking-surfaces.test.ts @@ -34,8 +34,10 @@ describe("crawlproof email-tracking", () => { it("lists every project with its state and a day of events", async () => { const c = capture(); expect(await runEmailTracking([], {}, call, c.out)).toBe(0); - expect(c.lines[0]).toMatch(/^on\s+moshcode\.sh\s+e960e0a69972a7f34ea197bb\s+12 opens, 3 clicks, 1 unsubs \(24h\)$/); - expect(c.lines[1]).toMatch(/^off\s+profullstack\.com/); + expect(c.lines).toEqual([ + `on ${"moshcode.sh".padEnd(28)} e960e0a69972a7f34ea197bb 12 opens, 3 clicks, 1 unsubs (24h)`, + `off ${"profullstack.com".padEnd(28)} cf9378b423ec82ad8f896fb7 0 opens, 0 clicks, 0 unsubs (24h)`, + ]); }); it("shows one, with the secret only when asked, and alone when piped", async () => { @@ -76,16 +78,18 @@ describe("the TUI's Email tab", () => { it("loads from the API, and e flips the highlighted project then reloads", async () => { const state = initialState({ tab: EMAIL_TAB }); let enabled = false; - const fetcher = vi.fn(async (url: string, init?: RequestInit) => { - expect(new Headers(init?.headers as Record).get("authorization")).toBe("Bearer crp_test"); - if (url.endsWith("/api/v1/email-tracking")) return Response.json({ projects: [{ ...ROWS[1], enabled }] }); - if (url.endsWith("/api/v1/email-tracking/p-pfs/enable") && init?.method === "POST") { - enabled = true; - return Response.json({ ...ROWS[1], enabled: true }); - } - return Response.json({ error: "no" }, { status: 404 }); - }); - const email = createEmailController(state, { baseUrl: "https://crawlproof.test/", token: "crp_test" }, () => {}, fetcher as unknown as typeof fetch); + const api = { + list: vi.fn(async (base: string, token: string) => { + expect([base, token]).toEqual(["https://crawlproof.test/", "crp_test"]); + return [{ ...ROWS[1], enabled }]; + }), + set: vi.fn(async (_base: string, _token: string, id: string, on: boolean) => { + expect([id, on]).toEqual(["p-pfs", true]); + enabled = on; + return { ...ROWS[1], enabled }; + }), + }; + const email = createEmailController(state, { baseUrl: "https://crawlproof.test/", token: "crp_test" }, () => {}, api); await email.load(); expect(state.email.rows.map((r) => r.enabled)).toEqual([false]);