@hogsend/engine 0.0.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/LICENSE +93 -0
- package/README.md +18 -0
- package/package.json +58 -0
- package/src/app.ts +102 -0
- package/src/container.ts +172 -0
- package/src/env.ts +56 -0
- package/src/index.ts +114 -0
- package/src/journeys/define-journey.ts +188 -0
- package/src/journeys/journey-context.ts +179 -0
- package/src/journeys/registry-singleton.ts +21 -0
- package/src/journeys/registry.ts +53 -0
- package/src/lib/alerting.ts +205 -0
- package/src/lib/api-key-hash.ts +19 -0
- package/src/lib/auth.ts +39 -0
- package/src/lib/backfill.ts +84 -0
- package/src/lib/contacts.ts +68 -0
- package/src/lib/db.ts +13 -0
- package/src/lib/email-service-types.ts +115 -0
- package/src/lib/email-stats.ts +33 -0
- package/src/lib/email.ts +94 -0
- package/src/lib/enrollment-guards.ts +56 -0
- package/src/lib/hatchet.ts +20 -0
- package/src/lib/html.ts +25 -0
- package/src/lib/ingestion.ts +162 -0
- package/src/lib/logger.ts +32 -0
- package/src/lib/mailer.ts +266 -0
- package/src/lib/notifications.ts +61 -0
- package/src/lib/posthog.ts +19 -0
- package/src/lib/redis.ts +30 -0
- package/src/lib/schemas.ts +8 -0
- package/src/lib/tracked.ts +175 -0
- package/src/lib/tracking-event-names.ts +5 -0
- package/src/lib/tracking-events.ts +84 -0
- package/src/lib/tracking.ts +78 -0
- package/src/middleware/api-key.ts +129 -0
- package/src/middleware/audit.ts +47 -0
- package/src/middleware/auth.ts +24 -0
- package/src/middleware/error-handler.ts +22 -0
- package/src/middleware/rate-limit.ts +65 -0
- package/src/middleware/request-logger.ts +19 -0
- package/src/routes/admin/alerts.ts +347 -0
- package/src/routes/admin/api-keys.ts +211 -0
- package/src/routes/admin/audit-logs.ts +102 -0
- package/src/routes/admin/bulk.ts +503 -0
- package/src/routes/admin/contacts.ts +342 -0
- package/src/routes/admin/dlq.ts +202 -0
- package/src/routes/admin/emails.ts +269 -0
- package/src/routes/admin/events.ts +132 -0
- package/src/routes/admin/index.ts +36 -0
- package/src/routes/admin/journey-logs.ts +117 -0
- package/src/routes/admin/journeys.ts +677 -0
- package/src/routes/admin/metrics.ts +559 -0
- package/src/routes/admin/preferences.ts +165 -0
- package/src/routes/admin/timeline.ts +221 -0
- package/src/routes/email/index.ts +8 -0
- package/src/routes/email/preferences.ts +144 -0
- package/src/routes/email/unsubscribe.ts +161 -0
- package/src/routes/health.ts +131 -0
- package/src/routes/index.ts +32 -0
- package/src/routes/ingest.ts +71 -0
- package/src/routes/tracking/click.ts +103 -0
- package/src/routes/tracking/index.ts +9 -0
- package/src/routes/tracking/open.ts +71 -0
- package/src/routes/webhooks/index.ts +17 -0
- package/src/routes/webhooks/resend.ts +68 -0
- package/src/routes/webhooks/sources.ts +97 -0
- package/src/webhook-sources/define-webhook-source.ts +34 -0
- package/src/worker.ts +64 -0
- package/src/workflows/check-alerts.ts +24 -0
- package/src/workflows/import-contacts.ts +134 -0
- package/src/workflows/send-email.ts +54 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
|
|
3
|
+
interface BackfillLogger {
|
|
4
|
+
info: (message: string) => unknown;
|
|
5
|
+
warn: (message: string) => unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface BatchedBackfillOptions {
|
|
9
|
+
db: Database;
|
|
10
|
+
logger: BackfillLogger;
|
|
11
|
+
/** Human label for logs, e.g. `contacts.normalized_email`. */
|
|
12
|
+
label: string;
|
|
13
|
+
/**
|
|
14
|
+
* Run one batch. MUST be idempotent and self-bounding — it should only touch
|
|
15
|
+
* rows that still need the change (e.g. `WHERE new_col IS NULL ... LIMIT n`)
|
|
16
|
+
* and return the number of rows affected. Return 0 when nothing is left.
|
|
17
|
+
*/
|
|
18
|
+
runBatch: (db: Database, batchSize: number) => Promise<number>;
|
|
19
|
+
/** Rows per batch. Small enough that each statement holds locks briefly. */
|
|
20
|
+
batchSize?: number;
|
|
21
|
+
/** Pause between batches (ms) to relieve pressure on a live database. */
|
|
22
|
+
pauseMs?: number;
|
|
23
|
+
/** Safety cap on total batches; logs and stops rather than looping forever. */
|
|
24
|
+
maxBatches?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BatchedBackfillResult {
|
|
28
|
+
batches: number;
|
|
29
|
+
rows: number;
|
|
30
|
+
/** True if the backfill ran to completion (a batch returned 0). */
|
|
31
|
+
exhausted: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run a long data migration in small, idempotent, lock-friendly batches.
|
|
36
|
+
*
|
|
37
|
+
* This is the supported home for bulk data changes — they must NOT live inside
|
|
38
|
+
* a schema migration, which would hold locks and run unbounded against a live
|
|
39
|
+
* database. Drive it from a Hatchet task so it's resumable and observable; if
|
|
40
|
+
* the process dies, re-running continues from where it left off because each
|
|
41
|
+
* batch only selects rows that still need work. See docs/UPGRADING.md.
|
|
42
|
+
*/
|
|
43
|
+
export async function runBatchedBackfill(
|
|
44
|
+
opts: BatchedBackfillOptions,
|
|
45
|
+
): Promise<BatchedBackfillResult> {
|
|
46
|
+
const {
|
|
47
|
+
db,
|
|
48
|
+
logger,
|
|
49
|
+
label,
|
|
50
|
+
runBatch,
|
|
51
|
+
batchSize = 500,
|
|
52
|
+
pauseMs = 0,
|
|
53
|
+
maxBatches = 100_000,
|
|
54
|
+
} = opts;
|
|
55
|
+
|
|
56
|
+
let batches = 0;
|
|
57
|
+
let rows = 0;
|
|
58
|
+
logger.info(`[backfill:${label}] starting (batchSize=${batchSize})`);
|
|
59
|
+
|
|
60
|
+
while (batches < maxBatches) {
|
|
61
|
+
const affected = await runBatch(db, batchSize);
|
|
62
|
+
if (affected === 0) {
|
|
63
|
+
logger.info(
|
|
64
|
+
`[backfill:${label}] complete — ${rows} row(s) across ${batches} batch(es)`,
|
|
65
|
+
);
|
|
66
|
+
return { batches, rows, exhausted: true };
|
|
67
|
+
}
|
|
68
|
+
batches += 1;
|
|
69
|
+
rows += affected;
|
|
70
|
+
if (batches % 20 === 0) {
|
|
71
|
+
logger.info(
|
|
72
|
+
`[backfill:${label}] progress — ${rows} row(s) across ${batches} batch(es)`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
if (pauseMs > 0) {
|
|
76
|
+
await new Promise((resolve) => setTimeout(resolve, pauseMs));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
logger.warn(
|
|
81
|
+
`[backfill:${label}] reached maxBatches=${maxBatches} — stopping. Re-run to continue.`,
|
|
82
|
+
);
|
|
83
|
+
return { batches, rows, exhausted: false };
|
|
84
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { contacts, type Database, type emailPreferences } from "@hogsend/db";
|
|
2
|
+
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
const UUID_REGEX =
|
|
5
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
6
|
+
|
|
7
|
+
export function contactWhereClause(id: string) {
|
|
8
|
+
return UUID_REGEX.test(id)
|
|
9
|
+
? eq(contacts.id, id)
|
|
10
|
+
: eq(contacts.externalId, id);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function resolveContact(opts: { db: Database; id: string }) {
|
|
14
|
+
const { db, id } = opts;
|
|
15
|
+
const rows = await db
|
|
16
|
+
.select()
|
|
17
|
+
.from(contacts)
|
|
18
|
+
.where(and(contactWhereClause(id), isNull(contacts.deletedAt)))
|
|
19
|
+
.limit(1);
|
|
20
|
+
return rows[0] ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function serializePrefs(row: typeof emailPreferences.$inferSelect) {
|
|
24
|
+
return {
|
|
25
|
+
id: row.id,
|
|
26
|
+
userId: row.userId,
|
|
27
|
+
email: row.email,
|
|
28
|
+
unsubscribedAll: row.unsubscribedAll,
|
|
29
|
+
suppressed: row.suppressed,
|
|
30
|
+
bounceCount: row.bounceCount,
|
|
31
|
+
categories: (row.categories ?? {}) as Record<string, boolean>,
|
|
32
|
+
suppressedAt: row.suppressedAt?.toISOString() ?? null,
|
|
33
|
+
lastBounceAt: row.lastBounceAt?.toISOString() ?? null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function contactSearchFilter(search: string) {
|
|
38
|
+
return or(
|
|
39
|
+
ilike(contacts.email, `%${search}%`),
|
|
40
|
+
ilike(contacts.externalId, `%${search}%`),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function upsertContact(opts: {
|
|
45
|
+
db: Database;
|
|
46
|
+
externalId: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
properties?: Record<string, unknown>;
|
|
49
|
+
}): Promise<void> {
|
|
50
|
+
const { db, externalId, email, properties } = opts;
|
|
51
|
+
|
|
52
|
+
await db
|
|
53
|
+
.insert(contacts)
|
|
54
|
+
.values({
|
|
55
|
+
externalId,
|
|
56
|
+
email: email || null,
|
|
57
|
+
properties: properties ?? {},
|
|
58
|
+
})
|
|
59
|
+
.onConflictDoUpdate({
|
|
60
|
+
target: contacts.externalId,
|
|
61
|
+
set: {
|
|
62
|
+
...(email ? { email } : {}),
|
|
63
|
+
properties: sql`COALESCE(${contacts.properties}, '{}'::jsonb) || ${JSON.stringify(properties ?? {})}::jsonb`,
|
|
64
|
+
lastSeenAt: new Date(),
|
|
65
|
+
updatedAt: new Date(),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
package/src/lib/db.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { createDatabase, type Database } from "@hogsend/db";
|
|
2
|
+
|
|
3
|
+
let _db: Database | undefined;
|
|
4
|
+
|
|
5
|
+
export function getDb(): Database {
|
|
6
|
+
if (!_db) {
|
|
7
|
+
const url = process.env.DATABASE_URL;
|
|
8
|
+
if (!url) throw new Error("DATABASE_URL is required");
|
|
9
|
+
const { db } = createDatabase({ url });
|
|
10
|
+
_db = db;
|
|
11
|
+
}
|
|
12
|
+
return _db;
|
|
13
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EmailServiceRenderOptions,
|
|
3
|
+
EmailServiceRenderResult,
|
|
4
|
+
RetryOptions,
|
|
5
|
+
TemplateName,
|
|
6
|
+
TemplateRegistry,
|
|
7
|
+
TemplateRegistryMap,
|
|
8
|
+
} from "@hogsend/email";
|
|
9
|
+
import type {
|
|
10
|
+
BatchEmailItem,
|
|
11
|
+
SendEmailOptions,
|
|
12
|
+
SendResult,
|
|
13
|
+
WebhookEventType,
|
|
14
|
+
WebhookHandlerMap,
|
|
15
|
+
} from "@hogsend/plugin-resend";
|
|
16
|
+
|
|
17
|
+
export type {
|
|
18
|
+
BatchEmailItem,
|
|
19
|
+
SendEmailOptions,
|
|
20
|
+
SendResult,
|
|
21
|
+
} from "@hogsend/plugin-resend";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Tracked email (high-level API)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface SendTrackedEmailOptions<
|
|
28
|
+
K extends TemplateName = TemplateName,
|
|
29
|
+
> {
|
|
30
|
+
templateKey: K;
|
|
31
|
+
props: TemplateRegistryMap[K];
|
|
32
|
+
from: string;
|
|
33
|
+
to: string;
|
|
34
|
+
subject?: string;
|
|
35
|
+
journeyStateId?: string;
|
|
36
|
+
category?: string;
|
|
37
|
+
tags?: Array<{ name: string; value: string }>;
|
|
38
|
+
headers?: Record<string, string>;
|
|
39
|
+
replyTo?: string | string[];
|
|
40
|
+
skipPreferenceCheck?: boolean;
|
|
41
|
+
baseUrl?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface TrackedSendResult {
|
|
45
|
+
emailSendId: string;
|
|
46
|
+
resendId: string;
|
|
47
|
+
status: "sent" | "suppressed" | "unsubscribed";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Email service (high-level DX) — engine-owned tracked mailer
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export interface EmailServiceConfig {
|
|
55
|
+
defaultFrom: string;
|
|
56
|
+
/**
|
|
57
|
+
* The client app's template registry (key → component + subject + category).
|
|
58
|
+
* Threaded into `getTemplate(..., { registry })` at send + render time so the
|
|
59
|
+
* engine never bakes in concrete business templates. Required to send/render
|
|
60
|
+
* any template; an empty registry simply has no sendable keys.
|
|
61
|
+
*/
|
|
62
|
+
templates: TemplateRegistry;
|
|
63
|
+
db?: unknown;
|
|
64
|
+
webhookSecret?: string;
|
|
65
|
+
webhookHandlers?: WebhookHandlerMap;
|
|
66
|
+
retryOptions?: RetryOptions;
|
|
67
|
+
bounceThreshold?: number;
|
|
68
|
+
baseUrl?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface EmailServiceSendOptions<
|
|
72
|
+
K extends TemplateName = TemplateName,
|
|
73
|
+
> {
|
|
74
|
+
template: K;
|
|
75
|
+
props: TemplateRegistryMap[K];
|
|
76
|
+
to: string;
|
|
77
|
+
from?: string;
|
|
78
|
+
subject?: string;
|
|
79
|
+
journeyStateId?: string;
|
|
80
|
+
category?: string;
|
|
81
|
+
tags?: Array<{ name: string; value: string }>;
|
|
82
|
+
headers?: Record<string, string>;
|
|
83
|
+
replyTo?: string | string[];
|
|
84
|
+
skipPreferenceCheck?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface EmailServiceWebhookOptions {
|
|
88
|
+
payload: string;
|
|
89
|
+
headers: Record<string, string>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface EmailServiceWebhookResult {
|
|
93
|
+
type: WebhookEventType;
|
|
94
|
+
handled: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EmailService {
|
|
98
|
+
send<K extends TemplateName>(
|
|
99
|
+
options: EmailServiceSendOptions<K>,
|
|
100
|
+
): Promise<TrackedSendResult>;
|
|
101
|
+
|
|
102
|
+
sendRaw(options: SendEmailOptions): Promise<SendResult>;
|
|
103
|
+
|
|
104
|
+
sendBatch(options: { emails: BatchEmailItem[] }): Promise<{
|
|
105
|
+
results: SendResult[];
|
|
106
|
+
}>;
|
|
107
|
+
|
|
108
|
+
render<K extends TemplateName>(
|
|
109
|
+
options: EmailServiceRenderOptions<K>,
|
|
110
|
+
): Promise<EmailServiceRenderResult>;
|
|
111
|
+
|
|
112
|
+
handleWebhook(
|
|
113
|
+
options: EmailServiceWebhookOptions,
|
|
114
|
+
): Promise<EmailServiceWebhookResult>;
|
|
115
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type Database, emailSends } from "@hogsend/db";
|
|
2
|
+
import { count, gte, sql } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
export interface EmailStatsResult {
|
|
5
|
+
total: number;
|
|
6
|
+
delivered: number;
|
|
7
|
+
bounced: number;
|
|
8
|
+
complained: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function getEmailStats(opts: {
|
|
12
|
+
db: Database;
|
|
13
|
+
since: Date;
|
|
14
|
+
}): Promise<EmailStatsResult> {
|
|
15
|
+
const { db, since } = opts;
|
|
16
|
+
const rows = await db
|
|
17
|
+
.select({
|
|
18
|
+
total: count(),
|
|
19
|
+
delivered: sql<number>`count(*) filter (where ${emailSends.status} = 'delivered')`,
|
|
20
|
+
bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
|
|
21
|
+
complained: sql<number>`count(*) filter (where ${emailSends.status} = 'complained')`,
|
|
22
|
+
})
|
|
23
|
+
.from(emailSends)
|
|
24
|
+
.where(gte(emailSends.createdAt, since));
|
|
25
|
+
|
|
26
|
+
const row = rows[0];
|
|
27
|
+
return {
|
|
28
|
+
total: row?.total ?? 0,
|
|
29
|
+
delivered: Number(row?.delivered ?? 0),
|
|
30
|
+
bounced: Number(row?.bounced ?? 0),
|
|
31
|
+
complained: Number(row?.complained ?? 0),
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/lib/email.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { generateUnsubscribeUrl } from "@hogsend/email";
|
|
2
|
+
import type {
|
|
3
|
+
EmailService,
|
|
4
|
+
EmailServiceSendOptions,
|
|
5
|
+
} from "./email-service-types.js";
|
|
6
|
+
|
|
7
|
+
let _service: EmailService | null = null;
|
|
8
|
+
|
|
9
|
+
export function setEmailService(service: EmailService): void {
|
|
10
|
+
_service = service;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getService(): EmailService {
|
|
14
|
+
if (!_service) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"Email service not initialized. Call setEmailService() at startup.",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
return _service;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SendEmailOptions {
|
|
23
|
+
to: string;
|
|
24
|
+
userId: string;
|
|
25
|
+
template: string;
|
|
26
|
+
subject: string;
|
|
27
|
+
journeyName?: string;
|
|
28
|
+
journeyStateId?: string;
|
|
29
|
+
props?: Record<string, unknown>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SendEmailResult {
|
|
33
|
+
emailSendId: string;
|
|
34
|
+
sentAt: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function sendEmail(
|
|
38
|
+
opts: SendEmailOptions,
|
|
39
|
+
): Promise<SendEmailResult> {
|
|
40
|
+
const service = getService();
|
|
41
|
+
|
|
42
|
+
let unsubscribeUrl: string | undefined;
|
|
43
|
+
if (process.env.API_PUBLIC_URL && process.env.BETTER_AUTH_SECRET) {
|
|
44
|
+
unsubscribeUrl = generateUnsubscribeUrl({
|
|
45
|
+
baseUrl: process.env.API_PUBLIC_URL,
|
|
46
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
47
|
+
externalId: opts.userId,
|
|
48
|
+
email: opts.to,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const headers: Record<string, string> = {};
|
|
53
|
+
if (unsubscribeUrl) {
|
|
54
|
+
headers["List-Unsubscribe"] = `<${unsubscribeUrl}>`;
|
|
55
|
+
headers["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// `sendEmail` is the loose, runtime-string entry point used by journeys: the
|
|
59
|
+
// template key and props are resolved at runtime, so we build the options
|
|
60
|
+
// untyped and hand them to the typed `service.send`. Type-safe call sites use
|
|
61
|
+
// `container.emailService.send({ template, props })` directly.
|
|
62
|
+
const sendOptions = {
|
|
63
|
+
template: opts.template,
|
|
64
|
+
props: {
|
|
65
|
+
...opts.props,
|
|
66
|
+
name:
|
|
67
|
+
(opts.props?.firstName as string) ??
|
|
68
|
+
(opts.props?.name as string) ??
|
|
69
|
+
opts.to.split("@")[0] ??
|
|
70
|
+
"there",
|
|
71
|
+
journeyName: opts.journeyName ?? opts.template,
|
|
72
|
+
eventName: opts.template,
|
|
73
|
+
body: opts.subject,
|
|
74
|
+
unsubscribeUrl,
|
|
75
|
+
},
|
|
76
|
+
to: opts.to,
|
|
77
|
+
subject: opts.subject,
|
|
78
|
+
journeyStateId: opts.journeyStateId,
|
|
79
|
+
category: "journey",
|
|
80
|
+
tags: [
|
|
81
|
+
{ name: "journeyId", value: opts.journeyName ?? opts.template },
|
|
82
|
+
{ name: "templateKey", value: opts.template },
|
|
83
|
+
{ name: "userId", value: opts.userId },
|
|
84
|
+
],
|
|
85
|
+
headers,
|
|
86
|
+
} as unknown as EmailServiceSendOptions;
|
|
87
|
+
|
|
88
|
+
const result = await service.send(sendOptions);
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
emailSendId: result.emailSendId,
|
|
92
|
+
sentAt: new Date().toISOString(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { durationToMs, hours } from "@hogsend/core";
|
|
2
|
+
import type { JourneyMeta } from "@hogsend/core/types";
|
|
3
|
+
import { type Database, emailPreferences, journeyStates } from "@hogsend/db";
|
|
4
|
+
import { and, eq } from "drizzle-orm";
|
|
5
|
+
|
|
6
|
+
export async function checkEntryLimit(opts: {
|
|
7
|
+
db: Database;
|
|
8
|
+
journey: JourneyMeta;
|
|
9
|
+
userId: string;
|
|
10
|
+
}): Promise<{ allowed: boolean; reason?: string }> {
|
|
11
|
+
const { db, journey, userId } = opts;
|
|
12
|
+
if (journey.entryLimit === "unlimited") return { allowed: true };
|
|
13
|
+
|
|
14
|
+
if (journey.entryLimit === "once") {
|
|
15
|
+
const existing = await db.query.journeyStates.findFirst({
|
|
16
|
+
where: and(
|
|
17
|
+
eq(journeyStates.userId, userId),
|
|
18
|
+
eq(journeyStates.journeyId, journey.id),
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
return existing
|
|
22
|
+
? { allowed: false, reason: "already_entered_once" }
|
|
23
|
+
: { allowed: true };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (journey.entryLimit === "once_per_period") {
|
|
27
|
+
const periodMs = durationToMs(journey.entryPeriod ?? hours(24));
|
|
28
|
+
const cutoff = new Date(Date.now() - periodMs);
|
|
29
|
+
|
|
30
|
+
const existing = await db.query.journeyStates.findFirst({
|
|
31
|
+
where: and(
|
|
32
|
+
eq(journeyStates.userId, userId),
|
|
33
|
+
eq(journeyStates.journeyId, journey.id),
|
|
34
|
+
),
|
|
35
|
+
orderBy: (states, { desc }) => [desc(states.createdAt)],
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return existing && existing.createdAt > cutoff
|
|
39
|
+
? { allowed: false, reason: "period_not_elapsed" }
|
|
40
|
+
: { allowed: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { allowed: true };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function checkEmailPreferences(opts: {
|
|
47
|
+
db: Database;
|
|
48
|
+
userId: string;
|
|
49
|
+
}): Promise<{ unsubscribed: boolean }> {
|
|
50
|
+
const { db, userId } = opts;
|
|
51
|
+
const prefs = await db.query.emailPreferences.findFirst({
|
|
52
|
+
where: eq(emailPreferences.userId, userId),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return { unsubscribed: prefs?.unsubscribedAll ?? false };
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import { env } from "../env.js";
|
|
3
|
+
|
|
4
|
+
// Construct the Hatchet client from our validated env contract so host/port and
|
|
5
|
+
// TLS strategy are honoured from a single source of truth, rather than relying on
|
|
6
|
+
// the SDK's independent process.env read. The SDK would read the same vars on its
|
|
7
|
+
// own, but passing them explicitly keeps env.ts authoritative and makes the
|
|
8
|
+
// connection config debuggable.
|
|
9
|
+
//
|
|
10
|
+
// `tls_config.tls_strategy` defaults to `tls` (secure); the local insecure
|
|
11
|
+
// hatchet-lite path sets HATCHET_CLIENT_TLS_STRATEGY=none. `namespace` is the
|
|
12
|
+
// future per-tenant knob (default-empty today).
|
|
13
|
+
export const hatchet = HatchetClient.init({
|
|
14
|
+
token: env.HATCHET_CLIENT_TOKEN,
|
|
15
|
+
host_port: env.HATCHET_CLIENT_HOST_PORT,
|
|
16
|
+
tls_config: { tls_strategy: env.HATCHET_CLIENT_TLS_STRATEGY },
|
|
17
|
+
...(env.HATCHET_CLIENT_NAMESPACE
|
|
18
|
+
? { namespace: env.HATCHET_CLIENT_NAMESPACE }
|
|
19
|
+
: {}),
|
|
20
|
+
});
|
package/src/lib/html.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function htmlPage(opts: { title: string; body: string }): string {
|
|
2
|
+
const { title, body } = opts;
|
|
3
|
+
return `<!DOCTYPE html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
<title>${title}</title>
|
|
9
|
+
<style>
|
|
10
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 20px; color: #1a1a1a; }
|
|
11
|
+
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
12
|
+
p { color: #555; line-height: 1.6; }
|
|
13
|
+
a { color: #2563eb; text-decoration: none; }
|
|
14
|
+
a:hover { text-decoration: underline; }
|
|
15
|
+
.pref-row { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid #e5e7eb; }
|
|
16
|
+
.pref-label { font-weight: 500; }
|
|
17
|
+
.pref-status { font-size: 0.875rem; }
|
|
18
|
+
.subscribed { color: #16a34a; }
|
|
19
|
+
.unsubscribed { color: #dc2626; }
|
|
20
|
+
.global-row { margin-top: 24px; padding-top: 16px; border-top: 2px solid #e5e7eb; }
|
|
21
|
+
</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>${body}</body>
|
|
24
|
+
</html>`;
|
|
25
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
2
|
+
import { evaluatePropertyConditions } from "@hogsend/core";
|
|
3
|
+
import type { JourneyRegistry } from "@hogsend/core/registry";
|
|
4
|
+
import { type Database, journeyStates, userEvents } from "@hogsend/db";
|
|
5
|
+
import { and, eq, inArray, isNull } from "drizzle-orm";
|
|
6
|
+
import { upsertContact } from "./contacts.js";
|
|
7
|
+
import type { Logger } from "./logger.js";
|
|
8
|
+
|
|
9
|
+
export interface IngestEvent {
|
|
10
|
+
event: string;
|
|
11
|
+
userId: string;
|
|
12
|
+
userEmail: string;
|
|
13
|
+
properties: Record<string, unknown>;
|
|
14
|
+
idempotencyKey?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExitResult {
|
|
18
|
+
journeyId: string;
|
|
19
|
+
stateId: string;
|
|
20
|
+
exited: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IngestResult {
|
|
24
|
+
stored: boolean;
|
|
25
|
+
exits: ExitResult[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ingestEvent(opts: {
|
|
29
|
+
db: Database;
|
|
30
|
+
registry: JourneyRegistry;
|
|
31
|
+
hatchet: HatchetClient;
|
|
32
|
+
logger: Logger;
|
|
33
|
+
event: IngestEvent;
|
|
34
|
+
}): Promise<IngestResult> {
|
|
35
|
+
const { db, registry, hatchet, logger, event } = opts;
|
|
36
|
+
|
|
37
|
+
if (event.idempotencyKey) {
|
|
38
|
+
const result = await db
|
|
39
|
+
.insert(userEvents)
|
|
40
|
+
.values({
|
|
41
|
+
userId: event.userId,
|
|
42
|
+
event: event.event,
|
|
43
|
+
properties: event.properties,
|
|
44
|
+
idempotencyKey: event.idempotencyKey,
|
|
45
|
+
})
|
|
46
|
+
.onConflictDoNothing({
|
|
47
|
+
target: userEvents.idempotencyKey,
|
|
48
|
+
})
|
|
49
|
+
.returning({ id: userEvents.id });
|
|
50
|
+
|
|
51
|
+
if (result.length === 0) {
|
|
52
|
+
return { stored: false, exits: [] };
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
await db.insert(userEvents).values({
|
|
56
|
+
userId: event.userId,
|
|
57
|
+
event: event.event,
|
|
58
|
+
properties: event.properties,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const serializableProperties = Object.fromEntries(
|
|
63
|
+
Object.entries(event.properties).filter(
|
|
64
|
+
([, v]) =>
|
|
65
|
+
typeof v === "string" ||
|
|
66
|
+
typeof v === "number" ||
|
|
67
|
+
typeof v === "boolean" ||
|
|
68
|
+
v === null,
|
|
69
|
+
),
|
|
70
|
+
) as Record<string, string | number | boolean | null>;
|
|
71
|
+
|
|
72
|
+
const [, exits] = await Promise.all([
|
|
73
|
+
hatchet.events.push(event.event, {
|
|
74
|
+
userId: event.userId,
|
|
75
|
+
userEmail: event.userEmail,
|
|
76
|
+
properties: serializableProperties,
|
|
77
|
+
}),
|
|
78
|
+
checkExits(db, registry, {
|
|
79
|
+
userId: event.userId,
|
|
80
|
+
eventName: event.event,
|
|
81
|
+
properties: event.properties,
|
|
82
|
+
}),
|
|
83
|
+
upsertContact({
|
|
84
|
+
db,
|
|
85
|
+
externalId: event.userId,
|
|
86
|
+
email: event.userEmail || undefined,
|
|
87
|
+
properties: event.properties,
|
|
88
|
+
}).catch((err) => {
|
|
89
|
+
logger.warn("Contact upsert failed", {
|
|
90
|
+
userId: event.userId,
|
|
91
|
+
error: err instanceof Error ? err.message : String(err),
|
|
92
|
+
});
|
|
93
|
+
}),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
logger.info("Event ingested", {
|
|
97
|
+
event: event.event,
|
|
98
|
+
userId: event.userId,
|
|
99
|
+
exits: exits.filter((e) => e.exited).length,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { stored: true, exits };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function checkExits(
|
|
106
|
+
db: Database,
|
|
107
|
+
registry: JourneyRegistry,
|
|
108
|
+
event: {
|
|
109
|
+
userId: string;
|
|
110
|
+
eventName: string;
|
|
111
|
+
properties: Record<string, unknown>;
|
|
112
|
+
},
|
|
113
|
+
): Promise<ExitResult[]> {
|
|
114
|
+
const results: ExitResult[] = [];
|
|
115
|
+
|
|
116
|
+
const activeStates = await db.query.journeyStates.findMany({
|
|
117
|
+
where: and(
|
|
118
|
+
eq(journeyStates.userId, event.userId),
|
|
119
|
+
inArray(journeyStates.status, ["active", "waiting"]),
|
|
120
|
+
isNull(journeyStates.deletedAt),
|
|
121
|
+
),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const statesToExit: string[] = [];
|
|
125
|
+
|
|
126
|
+
for (const state of activeStates) {
|
|
127
|
+
const journey = registry.get(state.journeyId);
|
|
128
|
+
if (!journey?.exitOn) continue;
|
|
129
|
+
|
|
130
|
+
const shouldExit = journey.exitOn.some((exitCondition) => {
|
|
131
|
+
if (exitCondition.event !== event.eventName) return false;
|
|
132
|
+
if (!exitCondition.where?.length) return true;
|
|
133
|
+
return evaluatePropertyConditions({
|
|
134
|
+
conditions: exitCondition.where,
|
|
135
|
+
properties: event.properties,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (shouldExit) {
|
|
140
|
+
statesToExit.push(state.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
results.push({
|
|
144
|
+
journeyId: state.journeyId,
|
|
145
|
+
stateId: state.id,
|
|
146
|
+
exited: shouldExit,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (statesToExit.length > 0) {
|
|
151
|
+
await db
|
|
152
|
+
.update(journeyStates)
|
|
153
|
+
.set({
|
|
154
|
+
status: "exited",
|
|
155
|
+
exitedAt: new Date(),
|
|
156
|
+
updatedAt: new Date(),
|
|
157
|
+
})
|
|
158
|
+
.where(inArray(journeyStates.id, statesToExit));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return results;
|
|
162
|
+
}
|