@astralibx/email-analytics 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs ADDED
@@ -0,0 +1,927 @@
1
+ import { z } from 'zod';
2
+ import { baseDbSchema, loggerSchema, AlxError, ConfigValidationError } from '@astralibx/core';
3
+ export { ConfigValidationError } from '@astralibx/core';
4
+ import { Schema, Types } from 'mongoose';
5
+ import { Router } from 'express';
6
+
7
+ // src/validation/config.schema.ts
8
+ var AlxAnalyticsError = class extends AlxError {
9
+ constructor(message, code) {
10
+ super(message, code);
11
+ this.code = code;
12
+ this.name = "AlxAnalyticsError";
13
+ }
14
+ };
15
+ var InvalidDateRangeError = class extends AlxAnalyticsError {
16
+ constructor(startDate, endDate) {
17
+ super(
18
+ `Invalid date range: ${startDate} to ${endDate}`,
19
+ "INVALID_DATE_RANGE"
20
+ );
21
+ this.startDate = startDate;
22
+ this.endDate = endDate;
23
+ this.name = "InvalidDateRangeError";
24
+ }
25
+ };
26
+ var AggregationError = class extends AlxAnalyticsError {
27
+ constructor(pipeline, originalError) {
28
+ super(
29
+ `Aggregation pipeline failed (${pipeline}): ${originalError.message}`,
30
+ "AGGREGATION_FAILED"
31
+ );
32
+ this.pipeline = pipeline;
33
+ this.originalError = originalError;
34
+ this.name = "AggregationError";
35
+ }
36
+ };
37
+
38
+ // src/validation/config.schema.ts
39
+ var configSchema = z.object({
40
+ db: baseDbSchema,
41
+ logger: loggerSchema.optional(),
42
+ options: z.object({
43
+ eventTTLDays: z.number().int().positive().optional(),
44
+ timezone: z.string().optional(),
45
+ aggregationSchedule: z.array(
46
+ z.enum(["daily", "weekly", "monthly"])
47
+ ).optional()
48
+ }).optional()
49
+ });
50
+ function validateConfig(raw) {
51
+ const result = configSchema.safeParse(raw);
52
+ if (!result.success) {
53
+ const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
54
+ throw new ConfigValidationError(
55
+ `Invalid EmailAnalyticsConfig:
56
+ ${issues}`,
57
+ result.error.issues[0]?.path.join(".") ?? ""
58
+ );
59
+ }
60
+ }
61
+
62
+ // src/constants/index.ts
63
+ var EVENT_TYPE = {
64
+ Sent: "sent",
65
+ Delivered: "delivered",
66
+ Bounced: "bounced",
67
+ Complained: "complained",
68
+ Opened: "opened",
69
+ Clicked: "clicked",
70
+ Unsubscribed: "unsubscribed",
71
+ Failed: "failed"
72
+ };
73
+ var AGGREGATION_INTERVAL = {
74
+ Daily: "daily",
75
+ Weekly: "weekly",
76
+ Monthly: "monthly"
77
+ };
78
+ var STATS_GROUP_BY = {
79
+ Account: "account",
80
+ Rule: "rule",
81
+ Template: "template"
82
+ };
83
+
84
+ // src/schemas/email-event.schema.ts
85
+ function createEmailEventSchema(options) {
86
+ const eventTypeValues = Object.values(EVENT_TYPE);
87
+ const schema = new Schema(
88
+ {
89
+ type: { type: String, required: true, enum: eventTypeValues, index: true },
90
+ accountId: { type: Schema.Types.ObjectId, required: true, index: true },
91
+ ruleId: { type: Schema.Types.ObjectId, index: true },
92
+ templateId: { type: Schema.Types.ObjectId, index: true },
93
+ recipientEmail: { type: String, required: true },
94
+ identifierId: { type: Schema.Types.ObjectId },
95
+ metadata: { type: Schema.Types.Mixed },
96
+ timestamp: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() }
97
+ },
98
+ {
99
+ timestamps: true,
100
+ collection: options?.collectionName || "email_events",
101
+ statics: {
102
+ record(event) {
103
+ return this.create({
104
+ type: event.type,
105
+ accountId: new Types.ObjectId(event.accountId),
106
+ ruleId: event.ruleId ? new Types.ObjectId(event.ruleId) : void 0,
107
+ templateId: event.templateId ? new Types.ObjectId(event.templateId) : void 0,
108
+ recipientEmail: event.recipientEmail,
109
+ identifierId: event.identifierId ? new Types.ObjectId(event.identifierId) : void 0,
110
+ metadata: event.metadata,
111
+ timestamp: event.timestamp || /* @__PURE__ */ new Date()
112
+ });
113
+ },
114
+ findByDateRange(start, end, filters) {
115
+ const query = {
116
+ timestamp: { $gte: start, $lte: end }
117
+ };
118
+ if (filters?.type) query.type = filters.type;
119
+ if (filters?.accountId) query.accountId = new Types.ObjectId(filters.accountId);
120
+ if (filters?.ruleId) query.ruleId = new Types.ObjectId(filters.ruleId);
121
+ if (filters?.templateId) query.templateId = new Types.ObjectId(filters.templateId);
122
+ return this.find(query).sort({ timestamp: -1 });
123
+ }
124
+ }
125
+ }
126
+ );
127
+ const ttlDays = options?.eventTTLDays ?? 90;
128
+ schema.index({ timestamp: 1 }, { expireAfterSeconds: ttlDays * 24 * 60 * 60 });
129
+ schema.index({ type: 1, timestamp: -1 });
130
+ schema.index({ accountId: 1, timestamp: -1 });
131
+ schema.index({ ruleId: 1, timestamp: -1 });
132
+ return schema;
133
+ }
134
+ function createAnalyticsStatsSchema(options) {
135
+ const intervalValues = Object.values(AGGREGATION_INTERVAL);
136
+ const schema = new Schema(
137
+ {
138
+ date: { type: String, required: true, index: true },
139
+ interval: { type: String, required: true, enum: intervalValues },
140
+ accountId: { type: Schema.Types.ObjectId, default: null },
141
+ ruleId: { type: Schema.Types.ObjectId, default: null },
142
+ templateId: { type: Schema.Types.ObjectId, default: null },
143
+ sent: { type: Number, default: 0 },
144
+ delivered: { type: Number, default: 0 },
145
+ bounced: { type: Number, default: 0 },
146
+ complained: { type: Number, default: 0 },
147
+ opened: { type: Number, default: 0 },
148
+ clicked: { type: Number, default: 0 },
149
+ unsubscribed: { type: Number, default: 0 },
150
+ failed: { type: Number, default: 0 }
151
+ },
152
+ {
153
+ timestamps: true,
154
+ collection: options?.collectionName || "analytics_stats",
155
+ statics: {
156
+ upsertStats(date, interval, dimensions, increments) {
157
+ const filter = { date, interval };
158
+ filter.accountId = dimensions.accountId ? new Types.ObjectId(dimensions.accountId) : null;
159
+ filter.ruleId = dimensions.ruleId ? new Types.ObjectId(dimensions.ruleId) : null;
160
+ filter.templateId = dimensions.templateId ? new Types.ObjectId(dimensions.templateId) : null;
161
+ const $inc = {};
162
+ for (const [key, value] of Object.entries(increments)) {
163
+ if (value) $inc[key] = value;
164
+ }
165
+ return this.findOneAndUpdate(
166
+ filter,
167
+ { $inc },
168
+ { upsert: true, new: true }
169
+ );
170
+ }
171
+ }
172
+ }
173
+ );
174
+ schema.index({ date: 1, interval: 1, accountId: 1, ruleId: 1, templateId: 1 }, { unique: true });
175
+ schema.index({ date: 1, interval: 1 });
176
+ return schema;
177
+ }
178
+
179
+ // src/services/event-recorder.ts
180
+ var EventRecorderService = class {
181
+ constructor(EmailEvent, logger) {
182
+ this.EmailEvent = EmailEvent;
183
+ this.logger = logger;
184
+ }
185
+ async record(event) {
186
+ await this.EmailEvent.record(event);
187
+ this.logger.info("Event recorded", { type: event.type, accountId: event.accountId });
188
+ }
189
+ async recordBatch(events) {
190
+ if (events.length === 0) return;
191
+ const docs = events.map((e) => ({
192
+ ...e,
193
+ timestamp: e.timestamp || /* @__PURE__ */ new Date()
194
+ }));
195
+ await this.EmailEvent.insertMany(docs, { ordered: false });
196
+ this.logger.info("Batch events recorded", { count: events.length });
197
+ }
198
+ async getEvents(filters, page = 1, limit = 50) {
199
+ const query = {
200
+ timestamp: { $gte: filters.dateFrom, $lte: filters.dateTo }
201
+ };
202
+ if (filters.type) query.type = filters.type;
203
+ if (filters.accountId) query.accountId = filters.accountId;
204
+ if (filters.ruleId) query.ruleId = filters.ruleId;
205
+ if (filters.recipientEmail) query.recipientEmail = filters.recipientEmail;
206
+ const skip = (page - 1) * limit;
207
+ const [events, total] = await Promise.all([
208
+ this.EmailEvent.find(query).sort({ timestamp: -1 }).skip(skip).limit(limit).lean(),
209
+ this.EmailEvent.countDocuments(query)
210
+ ]);
211
+ return { events, total };
212
+ }
213
+ async purgeOldEvents(olderThanDays) {
214
+ const cutoff = /* @__PURE__ */ new Date();
215
+ cutoff.setDate(cutoff.getDate() - olderThanDays);
216
+ const result = await this.EmailEvent.deleteMany({
217
+ timestamp: { $lt: cutoff }
218
+ });
219
+ const count = result.deletedCount || 0;
220
+ this.logger.info("Purged old events", { olderThanDays, deleted: count });
221
+ return count;
222
+ }
223
+ };
224
+
225
+ // src/services/aggregator.ts
226
+ var METRIC_CONDS = {
227
+ sent: { $cond: [{ $eq: ["$type", EVENT_TYPE.Sent] }, 1, 0] },
228
+ failed: { $cond: [{ $eq: ["$type", EVENT_TYPE.Failed] }, 1, 0] },
229
+ delivered: { $cond: [{ $eq: ["$type", EVENT_TYPE.Delivered] }, 1, 0] },
230
+ bounced: { $cond: [{ $eq: ["$type", EVENT_TYPE.Bounced] }, 1, 0] },
231
+ complained: { $cond: [{ $eq: ["$type", EVENT_TYPE.Complained] }, 1, 0] },
232
+ opened: { $cond: [{ $eq: ["$type", EVENT_TYPE.Opened] }, 1, 0] },
233
+ clicked: { $cond: [{ $eq: ["$type", EVENT_TYPE.Clicked] }, 1, 0] },
234
+ unsubscribed: { $cond: [{ $eq: ["$type", EVENT_TYPE.Unsubscribed] }, 1, 0] }
235
+ };
236
+ function buildSumGroup() {
237
+ return {
238
+ sent: { $sum: METRIC_CONDS.sent },
239
+ failed: { $sum: METRIC_CONDS.failed },
240
+ delivered: { $sum: METRIC_CONDS.delivered },
241
+ bounced: { $sum: METRIC_CONDS.bounced },
242
+ complained: { $sum: METRIC_CONDS.complained },
243
+ opened: { $sum: METRIC_CONDS.opened },
244
+ clicked: { $sum: METRIC_CONDS.clicked },
245
+ unsubscribed: { $sum: METRIC_CONDS.unsubscribed }
246
+ };
247
+ }
248
+ var AggregatorService = class {
249
+ constructor(EmailEvent, AnalyticsStats, timezone, logger) {
250
+ this.EmailEvent = EmailEvent;
251
+ this.AnalyticsStats = AnalyticsStats;
252
+ this.timezone = timezone;
253
+ this.logger = logger;
254
+ }
255
+ async aggregateDaily(date) {
256
+ const targetDate = date || /* @__PURE__ */ new Date();
257
+ const dayStart = this.getDayStart(targetDate);
258
+ const dayEnd = this.getDayEnd(targetDate);
259
+ const dateKey = this.toDateKey(dayStart);
260
+ this.logger.info("Running daily aggregation", { date: dateKey });
261
+ await this.aggregateByAccount(dayStart, dayEnd, dateKey);
262
+ await this.aggregateByRule(dayStart, dayEnd, dateKey);
263
+ await this.aggregateByTemplate(dayStart, dayEnd, dateKey);
264
+ await this.aggregateOverall(dayStart, dayEnd, dateKey);
265
+ this.logger.info("Daily aggregation complete", { date: dateKey });
266
+ }
267
+ async aggregateRange(from, to) {
268
+ if (!(from instanceof Date) || isNaN(from.getTime())) {
269
+ throw new InvalidDateRangeError(String(from), String(to));
270
+ }
271
+ if (!(to instanceof Date) || isNaN(to.getTime())) {
272
+ throw new InvalidDateRangeError(String(from), String(to));
273
+ }
274
+ if (from > to) {
275
+ throw new InvalidDateRangeError(from.toISOString(), to.toISOString());
276
+ }
277
+ const current = this.getDayStart(from);
278
+ const end = this.getDayStart(to);
279
+ this.logger.info("Running range aggregation", {
280
+ from: this.toDateKey(current),
281
+ to: this.toDateKey(end)
282
+ });
283
+ while (current <= end) {
284
+ await this.aggregateDaily(new Date(current));
285
+ current.setDate(current.getDate() + 1);
286
+ }
287
+ this.logger.info("Range aggregation complete");
288
+ }
289
+ async aggregateByAccount(dayStart, dayEnd, dateKey) {
290
+ let results;
291
+ try {
292
+ results = await this.EmailEvent.aggregate([
293
+ { $match: { timestamp: { $gte: dayStart, $lt: dayEnd } } },
294
+ { $group: { _id: "$accountId", ...buildSumGroup() } }
295
+ ]);
296
+ } catch (error) {
297
+ throw new AggregationError("byAccount", error instanceof Error ? error : new Error(String(error)));
298
+ }
299
+ const bulkOps = results.map((r) => ({
300
+ updateOne: {
301
+ filter: { date: dateKey, interval: AGGREGATION_INTERVAL.Daily, accountId: r._id, ruleId: null, templateId: null },
302
+ update: {
303
+ $set: {
304
+ date: dateKey,
305
+ interval: AGGREGATION_INTERVAL.Daily,
306
+ accountId: r._id,
307
+ ruleId: null,
308
+ templateId: null,
309
+ sent: r.sent,
310
+ failed: r.failed,
311
+ delivered: r.delivered,
312
+ bounced: r.bounced,
313
+ complained: r.complained,
314
+ opened: r.opened,
315
+ clicked: r.clicked,
316
+ unsubscribed: r.unsubscribed,
317
+ updatedAt: /* @__PURE__ */ new Date()
318
+ }
319
+ },
320
+ upsert: true
321
+ }
322
+ }));
323
+ if (bulkOps.length > 0) {
324
+ await this.AnalyticsStats.bulkWrite(bulkOps);
325
+ }
326
+ }
327
+ async aggregateByRule(dayStart, dayEnd, dateKey) {
328
+ let results;
329
+ try {
330
+ results = await this.EmailEvent.aggregate([
331
+ { $match: { timestamp: { $gte: dayStart, $lt: dayEnd }, ruleId: { $exists: true, $ne: null } } },
332
+ { $group: { _id: "$ruleId", ...buildSumGroup() } }
333
+ ]);
334
+ } catch (error) {
335
+ throw new AggregationError("byRule", error instanceof Error ? error : new Error(String(error)));
336
+ }
337
+ const bulkOps = results.map((r) => ({
338
+ updateOne: {
339
+ filter: { date: dateKey, interval: AGGREGATION_INTERVAL.Daily, ruleId: r._id, accountId: null, templateId: null },
340
+ update: {
341
+ $set: {
342
+ date: dateKey,
343
+ interval: AGGREGATION_INTERVAL.Daily,
344
+ ruleId: r._id,
345
+ accountId: null,
346
+ templateId: null,
347
+ sent: r.sent,
348
+ failed: r.failed,
349
+ delivered: r.delivered,
350
+ bounced: r.bounced,
351
+ complained: r.complained,
352
+ opened: r.opened,
353
+ clicked: r.clicked,
354
+ unsubscribed: r.unsubscribed,
355
+ updatedAt: /* @__PURE__ */ new Date()
356
+ }
357
+ },
358
+ upsert: true
359
+ }
360
+ }));
361
+ if (bulkOps.length > 0) {
362
+ await this.AnalyticsStats.bulkWrite(bulkOps);
363
+ }
364
+ }
365
+ async aggregateByTemplate(dayStart, dayEnd, dateKey) {
366
+ let results;
367
+ try {
368
+ results = await this.EmailEvent.aggregate([
369
+ { $match: { timestamp: { $gte: dayStart, $lt: dayEnd }, templateId: { $exists: true, $ne: null } } },
370
+ { $group: { _id: "$templateId", ...buildSumGroup() } }
371
+ ]);
372
+ } catch (error) {
373
+ throw new AggregationError("byTemplate", error instanceof Error ? error : new Error(String(error)));
374
+ }
375
+ const bulkOps = results.map((r) => ({
376
+ updateOne: {
377
+ filter: { date: dateKey, interval: AGGREGATION_INTERVAL.Daily, templateId: r._id, accountId: null, ruleId: null },
378
+ update: {
379
+ $set: {
380
+ date: dateKey,
381
+ interval: AGGREGATION_INTERVAL.Daily,
382
+ templateId: r._id,
383
+ accountId: null,
384
+ ruleId: null,
385
+ sent: r.sent,
386
+ failed: r.failed,
387
+ delivered: r.delivered,
388
+ bounced: r.bounced,
389
+ complained: r.complained,
390
+ opened: r.opened,
391
+ clicked: r.clicked,
392
+ unsubscribed: r.unsubscribed,
393
+ updatedAt: /* @__PURE__ */ new Date()
394
+ }
395
+ },
396
+ upsert: true
397
+ }
398
+ }));
399
+ if (bulkOps.length > 0) {
400
+ await this.AnalyticsStats.bulkWrite(bulkOps);
401
+ }
402
+ }
403
+ async aggregateOverall(dayStart, dayEnd, dateKey) {
404
+ let results;
405
+ try {
406
+ results = await this.EmailEvent.aggregate([
407
+ { $match: { timestamp: { $gte: dayStart, $lt: dayEnd } } },
408
+ { $group: { _id: null, ...buildSumGroup() } }
409
+ ]);
410
+ } catch (error) {
411
+ throw new AggregationError("overall", error instanceof Error ? error : new Error(String(error)));
412
+ }
413
+ if (results.length > 0) {
414
+ const r = results[0];
415
+ await this.AnalyticsStats.updateOne(
416
+ { date: dateKey, interval: AGGREGATION_INTERVAL.Daily, accountId: null, ruleId: null, templateId: null },
417
+ {
418
+ $set: {
419
+ date: dateKey,
420
+ interval: AGGREGATION_INTERVAL.Daily,
421
+ accountId: null,
422
+ ruleId: null,
423
+ templateId: null,
424
+ sent: r.sent,
425
+ failed: r.failed,
426
+ delivered: r.delivered,
427
+ bounced: r.bounced,
428
+ complained: r.complained,
429
+ opened: r.opened,
430
+ clicked: r.clicked,
431
+ unsubscribed: r.unsubscribed,
432
+ updatedAt: /* @__PURE__ */ new Date()
433
+ }
434
+ },
435
+ { upsert: true }
436
+ );
437
+ }
438
+ }
439
+ getDayStart(date) {
440
+ const formatter = new Intl.DateTimeFormat("en-CA", {
441
+ timeZone: this.timezone,
442
+ year: "numeric",
443
+ month: "2-digit",
444
+ day: "2-digit"
445
+ });
446
+ const parts = formatter.formatToParts(date);
447
+ const year = parseInt(parts.find((p) => p.type === "year").value, 10);
448
+ const month = parseInt(parts.find((p) => p.type === "month").value, 10) - 1;
449
+ const day = parseInt(parts.find((p) => p.type === "day").value, 10);
450
+ const localMidnight = new Date(Date.UTC(year, month, day));
451
+ const offsetMs = this.getTimezoneOffsetMs(localMidnight);
452
+ return new Date(localMidnight.getTime() - offsetMs);
453
+ }
454
+ getDayEnd(date) {
455
+ const dayStart = this.getDayStart(date);
456
+ return new Date(dayStart.getTime() + 24 * 60 * 60 * 1e3);
457
+ }
458
+ getTimezoneOffsetMs(utcDate) {
459
+ const utcStr = utcDate.toLocaleString("en-US", { timeZone: "UTC" });
460
+ const tzStr = utcDate.toLocaleString("en-US", { timeZone: this.timezone });
461
+ return new Date(tzStr).getTime() - new Date(utcStr).getTime();
462
+ }
463
+ toDateKey(date) {
464
+ const formatter = new Intl.DateTimeFormat("en-CA", {
465
+ timeZone: this.timezone,
466
+ year: "numeric",
467
+ month: "2-digit",
468
+ day: "2-digit"
469
+ });
470
+ return formatter.format(date);
471
+ }
472
+ };
473
+
474
+ // src/services/query.service.ts
475
+ var QueryService = class {
476
+ constructor(AnalyticsStats, logger) {
477
+ this.AnalyticsStats = AnalyticsStats;
478
+ this.logger = logger;
479
+ }
480
+ async getOverview(dateFrom, dateTo) {
481
+ const fromKey = this.toDateKey(dateFrom);
482
+ const toKey = this.toDateKey(dateTo);
483
+ const pipeline = [
484
+ {
485
+ $match: {
486
+ interval: "daily",
487
+ date: { $gte: fromKey, $lte: toKey },
488
+ accountId: null,
489
+ ruleId: null,
490
+ templateId: null
491
+ }
492
+ },
493
+ {
494
+ $group: {
495
+ _id: null,
496
+ sent: { $sum: "$sent" },
497
+ failed: { $sum: "$failed" },
498
+ delivered: { $sum: "$delivered" },
499
+ bounced: { $sum: "$bounced" },
500
+ complained: { $sum: "$complained" },
501
+ opened: { $sum: "$opened" },
502
+ clicked: { $sum: "$clicked" },
503
+ unsubscribed: { $sum: "$unsubscribed" }
504
+ }
505
+ }
506
+ ];
507
+ const results = await this.AnalyticsStats.aggregate(pipeline);
508
+ const empty = {
509
+ startDate: fromKey,
510
+ endDate: toKey,
511
+ sent: 0,
512
+ failed: 0,
513
+ delivered: 0,
514
+ bounced: 0,
515
+ complained: 0,
516
+ opened: 0,
517
+ clicked: 0,
518
+ unsubscribed: 0
519
+ };
520
+ if (results.length === 0) return empty;
521
+ const r = results[0];
522
+ return {
523
+ startDate: fromKey,
524
+ endDate: toKey,
525
+ sent: r.sent,
526
+ failed: r.failed,
527
+ delivered: r.delivered,
528
+ bounced: r.bounced,
529
+ complained: r.complained,
530
+ opened: r.opened,
531
+ clicked: r.clicked,
532
+ unsubscribed: r.unsubscribed
533
+ };
534
+ }
535
+ async getTimeline(dateFrom, dateTo, interval = "daily") {
536
+ const fromKey = this.toDateKey(dateFrom);
537
+ const toKey = this.toDateKey(dateTo);
538
+ const groupId = this.buildTimeGroupId(interval);
539
+ const pipeline = [
540
+ {
541
+ $match: {
542
+ interval: "daily",
543
+ date: { $gte: fromKey, $lte: toKey },
544
+ accountId: null,
545
+ ruleId: null,
546
+ templateId: null
547
+ }
548
+ },
549
+ {
550
+ $group: {
551
+ _id: groupId,
552
+ sent: { $sum: "$sent" },
553
+ failed: { $sum: "$failed" },
554
+ delivered: { $sum: "$delivered" },
555
+ bounced: { $sum: "$bounced" },
556
+ complained: { $sum: "$complained" },
557
+ opened: { $sum: "$opened" },
558
+ clicked: { $sum: "$clicked" },
559
+ unsubscribed: { $sum: "$unsubscribed" }
560
+ }
561
+ },
562
+ { $sort: { _id: 1 } }
563
+ ];
564
+ const results = await this.AnalyticsStats.aggregate(pipeline);
565
+ return results.map((r) => ({
566
+ date: r._id,
567
+ interval,
568
+ sent: r.sent,
569
+ failed: r.failed,
570
+ delivered: r.delivered,
571
+ bounced: r.bounced,
572
+ complained: r.complained,
573
+ opened: r.opened,
574
+ clicked: r.clicked,
575
+ unsubscribed: r.unsubscribed
576
+ }));
577
+ }
578
+ async getAccountStats(dateFrom, dateTo) {
579
+ const fromKey = this.toDateKey(dateFrom);
580
+ const toKey = this.toDateKey(dateTo);
581
+ const results = await this.AnalyticsStats.aggregate([
582
+ {
583
+ $match: {
584
+ interval: "daily",
585
+ date: { $gte: fromKey, $lte: toKey },
586
+ accountId: { $ne: null },
587
+ ruleId: null,
588
+ templateId: null
589
+ }
590
+ },
591
+ {
592
+ $group: {
593
+ _id: "$accountId",
594
+ sent: { $sum: "$sent" },
595
+ failed: { $sum: "$failed" },
596
+ delivered: { $sum: "$delivered" },
597
+ bounced: { $sum: "$bounced" },
598
+ complained: { $sum: "$complained" },
599
+ opened: { $sum: "$opened" },
600
+ clicked: { $sum: "$clicked" },
601
+ unsubscribed: { $sum: "$unsubscribed" }
602
+ }
603
+ },
604
+ { $sort: { sent: -1 } }
605
+ ]);
606
+ return results.map((r) => ({
607
+ accountId: r._id.toString(),
608
+ sent: r.sent,
609
+ failed: r.failed,
610
+ delivered: r.delivered,
611
+ bounced: r.bounced,
612
+ complained: r.complained,
613
+ opened: r.opened,
614
+ clicked: r.clicked,
615
+ unsubscribed: r.unsubscribed
616
+ }));
617
+ }
618
+ async getRuleStats(dateFrom, dateTo) {
619
+ const fromKey = this.toDateKey(dateFrom);
620
+ const toKey = this.toDateKey(dateTo);
621
+ const results = await this.AnalyticsStats.aggregate([
622
+ {
623
+ $match: {
624
+ interval: "daily",
625
+ date: { $gte: fromKey, $lte: toKey },
626
+ ruleId: { $ne: null },
627
+ accountId: null,
628
+ templateId: null
629
+ }
630
+ },
631
+ {
632
+ $group: {
633
+ _id: "$ruleId",
634
+ sent: { $sum: "$sent" },
635
+ failed: { $sum: "$failed" },
636
+ delivered: { $sum: "$delivered" },
637
+ bounced: { $sum: "$bounced" },
638
+ complained: { $sum: "$complained" },
639
+ opened: { $sum: "$opened" },
640
+ clicked: { $sum: "$clicked" },
641
+ unsubscribed: { $sum: "$unsubscribed" }
642
+ }
643
+ },
644
+ { $sort: { sent: -1 } }
645
+ ]);
646
+ return results.map((r) => ({
647
+ ruleId: r._id.toString(),
648
+ sent: r.sent,
649
+ failed: r.failed,
650
+ delivered: r.delivered,
651
+ bounced: r.bounced,
652
+ complained: r.complained,
653
+ opened: r.opened,
654
+ clicked: r.clicked,
655
+ unsubscribed: r.unsubscribed
656
+ }));
657
+ }
658
+ async getTemplateStats(dateFrom, dateTo) {
659
+ const fromKey = this.toDateKey(dateFrom);
660
+ const toKey = this.toDateKey(dateTo);
661
+ const results = await this.AnalyticsStats.aggregate([
662
+ {
663
+ $match: {
664
+ interval: "daily",
665
+ date: { $gte: fromKey, $lte: toKey },
666
+ templateId: { $ne: null },
667
+ accountId: null,
668
+ ruleId: null
669
+ }
670
+ },
671
+ {
672
+ $group: {
673
+ _id: "$templateId",
674
+ sent: { $sum: "$sent" },
675
+ failed: { $sum: "$failed" },
676
+ delivered: { $sum: "$delivered" },
677
+ bounced: { $sum: "$bounced" },
678
+ complained: { $sum: "$complained" },
679
+ opened: { $sum: "$opened" },
680
+ clicked: { $sum: "$clicked" },
681
+ unsubscribed: { $sum: "$unsubscribed" }
682
+ }
683
+ },
684
+ { $sort: { sent: -1 } }
685
+ ]);
686
+ return results.map((r) => ({
687
+ templateId: r._id.toString(),
688
+ sent: r.sent,
689
+ failed: r.failed,
690
+ delivered: r.delivered,
691
+ bounced: r.bounced,
692
+ complained: r.complained,
693
+ opened: r.opened,
694
+ clicked: r.clicked,
695
+ unsubscribed: r.unsubscribed
696
+ }));
697
+ }
698
+ buildTimeGroupId(interval) {
699
+ switch (interval) {
700
+ case "weekly": {
701
+ const dateExpr = { $dateFromString: { dateString: "$date" } };
702
+ return {
703
+ $concat: [
704
+ { $substr: ["$date", 0, 4] },
705
+ "-W",
706
+ {
707
+ $cond: [
708
+ { $lt: [{ $isoWeek: dateExpr }, 10] },
709
+ { $concat: ["0", { $toString: { $isoWeek: dateExpr } }] },
710
+ { $toString: { $isoWeek: dateExpr } }
711
+ ]
712
+ }
713
+ ]
714
+ };
715
+ }
716
+ case "monthly":
717
+ return { $substr: ["$date", 0, 7] };
718
+ case "daily":
719
+ default:
720
+ return "$date";
721
+ }
722
+ }
723
+ toDateKey(date) {
724
+ return date.toISOString().slice(0, 10);
725
+ }
726
+ };
727
+
728
+ // src/controllers/analytics.controller.ts
729
+ var MAX_AGGREGATION_RANGE_DAYS = 365;
730
+ function parseDateRange(req) {
731
+ const now = /* @__PURE__ */ new Date();
732
+ const thirtyDaysAgo = /* @__PURE__ */ new Date();
733
+ thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
734
+ const dateFrom = req.query.from ? new Date(req.query.from) : thirtyDaysAgo;
735
+ const dateTo = req.query.to ? new Date(req.query.to) : now;
736
+ if (isNaN(dateFrom.getTime())) {
737
+ return { error: `Invalid 'from' date: ${req.query.from}` };
738
+ }
739
+ if (isNaN(dateTo.getTime())) {
740
+ return { error: `Invalid 'to' date: ${req.query.to}` };
741
+ }
742
+ return { dateFrom, dateTo };
743
+ }
744
+ function createAnalyticsController(eventRecorder, aggregator, queryService) {
745
+ return {
746
+ async getOverview(req, res) {
747
+ try {
748
+ const parsed = parseDateRange(req);
749
+ if ("error" in parsed) {
750
+ return res.status(400).json({ success: false, error: parsed.error });
751
+ }
752
+ const data = await queryService.getOverview(parsed.dateFrom, parsed.dateTo);
753
+ res.json({ success: true, data });
754
+ } catch (error) {
755
+ const message = error instanceof Error ? error.message : "Unknown error";
756
+ res.status(500).json({ success: false, error: message });
757
+ }
758
+ },
759
+ async getTimeline(req, res) {
760
+ try {
761
+ const parsed = parseDateRange(req);
762
+ if ("error" in parsed) {
763
+ return res.status(400).json({ success: false, error: parsed.error });
764
+ }
765
+ const interval = req.query.interval || "daily";
766
+ if (!["daily", "weekly", "monthly"].includes(interval)) {
767
+ return res.status(400).json({
768
+ success: false,
769
+ error: "interval must be daily, weekly, or monthly"
770
+ });
771
+ }
772
+ const data = await queryService.getTimeline(
773
+ parsed.dateFrom,
774
+ parsed.dateTo,
775
+ interval
776
+ );
777
+ res.json({ success: true, data });
778
+ } catch (error) {
779
+ const message = error instanceof Error ? error.message : "Unknown error";
780
+ res.status(500).json({ success: false, error: message });
781
+ }
782
+ },
783
+ async getAccountStats(req, res) {
784
+ try {
785
+ const parsed = parseDateRange(req);
786
+ if ("error" in parsed) {
787
+ return res.status(400).json({ success: false, error: parsed.error });
788
+ }
789
+ const data = await queryService.getAccountStats(parsed.dateFrom, parsed.dateTo);
790
+ res.json({ success: true, data });
791
+ } catch (error) {
792
+ const message = error instanceof Error ? error.message : "Unknown error";
793
+ res.status(500).json({ success: false, error: message });
794
+ }
795
+ },
796
+ async getRuleStats(req, res) {
797
+ try {
798
+ const parsed = parseDateRange(req);
799
+ if ("error" in parsed) {
800
+ return res.status(400).json({ success: false, error: parsed.error });
801
+ }
802
+ const data = await queryService.getRuleStats(parsed.dateFrom, parsed.dateTo);
803
+ res.json({ success: true, data });
804
+ } catch (error) {
805
+ const message = error instanceof Error ? error.message : "Unknown error";
806
+ res.status(500).json({ success: false, error: message });
807
+ }
808
+ },
809
+ async getTemplateStats(req, res) {
810
+ try {
811
+ const parsed = parseDateRange(req);
812
+ if ("error" in parsed) {
813
+ return res.status(400).json({ success: false, error: parsed.error });
814
+ }
815
+ const data = await queryService.getTemplateStats(parsed.dateFrom, parsed.dateTo);
816
+ res.json({ success: true, data });
817
+ } catch (error) {
818
+ const message = error instanceof Error ? error.message : "Unknown error";
819
+ res.status(500).json({ success: false, error: message });
820
+ }
821
+ },
822
+ async triggerAggregation(req, res) {
823
+ try {
824
+ const { from, to } = req.body;
825
+ if (from && to) {
826
+ const fromDate = new Date(from);
827
+ const toDate = new Date(to);
828
+ if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
829
+ return res.status(400).json({
830
+ success: false,
831
+ error: "Invalid date format for from/to"
832
+ });
833
+ }
834
+ if (fromDate > toDate) {
835
+ return res.status(400).json({
836
+ success: false,
837
+ error: `Invalid date range: 'from' must be before 'to'`
838
+ });
839
+ }
840
+ const diffDays = (toDate.getTime() - fromDate.getTime()) / (1e3 * 60 * 60 * 24);
841
+ if (diffDays > MAX_AGGREGATION_RANGE_DAYS) {
842
+ return res.status(400).json({
843
+ success: false,
844
+ error: `Date range exceeds maximum of ${MAX_AGGREGATION_RANGE_DAYS} days`
845
+ });
846
+ }
847
+ await aggregator.aggregateRange(fromDate, toDate);
848
+ res.json({ success: true, message: "Range aggregation complete" });
849
+ } else {
850
+ let date;
851
+ if (from) {
852
+ date = new Date(from);
853
+ if (isNaN(date.getTime())) {
854
+ return res.status(400).json({
855
+ success: false,
856
+ error: "Invalid date format for from"
857
+ });
858
+ }
859
+ }
860
+ await aggregator.aggregateDaily(date);
861
+ res.json({ success: true, message: "Daily aggregation complete" });
862
+ }
863
+ } catch (error) {
864
+ if (error instanceof InvalidDateRangeError) {
865
+ return res.status(400).json({ success: false, error: error.message });
866
+ }
867
+ const message = error instanceof Error ? error.message : "Unknown error";
868
+ res.status(500).json({ success: false, error: message });
869
+ }
870
+ }
871
+ };
872
+ }
873
+ function createAnalyticsRoutes(controller) {
874
+ const router = Router();
875
+ router.get("/overview", controller.getOverview);
876
+ router.get("/timeline", controller.getTimeline);
877
+ router.get("/accounts", controller.getAccountStats);
878
+ router.get("/rules", controller.getRuleStats);
879
+ router.get("/templates", controller.getTemplateStats);
880
+ router.post("/aggregate", controller.triggerAggregation);
881
+ return router;
882
+ }
883
+
884
+ // src/index.ts
885
+ var noopLogger = {
886
+ info: () => {
887
+ },
888
+ warn: () => {
889
+ },
890
+ error: () => {
891
+ }
892
+ };
893
+ function createEmailAnalytics(config) {
894
+ validateConfig(config);
895
+ const conn = config.db.connection;
896
+ const prefix = config.db.collectionPrefix || "";
897
+ const logger = config.logger || noopLogger;
898
+ const timezone = config.options?.timezone || "UTC";
899
+ const ttlDays = config.options?.eventTTLDays;
900
+ const EmailEvent = conn.model(
901
+ `${prefix}EmailEvent`,
902
+ createEmailEventSchema(ttlDays ? { eventTTLDays: ttlDays } : void 0)
903
+ );
904
+ const AnalyticsStats = conn.model(
905
+ `${prefix}AnalyticsStats`,
906
+ createAnalyticsStatsSchema()
907
+ );
908
+ const eventRecorder = new EventRecorderService(EmailEvent, logger);
909
+ const aggregator = new AggregatorService(EmailEvent, AnalyticsStats, timezone, logger);
910
+ const queryService = new QueryService(AnalyticsStats, logger);
911
+ const controller = createAnalyticsController(eventRecorder, aggregator, queryService);
912
+ const routes = createAnalyticsRoutes(controller);
913
+ return {
914
+ routes,
915
+ events: eventRecorder,
916
+ aggregator,
917
+ query: queryService,
918
+ models: {
919
+ EmailEvent,
920
+ AnalyticsStats
921
+ }
922
+ };
923
+ }
924
+
925
+ export { AGGREGATION_INTERVAL, AggregationError, AggregatorService, AlxAnalyticsError, EVENT_TYPE, EventRecorderService, InvalidDateRangeError, QueryService, STATS_GROUP_BY, createAnalyticsController, createAnalyticsRoutes, createAnalyticsStatsSchema, createEmailAnalytics, createEmailEventSchema, validateConfig };
926
+ //# sourceMappingURL=index.mjs.map
927
+ //# sourceMappingURL=index.mjs.map