@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/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: opts.email?.templates ?? ({} as TemplateRegistry),
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 { JourneyRegistry } from "@hogsend/core/registry";
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 },
@@ -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
+ }
@@ -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;