@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
|
@@ -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,231 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import type { AppEnv } from "../../app.js";
|
|
3
|
+
import {
|
|
4
|
+
findContacts,
|
|
5
|
+
resolveContact,
|
|
6
|
+
resolveOrCreateContact,
|
|
7
|
+
serializeContact,
|
|
8
|
+
softDeleteContact,
|
|
9
|
+
} from "../../lib/contacts.js";
|
|
10
|
+
import { emitOutbound } from "../../lib/outbound.js";
|
|
11
|
+
import { applyListMembership } from "../../lib/preferences.js";
|
|
12
|
+
import { errorSchema } from "../../lib/schemas.js";
|
|
13
|
+
import { listMembershipError, requireIdentity } from "../_shared.js";
|
|
14
|
+
|
|
15
|
+
// The public, serialized contact shape (§2.5). `externalId` is nullable (D1 —
|
|
16
|
+
// email-only / anonymous contacts) and timestamps are ISO strings.
|
|
17
|
+
const contactSchema = z.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
externalId: z.string().nullable(),
|
|
20
|
+
email: z.string().nullable(),
|
|
21
|
+
properties: z.record(z.string(), z.unknown()),
|
|
22
|
+
firstSeenAt: z.string(),
|
|
23
|
+
lastSeenAt: z.string(),
|
|
24
|
+
createdAt: z.string(),
|
|
25
|
+
updatedAt: z.string(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const upsertRoute = createRoute({
|
|
29
|
+
method: "put",
|
|
30
|
+
path: "/",
|
|
31
|
+
tags: ["Contacts"],
|
|
32
|
+
summary: "Upsert a contact",
|
|
33
|
+
description:
|
|
34
|
+
"Resolves (create / fill-in-link / merge) a contact by email and/or userId, applies contactProperties, and optionally writes list membership.",
|
|
35
|
+
request: {
|
|
36
|
+
body: {
|
|
37
|
+
content: {
|
|
38
|
+
"application/json": {
|
|
39
|
+
schema: z.object({
|
|
40
|
+
email: z.string().email().optional(),
|
|
41
|
+
userId: z.string().min(1).optional(),
|
|
42
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
43
|
+
lists: z.record(z.string(), z.boolean()).optional(),
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
responses: {
|
|
50
|
+
200: {
|
|
51
|
+
content: {
|
|
52
|
+
"application/json": {
|
|
53
|
+
schema: z.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
created: z.boolean(),
|
|
56
|
+
linked: z.boolean(),
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
description: "Contact resolved",
|
|
61
|
+
},
|
|
62
|
+
400: {
|
|
63
|
+
content: { "application/json": { schema: errorSchema } },
|
|
64
|
+
description: "Missing recipient or unmanageable list membership",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const findRoute = createRoute({
|
|
70
|
+
method: "get",
|
|
71
|
+
path: "/find",
|
|
72
|
+
tags: ["Contacts"],
|
|
73
|
+
summary: "Find contacts by email or userId",
|
|
74
|
+
request: {
|
|
75
|
+
query: z.object({
|
|
76
|
+
email: z.string().optional(),
|
|
77
|
+
userId: z.string().optional(),
|
|
78
|
+
}),
|
|
79
|
+
},
|
|
80
|
+
responses: {
|
|
81
|
+
200: {
|
|
82
|
+
content: {
|
|
83
|
+
"application/json": {
|
|
84
|
+
schema: z.object({ contacts: z.array(contactSchema) }),
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
description: "Matching contacts (non-deleted)",
|
|
88
|
+
},
|
|
89
|
+
400: {
|
|
90
|
+
content: { "application/json": { schema: errorSchema } },
|
|
91
|
+
description: "Missing query key",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const deleteRoute = createRoute({
|
|
97
|
+
method: "delete",
|
|
98
|
+
path: "/",
|
|
99
|
+
tags: ["Contacts"],
|
|
100
|
+
summary: "Soft-delete a contact",
|
|
101
|
+
request: {
|
|
102
|
+
body: {
|
|
103
|
+
content: {
|
|
104
|
+
"application/json": {
|
|
105
|
+
schema: z.object({
|
|
106
|
+
email: z.string().optional(),
|
|
107
|
+
userId: z.string().optional(),
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
responses: {
|
|
114
|
+
200: {
|
|
115
|
+
content: {
|
|
116
|
+
"application/json": {
|
|
117
|
+
schema: z.object({ deleted: z.literal(true) }),
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
description: "Contact soft-deleted",
|
|
121
|
+
},
|
|
122
|
+
400: {
|
|
123
|
+
content: { "application/json": { schema: errorSchema } },
|
|
124
|
+
description: "Missing recipient key",
|
|
125
|
+
},
|
|
126
|
+
404: {
|
|
127
|
+
content: { "application/json": { schema: errorSchema } },
|
|
128
|
+
description: "Contact not found",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
export const contactsRouter = new OpenAPIHono<AppEnv>()
|
|
134
|
+
.openapi(upsertRoute, async (c) => {
|
|
135
|
+
const { db, hatchet, logger } = c.get("container");
|
|
136
|
+
const body = c.req.valid("json");
|
|
137
|
+
|
|
138
|
+
const guard = requireIdentity(c, body);
|
|
139
|
+
if (guard) return guard;
|
|
140
|
+
|
|
141
|
+
const { id, created, linked, merged } = await resolveOrCreateContact({
|
|
142
|
+
db,
|
|
143
|
+
userId: body.userId,
|
|
144
|
+
email: body.email,
|
|
145
|
+
contactProperties: body.properties,
|
|
146
|
+
});
|
|
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
|
+
|
|
173
|
+
// Lists applied AFTER the resolve so the contact exists (§2.5 lists
|
|
174
|
+
// ordering). `applyListMembership` requires a resolvable email — surface the
|
|
175
|
+
// "no email" case as a 400 rather than a 500.
|
|
176
|
+
if (body.lists && Object.keys(body.lists).length > 0) {
|
|
177
|
+
try {
|
|
178
|
+
await applyListMembership({
|
|
179
|
+
db,
|
|
180
|
+
userId: body.userId,
|
|
181
|
+
email: body.email,
|
|
182
|
+
lists: body.lists,
|
|
183
|
+
});
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return c.json({ error: listMembershipError(err) }, 400);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return c.json({ id, created, linked }, 200);
|
|
190
|
+
})
|
|
191
|
+
.openapi(findRoute, async (c) => {
|
|
192
|
+
const { db } = c.get("container");
|
|
193
|
+
const { email, userId } = c.req.valid("query");
|
|
194
|
+
|
|
195
|
+
const guard = requireIdentity(c, { email, userId });
|
|
196
|
+
if (guard) return guard;
|
|
197
|
+
|
|
198
|
+
const rows = await findContacts({ db, email, userId });
|
|
199
|
+
|
|
200
|
+
return c.json({ contacts: rows.map((row) => serializeContact(row)) }, 200);
|
|
201
|
+
})
|
|
202
|
+
.openapi(deleteRoute, async (c) => {
|
|
203
|
+
const { db, hatchet, logger } = c.get("container");
|
|
204
|
+
const { email, userId } = c.req.valid("json");
|
|
205
|
+
|
|
206
|
+
const guard = requireIdentity(c, { email, userId });
|
|
207
|
+
if (guard) return guard;
|
|
208
|
+
|
|
209
|
+
const result = await softDeleteContact({ db, email, userId });
|
|
210
|
+
if (!result.deleted) {
|
|
211
|
+
return c.json({ error: "Contact not found" }, 404);
|
|
212
|
+
}
|
|
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
|
+
|
|
230
|
+
return c.json({ deleted: true as const }, 200);
|
|
231
|
+
});
|
|
@@ -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
|
-
|
|
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
|
|
96
|
-
|
|
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
|
|
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
|
|
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,
|