@hogsend/engine 0.0.1 → 0.1.1
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/container.ts +77 -2
- package/src/env.ts +4 -0
- package/src/index.ts +13 -0
- package/src/journeys/client-defaults-singleton.ts +29 -0
- package/src/journeys/define-journey.ts +45 -2
- package/src/journeys/journey-context.ts +125 -20
- package/src/lib/auth.ts +8 -1
- package/src/lib/email-service-types.ts +38 -1
- package/src/lib/email.ts +2 -0
- package/src/lib/frequency-cap.ts +54 -0
- package/src/lib/mailer.ts +11 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/timezone.ts +126 -0
- package/src/lib/tracked.ts +44 -1
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +8 -2
- package/src/routes/admin/metrics.ts +31 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hogsend/engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -35,11 +35,11 @@
|
|
|
35
35
|
"resend": "^6.12.3",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"zod": "^4.4.3",
|
|
38
|
-
"@hogsend/core": "^0.0
|
|
39
|
-
"@hogsend/db": "^0.0
|
|
40
|
-
"@hogsend/email": "^0.0
|
|
41
|
-
"@hogsend/plugin-
|
|
42
|
-
"@hogsend/plugin-
|
|
38
|
+
"@hogsend/core": "^0.1.0",
|
|
39
|
+
"@hogsend/db": "^0.1.0",
|
|
40
|
+
"@hogsend/email": "^0.1.0",
|
|
41
|
+
"@hogsend/plugin-posthog": "^0.1.0",
|
|
42
|
+
"@hogsend/plugin-resend": "^0.1.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@types/node": "^22.15.3",
|
package/src/app.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { user } from "@hogsend/db";
|
|
1
2
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
3
|
import { apiReference } from "@scalar/hono-api-reference";
|
|
3
4
|
import { compress } from "hono/compress";
|
|
@@ -8,6 +9,7 @@ import type { ErrorHandler, MiddlewareHandler } from "hono/types";
|
|
|
8
9
|
import type { HogsendClient } from "./container.js";
|
|
9
10
|
import { API_VERSION } from "./env.js";
|
|
10
11
|
import type { Auth } from "./lib/auth.js";
|
|
12
|
+
import { mountStudio } from "./lib/studio.js";
|
|
11
13
|
import type { ApiKeyContext } from "./middleware/api-key.js";
|
|
12
14
|
import { errorHandler } from "./middleware/error-handler.js";
|
|
13
15
|
import { requestLogger } from "./middleware/request-logger.js";
|
|
@@ -64,13 +66,48 @@ export function createApp(
|
|
|
64
66
|
return c.json({ error: "Not Found" }, 404);
|
|
65
67
|
});
|
|
66
68
|
|
|
69
|
+
// Closed signup: the first user may register (first-load "create admin");
|
|
70
|
+
// once any user exists, sign-up is blocked. This is the security control that
|
|
71
|
+
// lets `requireAdmin` trust any authenticated session in a single-tenant app.
|
|
72
|
+
app.use("/api/auth/sign-up/*", async (c, next) => {
|
|
73
|
+
if (c.req.method === "POST") {
|
|
74
|
+
const { db } = c.get("container");
|
|
75
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
76
|
+
if (existing.length > 0) {
|
|
77
|
+
return c.json(
|
|
78
|
+
{ error: "Sign-ups are closed. An admin already exists." },
|
|
79
|
+
403,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return next();
|
|
84
|
+
});
|
|
85
|
+
|
|
67
86
|
app.on(["POST", "GET"], "/api/auth/*", (c) => {
|
|
68
87
|
const { auth } = c.get("container");
|
|
69
88
|
return auth.handler(c.req.raw);
|
|
70
89
|
});
|
|
71
90
|
|
|
91
|
+
// Public bootstrap probe: tells the Studio whether to show the first-run
|
|
92
|
+
// "create admin" screen (no users yet) instead of the login screen.
|
|
93
|
+
app.get("/v1/auth/status", async (c) => {
|
|
94
|
+
const { db } = c.get("container");
|
|
95
|
+
const existing = await db.select({ id: user.id }).from(user).limit(1);
|
|
96
|
+
return c.json({ needsSetup: existing.length === 0 });
|
|
97
|
+
});
|
|
98
|
+
|
|
72
99
|
registerRoutes(app, { webhookSources: opts.webhookSources ?? [] });
|
|
73
100
|
|
|
101
|
+
// Serve the Studio SPA at /studio/* (static layer, no auth — the SPA gates
|
|
102
|
+
// itself via /v1/auth/status + login; data endpoints stay behind requireAdmin).
|
|
103
|
+
// No-op when no built dist is present, so an unbuilt studio never crashes boot.
|
|
104
|
+
const studio = mountStudio(app);
|
|
105
|
+
if (studio.mounted) {
|
|
106
|
+
container.logger.info(
|
|
107
|
+
`Studio mounted at /studio (dist: ${studio.distPath})`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
74
111
|
opts.routes?.(app);
|
|
75
112
|
|
|
76
113
|
if (container.env.NODE_ENV !== "production") {
|
package/src/container.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import type { TimeZone } from "@hogsend/core";
|
|
2
3
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
|
+
import type { SendWindow } from "@hogsend/core/schedule";
|
|
3
5
|
import {
|
|
4
6
|
createDatabase,
|
|
5
7
|
type Database,
|
|
@@ -15,17 +17,30 @@ import {
|
|
|
15
17
|
} from "@hogsend/plugin-resend";
|
|
16
18
|
import type { Resend } from "resend";
|
|
17
19
|
import { env } from "./env.js";
|
|
20
|
+
import { setClientScheduleDefaults } from "./journeys/client-defaults-singleton.js";
|
|
18
21
|
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
19
22
|
import { buildJourneyRegistry } from "./journeys/registry.js";
|
|
20
23
|
import { type Auth, createAuth } from "./lib/auth.js";
|
|
21
24
|
import { setEmailService } from "./lib/email.js";
|
|
22
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
EmailService,
|
|
27
|
+
FrequencyCapConfig,
|
|
28
|
+
} from "./lib/email-service-types.js";
|
|
23
29
|
import { hatchet } from "./lib/hatchet.js";
|
|
24
30
|
import { createLogger, type Logger } from "./lib/logger.js";
|
|
25
31
|
import { createTrackedMailer } from "./lib/mailer.js";
|
|
26
32
|
import { getPostHog } from "./lib/posthog.js";
|
|
27
33
|
import { prepareTrackedHtml } from "./lib/tracking.js";
|
|
28
34
|
|
|
35
|
+
export interface HogsendDefaults {
|
|
36
|
+
/** Global fallback IANA timezone for scheduling. Defaults to "UTC". */
|
|
37
|
+
timezone: TimeZone;
|
|
38
|
+
/** Default quiet-hours / send window auto-applied by `ctx.when`. */
|
|
39
|
+
sendWindow?: SendWindow;
|
|
40
|
+
/** Optional per-recipient frequency cap enforced in the mailer. */
|
|
41
|
+
frequencyCap?: FrequencyCapConfig;
|
|
42
|
+
}
|
|
43
|
+
|
|
29
44
|
export interface HogsendClient {
|
|
30
45
|
env: typeof env;
|
|
31
46
|
logger: Logger;
|
|
@@ -34,6 +49,13 @@ export interface HogsendClient {
|
|
|
34
49
|
auth: Auth;
|
|
35
50
|
email: Resend;
|
|
36
51
|
emailService: EmailService;
|
|
52
|
+
/**
|
|
53
|
+
* The app's template registry (key → component + subject + category +
|
|
54
|
+
* optional preview/examples). Same object threaded into the engine mailer;
|
|
55
|
+
* exposed here so admin preview/catalog routes can enumerate keys and render
|
|
56
|
+
* templates without going through a send. Empty when no templates are wired.
|
|
57
|
+
*/
|
|
58
|
+
templates: TemplateRegistry;
|
|
37
59
|
analytics?: PostHogService;
|
|
38
60
|
registry: JourneyRegistry;
|
|
39
61
|
hatchet: HatchetClient;
|
|
@@ -44,6 +66,12 @@ export interface HogsendClient {
|
|
|
44
66
|
* track. The CLIENT track never gates boot (client-owned); engine does.
|
|
45
67
|
*/
|
|
46
68
|
clientJournal: JournalShape;
|
|
69
|
+
/**
|
|
70
|
+
* Resolved scheduling + frequency-cap defaults. `timezone` always has a value
|
|
71
|
+
* ("UTC" when unset). Read by the journey context (tz/window) and the mailer
|
|
72
|
+
* (frequency cap).
|
|
73
|
+
*/
|
|
74
|
+
defaults: HogsendDefaults;
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
export interface HogsendClientOptions {
|
|
@@ -89,6 +117,22 @@ export interface HogsendClientOptions {
|
|
|
89
117
|
* Defaults to `{ entries: [] }` (empty client track ⇒ trivially in sync).
|
|
90
118
|
*/
|
|
91
119
|
clientJournal?: JournalShape;
|
|
120
|
+
/**
|
|
121
|
+
* Declarative scheduling + delivery defaults.
|
|
122
|
+
*
|
|
123
|
+
* - `timezone` — global fallback IANA tz (e.g. "UTC"), the terminal step of
|
|
124
|
+
* the per-user tz resolution chain.
|
|
125
|
+
* - `sendWindow` — quiet-hours window ("HH:mm".."HH:mm") auto-applied by
|
|
126
|
+
* `ctx.when` so scheduled instants land inside the window. Enforced ONLY at
|
|
127
|
+
* the scheduling layer; immediate transactional sends bypass it.
|
|
128
|
+
* - `frequencyCap` — per-recipient send cap enforced in the mailer choke
|
|
129
|
+
* point. Opt-in; "transactional" is exempt by default.
|
|
130
|
+
*/
|
|
131
|
+
defaults?: {
|
|
132
|
+
timezone?: TimeZone;
|
|
133
|
+
sendWindow?: SendWindow;
|
|
134
|
+
frequencyCap?: FrequencyCapConfig;
|
|
135
|
+
};
|
|
92
136
|
/**
|
|
93
137
|
* Genuinely advanced / test-only seams. You probably don't need these —
|
|
94
138
|
* prefer the first-class `email` / `analytics` args above.
|
|
@@ -117,6 +161,18 @@ export function createHogsendClient(
|
|
|
117
161
|
db,
|
|
118
162
|
secret: env.BETTER_AUTH_SECRET,
|
|
119
163
|
baseURL: env.BETTER_AUTH_URL,
|
|
164
|
+
// Always trust the public API origin; add any explicitly configured ones
|
|
165
|
+
// (e.g. a remote Studio origin) on top. baseURL is trusted automatically.
|
|
166
|
+
trustedOrigins: Array.from(
|
|
167
|
+
new Set(
|
|
168
|
+
[
|
|
169
|
+
env.API_PUBLIC_URL,
|
|
170
|
+
...(env.BETTER_AUTH_TRUSTED_ORIGINS?.split(",") ?? []),
|
|
171
|
+
]
|
|
172
|
+
.map((o) => o.trim())
|
|
173
|
+
.filter(Boolean),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
120
176
|
});
|
|
121
177
|
|
|
122
178
|
const email = createResendClient({ apiKey: env.RESEND_API_KEY });
|
|
@@ -133,16 +189,33 @@ export function createHogsendClient(
|
|
|
133
189
|
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
134
190
|
});
|
|
135
191
|
|
|
192
|
+
const defaults: HogsendDefaults = {
|
|
193
|
+
timezone: opts.defaults?.timezone ?? "UTC",
|
|
194
|
+
sendWindow: opts.defaults?.sendWindow,
|
|
195
|
+
frequencyCap: opts.defaults?.frequencyCap,
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
// Expose the scheduling slice to the module-level journey task, which has no
|
|
199
|
+
// client reference of its own.
|
|
200
|
+
setClientScheduleDefaults({
|
|
201
|
+
timezone: defaults.timezone,
|
|
202
|
+
sendWindow: defaults.sendWindow,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const templates = opts.email?.templates ?? ({} as TemplateRegistry);
|
|
206
|
+
|
|
136
207
|
const emailService =
|
|
137
208
|
opts.overrides?.mailer ??
|
|
138
209
|
createTrackedMailer(
|
|
139
210
|
{
|
|
140
211
|
defaultFrom: env.RESEND_FROM_EMAIL,
|
|
141
|
-
templates
|
|
212
|
+
templates,
|
|
142
213
|
db,
|
|
143
214
|
webhookSecret: env.RESEND_WEBHOOK_SECRET,
|
|
144
215
|
bounceThreshold: 3,
|
|
145
216
|
baseUrl: env.API_PUBLIC_URL,
|
|
217
|
+
frequencyCap: defaults.frequencyCap,
|
|
218
|
+
logger,
|
|
146
219
|
},
|
|
147
220
|
{
|
|
148
221
|
provider,
|
|
@@ -164,9 +237,11 @@ export function createHogsendClient(
|
|
|
164
237
|
auth,
|
|
165
238
|
email,
|
|
166
239
|
emailService,
|
|
240
|
+
templates,
|
|
167
241
|
analytics,
|
|
168
242
|
registry,
|
|
169
243
|
hatchet: opts.overrides?.hatchet ?? hatchet,
|
|
170
244
|
clientJournal: opts.clientJournal ?? { entries: [] },
|
|
245
|
+
defaults,
|
|
171
246
|
};
|
|
172
247
|
}
|
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
|
package/src/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ export {
|
|
|
23
23
|
createHogsendClient,
|
|
24
24
|
type HogsendClient,
|
|
25
25
|
type HogsendClientOptions,
|
|
26
|
+
type HogsendDefaults,
|
|
26
27
|
} from "./container.js";
|
|
27
28
|
// --- Env ---
|
|
28
29
|
export { API_VERSION, env } from "./env.js";
|
|
@@ -65,11 +66,14 @@ export type {
|
|
|
65
66
|
EmailServiceSendOptions,
|
|
66
67
|
EmailServiceWebhookOptions,
|
|
67
68
|
EmailServiceWebhookResult,
|
|
69
|
+
FrequencyCapConfig,
|
|
70
|
+
FrequencyCapWindow,
|
|
68
71
|
SendTrackedEmailOptions,
|
|
69
72
|
TrackedSendResult,
|
|
70
73
|
} from "./lib/email-service-types.js";
|
|
71
74
|
// --- Enrollment guards ---
|
|
72
75
|
export { checkEmailPreferences } from "./lib/enrollment-guards.js";
|
|
76
|
+
export { isFrequencyCapped } from "./lib/frequency-cap.js";
|
|
73
77
|
export { hatchet } from "./lib/hatchet.js";
|
|
74
78
|
// --- Ingestion pipeline ---
|
|
75
79
|
export {
|
|
@@ -82,6 +86,15 @@ export { createLogger, type Logger } from "./lib/logger.js";
|
|
|
82
86
|
export { createTrackedMailer } from "./lib/mailer.js";
|
|
83
87
|
export { getPostHog } from "./lib/posthog.js";
|
|
84
88
|
export { getRedisIfConnected } from "./lib/redis.js";
|
|
89
|
+
export { type MountStudioResult, mountStudio } from "./lib/studio.js";
|
|
90
|
+
export {
|
|
91
|
+
type ResolveTimezoneInput,
|
|
92
|
+
type ResolveTimezoneResult,
|
|
93
|
+
resolveTimezone,
|
|
94
|
+
resolveTimezoneWithSource,
|
|
95
|
+
setContactTimezone,
|
|
96
|
+
type TimezoneSource,
|
|
97
|
+
} from "./lib/timezone.js";
|
|
85
98
|
export {
|
|
86
99
|
type PrepareTrackedHtmlFn,
|
|
87
100
|
sendTrackedEmail,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SendWindow } from "@hogsend/core/schedule";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The scheduling-relevant slice of the client `defaults`, set once by
|
|
5
|
+
* `createHogsendClient` and read by the module-level journey task in
|
|
6
|
+
* `define-journey` (which has no client reference of its own). Frequency-cap
|
|
7
|
+
* config is threaded through the mailer, not here.
|
|
8
|
+
*/
|
|
9
|
+
export interface ClientScheduleDefaults {
|
|
10
|
+
timezone: string;
|
|
11
|
+
sendWindow?: SendWindow;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let _defaults: ClientScheduleDefaults = { timezone: "UTC" };
|
|
15
|
+
|
|
16
|
+
export function setClientScheduleDefaults(
|
|
17
|
+
defaults: ClientScheduleDefaults,
|
|
18
|
+
): void {
|
|
19
|
+
_defaults = defaults;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getClientScheduleDefaults(): ClientScheduleDefaults {
|
|
23
|
+
return _defaults;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Reset to the UTC default — only for test cleanup. */
|
|
27
|
+
export function resetClientScheduleDefaults(): void {
|
|
28
|
+
_defaults = { timezone: "UTC" };
|
|
29
|
+
}
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
JourneyRunFn,
|
|
6
6
|
JourneyUser,
|
|
7
7
|
} from "@hogsend/core/types";
|
|
8
|
-
import { journeyConfigs, journeyStates } from "@hogsend/db";
|
|
8
|
+
import { contacts, journeyConfigs, journeyStates } from "@hogsend/db";
|
|
9
9
|
import { and, eq, inArray } from "drizzle-orm";
|
|
10
10
|
import { getDb } from "../lib/db.js";
|
|
11
11
|
import {
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import { hatchet } from "../lib/hatchet.js";
|
|
16
16
|
import { createLogger } from "../lib/logger.js";
|
|
17
17
|
import { getPostHog } from "../lib/posthog.js";
|
|
18
|
+
import { resolveTimezoneWithSource } from "../lib/timezone.js";
|
|
19
|
+
import { getClientScheduleDefaults } from "./client-defaults-singleton.js";
|
|
18
20
|
import { createJourneyContext } from "./journey-context.js";
|
|
19
21
|
import { getJourneyRegistrySingleton } from "./registry-singleton.js";
|
|
20
22
|
|
|
@@ -127,17 +129,58 @@ export function defineJourney(options: {
|
|
|
127
129
|
journeyName: meta.name,
|
|
128
130
|
};
|
|
129
131
|
|
|
132
|
+
const posthog = getPostHog();
|
|
133
|
+
const scheduleDefaults = getClientScheduleDefaults();
|
|
134
|
+
|
|
135
|
+
// Resolve the user's timezone via the precedence chain (explicit is N/A
|
|
136
|
+
// at enrollment; PostHog person props → contacts row → client default →
|
|
137
|
+
// UTC). Best-effort: failures fall through to the client default tz.
|
|
138
|
+
// Independent I/O — fetch the contact row and PostHog person props
|
|
139
|
+
// concurrently. PostHog failures fall through to undefined.
|
|
140
|
+
const [contact, posthogProperties] = await Promise.all([
|
|
141
|
+
db.query.contacts.findFirst({
|
|
142
|
+
where: eq(contacts.externalId, userId),
|
|
143
|
+
}),
|
|
144
|
+
posthog?.getPersonProperties(userId).catch(() => undefined),
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const tz = resolveTimezoneWithSource({
|
|
148
|
+
posthogProperties,
|
|
149
|
+
contactTimezone: contact?.timezone ?? null,
|
|
150
|
+
contactProperties: contact?.properties ?? null,
|
|
151
|
+
defaultTimezone: scheduleDefaults.timezone,
|
|
152
|
+
logger,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Opportunistic cache write: when the tz came from a PostHog source and
|
|
156
|
+
// the contacts.timezone column is empty, persist it (fire-and-forget;
|
|
157
|
+
// PostHog/JSONB remain authoritative so nothing blocks on the column).
|
|
158
|
+
if (
|
|
159
|
+
(tz.source === "posthog_timezone" || tz.source === "posthog_geoip") &&
|
|
160
|
+
contact &&
|
|
161
|
+
!contact.timezone
|
|
162
|
+
) {
|
|
163
|
+
db.update(contacts)
|
|
164
|
+
.set({ timezone: tz.timezone, updatedAt: new Date() })
|
|
165
|
+
.where(eq(contacts.id, contact.id))
|
|
166
|
+
.catch(() => {
|
|
167
|
+
// best-effort cache write; never block the journey
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
130
171
|
const ctx = createJourneyContext({
|
|
131
172
|
db,
|
|
132
173
|
hatchet,
|
|
133
174
|
hatchetCtx,
|
|
134
175
|
registry: getJourneyRegistrySingleton(),
|
|
135
176
|
logger,
|
|
136
|
-
posthog
|
|
177
|
+
posthog,
|
|
137
178
|
stateId,
|
|
138
179
|
userId,
|
|
139
180
|
userEmail,
|
|
140
181
|
journeyContext: { ...properties },
|
|
182
|
+
resolvedTimezone: tz.timezone,
|
|
183
|
+
defaultSendWindow: scheduleDefaults.sendWindow,
|
|
141
184
|
});
|
|
142
185
|
|
|
143
186
|
try {
|
|
@@ -2,7 +2,21 @@ import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
|
2
2
|
import type { DurationObject } from "@hogsend/core";
|
|
3
3
|
import { evaluateEventCondition } from "@hogsend/core";
|
|
4
4
|
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
isValidTimeZone,
|
|
7
|
+
resolveAfter,
|
|
8
|
+
resolveNextLocalTime,
|
|
9
|
+
resolveNextWeekday,
|
|
10
|
+
resolveTomorrow,
|
|
11
|
+
type SendWindow,
|
|
12
|
+
} from "@hogsend/core/schedule";
|
|
13
|
+
import type {
|
|
14
|
+
IfPast,
|
|
15
|
+
JourneyContext,
|
|
16
|
+
TimeOfDayBuilder,
|
|
17
|
+
Weekday,
|
|
18
|
+
WhenBuilder,
|
|
19
|
+
} from "@hogsend/core/types";
|
|
6
20
|
import { type Database, emailSends, journeyStates } from "@hogsend/db";
|
|
7
21
|
import type { PostHogService } from "@hogsend/plugin-posthog";
|
|
8
22
|
import { and, count, eq, max } from "drizzle-orm";
|
|
@@ -13,7 +27,11 @@ import type { Logger } from "../lib/logger.js";
|
|
|
13
27
|
interface JourneyContextConfig {
|
|
14
28
|
db: Database;
|
|
15
29
|
hatchet: HatchetClient;
|
|
16
|
-
hatchetCtx: {
|
|
30
|
+
hatchetCtx: {
|
|
31
|
+
// Hatchet's real `sleepFor` accepts a number (milliseconds) in addition to
|
|
32
|
+
// duration strings/objects; we use the number-ms form for `sleepUntil`.
|
|
33
|
+
sleepFor: (duration: DurationObject | number) => Promise<unknown>;
|
|
34
|
+
};
|
|
17
35
|
registry: JourneyRegistry;
|
|
18
36
|
logger: Logger;
|
|
19
37
|
posthog?: PostHogService;
|
|
@@ -21,6 +39,61 @@ interface JourneyContextConfig {
|
|
|
21
39
|
userId: string;
|
|
22
40
|
userEmail: string;
|
|
23
41
|
journeyContext: Record<string, unknown>;
|
|
42
|
+
/** The user's resolved IANA timezone, bound into `ctx.when`. */
|
|
43
|
+
resolvedTimezone: string;
|
|
44
|
+
/** The client default send window, auto-applied by `ctx.when`. */
|
|
45
|
+
defaultSendWindow?: SendWindow;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the timezone-bound fluent scheduler. A thin wrapper over the pure core
|
|
50
|
+
* resolvers: it injects the user's resolved tz, the real current instant, and
|
|
51
|
+
* the (optionally overridden) send window, returning absolute `Date`s.
|
|
52
|
+
*/
|
|
53
|
+
function createWhenBuilder(opts: {
|
|
54
|
+
timezone: string;
|
|
55
|
+
window?: SendWindow;
|
|
56
|
+
ifPast: IfPast;
|
|
57
|
+
}): WhenBuilder {
|
|
58
|
+
const baseOpts = () => ({
|
|
59
|
+
timezone: opts.timezone,
|
|
60
|
+
now: new Date(),
|
|
61
|
+
window: opts.window,
|
|
62
|
+
ifPast: opts.ifPast,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const timeBuilder = (resolve: (time: string) => Date): TimeOfDayBuilder => ({
|
|
66
|
+
at: (time) => resolve(time),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
next(weekday: Weekday) {
|
|
71
|
+
return timeBuilder((time) =>
|
|
72
|
+
resolveNextWeekday(weekday, time, baseOpts()),
|
|
73
|
+
);
|
|
74
|
+
},
|
|
75
|
+
nextLocal(time: string) {
|
|
76
|
+
return resolveNextLocalTime(time, baseOpts());
|
|
77
|
+
},
|
|
78
|
+
tomorrow() {
|
|
79
|
+
return timeBuilder((time) => resolveTomorrow(time, baseOpts()));
|
|
80
|
+
},
|
|
81
|
+
in(duration: DurationObject) {
|
|
82
|
+
return timeBuilder((time) => resolveAfter(duration, time, baseOpts()));
|
|
83
|
+
},
|
|
84
|
+
tz(timezone: string) {
|
|
85
|
+
if (!isValidTimeZone(timezone)) {
|
|
86
|
+
throw new TypeError(`ctx.when.tz: invalid timezone "${timezone}"`);
|
|
87
|
+
}
|
|
88
|
+
return createWhenBuilder({ ...opts, timezone });
|
|
89
|
+
},
|
|
90
|
+
window(start: string, end: string) {
|
|
91
|
+
return createWhenBuilder({ ...opts, window: { start, end } });
|
|
92
|
+
},
|
|
93
|
+
ifPast(strategy: IfPast) {
|
|
94
|
+
return createWhenBuilder({ ...opts, ifPast: strategy });
|
|
95
|
+
},
|
|
96
|
+
};
|
|
24
97
|
}
|
|
25
98
|
|
|
26
99
|
export function createJourneyContext(
|
|
@@ -37,31 +110,63 @@ export function createJourneyContext(
|
|
|
37
110
|
userId,
|
|
38
111
|
userEmail,
|
|
39
112
|
journeyContext,
|
|
113
|
+
resolvedTimezone,
|
|
114
|
+
defaultSendWindow,
|
|
40
115
|
} = config;
|
|
41
116
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
117
|
+
// Shared wait lifecycle: mark the state "waiting", durably sleep, mark it
|
|
118
|
+
// "active" again. `sleep` passes a DurationObject; `sleepUntil` passes a
|
|
119
|
+
// precomputed ms delay — Hatchet's `sleepFor` accepts both.
|
|
120
|
+
const performSleep = async (
|
|
121
|
+
durationOrMs: DurationObject | number,
|
|
122
|
+
nodeId: string,
|
|
123
|
+
): Promise<{ sleptAt: string; resumedAt: string }> => {
|
|
124
|
+
const sleptAt = new Date().toISOString();
|
|
45
125
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
currentNodeId: label ?? `wait:${JSON.stringify(duration)}`,
|
|
51
|
-
updatedAt: new Date(),
|
|
52
|
-
})
|
|
53
|
-
.where(eq(journeyStates.id, stateId));
|
|
126
|
+
await db
|
|
127
|
+
.update(journeyStates)
|
|
128
|
+
.set({ status: "waiting", currentNodeId: nodeId, updatedAt: new Date() })
|
|
129
|
+
.where(eq(journeyStates.id, stateId));
|
|
54
130
|
|
|
55
|
-
|
|
131
|
+
await hatchetCtx.sleepFor(durationOrMs);
|
|
56
132
|
|
|
57
|
-
|
|
133
|
+
const resumedAt = new Date().toISOString();
|
|
58
134
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
135
|
+
await db
|
|
136
|
+
.update(journeyStates)
|
|
137
|
+
.set({ status: "active", updatedAt: new Date() })
|
|
138
|
+
.where(eq(journeyStates.id, stateId));
|
|
139
|
+
|
|
140
|
+
return { sleptAt, resumedAt };
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
when: createWhenBuilder({
|
|
145
|
+
timezone: resolvedTimezone,
|
|
146
|
+
window: defaultSendWindow,
|
|
147
|
+
ifPast: "next",
|
|
148
|
+
}),
|
|
149
|
+
|
|
150
|
+
async sleep({ duration, label }) {
|
|
151
|
+
return performSleep(
|
|
152
|
+
duration,
|
|
153
|
+
label ?? `wait:${JSON.stringify(duration)}`,
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async sleepUntil(at, opts) {
|
|
158
|
+
const target = at instanceof Date ? at.getTime() : new Date(at).getTime();
|
|
159
|
+
if (Number.isNaN(target)) {
|
|
160
|
+
throw new TypeError("sleepUntil: invalid date");
|
|
161
|
+
}
|
|
63
162
|
|
|
64
|
-
|
|
163
|
+
// Compute the wake delay ONCE. Durability comes from Hatchet preserving
|
|
164
|
+
// the deadline across replays/restarts; a past instant gives ms = 0.
|
|
165
|
+
const ms = Math.max(0, target - Date.now());
|
|
166
|
+
return performSleep(
|
|
167
|
+
ms,
|
|
168
|
+
opts?.label ?? `wait-until:${new Date(target).toISOString()}`,
|
|
169
|
+
);
|
|
65
170
|
},
|
|
66
171
|
|
|
67
172
|
async checkpoint(label) {
|
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,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { DurationObject } from "@hogsend/core";
|
|
1
2
|
import type {
|
|
2
3
|
EmailServiceRenderOptions,
|
|
3
4
|
EmailServiceRenderResult,
|
|
@@ -13,6 +14,7 @@ import type {
|
|
|
13
14
|
WebhookEventType,
|
|
14
15
|
WebhookHandlerMap,
|
|
15
16
|
} from "@hogsend/plugin-resend";
|
|
17
|
+
import type { Logger } from "./logger.js";
|
|
16
18
|
|
|
17
19
|
export type {
|
|
18
20
|
BatchEmailItem,
|
|
@@ -33,6 +35,9 @@ export interface SendTrackedEmailOptions<
|
|
|
33
35
|
to: string;
|
|
34
36
|
subject?: string;
|
|
35
37
|
journeyStateId?: string;
|
|
38
|
+
/** Denormalized recipient identity, persisted on the email_sends row for reporting. */
|
|
39
|
+
userId?: string;
|
|
40
|
+
userEmail?: string;
|
|
36
41
|
category?: string;
|
|
37
42
|
tags?: Array<{ name: string; value: string }>;
|
|
38
43
|
headers?: Record<string, string>;
|
|
@@ -44,7 +49,28 @@ export interface SendTrackedEmailOptions<
|
|
|
44
49
|
export interface TrackedSendResult {
|
|
45
50
|
emailSendId: string;
|
|
46
51
|
resendId: string;
|
|
47
|
-
status: "sent" | "suppressed" | "unsubscribed";
|
|
52
|
+
status: "sent" | "suppressed" | "unsubscribed" | "skipped";
|
|
53
|
+
/** Present only when `status === "skipped"` by the frequency cap. */
|
|
54
|
+
reason?: "frequency_capped";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Frequency capping (client default config)
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
export interface FrequencyCapWindow {
|
|
62
|
+
count: number;
|
|
63
|
+
window: DurationObject;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface FrequencyCapConfig {
|
|
67
|
+
/** Global send count allowed within `window` per recipient. */
|
|
68
|
+
count: number;
|
|
69
|
+
window: DurationObject;
|
|
70
|
+
/** Per-category overrides (count + window, filtered by that category). */
|
|
71
|
+
byCategory?: Record<string, FrequencyCapWindow>;
|
|
72
|
+
/** Categories exempt from capping. Defaults to ["transactional"]. */
|
|
73
|
+
exemptCategories?: string[];
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
// ---------------------------------------------------------------------------
|
|
@@ -66,6 +92,14 @@ export interface EmailServiceConfig {
|
|
|
66
92
|
retryOptions?: RetryOptions;
|
|
67
93
|
bounceThreshold?: number;
|
|
68
94
|
baseUrl?: string;
|
|
95
|
+
/**
|
|
96
|
+
* Optional per-client frequency cap. When set, sends are counted per
|
|
97
|
+
* recipient within the window and skipped (no provider call, no `sent` row)
|
|
98
|
+
* once the cap is reached. Opt-in: undefined ⇒ no capping.
|
|
99
|
+
*/
|
|
100
|
+
frequencyCap?: FrequencyCapConfig;
|
|
101
|
+
/** Optional structured logger; used e.g. to record frequency-cap skips. */
|
|
102
|
+
logger?: Logger;
|
|
69
103
|
}
|
|
70
104
|
|
|
71
105
|
export interface EmailServiceSendOptions<
|
|
@@ -77,6 +111,9 @@ export interface EmailServiceSendOptions<
|
|
|
77
111
|
from?: string;
|
|
78
112
|
subject?: string;
|
|
79
113
|
journeyStateId?: string;
|
|
114
|
+
/** Denormalized recipient identity, persisted on the email_sends row for reporting. */
|
|
115
|
+
userId?: string;
|
|
116
|
+
userEmail?: string;
|
|
80
117
|
category?: string;
|
|
81
118
|
tags?: Array<{ name: string; value: string }>;
|
|
82
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 },
|