@hogsend/engine 0.6.0 → 0.7.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.
Files changed (43) hide show
  1. package/package.json +6 -6
  2. package/src/buckets/check-membership.ts +34 -15
  3. package/src/container.ts +33 -0
  4. package/src/env.ts +4 -0
  5. package/src/index.ts +13 -0
  6. package/src/journeys/journey-context.ts +5 -1
  7. package/src/lib/boot.ts +1 -1
  8. package/src/lib/bucket-emit.ts +2 -2
  9. package/src/lib/contacts.ts +1083 -18
  10. package/src/lib/email-service-types.ts +8 -0
  11. package/src/lib/ingestion.ts +63 -33
  12. package/src/lib/mailer.ts +1 -0
  13. package/src/lib/preferences.ts +106 -0
  14. package/src/lib/tracked.ts +159 -34
  15. package/src/lib/tracking-events.ts +1 -1
  16. package/src/lists/define-list.ts +81 -0
  17. package/src/lists/registry-singleton.ts +39 -0
  18. package/src/lists/registry.ts +95 -0
  19. package/src/middleware/api-key.ts +33 -7
  20. package/src/middleware/rate-limit.ts +73 -49
  21. package/src/routes/_shared.ts +30 -0
  22. package/src/routes/admin/api-keys.ts +1 -1
  23. package/src/routes/admin/bulk.ts +7 -3
  24. package/src/routes/admin/contacts.ts +66 -57
  25. package/src/routes/admin/events.ts +65 -0
  26. package/src/routes/admin/journeys.ts +3 -1
  27. package/src/routes/admin/preferences.ts +2 -2
  28. package/src/routes/admin/reporting.ts +3 -3
  29. package/src/routes/admin/timeline.ts +5 -2
  30. package/src/routes/campaigns/index.ts +252 -0
  31. package/src/routes/contacts/index.ts +188 -0
  32. package/src/routes/email/preferences.ts +27 -3
  33. package/src/routes/email/unsubscribe.ts +7 -49
  34. package/src/routes/emails/index.ts +133 -0
  35. package/src/routes/events/index.ts +119 -0
  36. package/src/routes/index.ts +52 -2
  37. package/src/routes/lists/index.ts +222 -0
  38. package/src/worker.ts +6 -0
  39. package/src/workflows/bucket-backfill.ts +32 -21
  40. package/src/workflows/bucket-reconcile.ts +20 -5
  41. package/src/workflows/import-contacts.ts +28 -20
  42. package/src/workflows/send-campaign.ts +589 -0
  43. package/src/routes/ingest.ts +0 -71
