Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 7 additions & 21 deletions cli/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -1388,18 +1381,13 @@ export function createEmailController(
state: State,
opts: Pick<DashboardOptions, "baseUrl" | "token">,
invalidate: () => void,
fetcher: typeof fetch = fetch,
api: { list: typeof listEmailTracking; set: typeof setEmailTracking } = { list: listEmailTracking, set: setEmailTracking },
): { load: () => Promise<void>; toggle: (row: EmailTrackingRow) => Promise<void> } {
const base = opts.baseUrl.replace(/\/$/, "");
const headers = { authorization: `Bearer ${opts.token}`, accept: "application/json" };
const load = async (): Promise<void> => {
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);
Expand All @@ -1409,12 +1397,10 @@ export function createEmailController(
}
};
const toggle = async (row: EmailTrackingRow): Promise<void> => {
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);
Expand Down
25 changes: 24 additions & 1 deletion lib/dashboard/collect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ class FeedError extends Error {
}
}

async function fetchJson<T>(url: string, token: string, timeoutMs = TIMEOUT_MS): Promise<T> {
async function fetchJson<T>(url: string, token: string, timeoutMs = TIMEOUT_MS, method: "GET" | "POST" = "GET"): Promise<T> {
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,
});
Expand All @@ -102,6 +103,28 @@ async function fetchJson<T>(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<EmailTrackingRow[]> {
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<EmailTrackingRow> {
const path = `/api/v1/email-tracking/${encodeURIComponent(projectId)}/${on ? "enable" : "disable"}`;
return fetchJson<EmailTrackingRow>(`${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<AdsInput> {
const url = `${baseUrl}/api/ads/v1/earnings?days=${encodeURIComponent(String(days))}`;
Expand Down
28 changes: 16 additions & 12 deletions tests/email-tracking-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<string, string>).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]);

Expand Down
Loading