@hogsend/engine 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/app.ts +36 -1
- package/src/buckets/check-membership.ts +34 -15
- package/src/container.ts +33 -0
- package/src/env.ts +29 -0
- package/src/index.ts +47 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +1 -1
- package/src/lib/bucket-emit.ts +47 -2
- package/src/lib/contacts.ts +1105 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +88 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +137 -0
- package/src/lib/tracked.ts +204 -37
- package/src/lib/tracking-events.ts +67 -2
- package/src/lib/webhook-signing.ts +151 -0
- package/src/lists/define-list.ts +81 -0
- package/src/lists/registry-singleton.ts +39 -0
- package/src/lists/registry.ts +95 -0
- package/src/middleware/api-key.ts +33 -7
- package/src/middleware/rate-limit.ts +73 -49
- package/src/routes/_shared.ts +30 -0
- package/src/routes/admin/api-keys.ts +1 -1
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +108 -59
- package/src/routes/admin/events.ts +65 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/journeys.ts +3 -1
- package/src/routes/admin/preferences.ts +2 -2
- package/src/routes/admin/reporting.ts +3 -3
- package/src/routes/admin/timeline.ts +5 -2
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +231 -0
- package/src/routes/email/preferences.ts +27 -3
- package/src/routes/email/unsubscribe.ts +7 -49
- package/src/routes/emails/index.ts +133 -0
- package/src/routes/events/index.ts +119 -0
- package/src/routes/index.ts +52 -2
- package/src/routes/lists/index.ts +258 -0
- package/src/routes/tracking/click.ts +59 -18
- package/src/routes/tracking/open.ts +62 -24
- package/src/routes/webhooks/sources.ts +69 -10
- package/src/webhook-sources/define-webhook-source.ts +57 -5
- package/src/webhook-sources/presets/clerk.ts +185 -0
- package/src/webhook-sources/presets/index.ts +80 -0
- package/src/webhook-sources/presets/segment.ts +120 -0
- package/src/webhook-sources/presets/stripe.ts +147 -0
- package/src/webhook-sources/presets/supabase.ts +131 -0
- package/src/webhook-sources/verify.ts +172 -0
- package/src/worker.ts +12 -0
- package/src/workflows/bucket-backfill.ts +32 -21
- package/src/workflows/bucket-reconcile.ts +20 -5
- package/src/workflows/deliver-webhook.ts +399 -0
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
|
@@ -2,6 +2,7 @@ import { userEvents } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, count, desc, eq, gte, lte } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { ingestEvent } from "../../lib/ingestion.js";
|
|
5
6
|
|
|
6
7
|
const eventSchema = z.object({
|
|
7
8
|
id: z.string(),
|
|
@@ -79,7 +80,71 @@ const getRoute = createRoute({
|
|
|
79
80
|
},
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
const exitSchema = z.object({
|
|
84
|
+
journeyId: z.string(),
|
|
85
|
+
stateId: z.string(),
|
|
86
|
+
exited: z.boolean(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const ingestRoute = createRoute({
|
|
90
|
+
method: "post",
|
|
91
|
+
path: "/",
|
|
92
|
+
tags: ["Admin — Events"],
|
|
93
|
+
summary: "Ingest a test event",
|
|
94
|
+
description:
|
|
95
|
+
"Session-authed ingest for the Studio Debug panel. Runs an event " +
|
|
96
|
+
"through the full ingest pipeline (stores it, routes it to journeys, " +
|
|
97
|
+
"evaluates exits). Inherits requireAdmin session auth from the admin " +
|
|
98
|
+
"mount — does NOT accept an hsk_ API key.",
|
|
99
|
+
request: {
|
|
100
|
+
body: {
|
|
101
|
+
content: {
|
|
102
|
+
"application/json": {
|
|
103
|
+
schema: z.object({
|
|
104
|
+
event: z.string().min(1),
|
|
105
|
+
userId: z.string().optional(),
|
|
106
|
+
userEmail: z.string().optional(),
|
|
107
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
responses: {
|
|
114
|
+
202: {
|
|
115
|
+
content: {
|
|
116
|
+
"application/json": {
|
|
117
|
+
schema: z.object({
|
|
118
|
+
stored: z.boolean(),
|
|
119
|
+
exits: z.array(exitSchema),
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
description: "Event accepted and ingested",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
82
128
|
export const eventsRouter = new OpenAPIHono<AppEnv>()
|
|
129
|
+
.openapi(ingestRoute, async (c) => {
|
|
130
|
+
const { db, registry, hatchet, logger } = c.get("container");
|
|
131
|
+
const { event, userId, userEmail, properties } = c.req.valid("json");
|
|
132
|
+
|
|
133
|
+
const result = await ingestEvent({
|
|
134
|
+
db,
|
|
135
|
+
registry,
|
|
136
|
+
hatchet,
|
|
137
|
+
logger,
|
|
138
|
+
event: {
|
|
139
|
+
event,
|
|
140
|
+
userId,
|
|
141
|
+
userEmail,
|
|
142
|
+
eventProperties: properties ?? {},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return c.json(result, 202);
|
|
147
|
+
})
|
|
83
148
|
.openapi(listRoute, async (c) => {
|
|
84
149
|
const { db } = c.get("container");
|
|
85
150
|
const { limit, offset, userId, event, from, to } = c.req.valid("query");
|
|
@@ -20,6 +20,7 @@ import { reportingRouter } from "./reporting.js";
|
|
|
20
20
|
import { suppressionsRouter } from "./suppressions.js";
|
|
21
21
|
import { templatesRouter } from "./templates.js";
|
|
22
22
|
import { timelineRouter } from "./timeline.js";
|
|
23
|
+
import { webhooksRouter } from "./webhooks.js";
|
|
23
24
|
|
|
24
25
|
export const adminRouter = new OpenAPIHono<AppEnv>();
|
|
25
26
|
adminRouter.use("*", requireAdmin);
|
|
@@ -39,6 +40,7 @@ adminRouter.route("/reporting", reportingRouter);
|
|
|
39
40
|
adminRouter.route("/templates", templatesRouter);
|
|
40
41
|
adminRouter.route("/suppressions", suppressionsRouter);
|
|
41
42
|
adminRouter.route("/api-keys", apiKeysRouter);
|
|
43
|
+
adminRouter.route("/webhooks", webhooksRouter);
|
|
42
44
|
adminRouter.route("/audit-logs", auditLogsRouter);
|
|
43
45
|
adminRouter.route("/alerts", alertsRouter);
|
|
44
46
|
adminRouter.route("/dlq", dlqRouter);
|
|
@@ -662,7 +662,9 @@ export const journeysRouter = new OpenAPIHono<AppEnv>()
|
|
|
662
662
|
event: meta.trigger.event,
|
|
663
663
|
userId: body.userId,
|
|
664
664
|
userEmail: body.userEmail,
|
|
665
|
-
|
|
665
|
+
// Public request field stays `properties` (decision #14); it maps to
|
|
666
|
+
// the event-property bag of the IngestEvent (D2 split).
|
|
667
|
+
eventProperties: body.properties ?? {},
|
|
666
668
|
},
|
|
667
669
|
});
|
|
668
670
|
|
|
@@ -104,7 +104,7 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>()
|
|
|
104
104
|
const rows = await db
|
|
105
105
|
.select()
|
|
106
106
|
.from(emailPreferences)
|
|
107
|
-
.where(eq(emailPreferences.userId, contact.externalId))
|
|
107
|
+
.where(eq(emailPreferences.userId, contact.externalId ?? contact.id))
|
|
108
108
|
.limit(1);
|
|
109
109
|
|
|
110
110
|
if (rows.length === 0) {
|
|
@@ -131,7 +131,7 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>()
|
|
|
131
131
|
const [upserted] = await db
|
|
132
132
|
.insert(emailPreferences)
|
|
133
133
|
.values({
|
|
134
|
-
userId: contact.externalId,
|
|
134
|
+
userId: contact.externalId ?? contact.id,
|
|
135
135
|
email: contact.email,
|
|
136
136
|
unsubscribedAll: body.unsubscribedAll ?? false,
|
|
137
137
|
suppressed: body.suppressed ?? false,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
sql,
|
|
13
13
|
} from "drizzle-orm";
|
|
14
14
|
import type { AppEnv } from "../../app.js";
|
|
15
|
-
import { resolveContact } from "../../lib/contacts.js";
|
|
15
|
+
import { contactKey, resolveContact } from "../../lib/contacts.js";
|
|
16
16
|
import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
|
|
17
17
|
import { errorSchema } from "../../lib/schemas.js";
|
|
18
18
|
|
|
@@ -109,7 +109,7 @@ const contactActivityRoute = createRoute({
|
|
|
109
109
|
"application/json": {
|
|
110
110
|
schema: z.object({
|
|
111
111
|
contact: z.object({
|
|
112
|
-
externalId: z.string(),
|
|
112
|
+
externalId: z.string().nullable(),
|
|
113
113
|
email: z.string().nullable(),
|
|
114
114
|
}),
|
|
115
115
|
sends: z.array(
|
|
@@ -244,7 +244,7 @@ export const reportingRouter = new OpenAPIHono<AppEnv>()
|
|
|
244
244
|
|
|
245
245
|
// Denormalized identity makes this single-table; fall back to the contact's
|
|
246
246
|
// email so journeyless sends still surface.
|
|
247
|
-
const idConds = [eq(emailSends.userId, contact
|
|
247
|
+
const idConds = [eq(emailSends.userId, contactKey(contact))];
|
|
248
248
|
if (contact.email) idConds.push(eq(emailSends.userEmail, contact.email));
|
|
249
249
|
const where = or(...idConds);
|
|
250
250
|
|
|
@@ -2,7 +2,7 @@ import { emailSends, journeyStates, userEvents } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, count, desc, eq, isNull } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
-
import { resolveContact } from "../../lib/contacts.js";
|
|
5
|
+
import { contactKey, resolveContact } from "../../lib/contacts.js";
|
|
6
6
|
|
|
7
7
|
const timelineEntrySchema = z.object({
|
|
8
8
|
type: z.enum(["event", "journey", "email"]),
|
|
@@ -64,7 +64,10 @@ export const timelineRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
64
64
|
return c.json({ error: "Contact not found" }, 404);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// Resolved string key the history tables (user_events/journey_states/
|
|
68
|
+
// email_sends) were written under: external_id, else anonymous_id, else the
|
|
69
|
+
// contact uuid (matches ingestEvent's resolvedKey).
|
|
70
|
+
const externalId = contactKey(contact);
|
|
68
71
|
const entries: TimelineEntry[] = [];
|
|
69
72
|
|
|
70
73
|
const shouldFetch = (t: string) => !type || type === t;
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { webhookDeliveries, webhookEndpoints } from "@hogsend/db";
|
|
3
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
4
|
+
import { count, desc, eq } from "drizzle-orm";
|
|
5
|
+
import type { AppEnv } from "../../app.js";
|
|
6
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
7
|
+
import {
|
|
8
|
+
generateWebhookSecret,
|
|
9
|
+
WEBHOOK_EVENT_TYPES,
|
|
10
|
+
type WebhookEventType,
|
|
11
|
+
} from "../../lib/webhook-signing.js";
|
|
12
|
+
import { deliverWebhookTask } from "../../workflows/deliver-webhook.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Admin outbound-webhook management (Section 1.8). Mounted at
|
|
16
|
+
* `/v1/admin/webhooks`, it inherits `requireAdmin` + `rateLimit` +
|
|
17
|
+
* `auditMiddleware` from the admin router root — no per-route auth here.
|
|
18
|
+
*
|
|
19
|
+
* Secret-once invariant (LOCKED decision 1): the full `whsec_…` secret is
|
|
20
|
+
* returned ONLY on create + rotate-secret. `serializeEndpoint` NEVER includes
|
|
21
|
+
* it; list/get/patch expose `secretPrefix` only. Anything that returns the full
|
|
22
|
+
* secret is an explicit, audited create/rotate response.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// The catalog enum for request validation — derived from the SINGLE source of
|
|
26
|
+
// truth in `webhook-signing.ts` (Section 1.3). `z.enum` needs a non-empty tuple,
|
|
27
|
+
// which `WEBHOOK_EVENT_TYPES` (12 strings, `as const`) satisfies.
|
|
28
|
+
const eventTypeEnum = z.enum(
|
|
29
|
+
WEBHOOK_EVENT_TYPES as unknown as [WebhookEventType, ...WebhookEventType[]],
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const webhookEndpointSchema = z.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
url: z.string(),
|
|
35
|
+
description: z.string().nullable(),
|
|
36
|
+
eventTypes: z.array(z.string()),
|
|
37
|
+
secretPrefix: z.string(),
|
|
38
|
+
status: z.enum(["enabled", "disabled"]),
|
|
39
|
+
organizationId: z.string().nullable(),
|
|
40
|
+
lastDeliveryAt: z.string().nullable(),
|
|
41
|
+
createdAt: z.string(),
|
|
42
|
+
updatedAt: z.string(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const listRoute = createRoute({
|
|
46
|
+
method: "get",
|
|
47
|
+
path: "/",
|
|
48
|
+
tags: ["Admin — Webhooks"],
|
|
49
|
+
summary: "List outbound webhook endpoints",
|
|
50
|
+
request: {
|
|
51
|
+
query: z.object({
|
|
52
|
+
limit: z.coerce.number().min(1).max(100).default(50),
|
|
53
|
+
offset: z.coerce.number().min(0).default(0),
|
|
54
|
+
includeDisabled: z.enum(["true", "false"]).default("true"),
|
|
55
|
+
}),
|
|
56
|
+
},
|
|
57
|
+
responses: {
|
|
58
|
+
200: {
|
|
59
|
+
content: {
|
|
60
|
+
"application/json": {
|
|
61
|
+
schema: z.object({
|
|
62
|
+
endpoints: z.array(webhookEndpointSchema),
|
|
63
|
+
total: z.number(),
|
|
64
|
+
limit: z.number(),
|
|
65
|
+
offset: z.number(),
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
description: "Paginated webhook endpoint list (secret never included)",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const createEndpointRoute = createRoute({
|
|
75
|
+
method: "post",
|
|
76
|
+
path: "/",
|
|
77
|
+
tags: ["Admin — Webhooks"],
|
|
78
|
+
summary: "Create a webhook endpoint",
|
|
79
|
+
request: {
|
|
80
|
+
body: {
|
|
81
|
+
content: {
|
|
82
|
+
"application/json": {
|
|
83
|
+
schema: z.object({
|
|
84
|
+
url: z.string().url(),
|
|
85
|
+
eventTypes: z.array(eventTypeEnum).min(1),
|
|
86
|
+
description: z.string().max(500).optional(),
|
|
87
|
+
disabled: z.boolean().optional(),
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
responses: {
|
|
94
|
+
201: {
|
|
95
|
+
content: {
|
|
96
|
+
"application/json": {
|
|
97
|
+
schema: webhookEndpointSchema.extend({ secret: z.string() }),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
description: "Endpoint created — signing secret shown only once",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const getEndpointRoute = createRoute({
|
|
106
|
+
method: "get",
|
|
107
|
+
path: "/{id}",
|
|
108
|
+
tags: ["Admin — Webhooks"],
|
|
109
|
+
summary: "Get a webhook endpoint",
|
|
110
|
+
request: {
|
|
111
|
+
params: z.object({ id: z.string().uuid() }),
|
|
112
|
+
},
|
|
113
|
+
responses: {
|
|
114
|
+
200: {
|
|
115
|
+
content: {
|
|
116
|
+
"application/json": { schema: webhookEndpointSchema },
|
|
117
|
+
},
|
|
118
|
+
description: "Webhook endpoint (secret never included)",
|
|
119
|
+
},
|
|
120
|
+
404: {
|
|
121
|
+
content: { "application/json": { schema: errorSchema } },
|
|
122
|
+
description: "Endpoint not found",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const updateEndpointRoute = createRoute({
|
|
128
|
+
method: "patch",
|
|
129
|
+
path: "/{id}",
|
|
130
|
+
tags: ["Admin — Webhooks"],
|
|
131
|
+
summary: "Update a webhook endpoint",
|
|
132
|
+
request: {
|
|
133
|
+
params: z.object({ id: z.string().uuid() }),
|
|
134
|
+
body: {
|
|
135
|
+
content: {
|
|
136
|
+
"application/json": {
|
|
137
|
+
schema: z.object({
|
|
138
|
+
url: z.string().url().optional(),
|
|
139
|
+
eventTypes: z.array(eventTypeEnum).min(1).optional(),
|
|
140
|
+
description: z.string().max(500).nullable().optional(),
|
|
141
|
+
disabled: z.boolean().optional(),
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
responses: {
|
|
148
|
+
200: {
|
|
149
|
+
content: {
|
|
150
|
+
"application/json": { schema: webhookEndpointSchema },
|
|
151
|
+
},
|
|
152
|
+
description: "Updated webhook endpoint (secret never included)",
|
|
153
|
+
},
|
|
154
|
+
404: {
|
|
155
|
+
content: { "application/json": { schema: errorSchema } },
|
|
156
|
+
description: "Endpoint not found",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const deleteEndpointRoute = createRoute({
|
|
162
|
+
method: "delete",
|
|
163
|
+
path: "/{id}",
|
|
164
|
+
tags: ["Admin — Webhooks"],
|
|
165
|
+
summary: "Delete a webhook endpoint",
|
|
166
|
+
request: {
|
|
167
|
+
params: z.object({ id: z.string().uuid() }),
|
|
168
|
+
},
|
|
169
|
+
responses: {
|
|
170
|
+
200: {
|
|
171
|
+
content: {
|
|
172
|
+
"application/json": {
|
|
173
|
+
schema: z.object({ deleted: z.boolean() }),
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
description: "Endpoint hard-deleted (deliveries cascade)",
|
|
177
|
+
},
|
|
178
|
+
404: {
|
|
179
|
+
content: { "application/json": { schema: errorSchema } },
|
|
180
|
+
description: "Endpoint not found",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const rotateSecretRoute = createRoute({
|
|
186
|
+
method: "post",
|
|
187
|
+
path: "/{id}/rotate-secret",
|
|
188
|
+
tags: ["Admin — Webhooks"],
|
|
189
|
+
summary: "Rotate a webhook endpoint's signing secret",
|
|
190
|
+
request: {
|
|
191
|
+
params: z.object({ id: z.string().uuid() }),
|
|
192
|
+
},
|
|
193
|
+
responses: {
|
|
194
|
+
200: {
|
|
195
|
+
content: {
|
|
196
|
+
"application/json": {
|
|
197
|
+
schema: z.object({
|
|
198
|
+
id: z.string(),
|
|
199
|
+
secret: z.string(),
|
|
200
|
+
secretPrefix: z.string(),
|
|
201
|
+
}),
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
description: "New signing secret — shown only once (hard cutover)",
|
|
205
|
+
},
|
|
206
|
+
404: {
|
|
207
|
+
content: { "application/json": { schema: errorSchema } },
|
|
208
|
+
description: "Endpoint not found",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const testRoute = createRoute({
|
|
214
|
+
method: "post",
|
|
215
|
+
path: "/{id}/test",
|
|
216
|
+
tags: ["Admin — Webhooks"],
|
|
217
|
+
summary: "Send a test event to a webhook endpoint",
|
|
218
|
+
request: {
|
|
219
|
+
params: z.object({ id: z.string().uuid() }),
|
|
220
|
+
},
|
|
221
|
+
responses: {
|
|
222
|
+
202: {
|
|
223
|
+
content: {
|
|
224
|
+
"application/json": {
|
|
225
|
+
schema: z.object({
|
|
226
|
+
enqueued: z.boolean(),
|
|
227
|
+
eventType: z.literal("webhook.test"),
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
description: "Out-of-band test event enqueued for delivery",
|
|
232
|
+
},
|
|
233
|
+
404: {
|
|
234
|
+
content: { "application/json": { schema: errorSchema } },
|
|
235
|
+
description: "Endpoint not found",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Serialize an endpoint row for an API response. NEVER includes `secret` — the
|
|
242
|
+
* full `whsec_…` is surfaced only on create + rotate-secret via the dedicated
|
|
243
|
+
* response shapes. `status` is derived from the `disabled` boolean.
|
|
244
|
+
*/
|
|
245
|
+
function serializeEndpoint(row: typeof webhookEndpoints.$inferSelect) {
|
|
246
|
+
return {
|
|
247
|
+
id: row.id,
|
|
248
|
+
url: row.url,
|
|
249
|
+
description: row.description,
|
|
250
|
+
eventTypes: row.eventTypes as string[],
|
|
251
|
+
secretPrefix: row.secretPrefix,
|
|
252
|
+
status: (row.disabled ? "disabled" : "enabled") as "enabled" | "disabled",
|
|
253
|
+
organizationId: row.organizationId,
|
|
254
|
+
lastDeliveryAt: row.lastDeliveryAt?.toISOString() ?? null,
|
|
255
|
+
createdAt: row.createdAt.toISOString(),
|
|
256
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const webhooksRouter = new OpenAPIHono<AppEnv>()
|
|
261
|
+
.openapi(listRoute, async (c) => {
|
|
262
|
+
const { db } = c.get("container");
|
|
263
|
+
const { limit, offset, includeDisabled } = c.req.valid("query");
|
|
264
|
+
|
|
265
|
+
const where =
|
|
266
|
+
includeDisabled === "true"
|
|
267
|
+
? undefined
|
|
268
|
+
: eq(webhookEndpoints.disabled, false);
|
|
269
|
+
|
|
270
|
+
const [rows, totalRows] = await Promise.all([
|
|
271
|
+
db
|
|
272
|
+
.select()
|
|
273
|
+
.from(webhookEndpoints)
|
|
274
|
+
.where(where)
|
|
275
|
+
.orderBy(desc(webhookEndpoints.createdAt))
|
|
276
|
+
.limit(limit)
|
|
277
|
+
.offset(offset),
|
|
278
|
+
db.select({ count: count() }).from(webhookEndpoints).where(where),
|
|
279
|
+
]);
|
|
280
|
+
|
|
281
|
+
return c.json(
|
|
282
|
+
{
|
|
283
|
+
endpoints: rows.map(serializeEndpoint),
|
|
284
|
+
total: totalRows[0]?.count ?? 0,
|
|
285
|
+
limit,
|
|
286
|
+
offset,
|
|
287
|
+
},
|
|
288
|
+
200,
|
|
289
|
+
);
|
|
290
|
+
})
|
|
291
|
+
.openapi(createEndpointRoute, async (c) => {
|
|
292
|
+
const { db } = c.get("container");
|
|
293
|
+
const body = c.req.valid("json");
|
|
294
|
+
|
|
295
|
+
const { secret, secretPrefix } = generateWebhookSecret();
|
|
296
|
+
|
|
297
|
+
const [created] = await db
|
|
298
|
+
.insert(webhookEndpoints)
|
|
299
|
+
.values({
|
|
300
|
+
url: body.url,
|
|
301
|
+
eventTypes: body.eventTypes,
|
|
302
|
+
description: body.description ?? null,
|
|
303
|
+
disabled: body.disabled ?? false,
|
|
304
|
+
secret,
|
|
305
|
+
secretPrefix,
|
|
306
|
+
})
|
|
307
|
+
.returning();
|
|
308
|
+
|
|
309
|
+
if (!created) throw new Error("Failed to create webhook endpoint");
|
|
310
|
+
|
|
311
|
+
// The ONLY list/get-shaped response that also carries the full secret.
|
|
312
|
+
return c.json({ ...serializeEndpoint(created), secret }, 201);
|
|
313
|
+
})
|
|
314
|
+
.openapi(getEndpointRoute, async (c) => {
|
|
315
|
+
const { db } = c.get("container");
|
|
316
|
+
const { id } = c.req.valid("param");
|
|
317
|
+
|
|
318
|
+
const [row] = await db
|
|
319
|
+
.select()
|
|
320
|
+
.from(webhookEndpoints)
|
|
321
|
+
.where(eq(webhookEndpoints.id, id))
|
|
322
|
+
.limit(1);
|
|
323
|
+
|
|
324
|
+
if (!row) {
|
|
325
|
+
return c.json({ error: "Webhook endpoint not found" }, 404);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return c.json(serializeEndpoint(row), 200);
|
|
329
|
+
})
|
|
330
|
+
.openapi(updateEndpointRoute, async (c) => {
|
|
331
|
+
const { db } = c.get("container");
|
|
332
|
+
const { id } = c.req.valid("param");
|
|
333
|
+
const body = c.req.valid("json");
|
|
334
|
+
|
|
335
|
+
const [existing] = await db
|
|
336
|
+
.select()
|
|
337
|
+
.from(webhookEndpoints)
|
|
338
|
+
.where(eq(webhookEndpoints.id, id))
|
|
339
|
+
.limit(1);
|
|
340
|
+
|
|
341
|
+
if (!existing) {
|
|
342
|
+
return c.json({ error: "Webhook endpoint not found" }, 404);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const patch: Partial<typeof webhookEndpoints.$inferInsert> = {
|
|
346
|
+
updatedAt: new Date(),
|
|
347
|
+
};
|
|
348
|
+
if (body.url !== undefined) patch.url = body.url;
|
|
349
|
+
if (body.eventTypes !== undefined) patch.eventTypes = body.eventTypes;
|
|
350
|
+
if (body.description !== undefined) patch.description = body.description;
|
|
351
|
+
if (body.disabled !== undefined) patch.disabled = body.disabled;
|
|
352
|
+
|
|
353
|
+
const [updated] = await db
|
|
354
|
+
.update(webhookEndpoints)
|
|
355
|
+
.set(patch)
|
|
356
|
+
.where(eq(webhookEndpoints.id, id))
|
|
357
|
+
.returning();
|
|
358
|
+
|
|
359
|
+
if (!updated) {
|
|
360
|
+
return c.json({ error: "Webhook endpoint not found" }, 404);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return c.json(serializeEndpoint(updated), 200);
|
|
364
|
+
})
|
|
365
|
+
.openapi(deleteEndpointRoute, async (c) => {
|
|
366
|
+
const { db } = c.get("container");
|
|
367
|
+
const { id } = c.req.valid("param");
|
|
368
|
+
|
|
369
|
+
// Hard delete (LOCKED: no soft-delete column). The FK cascade on
|
|
370
|
+
// `webhook_deliveries.endpoint_id` drops this endpoint's delivery rows.
|
|
371
|
+
const deleted = await db
|
|
372
|
+
.delete(webhookEndpoints)
|
|
373
|
+
.where(eq(webhookEndpoints.id, id))
|
|
374
|
+
.returning({ id: webhookEndpoints.id });
|
|
375
|
+
|
|
376
|
+
if (deleted.length === 0) {
|
|
377
|
+
return c.json({ error: "Webhook endpoint not found" }, 404);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return c.json({ deleted: true }, 200);
|
|
381
|
+
})
|
|
382
|
+
.openapi(rotateSecretRoute, async (c) => {
|
|
383
|
+
const { db } = c.get("container");
|
|
384
|
+
const { id } = c.req.valid("param");
|
|
385
|
+
|
|
386
|
+
const { secret, secretPrefix } = generateWebhookSecret();
|
|
387
|
+
|
|
388
|
+
// Hard cutover (LOCKED decision 10): the old secret is invalid immediately.
|
|
389
|
+
// The delivery task reads the LIVE endpoint secret, so in-flight deliveries
|
|
390
|
+
// re-sign with the new secret on their next attempt.
|
|
391
|
+
const [updated] = await db
|
|
392
|
+
.update(webhookEndpoints)
|
|
393
|
+
.set({ secret, secretPrefix, updatedAt: new Date() })
|
|
394
|
+
.where(eq(webhookEndpoints.id, id))
|
|
395
|
+
.returning({ id: webhookEndpoints.id });
|
|
396
|
+
|
|
397
|
+
if (!updated) {
|
|
398
|
+
return c.json({ error: "Webhook endpoint not found" }, 404);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return c.json({ id: updated.id, secret, secretPrefix }, 200);
|
|
402
|
+
})
|
|
403
|
+
.openapi(testRoute, async (c) => {
|
|
404
|
+
const { db, logger } = c.get("container");
|
|
405
|
+
const { id } = c.req.valid("param");
|
|
406
|
+
|
|
407
|
+
const [endpoint] = await db
|
|
408
|
+
.select()
|
|
409
|
+
.from(webhookEndpoints)
|
|
410
|
+
.where(eq(webhookEndpoints.id, id))
|
|
411
|
+
.limit(1);
|
|
412
|
+
|
|
413
|
+
if (!endpoint) {
|
|
414
|
+
return c.json({ error: "Webhook endpoint not found" }, 404);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Out-of-band test (LOCKED decision 11): delivered regardless of the
|
|
418
|
+
// endpoint's `eventTypes`. Build a synthetic delivery row directly — it does
|
|
419
|
+
// NOT go through `emitOutbound` (which filters by subscription) — then enqueue
|
|
420
|
+
// the same durable delivery task the live emit path uses.
|
|
421
|
+
const webhookId = `msg_${randomUUID()}`;
|
|
422
|
+
const timestamp = new Date();
|
|
423
|
+
const envelope = {
|
|
424
|
+
id: webhookId,
|
|
425
|
+
type: "webhook.test" as const,
|
|
426
|
+
timestamp: timestamp.toISOString(),
|
|
427
|
+
data: {
|
|
428
|
+
message: "Hogsend test event",
|
|
429
|
+
endpointId: endpoint.id,
|
|
430
|
+
sentAt: timestamp.toISOString(),
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const [delivery] = await db
|
|
435
|
+
.insert(webhookDeliveries)
|
|
436
|
+
.values({
|
|
437
|
+
endpointId: endpoint.id,
|
|
438
|
+
organizationId: endpoint.organizationId,
|
|
439
|
+
webhookId,
|
|
440
|
+
eventType: "webhook.test",
|
|
441
|
+
dedupeKey: null,
|
|
442
|
+
payload: envelope,
|
|
443
|
+
status: "pending",
|
|
444
|
+
attemptCount: 0,
|
|
445
|
+
nextRetryAt: timestamp,
|
|
446
|
+
})
|
|
447
|
+
.returning({ id: webhookDeliveries.id });
|
|
448
|
+
|
|
449
|
+
if (!delivery) throw new Error("Failed to enqueue webhook test delivery");
|
|
450
|
+
|
|
451
|
+
// Enqueue-and-202 (LOCKED): tolerate a broker hiccup — the row is already
|
|
452
|
+
// `pending` with `nextRetryAt <= now`, so the reaper re-drives it. Enqueue
|
|
453
|
+
// fire-and-forget (mirrors the emit spine) so a slow/unreachable broker never
|
|
454
|
+
// blocks the 202; a failed enqueue is logged, not surfaced as an error.
|
|
455
|
+
void deliverWebhookTask
|
|
456
|
+
.runNoWait({ deliveryId: delivery.id })
|
|
457
|
+
.catch((error: unknown) => {
|
|
458
|
+
logger.warn("webhooks/test: deliverWebhookTask enqueue failed", {
|
|
459
|
+
endpointId: endpoint.id,
|
|
460
|
+
deliveryId: delivery.id,
|
|
461
|
+
error: error instanceof Error ? error.message : String(error),
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return c.json({ enqueued: true, eventType: "webhook.test" as const }, 202);
|
|
466
|
+
});
|