Files
pitchfast/lib/rybbit-analytics.ts

196 lines
5.1 KiB
TypeScript

export type RybbitEvent = {
type?: string;
timestamp?: string;
pathname?: string;
path?: string;
url?: string;
event_name?: string;
name?: string;
properties?: Record<string, unknown>;
};
export type AuditRybbitSummary = {
opened: boolean;
viewCount: number;
lastView: string | null;
ctaClicks: number;
websiteLinkClicks: number;
deviceTypes: string[];
};
type FetchLike = (
input: string | URL,
init?: RequestInit,
) => Promise<Pick<Response, "ok" | "status" | "json" | "text">>;
export function buildRybbitEventsUrl(input: {
apiUrl: string;
siteId: string;
startDate?: string;
endDate?: string;
}) {
const base = input.apiUrl.endsWith("/") ? input.apiUrl : `${input.apiUrl}/`;
const url = new URL(`api/sites/${encodeURIComponent(input.siteId)}/events`, base);
if (input.startDate) {
url.searchParams.set("start_date", input.startDate);
}
if (input.endDate) {
url.searchParams.set("end_date", input.endDate);
}
return url;
}
function eventPath(event: RybbitEvent) {
const propertyPath =
typeof event.properties?.pathname === "string"
? event.properties.pathname
: typeof event.properties?.path === "string"
? event.properties.path
: undefined;
return event.pathname ?? event.path ?? propertyPath ?? event.url ?? "";
}
function eventName(event: RybbitEvent) {
const propertyName =
typeof event.properties?.event_name === "string"
? event.properties.event_name
: typeof event.properties?.name === "string"
? event.properties.name
: undefined;
return event.event_name ?? event.name ?? propertyName ?? "";
}
function eventDevice(event: RybbitEvent) {
const value =
event.properties?.deviceType ??
event.properties?.device_type ??
event.properties?.device;
return typeof value === "string" && value.trim().length > 0
? value.trim()
: null;
}
function isAuditEvent(event: RybbitEvent, auditPath: string) {
const path = eventPath(event);
return path === auditPath || path.endsWith(auditPath);
}
export function summarizeAuditRybbitEvents(
events: RybbitEvent[],
auditPath: string,
): AuditRybbitSummary {
const matchingEvents = events.filter((event) => isAuditEvent(event, auditPath));
const pageviews = matchingEvents.filter((event) => event.type === "pageview");
const ctaClicks = matchingEvents.filter((event) => {
const name = eventName(event);
return event.type === "custom_event" && name === "audit_cta_click";
});
const websiteLinkClicks = matchingEvents.filter((event) => {
const name = eventName(event);
return event.type === "outbound_link" || name === "audit_website_link_click";
});
const lastView = pageviews
.map((event) => event.timestamp)
.filter((timestamp): timestamp is string => Boolean(timestamp))
.sort()
.at(-1) ?? null;
const deviceTypes = [
...new Set(
matchingEvents
.map(eventDevice)
.filter((device): device is string => device !== null),
),
].sort();
return {
opened: pageviews.length > 0,
viewCount: pageviews.length,
lastView,
ctaClicks: ctaClicks.length,
websiteLinkClicks: websiteLinkClicks.length,
deviceTypes,
};
}
function normalizeEventsPayload(payload: unknown): RybbitEvent[] {
if (Array.isArray(payload)) {
return payload.filter((event): event is RybbitEvent => typeof event === "object" && event !== null);
}
if (
typeof payload === "object" &&
payload !== null &&
"data" in payload &&
Array.isArray((payload as { data?: unknown }).data)
) {
return (payload as { data: unknown[] }).data.filter(
(event): event is RybbitEvent => typeof event === "object" && event !== null,
);
}
return [];
}
export async function fetchRybbitAuditAnalytics(input: {
apiUrl?: string;
apiKey?: string;
siteId?: string;
auditPath: string;
startDate?: string;
endDate?: string;
fetchImpl?: FetchLike;
}) {
if (!input.apiUrl || !input.apiKey || !input.siteId) {
return {
ok: false as const,
error: "Rybbit ist nicht vollständig konfiguriert.",
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
try {
const response = await (input.fetchImpl ?? fetch)(
buildRybbitEventsUrl({
apiUrl: input.apiUrl,
siteId: input.siteId,
startDate: input.startDate,
endDate: input.endDate,
}),
{
headers: {
Authorization: `Bearer ${input.apiKey}`,
},
},
);
if (!response.ok) {
const body = await response.text();
return {
ok: false as const,
error: `Rybbit API Fehler ${response.status}: ${body.slice(0, 160)}`,
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
const payload = await response.json();
return {
ok: true as const,
data: summarizeAuditRybbitEvents(
normalizeEventsPayload(payload),
input.auditPath,
),
};
} catch (error) {
return {
ok: false as const,
error: error instanceof Error ? error.message : String(error),
data: summarizeAuditRybbitEvents([], input.auditPath),
};
}
}