@hogsend/engine 0.7.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/env.ts +25 -0
- package/src/index.ts +34 -1
- package/src/journeys/define-journey.ts +26 -2
- package/src/lib/bucket-emit.ts +45 -0
- package/src/lib/contacts.ts +28 -6
- package/src/lib/mailer.ts +87 -0
- package/src/lib/outbound.ts +216 -0
- package/src/lib/preferences.ts +31 -0
- package/src/lib/tracked.ts +45 -3
- package/src/lib/tracking-events.ts +66 -1
- package/src/lib/webhook-signing.ts +151 -0
- package/src/routes/admin/contacts.ts +43 -3
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/webhooks.ts +466 -0
- package/src/routes/contacts/index.ts +48 -5
- package/src/routes/lists/index.ts +41 -5
- 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 +6 -0
- package/src/workflows/deliver-webhook.ts +399 -0
|
@@ -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
|
+
});
|
|
@@ -2,10 +2,12 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
3
|
import {
|
|
4
4
|
findContacts,
|
|
5
|
+
resolveContact,
|
|
5
6
|
resolveOrCreateContact,
|
|
6
7
|
serializeContact,
|
|
7
8
|
softDeleteContact,
|
|
8
9
|
} from "../../lib/contacts.js";
|
|
10
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
9
11
|
import { applyListMembership } from "../../lib/preferences.js";
|
|
10
12
|
import { errorSchema } from "../../lib/schemas.js";
|
|
11
13
|
import { listMembershipError, requireIdentity } from "../_shared.js";
|
|
@@ -130,19 +132,44 @@ const deleteRoute = createRoute({
|
|
|
130
132
|
|
|
131
133
|
export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
132
134
|
.openapi(upsertRoute, async (c) => {
|
|
133
|
-
const { db } = c.get("container");
|
|
135
|
+
const { db, hatchet, logger } = c.get("container");
|
|
134
136
|
const body = c.req.valid("json");
|
|
135
137
|
|
|
136
138
|
const guard = requireIdentity(c, body);
|
|
137
139
|
if (guard) return guard;
|
|
138
140
|
|
|
139
|
-
const { id, created, linked } = await resolveOrCreateContact({
|
|
141
|
+
const { id, created, linked, merged } = await resolveOrCreateContact({
|
|
140
142
|
db,
|
|
141
143
|
userId: body.userId,
|
|
142
144
|
email: body.email,
|
|
143
145
|
contactProperties: body.properties,
|
|
144
146
|
});
|
|
145
147
|
|
|
148
|
+
// INTENT-LAYER outbound emit (decision #3): fire `contact.created` on a real
|
|
149
|
+
// creation, `contact.updated` only when an existing contact was linked/merged
|
|
150
|
+
// AND the request carried a non-empty property delta — NEVER inside
|
|
151
|
+
// `resolveOrCreateContact` (which runs on every event → would emit on every
|
|
152
|
+
// pageview). The emit is fire-and-forget; a read-back serializes the full
|
|
153
|
+
// contact payload the catalog expects.
|
|
154
|
+
const hadPropertyDelta = Boolean(
|
|
155
|
+
body.properties && Object.keys(body.properties).length > 0,
|
|
156
|
+
);
|
|
157
|
+
if (created || (linked || merged ? hadPropertyDelta : false)) {
|
|
158
|
+
const event = created ? "contact.created" : "contact.updated";
|
|
159
|
+
void resolveContact({ db, id })
|
|
160
|
+
.then((row) => {
|
|
161
|
+
if (!row) return;
|
|
162
|
+
return emitOutbound({
|
|
163
|
+
db,
|
|
164
|
+
hatchet,
|
|
165
|
+
logger,
|
|
166
|
+
event,
|
|
167
|
+
payload: serializeContact(row),
|
|
168
|
+
});
|
|
169
|
+
})
|
|
170
|
+
.catch(logger.warn);
|
|
171
|
+
}
|
|
172
|
+
|
|
146
173
|
// Lists applied AFTER the resolve so the contact exists (§2.5 lists
|
|
147
174
|
// ordering). `applyListMembership` requires a resolvable email — surface the
|
|
148
175
|
// "no email" case as a 400 rather than a 500.
|
|
@@ -173,16 +200,32 @@ export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
|
173
200
|
return c.json({ contacts: rows.map((row) => serializeContact(row)) }, 200);
|
|
174
201
|
})
|
|
175
202
|
.openapi(deleteRoute, async (c) => {
|
|
176
|
-
const { db } = c.get("container");
|
|
203
|
+
const { db, hatchet, logger } = c.get("container");
|
|
177
204
|
const { email, userId } = c.req.valid("json");
|
|
178
205
|
|
|
179
206
|
const guard = requireIdentity(c, { email, userId });
|
|
180
207
|
if (guard) return guard;
|
|
181
208
|
|
|
182
|
-
const
|
|
183
|
-
if (!deleted) {
|
|
209
|
+
const result = await softDeleteContact({ db, email, userId });
|
|
210
|
+
if (!result.deleted) {
|
|
184
211
|
return c.json({ error: "Contact not found" }, 404);
|
|
185
212
|
}
|
|
186
213
|
|
|
214
|
+
// The widened `softDeleteContact` returns the deleted row's identity so the
|
|
215
|
+
// `contact.deleted` outbound webhook carries it without a second read-back.
|
|
216
|
+
if (result.id) {
|
|
217
|
+
void emitOutbound({
|
|
218
|
+
db,
|
|
219
|
+
hatchet,
|
|
220
|
+
logger,
|
|
221
|
+
event: "contact.deleted",
|
|
222
|
+
payload: {
|
|
223
|
+
id: result.id,
|
|
224
|
+
externalId: result.externalId ?? null,
|
|
225
|
+
email: result.email ?? null,
|
|
226
|
+
},
|
|
227
|
+
}).catch(logger.warn);
|
|
228
|
+
}
|
|
229
|
+
|
|
187
230
|
return c.json({ deleted: true as const }, 200);
|
|
188
231
|
});
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import type { HatchetClient } from "@hatchet-dev/typescript-sdk/v1/index.js";
|
|
1
2
|
import type { Database } from "@hogsend/db";
|
|
2
3
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
4
|
import type { AppEnv } from "../../app.js";
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
resolveContact,
|
|
7
|
+
resolveOrCreateContact,
|
|
8
|
+
serializeContact,
|
|
9
|
+
} from "../../lib/contacts.js";
|
|
10
|
+
import type { Logger } from "../../lib/logger.js";
|
|
11
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
5
12
|
import { applyListMembership } from "../../lib/preferences.js";
|
|
6
13
|
import { errorSchema } from "../../lib/schemas.js";
|
|
7
14
|
import { getListRegistry } from "../../lists/registry-singleton.js";
|
|
@@ -122,6 +129,8 @@ const unsubscribeRoute = createRoute({
|
|
|
122
129
|
*/
|
|
123
130
|
async function applyListSubscription(opts: {
|
|
124
131
|
db: Database;
|
|
132
|
+
hatchet: HatchetClient;
|
|
133
|
+
logger: Logger;
|
|
125
134
|
id: string;
|
|
126
135
|
email?: string;
|
|
127
136
|
userId?: string;
|
|
@@ -132,7 +141,7 @@ async function applyListSubscription(opts: {
|
|
|
132
141
|
| { kind: "failed"; message: string }
|
|
133
142
|
| { kind: "ok" }
|
|
134
143
|
> {
|
|
135
|
-
const { db, id, email, userId, subscribed } = opts;
|
|
144
|
+
const { db, hatchet, logger, id, email, userId, subscribed } = opts;
|
|
136
145
|
|
|
137
146
|
if (!getListRegistry().has(id)) {
|
|
138
147
|
return { kind: "unknown_list" };
|
|
@@ -143,7 +152,30 @@ async function applyListSubscription(opts: {
|
|
|
143
152
|
}
|
|
144
153
|
|
|
145
154
|
try {
|
|
146
|
-
|
|
155
|
+
const { id: contactId, created } = await resolveOrCreateContact({
|
|
156
|
+
db,
|
|
157
|
+
userId,
|
|
158
|
+
email,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// INTENT-LAYER outbound emit (decision #3): the lists route emits
|
|
162
|
+
// `contact.created` ONLY on first creation (a list flip is not a contact
|
|
163
|
+
// property delta, so no `contact.updated`). Fire-and-forget after a read-back.
|
|
164
|
+
if (created) {
|
|
165
|
+
void resolveContact({ db, id: contactId })
|
|
166
|
+
.then((row) => {
|
|
167
|
+
if (!row) return;
|
|
168
|
+
return emitOutbound({
|
|
169
|
+
db,
|
|
170
|
+
hatchet,
|
|
171
|
+
logger,
|
|
172
|
+
event: "contact.created",
|
|
173
|
+
payload: serializeContact(row),
|
|
174
|
+
});
|
|
175
|
+
})
|
|
176
|
+
.catch(logger.warn);
|
|
177
|
+
}
|
|
178
|
+
|
|
147
179
|
await applyListMembership({
|
|
148
180
|
db,
|
|
149
181
|
userId,
|
|
@@ -175,12 +207,14 @@ export const listsRouter = new OpenAPIHono<AppEnv>()
|
|
|
175
207
|
return c.json({ lists }, 200);
|
|
176
208
|
})
|
|
177
209
|
.openapi(subscribeRoute, async (c) => {
|
|
178
|
-
const { db } = c.get("container");
|
|
210
|
+
const { db, hatchet, logger } = c.get("container");
|
|
179
211
|
const { id } = c.req.valid("param");
|
|
180
212
|
const { email, userId } = c.req.valid("json");
|
|
181
213
|
|
|
182
214
|
const result = await applyListSubscription({
|
|
183
215
|
db,
|
|
216
|
+
hatchet,
|
|
217
|
+
logger,
|
|
184
218
|
id,
|
|
185
219
|
email,
|
|
186
220
|
userId,
|
|
@@ -198,12 +232,14 @@ export const listsRouter = new OpenAPIHono<AppEnv>()
|
|
|
198
232
|
return c.json({ list: id, subscribed: true as const }, 200);
|
|
199
233
|
})
|
|
200
234
|
.openapi(unsubscribeRoute, async (c) => {
|
|
201
|
-
const { db } = c.get("container");
|
|
235
|
+
const { db, hatchet, logger } = c.get("container");
|
|
202
236
|
const { id } = c.req.valid("param");
|
|
203
237
|
const { email, userId } = c.req.valid("json");
|
|
204
238
|
|
|
205
239
|
const result = await applyListSubscription({
|
|
206
240
|
db,
|
|
241
|
+
hatchet,
|
|
242
|
+
logger,
|
|
207
243
|
id,
|
|
208
244
|
email,
|
|
209
245
|
userId,
|