@astralibx/email-rule-engine 3.0.2 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@ var Handlebars = require('handlebars');
5
5
  var mjml2html = require('mjml');
6
6
  var htmlToText = require('html-to-text');
7
7
  var core = require('@astralibx/core');
8
- var crypto = require('crypto');
8
+ var crypto2 = require('crypto');
9
9
  var express = require('express');
10
10
  var zod = require('zod');
11
11
 
@@ -13,7 +13,7 @@ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
13
 
14
14
  var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
15
15
  var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
16
- var crypto__default = /*#__PURE__*/_interopDefault(crypto);
16
+ var crypto2__default = /*#__PURE__*/_interopDefault(crypto2);
17
17
 
18
18
  // src/schemas/template.schema.ts
19
19
 
@@ -76,9 +76,9 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
76
76
  required: true,
77
77
  ...platformValues ? { enum: platformValues } : {}
78
78
  },
79
- subject: { type: String, required: true },
80
- body: { type: String, required: true },
81
79
  textBody: String,
80
+ subjects: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one subject is required"] },
81
+ bodies: { type: [{ type: String }], required: true, validate: [(v) => v.length >= 1, "At least one body is required"] },
82
82
  variables: [{ type: String }],
83
83
  version: { type: Number, default: 1 },
84
84
  isActive: { type: Boolean, default: true, index: true }
@@ -110,9 +110,9 @@ function createEmailTemplateSchema(platformValues, audienceValues, categoryValue
110
110
  category: input.category,
111
111
  audience: input.audience,
112
112
  platform: input.platform,
113
- subject: input.subject,
114
- body: input.body,
115
113
  textBody: input.textBody,
114
+ subjects: input.subjects,
115
+ bodies: input.bodies,
116
116
  variables: input.variables || [],
117
117
  version: 1,
118
118
  isActive: true
@@ -132,13 +132,14 @@ function createEmailRuleSchema(platformValues, audienceValues, collectionPrefix)
132
132
  value: { type: mongoose.Schema.Types.Mixed }
133
133
  }, { _id: false });
134
134
  const RuleTargetSchema = new mongoose.Schema({
135
- role: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE), required: true },
135
+ mode: { type: String, enum: ["query", "list"], required: true },
136
+ role: { type: String, enum: audienceValues || Object.values(TEMPLATE_AUDIENCE) },
136
137
  platform: {
137
138
  type: String,
138
- required: true,
139
139
  ...platformValues ? { enum: platformValues } : {}
140
140
  },
141
- conditions: [RuleConditionSchema]
141
+ conditions: [RuleConditionSchema],
142
+ identifiers: [{ type: String }]
142
143
  }, { _id: false });
