@hogsend/engine 0.1.1 → 0.3.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/check-membership.ts +490 -0
- package/src/buckets/define-bucket.ts +52 -0
- package/src/buckets/membership-epoch.ts +186 -0
- package/src/buckets/registry-singleton.ts +21 -0
- package/src/buckets/registry.ts +62 -0
- package/src/container.ts +27 -1
- package/src/env.ts +6 -0
- package/src/index.ts +39 -1
- package/src/lib/bucket-emit.ts +107 -0
- package/src/lib/bucket-posthog-sync.ts +63 -0
- package/src/lib/ingestion.ts +25 -0
- package/src/routes/admin/buckets.ts +462 -0
- package/src/routes/admin/index.ts +2 -0
- package/src/routes/admin/metrics.ts +255 -0
- package/src/worker.ts +37 -0
- package/src/workflows/bucket-backfill.ts +593 -0
- package/src/workflows/bucket-reconcile.ts +1010 -0
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { bucketConfigs, bucketMemberships } from "@hogsend/db";
|
|
2
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
3
|
+
import { and, count, desc, eq, inArray, isNull } from "drizzle-orm";
|
|
4
|
+
import type { AppEnv } from "../../app.js";
|
|
5
|
+
|
|
6
|
+
const bucketSchema = z.object({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
name: z.string(),
|
|
9
|
+
description: z.string().optional(),
|
|
10
|
+
enabled: z.boolean(),
|
|
11
|
+
kind: z.enum(["dynamic", "manual"]),
|
|
12
|
+
timeBased: z.boolean(),
|
|
13
|
+
entryLimit: z.enum(["once", "once_per_period", "unlimited"]),
|
|
14
|
+
counts: z.object({
|
|
15
|
+
active: z.number(),
|
|
16
|
+
left: z.number(),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const memberSchema = z.object({
|
|
21
|
+
id: z.string(),
|
|
22
|
+
userId: z.string(),
|
|
23
|
+
userEmail: z.string().nullable(),
|
|
24
|
+
bucketId: z.string(),
|
|
25
|
+
status: z.string(),
|
|
26
|
+
enteredAt: z.string(),
|
|
27
|
+
leftAt: z.string().nullable(),
|
|
28
|
+
expiresAt: z.string().nullable(),
|
|
29
|
+
lastEvaluatedAt: z.string().nullable(),
|
|
30
|
+
entryCount: z.number(),
|
|
31
|
+
source: z.string().nullable(),
|
|
32
|
+
context: z.record(z.string(), z.unknown()),
|
|
33
|
+
createdAt: z.string(),
|
|
34
|
+
updatedAt: z.string(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const errorSchema = z.object({ error: z.string() });
|
|
38
|
+
|
|
39
|
+
function serializeMember(row: typeof bucketMemberships.$inferSelect) {
|
|
40
|
+
return {
|
|
41
|
+
id: row.id,
|
|
42
|
+
userId: row.userId,
|
|
43
|
+
userEmail: row.userEmail,
|
|
44
|
+
bucketId: row.bucketId,
|
|
45
|
+
status: row.status,
|
|
46
|
+
enteredAt: row.enteredAt.toISOString(),
|
|
47
|
+
leftAt: row.leftAt?.toISOString() ?? null,
|
|
48
|
+
expiresAt: row.expiresAt?.toISOString() ?? null,
|
|
49
|
+
lastEvaluatedAt: row.lastEvaluatedAt?.toISOString() ?? null,
|
|
50
|
+
entryCount: row.entryCount,
|
|
51
|
+
source: row.source,
|
|
52
|
+
context: (row.context ?? {}) as Record<string, unknown>,
|
|
53
|
+
createdAt: row.createdAt.toISOString(),
|
|
54
|
+
updatedAt: row.updatedAt.toISOString(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const emptyCounts = {
|
|
59
|
+
active: 0,
|
|
60
|
+
left: 0,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// --- Route definitions ---
|
|
64
|
+
|
|
65
|
+
const listRoute = createRoute({
|
|
66
|
+
method: "get",
|
|
67
|
+
path: "/",
|
|
68
|
+
tags: ["Admin — Buckets"],
|
|
69
|
+
summary: "List all buckets",
|
|
70
|
+
request: {
|
|
71
|
+
query: z.object({
|
|
72
|
+
limit: z.coerce.number().min(1).max(100).default(50),
|
|
73
|
+
offset: z.coerce.number().min(0).default(0),
|
|
74
|
+
enabled: z.enum(["true", "false"]).optional(),
|
|
75
|
+
}),
|
|
76
|
+
},
|
|
77
|
+
responses: {
|
|
78
|
+
200: {
|
|
79
|
+
content: {
|
|
80
|
+
"application/json": {
|
|
81
|
+
schema: z.object({
|
|
82
|
+
buckets: z.array(bucketSchema),
|
|
83
|
+
total: z.number(),
|
|
84
|
+
limit: z.number(),
|
|
85
|
+
offset: z.number(),
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
description: "Paginated bucket list",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const getRoute = createRoute({
|
|
95
|
+
method: "get",
|
|
96
|
+
path: "/{id}",
|
|
97
|
+
tags: ["Admin — Buckets"],
|
|
98
|
+
summary: "Get bucket detail",
|
|
99
|
+
request: {
|
|
100
|
+
params: z.object({ id: z.string() }),
|
|
101
|
+
},
|
|
102
|
+
responses: {
|
|
103
|
+
200: {
|
|
104
|
+
content: {
|
|
105
|
+
"application/json": {
|
|
106
|
+
schema: z.object({
|
|
107
|
+
bucket: bucketSchema.extend({
|
|
108
|
+
criteria: z.record(z.string(), z.unknown()).optional(),
|
|
109
|
+
entryPeriod: z
|
|
110
|
+
.record(z.string(), z.unknown())
|
|
111
|
+
.nullable()
|
|
112
|
+
.optional(),
|
|
113
|
+
minDwell: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
114
|
+
maxDwell: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
115
|
+
reconcileEvery: z
|
|
116
|
+
.record(z.string(), z.unknown())
|
|
117
|
+
.nullable()
|
|
118
|
+
.optional(),
|
|
119
|
+
fastExpiry: z.boolean(),
|
|
120
|
+
syncToPostHog: z.boolean(),
|
|
121
|
+
feedsJourneys: z.array(
|
|
122
|
+
z.object({
|
|
123
|
+
id: z.string(),
|
|
124
|
+
name: z.string(),
|
|
125
|
+
trigger: z.string(),
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
recentMembers: z.array(memberSchema),
|
|
129
|
+
}),
|
|
130
|
+
}),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
description: "Bucket detail with counts, feeds, and recent members",
|
|
134
|
+
},
|
|
135
|
+
404: {
|
|
136
|
+
content: { "application/json": { schema: errorSchema } },
|
|
137
|
+
description: "Bucket not found",
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const patchRoute = createRoute({
|
|
143
|
+
method: "patch",
|
|
144
|
+
path: "/{id}",
|
|
145
|
+
tags: ["Admin — Buckets"],
|
|
146
|
+
summary: "Enable or disable a bucket",
|
|
147
|
+
request: {
|
|
148
|
+
params: z.object({ id: z.string() }),
|
|
149
|
+
body: {
|
|
150
|
+
content: {
|
|
151
|
+
"application/json": {
|
|
152
|
+
schema: z.object({ enabled: z.boolean() }),
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
responses: {
|
|
158
|
+
200: {
|
|
159
|
+
content: {
|
|
160
|
+
"application/json": {
|
|
161
|
+
schema: z.object({
|
|
162
|
+
bucket: z.object({
|
|
163
|
+
id: z.string(),
|
|
164
|
+
name: z.string(),
|
|
165
|
+
enabled: z.boolean(),
|
|
166
|
+
updatedAt: z.string(),
|
|
167
|
+
}),
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
description: "Bucket updated",
|
|
172
|
+
},
|
|
173
|
+
404: {
|
|
174
|
+
content: { "application/json": { schema: errorSchema } },
|
|
175
|
+
description: "Bucket not found",
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const listMembersRoute = createRoute({
|
|
181
|
+
method: "get",
|
|
182
|
+
path: "/{id}/members",
|
|
183
|
+
tags: ["Admin — Buckets"],
|
|
184
|
+
summary: "List bucket members",
|
|
185
|
+
request: {
|
|
186
|
+
params: z.object({ id: z.string() }),
|
|
187
|
+
query: z.object({
|
|
188
|
+
limit: z.coerce.number().min(1).max(100).default(50),
|
|
189
|
+
offset: z.coerce.number().min(0).default(0),
|
|
190
|
+
status: z.enum(["active", "left"]).default("active"),
|
|
191
|
+
userId: z.string().optional(),
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
responses: {
|
|
195
|
+
200: {
|
|
196
|
+
content: {
|
|
197
|
+
"application/json": {
|
|
198
|
+
schema: z.object({
|
|
199
|
+
members: z.array(memberSchema),
|
|
200
|
+
total: z.number(),
|
|
201
|
+
limit: z.number(),
|
|
202
|
+
offset: z.number(),
|
|
203
|
+
}),
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
description: "Paginated bucket members",
|
|
207
|
+
},
|
|
208
|
+
404: {
|
|
209
|
+
content: { "application/json": { schema: errorSchema } },
|
|
210
|
+
description: "Bucket not found",
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// --- Handlers ---
|
|
216
|
+
|
|
217
|
+
export const bucketsRouter = new OpenAPIHono<AppEnv>()
|
|
218
|
+
.openapi(listRoute, async (c) => {
|
|
219
|
+
const { db, bucketRegistry } = c.get("container");
|
|
220
|
+
const { limit, offset, enabled } = c.req.valid("query");
|
|
221
|
+
|
|
222
|
+
const allBuckets = bucketRegistry.getAll();
|
|
223
|
+
const bucketIds = allBuckets.map((b) => b.id);
|
|
224
|
+
|
|
225
|
+
const [configs, statusCounts] = await Promise.all([
|
|
226
|
+
bucketIds.length > 0
|
|
227
|
+
? db
|
|
228
|
+
.select()
|
|
229
|
+
.from(bucketConfigs)
|
|
230
|
+
.where(inArray(bucketConfigs.bucketId, bucketIds))
|
|
231
|
+
: Promise.resolve([]),
|
|
232
|
+
bucketIds.length > 0
|
|
233
|
+
? db
|
|
234
|
+
.select({
|
|
235
|
+
bucketId: bucketMemberships.bucketId,
|
|
236
|
+
status: bucketMemberships.status,
|
|
237
|
+
count: count(),
|
|
238
|
+
})
|
|
239
|
+
.from(bucketMemberships)
|
|
240
|
+
.where(
|
|
241
|
+
and(
|
|
242
|
+
inArray(bucketMemberships.bucketId, bucketIds),
|
|
243
|
+
isNull(bucketMemberships.deletedAt),
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
.groupBy(bucketMemberships.bucketId, bucketMemberships.status)
|
|
247
|
+
: Promise.resolve([]),
|
|
248
|
+
]);
|
|
249
|
+
|
|
250
|
+
const configMap = new Map(
|
|
251
|
+
configs.map((cfg) => [cfg.bucketId, cfg.enabled]),
|
|
252
|
+
);
|
|
253
|
+
const countsMap = new Map<string, typeof emptyCounts>();
|
|
254
|
+
for (const row of statusCounts) {
|
|
255
|
+
const existing = countsMap.get(row.bucketId) ?? { ...emptyCounts };
|
|
256
|
+
existing[row.status as keyof typeof emptyCounts] = row.count;
|
|
257
|
+
countsMap.set(row.bucketId, existing);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const result = allBuckets.map((b) => {
|
|
261
|
+
const dbEnabled = configMap.get(b.id);
|
|
262
|
+
const effectiveEnabled = dbEnabled !== undefined ? dbEnabled : b.enabled;
|
|
263
|
+
return {
|
|
264
|
+
id: b.id,
|
|
265
|
+
name: b.name,
|
|
266
|
+
description: b.description,
|
|
267
|
+
enabled: effectiveEnabled,
|
|
268
|
+
kind: b.kind ?? "dynamic",
|
|
269
|
+
timeBased: b.timeBased ?? false,
|
|
270
|
+
entryLimit: b.entryLimit ?? "unlimited",
|
|
271
|
+
counts: countsMap.get(b.id) ?? { ...emptyCounts },
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const filtered =
|
|
276
|
+
enabled !== undefined
|
|
277
|
+
? result.filter((b) => b.enabled === (enabled === "true"))
|
|
278
|
+
: result;
|
|
279
|
+
|
|
280
|
+
const total = filtered.length;
|
|
281
|
+
const paged = filtered.slice(offset, offset + limit);
|
|
282
|
+
|
|
283
|
+
return c.json({ buckets: paged, total, limit, offset }, 200);
|
|
284
|
+
})
|
|
285
|
+
.openapi(getRoute, async (c) => {
|
|
286
|
+
const { db, bucketRegistry, registry } = c.get("container");
|
|
287
|
+
const { id } = c.req.valid("param");
|
|
288
|
+
|
|
289
|
+
const meta = bucketRegistry.get(id);
|
|
290
|
+
if (!meta) {
|
|
291
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const [configs, statusCounts, recentRows] = await Promise.all([
|
|
295
|
+
db
|
|
296
|
+
.select()
|
|
297
|
+
.from(bucketConfigs)
|
|
298
|
+
.where(eq(bucketConfigs.bucketId, id))
|
|
299
|
+
.limit(1),
|
|
300
|
+
db
|
|
301
|
+
.select({
|
|
302
|
+
status: bucketMemberships.status,
|
|
303
|
+
count: count(),
|
|
304
|
+
})
|
|
305
|
+
.from(bucketMemberships)
|
|
306
|
+
.where(
|
|
307
|
+
and(
|
|
308
|
+
eq(bucketMemberships.bucketId, id),
|
|
309
|
+
isNull(bucketMemberships.deletedAt),
|
|
310
|
+
),
|
|
311
|
+
)
|
|
312
|
+
.groupBy(bucketMemberships.status),
|
|
313
|
+
db
|
|
314
|
+
.select()
|
|
315
|
+
.from(bucketMemberships)
|
|
316
|
+
.where(
|
|
317
|
+
and(
|
|
318
|
+
eq(bucketMemberships.bucketId, id),
|
|
319
|
+
eq(bucketMemberships.status, "active"),
|
|
320
|
+
isNull(bucketMemberships.deletedAt),
|
|
321
|
+
),
|
|
322
|
+
)
|
|
323
|
+
.orderBy(desc(bucketMemberships.enteredAt))
|
|
324
|
+
.limit(10),
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
const dbEnabled = configs[0]?.enabled;
|
|
328
|
+
const effectiveEnabled = dbEnabled !== undefined ? dbEnabled : meta.enabled;
|
|
329
|
+
|
|
330
|
+
const counts = { ...emptyCounts };
|
|
331
|
+
for (const row of statusCounts) {
|
|
332
|
+
counts[row.status as keyof typeof emptyCounts] = row.count;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Which journeys this bucket feeds — cross-reference the bucket's emitted
|
|
336
|
+
// transition events against the journey registry's trigger index. A journey
|
|
337
|
+
// bound to the per-bucket alias `bucket:entered:<id>` (the recommended,
|
|
338
|
+
// narrowly-routed binding) or the generic `bucket:entered` is woken by this
|
|
339
|
+
// bucket's joins.
|
|
340
|
+
const feedEvents = [
|
|
341
|
+
`bucket:entered:${id}`,
|
|
342
|
+
`bucket:left:${id}`,
|
|
343
|
+
"bucket:entered",
|
|
344
|
+
"bucket:left",
|
|
345
|
+
];
|
|
346
|
+
const feedsMap = new Map<
|
|
347
|
+
string,
|
|
348
|
+
{ id: string; name: string; trigger: string }
|
|
349
|
+
>();
|
|
350
|
+
for (const evt of feedEvents) {
|
|
351
|
+
for (const journey of registry.getByTriggerEvent(evt)) {
|
|
352
|
+
feedsMap.set(journey.id, {
|
|
353
|
+
id: journey.id,
|
|
354
|
+
name: journey.name,
|
|
355
|
+
trigger: evt,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return c.json(
|
|
361
|
+
{
|
|
362
|
+
bucket: {
|
|
363
|
+
id: meta.id,
|
|
364
|
+
name: meta.name,
|
|
365
|
+
description: meta.description,
|
|
366
|
+
enabled: effectiveEnabled,
|
|
367
|
+
kind: meta.kind ?? "dynamic",
|
|
368
|
+
timeBased: meta.timeBased ?? false,
|
|
369
|
+
entryLimit: meta.entryLimit ?? "unlimited",
|
|
370
|
+
criteria: meta.criteria as Record<string, unknown> | undefined,
|
|
371
|
+
entryPeriod: meta.entryPeriod as Record<string, unknown> | undefined,
|
|
372
|
+
minDwell: meta.minDwell as Record<string, unknown> | undefined,
|
|
373
|
+
maxDwell: meta.maxDwell as Record<string, unknown> | undefined,
|
|
374
|
+
reconcileEvery: meta.reconcileEvery as
|
|
375
|
+
| Record<string, unknown>
|
|
376
|
+
| undefined,
|
|
377
|
+
fastExpiry: meta.fastExpiry ?? false,
|
|
378
|
+
syncToPostHog: meta.syncToPostHog ?? false,
|
|
379
|
+
counts,
|
|
380
|
+
feedsJourneys: Array.from(feedsMap.values()),
|
|
381
|
+
recentMembers: recentRows.map(serializeMember),
|
|
382
|
+
},
|
|
383
|
+
},
|
|
384
|
+
200,
|
|
385
|
+
);
|
|
386
|
+
})
|
|
387
|
+
.openapi(patchRoute, async (c) => {
|
|
388
|
+
const { db, bucketRegistry } = c.get("container");
|
|
389
|
+
const { id } = c.req.valid("param");
|
|
390
|
+
const body = c.req.valid("json");
|
|
391
|
+
|
|
392
|
+
const meta = bucketRegistry.get(id);
|
|
393
|
+
if (!meta) {
|
|
394
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const [config] = await db
|
|
398
|
+
.insert(bucketConfigs)
|
|
399
|
+
.values({ bucketId: id, enabled: body.enabled })
|
|
400
|
+
.onConflictDoUpdate({
|
|
401
|
+
target: [bucketConfigs.bucketId],
|
|
402
|
+
set: { enabled: body.enabled, updatedAt: new Date() },
|
|
403
|
+
})
|
|
404
|
+
.returning();
|
|
405
|
+
|
|
406
|
+
if (!config) {
|
|
407
|
+
throw new Error("Failed to upsert bucket config");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return c.json(
|
|
411
|
+
{
|
|
412
|
+
bucket: {
|
|
413
|
+
id: meta.id,
|
|
414
|
+
name: meta.name,
|
|
415
|
+
enabled: config.enabled,
|
|
416
|
+
updatedAt: config.updatedAt.toISOString(),
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
200,
|
|
420
|
+
);
|
|
421
|
+
})
|
|
422
|
+
.openapi(listMembersRoute, async (c) => {
|
|
423
|
+
const { db, bucketRegistry } = c.get("container");
|
|
424
|
+
const { id } = c.req.valid("param");
|
|
425
|
+
const { limit, offset, status, userId } = c.req.valid("query");
|
|
426
|
+
|
|
427
|
+
if (!bucketRegistry.has(id)) {
|
|
428
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const conditions = [
|
|
432
|
+
eq(bucketMemberships.bucketId, id),
|
|
433
|
+
eq(bucketMemberships.status, status),
|
|
434
|
+
isNull(bucketMemberships.deletedAt),
|
|
435
|
+
];
|
|
436
|
+
if (userId) {
|
|
437
|
+
conditions.push(eq(bucketMemberships.userId, userId));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const where = and(...conditions);
|
|
441
|
+
|
|
442
|
+
const [rows, totalRows] = await Promise.all([
|
|
443
|
+
db
|
|
444
|
+
.select()
|
|
445
|
+
.from(bucketMemberships)
|
|
446
|
+
.where(where)
|
|
447
|
+
.orderBy(desc(bucketMemberships.enteredAt))
|
|
448
|
+
.limit(limit)
|
|
449
|
+
.offset(offset),
|
|
450
|
+
db.select({ count: count() }).from(bucketMemberships).where(where),
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
return c.json(
|
|
454
|
+
{
|
|
455
|
+
members: rows.map(serializeMember),
|
|
456
|
+
total: totalRows[0]?.count ?? 0,
|
|
457
|
+
limit,
|
|
458
|
+
offset,
|
|
459
|
+
},
|
|
460
|
+
200,
|
|
461
|
+
);
|
|
462
|
+
});
|
|
@@ -6,6 +6,7 @@ import { requireAdmin } from "../../middleware/require-admin.js";
|
|
|
6
6
|
import { alertsRouter } from "./alerts.js";
|
|
7
7
|
import { apiKeysRouter } from "./api-keys.js";
|
|
8
8
|
import { auditLogsRouter } from "./audit-logs.js";
|
|
9
|
+
import { bucketsRouter } from "./buckets.js";
|
|
9
10
|
import { bulkRouter } from "./bulk.js";
|
|
10
11
|
import { contactsRouter } from "./contacts.js";
|
|
11
12
|
import { dlqRouter } from "./dlq.js";
|
|
@@ -29,6 +30,7 @@ adminRouter.route("/contacts", contactsRouter);
|
|
|
29
30
|
adminRouter.route("/contacts", preferencesRouter);
|
|
30
31
|
adminRouter.route("/contacts", timelineRouter);
|
|
31
32
|
adminRouter.route("/journeys", journeysRouter);
|
|
33
|
+
adminRouter.route("/buckets", bucketsRouter);
|
|
32
34
|
adminRouter.route("/events", eventsRouter);
|
|
33
35
|
adminRouter.route("/emails", emailsRouter);
|
|
34
36
|
adminRouter.route("/journey-logs", journeyLogsRouter);
|