@hogsend/engine 0.23.0 → 0.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hogsend/engine",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,14 +40,14 @@
40
40
  "svix": "^1.95.1",
41
41
  "winston": "^3.19.0",
42
42
  "zod": "^4.4.3",
43
- "@hogsend/core": "^0.23.0",
44
- "@hogsend/db": "^0.23.0",
45
- "@hogsend/email": "^0.23.0",
46
- "@hogsend/plugin-posthog": "^0.23.0",
47
- "@hogsend/plugin-resend": "^0.23.0"
43
+ "@hogsend/core": "^0.24.0",
44
+ "@hogsend/db": "^0.24.0",
45
+ "@hogsend/email": "^0.24.0",
46
+ "@hogsend/plugin-posthog": "^0.24.0",
47
+ "@hogsend/plugin-resend": "^0.24.0"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@hogsend/plugin-postmark": "^0.23.0"
50
+ "@hogsend/plugin-postmark": "^0.24.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.3",
@@ -21,6 +21,7 @@ import {
21
21
  import type {
22
22
  IfPast,
23
23
  JourneyContext,
24
+ RecentEvent,
24
25
  TimeOfDayBuilder,
25
26
  WaitForEventResult,
26
27
  Weekday,
@@ -451,6 +452,36 @@ export function createJourneyContext(
451
452
  count: total,
452
453
  };
453
454
  },
455
+
456
+ async events({
457
+ userId: targetUserId,
458
+ limit = 50,
459
+ within,
460
+ }): Promise<RecentEvent[]> {
461
+ const conditions = [eq(userEvents.userId, targetUserId)];
462
+ if (within) {
463
+ const since = new Date(Date.now() - durationToMs(within));
464
+ conditions.push(gte(userEvents.occurredAt, since));
465
+ }
466
+ const rows = await db
467
+ .select({
468
+ event: userEvents.event,
469
+ properties: userEvents.properties,
470
+ occurredAt: userEvents.occurredAt,
471
+ })
472
+ .from(userEvents)
473
+ .where(and(...conditions))
474
+ .orderBy(desc(userEvents.occurredAt))
475
+ .limit(limit);
476
+ return rows.map((row) => ({
477
+ event: row.event,
478
+ properties: row.properties ?? null,
479
+ occurredAt:
480
+ row.occurredAt instanceof Date
481
+ ? row.occurredAt.toISOString()
482
+ : String(row.occurredAt),
483
+ }));
484
+ },
454
485
  },
455
486
  };
456
487
  }
@@ -144,6 +144,12 @@ const replayRoute = createRoute({
144
144
  },
145
145
  description: "Replay results",
146
146
  },
147
+ 400: {
148
+ content: {
149
+ "application/json": { schema: errorSchema },
150
+ },
151
+ description: "No replay selection (eventIds or filter) provided",
152
+ },
147
153
  },
148
154
  });
149
155
 
@@ -362,14 +368,26 @@ export const bulkRouter = new OpenAPIHono<AppEnv>()
362
368
  conditions.push(lte(userEvents.occurredAt, new Date(body.filter.to)));
363
369
  }
364
370
 
365
- const where = conditions.length > 0 ? and(...conditions) : undefined;
371
+ // Refuse an unscoped replay. With no `eventIds` and no filter the WHERE
372
+ // would collapse to `undefined`, silently re-pushing the most-recent
373
+ // `limit` events back through the full ingestion pipeline (re-triggering
374
+ // journeys, re-evaluating exits). Require an explicit selection.
375
+ if (conditions.length === 0) {
376
+ return c.json(
377
+ {
378
+ error:
379
+ "Replay requires `eventIds` or at least one `filter` field (event, userId, from, to).",
380
+ },
381
+ 400,
382
+ );
383
+ }
366
384
 
367
385
  events = await db
368
386
  .select()
369
387
  .from(userEvents)
370
- .where(where)
388
+ .where(and(...conditions))
371
389
  .orderBy(desc(userEvents.occurredAt))
372
- .limit(body.limit ?? 100);
390
+ .limit(body.limit);
373
391
  }
374
392
 
375
393
  let replayed = 0;
