| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283 |
- import { buildDomainHealth } from './domain-model.js';
- /**
- * @param {{
- * analytics?: any;
- * domains?: any[];
- * events?: any[];
- * config?: any;
- * smtpCredential?: any;
- * }} [input]
- */
- export function buildDashboardSummary({
- analytics = null,
- domains = [],
- events = [],
- config = null,
- smtpCredential = null
- } = {}) {
- const summary = analytics?.summary || {};
- const total = Number(summary.total || 0);
- const failed = Number(summary.failed || 0);
- const verifiedDomains = summary.verifiedDomains ?? domains.filter((domain) => domain.status?.verified).length;
- const dnsIssues = domains.reduce((count, domain) => count + buildDomainHealth(domain).dnsIssues, 0);
- return {
- verifiedDomains,
- today: Number(summary.today || 0),
- successRate: Number(summary.successRate || 0),
- bounceRate: total ? Math.round((failed / total) * 1000) / 10 : 0,
- complaintRate: Number(summary.complaintRate || 0),
- lastSentAt: events[0]?.createdAt || '',
- dnsIssues,
- smtpReady: Boolean(config?.submission?.enabled && smtpCredential?.passwordSet)
- };
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{date: string; total: number; accepted: number; failed: number; recipients: number}>}
- */
- export function buildTrendSeries(analytics = null) {
- return (analytics?.byDay || []).map((item) => ({
- date: item.date || item.day,
- total: Number(item.total || 0),
- accepted: Number(item.queued || 0),
- failed: Number(item.failed || 0),
- recipients: Number(item.recipients || 0)
- }));
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{status: string; label: string; value: number}>}
- */
- export function buildStatusDistribution(analytics = null) {
- return (analytics?.byStatus || []).map((item) => ({
- status: item.status || 'unknown',
- label: item.status || 'unknown',
- value: Number(item.total || 0)
- }));
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{stage: string; total: number; rate: number; tone: 'success' | 'warning' | 'error' | 'info' | 'neutral'}>}
- */
- export function buildDeliveryFunnel(analytics = null) {
- return (analytics?.deliveryFunnel || []).map((item) => {
- const stage = item.stage || 'unknown';
- return {
- stage,
- total: Number(item.total || 0),
- rate: Number(item.rate || 0),
- tone: deliveryStageTone(stage)
- };
- });
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{domain: string; total: number; accepted: number; failed: number; recipients: number}>}
- */
- export function buildDomainRanking(analytics = null) {
- return [...(analytics?.byDomain || [])]
- .sort((a, b) => Number(b.total || 0) - Number(a.total || 0))
- .map((item) => ({
- domain: item.domain || 'unknown',
- total: Number(item.total || 0),
- accepted: Number(item.queued || 0),
- failed: Number(item.failed || 0),
- recipients: Number(item.recipients || 0)
- }));
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{hour: string; total: number; accepted: number; failed: number}>}
- */
- export function buildHourlyHeatmap(analytics = null) {
- return (analytics?.hourly || []).map((item) => ({
- hour: `${String(Number(item.hour || 0)).padStart(2, '0')}:00`,
- total: Number(item.total || 0),
- accepted: Number(item.queued || 0),
- failed: Number(item.failed || 0)
- }));
- }
- /**
- * @param {any} [analytics]
- */
- export function buildEngagementSummary(analytics = null) {
- const engagement = analytics?.engagement || {};
- return {
- trackedDelivered: Number(engagement.trackedDelivered || 0),
- totalOpens: Number(engagement.totalOpens || 0),
- uniqueOpens: Number(engagement.uniqueOpens || 0),
- proxyOpens: Number(engagement.proxyOpens || 0),
- totalClicks: Number(engagement.totalClicks || 0),
- uniqueClicks: Number(engagement.uniqueClicks || 0),
- scannerEvents: Number(engagement.scannerEvents || 0),
- openRate: Number(engagement.openRate || 0),
- clickRate: Number(engagement.clickRate || 0),
- clickToOpenRate: Number(engagement.clickToOpenRate || 0)
- };
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{date: string; opens: number; uniqueOpens: number; clicks: number; uniqueClicks: number; scannerEvents: number}>}
- */
- export function buildEngagementTrend(analytics = null) {
- return (analytics?.engagementByDay || []).map((item) => ({
- date: item.day || item.date,
- opens: Number(item.opens || 0),
- uniqueOpens: Number(item.uniqueOpens || 0),
- clicks: Number(item.clicks || 0),
- uniqueClicks: Number(item.uniqueClicks || 0),
- scannerEvents: Number(item.scannerEvents || 0)
- }));
- }
- /**
- * @param {any} [analytics]
- * @returns {Array<{fingerprint: string; target: string; targetOrigin: string; clicks: number; uniqueClicks: number; lastClickedAt: string}>}
- */
- export function buildTopLinks(analytics = null) {
- return (analytics?.topLinks || []).map((item) => ({
- fingerprint: item.fingerprint || '',
- target: item.target || item.targetOrigin || '',
- targetOrigin: item.targetOrigin || '',
- clicks: Number(item.clicks || 0),
- uniqueClicks: Number(item.uniqueClicks || 0),
- lastClickedAt: item.lastClickedAt || ''
- }));
- }
- /**
- * @param {any} [event]
- * @returns {Array<{
- * stage: string;
- * at: string;
- * tone: 'success' | 'warning' | 'error' | 'info' | 'neutral';
- * status?: string;
- * queueId?: string;
- * recipient?: string;
- * relay?: string;
- * response?: string;
- * webhookId?: number;
- * responseStatus?: number | null;
- * }>}
- */
- export function buildEventTimeline(event = null) {
- if (!event) return [];
- const timeline = [];
- if (event.createdAt) {
- timeline.push({
- stage: 'submitted',
- at: event.createdAt,
- tone: 'info',
- status: event.status
- });
- }
- if (event.queueId) {
- timeline.push({
- stage: 'accepted',
- at: event.createdAt || '',
- tone: 'info',
- status: 'queued',
- queueId: event.queueId
- });
- }
- const attempts = Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : [];
- for (const attempt of attempts) {
- const stage = deliveryAttemptStage(attempt.status);
- timeline.push({
- stage,
- at: attempt.at || '',
- tone: deliveryStageTone(stage),
- status: attempt.status,
- queueId: attempt.queueId,
- recipient: attempt.recipient,
- relay: attempt.relay,
- response: attempt.response
- });
- }
- if (!attempts.length && event.deliveredAt) {
- timeline.push({
- stage: 'delivered',
- at: event.deliveredAt,
- tone: 'success',
- status: 'sent',
- queueId: event.queueId
- });
- }
- const trackingEvents = Array.isArray(event.tracking?.events) ? event.tracking.events : [];
- for (const trackingEvent of trackingEvents) {
- const stage = trackingEvent.eventType === 'click' ? 'clicked' : 'opened';
- timeline.push({
- stage,
- at: trackingEvent.occurredAt || '',
- tone: trackingEvent.source === 'scanner'
- ? 'neutral'
- : stage === 'clicked' ? 'success' : 'info',
- status: trackingEvent.source,
- source: trackingEvent.source,
- targetOrigin: trackingEvent.targetOrigin || '',
- trackingLinkId: trackingEvent.trackingLinkId ?? null
- });
- }
- const webhookDeliveries = Array.isArray(event.webhookDeliveries) ? event.webhookDeliveries : [];
- for (const delivery of webhookDeliveries) {
- timeline.push({
- stage: 'webhook',
- at: delivery.lastAttemptAt || delivery.createdAt || '',
- tone: webhookDeliveryTone(delivery.status),
- status: delivery.status,
- webhookId: delivery.webhookId,
- responseStatus: delivery.responseStatus
- });
- }
- const stageOrder = {
- submitted: 0,
- accepted: 1,
- delivered: 2,
- pending: 2,
- failed: 2,
- opened: 3,
- clicked: 4,
- webhook: 5
- };
- return timeline
- .map((item, index) => ({ item, index, time: Date.parse(item.at) }))
- .sort((left, right) => {
- const leftTime = Number.isNaN(left.time) ? Number.POSITIVE_INFINITY : left.time;
- const rightTime = Number.isNaN(right.time) ? Number.POSITIVE_INFINITY : right.time;
- if (leftTime !== rightTime) return leftTime - rightTime;
- const stageDifference = (stageOrder[left.item.stage] ?? 99) - (stageOrder[right.item.stage] ?? 99);
- return stageDifference || left.index - right.index;
- })
- .map(({ item }) => item);
- }
- function deliveryAttemptStage(status) {
- if (status === 'sent') return 'delivered';
- if (status === 'deferred') return 'pending';
- if (status === 'bounced' || status === 'failed') return 'failed';
- return 'pending';
- }
- function deliveryStageTone(stage) {
- if (stage === 'delivered') return 'success';
- if (stage === 'pending') return 'warning';
- if (stage === 'failed') return 'error';
- if (stage === 'submitted' || stage === 'accepted') return 'info';
- return 'neutral';
- }
- function webhookDeliveryTone(status) {
- if (status === 'success') return 'success';
- if (status === 'dead') return 'error';
- if (status === 'pending' || status === 'processing') return 'warning';
- return 'neutral';
- }
|