@hogsend/engine 0.1.0 → 0.2.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/app.ts +37 -0
- package/src/buckets/check-membership.ts +499 -0
- package/src/buckets/define-bucket.ts +29 -0
- package/src/buckets/registry-singleton.ts +21 -0
- package/src/buckets/registry.ts +62 -0
- package/src/container.ts +50 -2
- package/src/env.ts +10 -0
- package/src/index.ts +40 -1
- package/src/lib/auth.ts +8 -1
- package/src/lib/bucket-emit.ts +107 -0
- package/src/lib/bucket-posthog-sync.ts +63 -0
- package/src/lib/email-service-types.ts +6 -0
- package/src/lib/email.ts +2 -0
- package/src/lib/ingestion.ts +25 -0
- package/src/lib/mailer.ts +9 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/tracked.ts +4 -0
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/buckets.ts +464 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +10 -2
- package/src/routes/admin/metrics.ts +286 -23
- package/src/routes/admin/reporting.ts +369 -0
- package/src/routes/admin/suppressions.ts +99 -0
- package/src/routes/admin/templates.ts +298 -0
- package/src/worker.ts +35 -0
- package/src/workflows/bucket-backfill.ts +556 -0
- package/src/workflows/bucket-reconcile.ts +721 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
bucketMemberships,
|
|
2
3
|
contacts,
|
|
3
4
|
emailPreferences,
|
|
4
5
|
emailSends,
|
|
@@ -18,15 +19,9 @@ import {
|
|
|
18
19
|
sql,
|
|
19
20
|
} from "drizzle-orm";
|
|
20
21
|
import type { AppEnv } from "../../app.js";
|
|
22
|
+
import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
|
|
21
23
|
import { errorSchema } from "../../lib/schemas.js";
|
|
22
24
|
|
|
23
|
-
const TRUNC_SQL = {
|
|
24
|
-
hour: sql`'hour'`,
|
|
25
|
-
day: sql`'day'`,
|
|
26
|
-
week: sql`'week'`,
|
|
27
|
-
month: sql`'month'`,
|
|
28
|
-
} as const;
|
|
29
|
-
|
|
30
25
|
const overviewRoute = createRoute({
|
|
31
26
|
method: "get",
|
|
32
27
|
path: "/overview",
|
|
@@ -39,6 +34,8 @@ const overviewRoute = createRoute({
|
|
|
39
34
|
schema: z.object({
|
|
40
35
|
totalContacts: z.number(),
|
|
41
36
|
activeJourneys: z.number(),
|
|
37
|
+
activeBuckets: z.number(),
|
|
38
|
+
bucketMembers: z.number(),
|
|
42
39
|
emailsSent24h: z.number(),
|
|
43
40
|
emailsSent7d: z.number(),
|
|
44
41
|
emailsSent30d: z.number(),
|
|
@@ -123,6 +120,13 @@ const emailMetricsRoute = createRoute({
|
|
|
123
120
|
path: "/emails",
|
|
124
121
|
tags: ["Admin — Metrics"],
|
|
125
122
|
summary: "Per-template email metrics",
|
|
123
|
+
request: {
|
|
124
|
+
query: z.object({
|
|
125
|
+
from: z.string().datetime().optional(),
|
|
126
|
+
to: z.string().datetime().optional(),
|
|
127
|
+
includeUntemplated: z.enum(["true", "false"]).default("false"),
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
126
130
|
responses: {
|
|
127
131
|
200: {
|
|
128
132
|
content: {
|
|
@@ -139,6 +143,7 @@ const emailMetricsRoute = createRoute({
|
|
|
139
143
|
deliveryRate: z.number(),
|
|
140
144
|
openRate: z.number(),
|
|
141
145
|
clickRate: z.number(),
|
|
146
|
+
clickToDeliveryRate: z.number(),
|
|
142
147
|
}),
|
|
143
148
|
),
|
|
144
149
|
}),
|
|
@@ -216,6 +221,75 @@ const eventVolumeRoute = createRoute({
|
|
|
216
221
|
},
|
|
217
222
|
});
|
|
218
223
|
|
|
224
|
+
const bucketsMetricsRoute = createRoute({
|
|
225
|
+
method: "get",
|
|
226
|
+
path: "/buckets",
|
|
227
|
+
tags: ["Admin — Metrics"],
|
|
228
|
+
summary: "Per-bucket membership metrics",
|
|
229
|
+
responses: {
|
|
230
|
+
200: {
|
|
231
|
+
content: {
|
|
232
|
+
"application/json": {
|
|
233
|
+
schema: z.object({
|
|
234
|
+
buckets: z.array(
|
|
235
|
+
z.object({
|
|
236
|
+
bucketId: z.string(),
|
|
237
|
+
name: z.string(),
|
|
238
|
+
size: z.number(),
|
|
239
|
+
entered: z.number(),
|
|
240
|
+
left: z.number(),
|
|
241
|
+
avgDwellSecs: z.number().nullable(),
|
|
242
|
+
}),
|
|
243
|
+
),
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
description: "Per-bucket size, entered/left totals, and average dwell",
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const bucketTrendRoute = createRoute({
|
|
253
|
+
method: "get",
|
|
254
|
+
path: "/buckets/{id}",
|
|
255
|
+
tags: ["Admin — Metrics"],
|
|
256
|
+
summary: "Single bucket size-over-time and entered/left trend",
|
|
257
|
+
request: {
|
|
258
|
+
params: z.object({ id: z.string() }),
|
|
259
|
+
query: z.object({
|
|
260
|
+
period: z.enum(["day", "week", "month"]).default("day"),
|
|
261
|
+
from: z.string().datetime().optional(),
|
|
262
|
+
to: z.string().datetime().optional(),
|
|
263
|
+
}),
|
|
264
|
+
},
|
|
265
|
+
responses: {
|
|
266
|
+
200: {
|
|
267
|
+
content: {
|
|
268
|
+
"application/json": {
|
|
269
|
+
schema: z.object({
|
|
270
|
+
bucketId: z.string(),
|
|
271
|
+
size: z.number(),
|
|
272
|
+
points: z.array(
|
|
273
|
+
z.object({
|
|
274
|
+
date: z.string(),
|
|
275
|
+
entered: z.number(),
|
|
276
|
+
left: z.number(),
|
|
277
|
+
}),
|
|
278
|
+
),
|
|
279
|
+
}),
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
description: "Bucket entered/left time-series with current size",
|
|
283
|
+
},
|
|
284
|
+
404: {
|
|
285
|
+
content: {
|
|
286
|
+
"application/json": { schema: errorSchema },
|
|
287
|
+
},
|
|
288
|
+
description: "Bucket not found",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
219
293
|
export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
220
294
|
.openapi(overviewRoute, async (c) => {
|
|
221
295
|
const { db } = c.get("container");
|
|
@@ -228,6 +302,8 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
228
302
|
const [
|
|
229
303
|
contactsTotal,
|
|
230
304
|
activeJourneys,
|
|
305
|
+
activeBucketsRows,
|
|
306
|
+
bucketMembers,
|
|
231
307
|
emails24h,
|
|
232
308
|
emails7d,
|
|
233
309
|
emails30d,
|
|
@@ -250,6 +326,27 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
250
326
|
),
|
|
251
327
|
)
|
|
252
328
|
.then((r) => r[0]?.count ?? 0),
|
|
329
|
+
// Distinct buckets with at least one active member.
|
|
330
|
+
db
|
|
331
|
+
.selectDistinct({ bucketId: bucketMemberships.bucketId })
|
|
332
|
+
.from(bucketMemberships)
|
|
333
|
+
.where(
|
|
334
|
+
and(
|
|
335
|
+
inArray(bucketMemberships.status, ["active"]),
|
|
336
|
+
isNull(bucketMemberships.deletedAt),
|
|
337
|
+
),
|
|
338
|
+
),
|
|
339
|
+
// Total active memberships across all buckets.
|
|
340
|
+
db
|
|
341
|
+
.select({ count: count() })
|
|
342
|
+
.from(bucketMemberships)
|
|
343
|
+
.where(
|
|
344
|
+
and(
|
|
345
|
+
inArray(bucketMemberships.status, ["active"]),
|
|
346
|
+
isNull(bucketMemberships.deletedAt),
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
.then((r) => r[0]?.count ?? 0),
|
|
253
350
|
db
|
|
254
351
|
.select({ count: count() })
|
|
255
352
|
.from(emailSends)
|
|
@@ -284,18 +381,18 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
284
381
|
]);
|
|
285
382
|
|
|
286
383
|
const sent30d = emails30d;
|
|
287
|
-
const bounceRate30d = sent30d > 0 ? bounced30d / sent30d : 0;
|
|
288
|
-
const unsubscribeRate = totalPrefs > 0 ? unsubscribed / totalPrefs : 0;
|
|
289
384
|
|
|
290
385
|
return c.json(
|
|
291
386
|
{
|
|
292
387
|
totalContacts: contactsTotal,
|
|
293
388
|
activeJourneys,
|
|
389
|
+
activeBuckets: activeBucketsRows.length,
|
|
390
|
+
bucketMembers,
|
|
294
391
|
emailsSent24h: emails24h,
|
|
295
392
|
emailsSent7d: emails7d,
|
|
296
393
|
emailsSent30d: sent30d,
|
|
297
|
-
bounceRate30d:
|
|
298
|
-
unsubscribeRate:
|
|
394
|
+
bounceRate30d: rate(bounced30d, sent30d),
|
|
395
|
+
unsubscribeRate: rate(unsubscribed, totalPrefs),
|
|
299
396
|
},
|
|
300
397
|
200,
|
|
301
398
|
);
|
|
@@ -352,7 +449,6 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
352
449
|
(counts.failed ?? 0) +
|
|
353
450
|
(counts.exited ?? 0);
|
|
354
451
|
const completed = counts.completed ?? 0;
|
|
355
|
-
const completionRate = enrolled > 0 ? completed / enrolled : 0;
|
|
356
452
|
|
|
357
453
|
return {
|
|
358
454
|
journeyId: j.id,
|
|
@@ -362,7 +458,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
362
458
|
failed: counts.failed ?? 0,
|
|
363
459
|
exited: counts.exited ?? 0,
|
|
364
460
|
active: (counts.active ?? 0) + (counts.waiting ?? 0),
|
|
365
|
-
completionRate:
|
|
461
|
+
completionRate: rate(completed, enrolled),
|
|
366
462
|
avgDurationSecs: durationMap.has(j.id)
|
|
367
463
|
? Math.round(durationMap.get(j.id) ?? 0)
|
|
368
464
|
: null,
|
|
@@ -437,6 +533,15 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
437
533
|
})
|
|
438
534
|
.openapi(emailMetricsRoute, async (c) => {
|
|
439
535
|
const { db } = c.get("container");
|
|
536
|
+
const { from, to, includeUntemplated } = c.req.valid("query");
|
|
537
|
+
|
|
538
|
+
const conditions = [];
|
|
539
|
+
if (includeUntemplated !== "true")
|
|
540
|
+
conditions.push(isNotNull(emailSends.templateKey));
|
|
541
|
+
if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
|
|
542
|
+
if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
|
|
543
|
+
|
|
544
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
440
545
|
|
|
441
546
|
const rows = await db
|
|
442
547
|
.select({
|
|
@@ -448,7 +553,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
448
553
|
bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
|
|
449
554
|
})
|
|
450
555
|
.from(emailSends)
|
|
451
|
-
.where(
|
|
556
|
+
.where(where)
|
|
452
557
|
.groupBy(emailSends.templateKey);
|
|
453
558
|
|
|
454
559
|
const templates = rows.map((row) => {
|
|
@@ -456,19 +561,20 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
456
561
|
const delivered = Number(row.delivered);
|
|
457
562
|
const opened = Number(row.opened);
|
|
458
563
|
const clicked = Number(row.clicked);
|
|
564
|
+
// Open-rate denominator falls back to sent when delivered-webhooks aren't
|
|
565
|
+
// firing, so opens aren't silently zeroed.
|
|
566
|
+
const openDenominator = delivered > 0 ? delivered : sent;
|
|
459
567
|
return {
|
|
460
|
-
templateKey: row.templateKey ?? "",
|
|
568
|
+
templateKey: row.templateKey ?? "(none)",
|
|
461
569
|
sent,
|
|
462
570
|
delivered,
|
|
463
571
|
opened,
|
|
464
572
|
clicked,
|
|
465
573
|
bounced: Number(row.bounced),
|
|
466
|
-
deliveryRate:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
clickRate:
|
|
471
|
-
opened > 0 ? Math.round((clicked / opened) * 10000) / 10000 : 0,
|
|
574
|
+
deliveryRate: rate(delivered, sent),
|
|
575
|
+
openRate: rate(opened, openDenominator),
|
|
576
|
+
clickRate: rate(clicked, opened),
|
|
577
|
+
clickToDeliveryRate: rate(clicked, delivered),
|
|
472
578
|
};
|
|
473
579
|
});
|
|
474
580
|
|
|
@@ -510,8 +616,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
510
616
|
delivered,
|
|
511
617
|
bounced: Number(row.bounced),
|
|
512
618
|
complained: Number(row.complained),
|
|
513
|
-
deliveryRate:
|
|
514
|
-
total > 0 ? Math.round((delivered / total) * 10000) / 10000 : 0,
|
|
619
|
+
deliveryRate: rate(delivered, total),
|
|
515
620
|
};
|
|
516
621
|
});
|
|
517
622
|
|
|
@@ -556,4 +661,162 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
556
661
|
}));
|
|
557
662
|
|
|
558
663
|
return c.json({ events }, 200);
|
|
664
|
+
})
|
|
665
|
+
.openapi(bucketsMetricsRoute, async (c) => {
|
|
666
|
+
const { db, bucketRegistry } = c.get("container");
|
|
667
|
+
|
|
668
|
+
const [sizes, totals, dwell] = await Promise.all([
|
|
669
|
+
// Current size = active, non-deleted memberships per bucket.
|
|
670
|
+
db
|
|
671
|
+
.select({
|
|
672
|
+
bucketId: bucketMemberships.bucketId,
|
|
673
|
+
size: count(),
|
|
674
|
+
})
|
|
675
|
+
.from(bucketMemberships)
|
|
676
|
+
.where(
|
|
677
|
+
and(
|
|
678
|
+
eq(bucketMemberships.status, "active"),
|
|
679
|
+
isNull(bucketMemberships.deletedAt),
|
|
680
|
+
),
|
|
681
|
+
)
|
|
682
|
+
.groupBy(bucketMemberships.bucketId),
|
|
683
|
+
// Total entered = all rows; total left = rows that have flipped to "left".
|
|
684
|
+
db
|
|
685
|
+
.select({
|
|
686
|
+
bucketId: bucketMemberships.bucketId,
|
|
687
|
+
entered: count(),
|
|
688
|
+
left: sql<number>`count(*) filter (where ${bucketMemberships.status} = 'left')`,
|
|
689
|
+
})
|
|
690
|
+
.from(bucketMemberships)
|
|
691
|
+
.where(isNull(bucketMemberships.deletedAt))
|
|
692
|
+
.groupBy(bucketMemberships.bucketId),
|
|
693
|
+
// Average dwell — seconds between entry and leave (or now, if still active).
|
|
694
|
+
db
|
|
695
|
+
.select({
|
|
696
|
+
bucketId: bucketMemberships.bucketId,
|
|
697
|
+
avgDwell: sql<number>`avg(extract(epoch from (coalesce(${bucketMemberships.leftAt}, now()) - ${bucketMemberships.enteredAt})))`,
|
|
698
|
+
})
|
|
699
|
+
.from(bucketMemberships)
|
|
700
|
+
.where(isNull(bucketMemberships.deletedAt))
|
|
701
|
+
.groupBy(bucketMemberships.bucketId),
|
|
702
|
+
]);
|
|
703
|
+
|
|
704
|
+
const sizeMap = new Map(sizes.map((r) => [r.bucketId, r.size]));
|
|
705
|
+
const totalsMap = new Map(
|
|
706
|
+
totals.map((r) => [
|
|
707
|
+
r.bucketId,
|
|
708
|
+
{ entered: Number(r.entered), left: Number(r.left) },
|
|
709
|
+
]),
|
|
710
|
+
);
|
|
711
|
+
const dwellMap = new Map<string, number>();
|
|
712
|
+
for (const row of dwell) {
|
|
713
|
+
if (row.avgDwell != null) {
|
|
714
|
+
dwellMap.set(row.bucketId, Number(row.avgDwell));
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const buckets = bucketRegistry.getAll().map((b) => {
|
|
719
|
+
const t = totalsMap.get(b.id) ?? { entered: 0, left: 0 };
|
|
720
|
+
return {
|
|
721
|
+
bucketId: b.id,
|
|
722
|
+
name: b.name,
|
|
723
|
+
size: sizeMap.get(b.id) ?? 0,
|
|
724
|
+
entered: t.entered,
|
|
725
|
+
left: t.left,
|
|
726
|
+
avgDwellSecs: dwellMap.has(b.id)
|
|
727
|
+
? Math.round(dwellMap.get(b.id) ?? 0)
|
|
728
|
+
: null,
|
|
729
|
+
};
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
return c.json({ buckets }, 200);
|
|
733
|
+
})
|
|
734
|
+
.openapi(bucketTrendRoute, async (c) => {
|
|
735
|
+
const { db, bucketRegistry } = c.get("container");
|
|
736
|
+
const { id } = c.req.valid("param");
|
|
737
|
+
const { period, from, to } = c.req.valid("query");
|
|
738
|
+
|
|
739
|
+
if (!bucketRegistry.has(id)) {
|
|
740
|
+
return c.json({ error: "Bucket not found" }, 404);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const now = new Date();
|
|
744
|
+
const defaultFrom = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
745
|
+
const fromDate = from ? new Date(from) : defaultFrom;
|
|
746
|
+
const toDate = to ? new Date(to) : now;
|
|
747
|
+
|
|
748
|
+
const [size, enteredRows, leftRows] = await Promise.all([
|
|
749
|
+
db
|
|
750
|
+
.select({ count: count() })
|
|
751
|
+
.from(bucketMemberships)
|
|
752
|
+
.where(
|
|
753
|
+
and(
|
|
754
|
+
eq(bucketMemberships.bucketId, id),
|
|
755
|
+
eq(bucketMemberships.status, "active"),
|
|
756
|
+
isNull(bucketMemberships.deletedAt),
|
|
757
|
+
),
|
|
758
|
+
)
|
|
759
|
+
.then((r) => r[0]?.count ?? 0),
|
|
760
|
+
// Joins over time, bucketed on enteredAt.
|
|
761
|
+
db
|
|
762
|
+
.select({
|
|
763
|
+
date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})::text`,
|
|
764
|
+
count: count(),
|
|
765
|
+
})
|
|
766
|
+
.from(bucketMemberships)
|
|
767
|
+
.where(
|
|
768
|
+
and(
|
|
769
|
+
eq(bucketMemberships.bucketId, id),
|
|
770
|
+
isNull(bucketMemberships.deletedAt),
|
|
771
|
+
gte(bucketMemberships.enteredAt, fromDate),
|
|
772
|
+
lte(bucketMemberships.enteredAt, toDate),
|
|
773
|
+
),
|
|
774
|
+
)
|
|
775
|
+
.groupBy(
|
|
776
|
+
sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})`,
|
|
777
|
+
)
|
|
778
|
+
.orderBy(
|
|
779
|
+
sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.enteredAt})`,
|
|
780
|
+
),
|
|
781
|
+
// Leaves over time, bucketed on leftAt (only flipped rows have a leftAt).
|
|
782
|
+
db
|
|
783
|
+
.select({
|
|
784
|
+
date: sql<string>`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})::text`,
|
|
785
|
+
count: count(),
|
|
786
|
+
})
|
|
787
|
+
.from(bucketMemberships)
|
|
788
|
+
.where(
|
|
789
|
+
and(
|
|
790
|
+
eq(bucketMemberships.bucketId, id),
|
|
791
|
+
isNull(bucketMemberships.deletedAt),
|
|
792
|
+
isNotNull(bucketMemberships.leftAt),
|
|
793
|
+
gte(bucketMemberships.leftAt, fromDate),
|
|
794
|
+
lte(bucketMemberships.leftAt, toDate),
|
|
795
|
+
),
|
|
796
|
+
)
|
|
797
|
+
.groupBy(
|
|
798
|
+
sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})`,
|
|
799
|
+
)
|
|
800
|
+
.orderBy(
|
|
801
|
+
sql`date_trunc(${TRUNC_SQL[period]}, ${bucketMemberships.leftAt})`,
|
|
802
|
+
),
|
|
803
|
+
]);
|
|
804
|
+
|
|
805
|
+
const pointMap = new Map<string, { entered: number; left: number }>();
|
|
806
|
+
for (const row of enteredRows) {
|
|
807
|
+
const entry = pointMap.get(row.date) ?? { entered: 0, left: 0 };
|
|
808
|
+
entry.entered = row.count;
|
|
809
|
+
pointMap.set(row.date, entry);
|
|
810
|
+
}
|
|
811
|
+
for (const row of leftRows) {
|
|
812
|
+
const entry = pointMap.get(row.date) ?? { entered: 0, left: 0 };
|
|
813
|
+
entry.left = row.count;
|
|
814
|
+
pointMap.set(row.date, entry);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const points = Array.from(pointMap.entries())
|
|
818
|
+
.map(([date, v]) => ({ date, entered: v.entered, left: v.left }))
|
|
819
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
820
|
+
|
|
821
|
+
return c.json({ bucketId: id, size, points }, 200);
|
|
559
822
|
});
|