mailery 0.9.0 → 0.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.
@@ -113,6 +113,21 @@ type Predicate = {
113
113
  };
114
114
  } | {
115
115
  fieldExists: string;
116
+ }
117
+ /**
118
+ * Tests a property of the event that STARTED this run, so the answer is
119
+ * per-run rather than per-contact. Use when the gate depends on what the run
120
+ * is about (which account, plan, order) instead of a durable trait of the
121
+ * person: a contact-level tag is shared by every concurrent run and the last
122
+ * writer wins, which silently changes branching in runs already in flight.
123
+ */
124
+ | {
125
+ triggerPropertyEquals: {
126
+ key: string;
127
+ value: string | number | boolean | null;
128
+ };
129
+ } | {
130
+ triggerPropertyTruthy: string;
116
131
  } | {
117
132
  hasFiredEvent: string;
118
133
  sinceFlowStart?: boolean;
@@ -1427,6 +1442,7 @@ declare class Mailer {
1427
1442
  */
1428
1443
  abortFlow(flowSlug: string, externalId: string, opts?: {
1429
1444
  reason?: string;
1445
+ matchTriggerProperties?: Record<string, unknown>;
1430
1446
  }): Promise<{
1431
1447
  abortedRuns: number;
1432
1448
  cancelledSends: number;
@@ -113,6 +113,21 @@ type Predicate = {
113
113
  };
114
114
  } | {
115
115
  fieldExists: string;
116
+ }
117
+ /**
118
+ * Tests a property of the event that STARTED this run, so the answer is
119
+ * per-run rather than per-contact. Use when the gate depends on what the run
120
+ * is about (which account, plan, order) instead of a durable trait of the
121
+ * person: a contact-level tag is shared by every concurrent run and the last
122
+ * writer wins, which silently changes branching in runs already in flight.
123
+ */
124
+ | {
125
+ triggerPropertyEquals: {
126
+ key: string;
127
+ value: string | number | boolean | null;
128
+ };
129
+ } | {
130
+ triggerPropertyTruthy: string;
116
131
  } | {
117
132
  hasFiredEvent: string;
118
133
  sinceFlowStart?: boolean;
@@ -1427,6 +1442,7 @@ declare class Mailer {
1427
1442
  */
1428
1443
  abortFlow(flowSlug: string, externalId: string, opts?: {
1429
1444
  reason?: string;
1445
+ matchTriggerProperties?: Record<string, unknown>;
1430
1446
  }): Promise<{
1431
1447
  abortedRuns: number;
1432
1448
  cancelledSends: number;
package/dist/testing.cjs CHANGED
@@ -14208,7 +14208,25 @@ var tagInputSchema = zod.z.object({
14208
14208
  var abortFlowInputSchema = zod.z.object({
14209
14209
  flowSlug: slugSchema,
14210
14210
  externalId: externalIdSchema,
14211
- reason: zod.z.string().min(1).max(200).optional()
14211
+ reason: zod.z.string().min(1).max(200).optional(),
14212
+ /**
14213
+ * Restrict the abort to runs whose trigger event carried these properties —
14214
+ * e.g. `{ accountId }` to cancel one account's series while the same
14215
+ * contact's other accounts keep running. Omit to abort every active run for
14216
+ * the contact on this flow.
14217
+ *
14218
+ * Keys and values are both constrained because these go straight into a
14219
+ * Mongo query. Values are primitives only: an object value like
14220
+ * `{ $ne: null }` would reach the query as an OPERATOR and match every
14221
+ * scoped run, turning a one-account abort into abort-everything. Hosts
14222
+ * typically pass an id from a request body, so treat it as untrusted. The
14223
+ * key regex likewise blocks `$`-prefixed keys and dots (a dot would silently
14224
+ * extend the path and change match semantics).
14225
+ */
14226
+ matchTriggerProperties: zod.z.record(
14227
+ zod.z.string().regex(/^[A-Za-z0-9_]+$/),
14228
+ zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()])
14229
+ ).optional()
14212
14230
  });
14213
14231
  var abortAllFlowsInputSchema = abortFlowInputSchema.omit({ flowSlug: true });
14214
14232
  var sendOneOffInputSchema = zod.z.object({
@@ -14277,6 +14295,13 @@ var predicateSchema = zod.z.lazy(
14277
14295
  zod.z.object({ notHasTag: zod.z.string() }),
14278
14296
  zod.z.object({ fieldEquals: zod.z.object({ field: zod.z.string(), value: zod.z.unknown() }) }),
14279
14297
  zod.z.object({ fieldExists: zod.z.string() }),
14298
+ zod.z.object({
14299
+ triggerPropertyEquals: zod.z.object({
14300
+ key: zod.z.string().min(1),
14301
+ value: zod.z.union([zod.z.string(), zod.z.number(), zod.z.boolean(), zod.z.null()])
14302
+ })
14303
+ }),
14304
+ zod.z.object({ triggerPropertyTruthy: zod.z.string().min(1) }),
14280
14305
  zod.z.object({
14281
14306
  hasFiredEvent: zod.z.string(),
14282
14307
  sinceFlowStart: zod.z.boolean().optional(),
@@ -15367,6 +15392,12 @@ async function evaluatePredicate(predicate, ctx) {
15367
15392
  if ("fieldExists" in p) {
15368
15393
  return ctx.contact.fields[p.fieldExists] !== void 0;
15369
15394
  }
15395
+ if ("triggerPropertyEquals" in p) {
15396
+ return (ctx.run.triggerEvent?.properties ?? {})[p.triggerPropertyEquals.key] === p.triggerPropertyEquals.value;
15397
+ }
15398
+ if ("triggerPropertyTruthy" in p) {
15399
+ return Boolean((ctx.run.triggerEvent?.properties ?? {})[p.triggerPropertyTruthy]);
15400
+ }
15370
15401
  if ("subscriptionStatus" in p) {
15371
15402
  const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
15372
15403
  return sub?.status === p.subscriptionStatus;
@@ -16113,7 +16144,14 @@ async function handleFireEvent(run, step2, ctx) {
16113
16144
  await ctx.collections.events.insertOne({
16114
16145
  externalId: run.externalId,
16115
16146
  name: step2.eventName,
16116
- properties: step2.properties ?? {},
16147
+ // Inherit the triggering event's properties so a handoff carries the
16148
+ // context that identifies what the run is ABOUT (which account, order,
16149
+ // subscription, ...). A step's `properties` are static — authored once in
16150
+ // the flow definition — so without this a fired event can only ever say
16151
+ // "this contact", losing the scope the originating event supplied, and
16152
+ // the receiving flow has nothing to resolve variables against. Explicit
16153
+ // step.properties win on conflict.
16154
+ properties: { ...run.triggerEvent?.properties ?? {}, ...step2.properties ?? {} },
16117
16155
  dedupeKey,
16118
16156
  occurredAt: /* @__PURE__ */ new Date(),
16119
16157
  createdAt: /* @__PURE__ */ new Date()
@@ -17676,14 +17714,25 @@ var Mailer = class _Mailer {
17676
17714
  * same handler that processes the business event ("user upgraded").
17677
17715
  */
17678
17716
  async abortFlow(flowSlug, externalId, opts = {}) {
17679
- const parsed = abortFlowInputSchema.parse({ flowSlug, externalId, reason: opts.reason });
17717
+ const parsed = abortFlowInputSchema.parse({
17718
+ flowSlug,
17719
+ externalId,
17720
+ reason: opts.reason,
17721
+ matchTriggerProperties: opts.matchTriggerProperties
17722
+ });
17680
17723
  const flow = await this.collections.flows.findOne(
17681
17724
  { slug: parsed.flowSlug },
17682
17725
  { projection: { _id: 1 } }
17683
17726
  );
17684
17727
  if (!flow) throw new Error(`abortFlow: unknown flow slug "${parsed.flowSlug}"`);
17728
+ const triggerMatch = Object.fromEntries(
17729
+ Object.entries(parsed.matchTriggerProperties ?? {}).map(([k, v]) => [
17730
+ `triggerEvent.properties.${k}`,
17731
+ v
17732
+ ])
17733
+ );
17685
17734
  const result = await this.abortActiveRuns(
17686
- { externalId: parsed.externalId, flowId: flow._id },
17735
+ { externalId: parsed.externalId, flowId: flow._id, ...triggerMatch },
17687
17736
  parsed.reason ? `aborted_by_host:${parsed.reason}` : "aborted_by_host"
17688
17737
  );
17689
17738
  if (result.abortedRuns > 0 || result.cancelledSends > 0) {
@@ -17691,7 +17740,9 @@ var Mailer = class _Mailer {
17691
17740
  actor: "host",
17692
17741
  action: "flow.abort",
17693
17742
  resource: { collection: "mailer_flow_runs", slug: parsed.flowSlug },
17694
- diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17743
+ // Record the scope: without it a one-account abort and an abort-every-
17744
+ // run-for-this-contact are indistinguishable in the audit trail.
17745
+ diffSummary: `abortFlow slug=${parsed.flowSlug} externalId=${parsed.externalId} scope=${parsed.matchTriggerProperties ? JSON.stringify(parsed.matchTriggerProperties) : "all"} runs=${result.abortedRuns} sends=${result.cancelledSends}${parsed.reason ? ` reason=${parsed.reason}` : ""}`
17695
17746
  });
17696
17747
  }
17697
17748
  return result;