@hogsend/engine 0.0.1 → 0.1.1
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/container.ts +77 -2
- package/src/env.ts +4 -0
- package/src/index.ts +13 -0
- package/src/journeys/client-defaults-singleton.ts +29 -0
- package/src/journeys/define-journey.ts +45 -2
- package/src/journeys/journey-context.ts +125 -20
- package/src/lib/auth.ts +8 -1
- package/src/lib/email-service-types.ts +38 -1
- package/src/lib/email.ts +2 -0
- package/src/lib/frequency-cap.ts +54 -0
- package/src/lib/mailer.ts +11 -1
- package/src/lib/metrics-sql.ts +17 -0
- package/src/lib/studio.ts +105 -0
- package/src/lib/timezone.ts +126 -0
- package/src/lib/tracked.ts +44 -1
- package/src/middleware/rate-limit.ts +1 -1
- package/src/middleware/require-admin.ts +26 -0
- package/src/routes/admin/emails.ts +155 -9
- package/src/routes/admin/index.ts +8 -2
- package/src/routes/admin/metrics.ts +31 -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
|
@@ -6,7 +6,20 @@ import {
|
|
|
6
6
|
trackedLinks,
|
|
7
7
|
} from "@hogsend/db";
|
|
8
8
|
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
and,
|
|
11
|
+
asc,
|
|
12
|
+
count,
|
|
13
|
+
desc,
|
|
14
|
+
eq,
|
|
15
|
+
getTableColumns,
|
|
16
|
+
gte,
|
|
17
|
+
inArray,
|
|
18
|
+
isNotNull,
|
|
19
|
+
isNull,
|
|
20
|
+
lte,
|
|
21
|
+
or,
|
|
22
|
+
} from "drizzle-orm";
|
|
10
23
|
import type { AppEnv } from "../../app.js";
|
|
11
24
|
|
|
12
25
|
const emailSchema = z.object({
|
|
@@ -19,6 +32,8 @@ const emailSchema = z.object({
|
|
|
19
32
|
subject: z.string(),
|
|
20
33
|
category: z.string().nullable(),
|
|
21
34
|
status: z.string(),
|
|
35
|
+
userId: z.string().nullable(),
|
|
36
|
+
journeyId: z.string().nullable(),
|
|
22
37
|
sentAt: z.string().nullable(),
|
|
23
38
|
deliveredAt: z.string().nullable(),
|
|
24
39
|
openedAt: z.string().nullable(),
|
|
@@ -29,6 +44,14 @@ const emailSchema = z.object({
|
|
|
29
44
|
updatedAt: z.string(),
|
|
30
45
|
});
|
|
31
46
|
|
|
47
|
+
const eventSchema = z.object({
|
|
48
|
+
type: z.string(),
|
|
49
|
+
timestamp: z.string(),
|
|
50
|
+
url: z.string().optional(),
|
|
51
|
+
ipAddress: z.string().nullable().optional(),
|
|
52
|
+
userAgent: z.string().nullable().optional(),
|
|
53
|
+
});
|
|
54
|
+
|
|
32
55
|
const trackedLinkSchema = z.object({
|
|
33
56
|
id: z.string(),
|
|
34
57
|
originalUrl: z.string(),
|
|
@@ -54,7 +77,13 @@ const journeyContextSchema = z
|
|
|
54
77
|
|
|
55
78
|
import { errorSchema } from "../../lib/schemas.js";
|
|
56
79
|
|
|
57
|
-
function serializeEmail(
|
|
80
|
+
function serializeEmail(
|
|
81
|
+
row: typeof emailSends.$inferSelect,
|
|
82
|
+
identity: { userId: string | null; journeyId: string | null } = {
|
|
83
|
+
userId: null,
|
|
84
|
+
journeyId: null,
|
|
85
|
+
},
|
|
86
|
+
) {
|
|
58
87
|
return {
|
|
59
88
|
id: row.id,
|
|
60
89
|
journeyStateId: row.journeyStateId,
|
|
@@ -65,6 +94,8 @@ function serializeEmail(row: typeof emailSends.$inferSelect) {
|
|
|
65
94
|
subject: row.subject,
|
|
66
95
|
category: row.category,
|
|
67
96
|
status: row.status,
|
|
97
|
+
userId: identity.userId,
|
|
98
|
+
journeyId: identity.journeyId,
|
|
68
99
|
sentAt: row.sentAt?.toISOString() ?? null,
|
|
69
100
|
deliveredAt: row.deliveredAt?.toISOString() ?? null,
|
|
70
101
|
openedAt: row.openedAt?.toISOString() ?? null,
|
|
@@ -100,6 +131,16 @@ const listRoute = createRoute({
|
|
|
100
131
|
"failed",
|
|
101
132
|
])
|
|
102
133
|
.optional(),
|
|
134
|
+
journeyId: z.string().optional(),
|
|
135
|
+
userId: z.string().optional(),
|
|
136
|
+
category: z.string().optional(),
|
|
137
|
+
engagement: z
|
|
138
|
+
.enum(["opened", "clicked", "bounced", "complained"])
|
|
139
|
+
.optional(),
|
|
140
|
+
sort: z
|
|
141
|
+
.enum(["createdAt", "sentAt", "openedAt", "clickedAt"])
|
|
142
|
+
.default("createdAt"),
|
|
143
|
+
order: z.enum(["asc", "desc"]).default("desc"),
|
|
103
144
|
from: z.string().datetime().optional(),
|
|
104
145
|
to: z.string().datetime().optional(),
|
|
105
146
|
}),
|
|
@@ -135,6 +176,7 @@ const getRoute = createRoute({
|
|
|
135
176
|
"application/json": {
|
|
136
177
|
schema: z.object({
|
|
137
178
|
email: emailSchema,
|
|
179
|
+
events: z.array(eventSchema),
|
|
138
180
|
trackedLinks: z.array(trackedLinkSchema),
|
|
139
181
|
journeyContext: journeyContextSchema,
|
|
140
182
|
}),
|
|
@@ -188,32 +230,93 @@ async function fetchTrackedLinksWithClicks(db: Database, emailSendId: string) {
|
|
|
188
230
|
export const emailsRouter = new OpenAPIHono<AppEnv>()
|
|
189
231
|
.openapi(listRoute, async (c) => {
|
|
190
232
|
const { db } = c.get("container");
|
|
191
|
-
const {
|
|
192
|
-
|
|
233
|
+
const {
|
|
234
|
+
limit,
|
|
235
|
+
offset,
|
|
236
|
+
toEmail,
|
|
237
|
+
templateKey,
|
|
238
|
+
status,
|
|
239
|
+
journeyId,
|
|
240
|
+
userId,
|
|
241
|
+
category,
|
|
242
|
+
engagement,
|
|
243
|
+
sort,
|
|
244
|
+
order,
|
|
245
|
+
from,
|
|
246
|
+
to,
|
|
247
|
+
} = c.req.valid("query");
|
|
248
|
+
|
|
249
|
+
const engagementColumn = {
|
|
250
|
+
opened: emailSends.openedAt,
|
|
251
|
+
clicked: emailSends.clickedAt,
|
|
252
|
+
bounced: emailSends.bouncedAt,
|
|
253
|
+
complained: emailSends.complainedAt,
|
|
254
|
+
} as const;
|
|
255
|
+
|
|
256
|
+
const sortColumn = {
|
|
257
|
+
createdAt: emailSends.createdAt,
|
|
258
|
+
sentAt: emailSends.sentAt,
|
|
259
|
+
openedAt: emailSends.openedAt,
|
|
260
|
+
clickedAt: emailSends.clickedAt,
|
|
261
|
+
} as const;
|
|
193
262
|
|
|
194
263
|
const conditions = [];
|
|
195
264
|
if (toEmail) conditions.push(eq(emailSends.toEmail, toEmail));
|
|
196
265
|
if (templateKey) conditions.push(eq(emailSends.templateKey, templateKey));
|
|
197
266
|
if (status) conditions.push(eq(emailSends.status, status));
|
|
267
|
+
if (category) conditions.push(eq(emailSends.category, category));
|
|
268
|
+
if (journeyId) conditions.push(eq(journeyStates.journeyId, journeyId));
|
|
269
|
+
// Match the denormalized identity OR the journey-state join, so journeyless
|
|
270
|
+
// sends (which only carry the denormalized userId) are still filterable.
|
|
271
|
+
if (userId) {
|
|
272
|
+
conditions.push(
|
|
273
|
+
or(eq(emailSends.userId, userId), eq(journeyStates.userId, userId)),
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
if (engagement) conditions.push(isNotNull(engagementColumn[engagement]));
|
|
198
277
|
if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
|
|
199
278
|
if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
|
|
200
279
|
|
|
201
280
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
202
281
|
|
|
282
|
+
const orderBy =
|
|
283
|
+
order === "asc" ? asc(sortColumn[sort]) : desc(sortColumn[sort]);
|
|
284
|
+
|
|
285
|
+
const joinCondition = and(
|
|
286
|
+
eq(emailSends.journeyStateId, journeyStates.id),
|
|
287
|
+
isNull(journeyStates.deletedAt),
|
|
288
|
+
);
|
|
289
|
+
|
|
203
290
|
const [rows, totalRows] = await Promise.all([
|
|
204
291
|
db
|
|
205
|
-
.select(
|
|
292
|
+
.select({
|
|
293
|
+
...getTableColumns(emailSends),
|
|
294
|
+
identityUserId: journeyStates.userId,
|
|
295
|
+
identityJourneyId: journeyStates.journeyId,
|
|
296
|
+
})
|
|
206
297
|
.from(emailSends)
|
|
298
|
+
.leftJoin(journeyStates, joinCondition)
|
|
207
299
|
.where(where)
|
|
208
|
-
.orderBy(
|
|
300
|
+
.orderBy(orderBy)
|
|
209
301
|
.limit(limit)
|
|
210
302
|
.offset(offset),
|
|
211
|
-
db
|
|
303
|
+
db
|
|
304
|
+
.select({ count: count() })
|
|
305
|
+
.from(emailSends)
|
|
306
|
+
.leftJoin(journeyStates, joinCondition)
|
|
307
|
+
.where(where),
|
|
212
308
|
]);
|
|
213
309
|
|
|
214
310
|
return c.json(
|
|
215
311
|
{
|
|
216
|
-
emails: rows.map(
|
|
312
|
+
emails: rows.map(({ identityUserId, identityJourneyId, ...row }) =>
|
|
313
|
+
// Prefer the denormalized identity on the send row; fall back to the
|
|
314
|
+
// journey-state join (covers rows written before denormalization).
|
|
315
|
+
serializeEmail(row, {
|
|
316
|
+
userId: row.userId ?? identityUserId,
|
|
317
|
+
journeyId: identityJourneyId,
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
217
320
|
total: totalRows[0]?.count ?? 0,
|
|
218
321
|
limit,
|
|
219
322
|
offset,
|
|
@@ -258,9 +361,52 @@ export const emailsRouter = new OpenAPIHono<AppEnv>()
|
|
|
258
361
|
: Promise.resolve(null),
|
|
259
362
|
]);
|
|
260
363
|
|
|
364
|
+
const events: z.infer<typeof eventSchema>[] = [];
|
|
365
|
+
if (row.createdAt)
|
|
366
|
+
events.push({ type: "queued", timestamp: row.createdAt.toISOString() });
|
|
367
|
+
if (row.sentAt)
|
|
368
|
+
events.push({ type: "sent", timestamp: row.sentAt.toISOString() });
|
|
369
|
+
if (row.deliveredAt)
|
|
370
|
+
events.push({
|
|
371
|
+
type: "delivered",
|
|
372
|
+
timestamp: row.deliveredAt.toISOString(),
|
|
373
|
+
});
|
|
374
|
+
if (row.openedAt)
|
|
375
|
+
events.push({ type: "opened", timestamp: row.openedAt.toISOString() });
|
|
376
|
+
if (row.bouncedAt)
|
|
377
|
+
events.push({ type: "bounced", timestamp: row.bouncedAt.toISOString() });
|
|
378
|
+
if (row.complainedAt)
|
|
379
|
+
events.push({
|
|
380
|
+
type: "complained",
|
|
381
|
+
timestamp: row.complainedAt.toISOString(),
|
|
382
|
+
});
|
|
383
|
+
if (row.status === "failed" && row.updatedAt)
|
|
384
|
+
events.push({ type: "failed", timestamp: row.updatedAt.toISOString() });
|
|
385
|
+
|
|
386
|
+
for (const link of links) {
|
|
387
|
+
for (const click of link.clicks) {
|
|
388
|
+
events.push({
|
|
389
|
+
type: "clicked",
|
|
390
|
+
timestamp: click.clickedAt,
|
|
391
|
+
url: link.originalUrl,
|
|
392
|
+
ipAddress: click.ipAddress,
|
|
393
|
+
userAgent: click.userAgent,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
events.sort(
|
|
399
|
+
(a, b) =>
|
|
400
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
401
|
+
);
|
|
402
|
+
|
|
261
403
|
return c.json(
|
|
262
404
|
{
|
|
263
|
-
email: serializeEmail(row
|
|
405
|
+
email: serializeEmail(row, {
|
|
406
|
+
userId: row.userId ?? journeyContext?.userId ?? null,
|
|
407
|
+
journeyId: journeyContext?.journeyId ?? null,
|
|
408
|
+
}),
|
|
409
|
+
events,
|
|
264
410
|
trackedLinks: links,
|
|
265
411
|
journeyContext,
|
|
266
412
|
},
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
2
|
import type { AppEnv } from "../../app.js";
|
|
3
|
-
import { requireApiKey } from "../../middleware/api-key.js";
|
|
4
3
|
import { auditMiddleware } from "../../middleware/audit.js";
|
|
5
4
|
import { rateLimit } from "../../middleware/rate-limit.js";
|
|
5
|
+
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";
|
|
@@ -15,10 +15,13 @@ import { journeyLogsRouter } from "./journey-logs.js";
|
|
|
15
15
|
import { journeysRouter } from "./journeys.js";
|
|
16
16
|
import { metricsRouter } from "./metrics.js";
|
|
17
17
|
import { preferencesRouter } from "./preferences.js";
|
|
18
|
+
import { reportingRouter } from "./reporting.js";
|
|
19
|
+
import { suppressionsRouter } from "./suppressions.js";
|
|
20
|
+
import { templatesRouter } from "./templates.js";
|
|
18
21
|
import { timelineRouter } from "./timeline.js";
|
|
19
22
|
|
|
20
23
|
export const adminRouter = new OpenAPIHono<AppEnv>();
|
|
21
|
-
adminRouter.use("*",
|
|
24
|
+
adminRouter.use("*", requireAdmin);
|
|
22
25
|
adminRouter.use("*", rateLimit);
|
|
23
26
|
adminRouter.use("*", auditMiddleware);
|
|
24
27
|
adminRouter.route("/", bulkRouter);
|
|
@@ -30,6 +33,9 @@ adminRouter.route("/events", eventsRouter);
|
|
|
30
33
|
adminRouter.route("/emails", emailsRouter);
|
|
31
34
|
adminRouter.route("/journey-logs", journeyLogsRouter);
|
|
32
35
|
adminRouter.route("/metrics", metricsRouter);
|
|
36
|
+
adminRouter.route("/reporting", reportingRouter);
|
|
37
|
+
adminRouter.route("/templates", templatesRouter);
|
|
38
|
+
adminRouter.route("/suppressions", suppressionsRouter);
|
|
33
39
|
adminRouter.route("/api-keys", apiKeysRouter);
|
|
34
40
|
adminRouter.route("/audit-logs", auditLogsRouter);
|
|
35
41
|
adminRouter.route("/alerts", alertsRouter);
|
|
@@ -18,15 +18,9 @@ import {
|
|
|
18
18
|
sql,
|
|
19
19
|
} from "drizzle-orm";
|
|
20
20
|
import type { AppEnv } from "../../app.js";
|
|
21
|
+
import { rate, TRUNC_SQL } from "../../lib/metrics-sql.js";
|
|
21
22
|
import { errorSchema } from "../../lib/schemas.js";
|
|
22
23
|
|
|
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
24
|
const overviewRoute = createRoute({
|
|
31
25
|
method: "get",
|
|
32
26
|
path: "/overview",
|
|
@@ -123,6 +117,13 @@ const emailMetricsRoute = createRoute({
|
|
|
123
117
|
path: "/emails",
|
|
124
118
|
tags: ["Admin — Metrics"],
|
|
125
119
|
summary: "Per-template email metrics",
|
|
120
|
+
request: {
|
|
121
|
+
query: z.object({
|
|
122
|
+
from: z.string().datetime().optional(),
|
|
123
|
+
to: z.string().datetime().optional(),
|
|
124
|
+
includeUntemplated: z.enum(["true", "false"]).default("false"),
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
126
127
|
responses: {
|
|
127
128
|
200: {
|
|
128
129
|
content: {
|
|
@@ -139,6 +140,7 @@ const emailMetricsRoute = createRoute({
|
|
|
139
140
|
deliveryRate: z.number(),
|
|
140
141
|
openRate: z.number(),
|
|
141
142
|
clickRate: z.number(),
|
|
143
|
+
clickToDeliveryRate: z.number(),
|
|
142
144
|
}),
|
|
143
145
|
),
|
|
144
146
|
}),
|
|
@@ -284,8 +286,6 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
284
286
|
]);
|
|
285
287
|
|
|
286
288
|
const sent30d = emails30d;
|
|
287
|
-
const bounceRate30d = sent30d > 0 ? bounced30d / sent30d : 0;
|
|
288
|
-
const unsubscribeRate = totalPrefs > 0 ? unsubscribed / totalPrefs : 0;
|
|
289
289
|
|
|
290
290
|
return c.json(
|
|
291
291
|
{
|
|
@@ -294,8 +294,8 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
294
294
|
emailsSent24h: emails24h,
|
|
295
295
|
emailsSent7d: emails7d,
|
|
296
296
|
emailsSent30d: sent30d,
|
|
297
|
-
bounceRate30d:
|
|
298
|
-
unsubscribeRate:
|
|
297
|
+
bounceRate30d: rate(bounced30d, sent30d),
|
|
298
|
+
unsubscribeRate: rate(unsubscribed, totalPrefs),
|
|
299
299
|
},
|
|
300
300
|
200,
|
|
301
301
|
);
|
|
@@ -352,7 +352,6 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
352
352
|
(counts.failed ?? 0) +
|
|
353
353
|
(counts.exited ?? 0);
|
|
354
354
|
const completed = counts.completed ?? 0;
|
|
355
|
-
const completionRate = enrolled > 0 ? completed / enrolled : 0;
|
|
356
355
|
|
|
357
356
|
return {
|
|
358
357
|
journeyId: j.id,
|
|
@@ -362,7 +361,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
362
361
|
failed: counts.failed ?? 0,
|
|
363
362
|
exited: counts.exited ?? 0,
|
|
364
363
|
active: (counts.active ?? 0) + (counts.waiting ?? 0),
|
|
365
|
-
completionRate:
|
|
364
|
+
completionRate: rate(completed, enrolled),
|
|
366
365
|
avgDurationSecs: durationMap.has(j.id)
|
|
367
366
|
? Math.round(durationMap.get(j.id) ?? 0)
|
|
368
367
|
: null,
|
|
@@ -437,6 +436,15 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
437
436
|
})
|
|
438
437
|
.openapi(emailMetricsRoute, async (c) => {
|
|
439
438
|
const { db } = c.get("container");
|
|
439
|
+
const { from, to, includeUntemplated } = c.req.valid("query");
|
|
440
|
+
|
|
441
|
+
const conditions = [];
|
|
442
|
+
if (includeUntemplated !== "true")
|
|
443
|
+
conditions.push(isNotNull(emailSends.templateKey));
|
|
444
|
+
if (from) conditions.push(gte(emailSends.createdAt, new Date(from)));
|
|
445
|
+
if (to) conditions.push(lte(emailSends.createdAt, new Date(to)));
|
|
446
|
+
|
|
447
|
+
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
440
448
|
|
|
441
449
|
const rows = await db
|
|
442
450
|
.select({
|
|
@@ -448,7 +456,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
448
456
|
bounced: sql<number>`count(*) filter (where ${emailSends.status} = 'bounced')`,
|
|
449
457
|
})
|
|
450
458
|
.from(emailSends)
|
|
451
|
-
.where(
|
|
459
|
+
.where(where)
|
|
452
460
|
.groupBy(emailSends.templateKey);
|
|
453
461
|
|
|
454
462
|
const templates = rows.map((row) => {
|
|
@@ -456,19 +464,20 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
456
464
|
const delivered = Number(row.delivered);
|
|
457
465
|
const opened = Number(row.opened);
|
|
458
466
|
const clicked = Number(row.clicked);
|
|
467
|
+
// Open-rate denominator falls back to sent when delivered-webhooks aren't
|
|
468
|
+
// firing, so opens aren't silently zeroed.
|
|
469
|
+
const openDenominator = delivered > 0 ? delivered : sent;
|
|
459
470
|
return {
|
|
460
|
-
templateKey: row.templateKey ?? "",
|
|
471
|
+
templateKey: row.templateKey ?? "(none)",
|
|
461
472
|
sent,
|
|
462
473
|
delivered,
|
|
463
474
|
opened,
|
|
464
475
|
clicked,
|
|
465
476
|
bounced: Number(row.bounced),
|
|
466
|
-
deliveryRate:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
clickRate:
|
|
471
|
-
opened > 0 ? Math.round((clicked / opened) * 10000) / 10000 : 0,
|
|
477
|
+
deliveryRate: rate(delivered, sent),
|
|
478
|
+
openRate: rate(opened, openDenominator),
|
|
479
|
+
clickRate: rate(clicked, opened),
|
|
480
|
+
clickToDeliveryRate: rate(clicked, delivered),
|
|
472
481
|
};
|
|
473
482
|
});
|
|
474
483
|
|
|
@@ -510,8 +519,7 @@ export const metricsRouter = new OpenAPIHono<AppEnv>()
|
|
|
510
519
|
delivered,
|
|
511
520
|
bounced: Number(row.bounced),
|
|
512
521
|
complained: Number(row.complained),
|
|
513
|
-
deliveryRate:
|
|
514
|
-
total > 0 ? Math.round((delivered / total) * 10000) / 10000 : 0,
|
|
522
|
+
deliveryRate: rate(delivered, total),
|
|
515
523
|
};
|
|
516
524
|
});
|
|
517
525
|
|