@hogsend/engine 0.5.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.
- package/package.json +6 -6
- package/src/buckets/bucket-access.ts +213 -0
- package/src/buckets/bucket-reactions.ts +225 -0
- package/src/buckets/check-membership.ts +35 -15
- package/src/buckets/define-bucket.ts +79 -8
- package/src/buckets/registry.ts +81 -0
- package/src/container.ts +69 -4
- package/src/env.ts +4 -0
- package/src/index.ts +27 -0
- package/src/journeys/journey-context.ts +5 -1
- package/src/lib/boot.ts +12 -2
- package/src/lib/bucket-emit.ts +49 -7
- package/src/lib/contacts.ts +1083 -18
- package/src/lib/email-service-types.ts +8 -0
- package/src/lib/ingestion.ts +63 -33
- package/src/lib/mailer.ts +1 -0
- package/src/lib/preferences.ts +106 -0
- package/src/lib/tracked.ts +159 -34
- package/src/lib/tracking-events.ts +1 -1
- 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/buckets.ts +39 -9
- package/src/routes/admin/bulk.ts +7 -3
- package/src/routes/admin/contacts.ts +66 -57
- package/src/routes/admin/events.ts +65 -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/campaigns/index.ts +252 -0
- package/src/routes/contacts/index.ts +188 -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 +222 -0
- package/src/worker.ts +25 -2
- package/src/workflows/bucket-backfill.ts +122 -22
- package/src/workflows/bucket-reconcile.ts +225 -12
- package/src/workflows/import-contacts.ts +28 -20
- package/src/workflows/send-campaign.ts +589 -0
- package/src/routes/ingest.ts +0 -71
|
@@ -2,6 +2,7 @@ import { userEvents } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, count, desc, eq, gte, lte } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
+
import { ingestEvent } from "../../lib/ingestion.js";
|
|
5
6
|
|
|
6
7
|
const eventSchema = z.object({
|
|
7
8
|
id: z.string(),
|
|
@@ -79,7 +80,71 @@ const getRoute = createRoute({
|
|
|
79
80
|
},
|
|
80
81
|
});
|
|
81
82
|
|
|
83
|
+
const exitSchema = z.object({
|
|
84
|
+
journeyId: z.string(),
|
|
85
|
+
stateId: z.string(),
|
|
86
|
+
exited: z.boolean(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const ingestRoute = createRoute({
|
|
90
|
+
method: "post",
|
|
91
|
+
path: "/",
|
|
92
|
+
tags: ["Admin — Events"],
|
|
93
|
+
summary: "Ingest a test event",
|
|
94
|
+
description:
|
|
95
|
+
"Session-authed ingest for the Studio Debug panel. Runs an event " +
|
|
96
|
+
"through the full ingest pipeline (stores it, routes it to journeys, " +
|
|
97
|
+
"evaluates exits). Inherits requireAdmin session auth from the admin " +
|
|
98
|
+
"mount — does NOT accept an hsk_ API key.",
|
|
99
|
+
request: {
|
|
100
|
+
body: {
|
|
101
|
+
content: {
|
|
102
|
+
"application/json": {
|
|
103
|
+
schema: z.object({
|
|
104
|
+
event: z.string().min(1),
|
|
105
|
+
userId: z.string().optional(),
|
|
106
|
+
userEmail: z.string().optional(),
|
|
107
|
+
properties: z.record(z.string(), z.unknown()).optional(),
|
|
108
|
+
}),
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
responses: {
|
|
114
|
+
202: {
|
|
115
|
+
content: {
|
|
116
|
+
"application/json": {
|
|
117
|
+
schema: z.object({
|
|
118
|
+
stored: z.boolean(),
|
|
119
|
+
exits: z.array(exitSchema),
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
description: "Event accepted and ingested",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
82
128
|
export const eventsRouter = new OpenAPIHono<AppEnv>()
|
|
129
|
+
.openapi(ingestRoute, async (c) => {
|
|
130
|
+
const { db, registry, hatchet, logger } = c.get("container");
|
|
131
|
+
const { event, userId, userEmail, properties } = c.req.valid("json");
|
|
132
|
+
|
|
133
|
+
const result = await ingestEvent({
|
|
134
|
+
db,
|
|
135
|
+
registry,
|
|
136
|
+
hatchet,
|
|
137
|
+
logger,
|
|
138
|
+
event: {
|
|
139
|
+
event,
|
|
140
|
+
userId,
|
|
141
|
+
userEmail,
|
|
142
|
+
eventProperties: properties ?? {},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return c.json(result, 202);
|
|
147
|
+
})
|
|
83
148
|
.openapi(listRoute, async (c) => {
|
|
84
149
|
const { db } = c.get("container");
|
|
85
150
|
const { limit, offset, userId, event, from, to } = c.req.valid("query");
|
|
@@ -662,7 +662,9 @@ export const journeysRouter = new OpenAPIHono<AppEnv>()
|
|
|
662
662
|
event: meta.trigger.event,
|
|
663
663
|
userId: body.userId,
|
|
664
664
|
userEmail: body.userEmail,
|
|
665
|
-
|
|
665
|
+
// Public request field stays `properties` (decision #14); it maps to
|
|
666
|
+
// the event-property bag of the IngestEvent (D2 split).
|
|
667
|
+
eventProperties: body.properties ?? {},
|
|
666
668
|
},
|
|
667
669
|
});
|
|
668
670
|
|
|
@@ -104,7 +104,7 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>()
|
|
|
104
104
|
const rows = await db
|
|
105
105
|
.select()
|
|
106
106
|
.from(emailPreferences)
|
|
107
|
-
.where(eq(emailPreferences.userId, contact.externalId))
|
|
107
|
+
.where(eq(emailPreferences.userId, contact.externalId ?? contact.id))
|
|
108
108
|
.limit(1);
|
|
109
109
|
|
|
110
110
|
if (rows.length === 0) {
|
|
@@ -131,7 +131,7 @@ export const preferencesRouter = new OpenAPIHono<AppEnv>()
|
|
|
131
131
|
const [upserted] = await db
|
|
132
132
|
.insert(emailPreferences)
|
|
133
133
|
.values({
|
|
134
|
-
userId: contact.externalId,
|
|
134
|
+
userId: contact.externalId ?? contact.id,
|
|
135
135
|
email: contact.email,
|
|
136
136
|
unsubscribedAll: body.unsubscribedAll ?? false,
|
|
137
137
|
suppressed: body.suppressed ?? false,
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
sql,
|
|
13
13
|
} from "drizzle-orm";
|
|
14
14
|
import type { AppEnv } from "../../app.js";
|
|
15
|
-
import { resolveContact } from "../../lib/contacts.js";
|
|
15
|
+
import { contactKey, resolveContact } from "../../lib/contacts.js";
|
|
16
16
|
import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
|
|
17
17
|
import { errorSchema } from "../../lib/schemas.js";
|
|
18
18
|
|
|
@@ -109,7 +109,7 @@ const contactActivityRoute = createRoute({
|
|
|
109
109
|
"application/json": {
|
|
110
110
|
schema: z.object({
|
|
111
111
|
contact: z.object({
|
|
112
|
-
externalId: z.string(),
|
|
112
|
+
externalId: z.string().nullable(),
|
|
113
113
|
email: z.string().nullable(),
|
|
114
114
|
}),
|
|
115
115
|
sends: z.array(
|
|
@@ -244,7 +244,7 @@ export const reportingRouter = new OpenAPIHono<AppEnv>()
|
|
|
244
244
|
|
|
245
245
|
// Denormalized identity makes this single-table; fall back to the contact's
|
|
246
246
|
// email so journeyless sends still surface.
|
|
247
|
-
const idConds = [eq(emailSends.userId, contact
|
|
247
|
+
const idConds = [eq(emailSends.userId, contactKey(contact))];
|
|
248
248
|
if (contact.email) idConds.push(eq(emailSends.userEmail, contact.email));
|
|
249
249
|
const where = or(...idConds);
|
|
250
250
|
|
|
@@ -2,7 +2,7 @@ import { emailSends, journeyStates, userEvents } from "@hogsend/db";
|
|
|
2
2
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
3
|
import { and, count, desc, eq, isNull } from "drizzle-orm";
|
|
4
4
|
import type { AppEnv } from "../../app.js";
|
|
5
|
-
import { resolveContact } from "../../lib/contacts.js";
|
|
5
|
+
import { contactKey, resolveContact } from "../../lib/contacts.js";
|
|
6
6
|
|
|
7
7
|
const timelineEntrySchema = z.object({
|
|
8
8
|
type: z.enum(["event", "journey", "email"]),
|
|
@@ -64,7 +64,10 @@ export const timelineRouter = new OpenAPIHono<AppEnv>().openapi(
|
|
|
64
64
|
return c.json({ error: "Contact not found" }, 404);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
// Resolved string key the history tables (user_events/journey_states/
|
|
68
|
+
// email_sends) were written under: external_id, else anonymous_id, else the
|
|
69
|
+
// contact uuid (matches ingestEvent's resolvedKey).
|
|
70
|
+
const externalId = contactKey(contact);
|
|
68
71
|
const entries: TimelineEntry[] = [];
|
|
69
72
|
|
|
70
73
|
const shouldFetch = (t: string) => !type || type === t;
|
|
@@ -0,0 +1,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
|
-
|
|
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";
|