@astralibx/email-rule-engine 12.7.3 → 12.10.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/README.md +3 -0
- package/dist/index.cjs +802 -759
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +99 -12
- package/dist/index.d.ts +99 -12
- package/dist/index.mjs +799 -760
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -11,6 +11,26 @@ import { Queue, Worker } from 'bullmq';
|
|
|
11
11
|
|
|
12
12
|
// src/schemas/template.schema.ts
|
|
13
13
|
|
|
14
|
+
// src/constants/field-types.ts
|
|
15
|
+
var FIELD_TYPE = {
|
|
16
|
+
String: "string",
|
|
17
|
+
Number: "number",
|
|
18
|
+
Boolean: "boolean",
|
|
19
|
+
Date: "date",
|
|
20
|
+
ObjectId: "objectId",
|
|
21
|
+
Array: "array",
|
|
22
|
+
Object: "object"
|
|
23
|
+
};
|
|
24
|
+
var TYPE_OPERATORS = {
|
|
25
|
+
string: ["eq", "neq", "contains", "in", "not_in", "exists", "not_exists"],
|
|
26
|
+
number: ["eq", "neq", "gt", "gte", "lt", "lte", "in", "not_in", "exists", "not_exists"],
|
|
27
|
+
boolean: ["eq", "neq", "exists", "not_exists"],
|
|
28
|
+
date: ["eq", "neq", "gt", "gte", "lt", "lte", "exists", "not_exists"],
|
|
29
|
+
objectId: ["eq", "neq", "in", "not_in", "exists", "not_exists"],
|
|
30
|
+
array: ["contains", "in", "not_in", "exists", "not_exists"],
|
|
31
|
+
object: ["exists", "not_exists"]
|
|
32
|
+
};
|
|
33
|
+
|
|
14
34
|
// src/constants/index.ts
|
|
15
35
|
var TEMPLATE_CATEGORY = {
|
|
16
36
|
Onboarding: "onboarding",
|
|
@@ -152,6 +172,17 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
|
|
|
152
172
|
schema.index({ audience: 1, platform: 1, isActive: 1 });
|
|
153
173
|
return schema;
|
|
154
174
|
}
|
|
175
|
+
function createRunStatsSchema() {
|
|
176
|
+
return new Schema({
|
|
177
|
+
matched: { type: Number, default: 0 },
|
|
178
|
+
sent: { type: Number, default: 0 },
|
|
179
|
+
skipped: { type: Number, default: 0 },
|
|
180
|
+
skippedByThrottle: { type: Number, default: 0 },
|
|
181
|
+
errorCount: { type: Number, default: 0 }
|
|
182
|
+
}, { _id: false });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/schemas/rule.schema.ts
|
|
155
186
|
function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix) {
|
|
156
187
|
const RuleConditionSchema = new Schema({
|
|
157
188
|
field: { type: String, required: true },
|
|
@@ -166,15 +197,10 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
|
|
|
166
197
|
...platformValues ? { enum: platformValues } : {}
|
|
167
198
|
},
|
|
168
199
|
conditions: [RuleConditionSchema],
|
|
169
|
-
identifiers: [{ type: String }]
|
|
170
|
-
|
|
171
|
-
const RuleRunStatsSchema = new Schema({
|
|
172
|
-
matched: { type: Number, default: 0 },
|
|
173
|
-
sent: { type: Number, default: 0 },
|
|
174
|
-
skipped: { type: Number, default: 0 },
|
|
175
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
176
|
-
errorCount: { type: Number, default: 0 }
|
|
200
|
+
identifiers: [{ type: String }],
|
|
201
|
+
collection: { type: String }
|
|
177
202
|
}, { _id: false });
|
|
203
|
+
const RuleRunStatsSchema = createRunStatsSchema();
|
|
178
204
|
const schema = new Schema(
|
|
179
205
|
{
|
|
180
206
|
name: { type: String, required: true },
|
|
@@ -257,9 +283,9 @@ function createEmailRuleSendSchema(collectionPrefix) {
|
|
|
257
283
|
const schema = new Schema(
|
|
258
284
|
{
|
|
259
285
|
ruleId: { type: Schema.Types.ObjectId, ref: "EmailRule", required: true },
|
|
260
|
-
userId: { type:
|
|
261
|
-
emailIdentifierId: { type:
|
|
262
|
-
messageId: { type:
|
|
286
|
+
userId: { type: String, required: true },
|
|
287
|
+
emailIdentifierId: { type: String },
|
|
288
|
+
messageId: { type: String },
|
|
263
289
|
sentAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
|
|
264
290
|
status: { type: String },
|
|
265
291
|
accountId: { type: String },
|
|
@@ -299,25 +325,17 @@ function createEmailRuleSendSchema(collectionPrefix) {
|
|
|
299
325
|
schema.index({ ruleId: 1, userId: 1, sentAt: -1 });
|
|
300
326
|
schema.index({ userId: 1, sentAt: -1 });
|
|
301
327
|
schema.index({ ruleId: 1, sentAt: -1 });
|
|
328
|
+
schema.index({ status: 1, sentAt: -1 });
|
|
302
329
|
return schema;
|
|
303
330
|
}
|
|
304
331
|
function createEmailRuleRunLogSchema(collectionPrefix) {
|
|
332
|
+
const baseStatsSchema = createRunStatsSchema();
|
|
305
333
|
const PerRuleStatsSchema = new Schema({
|
|
306
334
|
ruleId: { type: Schema.Types.ObjectId, ref: "EmailRule", required: true },
|
|
307
335
|
ruleName: { type: String, required: true },
|
|
308
|
-
|
|
309
|
-
sent: { type: Number, default: 0 },
|
|
310
|
-
skipped: { type: Number, default: 0 },
|
|
311
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
312
|
-
errorCount: { type: Number, default: 0 }
|
|
313
|
-
}, { _id: false });
|
|
314
|
-
const TotalStatsSchema = new Schema({
|
|
315
|
-
matched: { type: Number, default: 0 },
|
|
316
|
-
sent: { type: Number, default: 0 },
|
|
317
|
-
skipped: { type: Number, default: 0 },
|
|
318
|
-
skippedByThrottle: { type: Number, default: 0 },
|
|
319
|
-
errorCount: { type: Number, default: 0 }
|
|
336
|
+
...baseStatsSchema.obj
|
|
320
337
|
}, { _id: false });
|
|
338
|
+
const TotalStatsSchema = createRunStatsSchema();
|
|
321
339
|
const schema = new Schema(
|
|
322
340
|
{
|
|
323
341
|
runId: { type: String, index: true },
|
|
@@ -341,8 +359,7 @@ function createEmailRuleRunLogSchema(collectionPrefix) {
|
|
|
341
359
|
}
|
|
342
360
|
}
|
|
343
361
|
);
|
|
344
|
-
schema.index({ runAt: -1 });
|
|
345
|
-
schema.index({ runAt: 1 }, { expireAfterSeconds: 90 * 86400 });
|
|
362
|
+
schema.index({ runAt: -1 }, { expireAfterSeconds: 90 * 86400 });
|
|
346
363
|
return schema;
|
|
347
364
|
}
|
|
348
365
|
function createEmailThrottleConfigSchema(collectionPrefix) {
|
|
@@ -469,15 +486,15 @@ var TemplateRenderService = class {
|
|
|
469
486
|
ensureHelpers();
|
|
470
487
|
}
|
|
471
488
|
renderSingle(subject, body, data, textBody) {
|
|
472
|
-
const subjectFn = Handlebars.compile(subject, { strict:
|
|
489
|
+
const subjectFn = Handlebars.compile(subject, { strict: false });
|
|
473
490
|
const resolvedSubject = subjectFn(data);
|
|
474
|
-
const bodyFn = Handlebars.compile(body, { strict:
|
|
491
|
+
const bodyFn = Handlebars.compile(body, { strict: false });
|
|
475
492
|
const resolvedBody = bodyFn(data);
|
|
476
493
|
const mjmlSource = wrapInMjml(resolvedBody);
|
|
477
494
|
const html = compileMjml(mjmlSource);
|
|
478
495
|
let text;
|
|
479
496
|
if (textBody) {
|
|
480
|
-
const textFn = Handlebars.compile(textBody, { strict:
|
|
497
|
+
const textFn = Handlebars.compile(textBody, { strict: false });
|
|
481
498
|
text = textFn(data);
|
|
482
499
|
} else {
|
|
483
500
|
text = htmlToPlainText(html);
|
|
@@ -487,20 +504,20 @@ var TemplateRenderService = class {
|
|
|
487
504
|
compileBatch(subject, body, textBody) {
|
|
488
505
|
const mjmlSource = wrapInMjml(body);
|
|
489
506
|
const htmlWithHandlebars = compileMjml(mjmlSource);
|
|
490
|
-
const subjectFn = Handlebars.compile(subject, { strict:
|
|
491
|
-
const bodyFn = Handlebars.compile(htmlWithHandlebars, { strict:
|
|
492
|
-
const textBodyFn = textBody ? Handlebars.compile(textBody, { strict:
|
|
507
|
+
const subjectFn = Handlebars.compile(subject, { strict: false });
|
|
508
|
+
const bodyFn = Handlebars.compile(htmlWithHandlebars, { strict: false });
|
|
509
|
+
const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: false }) : void 0;
|
|
493
510
|
return { subjectFn, bodyFn, textBodyFn };
|
|
494
511
|
}
|
|
495
512
|
compileBatchVariants(subjects, bodies, textBody, preheaders) {
|
|
496
|
-
const subjectFns = subjects.map((s) => Handlebars.compile(s, { strict:
|
|
513
|
+
const subjectFns = subjects.map((s) => Handlebars.compile(s, { strict: false }));
|
|
497
514
|
const bodyFns = bodies.map((b) => {
|
|
498
515
|
const mjmlSource = wrapInMjml(b);
|
|
499
516
|
const htmlWithHandlebars = compileMjml(mjmlSource);
|
|
500
|
-
return Handlebars.compile(htmlWithHandlebars, { strict:
|
|
517
|
+
return Handlebars.compile(htmlWithHandlebars, { strict: false });
|
|
501
518
|
});
|
|
502
|
-
const textBodyFn = textBody ? Handlebars.compile(textBody, { strict:
|
|
503
|
-
const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars.compile(p, { strict:
|
|
519
|
+
const textBodyFn = textBody ? Handlebars.compile(textBody, { strict: false }) : void 0;
|
|
520
|
+
const preheaderFns = preheaders && preheaders.length > 0 ? preheaders.map((p) => Handlebars.compile(p, { strict: false })) : void 0;
|
|
504
521
|
return { subjectFns, bodyFns, textBodyFn, preheaderFns };
|
|
505
522
|
}
|
|
506
523
|
renderFromCompiled(compiled, data) {
|
|
@@ -616,6 +633,38 @@ var DuplicateSlugError = class extends AlxEmailError {
|
|
|
616
633
|
}
|
|
617
634
|
};
|
|
618
635
|
|
|
636
|
+
// src/utils/query-helpers.ts
|
|
637
|
+
function isValidDateString(s) {
|
|
638
|
+
return s.trim() !== "" && !isNaN(new Date(s).getTime());
|
|
639
|
+
}
|
|
640
|
+
function buildDateRangeFilter(dateField, from, to) {
|
|
641
|
+
const validFrom = from && isValidDateString(from) ? from : void 0;
|
|
642
|
+
const validTo = to && isValidDateString(to) ? to : void 0;
|
|
643
|
+
if (!validFrom && !validTo) return {};
|
|
644
|
+
const filter = {};
|
|
645
|
+
filter[dateField] = {};
|
|
646
|
+
if (validFrom) filter[dateField].$gte = new Date(validFrom);
|
|
647
|
+
if (validTo) filter[dateField].$lte = /* @__PURE__ */ new Date(validTo + "T23:59:59.999Z");
|
|
648
|
+
return filter;
|
|
649
|
+
}
|
|
650
|
+
function calculatePagination(page, limit, maxLimit = 500) {
|
|
651
|
+
const rawPage = page != null && !isNaN(page) ? page : 1;
|
|
652
|
+
const rawLimit = limit != null && !isNaN(limit) ? limit : 200;
|
|
653
|
+
const p = Math.max(1, rawPage);
|
|
654
|
+
const l = Math.max(1, Math.min(rawLimit, maxLimit));
|
|
655
|
+
const skip = (p - 1) * l;
|
|
656
|
+
return { page: p, limit: l, skip };
|
|
657
|
+
}
|
|
658
|
+
function filterUpdateableFields(input, allowedFields) {
|
|
659
|
+
const result = {};
|
|
660
|
+
for (const [key, value] of Object.entries(input)) {
|
|
661
|
+
if (value !== void 0 && allowedFields.has(key)) {
|
|
662
|
+
result[key] = value;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return result;
|
|
666
|
+
}
|
|
667
|
+
|
|
619
668
|
// src/services/template.service.ts
|
|
620
669
|
function stripScriptTags(text) {
|
|
621
670
|
return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "");
|
|
@@ -639,9 +688,10 @@ function slugify(name) {
|
|
|
639
688
|
return name.toLowerCase().trim().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
640
689
|
}
|
|
641
690
|
var TemplateService = class {
|
|
642
|
-
constructor(EmailTemplate, config) {
|
|
691
|
+
constructor(EmailTemplate, config, EmailRule) {
|
|
643
692
|
this.EmailTemplate = EmailTemplate;
|
|
644
693
|
this.config = config;
|
|
694
|
+
this.EmailRule = EmailRule;
|
|
645
695
|
}
|
|
646
696
|
renderService = new TemplateRenderService();
|
|
647
697
|
async list(filters) {
|
|
@@ -650,7 +700,14 @@ var TemplateService = class {
|
|
|
650
700
|
if (filters?.audience) query["audience"] = filters.audience;
|
|
651
701
|
if (filters?.platform) query["platform"] = filters.platform;
|
|
652
702
|
if (filters?.isActive !== void 0) query["isActive"] = filters.isActive;
|
|
653
|
-
|
|
703
|
+
const page = filters?.page ?? 1;
|
|
704
|
+
const limit = filters?.limit ?? 200;
|
|
705
|
+
const skip = (page - 1) * limit;
|
|
706
|
+
const [templates, total] = await Promise.all([
|
|
707
|
+
this.EmailTemplate.find(query).sort({ category: 1, name: 1 }).skip(skip).limit(limit),
|
|
708
|
+
this.EmailTemplate.countDocuments(query)
|
|
709
|
+
]);
|
|
710
|
+
return { templates, total };
|
|
654
711
|
}
|
|
655
712
|
async getById(id) {
|
|
656
713
|
return this.EmailTemplate.findById(id);
|
|
@@ -716,12 +773,7 @@ var TemplateService = class {
|
|
|
716
773
|
const allContent = [...subjects, ...bodies, ...preheaders, textBody || ""].join(" ");
|
|
717
774
|
input.variables = this.renderService.extractVariables(allContent);
|
|
718
775
|
}
|
|
719
|
-
const setFields =
|
|
720
|
-
for (const [key, value] of Object.entries(input)) {
|
|
721
|
-
if (value !== void 0 && UPDATEABLE_FIELDS.has(key)) {
|
|
722
|
-
setFields[key] = value;
|
|
723
|
-
}
|
|
724
|
-
}
|
|
776
|
+
const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS);
|
|
725
777
|
const update = { $set: setFields };
|
|
726
778
|
if (input.textBody || input.subjects || input.bodies || input.preheaders) {
|
|
727
779
|
update["$inc"] = { version: 1 };
|
|
@@ -733,6 +785,13 @@ var TemplateService = class {
|
|
|
733
785
|
);
|
|
734
786
|
}
|
|
735
787
|
async delete(id) {
|
|
788
|
+
if (this.EmailRule) {
|
|
789
|
+
const activeRules = await this.EmailRule.find({ templateId: id, isActive: true });
|
|
790
|
+
if (activeRules.length > 0) {
|
|
791
|
+
const names = activeRules.map((r) => r.name).join(", ");
|
|
792
|
+
throw new Error(`Cannot delete template: ${activeRules.length} active rule(s) reference it (${names}). Deactivate them first.`);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
736
795
|
const result = await this.EmailTemplate.findByIdAndDelete(id);
|
|
737
796
|
return result !== null;
|
|
738
797
|
}
|
|
@@ -812,7 +871,8 @@ var TemplateService = class {
|
|
|
812
871
|
async previewWithRecipient(templateId, recipientData) {
|
|
813
872
|
const template = await this.EmailTemplate.findById(templateId);
|
|
814
873
|
if (!template) return null;
|
|
815
|
-
const
|
|
874
|
+
const variables = template.variables ?? [];
|
|
875
|
+
const data = this._buildSampleData(variables, { ...template.fields ?? {}, ...recipientData });
|
|
816
876
|
return this.renderService.renderPreview(
|
|
817
877
|
template.subjects[0],
|
|
818
878
|
template.bodies[0],
|
|
@@ -822,6 +882,125 @@ var TemplateService = class {
|
|
|
822
882
|
}
|
|
823
883
|
};
|
|
824
884
|
|
|
885
|
+
// src/controllers/collection.controller.ts
|
|
886
|
+
function flattenFields(fields, prefix = "", parentIsArray = false) {
|
|
887
|
+
const result = [];
|
|
888
|
+
for (const field of fields) {
|
|
889
|
+
const path = prefix ? `${prefix}.${field.name}` : field.name;
|
|
890
|
+
const isArray = field.type === "array";
|
|
891
|
+
if (field.type === "object" && field.fields?.length) {
|
|
892
|
+
result.push({
|
|
893
|
+
path,
|
|
894
|
+
type: "object",
|
|
895
|
+
label: field.label,
|
|
896
|
+
description: field.description
|
|
897
|
+
});
|
|
898
|
+
result.push(...flattenFields(field.fields, path, false));
|
|
899
|
+
} else if (isArray && field.fields?.length) {
|
|
900
|
+
result.push({
|
|
901
|
+
path: `${path}[]`,
|
|
902
|
+
type: "array",
|
|
903
|
+
label: field.label,
|
|
904
|
+
description: field.description,
|
|
905
|
+
isArray: true
|
|
906
|
+
});
|
|
907
|
+
result.push(...flattenFields(field.fields, `${path}[]`, true));
|
|
908
|
+
} else {
|
|
909
|
+
result.push({
|
|
910
|
+
path,
|
|
911
|
+
type: field.type,
|
|
912
|
+
label: field.label,
|
|
913
|
+
description: field.description,
|
|
914
|
+
enumValues: field.enumValues,
|
|
915
|
+
isArray: parentIsArray || isArray
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return result;
|
|
920
|
+
}
|
|
921
|
+
function createCollectionController(collections) {
|
|
922
|
+
return {
|
|
923
|
+
list(_req, res) {
|
|
924
|
+
const summary = collections.map((c) => ({
|
|
925
|
+
name: c.name,
|
|
926
|
+
label: c.label,
|
|
927
|
+
description: c.description,
|
|
928
|
+
identifierField: c.identifierField,
|
|
929
|
+
fieldCount: c.fields.length,
|
|
930
|
+
joinCount: c.joins?.length ?? 0
|
|
931
|
+
}));
|
|
932
|
+
res.json({ collections: summary });
|
|
933
|
+
},
|
|
934
|
+
getFields(req, res) {
|
|
935
|
+
const { name } = req.params;
|
|
936
|
+
const collection = collections.find((c) => c.name === name);
|
|
937
|
+
if (!collection) {
|
|
938
|
+
res.status(404).json({ error: `Collection "${name}" not found` });
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
const fields = flattenFields(collection.fields);
|
|
942
|
+
if (collection.joins?.length) {
|
|
943
|
+
for (const join of collection.joins) {
|
|
944
|
+
const joinedCollection = collections.find((c) => c.name === join.from);
|
|
945
|
+
if (joinedCollection) {
|
|
946
|
+
const joinedFields = flattenFields(joinedCollection.fields, join.as);
|
|
947
|
+
fields.push(...joinedFields);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
res.json({
|
|
952
|
+
name: collection.name,
|
|
953
|
+
label: collection.label,
|
|
954
|
+
identifierField: collection.identifierField,
|
|
955
|
+
fields,
|
|
956
|
+
typeOperators: TYPE_OPERATORS
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/validation/condition.validator.ts
|
|
963
|
+
function validateConditions(conditions, collectionName, collections) {
|
|
964
|
+
if (!collectionName || collections.length === 0) return [];
|
|
965
|
+
const collection = collections.find((c) => c.name === collectionName);
|
|
966
|
+
if (!collection) return [];
|
|
967
|
+
const flatFields = flattenFields(collection.fields);
|
|
968
|
+
if (collection.joins?.length) {
|
|
969
|
+
for (const join of collection.joins) {
|
|
970
|
+
const joinedCollection = collections.find((c) => c.name === join.from);
|
|
971
|
+
if (joinedCollection) {
|
|
972
|
+
flatFields.push(...flattenFields(joinedCollection.fields, join.as));
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const fieldMap = /* @__PURE__ */ new Map();
|
|
977
|
+
for (const f of flatFields) {
|
|
978
|
+
fieldMap.set(f.path, f);
|
|
979
|
+
}
|
|
980
|
+
const errors = [];
|
|
981
|
+
for (let i = 0; i < conditions.length; i++) {
|
|
982
|
+
const cond = conditions[i];
|
|
983
|
+
const fieldDef = fieldMap.get(cond.field);
|
|
984
|
+
if (!fieldDef) {
|
|
985
|
+
errors.push({
|
|
986
|
+
index: i,
|
|
987
|
+
field: cond.field,
|
|
988
|
+
message: `Field "${cond.field}" does not exist in collection "${collectionName}"`
|
|
989
|
+
});
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
const allowedOps = TYPE_OPERATORS[fieldDef.type];
|
|
993
|
+
if (allowedOps && !allowedOps.includes(cond.operator)) {
|
|
994
|
+
errors.push({
|
|
995
|
+
index: i,
|
|
996
|
+
field: cond.field,
|
|
997
|
+
message: `Operator "${cond.operator}" is not valid for field type "${fieldDef.type}"`
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return errors;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
825
1004
|
// src/services/rule.service.ts
|
|
826
1005
|
function isQueryTarget(target) {
|
|
827
1006
|
return !target.mode || target.mode === "query";
|
|
@@ -872,8 +1051,15 @@ var RuleService = class {
|
|
|
872
1051
|
this.EmailRuleRunLog = EmailRuleRunLog;
|
|
873
1052
|
this.config = config;
|
|
874
1053
|
}
|
|
875
|
-
async list() {
|
|
876
|
-
|
|
1054
|
+
async list(opts) {
|
|
1055
|
+
const page = opts?.page ?? 1;
|
|
1056
|
+
const limit = opts?.limit ?? 200;
|
|
1057
|
+
const skip = (page - 1) * limit;
|
|
1058
|
+
const [rules, total] = await Promise.all([
|
|
1059
|
+
this.EmailRule.find().populate("templateId", "name slug").sort({ sortOrder: 1, createdAt: -1 }).skip(skip).limit(limit),
|
|
1060
|
+
this.EmailRule.countDocuments()
|
|
1061
|
+
]);
|
|
1062
|
+
return { rules, total };
|
|
877
1063
|
}
|
|
878
1064
|
async getById(id) {
|
|
879
1065
|
return this.EmailRule.findById(id);
|
|
@@ -900,6 +1086,14 @@ var RuleService = class {
|
|
|
900
1086
|
throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
|
|
901
1087
|
}
|
|
902
1088
|
}
|
|
1089
|
+
if (isQueryTarget(input.target) && input.target.collection && this.config.collections?.length) {
|
|
1090
|
+
const condErrors = validateConditions(input.target.conditions, input.target.collection, this.config.collections);
|
|
1091
|
+
if (condErrors.length > 0) {
|
|
1092
|
+
throw new RuleTemplateIncompatibleError(
|
|
1093
|
+
`Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
903
1097
|
return this.EmailRule.createRule(input);
|
|
904
1098
|
}
|
|
905
1099
|
async update(id, input) {
|
|
@@ -929,12 +1123,18 @@ var RuleService = class {
|
|
|
929
1123
|
throw new RuleTemplateIncompatibleError(compatError);
|
|
930
1124
|
}
|
|
931
1125
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
if (
|
|
935
|
-
|
|
1126
|
+
if (isQueryTarget(effectiveTarget)) {
|
|
1127
|
+
const qt = effectiveTarget;
|
|
1128
|
+
if (qt.collection && this.config.collections?.length) {
|
|
1129
|
+
const condErrors = validateConditions(qt.conditions || [], qt.collection, this.config.collections);
|
|
1130
|
+
if (condErrors.length > 0) {
|
|
1131
|
+
throw new RuleTemplateIncompatibleError(
|
|
1132
|
+
`Invalid conditions: ${condErrors.map((e) => e.message).join("; ")}`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
936
1135
|
}
|
|
937
1136
|
}
|
|
1137
|
+
const setFields = filterUpdateableFields(input, UPDATEABLE_FIELDS2);
|
|
938
1138
|
return this.EmailRule.findByIdAndUpdate(
|
|
939
1139
|
id,
|
|
940
1140
|
{ $set: setFields },
|
|
@@ -983,7 +1183,10 @@ var RuleService = class {
|
|
|
983
1183
|
const sample2 = identifiers.slice(0, 10).map((id2) => ({ email: id2 }));
|
|
984
1184
|
return { matchedCount: matchedCount2, effectiveLimit, willProcess: willProcess2, ruleId: id, sample: sample2 };
|
|
985
1185
|
}
|
|
986
|
-
const
|
|
1186
|
+
const queryTarget = rule.target;
|
|
1187
|
+
const collectionName = queryTarget.collection;
|
|
1188
|
+
const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
|
|
1189
|
+
const users = await this.config.adapters.queryUsers(rule.target, 5e4, collectionSchema ? { collectionSchema } : void 0);
|
|
987
1190
|
const matchedCount = users.length;
|
|
988
1191
|
const willProcess = Math.min(matchedCount, effectiveLimit);
|
|
989
1192
|
const sample = users.slice(0, 10).map((u) => ({
|
|
@@ -1005,8 +1208,14 @@ var RuleService = class {
|
|
|
1005
1208
|
rest.lastRunStats = void 0;
|
|
1006
1209
|
return this.EmailRule.create(rest);
|
|
1007
1210
|
}
|
|
1008
|
-
async getRunHistory(limit = 20) {
|
|
1009
|
-
|
|
1211
|
+
async getRunHistory(limit = 20, opts) {
|
|
1212
|
+
const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
|
|
1213
|
+
const pagination = calculatePagination(opts?.page, limit);
|
|
1214
|
+
return this.EmailRuleRunLog.find(filter).sort({ runAt: -1 }).skip(pagination.skip).limit(pagination.limit);
|
|
1215
|
+
}
|
|
1216
|
+
async getRunHistoryCount(opts) {
|
|
1217
|
+
const filter = buildDateRangeFilter("runAt", opts?.from, opts?.to);
|
|
1218
|
+
return this.EmailRuleRunLog.countDocuments(filter);
|
|
1010
1219
|
}
|
|
1011
1220
|
};
|
|
1012
1221
|
var MS_PER_DAY = 864e5;
|
|
@@ -1076,6 +1285,7 @@ var RuleRunnerService = class {
|
|
|
1076
1285
|
const lockAcquired = await this.lock.acquire();
|
|
1077
1286
|
if (!lockAcquired) {
|
|
1078
1287
|
this.logger.warn("Rule runner already executing, skipping");
|
|
1288
|
+
await this.updateRunProgress(runId, { status: "failed", currentRule: "Another run is already in progress" });
|
|
1079
1289
|
return { runId };
|
|
1080
1290
|
}
|
|
1081
1291
|
const runStartTime = Date.now();
|
|
@@ -1226,193 +1436,252 @@ var RuleRunnerService = class {
|
|
|
1226
1436
|
}
|
|
1227
1437
|
return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
|
|
1228
1438
|
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1439
|
+
emitSendEvent(rule, email, status, templateId, runId, opts) {
|
|
1440
|
+
this.config.hooks?.onSend?.({
|
|
1441
|
+
ruleId: rule._id.toString(),
|
|
1442
|
+
ruleName: rule.name,
|
|
1443
|
+
email,
|
|
1444
|
+
status,
|
|
1445
|
+
accountId: opts?.accountId ?? "",
|
|
1446
|
+
templateId,
|
|
1447
|
+
runId: runId || "",
|
|
1448
|
+
subjectIndex: opts?.subjectIndex ?? -1,
|
|
1449
|
+
bodyIndex: opts?.bodyIndex ?? -1,
|
|
1450
|
+
preheaderIndex: opts?.preheaderIndex,
|
|
1451
|
+
failureReason: opts?.failureReason
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
async processSingleUser(params) {
|
|
1455
|
+
const { rule, email, userKey, identifier, user, sendMap, throttleMap, throttleConfig, template, compiledVariants, templateId, ruleId, runId, stats } = params;
|
|
1456
|
+
const lastSend = sendMap.get(userKey);
|
|
1457
|
+
if (lastSend) {
|
|
1458
|
+
if (rule.sendOnce && rule.resendAfterDays == null) {
|
|
1459
|
+
stats.skipped++;
|
|
1460
|
+
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "send once" });
|
|
1461
|
+
return "skipped";
|
|
1462
|
+
}
|
|
1463
|
+
if (rule.resendAfterDays != null) {
|
|
1464
|
+
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1465
|
+
if (daysSince < rule.resendAfterDays) {
|
|
1466
|
+
stats.skipped++;
|
|
1467
|
+
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "resend too soon" });
|
|
1468
|
+
return "skipped";
|
|
1469
|
+
}
|
|
1470
|
+
} else {
|
|
1471
|
+
stats.skipped++;
|
|
1472
|
+
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "send once" });
|
|
1473
|
+
return "skipped";
|
|
1474
|
+
}
|
|
1475
|
+
if (rule.cooldownDays) {
|
|
1476
|
+
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1477
|
+
if (daysSince < rule.cooldownDays) {
|
|
1478
|
+
stats.skipped++;
|
|
1479
|
+
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "cooldown period" });
|
|
1480
|
+
return "skipped";
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1235
1483
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1484
|
+
if (!this.checkThrottle(rule, userKey, email, throttleMap, throttleConfig, stats, templateId, runId)) return "skipped";
|
|
1485
|
+
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1486
|
+
if (!agentSelection) {
|
|
1487
|
+
stats.skipped++;
|
|
1488
|
+
this.emitSendEvent(rule, email, "skipped", templateId, runId || "", { failureReason: "no account available" });
|
|
1489
|
+
return "skipped";
|
|
1490
|
+
}
|
|
1491
|
+
const resolvedData = this.config.adapters.resolveData(user);
|
|
1492
|
+
const templateData = { ...template.fields || {}, ...resolvedData };
|
|
1493
|
+
const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
|
|
1494
|
+
const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
|
|
1495
|
+
const renderedSubject = compiledVariants.subjectFns[si](templateData);
|
|
1496
|
+
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1497
|
+
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1498
|
+
let finalHtml = renderedHtml;
|
|
1499
|
+
let finalText = renderedText;
|
|
1500
|
+
let finalSubject = renderedSubject;
|
|
1501
|
+
let pi;
|
|
1502
|
+
if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
|
|
1503
|
+
pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
|
|
1504
|
+
const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
|
|
1505
|
+
if (renderedPreheader) {
|
|
1506
|
+
const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
|
|
1507
|
+
finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
if (this.config.hooks?.beforeSend) {
|
|
1511
|
+
try {
|
|
1512
|
+
const modified = await this.config.hooks.beforeSend({
|
|
1513
|
+
htmlBody: finalHtml,
|
|
1514
|
+
textBody: finalText,
|
|
1515
|
+
subject: finalSubject,
|
|
1516
|
+
account: {
|
|
1517
|
+
id: agentSelection.accountId,
|
|
1518
|
+
email: agentSelection.email,
|
|
1519
|
+
metadata: agentSelection.metadata
|
|
1520
|
+
},
|
|
1521
|
+
user: {
|
|
1522
|
+
id: String(userKey),
|
|
1523
|
+
email,
|
|
1524
|
+
name: String(user.name || user.firstName || "")
|
|
1525
|
+
},
|
|
1526
|
+
context: {
|
|
1527
|
+
ruleId,
|
|
1528
|
+
templateId,
|
|
1529
|
+
runId: runId || ""
|
|
1530
|
+
}
|
|
1531
|
+
});
|
|
1532
|
+
finalHtml = modified.htmlBody;
|
|
1533
|
+
finalText = modified.textBody;
|
|
1534
|
+
finalSubject = modified.subject;
|
|
1535
|
+
} catch (hookErr) {
|
|
1536
|
+
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1537
|
+
stats.errorCount++;
|
|
1538
|
+
this.emitSendEvent(rule, email, "error", templateId, runId || "", { accountId: agentSelection.accountId, subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
|
|
1539
|
+
return "error";
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
await this.config.adapters.sendEmail({
|
|
1543
|
+
identifierId: identifier.id,
|
|
1544
|
+
contactId: identifier.contactId,
|
|
1545
|
+
accountId: agentSelection.accountId,
|
|
1546
|
+
subject: finalSubject,
|
|
1547
|
+
htmlBody: finalHtml,
|
|
1548
|
+
textBody: finalText,
|
|
1549
|
+
ruleId,
|
|
1550
|
+
autoApprove: rule.autoApprove ?? true,
|
|
1551
|
+
attachments: template.attachments || []
|
|
1552
|
+
});
|
|
1553
|
+
await this.EmailRuleSend.logSend(
|
|
1554
|
+
ruleId,
|
|
1555
|
+
userKey,
|
|
1556
|
+
identifier.id,
|
|
1557
|
+
void 0,
|
|
1558
|
+
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
|
|
1559
|
+
);
|
|
1560
|
+
const current = throttleMap.get(userKey) || { today: 0, thisWeek: 0};
|
|
1561
|
+
throttleMap.set(userKey, {
|
|
1562
|
+
today: current.today + 1,
|
|
1563
|
+
thisWeek: current.thisWeek + 1,
|
|
1564
|
+
lastSentDate: /* @__PURE__ */ new Date()
|
|
1565
|
+
});
|
|
1566
|
+
stats.sent++;
|
|
1567
|
+
this.emitSendEvent(rule, email, "sent", templateId, runId || "", { accountId: agentSelection.accountId, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi });
|
|
1568
|
+
return "sent";
|
|
1569
|
+
}
|
|
1570
|
+
async resolveIdentifiers(emails) {
|
|
1242
1571
|
const identifierResults = await processInChunks(
|
|
1243
|
-
|
|
1572
|
+
emails,
|
|
1244
1573
|
async (email) => {
|
|
1245
1574
|
const result = await this.config.adapters.findIdentifier(email);
|
|
1246
1575
|
return result ? { email, ...result } : null;
|
|
1247
1576
|
},
|
|
1248
1577
|
IDENTIFIER_CHUNK_SIZE
|
|
1249
1578
|
);
|
|
1250
|
-
const
|
|
1579
|
+
const map = /* @__PURE__ */ new Map();
|
|
1251
1580
|
for (const result of identifierResults) {
|
|
1252
1581
|
if (result) {
|
|
1253
|
-
|
|
1582
|
+
map.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1254
1583
|
}
|
|
1255
1584
|
}
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
for (const send of
|
|
1585
|
+
return map;
|
|
1586
|
+
}
|
|
1587
|
+
buildSendMap(sends) {
|
|
1588
|
+
const map = /* @__PURE__ */ new Map();
|
|
1589
|
+
for (const send of sends) {
|
|
1261
1590
|
const uid = send.userId.toString();
|
|
1262
|
-
if (!
|
|
1263
|
-
|
|
1591
|
+
if (!map.has(uid)) {
|
|
1592
|
+
map.set(uid, send);
|
|
1264
1593
|
}
|
|
1265
1594
|
}
|
|
1595
|
+
return map;
|
|
1596
|
+
}
|
|
1597
|
+
compileTemplateVariants(template) {
|
|
1266
1598
|
const preheaders = template.preheaders || [];
|
|
1267
|
-
|
|
1599
|
+
return this.templateRenderer.compileBatchVariants(
|
|
1268
1600
|
template.subjects,
|
|
1269
1601
|
template.bodies,
|
|
1270
1602
|
template.textBody,
|
|
1271
1603
|
preheaders
|
|
1272
1604
|
);
|
|
1605
|
+
}
|
|
1606
|
+
async checkCancelled(runId, index) {
|
|
1607
|
+
if (!runId || index % 10 !== 0) return false;
|
|
1608
|
+
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1609
|
+
return !!await this.redis.exists(cancelKey);
|
|
1610
|
+
}
|
|
1611
|
+
async applySendDelay(isLast) {
|
|
1612
|
+
if (isLast) return;
|
|
1613
|
+
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1614
|
+
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1615
|
+
if (delayMs > 0 || jitterMs > 0) {
|
|
1616
|
+
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1617
|
+
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
async finalizeRuleStats(rule, stats, ruleId, templateId, runId) {
|
|
1621
|
+
await this.EmailRule.findByIdAndUpdate(rule._id, {
|
|
1622
|
+
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1623
|
+
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1624
|
+
});
|
|
1625
|
+
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
|
|
1626
|
+
}
|
|
1627
|
+
async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
1628
|
+
const rawIdentifiers = rule.target.identifiers || [];
|
|
1629
|
+
const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
|
|
1630
|
+
const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
|
|
1631
|
+
if (uniqueEmails.length > limit) {
|
|
1632
|
+
this.logger.warn(`Rule "${rule.name}" matched ${uniqueEmails.length} users but maxPerRun is ${limit} \u2014 only ${limit} will be processed`, { ruleId: rule._id.toString(), matchedCount: uniqueEmails.length, maxPerRun: limit });
|
|
1633
|
+
}
|
|
1634
|
+
const emailsToProcess = uniqueEmails.slice(0, limit);
|
|
1635
|
+
stats.matched = emailsToProcess.length;
|
|
1636
|
+
const ruleId = rule._id.toString();
|
|
1637
|
+
const templateId = rule.templateId.toString();
|
|
1638
|
+
this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length, templateId, runId: runId || "" });
|
|
1639
|
+
if (emailsToProcess.length === 0) return stats;
|
|
1640
|
+
const identifierMap = await this.resolveIdentifiers(emailsToProcess);
|
|
1641
|
+
const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
|
|
1642
|
+
const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
|
|
1643
|
+
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
|
|
1644
|
+
const sendMap = this.buildSendMap(allRuleSends);
|
|
1645
|
+
const compiledVariants = this.compileTemplateVariants(template);
|
|
1273
1646
|
let totalProcessed = 0;
|
|
1274
1647
|
for (let i = 0; i < emailsToProcess.length; i++) {
|
|
1275
1648
|
const email = emailsToProcess[i];
|
|
1276
|
-
if (runId
|
|
1277
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1278
|
-
const cancelled = await this.redis.exists(cancelKey);
|
|
1279
|
-
if (cancelled) break;
|
|
1280
|
-
}
|
|
1649
|
+
if (await this.checkCancelled(runId, i)) break;
|
|
1281
1650
|
try {
|
|
1282
1651
|
const identifier = identifierMap.get(email);
|
|
1283
1652
|
if (!identifier) {
|
|
1284
1653
|
stats.skipped++;
|
|
1285
|
-
this.
|
|
1286
|
-
continue;
|
|
1287
|
-
}
|
|
1288
|
-
const dedupKey = identifier.id;
|
|
1289
|
-
const lastSend = sendMap.get(dedupKey);
|
|
1290
|
-
if (lastSend) {
|
|
1291
|
-
if (rule.sendOnce && !rule.resendAfterDays) {
|
|
1292
|
-
stats.skipped++;
|
|
1293
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1294
|
-
continue;
|
|
1295
|
-
}
|
|
1296
|
-
if (rule.resendAfterDays) {
|
|
1297
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1298
|
-
if (daysSince < rule.resendAfterDays) {
|
|
1299
|
-
stats.skipped++;
|
|
1300
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "resend too soon" });
|
|
1301
|
-
continue;
|
|
1302
|
-
}
|
|
1303
|
-
} else {
|
|
1304
|
-
stats.skipped++;
|
|
1305
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1306
|
-
continue;
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1309
|
-
if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats, templateId, runId)) continue;
|
|
1310
|
-
const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
|
|
1311
|
-
if (!agentSelection) {
|
|
1312
|
-
stats.skipped++;
|
|
1313
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "no account available" });
|
|
1654
|
+
this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1314
1655
|
continue;
|
|
1315
1656
|
}
|
|
1316
|
-
const
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
|
|
1329
|
-
pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
|
|
1330
|
-
const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
|
|
1331
|
-
if (renderedPreheader) {
|
|
1332
|
-
const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
|
|
1333
|
-
finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
if (this.config.hooks?.beforeSend) {
|
|
1337
|
-
try {
|
|
1338
|
-
const modified = await this.config.hooks.beforeSend({
|
|
1339
|
-
htmlBody: finalHtml,
|
|
1340
|
-
textBody: finalText,
|
|
1341
|
-
subject: finalSubject,
|
|
1342
|
-
account: {
|
|
1343
|
-
id: agentSelection.accountId,
|
|
1344
|
-
email: agentSelection.email,
|
|
1345
|
-
metadata: agentSelection.metadata
|
|
1346
|
-
},
|
|
1347
|
-
user: {
|
|
1348
|
-
id: dedupKey,
|
|
1349
|
-
email,
|
|
1350
|
-
name: ""
|
|
1351
|
-
},
|
|
1352
|
-
context: {
|
|
1353
|
-
ruleId,
|
|
1354
|
-
templateId,
|
|
1355
|
-
runId: runId || ""
|
|
1356
|
-
}
|
|
1357
|
-
});
|
|
1358
|
-
finalHtml = modified.htmlBody;
|
|
1359
|
-
finalText = modified.textBody;
|
|
1360
|
-
finalSubject = modified.subject;
|
|
1361
|
-
} catch (hookErr) {
|
|
1362
|
-
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1363
|
-
stats.errorCount++;
|
|
1364
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
|
|
1365
|
-
continue;
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
await this.config.adapters.sendEmail({
|
|
1369
|
-
identifierId: identifier.id,
|
|
1370
|
-
contactId: identifier.contactId,
|
|
1371
|
-
accountId: agentSelection.accountId,
|
|
1372
|
-
subject: finalSubject,
|
|
1373
|
-
htmlBody: finalHtml,
|
|
1374
|
-
textBody: finalText,
|
|
1657
|
+
const result = await this.processSingleUser({
|
|
1658
|
+
rule,
|
|
1659
|
+
email,
|
|
1660
|
+
userKey: identifier.id,
|
|
1661
|
+
identifier,
|
|
1662
|
+
user: { _id: identifier.id, email },
|
|
1663
|
+
sendMap,
|
|
1664
|
+
throttleMap,
|
|
1665
|
+
throttleConfig,
|
|
1666
|
+
template,
|
|
1667
|
+
compiledVariants,
|
|
1668
|
+
templateId,
|
|
1375
1669
|
ruleId,
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
});
|
|
1379
|
-
await this.EmailRuleSend.logSend(
|
|
1380
|
-
ruleId,
|
|
1381
|
-
dedupKey,
|
|
1382
|
-
identifier.id,
|
|
1383
|
-
void 0,
|
|
1384
|
-
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
|
|
1385
|
-
);
|
|
1386
|
-
const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1387
|
-
throttleMap.set(dedupKey, {
|
|
1388
|
-
today: current.today + 1,
|
|
1389
|
-
thisWeek: current.thisWeek + 1,
|
|
1390
|
-
lastSentDate: /* @__PURE__ */ new Date()
|
|
1670
|
+
runId,
|
|
1671
|
+
stats
|
|
1391
1672
|
});
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
await this.updateRunSendProgress(runId, stats);
|
|
1397
|
-
}
|
|
1398
|
-
if (i < emailsToProcess.length - 1) {
|
|
1399
|
-
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1400
|
-
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1401
|
-
if (delayMs > 0 || jitterMs > 0) {
|
|
1402
|
-
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1403
|
-
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1404
|
-
}
|
|
1673
|
+
if (result === "sent") {
|
|
1674
|
+
totalProcessed++;
|
|
1675
|
+
if (runId && totalProcessed % 10 === 0) await this.updateRunSendProgress(runId, stats);
|
|
1676
|
+
await this.applySendDelay(i >= emailsToProcess.length - 1);
|
|
1405
1677
|
}
|
|
1406
1678
|
} catch (err) {
|
|
1407
1679
|
stats.errorCount++;
|
|
1408
|
-
this.
|
|
1680
|
+
this.emitSendEvent(rule, email, "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
|
|
1409
1681
|
this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
|
|
1410
1682
|
}
|
|
1411
1683
|
}
|
|
1412
|
-
await this.
|
|
1413
|
-
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1414
|
-
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1415
|
-
});
|
|
1684
|
+
await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
|
|
1416
1685
|
if (rule.sendOnce) {
|
|
1417
1686
|
const allIdentifiers = rule.target.identifiers || [];
|
|
1418
1687
|
const totalIdentifiers = new Set(allIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean)).size;
|
|
@@ -1428,14 +1697,15 @@ var RuleRunnerService = class {
|
|
|
1428
1697
|
this.logger.info(`Rule '${rule.name}' auto-disabled \u2014 all identifiers processed`);
|
|
1429
1698
|
}
|
|
1430
1699
|
}
|
|
1431
|
-
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
|
|
1432
1700
|
return stats;
|
|
1433
1701
|
}
|
|
1434
1702
|
async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
|
|
1435
1703
|
const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
|
|
1436
1704
|
let users;
|
|
1437
1705
|
try {
|
|
1438
|
-
|
|
1706
|
+
const collectionName = rule.target?.collection;
|
|
1707
|
+
const collectionSchema = collectionName ? this.config.collections?.find((c) => c.name === collectionName) : void 0;
|
|
1708
|
+
users = await this.config.adapters.queryUsers(rule.target, limit, collectionSchema ? { collectionSchema } : void 0);
|
|
1439
1709
|
} catch (err) {
|
|
1440
1710
|
this.logger.error(`Rule "${rule.name}": query failed`, { error: err });
|
|
1441
1711
|
stats.errorCount = 1;
|
|
@@ -1450,186 +1720,58 @@ var RuleRunnerService = class {
|
|
|
1450
1720
|
const userIds = users.map((u) => u._id?.toString()).filter(Boolean);
|
|
1451
1721
|
const emails = users.map((u) => u.email).filter(Boolean);
|
|
1452
1722
|
const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: userIds } }).sort({ sentAt: -1 }).lean();
|
|
1453
|
-
const sendMap =
|
|
1454
|
-
for (const send of allRuleSends) {
|
|
1455
|
-
const uid = send.userId.toString();
|
|
1456
|
-
if (!sendMap.has(uid)) {
|
|
1457
|
-
sendMap.set(uid, send);
|
|
1458
|
-
}
|
|
1459
|
-
}
|
|
1723
|
+
const sendMap = this.buildSendMap(allRuleSends);
|
|
1460
1724
|
const uniqueEmails = [...new Set(emails.map((e) => e.toLowerCase().trim()))];
|
|
1461
|
-
const
|
|
1462
|
-
|
|
1463
|
-
async (email) => {
|
|
1464
|
-
const result = await this.config.adapters.findIdentifier(email);
|
|
1465
|
-
return result ? { email, ...result } : null;
|
|
1466
|
-
},
|
|
1467
|
-
IDENTIFIER_CHUNK_SIZE
|
|
1468
|
-
);
|
|
1469
|
-
const identifierMap = /* @__PURE__ */ new Map();
|
|
1470
|
-
for (const result of identifierResults) {
|
|
1471
|
-
if (result) {
|
|
1472
|
-
identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
const preheadersQ = template.preheaders || [];
|
|
1476
|
-
const compiledVariants = this.templateRenderer.compileBatchVariants(
|
|
1477
|
-
template.subjects,
|
|
1478
|
-
template.bodies,
|
|
1479
|
-
template.textBody,
|
|
1480
|
-
preheadersQ
|
|
1481
|
-
);
|
|
1725
|
+
const identifierMap = await this.resolveIdentifiers(uniqueEmails);
|
|
1726
|
+
const compiledVariants = this.compileTemplateVariants(template);
|
|
1482
1727
|
const ruleId = rule._id.toString();
|
|
1483
1728
|
const templateId = rule.templateId.toString();
|
|
1484
1729
|
let totalProcessed = 0;
|
|
1485
1730
|
for (let i = 0; i < users.length; i++) {
|
|
1486
1731
|
const user = users[i];
|
|
1487
|
-
if (runId
|
|
1488
|
-
const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
|
|
1489
|
-
const cancelled = await this.redis.exists(cancelKey);
|
|
1490
|
-
if (cancelled) break;
|
|
1491
|
-
}
|
|
1732
|
+
if (await this.checkCancelled(runId, i)) break;
|
|
1492
1733
|
try {
|
|
1493
1734
|
const userId = user._id?.toString();
|
|
1494
1735
|
const email = user.email;
|
|
1495
1736
|
if (!userId || !email) {
|
|
1496
1737
|
stats.skipped++;
|
|
1497
|
-
this.
|
|
1738
|
+
this.emitSendEvent(rule, email || "unknown", "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1498
1739
|
continue;
|
|
1499
1740
|
}
|
|
1500
|
-
const lastSend = sendMap.get(userId);
|
|
1501
|
-
if (lastSend) {
|
|
1502
|
-
if (rule.sendOnce && !rule.resendAfterDays) {
|
|
1503
|
-
stats.skipped++;
|
|
1504
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1505
|
-
continue;
|
|
1506
|
-
}
|
|
1507
|
-
if (rule.resendAfterDays) {
|
|
1508
|
-
const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
|
|
1509
|
-
if (daysSince < rule.resendAfterDays) {
|
|
1510
|
-
stats.skipped++;
|
|
1511
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "resend too soon" });
|
|
1512
|
-
continue;
|
|
1513
|
-
}
|
|
1514
|
-
} else {
|
|
1515
|
-
stats.skipped++;
|
|
1516
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped", accountId: "", templateId, runId: runId || "", subjectIndex: -1, bodyIndex: -1, failureReason: "send once" });
|
|
1517
|
-
continue;
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
1741
|
const identifier = identifierMap.get(email.toLowerCase().trim());
|
|
1521
1742
|
if (!identifier) {
|
|
1522
1743
|
stats.skipped++;
|
|
1523
|
-
this.
|
|
1744
|
+
this.emitSendEvent(rule, email, "invalid", templateId, runId || "", { failureReason: "invalid email" });
|
|
1524
1745
|
continue;
|
|
1525
1746
|
}
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
const renderedHtml = compiledVariants.bodyFns[bi](templateData);
|
|
1539
|
-
const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
|
|
1540
|
-
let finalHtml = renderedHtml;
|
|
1541
|
-
let finalText = renderedText;
|
|
1542
|
-
let finalSubject = renderedSubject;
|
|
1543
|
-
let pi;
|
|
1544
|
-
if (compiledVariants.preheaderFns && compiledVariants.preheaderFns.length > 0) {
|
|
1545
|
-
pi = Math.floor(Math.random() * compiledVariants.preheaderFns.length);
|
|
1546
|
-
const renderedPreheader = compiledVariants.preheaderFns[pi](templateData);
|
|
1547
|
-
if (renderedPreheader) {
|
|
1548
|
-
const preheaderHtml = `<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">${renderedPreheader}</div>`;
|
|
1549
|
-
finalHtml = finalHtml.replace(/(<body[^>]*>)/i, `$1${preheaderHtml}`);
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
if (this.config.hooks?.beforeSend) {
|
|
1553
|
-
try {
|
|
1554
|
-
const modified = await this.config.hooks.beforeSend({
|
|
1555
|
-
htmlBody: finalHtml,
|
|
1556
|
-
textBody: finalText,
|
|
1557
|
-
subject: finalSubject,
|
|
1558
|
-
account: {
|
|
1559
|
-
id: agentSelection.accountId,
|
|
1560
|
-
email: agentSelection.email,
|
|
1561
|
-
metadata: agentSelection.metadata
|
|
1562
|
-
},
|
|
1563
|
-
user: {
|
|
1564
|
-
id: String(userId),
|
|
1565
|
-
email,
|
|
1566
|
-
name: String(user.name || user.firstName || "")
|
|
1567
|
-
},
|
|
1568
|
-
context: {
|
|
1569
|
-
ruleId,
|
|
1570
|
-
templateId,
|
|
1571
|
-
runId: runId || ""
|
|
1572
|
-
}
|
|
1573
|
-
});
|
|
1574
|
-
finalHtml = modified.htmlBody;
|
|
1575
|
-
finalText = modified.textBody;
|
|
1576
|
-
finalSubject = modified.subject;
|
|
1577
|
-
} catch (hookErr) {
|
|
1578
|
-
this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
|
|
1579
|
-
stats.errorCount++;
|
|
1580
|
-
this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error", accountId: agentSelection.accountId, templateId, runId: runId || "", subjectIndex: si, bodyIndex: bi, failureReason: hookErr.message });
|
|
1581
|
-
continue;
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
await this.config.adapters.sendEmail({
|
|
1585
|
-
identifierId: identifier.id,
|
|
1586
|
-
contactId: identifier.contactId,
|
|
1587
|
-
accountId: agentSelection.accountId,
|
|
1588
|
-
subject: finalSubject,
|
|
1589
|
-
htmlBody: finalHtml,
|
|
1590
|
-
textBody: finalText,
|
|
1747
|
+
const result = await this.processSingleUser({
|
|
1748
|
+
rule,
|
|
1749
|
+
email,
|
|
1750
|
+
userKey: userId,
|
|
1751
|
+
identifier,
|
|
1752
|
+
user,
|
|
1753
|
+
sendMap,
|
|
1754
|
+
throttleMap,
|
|
1755
|
+
throttleConfig,
|
|
1756
|
+
template,
|
|
1757
|
+
compiledVariants,
|
|
1758
|
+
templateId,
|
|
1591
1759
|
ruleId,
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
});
|
|
1595
|
-
await this.EmailRuleSend.logSend(
|
|
1596
|
-
ruleId,
|
|
1597
|
-
userId,
|
|
1598
|
-
identifier.id,
|
|
1599
|
-
void 0,
|
|
1600
|
-
{ status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi, preheaderIndex: pi }
|
|
1601
|
-
);
|
|
1602
|
-
const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1603
|
-
throttleMap.set(userId, {
|
|
1604
|
-
today: current.today + 1,
|
|
1605
|
-
thisWeek: current.thisWeek + 1,
|
|
1606
|
-
lastSentDate: /* @__PURE__ */ new Date()
|
|
1760
|
+
runId,
|
|
1761
|
+
stats
|
|
1607
1762
|
});
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
await this.updateRunSendProgress(runId, stats);
|
|
1613
|
-
}
|
|
1614
|
-
if (i < users.length - 1) {
|
|
1615
|
-
const delayMs = this.config.options?.delayBetweenSendsMs || 0;
|
|
1616
|
-
const jitterMs = this.config.options?.jitterMs || 0;
|
|
1617
|
-
if (delayMs > 0 || jitterMs > 0) {
|
|
1618
|
-
const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
|
|
1619
|
-
if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
|
|
1620
|
-
}
|
|
1763
|
+
if (result === "sent") {
|
|
1764
|
+
totalProcessed++;
|
|
1765
|
+
if (runId && totalProcessed % 10 === 0) await this.updateRunSendProgress(runId, stats);
|
|
1766
|
+
await this.applySendDelay(i >= users.length - 1);
|
|
1621
1767
|
}
|
|
1622
1768
|
} catch (err) {
|
|
1623
1769
|
stats.errorCount++;
|
|
1624
|
-
this.
|
|
1770
|
+
this.emitSendEvent(rule, user.email || "unknown", "error", templateId, runId || "", { failureReason: err.message || "unknown error" });
|
|
1625
1771
|
this.logger.error(`Rule "${rule.name}" failed for user ${user._id?.toString()}`, { error: err });
|
|
1626
1772
|
}
|
|
1627
1773
|
}
|
|
1628
|
-
await this.
|
|
1629
|
-
$set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
|
|
1630
|
-
$inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
|
|
1631
|
-
});
|
|
1632
|
-
this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats, templateId, runId: runId || "" });
|
|
1774
|
+
await this.finalizeRuleStats(rule, stats, ruleId, templateId, runId);
|
|
1633
1775
|
return stats;
|
|
1634
1776
|
}
|
|
1635
1777
|
checkThrottle(rule, userId, email, throttleMap, config, stats, templateId, runId) {
|
|
@@ -1641,19 +1783,19 @@ var RuleRunnerService = class {
|
|
|
1641
1783
|
const userThrottle = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
|
|
1642
1784
|
if (userThrottle.today >= dailyLimit) {
|
|
1643
1785
|
stats.skippedByThrottle++;
|
|
1644
|
-
this.
|
|
1786
|
+
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "daily throttle limit" });
|
|
1645
1787
|
return false;
|
|
1646
1788
|
}
|
|
1647
1789
|
if (userThrottle.thisWeek >= weeklyLimit) {
|
|
1648
1790
|
stats.skippedByThrottle++;
|
|
1649
|
-
this.
|
|
1791
|
+
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "weekly throttle limit" });
|
|
1650
1792
|
return false;
|
|
1651
1793
|
}
|
|
1652
1794
|
if (userThrottle.lastSentDate) {
|
|
1653
1795
|
const daysSinceLastSend = (Date.now() - userThrottle.lastSentDate.getTime()) / MS_PER_DAY;
|
|
1654
1796
|
if (daysSinceLastSend < minGap) {
|
|
1655
1797
|
stats.skippedByThrottle++;
|
|
1656
|
-
this.
|
|
1798
|
+
this.emitSendEvent(rule, email, "throttled", templateId || "", runId || "", { failureReason: "min gap days" });
|
|
1657
1799
|
return false;
|
|
1658
1800
|
}
|
|
1659
1801
|
}
|
|
@@ -1760,7 +1902,7 @@ var RuleRunnerService = class {
|
|
|
1760
1902
|
this.updateRunProgress(runId, { status: "failed" }).catch(() => {
|
|
1761
1903
|
});
|
|
1762
1904
|
});
|
|
1763
|
-
return { runId };
|
|
1905
|
+
return { runId, started: true };
|
|
1764
1906
|
}
|
|
1765
1907
|
buildThrottleMap(recentSends) {
|
|
1766
1908
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -1781,467 +1923,342 @@ var RuleRunnerService = class {
|
|
|
1781
1923
|
return map;
|
|
1782
1924
|
}
|
|
1783
1925
|
};
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
}
|
|
1926
|
+
|
|
1927
|
+
// src/utils/controller.ts
|
|
1787
1928
|
function getErrorStatus(message) {
|
|
1788
|
-
if (message.includes("already exists") || message.includes("validation failed")) return 400;
|
|
1789
1929
|
if (message.includes("not found")) return 404;
|
|
1930
|
+
if (message.includes("already exists") || message.includes("validation failed") || message.includes("mismatch") || message.includes("Cannot activate") || message.includes("Cannot delete")) return 400;
|
|
1790
1931
|
return 500;
|
|
1791
1932
|
}
|
|
1933
|
+
function isValidValue(allowed, value) {
|
|
1934
|
+
return typeof value === "string" && allowed.includes(value);
|
|
1935
|
+
}
|
|
1936
|
+
function asyncHandler(handler) {
|
|
1937
|
+
return (req, res) => {
|
|
1938
|
+
handler(req, res).catch((error) => {
|
|
1939
|
+
if (res.headersSent) return;
|
|
1940
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1941
|
+
const status = getErrorStatus(message);
|
|
1942
|
+
res.status(status).json({ success: false, error: message });
|
|
1943
|
+
});
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// src/controllers/template.controller.ts
|
|
1792
1948
|
function createTemplateController(templateService, options) {
|
|
1793
1949
|
const platformValues = options?.platforms;
|
|
1794
1950
|
const validCategories = options?.categories || Object.values(TEMPLATE_CATEGORY);
|
|
1795
1951
|
const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1952
|
+
const list = asyncHandler(async (req, res) => {
|
|
1953
|
+
const { category, audience, platform, isActive, page, limit } = req.query;
|
|
1954
|
+
const { templates, total } = await templateService.list({
|
|
1955
|
+
category,
|
|
1956
|
+
audience,
|
|
1957
|
+
platform,
|
|
1958
|
+
isActive: isActive !== void 0 ? isActive === "true" : void 0,
|
|
1959
|
+
...calculatePagination(parseInt(String(page), 10) || void 0, parseInt(String(limit), 10) || void 0)
|
|
1960
|
+
});
|
|
1961
|
+
res.json({ success: true, data: { templates, total } });
|
|
1962
|
+
});
|
|
1963
|
+
const getById = asyncHandler(async (req, res) => {
|
|
1964
|
+
const template = await templateService.getById(getParam(req, "id"));
|
|
1965
|
+
if (!template) {
|
|
1966
|
+
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1809
1967
|
}
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
}
|
|
1817
|
-
res.json({ success: true, data: { template } });
|
|
1818
|
-
} catch (error) {
|
|
1819
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1820
|
-
res.status(500).json({ success: false, error: message });
|
|
1968
|
+
res.json({ success: true, data: { template } });
|
|
1969
|
+
});
|
|
1970
|
+
const create = asyncHandler(async (req, res) => {
|
|
1971
|
+
const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
|
|
1972
|
+
if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
|
|
1973
|
+
return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
|
|
1821
1974
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
try {
|
|
1825
|
-
const { name, subjects, bodies, category, audience, platform, preheaders } = req.body;
|
|
1826
|
-
if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
|
|
1827
|
-
return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
|
|
1828
|
-
}
|
|
1829
|
-
if (!isValidValue(validCategories, category)) {
|
|
1830
|
-
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
1831
|
-
}
|
|
1832
|
-
if (!isValidValue(validAudiences, audience)) {
|
|
1833
|
-
return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
|
|
1834
|
-
}
|
|
1835
|
-
if (platformValues && !platformValues.includes(platform)) {
|
|
1836
|
-
return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1837
|
-
}
|
|
1838
|
-
const template = await templateService.create(req.body);
|
|
1839
|
-
res.status(201).json({ success: true, data: { template } });
|
|
1840
|
-
} catch (error) {
|
|
1841
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1842
|
-
res.status(getErrorStatus(message)).json({ success: false, error: message });
|
|
1975
|
+
if (!isValidValue(validCategories, category)) {
|
|
1976
|
+
return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
|
|
1843
1977
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
try {
|
|
1847
|
-
const template = await templateService.update(getParam(req, "id"), req.body);
|
|
1848
|
-
if (!template) {
|
|
1849
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1850
|
-
}
|
|
1851
|
-
res.json({ success: true, data: { template } });
|
|
1852
|
-
} catch (error) {
|
|
1853
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1854
|
-
res.status(getErrorStatus(message)).json({ success: false, error: message });
|
|
1978
|
+
if (!isValidValue(validAudiences, audience)) {
|
|
1979
|
+
return res.status(400).json({ success: false, error: `Invalid audience. Must be one of: ${validAudiences.join(", ")}` });
|
|
1855
1980
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
try {
|
|
1859
|
-
const deleted = await templateService.delete(getParam(req, "id"));
|
|
1860
|
-
if (!deleted) {
|
|
1861
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1862
|
-
}
|
|
1863
|
-
res.json({ success: true });
|
|
1864
|
-
} catch (error) {
|
|
1865
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1866
|
-
res.status(500).json({ success: false, error: message });
|
|
1981
|
+
if (platformValues && !platformValues.includes(platform)) {
|
|
1982
|
+
return res.status(400).json({ success: false, error: `Invalid platform. Must be one of: ${platformValues.join(", ")}` });
|
|
1867
1983
|
}
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
}
|
|
1875
|
-
res.json({ success: true, data: { template } });
|
|
1876
|
-
} catch (error) {
|
|
1877
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1878
|
-
res.status(500).json({ success: false, error: message });
|
|
1984
|
+
const template = await templateService.create(req.body);
|
|
1985
|
+
res.status(201).json({ success: true, data: { template } });
|
|
1986
|
+
});
|
|
1987
|
+
const update = asyncHandler(async (req, res) => {
|
|
1988
|
+
const template = await templateService.update(getParam(req, "id"), req.body);
|
|
1989
|
+
if (!template) {
|
|
1990
|
+
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1879
1991
|
}
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1887
|
-
}
|
|
1888
|
-
res.json({ success: true, data: result });
|
|
1889
|
-
} catch (error) {
|
|
1890
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1891
|
-
res.status(500).json({ success: false, error: message });
|
|
1992
|
+
res.json({ success: true, data: { template } });
|
|
1993
|
+
});
|
|
1994
|
+
const remove = asyncHandler(async (req, res) => {
|
|
1995
|
+
const deleted = await templateService.delete(getParam(req, "id"));
|
|
1996
|
+
if (!deleted) {
|
|
1997
|
+
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1892
1998
|
}
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
}
|
|
1900
|
-
const result = await templateService.previewRaw(subject, body, sampleData || {}, variables, textBody);
|
|
1901
|
-
res.json({ success: true, data: result });
|
|
1902
|
-
} catch (error) {
|
|
1903
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1904
|
-
res.status(500).json({ success: false, error: message });
|
|
1999
|
+
res.json({ success: true });
|
|
2000
|
+
});
|
|
2001
|
+
const toggleActive = asyncHandler(async (req, res) => {
|
|
2002
|
+
const template = await templateService.toggleActive(getParam(req, "id"));
|
|
2003
|
+
if (!template) {
|
|
2004
|
+
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1905
2005
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
}
|
|
1913
|
-
const result = await templateService.validate(templateBody);
|
|
1914
|
-
res.json({ success: true, data: result });
|
|
1915
|
-
} catch (error) {
|
|
1916
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1917
|
-
res.status(500).json({ success: false, error: message });
|
|
2006
|
+
res.json({ success: true, data: { template } });
|
|
2007
|
+
});
|
|
2008
|
+
const preview = asyncHandler(async (req, res) => {
|
|
2009
|
+
const { sampleData } = req.body;
|
|
2010
|
+
const result = await templateService.preview(getParam(req, "id"), sampleData || {});
|
|
2011
|
+
if (!result) {
|
|
2012
|
+
return res.status(404).json({ success: false, error: "Template not found" });
|
|
1918
2013
|
}
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
res.json({ success:
|
|
1925
|
-
} catch (error) {
|
|
1926
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1927
|
-
res.status(error instanceof Error && error.message === "Template not found" ? 404 : 500).json({ success: false, error: message });
|
|
2014
|
+
res.json({ success: true, data: result });
|
|
2015
|
+
});
|
|
2016
|
+
const previewRaw = asyncHandler(async (req, res) => {
|
|
2017
|
+
const { subject, body, textBody, sampleData, variables } = req.body;
|
|
2018
|
+
if (!subject || !body) {
|
|
2019
|
+
return res.status(400).json({ success: false, error: "subject and body are required" });
|
|
1928
2020
|
}
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
}
|
|
1936
|
-
const result = await templateService.sendTestEmail(getParam(req, "id"), testEmail, sampleData || {});
|
|
1937
|
-
if (!result.success) {
|
|
1938
|
-
return res.status(400).json({ success: false, error: result.error });
|
|
1939
|
-
}
|
|
1940
|
-
res.json({ success: true });
|
|
1941
|
-
} catch (error) {
|
|
1942
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1943
|
-
res.status(500).json({ success: false, error: message });
|
|
2021
|
+
const result = await templateService.previewRaw(subject, body, sampleData || {}, variables, textBody);
|
|
2022
|
+
res.json({ success: true, data: result });
|
|
2023
|
+
});
|
|
2024
|
+
const validate = asyncHandler(async (req, res) => {
|
|
2025
|
+
const { body: templateBody } = req.body;
|
|
2026
|
+
if (!templateBody) {
|
|
2027
|
+
return res.status(400).json({ success: false, error: "body is required" });
|
|
1944
2028
|
}
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
res.json({ success:
|
|
1957
|
-
} catch (error) {
|
|
1958
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1959
|
-
res.status(500).json({ success: false, error: message });
|
|
2029
|
+
const result = await templateService.validate(templateBody);
|
|
2030
|
+
res.json({ success: true, data: result });
|
|
2031
|
+
});
|
|
2032
|
+
const clone = asyncHandler(async (req, res) => {
|
|
2033
|
+
const { name } = req.body;
|
|
2034
|
+
const result = await templateService.clone(getParam(req, "id"), name);
|
|
2035
|
+
res.json({ success: true, data: result });
|
|
2036
|
+
});
|
|
2037
|
+
const sendTestEmail = asyncHandler(async (req, res) => {
|
|
2038
|
+
const { testEmail, sampleData } = req.body;
|
|
2039
|
+
if (!testEmail) {
|
|
2040
|
+
return res.status(400).json({ success: false, error: "testEmail is required" });
|
|
1960
2041
|
}
|
|
1961
|
-
|
|
2042
|
+
const result = await templateService.sendTestEmail(getParam(req, "id"), testEmail, sampleData || {});
|
|
2043
|
+
if (!result.success) {
|
|
2044
|
+
return res.status(400).json({ success: false, error: result.error });
|
|
2045
|
+
}
|
|
2046
|
+
res.json({ success: true });
|
|
2047
|
+
});
|
|
2048
|
+
const previewWithRecipient = asyncHandler(async (req, res) => {
|
|
2049
|
+
const { recipientData } = req.body;
|
|
2050
|
+
if (!recipientData || typeof recipientData !== "object") {
|
|
2051
|
+
return res.status(400).json({ success: false, error: "recipientData object is required" });
|
|
2052
|
+
}
|
|
2053
|
+
const result = await templateService.previewWithRecipient(getParam(req, "id"), recipientData);
|
|
2054
|
+
if (!result) {
|
|
2055
|
+
return res.status(404).json({ success: false, error: "Template not found" });
|
|
2056
|
+
}
|
|
2057
|
+
res.json({ success: true, data: result });
|
|
2058
|
+
});
|
|
1962
2059
|
return { list, getById, create, update, remove, toggleActive, preview, previewRaw, validate, sendTestEmail, clone, previewWithRecipient };
|
|
1963
2060
|
}
|
|
1964
|
-
function isValidValue2(allowed, value) {
|
|
1965
|
-
return typeof value === "string" && allowed.includes(value);
|
|
1966
|
-
}
|
|
1967
|
-
function getErrorStatus2(message) {
|
|
1968
|
-
if (message.includes("not found")) return 404;
|
|
1969
|
-
if (message.includes("mismatch") || message.includes("validation failed") || message.includes("Cannot activate")) return 400;
|
|
1970
|
-
return 500;
|
|
1971
|
-
}
|
|
1972
2061
|
function createRuleController(ruleService, options) {
|
|
1973
2062
|
const platformValues = options?.platforms;
|
|
1974
2063
|
const validAudiences = options?.audiences || Object.values(TEMPLATE_AUDIENCE);
|
|
1975
2064
|
const validEmailTypes = Object.values(EMAIL_TYPE);
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
2065
|
+
const list = asyncHandler(async (req, res) => {
|
|
2066
|
+
const { page, limit } = calculatePagination(
|
|
2067
|
+
parseInt(String(req.query.page), 10) || void 0,
|
|
2068
|
+
parseInt(String(req.query.limit), 10) || void 0
|
|
2069
|
+
);
|
|
2070
|
+
const { rules, total } = await ruleService.list({ page, limit });
|
|
2071
|
+
res.json({ success: true, data: { rules, total } });
|
|
2072
|
+
});
|
|
2073
|
+
const getById = asyncHandler(async (req, res) => {
|
|
2074
|
+
const rule = await ruleService.getById(getParam(req, "id"));
|
|
2075
|
+
if (!rule) {
|
|
2076
|
+
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
1983
2077
|
}
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
}
|
|
1991
|
-
res.json({ success: true, data: { rule } });
|
|
1992
|
-
} catch (error) {
|
|
1993
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1994
|
-
res.status(500).json({ success: false, error: message });
|
|
2078
|
+
res.json({ success: true, data: { rule } });
|
|
2079
|
+
});
|
|
2080
|
+
const create = asyncHandler(async (req, res) => {
|
|
2081
|
+
const { name, target, templateId } = req.body;
|
|
2082
|
+
if (!name || !target || !templateId) {
|
|
2083
|
+
return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
|
|
1995
2084
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2085
|
+
const mode = target.mode || "query";
|
|
2086
|
+
if (mode === "list") {
|
|
2087
|
+
if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
|
|
2088
|
+
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2089
|
+
}
|
|
2090
|
+
} else {
|
|
2091
|
+
if (!target.role || !isValidValue(validAudiences, target.role)) {
|
|
2092
|
+
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2093
|
+
}
|
|
2094
|
+
if (platformValues && !platformValues.includes(target.platform)) {
|
|
2095
|
+
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2096
|
+
}
|
|
2097
|
+
if (!Array.isArray(target.conditions)) {
|
|
2098
|
+
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2002
2099
|
}
|
|
2100
|
+
}
|
|
2101
|
+
if (req.body.emailType && !isValidValue(validEmailTypes, req.body.emailType)) {
|
|
2102
|
+
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2103
|
+
}
|
|
2104
|
+
const rule = await ruleService.create(req.body);
|
|
2105
|
+
res.status(201).json({ success: true, data: { rule } });
|
|
2106
|
+
});
|
|
2107
|
+
const update = asyncHandler(async (req, res) => {
|
|
2108
|
+
const { target, emailType } = req.body;
|
|
2109
|
+
if (target) {
|
|
2003
2110
|
const mode = target.mode || "query";
|
|
2004
2111
|
if (mode === "list") {
|
|
2005
|
-
if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
|
|
2112
|
+
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
2006
2113
|
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2007
2114
|
}
|
|
2008
2115
|
} else {
|
|
2009
|
-
if (
|
|
2116
|
+
if (target.role && !isValidValue(validAudiences, target.role)) {
|
|
2010
2117
|
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2011
2118
|
}
|
|
2012
|
-
if (platformValues && !platformValues.includes(target.platform)) {
|
|
2119
|
+
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
2013
2120
|
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2014
2121
|
}
|
|
2015
|
-
if (!Array.isArray(target.conditions)) {
|
|
2122
|
+
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
2016
2123
|
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2017
2124
|
}
|
|
2018
2125
|
}
|
|
2019
|
-
if (req.body.emailType && !isValidValue2(validEmailTypes, req.body.emailType)) {
|
|
2020
|
-
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2021
|
-
}
|
|
2022
|
-
const rule = await ruleService.create(req.body);
|
|
2023
|
-
res.status(201).json({ success: true, data: { rule } });
|
|
2024
|
-
} catch (error) {
|
|
2025
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2026
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
async function update(req, res) {
|
|
2030
|
-
try {
|
|
2031
|
-
const { target, emailType } = req.body;
|
|
2032
|
-
if (target) {
|
|
2033
|
-
const mode = target.mode || "query";
|
|
2034
|
-
if (mode === "list") {
|
|
2035
|
-
if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
|
|
2036
|
-
return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
|
|
2037
|
-
}
|
|
2038
|
-
} else {
|
|
2039
|
-
if (target.role && !isValidValue2(validAudiences, target.role)) {
|
|
2040
|
-
return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
|
|
2041
|
-
}
|
|
2042
|
-
if (target.platform && platformValues && !platformValues.includes(target.platform)) {
|
|
2043
|
-
return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
|
|
2044
|
-
}
|
|
2045
|
-
if (target.conditions && !Array.isArray(target.conditions)) {
|
|
2046
|
-
return res.status(400).json({ success: false, error: "target.conditions must be an array" });
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
if (emailType && !isValidValue2(validEmailTypes, emailType)) {
|
|
2051
|
-
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2052
|
-
}
|
|
2053
|
-
const rule = await ruleService.update(getParam(req, "id"), req.body);
|
|
2054
|
-
if (!rule) {
|
|
2055
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2056
|
-
}
|
|
2057
|
-
res.json({ success: true, data: { rule } });
|
|
2058
|
-
} catch (error) {
|
|
2059
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2060
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2061
|
-
}
|
|
2062
|
-
}
|
|
2063
|
-
async function remove(req, res) {
|
|
2064
|
-
try {
|
|
2065
|
-
const result = await ruleService.delete(getParam(req, "id"));
|
|
2066
|
-
if (!result.deleted && !result.disabled) {
|
|
2067
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2068
|
-
}
|
|
2069
|
-
res.json({ success: true, data: result });
|
|
2070
|
-
} catch (error) {
|
|
2071
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2072
|
-
res.status(500).json({ success: false, error: message });
|
|
2073
2126
|
}
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
try {
|
|
2077
|
-
const rule = await ruleService.toggleActive(getParam(req, "id"));
|
|
2078
|
-
if (!rule) {
|
|
2079
|
-
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2080
|
-
}
|
|
2081
|
-
res.json({ success: true, data: { rule } });
|
|
2082
|
-
} catch (error) {
|
|
2083
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2084
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2127
|
+
if (emailType && !isValidValue(validEmailTypes, emailType)) {
|
|
2128
|
+
return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
|
|
2085
2129
|
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
const result = await ruleService.dryRun(getParam(req, "id"));
|
|
2090
|
-
res.json({ success: true, data: result });
|
|
2091
|
-
} catch (error) {
|
|
2092
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2093
|
-
res.status(getErrorStatus2(message)).json({ success: false, error: message });
|
|
2130
|
+
const rule = await ruleService.update(getParam(req, "id"), req.body);
|
|
2131
|
+
if (!rule) {
|
|
2132
|
+
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2094
2133
|
}
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
res.json({ success:
|
|
2101
|
-
} catch (error) {
|
|
2102
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2103
|
-
res.status(error instanceof Error && error.message === "Rule not found" ? 404 : 500).json({ success: false, error: message });
|
|
2134
|
+
res.json({ success: true, data: { rule } });
|
|
2135
|
+
});
|
|
2136
|
+
const remove = asyncHandler(async (req, res) => {
|
|
2137
|
+
const result = await ruleService.delete(getParam(req, "id"));
|
|
2138
|
+
if (!result.deleted && !result.disabled) {
|
|
2139
|
+
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2104
2140
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
res.json({ success: true, data: { logs } });
|
|
2112
|
-
} catch (error) {
|
|
2113
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2114
|
-
res.status(500).json({ success: false, error: message });
|
|
2141
|
+
res.json({ success: true, data: result });
|
|
2142
|
+
});
|
|
2143
|
+
const toggleActive = asyncHandler(async (req, res) => {
|
|
2144
|
+
const rule = await ruleService.toggleActive(getParam(req, "id"));
|
|
2145
|
+
if (!rule) {
|
|
2146
|
+
return res.status(404).json({ success: false, error: "Rule not found" });
|
|
2115
2147
|
}
|
|
2116
|
-
|
|
2148
|
+
res.json({ success: true, data: { rule } });
|
|
2149
|
+
});
|
|
2150
|
+
const dryRun = asyncHandler(async (req, res) => {
|
|
2151
|
+
const result = await ruleService.dryRun(getParam(req, "id"));
|
|
2152
|
+
res.json({ success: true, data: result });
|
|
2153
|
+
});
|
|
2154
|
+
const clone = asyncHandler(async (req, res) => {
|
|
2155
|
+
const { name } = req.body;
|
|
2156
|
+
const result = await ruleService.clone(getParam(req, "id"), name);
|
|
2157
|
+
res.json({ success: true, data: result });
|
|
2158
|
+
});
|
|
2159
|
+
const runHistory = asyncHandler(async (req, res) => {
|
|
2160
|
+
const { page, limit } = calculatePagination(
|
|
2161
|
+
parseInt(String(req.query.page), 10) || void 0,
|
|
2162
|
+
parseInt(String(req.query.limit), 10) || 20
|
|
2163
|
+
);
|
|
2164
|
+
const from = req.query.from ? String(req.query.from) : void 0;
|
|
2165
|
+
const to = req.query.to ? String(req.query.to) : void 0;
|
|
2166
|
+
const logs = await ruleService.getRunHistory(limit, { page, from, to });
|
|
2167
|
+
const total = await ruleService.getRunHistoryCount({ from, to });
|
|
2168
|
+
res.json({ success: true, data: { logs, total } });
|
|
2169
|
+
});
|
|
2117
2170
|
return { list, getById, create, update, remove, toggleActive, dryRun, runHistory, clone };
|
|
2118
2171
|
}
|
|
2119
2172
|
function createRunnerController(runnerService, EmailRuleRunLog, logger) {
|
|
2120
|
-
|
|
2173
|
+
const triggerManualRun = asyncHandler(async (_req, res) => {
|
|
2121
2174
|
const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
|
|
2122
2175
|
res.json({ success: true, data: { message: "Rule run triggered", runId } });
|
|
2123
|
-
}
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
async function getStatusByRunId(req, res) {
|
|
2134
|
-
try {
|
|
2135
|
-
const status = await runnerService.getStatus(getParam(req, "runId"));
|
|
2136
|
-
if (!status) {
|
|
2137
|
-
res.status(404).json({ success: false, error: "Run not found" });
|
|
2138
|
-
return;
|
|
2139
|
-
}
|
|
2140
|
-
res.json({ success: true, data: status });
|
|
2141
|
-
} catch (error) {
|
|
2142
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2143
|
-
res.status(500).json({ success: false, error: message });
|
|
2176
|
+
});
|
|
2177
|
+
const getLatestRun = asyncHandler(async (_req, res) => {
|
|
2178
|
+
const latestRun = await EmailRuleRunLog.findOne().sort({ runAt: -1 });
|
|
2179
|
+
res.json({ success: true, data: { latestRun } });
|
|
2180
|
+
});
|
|
2181
|
+
const getStatusByRunId = asyncHandler(async (req, res) => {
|
|
2182
|
+
const status = await runnerService.getStatus(getParam(req, "runId"));
|
|
2183
|
+
if (!status) {
|
|
2184
|
+
res.status(404).json({ success: false, error: "Run not found" });
|
|
2185
|
+
return;
|
|
2144
2186
|
}
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
}
|
|
2153
|
-
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
2154
|
-
} catch (error) {
|
|
2155
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2156
|
-
res.status(500).json({ success: false, error: message });
|
|
2187
|
+
res.json({ success: true, data: status });
|
|
2188
|
+
});
|
|
2189
|
+
const cancelRun = asyncHandler(async (req, res) => {
|
|
2190
|
+
const result = await runnerService.cancel(getParam(req, "runId"));
|
|
2191
|
+
if (!result.ok) {
|
|
2192
|
+
res.status(404).json({ success: false, error: "Run not found" });
|
|
2193
|
+
return;
|
|
2157
2194
|
}
|
|
2158
|
-
|
|
2195
|
+
res.json({ success: true, data: { message: "Cancel requested" } });
|
|
2196
|
+
});
|
|
2159
2197
|
return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
|
|
2160
2198
|
}
|
|
2161
2199
|
|
|
2162
2200
|
// src/controllers/settings.controller.ts
|
|
2163
2201
|
function createSettingsController(EmailThrottleConfig) {
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
try {
|
|
2175
|
-
const { maxPerUserPerDay, maxPerUserPerWeek, minGapDays } = req.body;
|
|
2176
|
-
const updates = {};
|
|
2177
|
-
if (maxPerUserPerDay !== void 0) {
|
|
2178
|
-
if (!Number.isInteger(maxPerUserPerDay) || maxPerUserPerDay < 1) {
|
|
2179
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerDay must be a positive integer" });
|
|
2180
|
-
}
|
|
2181
|
-
updates.maxPerUserPerDay = maxPerUserPerDay;
|
|
2182
|
-
}
|
|
2183
|
-
if (maxPerUserPerWeek !== void 0) {
|
|
2184
|
-
if (!Number.isInteger(maxPerUserPerWeek) || maxPerUserPerWeek < 1) {
|
|
2185
|
-
return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be a positive integer" });
|
|
2186
|
-
}
|
|
2187
|
-
updates.maxPerUserPerWeek = maxPerUserPerWeek;
|
|
2188
|
-
}
|
|
2189
|
-
if (minGapDays !== void 0) {
|
|
2190
|
-
if (!Number.isInteger(minGapDays) || minGapDays < 0) {
|
|
2191
|
-
return res.status(400).json({ success: false, error: "minGapDays must be a non-negative integer" });
|
|
2192
|
-
}
|
|
2193
|
-
updates.minGapDays = minGapDays;
|
|
2202
|
+
const getThrottleConfig = asyncHandler(async (_req, res) => {
|
|
2203
|
+
const config = await EmailThrottleConfig.getConfig();
|
|
2204
|
+
res.json({ success: true, data: { config } });
|
|
2205
|
+
});
|
|
2206
|
+
const updateThrottleConfig = asyncHandler(async (req, res) => {
|
|
2207
|
+
const { maxPerUserPerDay, maxPerUserPerWeek, minGapDays } = req.body;
|
|
2208
|
+
const updates = {};
|
|
2209
|
+
if (maxPerUserPerDay !== void 0) {
|
|
2210
|
+
if (!Number.isInteger(maxPerUserPerDay) || maxPerUserPerDay < 1) {
|
|
2211
|
+
return res.status(400).json({ success: false, error: "maxPerUserPerDay must be a positive integer" });
|
|
2194
2212
|
}
|
|
2195
|
-
|
|
2196
|
-
|
|
2213
|
+
updates.maxPerUserPerDay = maxPerUserPerDay;
|
|
2214
|
+
}
|
|
2215
|
+
if (maxPerUserPerWeek !== void 0) {
|
|
2216
|
+
if (!Number.isInteger(maxPerUserPerWeek) || maxPerUserPerWeek < 1) {
|
|
2217
|
+
return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be a positive integer" });
|
|
2197
2218
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
if (
|
|
2202
|
-
return res.status(400).json({ success: false, error: "
|
|
2219
|
+
updates.maxPerUserPerWeek = maxPerUserPerWeek;
|
|
2220
|
+
}
|
|
2221
|
+
if (minGapDays !== void 0) {
|
|
2222
|
+
if (!Number.isInteger(minGapDays) || minGapDays < 0) {
|
|
2223
|
+
return res.status(400).json({ success: false, error: "minGapDays must be a non-negative integer" });
|
|
2203
2224
|
}
|
|
2204
|
-
|
|
2205
|
-
config._id,
|
|
2206
|
-
{ $set: updates },
|
|
2207
|
-
{ new: true }
|
|
2208
|
-
);
|
|
2209
|
-
res.json({ success: true, data: { config: updated } });
|
|
2210
|
-
} catch (error) {
|
|
2211
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2212
|
-
res.status(500).json({ success: false, error: message });
|
|
2225
|
+
updates.minGapDays = minGapDays;
|
|
2213
2226
|
}
|
|
2214
|
-
|
|
2227
|
+
if (Object.keys(updates).length === 0) {
|
|
2228
|
+
return res.status(400).json({ success: false, error: "No valid fields to update" });
|
|
2229
|
+
}
|
|
2230
|
+
const config = await EmailThrottleConfig.getConfig();
|
|
2231
|
+
const finalDaily = updates.maxPerUserPerDay ?? config.maxPerUserPerDay;
|
|
2232
|
+
const finalWeekly = updates.maxPerUserPerWeek ?? config.maxPerUserPerWeek;
|
|
2233
|
+
if (finalWeekly < finalDaily) {
|
|
2234
|
+
return res.status(400).json({ success: false, error: "maxPerUserPerWeek must be >= maxPerUserPerDay" });
|
|
2235
|
+
}
|
|
2236
|
+
const updated = await EmailThrottleConfig.findByIdAndUpdate(
|
|
2237
|
+
config._id,
|
|
2238
|
+
{ $set: updates },
|
|
2239
|
+
{ new: true }
|
|
2240
|
+
);
|
|
2241
|
+
res.json({ success: true, data: { config: updated } });
|
|
2242
|
+
});
|
|
2215
2243
|
return { getThrottleConfig, updateThrottleConfig };
|
|
2216
2244
|
}
|
|
2217
2245
|
|
|
2218
2246
|
// src/controllers/send-log.controller.ts
|
|
2219
2247
|
function createSendLogController(EmailRuleSend) {
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
const skip = (pageNum - 1) * limitNum;
|
|
2235
|
-
const [sends, total] = await Promise.all([
|
|
2236
|
-
EmailRuleSend.find(filter).sort({ sentAt: -1 }).skip(skip).limit(limitNum).lean(),
|
|
2237
|
-
EmailRuleSend.countDocuments(filter)
|
|
2238
|
-
]);
|
|
2239
|
-
res.json({ success: true, data: { sends, total } });
|
|
2240
|
-
} catch (error) {
|
|
2241
|
-
const message = error instanceof Error ? error.message : "Failed to query send logs";
|
|
2242
|
-
res.status(500).json({ success: false, error: message });
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2248
|
+
const list = asyncHandler(async (req, res) => {
|
|
2249
|
+
const { ruleId, status, email, from, to, page, limit } = req.query;
|
|
2250
|
+
const filter = {};
|
|
2251
|
+
if (ruleId) filter.ruleId = ruleId;
|
|
2252
|
+
if (status) filter.status = status;
|
|
2253
|
+
if (email) filter.userId = { $regex: email, $options: "i" };
|
|
2254
|
+
Object.assign(filter, buildDateRangeFilter("sentAt", from, to));
|
|
2255
|
+
const pagination = calculatePagination(Number(page) || void 0, Number(limit) || 50, 200);
|
|
2256
|
+
const [sends, total] = await Promise.all([
|
|
2257
|
+
EmailRuleSend.find(filter).sort({ sentAt: -1 }).skip(pagination.skip).limit(pagination.limit).lean(),
|
|
2258
|
+
EmailRuleSend.countDocuments(filter)
|
|
2259
|
+
]);
|
|
2260
|
+
res.json({ success: true, data: { sends, total } });
|
|
2261
|
+
});
|
|
2245
2262
|
return { list };
|
|
2246
2263
|
}
|
|
2247
2264
|
|
|
@@ -2260,6 +2277,7 @@ function createRoutes(deps) {
|
|
|
2260
2277
|
const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
|
|
2261
2278
|
const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
|
|
2262
2279
|
const sendLogCtrl = createSendLogController(deps.EmailRuleSend);
|
|
2280
|
+
const collectionCtrl = createCollectionController(deps.collections || []);
|
|
2263
2281
|
const templateRouter = Router();
|
|
2264
2282
|
templateRouter.get("/", templateCtrl.list);
|
|
2265
2283
|
templateRouter.post("/", templateCtrl.create);
|
|
@@ -2290,10 +2308,14 @@ function createRoutes(deps) {
|
|
|
2290
2308
|
runnerRouter.get("/logs", ruleCtrl.runHistory);
|
|
2291
2309
|
const sendLogRouter = Router();
|
|
2292
2310
|
sendLogRouter.get("/", sendLogCtrl.list);
|
|
2311
|
+
const collectionRouter = Router();
|
|
2312
|
+
collectionRouter.get("/", collectionCtrl.list);
|
|
2313
|
+
collectionRouter.get("/:name/fields", collectionCtrl.getFields);
|
|
2293
2314
|
router.use("/templates", templateRouter);
|
|
2294
2315
|
router.use("/rules", ruleRouter);
|
|
2295
2316
|
router.use("/runner", runnerRouter);
|
|
2296
2317
|
router.use("/sends", sendLogRouter);
|
|
2318
|
+
router.use("/collections", collectionRouter);
|
|
2297
2319
|
router.get("/throttle", settingsCtrl.getThrottleConfig);
|
|
2298
2320
|
router.put("/throttle", settingsCtrl.updateThrottleConfig);
|
|
2299
2321
|
return router;
|
|
@@ -2309,6 +2331,19 @@ var configSchema = z.object({
|
|
|
2309
2331
|
findIdentifier: z.function(),
|
|
2310
2332
|
sendTestEmail: z.function().optional()
|
|
2311
2333
|
}),
|
|
2334
|
+
collections: z.array(z.object({
|
|
2335
|
+
name: z.string(),
|
|
2336
|
+
label: z.string().optional(),
|
|
2337
|
+
description: z.string().optional(),
|
|
2338
|
+
identifierField: z.string().optional(),
|
|
2339
|
+
fields: z.array(z.any()),
|
|
2340
|
+
joins: z.array(z.object({
|
|
2341
|
+
from: z.string(),
|
|
2342
|
+
localField: z.string(),
|
|
2343
|
+
foreignField: z.string(),
|
|
2344
|
+
as: z.string()
|
|
2345
|
+
})).optional()
|
|
2346
|
+
})).optional(),
|
|
2312
2347
|
platforms: z.array(z.string()).optional(),
|
|
2313
2348
|
audiences: z.array(z.string()).optional(),
|
|
2314
2349
|
categories: z.array(z.string()).optional(),
|
|
@@ -2385,7 +2420,10 @@ var SchedulerService = class {
|
|
|
2385
2420
|
}, { connection: connectionOpts, prefix: this.keyPrefix });
|
|
2386
2421
|
}
|
|
2387
2422
|
async stopWorker() {
|
|
2388
|
-
|
|
2423
|
+
if (this.worker) {
|
|
2424
|
+
await this.worker.close();
|
|
2425
|
+
this.worker = void 0;
|
|
2426
|
+
}
|
|
2389
2427
|
}
|
|
2390
2428
|
async getScheduledJobs() {
|
|
2391
2429
|
const jobs = await this.queue.getRepeatableJobs();
|
|
@@ -2422,7 +2460,7 @@ function createEmailRuleEngine(config) {
|
|
|
2422
2460
|
`${prefix}EmailThrottleConfig`,
|
|
2423
2461
|
createEmailThrottleConfigSchema(prefix)
|
|
2424
2462
|
);
|
|
2425
|
-
const templateService = new TemplateService(EmailTemplate, config);
|
|
2463
|
+
const templateService = new TemplateService(EmailTemplate, config, EmailRule);
|
|
2426
2464
|
const ruleService = new RuleService(EmailRule, EmailTemplate, EmailRuleRunLog, config);
|
|
2427
2465
|
const runnerService = new RuleRunnerService(
|
|
2428
2466
|
EmailRule,
|
|
@@ -2442,7 +2480,8 @@ function createEmailRuleEngine(config) {
|
|
|
2442
2480
|
platformValues: config.platforms,
|
|
2443
2481
|
categoryValues: config.categories,
|
|
2444
2482
|
audienceValues: config.audiences,
|
|
2445
|
-
logger: config.logger
|
|
2483
|
+
logger: config.logger,
|
|
2484
|
+
collections: config.collections || []
|
|
2446
2485
|
});
|
|
2447
2486
|
return {
|
|
2448
2487
|
routes,
|
|
@@ -2453,6 +2492,6 @@ function createEmailRuleEngine(config) {
|
|
|
2453
2492
|
};
|
|
2454
2493
|
}
|
|
2455
2494
|
|
|
2456
|
-
export { AlxEmailError, ConfigValidationError, DuplicateSlugError, EMAIL_SEND_STATUS, EMAIL_TYPE, LockAcquisitionError, RULE_OPERATOR, RUN_LOG_STATUS, RUN_TRIGGER, RuleNotFoundError, RuleRunnerService, RuleService, RuleTemplateIncompatibleError, SchedulerService, TARGET_MODE, TEMPLATE_AUDIENCE, TEMPLATE_CATEGORY, THROTTLE_WINDOW, TemplateNotFoundError, TemplateRenderService, TemplateService, TemplateSyntaxError, createEmailRuleEngine, createEmailRuleRunLogSchema, createEmailRuleSchema, createEmailRuleSendSchema, createEmailTemplateSchema, createEmailThrottleConfigSchema, validateConfig };
|
|
2495
|
+
export { AlxEmailError, ConfigValidationError, DuplicateSlugError, EMAIL_SEND_STATUS, EMAIL_TYPE, FIELD_TYPE, LockAcquisitionError, RULE_OPERATOR, RUN_LOG_STATUS, RUN_TRIGGER, RuleNotFoundError, RuleRunnerService, RuleService, RuleTemplateIncompatibleError, SchedulerService, TARGET_MODE, TEMPLATE_AUDIENCE, TEMPLATE_CATEGORY, THROTTLE_WINDOW, TYPE_OPERATORS, TemplateNotFoundError, TemplateRenderService, TemplateService, TemplateSyntaxError, createEmailRuleEngine, createEmailRuleRunLogSchema, createEmailRuleSchema, createEmailRuleSendSchema, createEmailTemplateSchema, createEmailThrottleConfigSchema, flattenFields, validateConditions, validateConfig };
|
|
2457
2496
|
//# sourceMappingURL=index.mjs.map
|
|
2458
2497
|
//# sourceMappingURL=index.mjs.map
|