@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,71 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../app.js";
|
|
3
|
+
import { ingestEvent } from "../lib/ingestion.js";
|
|
4
|
+
|
|
5
|
+
const ingestRequestSchema = z.object({
|
|
6
|
+
event: z.string().min(1),
|
|
7
|
+
userId: z.string().min(1),
|
|
8
|
+
userEmail: z.string().email().optional(),
|
|
9
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
10
|
+
idempotencyKey: z.string().optional(),
|
|
11
|
+
timestamp: z.string().datetime().optional(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const ingestResponseSchema = z.object({
|
|
15
|
+
stored: z.boolean(),
|
|
16
|
+
exits: z.array(
|
|
17
|
+
z.object({
|
|
18
|
+
journeyId: z.string(),
|
|
19
|
+
stateId: z.string(),
|
|
20
|
+
exited: z.boolean(),
|
|
21
|
+
}),
|
|
22
|
+
),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ingestRoute = createRoute({
|
|
26
|
+
method: "post",
|
|
27
|
+
path: "/",
|
|
28
|
+
tags: ["Ingestion"],
|
|
29
|
+
summary: "Ingest an event",
|
|
30
|
+
description:
|
|
31
|
+
"Receives events from direct API calls. Stores the event, pushes it to Hatchet for journey routing, and processes exit conditions.",
|
|
32
|
+
request: {
|
|
33
|
+
body: {
|
|
34
|
+
content: {
|
|
35
|
+
"application/json": { schema: ingestRequestSchema },
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
responses: {
|
|
40
|
+
202: {
|
|
41
|
+
content: {
|
|
42
|
+
"application/json": { schema: ingestResponseSchema },
|
|
43
|
+
},
|
|
44
|
+
description: "Event accepted and dispatched",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const ingestRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
50
|
+
ingestRoute,
|
|
51
|
+
async (c) => {
|
|
52
|
+
const body = c.req.valid("json");
|
|
53
|
+
const { db, registry, hatchet, logger } = c.get("container");
|
|
54
|
+
|
|
55
|
+
const result = await ingestEvent({
|
|
56
|
+
db,
|
|
57
|
+
registry,
|
|
58
|
+
hatchet,
|
|
59
|
+
logger,
|
|
60
|
+
event: {
|
|
61
|
+
event: body.event,
|
|
62
|
+
userId: body.userId,
|
|
63
|
+
userEmail: body.userEmail ?? "",
|
|
64
|
+
properties: body.properties ?? {},
|
|
65
|
+
idempotencyKey: body.idempotencyKey,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return c.json(result, 202);
|
|
70
|
+
},
|
|
71
|
+
);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { emailSends, linkClicks, trackedLinks } from "@hogsend/db";
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import { and, eq, isNull, sql } from "drizzle-orm";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { EMAIL_LINK_CLICKED } from "../../lib/tracking-event-names.js";
|
|
6
|
+
import { pushTrackingEvent } from "../../lib/tracking-events.js";
|
|
7
|
+
|
|
8
|
+
const clickRoute = createRoute({
|
|
9
|
+
method: "get",
|
|
10
|
+
path: "/c/:id",
|
|
11
|
+
tags: ["Tracking"],
|
|
12
|
+
summary: "Track link click and redirect",
|
|
13
|
+
request: {
|
|
14
|
+
params: z.object({
|
|
15
|
+
id: z.string().uuid(),
|
|
16
|
+
}),
|
|
17
|
+
},
|
|
18
|
+
responses: {
|
|
19
|
+
302: { description: "Redirect to original URL" },
|
|
20
|
+
404: { description: "Link not found" },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const clickRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
25
|
+
clickRoute,
|
|
26
|
+
async (c) => {
|
|
27
|
+
const { id } = c.req.valid("param");
|
|
28
|
+
const { db, env } = c.get("container");
|
|
29
|
+
|
|
30
|
+
const rows = await db
|
|
31
|
+
.select({
|
|
32
|
+
id: trackedLinks.id,
|
|
33
|
+
originalUrl: trackedLinks.originalUrl,
|
|
34
|
+
emailSendId: trackedLinks.emailSendId,
|
|
35
|
+
})
|
|
36
|
+
.from(trackedLinks)
|
|
37
|
+
.where(eq(trackedLinks.id, id))
|
|
38
|
+
.limit(1);
|
|
39
|
+
|
|
40
|
+
const link = rows[0];
|
|
41
|
+
if (!link) {
|
|
42
|
+
return c.redirect(env.API_PUBLIC_URL, 302);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const ip =
|
|
46
|
+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
47
|
+
c.req.header("x-real-ip") ??
|
|
48
|
+
null;
|
|
49
|
+
const userAgent = c.req.header("user-agent") ?? null;
|
|
50
|
+
|
|
51
|
+
await Promise.all([
|
|
52
|
+
db.insert(linkClicks).values({
|
|
53
|
+
trackedLinkId: link.id,
|
|
54
|
+
ipAddress: ip,
|
|
55
|
+
userAgent,
|
|
56
|
+
}),
|
|
57
|
+
db
|
|
58
|
+
.update(trackedLinks)
|
|
59
|
+
.set({
|
|
60
|
+
clickCount: sql`${trackedLinks.clickCount} + 1`,
|
|
61
|
+
updatedAt: new Date(),
|
|
62
|
+
})
|
|
63
|
+
.where(eq(trackedLinks.id, link.id)),
|
|
64
|
+
db
|
|
65
|
+
.update(emailSends)
|
|
66
|
+
.set({
|
|
67
|
+
clickedAt: new Date(),
|
|
68
|
+
updatedAt: new Date(),
|
|
69
|
+
})
|
|
70
|
+
.where(
|
|
71
|
+
and(
|
|
72
|
+
eq(emailSends.id, link.emailSendId),
|
|
73
|
+
isNull(emailSends.clickedAt),
|
|
74
|
+
),
|
|
75
|
+
),
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
const {
|
|
79
|
+
hatchet,
|
|
80
|
+
registry,
|
|
81
|
+
logger,
|
|
82
|
+
analytics: posthog,
|
|
83
|
+
} = c.get("container");
|
|
84
|
+
|
|
85
|
+
pushTrackingEvent({
|
|
86
|
+
db,
|
|
87
|
+
hatchet,
|
|
88
|
+
registry,
|
|
89
|
+
logger,
|
|
90
|
+
posthog,
|
|
91
|
+
event: EMAIL_LINK_CLICKED,
|
|
92
|
+
emailSendId: link.emailSendId,
|
|
93
|
+
properties: { linkUrl: link.originalUrl, linkId: link.id },
|
|
94
|
+
}).catch((err) => {
|
|
95
|
+
logger.warn("Failed to push click tracking event", {
|
|
96
|
+
linkId: link.id,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return c.redirect(link.originalUrl, 302);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import { clickRouter } from "./click.js";
|
|
4
|
+
import { openRouter } from "./open.js";
|
|
5
|
+
|
|
6
|
+
export const trackingRouter = new OpenAPIHono<AppEnv>();
|
|
7
|
+
|
|
8
|
+
trackingRouter.route("/", clickRouter);
|
|
9
|
+
trackingRouter.route("/", openRouter);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { emailSends } from "@hogsend/db";
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { EMAIL_OPENED } from "../../lib/tracking-event-names.js";
|
|
6
|
+
import { pushTrackingEvent } from "../../lib/tracking-events.js";
|
|
7
|
+
|
|
8
|
+
const TRANSPARENT_GIF = Buffer.from(
|
|
9
|
+
"R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
|
10
|
+
"base64",
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
const openRoute = createRoute({
|
|
14
|
+
method: "get",
|
|
15
|
+
path: "/o/:id",
|
|
16
|
+
tags: ["Tracking"],
|
|
17
|
+
summary: "Track email open",
|
|
18
|
+
request: {
|
|
19
|
+
params: z.object({
|
|
20
|
+
id: z.string().uuid(),
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
responses: {
|
|
24
|
+
200: { description: "1x1 transparent GIF" },
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const openRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
29
|
+
openRoute,
|
|
30
|
+
async (c) => {
|
|
31
|
+
const { id } = c.req.valid("param");
|
|
32
|
+
const { db } = c.get("container");
|
|
33
|
+
|
|
34
|
+
await db
|
|
35
|
+
.update(emailSends)
|
|
36
|
+
.set({
|
|
37
|
+
openedAt: new Date(),
|
|
38
|
+
updatedAt: new Date(),
|
|
39
|
+
})
|
|
40
|
+
.where(and(eq(emailSends.id, id), isNull(emailSends.openedAt)));
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
hatchet,
|
|
44
|
+
registry,
|
|
45
|
+
logger,
|
|
46
|
+
analytics: posthog,
|
|
47
|
+
} = c.get("container");
|
|
48
|
+
|
|
49
|
+
pushTrackingEvent({
|
|
50
|
+
db,
|
|
51
|
+
hatchet,
|
|
52
|
+
registry,
|
|
53
|
+
logger,
|
|
54
|
+
posthog,
|
|
55
|
+
event: EMAIL_OPENED,
|
|
56
|
+
emailSendId: id,
|
|
57
|
+
}).catch((err) => {
|
|
58
|
+
logger.warn("Failed to push open tracking event", {
|
|
59
|
+
emailSendId: id,
|
|
60
|
+
error: err instanceof Error ? err.message : String(err),
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return c.body(TRANSPARENT_GIF, 200, {
|
|
65
|
+
"Content-Type": "image/gif",
|
|
66
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
67
|
+
Pragma: "no-cache",
|
|
68
|
+
Expires: "0",
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
|
|
4
|
+
import { resendWebhookRouter } from "./resend.js";
|
|
5
|
+
import { registerWebhookSourceRoutes } from "./sources.js";
|
|
6
|
+
|
|
7
|
+
export interface RegisterWebhookRoutesOptions {
|
|
8
|
+
webhookSources: DefinedWebhookSource[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function registerWebhookRoutes(
|
|
12
|
+
app: OpenAPIHono<AppEnv>,
|
|
13
|
+
opts: RegisterWebhookRoutesOptions,
|
|
14
|
+
) {
|
|
15
|
+
app.route("/v1/webhooks", resendWebhookRouter);
|
|
16
|
+
registerWebhookSourceRoutes(app, opts.webhookSources);
|
|
17
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
|
|
4
|
+
const resendWebhookRoute = createRoute({
|
|
5
|
+
method: "post",
|
|
6
|
+
path: "/resend",
|
|
7
|
+
tags: ["Webhooks"],
|
|
8
|
+
summary: "Resend webhook receiver",
|
|
9
|
+
request: {
|
|
10
|
+
body: {
|
|
11
|
+
content: {
|
|
12
|
+
"application/json": {
|
|
13
|
+
schema: z.record(z.string(), z.unknown()),
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
responses: {
|
|
19
|
+
200: {
|
|
20
|
+
content: {
|
|
21
|
+
"application/json": {
|
|
22
|
+
schema: z.object({ ok: z.boolean() }),
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
description: "Webhook processed",
|
|
26
|
+
},
|
|
27
|
+
401: {
|
|
28
|
+
content: {
|
|
29
|
+
"application/json": {
|
|
30
|
+
schema: z.object({ error: z.string() }),
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
description: "Missing or invalid webhook secret",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const resendWebhookRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
39
|
+
resendWebhookRoute,
|
|
40
|
+
async (c) => {
|
|
41
|
+
const { emailService, logger } = c.get("container");
|
|
42
|
+
|
|
43
|
+
const rawBody = await c.req.text();
|
|
44
|
+
const headers: Record<string, string> = {};
|
|
45
|
+
for (const [key, value] of c.req.raw.headers.entries()) {
|
|
46
|
+
headers[key] = value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const result = await emailService.handleWebhook({
|
|
51
|
+
payload: rawBody,
|
|
52
|
+
headers,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
logger.info("Resend webhook processed", {
|
|
56
|
+
type: result.type,
|
|
57
|
+
handled: result.handled,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return c.json({ ok: true }, 200);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
logger.warn("Resend webhook failed", {
|
|
63
|
+
error: err instanceof Error ? err.message : String(err),
|
|
64
|
+
});
|
|
65
|
+
return c.json({ error: "Webhook verification failed" }, 401);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createRoute, type OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import { ingestEvent } from "../../lib/ingestion.js";
|
|
4
|
+
import type { DefinedWebhookSource } from "../../webhook-sources/define-webhook-source.js";
|
|
5
|
+
|
|
6
|
+
export function registerWebhookSourceRoutes(
|
|
7
|
+
app: OpenAPIHono<AppEnv>,
|
|
8
|
+
sources: DefinedWebhookSource[],
|
|
9
|
+
) {
|
|
10
|
+
const sourceMap = new Map(sources.map((s) => [s.meta.id, s]));
|
|
11
|
+
|
|
12
|
+
const webhookRoute = createRoute({
|
|
13
|
+
method: "post",
|
|
14
|
+
path: "/v1/webhooks/{sourceId}",
|
|
15
|
+
request: {
|
|
16
|
+
params: z.object({ sourceId: z.string() }),
|
|
17
|
+
},
|
|
18
|
+
responses: {
|
|
19
|
+
200: {
|
|
20
|
+
description: "Webhook accepted",
|
|
21
|
+
content: {
|
|
22
|
+
"application/json": {
|
|
23
|
+
schema: z.object({
|
|
24
|
+
ok: z.boolean(),
|
|
25
|
+
skipped: z.boolean().optional(),
|
|
26
|
+
event: z.string().optional(),
|
|
27
|
+
userId: z.string().optional(),
|
|
28
|
+
exits: z.number().optional(),
|
|
29
|
+
}),
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
400: {
|
|
34
|
+
description: "Invalid payload",
|
|
35
|
+
},
|
|
36
|
+
401: {
|
|
37
|
+
description: "Unauthorized",
|
|
38
|
+
},
|
|
39
|
+
404: {
|
|
40
|
+
description: "Source not found",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.openapi(webhookRoute, async (c) => {
|
|
46
|
+
const { sourceId } = c.req.valid("param");
|
|
47
|
+
const source = sourceMap.get(sourceId);
|
|
48
|
+
|
|
49
|
+
if (!source) {
|
|
50
|
+
return c.json({ error: "Unknown webhook source" }, 404);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const { db, logger, env, registry, hatchet } = c.get("container");
|
|
54
|
+
|
|
55
|
+
// Auth is enforced only when the source's secret is configured. An
|
|
56
|
+
// unconfigured source is treated as open (parity with the pre-engine route).
|
|
57
|
+
const secret = env[source.auth.envKey as keyof typeof env] as
|
|
58
|
+
| string
|
|
59
|
+
| undefined;
|
|
60
|
+
if (secret) {
|
|
61
|
+
const provided =
|
|
62
|
+
c.req.header(source.auth.header) ??
|
|
63
|
+
c.req.header("authorization")?.replace("Bearer ", "");
|
|
64
|
+
|
|
65
|
+
if (provided !== secret) {
|
|
66
|
+
return c.json({ error: "Invalid webhook secret" }, 401);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let payload: unknown = await c.req.json();
|
|
71
|
+
if (source.schema) {
|
|
72
|
+
const parsed = source.schema.safeParse(payload);
|
|
73
|
+
if (!parsed.success) {
|
|
74
|
+
return c.json(
|
|
75
|
+
{ error: "Invalid payload", details: parsed.error.flatten() },
|
|
76
|
+
400,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
payload = parsed.data;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const event = await source.transform(payload, { db, logger });
|
|
83
|
+
if (!event) {
|
|
84
|
+
logger.info("Webhook event skipped", { source: sourceId });
|
|
85
|
+
return c.json({ ok: true, skipped: true });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const result = await ingestEvent({ db, registry, hatchet, logger, event });
|
|
89
|
+
|
|
90
|
+
return c.json({
|
|
91
|
+
ok: true,
|
|
92
|
+
event: event.event,
|
|
93
|
+
userId: event.userId,
|
|
94
|
+
exits: result.exits,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { IngestEvent } from "../lib/ingestion.js";
|
|
4
|
+
import type { Logger } from "../lib/logger.js";
|
|
5
|
+
|
|
6
|
+
export interface WebhookSourceAuth {
|
|
7
|
+
header: string;
|
|
8
|
+
envKey: string;
|
|
9
|
+
type: "match";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface WebhookSourceCtx {
|
|
13
|
+
db: Database;
|
|
14
|
+
logger: Logger;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface WebhookSourceMeta {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DefinedWebhookSource<T = unknown> {
|
|
24
|
+
meta: WebhookSourceMeta;
|
|
25
|
+
auth: WebhookSourceAuth;
|
|
26
|
+
schema?: z.ZodSchema<T>;
|
|
27
|
+
transform(payload: T, ctx: WebhookSourceCtx): Promise<IngestEvent | null>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function defineWebhookSource<T>(
|
|
31
|
+
def: DefinedWebhookSource<T>,
|
|
32
|
+
): DefinedWebhookSource<T> {
|
|
33
|
+
return def;
|
|
34
|
+
}
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { HogsendClient } from "./container.js";
|
|
2
|
+
import type { DefinedJourney } from "./journeys/define-journey.js";
|
|
3
|
+
import { selectJourneyTasks } from "./journeys/registry.js";
|
|
4
|
+
import { hatchet } from "./lib/hatchet.js";
|
|
5
|
+
import { getPostHog } from "./lib/posthog.js";
|
|
6
|
+
import { getRedisIfConnected } from "./lib/redis.js";
|
|
7
|
+
import { checkAlertsTask } from "./workflows/check-alerts.js";
|
|
8
|
+
import { importContactsTask } from "./workflows/import-contacts.js";
|
|
9
|
+
import { sendEmailTask } from "./workflows/send-email.js";
|
|
10
|
+
|
|
11
|
+
export interface CreateWorkerOptions {
|
|
12
|
+
container: HogsendClient;
|
|
13
|
+
journeys: DefinedJourney[];
|
|
14
|
+
/** Defaults to `container.env.ENABLED_JOURNEYS`. */
|
|
15
|
+
enabledJourneys?: string;
|
|
16
|
+
/** Extra client tasks registered alongside the built-in workflows. */
|
|
17
|
+
extraWorkflows?: unknown[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Worker {
|
|
21
|
+
start(): Promise<void>;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createWorker(opts: CreateWorkerOptions): Worker {
|
|
26
|
+
const { container, journeys } = opts;
|
|
27
|
+
const enabled = opts.enabledJourneys ?? container.env.ENABLED_JOURNEYS;
|
|
28
|
+
const journeyTasks = selectJourneyTasks(journeys, enabled);
|
|
29
|
+
|
|
30
|
+
const baseWorkflows = [
|
|
31
|
+
sendEmailTask,
|
|
32
|
+
importContactsTask,
|
|
33
|
+
checkAlertsTask,
|
|
34
|
+
...journeyTasks,
|
|
35
|
+
];
|
|
36
|
+
const workflows = [
|
|
37
|
+
...baseWorkflows,
|
|
38
|
+
...((opts.extraWorkflows ?? []) as typeof baseWorkflows),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Hatchet's worker is created lazily on start so signal wiring can own its
|
|
42
|
+
// lifecycle. `_worker` is captured for stop().
|
|
43
|
+
let _worker: Awaited<ReturnType<typeof hatchet.worker>> | undefined;
|
|
44
|
+
|
|
45
|
+
async function stop(): Promise<void> {
|
|
46
|
+
await Promise.allSettled([
|
|
47
|
+
_worker?.stop(),
|
|
48
|
+
getPostHog()?.shutdown(),
|
|
49
|
+
getRedisIfConnected()?.quit(),
|
|
50
|
+
]);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function start(): Promise<void> {
|
|
54
|
+
_worker = await hatchet.worker("hogsend-worker", { workflows });
|
|
55
|
+
|
|
56
|
+
container.logger.info(
|
|
57
|
+
`Hogsend worker started with ${journeyTasks.length} journey task(s)`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await _worker.start();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { start, stop };
|
|
64
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createDatabase } from "@hogsend/db";
|
|
2
|
+
import { checkAlertRules } from "../lib/alerting.js";
|
|
3
|
+
import { hatchet } from "../lib/hatchet.js";
|
|
4
|
+
import { createLogger } from "../lib/logger.js";
|
|
5
|
+
|
|
6
|
+
export const checkAlertsTask = hatchet.task({
|
|
7
|
+
name: "check-alerts",
|
|
8
|
+
retries: 1,
|
|
9
|
+
executionTimeout: "60s",
|
|
10
|
+
fn: async () => {
|
|
11
|
+
const { db } = createDatabase({
|
|
12
|
+
url: process.env.DATABASE_URL ?? "",
|
|
13
|
+
});
|
|
14
|
+
const logger = createLogger(process.env.LOG_LEVEL ?? "info");
|
|
15
|
+
|
|
16
|
+
await checkAlertRules({
|
|
17
|
+
db,
|
|
18
|
+
logger,
|
|
19
|
+
resendApiKey: process.env.RESEND_API_KEY,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return { checked: true };
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { createDatabase, importJobs } from "@hogsend/db";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import Papa from "papaparse";
|
|
4
|
+
import { upsertContact } from "../lib/contacts.js";
|
|
5
|
+
import { hatchet } from "../lib/hatchet.js";
|
|
6
|
+
|
|
7
|
+
const BATCH_SIZE = 500;
|
|
8
|
+
|
|
9
|
+
export const importContactsTask = hatchet.task({
|
|
10
|
+
name: "import-contacts",
|
|
11
|
+
retries: 0,
|
|
12
|
+
executionTimeout: "600s",
|
|
13
|
+
fn: async (input: { jobId: string; data: string; format: string }) => {
|
|
14
|
+
const { db } = createDatabase({
|
|
15
|
+
url: process.env.DATABASE_URL ?? "",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await db
|
|
19
|
+
.update(importJobs)
|
|
20
|
+
.set({ status: "processing", updatedAt: new Date() })
|
|
21
|
+
.where(eq(importJobs.id, input.jobId));
|
|
22
|
+
|
|
23
|
+
let rows: Array<{
|
|
24
|
+
externalId: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
properties?: Record<string, unknown>;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
if (input.format === "json") {
|
|
31
|
+
rows = JSON.parse(input.data);
|
|
32
|
+
} else {
|
|
33
|
+
rows = parseCsv(input.data);
|
|
34
|
+
}
|
|
35
|
+
} catch (err) {
|
|
36
|
+
await db
|
|
37
|
+
.update(importJobs)
|
|
38
|
+
.set({
|
|
39
|
+
status: "failed",
|
|
40
|
+
errors: [
|
|
41
|
+
{
|
|
42
|
+
row: 0,
|
|
43
|
+
error: `Parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
updatedAt: new Date(),
|
|
47
|
+
})
|
|
48
|
+
.where(eq(importJobs.id, input.jobId));
|
|
49
|
+
return { status: "failed" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await db
|
|
53
|
+
.update(importJobs)
|
|
54
|
+
.set({ totalRows: rows.length, updatedAt: new Date() })
|
|
55
|
+
.where(eq(importJobs.id, input.jobId));
|
|
56
|
+
|
|
57
|
+
let processed = 0;
|
|
58
|
+
let failed = 0;
|
|
59
|
+
const errors: Array<{ row: number; error: string }> = [];
|
|
60
|
+
|
|
61
|
+
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
|
|
62
|
+
const batch = rows.slice(i, i + BATCH_SIZE);
|
|
63
|
+
const results = await Promise.allSettled(
|
|
64
|
+
batch.map((row, idx) =>
|
|
65
|
+
upsertContact({
|
|
66
|
+
db,
|
|
67
|
+
externalId: row.externalId,
|
|
68
|
+
email: row.email,
|
|
69
|
+
properties: row.properties,
|
|
70
|
+
}).then(() => ({ index: i + idx, ok: true })),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
results.forEach((result, batchIdx) => {
|
|
75
|
+
if (result.status === "fulfilled") {
|
|
76
|
+
processed++;
|
|
77
|
+
} else {
|
|
78
|
+
failed++;
|
|
79
|
+
errors.push({
|
|
80
|
+
row: i + batchIdx,
|
|
81
|
+
error: result.reason?.message ?? "Unknown error",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await db
|
|
87
|
+
.update(importJobs)
|
|
88
|
+
.set({
|
|
89
|
+
processedRows: processed,
|
|
90
|
+
failedRows: failed,
|
|
91
|
+
updatedAt: new Date(),
|
|
92
|
+
})
|
|
93
|
+
.where(eq(importJobs.id, input.jobId));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
await db
|
|
97
|
+
.update(importJobs)
|
|
98
|
+
.set({
|
|
99
|
+
status: failed === rows.length ? "failed" : "completed",
|
|
100
|
+
processedRows: processed,
|
|
101
|
+
failedRows: failed,
|
|
102
|
+
errors: errors.length > 0 ? errors.slice(0, 100) : null,
|
|
103
|
+
updatedAt: new Date(),
|
|
104
|
+
})
|
|
105
|
+
.where(eq(importJobs.id, input.jobId));
|
|
106
|
+
|
|
107
|
+
return { status: "completed", processed, failed };
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
function parseCsv(data: string): Array<{
|
|
112
|
+
externalId: string;
|
|
113
|
+
email?: string;
|
|
114
|
+
properties?: Record<string, unknown>;
|
|
115
|
+
}> {
|
|
116
|
+
const result = Papa.parse<Record<string, string>>(data, {
|
|
117
|
+
header: true,
|
|
118
|
+
skipEmptyLines: true,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (!result.meta.fields?.includes("externalId")) {
|
|
122
|
+
throw new Error("CSV must have an externalId column");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result.data.map((row) => {
|
|
126
|
+
const { externalId, email, ...rest } = row;
|
|
127
|
+
const properties = Object.keys(rest).length > 0 ? rest : undefined;
|
|
128
|
+
return {
|
|
129
|
+
externalId: externalId ?? "",
|
|
130
|
+
email: email || undefined,
|
|
131
|
+
properties: properties as Record<string, unknown> | undefined,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|