analytics-model.js 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import { buildDomainHealth } from './domain-model.js';
  2. /**
  3. * @param {{
  4. * analytics?: any;
  5. * domains?: any[];
  6. * events?: any[];
  7. * config?: any;
  8. * smtpCredential?: any;
  9. * }} [input]
  10. */
  11. export function buildDashboardSummary({
  12. analytics = null,
  13. domains = [],
  14. events = [],
  15. config = null,
  16. smtpCredential = null
  17. } = {}) {
  18. const summary = analytics?.summary || {};
  19. const total = Number(summary.total || 0);
  20. const failed = Number(summary.failed || 0);
  21. const verifiedDomains = summary.verifiedDomains ?? domains.filter((domain) => domain.status?.verified).length;
  22. const dnsIssues = domains.reduce((count, domain) => count + buildDomainHealth(domain).dnsIssues, 0);
  23. return {
  24. verifiedDomains,
  25. today: Number(summary.today || 0),
  26. successRate: Number(summary.successRate || 0),
  27. bounceRate: total ? Math.round((failed / total) * 1000) / 10 : 0,
  28. complaintRate: Number(summary.complaintRate || 0),
  29. lastSentAt: events[0]?.createdAt || '',
  30. dnsIssues,
  31. smtpReady: Boolean(config?.submission?.enabled && smtpCredential?.passwordSet)
  32. };
  33. }
  34. /**
  35. * @param {any} [analytics]
  36. * @returns {Array<{date: string; total: number; accepted: number; failed: number; recipients: number}>}
  37. */
  38. export function buildTrendSeries(analytics = null) {
  39. return (analytics?.byDay || []).map((item) => ({
  40. date: item.date || item.day,
  41. total: Number(item.total || 0),
  42. accepted: Number(item.queued || 0),
  43. failed: Number(item.failed || 0),
  44. recipients: Number(item.recipients || 0)
  45. }));
  46. }
  47. /**
  48. * @param {any} [analytics]
  49. * @returns {Array<{status: string; label: string; value: number}>}
  50. */
  51. export function buildStatusDistribution(analytics = null) {
  52. return (analytics?.byStatus || []).map((item) => ({
  53. status: item.status || 'unknown',
  54. label: item.status || 'unknown',
  55. value: Number(item.total || 0)
  56. }));
  57. }
  58. /**
  59. * @param {any} [analytics]
  60. * @returns {Array<{stage: string; total: number; rate: number; tone: 'success' | 'warning' | 'error' | 'info' | 'neutral'}>}
  61. */
  62. export function buildDeliveryFunnel(analytics = null) {
  63. return (analytics?.deliveryFunnel || []).map((item) => {
  64. const stage = item.stage || 'unknown';
  65. return {
  66. stage,
  67. total: Number(item.total || 0),
  68. rate: Number(item.rate || 0),
  69. tone: deliveryStageTone(stage)
  70. };
  71. });
  72. }
  73. /**
  74. * @param {any} [analytics]
  75. * @returns {Array<{domain: string; total: number; accepted: number; failed: number; recipients: number}>}
  76. */
  77. export function buildDomainRanking(analytics = null) {
  78. return [...(analytics?.byDomain || [])]
  79. .sort((a, b) => Number(b.total || 0) - Number(a.total || 0))
  80. .map((item) => ({
  81. domain: item.domain || 'unknown',
  82. total: Number(item.total || 0),
  83. accepted: Number(item.queued || 0),
  84. failed: Number(item.failed || 0),
  85. recipients: Number(item.recipients || 0)
  86. }));
  87. }
  88. /**
  89. * @param {any} [analytics]
  90. * @returns {Array<{hour: string; total: number; accepted: number; failed: number}>}
  91. */
  92. export function buildHourlyHeatmap(analytics = null) {
  93. return (analytics?.hourly || []).map((item) => ({
  94. hour: `${String(Number(item.hour || 0)).padStart(2, '0')}:00`,
  95. total: Number(item.total || 0),
  96. accepted: Number(item.queued || 0),
  97. failed: Number(item.failed || 0)
  98. }));
  99. }
  100. /**
  101. * @param {any} [analytics]
  102. */
  103. export function buildEngagementSummary(analytics = null) {
  104. const engagement = analytics?.engagement || {};
  105. return {
  106. trackedDelivered: Number(engagement.trackedDelivered || 0),
  107. totalOpens: Number(engagement.totalOpens || 0),
  108. uniqueOpens: Number(engagement.uniqueOpens || 0),
  109. proxyOpens: Number(engagement.proxyOpens || 0),
  110. totalClicks: Number(engagement.totalClicks || 0),
  111. uniqueClicks: Number(engagement.uniqueClicks || 0),
  112. scannerEvents: Number(engagement.scannerEvents || 0),
  113. openRate: Number(engagement.openRate || 0),
  114. clickRate: Number(engagement.clickRate || 0),
  115. clickToOpenRate: Number(engagement.clickToOpenRate || 0)
  116. };
  117. }
  118. /**
  119. * @param {any} [analytics]
  120. * @returns {Array<{date: string; opens: number; uniqueOpens: number; clicks: number; uniqueClicks: number; scannerEvents: number}>}
  121. */
  122. export function buildEngagementTrend(analytics = null) {
  123. return (analytics?.engagementByDay || []).map((item) => ({
  124. date: item.day || item.date,
  125. opens: Number(item.opens || 0),
  126. uniqueOpens: Number(item.uniqueOpens || 0),
  127. clicks: Number(item.clicks || 0),
  128. uniqueClicks: Number(item.uniqueClicks || 0),
  129. scannerEvents: Number(item.scannerEvents || 0)
  130. }));
  131. }
  132. /**
  133. * @param {any} [analytics]
  134. * @returns {Array<{fingerprint: string; target: string; targetOrigin: string; clicks: number; uniqueClicks: number; lastClickedAt: string}>}
  135. */
  136. export function buildTopLinks(analytics = null) {
  137. return (analytics?.topLinks || []).map((item) => ({
  138. fingerprint: item.fingerprint || '',
  139. target: item.target || item.targetOrigin || '',
  140. targetOrigin: item.targetOrigin || '',
  141. clicks: Number(item.clicks || 0),
  142. uniqueClicks: Number(item.uniqueClicks || 0),
  143. lastClickedAt: item.lastClickedAt || ''
  144. }));
  145. }
  146. /**
  147. * @param {any} [event]
  148. * @returns {Array<{
  149. * stage: string;
  150. * at: string;
  151. * tone: 'success' | 'warning' | 'error' | 'info' | 'neutral';
  152. * status?: string;
  153. * queueId?: string;
  154. * recipient?: string;
  155. * relay?: string;
  156. * response?: string;
  157. * webhookId?: number;
  158. * responseStatus?: number | null;
  159. * }>}
  160. */
  161. export function buildEventTimeline(event = null) {
  162. if (!event) return [];
  163. const timeline = [];
  164. if (event.createdAt) {
  165. timeline.push({
  166. stage: 'submitted',
  167. at: event.createdAt,
  168. tone: 'info',
  169. status: event.status
  170. });
  171. }
  172. if (event.queueId) {
  173. timeline.push({
  174. stage: 'accepted',
  175. at: event.createdAt || '',
  176. tone: 'info',
  177. status: 'queued',
  178. queueId: event.queueId
  179. });
  180. }
  181. const attempts = Array.isArray(event.deliveryAttempts) ? event.deliveryAttempts : [];
  182. for (const attempt of attempts) {
  183. const stage = deliveryAttemptStage(attempt.status);
  184. timeline.push({
  185. stage,
  186. at: attempt.at || '',
  187. tone: deliveryStageTone(stage),
  188. status: attempt.status,
  189. queueId: attempt.queueId,
  190. recipient: attempt.recipient,
  191. relay: attempt.relay,
  192. response: attempt.response
  193. });
  194. }
  195. if (!attempts.length && event.deliveredAt) {
  196. timeline.push({
  197. stage: 'delivered',
  198. at: event.deliveredAt,
  199. tone: 'success',
  200. status: 'sent',
  201. queueId: event.queueId
  202. });
  203. }
  204. const trackingEvents = Array.isArray(event.tracking?.events) ? event.tracking.events : [];
  205. for (const trackingEvent of trackingEvents) {
  206. const stage = trackingEvent.eventType === 'click' ? 'clicked' : 'opened';
  207. timeline.push({
  208. stage,
  209. at: trackingEvent.occurredAt || '',
  210. tone: trackingEvent.source === 'scanner'
  211. ? 'neutral'
  212. : stage === 'clicked' ? 'success' : 'info',
  213. status: trackingEvent.source,
  214. source: trackingEvent.source,
  215. targetOrigin: trackingEvent.targetOrigin || '',
  216. trackingLinkId: trackingEvent.trackingLinkId ?? null
  217. });
  218. }
  219. const webhookDeliveries = Array.isArray(event.webhookDeliveries) ? event.webhookDeliveries : [];
  220. for (const delivery of webhookDeliveries) {
  221. timeline.push({
  222. stage: 'webhook',
  223. at: delivery.lastAttemptAt || delivery.createdAt || '',
  224. tone: webhookDeliveryTone(delivery.status),
  225. status: delivery.status,
  226. webhookId: delivery.webhookId,
  227. responseStatus: delivery.responseStatus
  228. });
  229. }
  230. const stageOrder = {
  231. submitted: 0,
  232. accepted: 1,
  233. delivered: 2,
  234. pending: 2,
  235. failed: 2,
  236. opened: 3,
  237. clicked: 4,
  238. webhook: 5
  239. };
  240. return timeline
  241. .map((item, index) => ({ item, index, time: Date.parse(item.at) }))
  242. .sort((left, right) => {
  243. const leftTime = Number.isNaN(left.time) ? Number.POSITIVE_INFINITY : left.time;
  244. const rightTime = Number.isNaN(right.time) ? Number.POSITIVE_INFINITY : right.time;
  245. if (leftTime !== rightTime) return leftTime - rightTime;
  246. const stageDifference = (stageOrder[left.item.stage] ?? 99) - (stageOrder[right.item.stage] ?? 99);
  247. return stageDifference || left.index - right.index;
  248. })
  249. .map(({ item }) => item);
  250. }
  251. function deliveryAttemptStage(status) {
  252. if (status === 'sent') return 'delivered';
  253. if (status === 'deferred') return 'pending';
  254. if (status === 'bounced' || status === 'failed') return 'failed';
  255. return 'pending';
  256. }
  257. function deliveryStageTone(stage) {
  258. if (stage === 'delivered') return 'success';
  259. if (stage === 'pending') return 'warning';
  260. if (stage === 'failed') return 'error';
  261. if (stage === 'submitted' || stage === 'accepted') return 'info';
  262. return 'neutral';
  263. }
  264. function webhookDeliveryTone(status) {
  265. if (status === 'success') return 'success';
  266. if (status === 'dead') return 'error';
  267. if (status === 'pending' || status === 'processing') return 'warning';
  268. return 'neutral';
  269. }