@@ -147,6 +147,14 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>()
147
147
  ? {
148
148
  suppressed: body.suppressed,
149
149
  suppressedAt: body.suppressed ? new Date() : null,
150
+ // Un-suppressing clears the bounce slate. `bounceCount` only
151
+ // drives the auto-suppress threshold (the send-gate keys off
152
+ // `suppressed`/`unsubscribedAll`), so a leftover count would
153
+ // otherwise keep a bounced recipient pinned to the suppression
154
+ // list with no way to remove them.
155
+ ...(body.suppressed
156
+ ? {}
157
+ : { bounceCount: 0, lastBounceAt: null }),
150
158
  }
151
159
  : {}),
152
160
  ...(body.categories !== undefined
@@ -362,8 +362,19 @@ reportingRouter.get("/sends/export", async (c) => {
362
362
  );
363
363
  }
364
364
 
365
+ // The export intentionally returns all matching sends, but is hard-capped at
366
+ // MAX_EXPORT_ROWS. Signal when the result was truncated so a caller never
367
+ // mistakes a partial CSV for the complete history.
368
+ const truncated = rows.length >= MAX_EXPORT_ROWS;
369
+
365
370
  return c.body(lines.join("\n"), 200, {
366
371
  "Content-Type": "text/csv; charset=utf-8",
367
372
  "Content-Disposition": 'attachment; filename="email-sends.csv"',
373
+ ...(truncated
374
+ ? {
375
+ "X-Hogsend-Export-Truncated": "true",
376
+ "X-Hogsend-Export-Limit": String(MAX_EXPORT_ROWS),
377
+ }
378
+ : {}),
368
379
  });
369
380
  });
@@ -1,6 +1,6 @@
1
1
  import { emailPreferences } from "@hogsend/db";
2
2
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
3
- import { and, count, desc, eq, gt, type SQL } from "drizzle-orm";
3
+ import { and, count, desc, eq, gt, or, type SQL } from "drizzle-orm";
4
4
  import type { AppEnv } from "../../app.js";
5
5
  import { serializePrefs } from "../../lib/contacts.js";
6
6
 
@@ -8,6 +8,11 @@ import { serializePrefs } from "../../lib/contacts.js";
8
8
  // `complained` has no dedicated column — a complaint sets `suppressed` without
9
9
  // incrementing `bounceCount` (see mailer `handleComplaint`), so we identify it
10
10
  // as suppressed-but-not-bounced.
11
+ //
12
+ // IMPORTANT: the `email_preferences` table holds a row for (nearly) every
13
+ // contact, most of whom are NOT suppressed. The "All" view must therefore
14
+ // restrict to recipients suppressed in *some* way — returning `undefined`
15
+ // here would drop the WHERE clause entirely and list every contact.
11
16
  function typeFilter(
12
17
  type: "bounced" | "unsubscribed" | "complained" | undefined,
13
18
  ): SQL | undefined {
@@ -22,7 +27,12 @@ function typeFilter(
22
27
  eq(emailPreferences.bounceCount, 0),
23
28
  );
24
29
  default:
25
- return undefined;
30
+ // "All" = the union of every suppression reason.
31
+ return or(
32
+ eq(emailPreferences.suppressed, true),
33
+ eq(emailPreferences.unsubscribedAll, true),
34
+ gt(emailPreferences.bounceCount, 0),
35
+ );
26
36
  }
27
37
  }
28
38
 
@@ -135,7 +135,15 @@ export function registerWebhookSourceRoutes(
135
135
  const rawBody = await c.req.text();
136
136
  const headers = headersToRecord(c.req.raw.headers);
137
137
 
138
- let secret = env[auth.envKey as keyof typeof env] as string | undefined;
138
+ // Engine-declared secrets (presets) resolve from the validated env. A
139
+ // CONSUMER-defined webhook source may name an env var the engine schema
140
+ // doesn't declare (a BYO signature/match source), so fall back to the raw
141
+ // process.env value for it. Both auth branches below gate on truthiness, so
142
+ // an unset/blank secret stays fail-closed (signature → 401) and
143
+ // open-when-unconfigured (match) exactly as before.
144
+ let secret =
145
+ (env[auth.envKey as keyof typeof env] as string | undefined) ??
146
+ process.env[auth.envKey];
139
147
 
140
148
  // For the inbound PostHog source, fall back to the secret minted by
141
149
  // `hogsend connect` (kind="derived" store) when env has none — so an