Skip to content

Commit 98d8ac6

Browse files
committed
perf(webapp): aggregate admin notification interaction counts in the database
The admin notifications list fetched every interaction row for the page's notifications (~76k rows/call) into the webapp to compute three per-notification counters in JS. Replace the Prisma relation include with a single grouped COUNT(*) FILTER aggregate scoped to the page's ids, returning ~20 rows instead of ~76k and eliminating the egress and object hydration.
1 parent 949e9cf commit 98d8ac6

4 files changed

Lines changed: 131 additions & 29 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
The notifications admin list loads faster by counting interactions in the database instead of loading every interaction row per page.
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { postgresTest } from "@internal/testcontainers";
2+
import { describe, expect, vi } from "vitest";
3+
4+
vi.mock("~/db.server", async () => {
5+
const { Prisma } = await import("@trigger.dev/database");
6+
return {
7+
prisma: {},
8+
Prisma,
9+
sqlDatabaseSchema: Prisma.sql(["public"]),
10+
};
11+
});
12+
13+
vi.mock("~/services/platformNotificationCounter.server", () => ({
14+
incrementCliRequestCounter: vi.fn(),
15+
}));
16+
17+
import { getAdminNotificationsList } from "./platformNotifications.server";
18+
19+
vi.setConfig({ testTimeout: 60_000 });
20+
21+
describe("getAdminNotificationsList interaction stats", () => {
22+
postgresTest("counts seen/clicked/dismissed per notification", async ({ prisma }) => {
23+
const now = new Date();
24+
const later = new Date(now.getTime() + 1000 * 60 * 60 * 24);
25+
26+
const withInteractions = await prisma.platformNotification.create({
27+
data: {
28+
title: "with-interactions",
29+
payload: {},
30+
surface: "WEBAPP",
31+
scope: "GLOBAL",
32+
startsAt: now,
33+
endsAt: later,
34+
},
35+
});
36+
37+
const withoutInteractions = await prisma.platformNotification.create({
38+
data: {
39+
title: "without-interactions",
40+
payload: {},
41+
surface: "WEBAPP",
42+
scope: "GLOBAL",
43+
startsAt: now,
44+
endsAt: later,
45+
},
46+
});
47+
48+
const users = await Promise.all(
49+
[1, 2, 3, 4, 5].map((n) =>
50+
prisma.user.create({
51+
data: { email: `pni-stats-${n}@example.com`, authenticationMethod: "MAGIC_LINK" },
52+
})
53+
)
54+
);
55+
56+
await prisma.platformNotificationInteraction.createMany({
57+
data: [
58+
{ notificationId: withInteractions.id, userId: users[0].id, webappClickedAt: now },
59+
{ notificationId: withInteractions.id, userId: users[1].id, webappDismissedAt: now },
60+
{ notificationId: withInteractions.id, userId: users[2].id, cliDismissedAt: now },
61+
{
62+
notificationId: withInteractions.id,
63+
userId: users[3].id,
64+
webappDismissedAt: now,
65+
cliDismissedAt: now,
66+
},
67+
{ notificationId: withInteractions.id, userId: users[4].id },
68+
],
69+
});
70+
71+
const result = await getAdminNotificationsList({}, prisma);
72+
73+
const byId = new Map(result.notifications.map((n) => [n.id, n.stats]));
74+
75+
expect(byId.get(withInteractions.id)).toEqual({ seen: 5, clicked: 1, dismissed: 3 });
76+
expect(byId.get(withoutInteractions.id)).toEqual({ seen: 0, clicked: 0, dismissed: 0 });
77+
});
78+
});