@@ -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
- properties: body.properties ?? {},
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.externalId)];
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
- const externalId = contact.externalId;
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,252 @@
1
+ import { campaigns } from "@hogsend/db";
2
+ import { getTemplateNames, type TemplateName } from "@hogsend/email";
3
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
4
+ import { eq } from "drizzle-orm";
5
+ import type { AppEnv } from "../../app.js";
6
+ import { errorSchema } from "../../lib/schemas.js";
7
+ import { sendCampaignTask } from "../../workflows/send-campaign.js";
8
+
9
+ const createCampaignSchema = z
10
+ .object({
11
+ name: z.string().min(1).optional(),
12
+ list: z.string().min(1).optional(),
13
+ bucket: z.string().min(1).optional(),
14
+ template: z.string().min(1),
15
+ props: z.record(z.string(), z.unknown()).optional(),
16
+ from: z.string().optional(),
17
+ subject: z.string().optional(),
18
+ /**
19
+ * Optional client idempotency key. A retried create with the same key
20
+ * resolves to the EXISTING campaign instead of spawning a second broadcast
21
+ * (which would double-send to the same recipients). The `Idempotency-Key`
22
+ * header wins over this body field (mirrors /v1/emails + /v1/events).
23
+ */
24
+ idempotencyKey: z.string().min(1).optional(),
25
+ })
26
+ // EXACTLY ONE of list|bucket — XOR. Both-or-neither is a 400.
27
+ .refine((b) => (b.list ? 1 : 0) + (b.bucket ? 1 : 0) === 1, {
28
+ message: "Exactly one of `list` or `bucket` is required",
29
+ });
30
+
31
+ const createResponseSchema = z.object({
32
+ campaignId: z.string(),
33
+ status: z.string(),
34
+ });
35
+
36
+ const campaignSchema = z.object({
37
+ id: z.string(),
38
+ name: z.string(),
39
+ status: z.string(),
40
+ audienceKind: z.string(),
41
+ audienceId: z.string(),
42
+ templateKey: z.string(),
43
+ totalRecipients: z.number(),
44
+ sentCount: z.number(),
45
+ skippedCount: z.number(),
46
+ failedCount: z.number(),
47
+ startedAt: z.string().nullable(),
48
+ completedAt: z.string().nullable(),
49
+ createdAt: z.string(),
50
+ });
51
+
52
+ const createRouteDef = createRoute({
53
+ method: "post",
54
+ path: "/",
55
+ tags: ["Campaigns"],
56
+ summary: "Create + enqueue a campaign",
57
+ description:
58
+ "Sends one template to every subscribed member of a list (or every active member of a bucket). Validates the template + audience, inserts a `queued` campaign, and enqueues the durable `send-campaign` task. Exactly one of `list` or `bucket` is required.",
59
+ request: {
60
+ body: {
61
+ content: {
62
+ "application/json": { schema: createCampaignSchema },
63
+ },
64
+ },
65
+ },
66
+ responses: {
67
+ 202: {
68
+ content: {
69
+ "application/json": { schema: createResponseSchema },
70
+ },
71
+ description: "Campaign created and enqueued",
72
+ },
73
+ 400: {
74
+ content: { "application/json": { schema: errorSchema } },
75
+ description: "Invalid audience selector or unknown template",
76
+ },
77
+ 404: {
78
+ content: { "application/json": { schema: errorSchema } },
79
+ description: "Unknown list or bucket id",
80
+ },
81
+ },
82
+ });
83
+
84
+ const getRouteDef = createRoute({
85
+ method: "get",
86
+ path: "/{id}",
87
+ tags: ["Campaigns"],
88
+ summary: "Get a campaign",
89
+ request: {
90
+ params: z.object({ id: z.string() }),
91
+ },
92
+ responses: {
93
+ 200: {
94
+ content: {
95
+ "application/json": { schema: campaignSchema },
96
+ },
97
+ description: "The campaign",
98
+ },
99
+ 404: {
100
+ content: { "application/json": { schema: errorSchema } },
101
+ description: "Unknown campaign id",
102
+ },
103
+ },
104
+ });
105
+
106
+ // The campaigns router does NOT re-apply auth internally — the data-plane prefix
107
+ // guards in `routes/index.ts` (decision #16) apply `requireApiKey` +
108
+ // `requireScope("ingest")` to `/v1/campaigns` (bare + `/*`) before requests
109
+ // reach this router.
110
+ export const campaignsRouter = new OpenAPIHono<AppEnv>()
111
+ .openapi(createRouteDef, async (c) => {
112
+ const { db, templates, listRegistry, bucketRegistry, logger } =
113
+ c.get("container");
114
+ const body = c.req.valid("json");
115
+
116
+ // Enqueue the durable send task, swallowing a transient broker/transport
117
+ // failure: the campaign row is already committed in `queued`, so a failed
118
+ // enqueue is recovered by the reaper cron re-enqueueing the orphan rather
119
+ // than 500-ing the request (which a keyless client would retry into a
120
+ // duplicate broadcast).
121
+ const enqueue = async (campaignId: string): Promise<void> => {
122
+ try {
123
+ await sendCampaignTask.run({ campaignId });
124
+ } catch (err) {
125
+ logger.warn("POST /v1/campaigns: enqueue failed (reaper will retry)", {
126
+ campaignId,
127
+ error: err instanceof Error ? err.message : String(err),
128
+ });
129
+ }
130
+ };
131
+
132
+ // Validate the template against the wired registry (mirrors /v1/emails).
133
+ if (!getTemplateNames(templates).includes(body.template as TemplateName)) {
134
+ return c.json({ error: `Unknown template: ${body.template}` }, 400);
135
+ }
136
+
137
+ // XOR is already enforced by the schema refine, so exactly one is present.
138
+ const audienceKind = body.list ? "list" : "bucket";
139
+ const audienceId = (body.list ?? body.bucket) as string;
140
+
141
+ if (audienceKind === "list" && !listRegistry.has(audienceId)) {
142
+ return c.json({ error: `Unknown list: ${audienceId}` }, 404);
143
+ }
144
+ if (audienceKind === "bucket" && !bucketRegistry.has(audienceId)) {
145
+ return c.json({ error: `Unknown bucket: ${audienceId}` }, 404);
146
+ }
147
+
148
+ // Idempotency: the `Idempotency-Key` header wins over the body field
149
+ // (mirrors /v1/emails + /v1/events). A retried create with the same key must
150
+ // resolve to the SAME campaign — a fresh row would give the recipients a
151
+ // different per-send idempotency key and double-send the blast.
152
+ const headerKey = c.req.header("idempotency-key");
153
+ const idempotencyKey = headerKey ?? body.idempotencyKey ?? null;
154
+
155
+ if (idempotencyKey) {
156
+ const existing = await db
157
+ .select({ id: campaigns.id, status: campaigns.status })
158
+ .from(campaigns)
159
+ .where(eq(campaigns.idempotencyKey, idempotencyKey))
160
+ .limit(1);
161
+ const prior = existing[0];
162
+ if (prior) {
163
+ // Re-enqueue is safe (terminal-`sent` short-circuits; the per-send key
164
+ // dedups), so an idempotent retry both returns the existing campaign AND
165
+ // ensures it is running — covering a first attempt that committed the
166
+ // row but failed to enqueue.
167
+ await enqueue(prior.id);
168
+ return c.json({ campaignId: prior.id, status: prior.status }, 202);
169
+ }
170
+ }
171
+
172
+ const baseInsert = db.insert(campaigns).values({
173
+ name: body.name ?? `Campaign to ${audienceKind} ${audienceId}`,
174
+ status: "queued",
175
+ audienceKind,
176
+ audienceId,
177
+ templateKey: body.template,
178
+ props: (body.props ?? {}) as Record<string, unknown>,
179
+ fromEmail: body.from ?? null,
180
+ subject: body.subject ?? null,
181
+ idempotencyKey,
182
+ });
183
+
184
+ // With a key, swallow a concurrent-insert collision on the partial-unique
185
+ // index (the select-then-insert above is not atomic) and resolve the winner.
186
+ const insertRows = idempotencyKey
187
+ ? await baseInsert
188
+ .onConflictDoNothing({ target: campaigns.idempotencyKey })
189
+ .returning({ id: campaigns.id })
190
+ : await baseInsert.returning({ id: campaigns.id });
191
+
192
+ const campaignId = insertRows[0]?.id;
193
+ if (!campaignId && idempotencyKey) {
194
+ const winner = await db
195
+ .select({ id: campaigns.id, status: campaigns.status })
196
+ .from(campaigns)
197
+ .where(eq(campaigns.idempotencyKey, idempotencyKey))
198
+ .limit(1);
199
+ if (winner[0]) {
200
+ await enqueue(winner[0].id);
201
+ return c.json(
202
+ { campaignId: winner[0].id, status: winner[0].status },
203
+ 202,
204
+ );
205
+ }
206
+ }
207
+ if (!campaignId) throw new Error("Failed to create campaign");
208
+
209
+ // Enqueue the durable task. The campaign row is already committed in
210
+ // `queued`, so a transient enqueue failure (broker down) is NON-fatal: we
211
+ // still return 202 and the reaper cron re-enqueues the orphaned `queued`
212
+ // row. This keeps the request from 500-ing AFTER a committed row, which a
213
+ // client would otherwise retry into a duplicate (sans idempotency key).
214
+ await enqueue(campaignId);
215
+
216
+ return c.json({ campaignId, status: "queued" }, 202);
217
+ })
218
+ .openapi(getRouteDef, async (c) => {
219
+ const { db } = c.get("container");
220
+ const { id } = c.req.valid("param");
221
+
222
+ const rows = await db
223
+ .select()
224
+ .from(campaigns)
225
+ .where(eq(campaigns.id, id))
226
+ .limit(1);
227
+ const campaign = rows[0];
228
+ if (!campaign) {
229
+ return c.json({ error: `Unknown campaign: ${id}` }, 404);
230
+ }
231
+
232
+ return c.json(
233
+ {
234
+ id: campaign.id,
235
+ name: campaign.name,
236
+ status: campaign.status,
237
+ audienceKind: campaign.audienceKind,
238
+ audienceId: campaign.audienceId,
239
+ templateKey: campaign.templateKey,
240
+ totalRecipients: campaign.totalRecipients,
241
+ sentCount: campaign.sentCount,
242
+ skippedCount: campaign.skippedCount,
243
+ failedCount: campaign.failedCount,
244
+ startedAt: campaign.startedAt ? campaign.startedAt.toISOString() : null,
245
+ completedAt: campaign.completedAt
246
+ ? campaign.completedAt.toISOString()
247
+ : null,
248
+ createdAt: campaign.createdAt.toISOString(),
249
+ },
250
+ 200,
251
+ );
252
+ });
@@ -0,0 +1,188 @@
1
+ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
2
+ import type { AppEnv } from "../../app.js";
3
+ import {
4
+ findContacts,
5
+ resolveOrCreateContact,
6
+ serializeContact,
7
+ softDeleteContact,
8
+ } from "../../lib/contacts.js";
9
+ import { applyListMembership } from "../../lib/preferences.js";
10
+ import { errorSchema } from "../../lib/schemas.js";
11
+ import { listMembershipError, requireIdentity } from "../_shared.js";
12
+
13
+ // The public, serialized contact shape (§2.5). `externalId` is nullable (D1 —
14
+ // email-only / anonymous contacts) and timestamps are ISO strings.
15
+ const contactSchema = z.object({
16
+ id: z.string(),
17
+ externalId: z.string().nullable(),
18
+ email: z.string().nullable(),
19
+ properties: z.record(z.string(), z.unknown()),
20
+ firstSeenAt: z.string(),
21
+ lastSeenAt: z.string(),
22
+ createdAt: z.string(),
23
+ updatedAt: z.string(),
24
+ });
25
+
26
+ const upsertRoute = createRoute({
27
+ method: "put",
28
+ path: "/",
29
+ tags: ["Contacts"],
30
+ summary: "Upsert a contact",
31
+ description:
32
+ "Resolves (create / fill-in-link / merge) a contact by email and/or userId, applies contactProperties, and optionally writes list membership.",
33
+ request: {
34
+ body: {
35
+ content: {
36
+ "application/json": {
37
+ schema: z.object({
38
+ email: z.string().email().optional(),
39
+ userId: z.string().min(1).optional(),
40
+ properties: z.record(z.string(), z.unknown()).optional(),
41
+ lists: z.record(z.string(), z.boolean()).optional(),
42
+ }),
43
+ },
44
+ },
45
+ },
46
+ },
47
+ responses: {
48
+ 200: {
49
+ content: {
50
+ "application/json": {
51
+ schema: z.object({
52
+ id: z.string(),
53
+ created: z.boolean(),
54
+ linked: z.boolean(),
55
+ }),
56
+ },
57
+ },
58
+ description: "Contact resolved",
59
+ },
60
+ 400: {
61
+ content: { "application/json": { schema: errorSchema } },
62
+ description: "Missing recipient or unmanageable list membership",
63
+ },
64
+ },
65
+ });
66
+
67
+ const findRoute = createRoute({
68
+ method: "get",
69
+ path: "/find",
70
+ tags: ["Contacts"],
71
+ summary: "Find contacts by email or userId",
72
+ request: {
73
+ query: z.object({
74
+ email: z.string().optional(),
75
+ userId: z.string().optional(),
76
+ }),
77
+ },
78
+ responses: {
79
+ 200: {
80
+ content: {
81
+ "application/json": {
82
+ schema: z.object({ contacts: z.array(contactSchema) }),
83
+ },
84
+ },
85
+ description: "Matching contacts (non-deleted)",
86
+ },
87
+ 400: {
88
+ content: { "application/json": { schema: errorSchema } },
89
+ description: "Missing query key",
90
+ },
91
+ },
92
+ });
93
+
94
+ const deleteRoute = createRoute({
95
+ method: "delete",
96
+ path: "/",
97
+ tags: ["Contacts"],
98
+ summary: "Soft-delete a contact",
99
+ request: {
100
+ body: {
101
+ content: {
102
+ "application/json": {
103
+ schema: z.object({
104
+ email: z.string().optional(),
105
+ userId: z.string().optional(),
106
+ }),
107
+ },
108
+ },
109
+ },
110
+ },
111
+ responses: {
112
+ 200: {
113
+ content: {
114
+ "application/json": {
115
+ schema: z.object({ deleted: z.literal(true) }),
116
+ },
117
+ },
118
+ description: "Contact soft-deleted",
119
+ },
120
+ 400: {
121
+ content: { "application/json": { schema: errorSchema } },
122
+ description: "Missing recipient key",
123
+ },
124
+ 404: {
125
+ content: { "application/json": { schema: errorSchema } },
126
+ description: "Contact not found",
127
+ },
128
+ },
129
+ });
130
+
131
+ export const contactsRouter = new OpenAPIHono<AppEnv>()
132
+ .openapi(upsertRoute, async (c) => {
133
+ const { db } = c.get("container");
134
+ const body = c.req.valid("json");
135
+
136
+ const guard = requireIdentity(c, body);
137
+ if (guard) return guard;
138
+
139
+ const { id, created, linked } = await resolveOrCreateContact({
140
+ db,
141
+ userId: body.userId,
142
+ email: body.email,
143
+ contactProperties: body.properties,
144
+ });
145
+
146
+ // Lists applied AFTER the resolve so the contact exists (§2.5 lists
147
+ // ordering). `applyListMembership` requires a resolvable email — surface the
148
+ // "no email" case as a 400 rather than a 500.
149
+ if (body.lists && Object.keys(body.lists).length > 0) {
150
+ try {
151
+ await applyListMembership({
152
+ db,
153
+ userId: body.userId,
154
+ email: body.email,
155
+ lists: body.lists,
156
+ });
157
+ } catch (err) {
158
+ return c.json({ error: listMembershipError(err) }, 400);
159
+ }
160
+ }
161
+
162
+ return c.json({ id, created, linked }, 200);
163
+ })
164
+ .openapi(findRoute, async (c) => {
165
+ const { db } = c.get("container");
166
+ const { email, userId } = c.req.valid("query");
167
+
168
+ const guard = requireIdentity(c, { email, userId });
169
+ if (guard) return guard;
170
+
171
+ const rows = await findContacts({ db, email, userId });
172
+
173
+ return c.json({ contacts: rows.map((row) => serializeContact(row)) }, 200);
174
+ })
175
+ .openapi(deleteRoute, async (c) => {
176
+ const { db } = c.get("container");
177
+ const { email, userId } = c.req.valid("json");
178
+
179
+ const guard = requireIdentity(c, { email, userId });
180
+ if (guard) return guard;
181
+
182
+ const deleted = await softDeleteContact({ db, email, userId });
183
+ if (!deleted) {
184
+ return c.json({ error: "Contact not found" }, 404);
185
+ }
186
+
187
+ return c.json({ deleted: true as const }, 200);
188
+ });
@@ -9,8 +9,12 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
9
9
  import { and, eq } from "drizzle-orm";
