@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.
|
|
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.
|
|
44
|
-
"@hogsend/db": "^0.
|
|
45
|
-
"@hogsend/email": "^0.
|
|
46
|
-
"@hogsend/plugin-posthog": "^0.
|
|
47
|
-
"@hogsend/plugin-resend": "^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.
|
|
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
|
}
|
package/src/routes/admin/bulk.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
388
|
+
.where(and(...conditions))
|
|
371
389
|
.orderBy(desc(userEvents.occurredAt))
|
|
372
|
-
.limit(body.limit
|
|
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
|
-
|
|
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
|
-
|
|
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
|