@hogsend/engine 0.1.0 → 0.2.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 +6 -6
- package/src/app.ts +37 -0
- package/src/buckets/check-membership.ts +499 -0
- package/src/buckets/define-bucket.ts +29 -0
- package/src/buckets/registry-singleton.ts +21 -0
- package/src/buckets/registry.ts +62 -0
- package/src/container.ts +50 -2
- package/src/env.ts +10 -0
- package/src/index.ts +40 -1
- package/src/lib/auth.ts +8 -1
- package/src/lib/bucket-emit.ts +107 -0
- package/src/lib/bucket-posthog-sync.ts +63 -0
- package/src/lib/email-service-types.ts +6 -0
- package/src/lib/email.ts +2 -0
- package/src/lib/ingestion.ts +25 -0
- package/src/lib/mailer.ts +9 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/tracked.ts +4 -0
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/buckets.ts +464 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +10 -2
- package/src/routes/admin/metrics.ts +286 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
- package/src/worker.ts +35 -0
- package/src/workflows/bucket-backfill.ts +556 -0
- package/src/workflows/bucket-reconcile.ts +721 -0
package/src/container.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
2
|
import type { TimeZone } from "@hogsend/core";
|
|
3
|
-
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
3
|
+
import type { BucketRegistry, JourneyRegistry } from "@hogsend/core/registry";
|
|
4
4
|
import type { SendWindow } from "@hogsend/core/schedule";
|
|
5
5
|
import {
|
|
6
6
|
createDatabase,
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
type EmailProvider,
|
|
17
17
|
} from "@hogsend/plugin-resend";
|
|
18
18
|
import type { Resend } from "resend";
|
|
19
|
+
import type { DefinedBucket } from "./buckets/define-bucket.js";
|
|
20
|
+
import { buildBucketRegistry } from "./buckets/registry.js";
|
|
19
21
|
import { env } from "./env.js";
|
|
20
22
|
import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
|
|
21
23
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
@@ -49,8 +51,22 @@ export interface HogsendClient {
|
|
|
49
51
|
auth: Auth;
|
|
50
52
|
email: Resend;
|
|
51
53
|
emailService: EmailService;
|
|
54
|
+
/**
|
|
55
|
+
* The app's template registry (key → component + subject + category +
|
|
56
|
+
* optional preview/examples). Same object threaded into the engine mailer;
|
|
57
|
+
* exposed here so admin preview/catalog routes can enumerate keys and render
|
|
58
|
+
* templates without going through a send. Empty when no templates are wired.
|
|
59
|
+
*/
|
|
60
|
+
templates: TemplateRegistry;
|
|
52
61
|
analytics?: PostHogService;
|
|
53
62
|
registry: JourneyRegistry;
|
|
63
|
+
/**
|
|
64
|
+
* The bucket registry (id map + event/property inverted indexes for candidate
|
|
65
|
+
* narrowing). Built and installed as the process singleton at client build;
|
|
66
|
+
* the real-time ingest path reads it via `getBucketRegistrySingleton()`.
|
|
67
|
+
* Empty when no buckets are wired.
|
|
68
|
+
*/
|
|
69
|
+
bucketRegistry: BucketRegistry;
|
|
54
70
|
hatchet: HatchetClient;
|
|
55
71
|
/**
|
|
56
72
|
* The client repo's migration journal (`migrations/meta/_journal.json`),
|
|
@@ -70,6 +86,8 @@ export interface HogsendClient {
|
|
|
70
86
|
export interface HogsendClientOptions {
|
|
71
87
|
/** Journeys to register in the {@link JourneyRegistry}. Defaults to none. */
|
|
72
88
|
journeys?: DefinedJourney[];
|
|
89
|
+
/** Buckets to register in the {@link BucketRegistry}. Defaults to none. */
|
|
90
|
+
buckets?: DefinedBucket[];
|
|
73
91
|
/**
|
|
74
92
|
* Email is a first-class channel. Its config is grouped here rather than
|
|
75
93
|
* spread across top-level args — the engine owns the cohesive email pipeline
|
|
@@ -105,6 +123,11 @@ export interface HogsendClientOptions {
|
|
|
105
123
|
* `env.ENABLED_JOURNEYS`.
|
|
106
124
|
*/
|
|
107
125
|
enabledJourneys?: string;
|
|
126
|
+
/**
|
|
127
|
+
* Comma-separated ids (or `*`) controlling which buckets load. Defaults to
|
|
128
|
+
* `env.ENABLED_BUCKETS`.
|
|
129
|
+
*/
|
|
130
|
+
enabledBuckets?: string;
|
|
108
131
|
/**
|
|
109
132
|
* The client repo's migration journal for the `schema.client` health block.
|
|
110
133
|
* Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
|
|
@@ -154,6 +177,18 @@ export function createHogsendClient(
|
|
|
154
177
|
db,
|
|
155
178
|
secret: env.BETTER_AUTH_SECRET,
|
|
156
179
|
baseURL: env.BETTER_AUTH_URL,
|
|
180
|
+
// Always trust the public API origin; add any explicitly configured ones
|
|
181
|
+
// (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
|
|
182
|
+
trustedOrigins: Array.from(
|
|
183
|
+
new Set(
|
|
184
|
+
[
|
|
185
|
+
env.API_PUBLIC_URL,
|
|
186
|
+
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
|
|
187
|
+
]
|
|
188
|
+
.map((o) => o.trim())
|
|
189
|
+
.filter(Boolean),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
157
192
|
});
|
|
158
193
|
|
|
159
194
|
const email = createResendClient({ apiKey: env.RESEND_API_KEY });
|
|
@@ -163,6 +198,14 @@ export function createHogsendClient(
|
|
|
163
198
|
opts.enabledJourneys ?? env.ENABLED_JOURNEYS,
|
|
164
199
|
);
|
|
165
200
|
|
|
201
|
+
// Installs the bucket registry singleton in BOTH the API and worker processes
|
|
202
|
+
// (both call createHogsendClient); the real-time ingest path reads it via
|
|
203
|
+
// getBucketRegistrySingleton().
|
|
204
|
+
const bucketRegistry = buildBucketRegistry(
|
|
205
|
+
opts.buckets ?? [],
|
|
206
|
+
opts.enabledBuckets ?? env.ENABLED_BUCKETS,
|
|
207
|
+
);
|
|
208
|
+
|
|
166
209
|
const provider =
|
|
167
210
|
opts.email?.provider ??
|
|
168
211
|
createResendProvider({
|
|
@@ -183,12 +226,14 @@ export function createHogsendClient(
|
|
|
183
226
|
sendWindow: defaults.sendWindow,
|
|
184
227
|
});
|
|
185
228
|
|
|
229
|
+
const templates = opts.email?.templates ?? ({} as TemplateRegistry);
|
|
230
|
+
|
|
186
231
|
const emailService =
|
|
187
232
|
opts.overrides?.mailer ??
|
|
188
233
|
createTrackedMailer(
|
|
189
234
|
{
|
|
190
235
|
defaultFrom: env.RESEND_FROM_EMAIL,
|
|
191
|
-
templates
|
|
236
|
+
templates,
|
|
192
237
|
db,
|
|
193
238
|
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
194
239
|
bounceThreshold: 3,
|
|
@@ -207,6 +252,7 @@ export function createHogsendClient(
|
|
|
207
252
|
const analytics = opts.analytics ?? getPostHog();
|
|
208
253
|
|
|
209
254
|
logger.info(`Journey registry loaded: ${registry.count()} journeys`);
|
|
255
|
+
logger.info(`Bucket registry loaded: ${bucketRegistry.count()} buckets`);
|
|
210
256
|
|
|
211
257
|
return {
|
|
212
258
|
env,
|
|
@@ -216,8 +262,10 @@ export function createHogsendClient(
|
|
|
216
262
|
auth,
|
|
217
263
|
email,
|
|
218
264
|
emailService,
|
|
265
|
+
templates,
|
|
219
266
|
analytics,
|
|
220
267
|
registry,
|
|
268
|
+
bucketRegistry,
|
|
221
269
|
hatchet: opts.overrides?.hatchet ?? hatchet,
|
|
222
270
|
clientJournal: opts.clientJournal ?? { entries: [] },
|
|
223
271
|
defaults,
|
package/src/env.ts
CHANGED
|
@@ -16,6 +16,10 @@ export const env = createEnv({
|
|
|
16
16
|
REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
|
|
17
17
|
BETTER_AUTH_SECRET: z.string().min(1),
|
|
18
18
|
BETTER_AUTH_URL: z.string().url().default("http://localhost:3002"),
|
|
19
|
+
// Extra origins allowed to call the auth endpoints (beyond BETTER_AUTH_URL),
|
|
20
|
+
// comma-separated. Needed when the Studio is served from a different origin
|
|
21
|
+
// than the API — e.g. the `hogsend studio` CLI pointing at a remote instance.
|
|
22
|
+
BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
|
|
19
23
|
RESEND_API_KEY: z.string().min(1),
|
|
20
24
|
RESEND_FROM_EMAIL: z.string().email().default("noreply@hogsend.com"),
|
|
21
25
|
// Hatchet connection contract. The @hatchet-dev SDK also reads these straight
|
|
@@ -50,6 +54,12 @@ export const env = createEnv({
|
|
|
50
54
|
ADMIN_API_KEY: z.string().min(1).optional(),
|
|
51
55
|
API_PUBLIC_URL: z.string().url().default("http://localhost:3002"),
|
|
52
56
|
ENABLED_JOURNEYS: z.string().default("*"),
|
|
57
|
+
// Buckets: same `"*"`-or-csv contract as ENABLED_JOURNEYS (Section 9.3).
|
|
58
|
+
// Evaluated at worker boot — a toggle requires a worker restart; only the
|
|
59
|
+
// bucket_configs DB override is hot.
|
|
60
|
+
ENABLED_BUCKETS: z.string().default("*"),
|
|
61
|
+
// Cadence for the engine-owned bucket reconcile cron (time-based leaves).
|
|
62
|
+
BUCKET_RECONCILE_CRON: z.string().default("*/5 * * * *"),
|
|
53
63
|
},
|
|
54
64
|
runtimeEnv: process.env,
|
|
55
65
|
emptyStringAsUndefined: true,
|
package/src/index.ts
CHANGED
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
// Core helpers used by content journeys (days/hours/minutes, condition + journey
|
|
7
7
|
// types) so content can import everything from `@hogsend/engine`.
|
|
8
8
|
export * from "@hogsend/core";
|
|
9
|
-
export {
|
|
9
|
+
export {
|
|
10
|
+
BucketRegistry,
|
|
11
|
+
JourneyRegistry,
|
|
12
|
+
} from "@hogsend/core/registry";
|
|
10
13
|
// --- Re-exports for content ---
|
|
11
14
|
// Schema/version helpers used by the boot guard and the /v1/health route.
|
|
12
15
|
export {
|
|
@@ -19,6 +22,25 @@ export {
|
|
|
19
22
|
} from "@hogsend/db";
|
|
20
23
|
// --- App / container / worker factories ---
|
|
21
24
|
export { type AppEnv, type CreateAppOptions, createApp } from "./app.js";
|
|
25
|
+
// --- Buckets ---
|
|
26
|
+
export {
|
|
27
|
+
type BucketTransition,
|
|
28
|
+
type BucketTransitionKind,
|
|
29
|
+
checkBucketMembership,
|
|
30
|
+
} from "./buckets/check-membership.js";
|
|
31
|
+
export {
|
|
32
|
+
type DefinedBucket,
|
|
33
|
+
defineBucket,
|
|
34
|
+
} from "./buckets/define-bucket.js";
|
|
35
|
+
export {
|
|
36
|
+
buildBucketRegistry,
|
|
37
|
+
selectBucketTasks,
|
|
38
|
+
} from "./buckets/registry.js";
|
|
39
|
+
export {
|
|
40
|
+
getBucketRegistrySingleton,
|
|
41
|
+
resetBucketRegistry,
|
|
42
|
+
setBucketRegistry,
|
|
43
|
+
} from "./buckets/registry-singleton.js";
|
|
22
44
|
export {
|
|
23
45
|
createHogsendClient,
|
|
24
46
|
type HogsendClient,
|
|
@@ -50,6 +72,11 @@ export {
|
|
|
50
72
|
type BatchedBackfillResult,
|
|
51
73
|
runBatchedBackfill,
|
|
52
74
|
} from "./lib/backfill.js";
|
|
75
|
+
// --- Bucket transition emission (shared by real-time / cron / fast-expiry) ---
|
|
76
|
+
export {
|
|
77
|
+
type BucketTransitionSource,
|
|
78
|
+
emitBucketTransition,
|
|
79
|
+
} from "./lib/bucket-emit.js";
|
|
53
80
|
// --- Infrastructure singletons ---
|
|
54
81
|
export { getDb } from "./lib/db.js";
|
|
55
82
|
// --- Email ---
|
|
@@ -86,6 +113,7 @@ export { createLogger, type Logger } from "./lib/logger.js";
|
|
|
86
113
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
87
114
|
export { getPostHog } from "./lib/posthog.js";
|
|
88
115
|
export { getRedisIfConnected } from "./lib/redis.js";
|
|
116
|
+
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
89
117
|
export {
|
|
90
118
|
type ResolveTimezoneInput,
|
|
91
119
|
type ResolveTimezoneResult,
|
|
@@ -120,6 +148,17 @@ export {
|
|
|
120
148
|
createWorker,
|
|
121
149
|
type Worker,
|
|
122
150
|
} from "./worker.js";
|
|
151
|
+
export {
|
|
152
|
+
type BucketBackfillInput,
|
|
153
|
+
bucketBackfillTask,
|
|
154
|
+
computeCriteriaHash,
|
|
155
|
+
enqueueBucketBackfills,
|
|
156
|
+
} from "./workflows/bucket-backfill.js";
|
|
157
|
+
export {
|
|
158
|
+
type BucketArmExpiryInput,
|
|
159
|
+
bucketExpiryTask,
|
|
160
|
+
bucketReconcileTask,
|
|
161
|
+
} from "./workflows/bucket-reconcile.js";
|
|
123
162
|
export { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
124
163
|
export { importContactsTask } from "./workflows/import-contacts.js";
|
|
125
164
|
// --- Built-in Hatchet workflow tasks ---
|
package/src/lib/auth.ts
CHANGED
|
@@ -8,12 +8,19 @@ export function createAuth(opts: {
|
|
|
8
8
|
db: Database;
|
|
9
9
|
secret: string;
|
|
10
10
|
baseURL: string;
|
|
11
|
+
/**
|
|
12
|
+
* Extra origins allowed to call auth endpoints, beyond `baseURL` (which is
|
|
13
|
+
* always trusted). Needed when the Studio is served from a different origin
|
|
14
|
+
* than the API (e.g. the `hogsend studio` CLI against a remote instance).
|
|
15
|
+
*/
|
|
16
|
+
trustedOrigins?: string[];
|
|
11
17
|
}) {
|
|
12
|
-
const { db, secret, baseURL } = opts;
|
|
18
|
+
const { db, secret, baseURL, trustedOrigins } = opts;
|
|
13
19
|
return betterAuth({
|
|
14
20
|
basePath: "/api/auth",
|
|
15
21
|
secret,
|
|
16
22
|
baseURL,
|
|
23
|
+
...(trustedOrigins && trustedOrigins.length > 0 ? { trustedOrigins } : {}),
|
|
17
24
|
database: drizzleAdapter(db, {
|
|
18
25
|
provider: "pg",
|
|
19
26
|
schema,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { BucketMeta } from "@hogsend/core";
|
|
3
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
|
+
import type { Database } from "@hogsend/db";
|
|
5
|
+
import { syncBucketToPostHog } from "./bucket-posthog-sync.js";
|
|
6
|
+
import { ingestEvent } from "./ingestion.js";
|
|
7
|
+
import type { Logger } from "./logger.js";
|
|
8
|
+
|
|
9
|
+
export type BucketTransitionKind = "entered" | "left";
|
|
10
|
+
|
|
11
|
+
/** Where a transition originated — carried on the emitted event properties. */
|
|
12
|
+
export type BucketTransitionSource =
|
|
13
|
+
| "event"
|
|
14
|
+
| "reconcile"
|
|
15
|
+
| "backfill"
|
|
16
|
+
| "manual";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Emit a bucket transition back through `ingestEvent` (the `ctx.trigger`
|
|
20
|
+
* precedent) — shared by ALL three producers (real-time `checkBucketMembership`,
|
|
21
|
+
* the reconcile cron, and the fast-expiry timer) so they compute byte-identical
|
|
22
|
+
* `idempotencyKey`s for the same transition and converge to ONE emission
|
|
23
|
+
* (Section 6.3 worked example).
|
|
24
|
+
*
|
|
25
|
+
* Persists to `userEvents`, pushes to Hatchet (routing to journeys), and runs
|
|
26
|
+
* `checkExits`. Emits the per-bucket ALIAS (`bucket:<kind>:<id>`) by default; the
|
|
27
|
+
* generic `bucket:<kind>` is emitted ONLY when a generic-bound journey actually
|
|
28
|
+
* exists (aliased-only default — Section 8.5). `epoch` is the winning membership
|
|
29
|
+
* row's `entryCount`, read off the single winning mutation by the caller.
|
|
30
|
+
*/
|
|
31
|
+
export async function emitBucketTransition(opts: {
|
|
32
|
+
db: Database;
|
|
33
|
+
/** The JOURNEY registry — forwarded into the recursive emit ingestEvent. */
|
|
34
|
+
registry: JourneyRegistry;
|
|
35
|
+
hatchet: HatchetClient;
|
|
36
|
+
logger: Logger;
|
|
37
|
+
kind: BucketTransitionKind;
|
|
38
|
+
bucket: BucketMeta;
|
|
39
|
+
userId: string;
|
|
40
|
+
userEmail: string | null;
|
|
41
|
+
epoch: number;
|
|
42
|
+
source?: BucketTransitionSource;
|
|
43
|
+
}): Promise<void> {
|
|
44
|
+
const {
|
|
45
|
+
db,
|
|
46
|
+
registry,
|
|
47
|
+
hatchet,
|
|
48
|
+
logger,
|
|
49
|
+
kind,
|
|
50
|
+
bucket,
|
|
51
|
+
userId,
|
|
52
|
+
userEmail,
|
|
53
|
+
epoch,
|
|
54
|
+
source = "event",
|
|
55
|
+
} = opts;
|
|
56
|
+
|
|
57
|
+
const properties: Record<string, unknown> = {
|
|
58
|
+
bucketId: bucket.id,
|
|
59
|
+
bucketName: bucket.name,
|
|
60
|
+
userId,
|
|
61
|
+
transition: kind,
|
|
62
|
+
source,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Optional PostHog person-property mirror (Section 12). Off by default; a
|
|
66
|
+
// no-op without POSTHOG_API_KEY. Wired here, the single transition path shared
|
|
67
|
+
// by all three producers (real-time / reconcile / fast-expiry), so the sync
|
|
68
|
+
// fires exactly once per emitted transition. Best-effort — it never blocks the
|
|
69
|
+
// event emit below.
|
|
70
|
+
syncBucketToPostHog({ logger, kind, bucket, userId });
|
|
71
|
+
|
|
72
|
+
// Per-bucket alias — the recommended, narrowly-routed binding. The
|
|
73
|
+
// deterministic idempotencyKey rides the userEvents dedup short-circuit as
|
|
74
|
+
// defense-in-depth (Section 6.3).
|
|
75
|
+
await ingestEvent({
|
|
76
|
+
db,
|
|
77
|
+
registry,
|
|
78
|
+
hatchet,
|
|
79
|
+
logger,
|
|
80
|
+
event: {
|
|
81
|
+
event: `bucket:${kind}:${bucket.id}`,
|
|
82
|
+
userId,
|
|
83
|
+
userEmail: userEmail ?? "",
|
|
84
|
+
properties,
|
|
85
|
+
idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}`,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Generic form — emitted ONLY if a journey actually binds to it, so the
|
|
90
|
+
// recursion-guarded generic event is not written for nothing (Section 8.5).
|
|
91
|
+
const genericEvent = `bucket:${kind}`;
|
|
92
|
+
if (registry.getByTriggerEvent(genericEvent).length > 0) {
|
|
93
|
+
await ingestEvent({
|
|
94
|
+
db,
|
|
95
|
+
registry,
|
|
96
|
+
hatchet,
|
|
97
|
+
logger,
|
|
98
|
+
event: {
|
|
99
|
+
event: genericEvent,
|
|
100
|
+
userId,
|
|
101
|
+
userEmail: userEmail ?? "",
|
|
102
|
+
properties,
|
|
103
|
+
idempotencyKey: `bucket:${bucket.id}:${userId}:${kind}:${epoch}:generic`,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { BucketMeta } from "@hogsend/core";
|
|
2
|
+
import type { Logger } from "./logger.js";
|
|
3
|
+
import { getPostHog } from "./posthog.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Optional PostHog person-property mirror for a bucket transition (Section 12).
|
|
7
|
+
*
|
|
8
|
+
* OFF BY DEFAULT — a no-op unless `meta.syncToPostHog === true`. Also a no-op
|
|
9
|
+
* without `POSTHOG_API_KEY` (`getPostHog()` returns undefined), so self-host
|
|
10
|
+
* setups that omit PostHog silently do nothing — documented, not broken.
|
|
11
|
+
*
|
|
12
|
+
* On JOIN it `$set`s a boolean person property `true`; on LEAVE it `$unset`s the
|
|
13
|
+
* same key. `$unset` (NOT `$set false`) is the recommended default: a cohort
|
|
14
|
+
* `key = true` excludes a false value, but a cohort `key is set` STILL matches a
|
|
15
|
+
* false value, so `$unset` is the only form where both cohort idioms behave
|
|
16
|
+
* correctly. The property key defaults to `hogsend_bucket_<id>`, overridable via
|
|
17
|
+
* `meta.postHogPropertyKey`.
|
|
18
|
+
*
|
|
19
|
+
* This reuses the existing `plugin-posthog` capture path (the same one
|
|
20
|
+
* `identify()` uses at journey-context.ts) — it adds no new integration surface
|
|
21
|
+
* and never pushes to any non-PostHog destination (the Section 2.4 anti-CDP
|
|
22
|
+
* invariant). Best-effort: a capture failure is logged and swallowed so a sync
|
|
23
|
+
* error never blocks a transition emit.
|
|
24
|
+
*/
|
|
25
|
+
export function syncBucketToPostHog(opts: {
|
|
26
|
+
logger: Logger;
|
|
27
|
+
kind: "entered" | "left";
|
|
28
|
+
bucket: BucketMeta;
|
|
29
|
+
userId: string;
|
|
30
|
+
}): void {
|
|
31
|
+
const { logger, kind, bucket, userId } = opts;
|
|
32
|
+
|
|
33
|
+
if (!bucket.syncToPostHog) return;
|
|
34
|
+
|
|
35
|
+
const posthog = getPostHog();
|
|
36
|
+
if (!posthog) return; // no POSTHOG_API_KEY → silent no-op
|
|
37
|
+
|
|
38
|
+
const propertyKey =
|
|
39
|
+
bucket.postHogPropertyKey ?? `hogsend_bucket_${bucket.id}`;
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
if (kind === "entered") {
|
|
43
|
+
// $set { key: true } — mirrors plugin-posthog identify() ($set path).
|
|
44
|
+
posthog.identify(userId, { [propertyKey]: true });
|
|
45
|
+
} else {
|
|
46
|
+
// $unset [key] — RECOMMENDED on leave (Section 12). The property is absent
|
|
47
|
+
// unless the user is currently a member, so both `key = true` and
|
|
48
|
+
// `key is set` cohorts behave correctly.
|
|
49
|
+
posthog.captureEvent({
|
|
50
|
+
distinctId: userId,
|
|
51
|
+
event: "$set",
|
|
52
|
+
properties: { $unset: [propertyKey] },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
logger.warn("Bucket PostHog sync failed (best-effort)", {
|
|
57
|
+
bucketId: bucket.id,
|
|
58
|
+
userId,
|
|
59
|
+
kind,
|
|
60
|
+
error: err instanceof Error ? err.message : String(err),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -35,6 +35,9 @@ export interface SendTrackedEmailOptions<
|
|
|
35
35
|
to: string;
|
|
36
36
|
subject?: string;
|
|
37
37
|
journeyStateId?: string;
|
|
38
|
+
/** Denormalized recipient identity, persisted on the email_sends row for reporting. */
|
|
39
|
+
userId?: string;
|
|
40
|
+
userEmail?: string;
|
|
38
41
|
category?: string;
|
|
39
42
|
tags?: Array<{ name: string; value: string }>;
|
|
40
43
|
headers?: Record<string, string>;
|
|
@@ -108,6 +111,9 @@ export interface EmailServiceSendOptions<
|
|
|
108
111
|
from?: string;
|
|
109
112
|
subject?: string;
|
|
110
113
|
journeyStateId?: string;
|
|
114
|
+
/** Denormalized recipient identity, persisted on the email_sends row for reporting. */
|
|
115
|
+
userId?: string;
|
|
116
|
+
userEmail?: string;
|
|
111
117
|
category?: string;
|
|
112
118
|
tags?: Array<{ name: string; value: string }>;
|
|
113
119
|
headers?: Record<string, string>;
|
package/src/lib/email.ts
CHANGED
|
@@ -76,6 +76,8 @@ export async function sendEmail(
|
|
|
76
76
|
to: opts.to,
|
|
77
77
|
subject: opts.subject,
|
|
78
78
|
journeyStateId: opts.journeyStateId,
|
|
79
|
+
userId: opts.userId,
|
|
80
|
+
userEmail: opts.to,
|
|
79
81
|
category: "journey",
|
|
80
82
|
tags: [
|
|
81
83
|
{ name: "journeyId", value: opts.journeyName ?? opts.template },
|
package/src/lib/ingestion.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { evaluatePropertyConditions } from "@hogsend/core";
|
|
|
3
3
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
4
|
import { type Database, journeyStates, userEvents } from "@hogsend/db";
|
|
5
5
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
|
6
|
+
import { checkBucketMembership } from "../buckets/check-membership.js";
|
|
6
7
|
import { upsertContact } from "./contacts.js";
|
|
7
8
|
import type { Logger } from "./logger.js";
|
|
8
9
|
|
|
@@ -93,6 +94,30 @@ export async function ingestEvent(opts: {
|
|
|
93
94
|
}),
|
|
94
95
|
]);
|
|
95
96
|
|
|
97
|
+
// Real-time bucket membership re-evaluation (Section 6.1). NOT part of the
|
|
98
|
+
// Promise.all above: its property eval reads MERGED contact state, and its
|
|
99
|
+
// bucket:entered/left emissions recurse back into ingestEvent (the recursion
|
|
100
|
+
// guard in checkBucketMembership bounds them). Best-effort: a bucket failure
|
|
101
|
+
// must not fail the ingest of the originating event.
|
|
102
|
+
try {
|
|
103
|
+
await checkBucketMembership({
|
|
104
|
+
db,
|
|
105
|
+
registry,
|
|
106
|
+
hatchet,
|
|
107
|
+
logger,
|
|
108
|
+
userId: event.userId,
|
|
109
|
+
userEmail: event.userEmail || null,
|
|
110
|
+
event: event.event,
|
|
111
|
+
properties: event.properties,
|
|
112
|
+
});
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.warn("Bucket membership check failed", {
|
|
115
|
+
event: event.event,
|
|
116
|
+
userId: event.userId,
|
|
117
|
+
error: err instanceof Error ? err.message : String(err),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
96
121
|
logger.info("Event ingested", {
|
|
97
122
|
event: event.event,
|
|
98
123
|
userId: event.userId,
|
package/src/lib/mailer.ts
CHANGED
|
@@ -91,6 +91,8 @@ export function createTrackedMailer(
|
|
|
91
91
|
to: options.to,
|
|
92
92
|
subject: options.subject,
|
|
93
93
|
journeyStateId: options.journeyStateId,
|
|
94
|
+
userId: options.userId,
|
|
95
|
+
userEmail: options.userEmail,
|
|
94
96
|
category: options.category,
|
|
95
97
|
tags: options.tags,
|
|
96
98
|
headers: options.headers,
|
|
@@ -190,7 +192,10 @@ export function createTrackedMailer(
|
|
|
190
192
|
await updateEmailStatus(event.type, event.data.email_id);
|
|
191
193
|
break;
|
|
192
194
|
case "email.bounced":
|
|
193
|
-
await updateEmailStatus(event.type, event.data.email_id
|
|
195
|
+
await updateEmailStatus(event.type, event.data.email_id, {
|
|
196
|
+
bounceType: event.data.bounce?.type,
|
|
197
|
+
bounceReason: event.data.bounce?.message,
|
|
198
|
+
});
|
|
194
199
|
await handleBounce(event.data.to);
|
|
195
200
|
break;
|
|
196
201
|
case "email.complained":
|
|
@@ -247,6 +252,7 @@ export function createTrackedMailer(
|
|
|
247
252
|
async function updateEmailStatus(
|
|
248
253
|
eventType: WebhookEventType,
|
|
249
254
|
resendId: string,
|
|
255
|
+
extra?: { bounceType?: string; bounceReason?: string },
|
|
250
256
|
): Promise<void> {
|
|
251
257
|
if (!db) return;
|
|
252
258
|
|
|
@@ -259,6 +265,8 @@ export function createTrackedMailer(
|
|
|
259
265
|
.set({
|
|
260
266
|
status: status as typeof emailSends.$inferSelect.status,
|
|
261
267
|
[timestampField]: new Date(),
|
|
268
|
+
...(extra?.bounceType ? { bounceType: extra.bounceType } : {}),
|
|
269
|
+
...(extra?.bounceReason ? { bounceReason: extra.bounceReason } : {}),
|
|
262
270
|
updatedAt: new Date(),
|
|
263
271
|
})
|
|
264
272
|
.where(eq(emailSends.resendId, resendId));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
|
|
3
|
+
// Shared SQL building blocks for the admin metrics + reporting routers.
|
|
4
|
+
|
|
5
|
+
/** Guarded divide, rounded to 4 decimal places. Returns 0 when denom <= 0. */
|
|
6
|
+
export const rate = (num: number, denom: number) =>
|
|
7
|
+
denom > 0 ? Math.round((num / denom) * 10000) / 10000 : 0;
|
|
8
|
+
|
|
9
|
+
/** `date_trunc` granularity literals, keyed by the API's period/granularity enum. */
|
|
10
|
+
export const TRUNC_SQL = {
|
|
11
|
+
hour: sql`'hour'`,
|
|
12
|
+
day: sql`'day'`,
|
|
13
|
+
week: sql`'week'`,
|
|
14
|
+
month: sql`'month'`,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
export type TruncPeriod = keyof typeof TRUNC_SQL;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
6
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
7
|
+
import type { AppEnv } from "../app.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where the built Studio SPA lives. The Studio (`@hogsend/studio`) is a separate
|
|
11
|
+
* Vite package that builds to a static `dist/` under base `/studio/`. The engine
|
|
12
|
+
* serves that `dist/` as static files at `/studio/*` with an SPA fallback.
|
|
13
|
+
*
|
|
14
|
+
* The Studio is NOT a runtime dependency of the engine — it ships as a built
|
|
15
|
+
* artifact and is optional. Mounting is best-effort: if no `dist/` is found, the
|
|
16
|
+
* mount is silently skipped so an unbuilt / studio-less deploy never crashes.
|
|
17
|
+
*
|
|
18
|
+
* Resolution order:
|
|
19
|
+
* 1. `STUDIO_DIST_PATH` env var (explicit override; absolute or cwd-relative).
|
|
20
|
+
* 2. `require.resolve("@hogsend/studio/package.json")` → sibling `dist/`
|
|
21
|
+
* (works when the studio package is installed/linked alongside the engine).
|
|
22
|
+
* 3. Monorepo source layout: walk up from this file to `packages/studio/dist`.
|
|
23
|
+
* 4. cwd-relative `packages/studio/dist` (dogfood app run from repo root).
|
|
24
|
+
*/
|
|
25
|
+
function resolveStudioDist(): string | null {
|
|
26
|
+
const candidates: string[] = [];
|
|
27
|
+
|
|
28
|
+
const envPath = process.env.STUDIO_DIST_PATH;
|
|
29
|
+
if (envPath && envPath.length > 0) {
|
|
30
|
+
candidates.push(resolve(process.cwd(), envPath));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
try {
|
|
35
|
+
const pkgJson = require.resolve("@hogsend/studio/package.json");
|
|
36
|
+
candidates.push(join(dirname(pkgJson), "dist"));
|
|
37
|
+
} catch {
|
|
38
|
+
// Not resolvable as a module — fall through to layout-based guesses.
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Monorepo source layout: this file is packages/engine/src/lib/studio.ts, so
|
|
42
|
+
// the studio dist is ../../../studio/dist relative to here.
|
|
43
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
candidates.push(resolve(here, "../../../studio/dist"));
|
|
45
|
+
|
|
46
|
+
// cwd fallbacks for a repo-root process (apps/api dogfood, tests).
|
|
47
|
+
candidates.push(resolve(process.cwd(), "packages/studio/dist"));
|
|
48
|
+
candidates.push(resolve(process.cwd(), "../../packages/studio/dist"));
|
|
49
|
+
|
|
50
|
+
for (const dir of candidates) {
|
|
51
|
+
if (existsSync(join(dir, "index.html"))) {
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface MountStudioResult {
|
|
59
|
+
/** True when the SPA was mounted, false when no built dist was found. */
|
|
60
|
+
mounted: boolean;
|
|
61
|
+
/** Absolute path to the served dist directory, when mounted. */
|
|
62
|
+
distPath?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Mount the Studio SPA at `/studio/*` as static files, with an SPA fallback to
|
|
67
|
+
* `index.html` for client-side routes.
|
|
68
|
+
*
|
|
69
|
+
* IMPORTANT: this is intentionally OUTSIDE the `/v1/admin` auth guard at the
|
|
70
|
+
* static layer. The SPA itself gates access via `/v1/auth/status` + login; the
|
|
71
|
+
* actual data endpoints under `/v1/admin/*` stay protected by `requireAdmin`.
|
|
72
|
+
*
|
|
73
|
+
* No-op (returns `{ mounted: false }`) when no built `dist/` is found, so an
|
|
74
|
+
* unbuilt studio never crashes the server.
|
|
75
|
+
*/
|
|
76
|
+
export function mountStudio(app: OpenAPIHono<AppEnv>): MountStudioResult {
|
|
77
|
+
const distPath = resolveStudioDist();
|
|
78
|
+
if (!distPath) {
|
|
79
|
+
return { mounted: false };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// serveStatic resolves `path` relative to `root` (which is relative to cwd by
|
|
83
|
+
// default). We pass an absolute `root` and strip the `/studio` URL prefix so a
|
|
84
|
+
// request for `/studio/assets/x.js` maps to `<dist>/assets/x.js`.
|
|
85
|
+
const staticHandler = serveStatic({
|
|
86
|
+
root: distPath,
|
|
87
|
+
rewriteRequestPath: (path) => path.replace(/^\/studio/, "") || "/",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Redirect the bare `/studio` to `/studio/` so relative/base assets resolve.
|
|
91
|
+
app.get("/studio", (c) => c.redirect("/studio/"));
|
|
92
|
+
|
|
93
|
+
// Static assets (js/css/images) under /studio/*.
|
|
94
|
+
app.use("/studio/*", staticHandler);
|
|
95
|
+
|
|
96
|
+
// SPA fallback: any /studio/* path that didn't resolve to a file serves
|
|
97
|
+
// index.html so client-side (TanStack Router) routes work on hard refresh.
|
|
98
|
+
const indexHandler = serveStatic({
|
|
99
|
+
root: distPath,
|
|
100
|
+
rewriteRequestPath: () => "/index.html",
|
|
101
|
+
});
|
|
102
|
+
app.get("/studio/*", indexHandler);
|
|
103
|
+
|
|
104
|
+
return { mounted: true, distPath };
|
|
105
|
+
}
|
package/src/lib/tracked.ts
CHANGED
|
@@ -66,6 +66,8 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
66
66
|
subject: options.subject ?? "",
|
|
67
67
|
category: options.category,
|
|
68
68
|
journeyStateId: options.journeyStateId,
|
|
69
|
+
userId: options.userId,
|
|
70
|
+
userEmail: options.userEmail ?? options.to,
|
|
69
71
|
status: "failed",
|
|
70
72
|
})
|
|
71
73
|
.returning({ id: emailSends.id });
|
|
@@ -126,6 +128,8 @@ export async function sendTrackedEmail<K extends TemplateName>(
|
|
|
126
128
|
subject,
|
|
127
129
|
category: options.category ?? category,
|
|
128
130
|
journeyStateId: options.journeyStateId,
|
|
131
|
+
userId: options.userId,
|
|
132
|
+
userEmail: options.userEmail ?? options.to,
|
|
129
133
|
status: "queued",
|
|
130
134
|
})
|
|
131
135
|
.returning({ id: emailSends.id });
|
|
@@ -13,7 +13,7 @@ export const rateLimit = createMiddleware<AppEnv>(async (c, next) => {
|
|
|
13
13
|
if (process.env.NODE_ENV === "test") return next();
|
|
14
14
|
|
|
15
15
|
const apiKey = c.get("apiKey");
|
|
16
|
-
const keyId = apiKey?.id ?? "anonymous";
|
|
16
|
+
const keyId = apiKey?.id ?? c.get("user")?.id ?? "anonymous";
|
|
17
17
|
const now = Date.now();
|
|
18
18
|
|
|
19
19
|
let count: number;
|