10
10
  import type { AppEnv } from "../../app.js";
11
11
  import { htmlPage } from "../../lib/html.js";
12
+ import { getListRegistry } from "../../lists/registry-singleton.js";
12
13
 
13
- const EMAIL_CATEGORIES = [
14
+ // The built-in journey/lifecycle category is always shown. Defined lists (D3)
15
+ // are appended from the registry so the preference center and the mailer's
16
+ // suppression check share ONE polarity source (`ListRegistry.isSubscribed`).
17
+ const BUILTIN_CATEGORIES = [
14
18
  { id: "journey", label: "Journey & lifecycle emails" },
15
19
  ] as const;
16
20
 
@@ -91,9 +95,29 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>().openapi(
91
95
  return `${env.API_PUBLIC_URL}/v1/email/unsubscribe?token=${encodeURIComponent(actionToken)}`;
92
96
  }
93
97
 
98
+ // Built-in journey category + every enabled defined list, deduped by id
99
+ // (a list MAY NOT reuse a reserved category id, but guard anyway).
100
+ const listRegistry = getListRegistry();
101
+ const seen = new Set<string>();
102
+ const renderableCategories: { id: string; label: string }[] = [];
103
+ for (const cat of BUILTIN_CATEGORIES) {
104
+ seen.add(cat.id);
105
+ renderableCategories.push({ id: cat.id, label: cat.label });
106
+ }
107
+ for (const list of listRegistry.getEnabled()) {
108
+ if (seen.has(list.id)) continue;
109
+ seen.add(list.id);
110
+ renderableCategories.push({ id: list.id, label: list.name });
111
+ }
112
+
94
113
  let categoryRows = "";
95
- for (const cat of EMAIL_CATEGORIES) {
96
- const isSubscribed = categories[cat.id] !== false && !globalUnsub;
114
+ for (const cat of renderableCategories) {
115
+ // Registry-driven polarity (§2.6): defined lists honour their
116
+ // `defaultOptIn`; unknown ids (e.g. the built-in `journey`) fall through
117
+ // to opt-in default (blocked only on explicit `false`). A global
118
+ // unsubscribe overrides every per-category state.
119
+ const isSubscribed =
120
+ listRegistry.isSubscribed(categories, cat.id) && !globalUnsub;
97
121
  const statusClass = isSubscribed ? "subscribed" : "unsubscribed";
98
122
  const statusText = isSubscribed ? "Subscribed" : "Unsubscribed";
99
123
  const actionLabel = isSubscribed ? "Unsubscribe" : "Resubscribe";
@@ -1,5 +1,3 @@
1
- import type { Database } from "@hogsend/db";
2
- import { emailPreferences } from "@hogsend/db";
3
1
  import type { UnsubscribeTokenPayload } from "@hogsend/email";
4
2
  import {
5
3
  generatePreferenceCenterUrl,
@@ -7,9 +5,9 @@ import {
7
5
  validateUnsubscribeToken,
8
6
  } from "@hogsend/email";
9
7
  import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
10
- import { sql } from "drizzle-orm";
11
8
  import type { AppEnv } from "../../app.js";
12
9
  import { htmlPage } from "../../lib/html.js";
10
+ import { upsertEmailPreference } from "../../lib/preferences.js";
13
11
 
14
12
  const unsubscribeRoute = createRoute({
15
13
  method: "get",
@@ -33,46 +31,6 @@ const unsubscribeRoute = createRoute({
33
31
  },
34
32
  });
35
33
 
36
- async function upsertPreference(
37
- db: Database,
38
- externalId: string,
39
- email: string,
40
- update: {
41
- unsubscribedAll?: boolean;
42
- categoryKey?: string;
43
- categoryValue?: boolean;
44
- },
45
- ) {
46
- const setClause: Record<string, unknown> = { updatedAt: new Date() };
47
-
48
- if (update.unsubscribedAll !== undefined) {
49
- setClause.unsubscribedAll = update.unsubscribedAll;
50
- }
51
- if (update.categoryKey !== undefined) {
52
- const jsonValue = update.categoryValue ? "true" : "false";
53
- setClause.categories = sql`jsonb_set(COALESCE(${emailPreferences.categories}, '{}'::jsonb), ${`{${update.categoryKey}}`}, ${jsonValue}::jsonb)`;
54
- }
55
-
56
- await db
57
- .insert(emailPreferences)
58
- .values({
59
- userId: externalId,
60
- email,
61
- ...(update.unsubscribedAll !== undefined
62
- ? { unsubscribedAll: update.unsubscribedAll }
63
- : {}),
64
- ...(update.categoryKey !== undefined
65
- ? {
66
- categories: { [update.categoryKey]: update.categoryValue ?? false },
67
- }
68
- : {}),
69
- })
70
- .onConflictDoUpdate({
71
- target: [emailPreferences.userId, emailPreferences.email],
72
- set: setClause,
73
- });
74
- }
75
-
76
34
  export const unsubscribeRouter = new OpenAPIHono<AppEnv>().openapi(
77
35
  unsubscribeRoute,
78
36
  async (c) => {
@@ -110,18 +68,18 @@ export const unsubscribeRouter = new OpenAPIHono<AppEnv>().openapi(
110
68
  }
111
69
 
112
70
  if (action === "resubscribe") {
113
- await upsertPreference(
71
+ await upsertEmailPreference({
114
72
  db,
115
73
  externalId,
116
74
  email,
117
- category
75
+ update: category
118
76
  ? {
119
77
  categoryKey: category,
120
78
  categoryValue: true,
121
79
  unsubscribedAll: false,
122
80
  }
123
81
  : { unsubscribedAll: false },
124
- );
82
+ });
125
83
 
126
84
  return c.html(
127
85
  htmlPage({
@@ -132,14 +90,14 @@ export const unsubscribeRouter = new OpenAPIHono<AppEnv>().openapi(
132
90
  );
133
91
  }
134
92
 
135
- await upsertPreference(
93
+ await upsertEmailPreference({
136
94
  db,
137
95
  externalId,
138
96
  email,
139
- category
97
+ update: category
140
98
  ? { categoryKey: category, categoryValue: false }
141
99
  : { unsubscribedAll: true },
142
- );
100
+ });
143
101
 
144
102
  const preferenceCenterUrl = generatePreferenceCenterUrl({
145
103
  baseUrl: env.API_PUBLIC_URL,