apps/webapp/app/services/platformNotifications.server.ts

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { z } from "zod";
22
import { errAsync, fromPromise, type ResultAsync } from "neverthrow";
3-
import { prisma } from "~/db.server";
3+
import { Prisma, prisma, sqlDatabaseSchema, type PrismaClientOrTransaction } from "~/db.server";
44
import {
55
type PlatformNotificationScope,
66
type PlatformNotificationSurface,
@@ -32,42 +32,61 @@ export type PlatformNotificationWithPayload = {
3232

3333
// --- Read: admin list with interaction stats ---
3434

35-
export async function getAdminNotificationsList({
36-
page = 1,
37-
pageSize = 20,
38-
hideInactive = false,
39-
}: {
40-
page?: number;
41-
pageSize?: number;
42-
hideInactive?: boolean;
43-
}) {
35+
export async function getAdminNotificationsList(
36+
{
37+
page = 1,
38+
pageSize = 20,
39+
hideInactive = false,
40+
}: {
41+
page?: number;
42+
pageSize?: number;
43+
hideInactive?: boolean;
44+
},
45+
prismaClient: PrismaClientOrTransaction = prisma
46+
) {
4447
const where = hideInactive ? { archivedAt: null, endsAt: { gt: new Date() } } : {};
4548

4649
const [notifications, total] = await Promise.all([
47-
prisma.platformNotification.findMany({
50+
prismaClient.platformNotification.findMany({
4851
where,
4952
orderBy: [{ createdAt: "desc" }],
5053
skip: (page - 1) * pageSize,
5154
take: pageSize,
52-
include: {
53-
_count: {
54-
select: { interactions: true },
55-
},
56-
interactions: {
57-
select: {
58-
webappDismissedAt: true,
59-
webappClickedAt: true,
60-
cliDismissedAt: true,
61-
},
62-
},
63-
},
6455
}),
65-
prisma.platformNotification.count({ where }),
56+
prismaClient.platformNotification.count({ where }),
6657
]);
6758

59+
const notificationIds = notifications.map((n) => n.id);
60+
61+
const interactionStats =
62+
notificationIds.length > 0
63+
? await prismaClient.$queryRaw<
64+
{
65+
notificationId: string;
66+
seen: bigint;
67+
clicked: bigint;
68+
dismissed: bigint;
69+
}[]
70+
>`
71+
SELECT
72+
"notificationId",
73+
COUNT(*) AS seen,
74+
COUNT(*) FILTER (WHERE "webappClickedAt" IS NOT NULL) AS clicked,
75+
COUNT(*) FILTER (
76+
WHERE "webappDismissedAt" IS NOT NULL OR "cliDismissedAt" IS NOT NULL
77+
) AS dismissed
78+
FROM ${sqlDatabaseSchema}."PlatformNotificationInteraction"
79+
WHERE "notificationId" IN (${Prisma.join(notificationIds)})
80+
GROUP BY "notificationId"
81+
`
82+
: [];
83+
84+
const statsById = new Map(interactionStats.map((row) => [row.notificationId, row]));
85+
6886
return {
6987
notifications: notifications.map((n) => {
7088
const parsed = PayloadV1Schema.safeParse(n.payload);
89+
const stats = statsById.get(n.id);
7190
return {
7291
id: n.id,
7392
friendlyId: n.friendlyId,
@@ -99,11 +118,9 @@ export async function getAdminNotificationsList({
99118
cliMaxDaysAfterFirstSeen: n.cliMaxDaysAfterFirstSeen,
100119
cliShowEvery: n.cliShowEvery,
101120
stats: {
102-
seen: n._count.interactions,
103-
clicked: n.interactions.filter((i) => i.webappClickedAt !== null).length,
104-
dismissed: n.interactions.filter(
105-
(i) => i.webappDismissedAt !== null || i.cliDismissedAt !== null
106-
).length,
121+
seen: stats ? Number(stats.seen) : 0,
122+
clicked: stats ? Number(stats.clicked) : 0,
123+
dismissed: stats ? Number(stats.dismissed) : 0,
107124
},
108125
};
109126
}),

apps/webapp/vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export default defineConfig({
1717
"app/v3/services/bulk/**/*.test.ts",
1818
"app/runEngine/concerns/**/*.test.ts",
1919
"app/runEngine/services/**/*.test.ts",
20+
"app/services/**/*.test.ts",
2021
"app/utils/**/*.test.ts",
2122
"app/components/code/**/*.test.ts",
2223
"app/components/dashboard-agent/**/*.test.ts",

0 commit comments

Comments
 (0)