@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.
@@ -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 deleted = await softDeleteContact({ db, email, userId });
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 { resolveOrCreateContact } from "../../lib/contacts.js";
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
- await resolveOrCreateContact({ db, userId, email });
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,