143
144
  const RuleRunStatsSchema = new mongoose.Schema({
144
145
  matched: { type: Number, default: 0 },
@@ -215,6 +216,8 @@ function createEmailRuleSendSchema(collectionPrefix) {
215
216
  accountId: { type: String },
216
217
  senderName: { type: String },
217
218
  subject: { type: String },
219
+ subjectIndex: { type: Number },
220
+ bodyIndex: { type: Number },
218
221
  failureReason: { type: String }
219
222
  },
220
223
  {
@@ -267,7 +270,9 @@ function createEmailRuleRunLogSchema(collectionPrefix) {
267
270
  }, { _id: false });
268
271
  const schema = new mongoose.Schema(
269
272
  {
273
+ runId: { type: String, index: true },
270
274
  runAt: { type: Date, required: true, default: () => /* @__PURE__ */ new Date() },
275
+ status: { type: String, enum: ["completed", "cancelled", "failed"], default: "completed" },
271
276
  triggeredBy: { type: String, enum: Object.values(RUN_TRIGGER), required: true },
272
277
  duration: { type: Number, required: true },
273
278
  rulesProcessed: { type: Number, required: true },
@@ -437,6 +442,16 @@ var TemplateRenderService = class {
437
442
  const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
438
443
  return { subjectFn, bodyFn, textBodyFn };
439
444
  }
445
+ compileBatchVariants(subjects, bodies, textBody) {
446
+ const subjectFns = subjects.map((s) => Handlebars__default.default.compile(s, { strict: true }));
447
+ const bodyFns = bodies.map((b) => {
448
+ const mjmlSource = wrapInMjml(b);
449
+ const htmlWithHandlebars = compileMjml(mjmlSource);
450
+ return Handlebars__default.default.compile(htmlWithHandlebars, { strict: true });
451
+ });
452
+ const textBodyFn = textBody ? Handlebars__default.default.compile(textBody, { strict: true }) : void 0;
453
+ return { subjectFns, bodyFns, textBodyFn };
454
+ }
440
455
  renderFromCompiled(compiled, data) {
441
456
  const subject = compiled.subjectFn(data);
442
457
  const html = compiled.bodyFn(data);
@@ -446,6 +461,9 @@ var TemplateRenderService = class {
446
461
  renderPreview(subject, body, data, textBody) {
447
462
  return this.renderSingle(subject, body, data, textBody);
448
463
  }
464
+ htmlToText(html) {
465
+ return htmlToPlainText(html);
466
+ }
449
467
  extractVariables(template) {
450
468
  const regex = /\{\{(?!#|\/|!|>)([^}]+)\}\}/g;
451
469
  const variables = /* @__PURE__ */ new Set();
@@ -541,9 +559,9 @@ var UPDATEABLE_FIELDS = /* @__PURE__ */ new Set([
541
559
  "category",
542
560
  "audience",
543
561
  "platform",
544
- "subject",
545
- "body",
546
562
  "textBody",
563
+ "subjects",
564
+ "bodies",
547
565
  "variables",
548
566
  "isActive"
549
567
  ]);
@@ -576,35 +594,49 @@ var TemplateService = class {
576
594
  if (existing) {
577
595
  throw new DuplicateSlugError(slug);
578
596
  }
579
- const validation = this.renderService.validateTemplate(input.body);
580
- if (!validation.valid) {
581
- throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
597
+ const { subjects, bodies } = input;
598
+ if (subjects.length === 0) throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
599
+ if (bodies.length === 0) throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
600
+ for (const b of bodies) {
601
+ const validation = this.renderService.validateTemplate(b);
602
+ if (!validation.valid) {
603
+ throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
604
+ }
582
605
  }
583
- const variables = input.variables || this.renderService.extractVariables(
584
- `${input.subject} ${input.body} ${input.textBody || ""}`
585
- );
606
+ const allContent = [...subjects, ...bodies, input.textBody || ""].join(" ");
607
+ const variables = input.variables || this.renderService.extractVariables(allContent);
586
608
  return this.EmailTemplate.createTemplate({
587
609
  ...input,
588
610
  slug,
611
+ subjects,
612
+ bodies,
589
613
  variables
590
614
  });
591
615
  }
592
616
  async update(id, input) {
593
617
  const template = await this.EmailTemplate.findById(id);
594
618
  if (!template) return null;
595
- if (input.body) {
596
- const validation = this.renderService.validateTemplate(input.body);
597
- if (!validation.valid) {
598
- throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
619
+ if (input.subjects && input.subjects.length === 0) {
620
+ throw new TemplateSyntaxError("At least one subject is required", ["At least one subject is required"]);
621
+ }
622
+ if (input.bodies && input.bodies.length === 0) {
623
+ throw new TemplateSyntaxError("At least one body is required", ["At least one body is required"]);
624
+ }
625
+ const bodiesToValidate = input.bodies || null;
626
+ if (bodiesToValidate) {
627
+ for (const b of bodiesToValidate) {
628
+ const validation = this.renderService.validateTemplate(b);
629
+ if (!validation.valid) {
630
+ throw new TemplateSyntaxError(`Template validation failed: ${validation.errors.join("; ")}`, validation.errors);
631
+ }
599
632
  }
600
633
  }
601
- if (input.body || input.subject || input.textBody) {
602
- const subject = input.subject ?? template.subject;
603
- const body = input.body ?? template.body;
634
+ if (input.textBody || input.subjects || input.bodies) {
635
+ const subjects = input.subjects ?? template.subjects;
636
+ const bodies = input.bodies ?? template.bodies;
604
637
  const textBody = input.textBody ?? template.textBody;
605
- input.variables = this.renderService.extractVariables(
606
- `${subject} ${body} ${textBody || ""}`
607
- );
638
+ const allContent = [...subjects, ...bodies, textBody || ""].join(" ");
639
+ input.variables = this.renderService.extractVariables(allContent);
608
640
  }
609
641
  const setFields = {};
610
642
  for (const [key, value] of Object.entries(input)) {
@@ -613,7 +645,7 @@ var TemplateService = class {
613
645
  }
614
646
  }
615
647
  const update = { $set: setFields };
616
- if (input.body || input.subject || input.textBody) {
648
+ if (input.textBody || input.subjects || input.bodies) {
617
649
  update["$inc"] = { version: 1 };
618
650
  }
619
651
  return this.EmailTemplate.findByIdAndUpdate(
@@ -637,8 +669,8 @@ var TemplateService = class {
637
669
  const template = await this.EmailTemplate.findById(id);
638
670
  if (!template) return null;
639
671
  return this.renderService.renderPreview(
640
- template.subject,
641
- template.body,
672
+ template.subjects[0],
673
+ template.bodies[0],
642
674
  sampleData,
643
675
  template.textBody
644
676
  );
@@ -660,8 +692,8 @@ var TemplateService = class {
660
692
  return { success: false, error: "Template not found" };
661
693
  }
662
694
  const rendered = this.renderService.renderSingle(
663
- template.subject,
664
- template.body,
695
+ template.subjects[0],
696
+ template.bodies[0],
665
697
  sampleData,
666
698
  template.textBody
667
699
  );
@@ -680,6 +712,9 @@ var TemplateService = class {
680
712
  };
681
713
 
682
714
  // src/services/rule.service.ts
715
+ function isQueryTarget(target) {
716
+ return !target.mode || target.mode === "query";
717
+ }
683
718
  var UPDATEABLE_FIELDS2 = /* @__PURE__ */ new Set([
684
719
  "name",
685
720
  "description",
@@ -733,13 +768,22 @@ var RuleService = class {
733
768
  if (!template) {
734
769
  throw new TemplateNotFoundError(input.templateId);
735
770
  }
736
- const compatError = validateRuleTemplateCompat(
737
- input.target.role,
738
- input.target.platform,
739
- template
740
- );
741
- if (compatError) {
742
- throw new RuleTemplateIncompatibleError(compatError);
771
+ if (isQueryTarget(input.target)) {
772
+ if (!input.target.role || !input.target.platform) {
773
+ throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
774
+ }
775
+ const compatError = validateRuleTemplateCompat(
776
+ input.target.role,
777
+ input.target.platform,
778
+ template
779
+ );
780
+ if (compatError) {
781
+ throw new RuleTemplateIncompatibleError(compatError);
782
+ }
783
+ } else {
784
+ if (!input.target.identifiers || input.target.identifiers.length === 0) {
785
+ throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
786
+ }
743
787
  }
744
788
  return this.EmailRule.createRule(input);
745
789
  }
@@ -747,14 +791,25 @@ var RuleService = class {
747
791
  const rule = await this.EmailRule.findById(id);
748
792
  if (!rule) return null;
749
793
  const templateId = input.templateId ?? rule.templateId.toString();
750
- const targetRole = input.target?.role ?? rule.target.role;
751
- const targetPlatform = input.target?.platform ?? rule.target.platform;
752
- if (input.templateId || input.target) {
794
+ if (input.target) {
795
+ if (isQueryTarget(input.target)) {
796
+ if (!input.target.role || !input.target.platform) {
797
+ throw new RuleTemplateIncompatibleError("target.role and target.platform are required for query mode, validation failed");
798
+ }
799
+ } else {
800
+ if (!input.target.identifiers || input.target.identifiers.length === 0) {
801
+ throw new RuleTemplateIncompatibleError("target.identifiers must be a non-empty array for list mode, validation failed");
802
+ }
803
+ }
804
+ }
805
+ const effectiveTarget = input.target ?? rule.target;
806
+ if ((input.templateId || input.target) && isQueryTarget(effectiveTarget)) {
807
+ const qt = effectiveTarget;
753
808
  const template = await this.EmailTemplate.findById(templateId);
754
809
  if (!template) {
755
810
  throw new TemplateNotFoundError(templateId);
756
811
  }
757
- const compatError = validateRuleTemplateCompat(targetRole, targetPlatform, template);
812
+ const compatError = validateRuleTemplateCompat(qt.role, qt.platform, template);
758
813
  if (compatError) {
759
814
  throw new RuleTemplateIncompatibleError(compatError);
760
815
  }
@@ -803,6 +858,11 @@ var RuleService = class {
803
858
  if (!rule) {
804
859
  throw new RuleNotFoundError(id);
805
860
  }
861
+ const target = rule.target;
862
+ if (target.mode === "list") {
863
+ const identifiers = target.identifiers || [];
864
+ return { matchedCount: identifiers.length, ruleId: id };
865
+ }
806
866
  const users = await this.config.adapters.queryUsers(rule.target, 5e4);
807
867
  return { matchedCount: users.length, ruleId: id };
808
868
  }
@@ -819,7 +879,7 @@ var RedisLock = class {
819
879
  }
820
880
  lockValue = "";
821
881
  async acquire() {
822
- this.lockValue = crypto__default.default.randomUUID();
882
+ this.lockValue = crypto2__default.default.randomUUID();
823
883
  const result = await this.redis.set(this.lockKey, this.lockValue, "PX", this.ttlMs, "NX");
824
884
  return result === "OK";
825
885
  }
@@ -832,8 +892,6 @@ var RedisLock = class {
832
892
  }
833
893
  }
834
894
  };
835
-
836
- // src/services/rule-runner.service.ts
837
895
  var MS_PER_DAY = 864e5;
838
896
  var DEFAULT_LOCK_TTL_MS = 30 * 60 * 1e3;
839
897
  var IDENTIFIER_CHUNK_SIZE = 50;
@@ -862,11 +920,12 @@ var RuleRunnerService = class {
862
920
  this.EmailRuleRunLog = EmailRuleRunLog;
863
921
  this.EmailThrottleConfig = EmailThrottleConfig;
864
922
  this.config = config;
865
- const keyPrefix = config.redis.keyPrefix || "";
923
+ this.keyPrefix = config.redis.keyPrefix || "";
924
+ this.redis = config.redis.connection;
866
925
  const lockTTL = config.options?.lockTTLMs || DEFAULT_LOCK_TTL_MS;
867
926
  this.lock = new RedisLock(
868
- config.redis.connection,
869
- `${keyPrefix}email-rule-runner:lock`,
927
+ this.redis,
928
+ `${this.keyPrefix}email-rule-runner:lock`,
870
929
  lockTTL,
871
930
  config.logger
872
931
  );
@@ -875,7 +934,11 @@ var RuleRunnerService = class {
875
934
  templateRenderer = new TemplateRenderService();
876
935
  lock;
877
936
  logger;
878
- async runAllRules(triggeredBy = RUN_TRIGGER.Cron) {
937
+ redis;
938
+ keyPrefix;
939
+ async runAllRules(triggeredBy = RUN_TRIGGER.Cron, runId) {
940
+ if (!runId) runId = crypto2__default.default.randomUUID();
941
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
879
942
  if (this.config.options?.sendWindow) {
880
943
  const { startHour, endHour, timezone } = this.config.options.sendWindow;
881
944
  const now = /* @__PURE__ */ new Date();
@@ -883,30 +946,45 @@ var RuleRunnerService = class {
883
946
  const currentHour = parseInt(formatter.format(now), 10);
884
947
  if (currentHour < startHour || currentHour >= endHour) {
885
948
  this.logger.info("Outside send window, skipping run", { currentHour, startHour, endHour, timezone });
886
- return;
949
+ return { runId };
887
950
  }
888
951
  }
889
952
  const lockAcquired = await this.lock.acquire();
890
953
  if (!lockAcquired) {
891
954
  this.logger.warn("Rule runner already executing, skipping");
892
- return;
955
+ return { runId };
893
956
  }
894
957
  const runStartTime = Date.now();
958
+ await this.updateRunProgress(runId, {
959
+ runId,
960
+ status: "running",
961
+ currentRule: "",
962
+ progress: { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 },
963
+ startedAt,
964
+ elapsed: 0
965
+ });
966
+ let runStatus = "completed";
895
967
  try {
896
968
  const throttleConfig = await this.EmailThrottleConfig.getConfig();
897
969
  const activeRules = await this.EmailRule.findActive();
898
970
  this.config.hooks?.onRunStart?.({ rulesCount: activeRules.length, triggeredBy });
971
+ await this.updateRunProgress(runId, {
972
+ progress: { rulesTotal: activeRules.length, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 }
973
+ });
899
974
  if (activeRules.length === 0) {
900
975
  this.logger.info("No active rules to process");
901
976
  await this.EmailRuleRunLog.create({
977
+ runId,
902
978
  runAt: /* @__PURE__ */ new Date(),
903
979
  triggeredBy,
904
980
  duration: Date.now() - runStartTime,
905
981
  rulesProcessed: 0,
906
982
  totalStats: { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 },
907
- perRuleStats: []
983
+ perRuleStats: [],
984
+ status: "completed"
908
985
  });
909
- return;
986
+ await this.updateRunProgress(runId, { status: "completed", elapsed: Date.now() - runStartTime });
987
+ return { runId };
910
988
  }
911
989
  const templateIds = [...new Set(activeRules.map((r) => r.templateId.toString()))];
912
990
  const templates = await this.EmailTemplate.find({ _id: { $in: templateIds } }).lean();
@@ -919,13 +997,43 @@ var RuleRunnerService = class {
919
997
  }).lean();
920
998
  const throttleMap = this.buildThrottleMap(recentSends);
921
999
  const perRuleStats = [];
922
- for (const rule of activeRules) {
923
- const stats = await this.executeRule(rule, throttleMap, throttleConfig, templateMap);
1000
+ let totalSent = 0;
1001
+ let totalFailed = 0;
1002
+ let totalSkipped = 0;
1003
+ let totalInvalid = 0;
1004
+ for (let ri = 0; ri < activeRules.length; ri++) {
1005
+ const rule = activeRules[ri];
1006
+ const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1007
+ const cancelled = await this.redis.exists(cancelKey);
1008
+ if (cancelled) {
1009
+ runStatus = "cancelled";
1010
+ break;
1011
+ }
1012
+ await this.updateRunProgress(runId, {
1013
+ currentRule: rule.name,
1014
+ elapsed: Date.now() - runStartTime
1015
+ });
1016
+ const stats = await this.executeRule(rule, throttleMap, throttleConfig, templateMap, runId);
1017
+ totalSent += stats.sent;
1018
+ totalFailed += stats.errors;
1019
+ totalSkipped += stats.skipped + stats.skippedByThrottle;
1020
+ totalInvalid += stats.matched - stats.sent - stats.skipped - stats.skippedByThrottle - stats.errors;
924
1021
  perRuleStats.push({
925
1022
  ruleId: rule._id.toString(),
926
1023
  ruleName: rule.name,
927
1024
  ...stats
928
1025
  });
1026
+ await this.updateRunProgress(runId, {
1027
+ progress: {
1028
+ rulesTotal: activeRules.length,
1029
+ rulesCompleted: ri + 1,
1030
+ sent: totalSent,
1031
+ failed: totalFailed,
1032
+ skipped: totalSkipped,
1033
+ invalid: totalInvalid < 0 ? 0 : totalInvalid
1034
+ },
1035
+ elapsed: Date.now() - runStartTime
1036
+ });
929
1037
  }
930
1038
  const totalStats = perRuleStats.reduce(
931
1039
  (acc, s) => ({
@@ -938,13 +1046,16 @@ var RuleRunnerService = class {
938
1046
  { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 }
939
1047
  );
940
1048
  await this.EmailRuleRunLog.create({
1049
+ runId,
941
1050
  runAt: /* @__PURE__ */ new Date(),
942
1051
  triggeredBy,
943
1052
  duration: Date.now() - runStartTime,
944
1053
  rulesProcessed: activeRules.length,
945
1054
  totalStats,
946
- perRuleStats
1055
+ perRuleStats,
1056
+ status: runStatus
947
1057
  });
1058
+ await this.updateRunProgress(runId, { status: runStatus, currentRule: "", elapsed: Date.now() - runStartTime });
948
1059
  this.config.hooks?.onRunComplete?.({ duration: Date.now() - runStartTime, totalStats, perRuleStats });
949
1060
  this.logger.info("Rule run completed", {
950
1061
  triggeredBy,
@@ -953,11 +1064,16 @@ var RuleRunnerService = class {
953
1064
  totalSkipped: totalStats.skipped,
954
1065
  duration: Date.now() - runStartTime
955
1066
  });
1067
+ } catch (err) {
1068
+ runStatus = "failed";
1069
+ await this.updateRunProgress(runId, { status: "failed", elapsed: Date.now() - runStartTime });
1070
+ throw err;
956
1071
  } finally {
957
1072
  await this.lock.release();
958
1073
  }
1074
+ return { runId };
959
1075
  }
960
- async executeRule(rule, throttleMap, throttleConfig, templateMap) {
1076
+ async executeRule(rule, throttleMap, throttleConfig, templateMap, runId) {
961
1077
  const stats = { matched: 0, sent: 0, skipped: 0, skippedByThrottle: 0, errors: 0 };
962
1078
  const template = templateMap?.get(rule.templateId.toString()) ?? await this.EmailTemplate.findById(rule.templateId);
963
1079
  if (!template) {
@@ -965,6 +1081,182 @@ var RuleRunnerService = class {
965
1081
  stats.errors = 1;
966
1082
  return stats;
967
1083
  }
1084
+ const isListMode = rule.target?.mode === "list";
1085
+ if (isListMode) {
1086
+ return this.executeListMode(rule, template, throttleMap, throttleConfig, stats, runId);
1087
+ }
1088
+ return this.executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId);
1089
+ }
1090
+ async executeListMode(rule, template, throttleMap, throttleConfig, stats, runId) {
1091
+ const rawIdentifiers = rule.target.identifiers || [];
1092
+ const uniqueEmails = [...new Set(rawIdentifiers.map((e) => e.toLowerCase().trim()).filter(Boolean))];
1093
+ const limit = rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500;
1094
+ const emailsToProcess = uniqueEmails.slice(0, limit);
1095
+ stats.matched = emailsToProcess.length;
1096
+ const ruleId = rule._id.toString();
1097
+ const templateId = rule.templateId.toString();
1098
+ this.config.hooks?.onRuleStart?.({ ruleId, ruleName: rule.name, matchedCount: emailsToProcess.length });
1099
+ if (emailsToProcess.length === 0) return stats;
1100
+ const identifierResults = await processInChunks(
1101
+ emailsToProcess,
1102
+ async (email) => {
1103
+ const result = await this.config.adapters.findIdentifier(email);
1104
+ return result ? { email, ...result } : null;
1105
+ },
1106
+ IDENTIFIER_CHUNK_SIZE
1107
+ );
1108
+ const identifierMap = /* @__PURE__ */ new Map();
1109
+ for (const result of identifierResults) {
1110
+ if (result) {
1111
+ identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
1112
+ }
1113
+ }
1114
+ const validEmails = emailsToProcess.filter((e) => identifierMap.has(e));
1115
+ const identifierIds = validEmails.map((e) => identifierMap.get(e).id);
1116
+ const allRuleSends = await this.EmailRuleSend.find({ ruleId: rule._id, userId: { $in: identifierIds } }).sort({ sentAt: -1 }).lean();
1117
+ const sendMap = /* @__PURE__ */ new Map();
1118
+ for (const send of allRuleSends) {
1119
+ const uid = send.userId.toString();
1120
+ if (!sendMap.has(uid)) {
1121
+ sendMap.set(uid, send);
1122
+ }
1123
+ }
1124
+ const compiledVariants = this.templateRenderer.compileBatchVariants(
1125
+ template.subjects,
1126
+ template.bodies,
1127
+ template.textBody
1128
+ );
1129
+ let totalProcessed = 0;
1130
+ for (let i = 0; i < emailsToProcess.length; i++) {
1131
+ const email = emailsToProcess[i];
1132
+ if (runId && i % 10 === 0) {
1133
+ const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1134
+ const cancelled = await this.redis.exists(cancelKey);
1135
+ if (cancelled) break;
1136
+ }
1137
+ try {
1138
+ const identifier = identifierMap.get(email);
1139
+ if (!identifier) {
1140
+ stats.skipped++;
1141
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "invalid" });
1142
+ continue;
1143
+ }
1144
+ const dedupKey = identifier.id;
1145
+ const lastSend = sendMap.get(dedupKey);
1146
+ if (lastSend) {
1147
+ if (rule.sendOnce && !rule.resendAfterDays) {
1148
+ stats.skipped++;
1149
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
1150
+ continue;
1151
+ }
1152
+ if (rule.resendAfterDays) {
1153
+ const daysSince = (Date.now() - new Date(lastSend.sentAt).getTime()) / MS_PER_DAY;
1154
+ if (daysSince < rule.resendAfterDays) {
1155
+ stats.skipped++;
1156
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
1157
+ continue;
1158
+ }
1159
+ } else {
1160
+ stats.skipped++;
1161
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
1162
+ continue;
1163
+ }
1164
+ }
1165
+ if (!this.checkThrottle(rule, dedupKey, email, throttleMap, throttleConfig, stats)) continue;
1166
+ const agentSelection = await this.config.adapters.selectAgent(identifier.id, { ruleId, templateId });
1167
+ if (!agentSelection) {
1168
+ stats.skipped++;
1169
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "skipped" });
1170
+ continue;
1171
+ }
1172
+ const user = { _id: identifier.id, email };
1173
+ const templateData = this.config.adapters.resolveData(user);
1174
+ const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
1175
+ const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
1176
+ const renderedSubject = compiledVariants.subjectFns[si](templateData);
1177
+ const renderedHtml = compiledVariants.bodyFns[bi](templateData);
1178
+ const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
1179
+ let finalHtml = renderedHtml;
1180
+ let finalText = renderedText;
1181
+ let finalSubject = renderedSubject;
1182
+ if (this.config.hooks?.beforeSend) {
1183
+ try {
1184
+ const modified = await this.config.hooks.beforeSend({
1185
+ htmlBody: finalHtml,
1186
+ textBody: finalText,
1187
+ subject: finalSubject,
1188
+ account: {
1189
+ id: agentSelection.accountId,
1190
+ email: agentSelection.email,
1191
+ metadata: agentSelection.metadata
1192
+ },
1193
+ user: {
1194
+ id: dedupKey,
1195
+ email,
1196
+ name: ""
1197
+ }
1198
+ });
1199
+ finalHtml = modified.htmlBody;
1200
+ finalText = modified.textBody;
1201
+ finalSubject = modified.subject;
1202
+ } catch (hookErr) {
1203
+ this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
1204
+ stats.errors++;
1205
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
1206
+ continue;
1207
+ }
1208
+ }
1209
+ await this.config.adapters.sendEmail({
1210
+ identifierId: identifier.id,
1211
+ contactId: identifier.contactId,
1212
+ accountId: agentSelection.accountId,
1213
+ subject: finalSubject,
1214
+ htmlBody: finalHtml,
1215
+ textBody: finalText,
1216
+ ruleId,
1217
+ autoApprove: rule.autoApprove ?? true
1218
+ });
1219
+ await this.EmailRuleSend.logSend(
1220
+ ruleId,
1221
+ dedupKey,
1222
+ identifier.id,
1223
+ void 0,
1224
+ { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
1225
+ );
1226
+ const current = throttleMap.get(dedupKey) || { today: 0, thisWeek: 0, lastSentDate: null };
1227
+ throttleMap.set(dedupKey, {
1228
+ today: current.today + 1,
1229
+ thisWeek: current.thisWeek + 1,
1230
+ lastSentDate: /* @__PURE__ */ new Date()
1231
+ });
1232
+ stats.sent++;
1233
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent" });
1234
+ totalProcessed++;
1235
+ if (runId && totalProcessed % 10 === 0) {
1236
+ await this.updateRunSendProgress(runId, stats);
1237
+ }
1238
+ if (i < emailsToProcess.length - 1) {
1239
+ const delayMs = this.config.options?.delayBetweenSendsMs || 0;
1240
+ const jitterMs = this.config.options?.jitterMs || 0;
1241
+ if (delayMs > 0 || jitterMs > 0) {
1242
+ const totalDelay = delayMs + Math.floor(Math.random() * (jitterMs + 1));
1243
+ if (totalDelay > 0) await new Promise((resolve) => setTimeout(resolve, totalDelay));
1244
+ }
1245
+ }
1246
+ } catch (err) {
1247
+ stats.errors++;
1248
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
1249
+ this.logger.error(`Rule "${rule.name}" failed for identifier ${email}`, { error: err });
1250
+ }
1251
+ }
1252
+ await this.EmailRule.findByIdAndUpdate(rule._id, {
1253
+ $set: { lastRunAt: /* @__PURE__ */ new Date(), lastRunStats: stats },
1254
+ $inc: { totalSent: stats.sent, totalSkipped: stats.skipped }
1255
+ });
1256
+ this.config.hooks?.onRuleComplete?.({ ruleId, ruleName: rule.name, stats });
1257
+ return stats;
1258
+ }
1259
+ async executeQueryMode(rule, template, throttleMap, throttleConfig, stats, runId) {
968
1260
  let users;
969
1261
  try {
970
1262
  users = await this.config.adapters.queryUsers(rule.target, rule.maxPerRun || this.config.options?.defaultMaxPerRun || 500);
@@ -1001,15 +1293,21 @@ var RuleRunnerService = class {
1001
1293
  identifierMap.set(result.email, { id: result.id, contactId: result.contactId });
1002
1294
  }
1003
1295
  }
1004
- const compiled = this.templateRenderer.compileBatch(
1005
- template.subject,
1006
- template.body,
1296
+ const compiledVariants = this.templateRenderer.compileBatchVariants(
1297
+ template.subjects,
1298
+ template.bodies,
1007
1299
  template.textBody
1008
1300
  );
1009
1301
  const ruleId = rule._id.toString();
1010
1302
  const templateId = rule.templateId.toString();
1303
+ let totalProcessed = 0;
1011
1304
  for (let i = 0; i < users.length; i++) {
1012
1305
  const user = users[i];
1306
+ if (runId && i % 10 === 0) {
1307
+ const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1308
+ const cancelled = await this.redis.exists(cancelKey);
1309
+ if (cancelled) break;
1310
+ }
1013
1311
  try {
1014
1312
  const userId = user._id?.toString();
1015
1313
  const email = user.email;
@@ -1052,14 +1350,48 @@ var RuleRunnerService = class {
1052
1350
  continue;
1053
1351
  }
1054
1352
  const templateData = this.config.adapters.resolveData(user);
1055
- const rendered = this.templateRenderer.renderFromCompiled(compiled, templateData);
1353
+ const si = Math.floor(Math.random() * compiledVariants.subjectFns.length);
1354
+ const bi = Math.floor(Math.random() * compiledVariants.bodyFns.length);
1355
+ const renderedSubject = compiledVariants.subjectFns[si](templateData);
1356
+ const renderedHtml = compiledVariants.bodyFns[bi](templateData);
1357
+ const renderedText = compiledVariants.textBodyFn ? compiledVariants.textBodyFn(templateData) : this.templateRenderer.htmlToText(renderedHtml);
1358
+ let finalHtml = renderedHtml;
1359
+ let finalText = renderedText;
1360
+ let finalSubject = renderedSubject;
1361
+ if (this.config.hooks?.beforeSend) {
1362
+ try {
1363
+ const modified = await this.config.hooks.beforeSend({
1364
+ htmlBody: finalHtml,
1365
+ textBody: finalText,
1366
+ subject: finalSubject,
1367
+ account: {
1368
+ id: agentSelection.accountId,
1369
+ email: agentSelection.email,
1370
+ metadata: agentSelection.metadata
1371
+ },
1372
+ user: {
1373
+ id: String(userId),
1374
+ email,
1375
+ name: String(user.name || user.firstName || "")
1376
+ }
1377
+ });
1378
+ finalHtml = modified.htmlBody;
1379
+ finalText = modified.textBody;
1380
+ finalSubject = modified.subject;
1381
+ } catch (hookErr) {
1382
+ this.logger.error(`beforeSend hook failed for email ${email}: ${hookErr.message}`);
1383
+ stats.errors++;
1384
+ this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "error" });
1385
+ continue;
1386
+ }
1387
+ }
1056
1388
  await this.config.adapters.sendEmail({
1057
1389
  identifierId: identifier.id,
1058
1390
  contactId: identifier.contactId,
1059
1391
  accountId: agentSelection.accountId,
1060
- subject: rendered.subject,
1061
- htmlBody: rendered.html,
1062
- textBody: rendered.text,
1392
+ subject: finalSubject,
1393
+ htmlBody: finalHtml,
1394
+ textBody: finalText,
1063
1395
  ruleId,
1064
1396
  autoApprove: rule.autoApprove ?? true
1065
1397
  });
@@ -1068,7 +1400,7 @@ var RuleRunnerService = class {
1068
1400
  userId,
1069
1401
  identifier.id,
1070
1402
  void 0,
1071
- { status: "sent", accountId: agentSelection.accountId, subject: rendered.subject }
1403
+ { status: "sent", accountId: agentSelection.accountId, subject: finalSubject, subjectIndex: si, bodyIndex: bi }
1072
1404
  );
1073
1405
  const current = throttleMap.get(userId) || { today: 0, thisWeek: 0, lastSentDate: null };
1074
1406
  throttleMap.set(userId, {
@@ -1078,6 +1410,10 @@ var RuleRunnerService = class {
1078
1410
  });
1079
1411
  stats.sent++;
1080
1412
  this.config.hooks?.onSend?.({ ruleId, ruleName: rule.name, email, status: "sent" });
1413
+ totalProcessed++;
1414
+ if (runId && totalProcessed % 10 === 0) {
1415
+ await this.updateRunSendProgress(runId, stats);
1416
+ }
1081
1417
  if (i < users.length - 1) {
1082
1418
  const delayMs = this.config.options?.delayBetweenSendsMs || 0;
1083
1419
  const jitterMs = this.config.options?.jitterMs || 0;
@@ -1157,6 +1493,74 @@ var RuleRunnerService = class {
1157
1493
  todayStart.setHours(0, 0, 0, 0);
1158
1494
  return todayStart;
1159
1495
  }
1496
+ async updateRunProgress(runId, data) {
1497
+ const key = `${this.keyPrefix}run:${runId}:progress`;
1498
+ const flat = [];
1499
+ for (const [k, v] of Object.entries(data)) {
1500
+ if (typeof v === "object" && v !== null) {
1501
+ flat.push(k, JSON.stringify(v));
1502
+ } else {
1503
+ flat.push(k, String(v));
1504
+ }
1505
+ }
1506
+ if (flat.length > 0) {
1507
+ await this.redis.hset(key, ...flat);
1508
+ await this.redis.expire(key, 3600);
1509
+ }
1510
+ }
1511
+ async updateRunSendProgress(runId, stats) {
1512
+ const key = `${this.keyPrefix}run:${runId}:progress`;
1513
+ const existing = await this.redis.hget(key, "progress");
1514
+ let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
1515
+ if (existing) {
1516
+ try {
1517
+ progress = JSON.parse(existing);
1518
+ } catch {
1519
+ }
1520
+ }
1521
+ progress.sent = stats.sent;
1522
+ progress.failed = stats.errors;
1523
+ progress.skipped = stats.skipped + stats.skippedByThrottle;
1524
+ await this.redis.hset(key, "progress", JSON.stringify(progress));
1525
+ await this.redis.expire(key, 3600);
1526
+ }
1527
+ async getStatus(runId) {
1528
+ const key = `${this.keyPrefix}run:${runId}:progress`;
1529
+ const data = await this.redis.hgetall(key);
1530
+ if (!data || Object.keys(data).length === 0) return null;
1531
+ let progress = { rulesTotal: 0, rulesCompleted: 0, sent: 0, failed: 0, skipped: 0, invalid: 0 };
1532
+ if (data.progress) {
1533
+ try {
1534
+ progress = JSON.parse(data.progress);
1535
+ } catch {
1536
+ }
1537
+ }
1538
+ return {
1539
+ runId: data.runId || runId,
1540
+ status: data.status || "running",
1541
+ currentRule: data.currentRule || "",
1542
+ progress,
1543
+ startedAt: data.startedAt || "",
1544
+ elapsed: parseInt(data.elapsed || "0", 10)
1545
+ };
1546
+ }
1547
+ async cancel(runId) {
1548
+ const progressKey = `${this.keyPrefix}run:${runId}:progress`;
1549
+ const exists = await this.redis.exists(progressKey);
1550
+ if (!exists) return { ok: false };
1551
+ const cancelKey = `${this.keyPrefix}run:${runId}:cancel`;
1552
+ await this.redis.set(cancelKey, "1", "EX", 3600);
1553
+ return { ok: true };
1554
+ }
1555
+ trigger(triggeredBy) {
1556
+ const runId = crypto2__default.default.randomUUID();
1557
+ this.runAllRules(triggeredBy || RUN_TRIGGER.Manual, runId).catch((err) => {
1558
+ this.logger.error("Background rule run failed", { error: err, runId });
1559
+ this.updateRunProgress(runId, { status: "failed" }).catch(() => {
1560
+ });
1561
+ });
1562
+ return { runId };
1563
+ }
1160
1564
  buildThrottleMap(recentSends) {
1161
1565
  const map = /* @__PURE__ */ new Map();
1162
1566
  const todayStart = this.getTodayStart();
@@ -1225,9 +1629,9 @@ function createTemplateController(templateService, options) {
1225
1629
  }
1226
1630
  async function create(req, res) {
1227
1631
  try {
1228
- const { name, subject, body, category, audience, platform } = req.body;
1229
- if (!name || !subject || !body || !category || !audience || !platform) {
1230
- return res.status(400).json({ success: false, error: "name, subject, body, category, audience, and platform are required" });
1632
+ const { name, subjects, bodies, category, audience, platform } = req.body;
1633
+ if (!name || !subjects || subjects.length === 0 || !bodies || bodies.length === 0 || !category || !audience || !platform) {
1634
+ return res.status(400).json({ success: false, error: "name, subjects, bodies, category, audience, and platform are required" });
1231
1635
  }
1232
1636
  if (!isValidValue(validCategories, category)) {
1233
1637
  return res.status(400).json({ success: false, error: `Invalid category. Must be one of: ${validCategories.join(", ")}` });
@@ -1379,14 +1783,21 @@ function createRuleController(ruleService, options) {
1379
1783
  if (!name || !target || !templateId) {
1380
1784
  return res.status(400).json({ success: false, error: "name, target, and templateId are required" });
1381
1785
  }
1382
- if (!target.role || !isValidValue2(validAudiences, target.role)) {
1383
- return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
1384
- }
1385
- if (platformValues && !platformValues.includes(target.platform)) {
1386
- return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
1387
- }
1388
- if (!Array.isArray(target.conditions)) {
1389
- return res.status(400).json({ success: false, error: "target.conditions must be an array" });
1786
+ const mode = target.mode || "query";
1787
+ if (mode === "list") {
1788
+ if (!Array.isArray(target.identifiers) || target.identifiers.length === 0) {
1789
+ return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
1790
+ }
1791
+ } else {
1792
+ if (!target.role || !isValidValue2(validAudiences, target.role)) {
1793
+ return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
1794
+ }
1795
+ if (platformValues && !platformValues.includes(target.platform)) {
1796
+ return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
1797
+ }
1798
+ if (!Array.isArray(target.conditions)) {
1799
+ return res.status(400).json({ success: false, error: "target.conditions must be an array" });
1800
+ }
1390
1801
  }
1391
1802
  if (req.body.emailType && !isValidValue2(validEmailTypes, req.body.emailType)) {
1392
1803
  return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
@@ -1401,14 +1812,23 @@ function createRuleController(ruleService, options) {
1401
1812
  async function update(req, res) {
1402
1813
  try {
1403
1814
  const { target, emailType } = req.body;
1404
- if (target?.role && !isValidValue2(validAudiences, target.role)) {
1405
- return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
1406
- }
1407
- if (target?.platform && platformValues && !platformValues.includes(target.platform)) {
1408
- return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
1409
- }
1410
- if (target?.conditions && !Array.isArray(target.conditions)) {
1411
- return res.status(400).json({ success: false, error: "target.conditions must be an array" });
1815
+ if (target) {
1816
+ const mode = target.mode || "query";
1817
+ if (mode === "list") {
1818
+ if (target.identifiers && (!Array.isArray(target.identifiers) || target.identifiers.length === 0)) {
1819
+ return res.status(400).json({ success: false, error: "target.identifiers must be a non-empty array for list mode" });
1820
+ }
1821
+ } else {
1822
+ if (target.role && !isValidValue2(validAudiences, target.role)) {
1823
+ return res.status(400).json({ success: false, error: `Invalid target.role. Must be one of: ${validAudiences.join(", ")}` });
1824
+ }
1825
+ if (target.platform && platformValues && !platformValues.includes(target.platform)) {
1826
+ return res.status(400).json({ success: false, error: `Invalid target.platform. Must be one of: ${platformValues.join(", ")}` });
1827
+ }
1828
+ if (target.conditions && !Array.isArray(target.conditions)) {
1829
+ return res.status(400).json({ success: false, error: "target.conditions must be an array" });
1830
+ }
1831
+ }
1412
1832
  }
1413
1833
  if (emailType && !isValidValue2(validEmailTypes, emailType)) {
1414
1834
  return res.status(400).json({ success: false, error: `Invalid emailType. Must be one of: ${validEmailTypes.join(", ")}` });
@@ -1469,23 +1889,10 @@ function createRuleController(ruleService, options) {
1469
1889
  }
1470
1890
  return { list, getById, create, update, remove, toggleActive, dryRun, runHistory };
1471
1891
  }
1472
-
1473
- // src/controllers/runner.controller.ts
1474
- var defaultLogger2 = {
1475
- info: () => {
1476
- },
1477
- warn: () => {
1478
- },
1479
- error: () => {
1480
- }
1481
- };
1482
1892
  function createRunnerController(runnerService, EmailRuleRunLog, logger) {
1483
- const log = logger || defaultLogger2;
1484
1893
  async function triggerManualRun(_req, res) {
1485
- runnerService.runAllRules(RUN_TRIGGER.Manual).catch((err) => {
1486
- log.error("Manual rule run failed", { error: err });
1487
- });
1488
- res.json({ success: true, data: { message: "Rule run triggered" } });
1894
+ const { runId } = runnerService.trigger(RUN_TRIGGER.Manual);
1895
+ res.json({ success: true, data: { message: "Rule run triggered", runId } });
1489
1896
  }
1490
1897
  async function getLatestRun(_req, res) {
1491
1898
  try {
@@ -1496,7 +1903,33 @@ function createRunnerController(runnerService, EmailRuleRunLog, logger) {
1496
1903
  res.status(500).json({ success: false, error: message });
1497
1904
  }
1498
1905
  }
1499
- return { triggerManualRun, getLatestRun };
1906
+ async function getStatusByRunId(req, res) {
1907
+ try {
1908
+ const status = await runnerService.getStatus(getParam(req, "runId"));
1909
+ if (!status) {
1910
+ res.status(404).json({ success: false, error: "Run not found" });
1911
+ return;
1912
+ }
1913
+ res.json({ success: true, data: status });
1914
+ } catch (error) {
1915
+ const message = error instanceof Error ? error.message : "Unknown error";
1916
+ res.status(500).json({ success: false, error: message });
1917
+ }
1918
+ }
1919
+ async function cancelRun(req, res) {
1920
+ try {
1921
+ const result = await runnerService.cancel(getParam(req, "runId"));
1922
+ if (!result.ok) {
1923
+ res.status(404).json({ success: false, error: "Run not found" });
1924
+ return;
1925
+ }
1926
+ res.json({ success: true, data: { message: "Cancel requested" } });
1927
+ } catch (error) {
1928
+ const message = error instanceof Error ? error.message : "Unknown error";
1929
+ res.status(500).json({ success: false, error: message });
1930
+ }
1931
+ }
1932
+ return { triggerManualRun, getLatestRun, getStatusByRunId, cancelRun };
1500
1933
  }
1501
1934
 
1502
1935
  // src/controllers/settings.controller.ts
@@ -1567,7 +2000,7 @@ function createRoutes(deps) {
1567
2000
  platforms: deps.platformValues,
1568
2001
  audiences: deps.audienceValues
1569
2002
  });
1570
- const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog, deps.logger);
2003
+ const runnerCtrl = createRunnerController(deps.runnerService, deps.EmailRuleRunLog);
1571
2004
  const settingsCtrl = createSettingsController(deps.EmailThrottleConfig);
1572
2005
  const templateRouter = express.Router();
1573
2006
  templateRouter.get("/", templateCtrl.list);
@@ -1592,6 +2025,8 @@ function createRoutes(deps) {
1592
2025
  const runnerRouter = express.Router();
1593
2026
  runnerRouter.post("/", runnerCtrl.triggerManualRun);
1594
2027
  runnerRouter.get("/status", runnerCtrl.getLatestRun);
2028
+ runnerRouter.get("/status/:runId", runnerCtrl.getStatusByRunId);
2029
+ runnerRouter.post("/cancel/:runId", runnerCtrl.cancelRun);
1595
2030
  const settingsRouter = express.Router();
1596
2031
  settingsRouter.get("/throttle", settingsCtrl.getThrottleConfig);
1597
2032
  settingsRouter.patch("/throttle", settingsCtrl.updateThrottleConfig);