Skip to content

Commit fe6593a

Browse files
committed
feat(webapp): add dashboard platform notifications service & UI
Introduce a new server-side service to read and record platform notifications targeted at the webapp. - Add payload schema (v1) using zod and typed PayloadV1. - Define PlatformNotificationWithPayload type and scope priority map. - Implement getActivePlatformNotifications to: - query active WEBAPP notifications with scope/org/project/user filters, - include user interactions and validate payloads, - filter dismissed items, compute unreadCount, and return sorted results. - Add helper functions: - findInteraction to match global/org interactions, - compareNotifications to sort by scope, priority, then recency. - Implement upsertInteraction to create or update platform notification interactions, handling GLOBAL-scoped interactions per organization. These changes centralize notification read/write logic, enforce payload validation, and provide deterministic ordering and unread counts for the webapp UI.
1 parent 73db7f8 commit fe6593a

10 files changed

+1171
-8
lines changed

apps/webapp/app/components/navigation/NotificationPanel.tsx

Lines changed: 508 additions & 0 deletions
Large diffs are not rendered by default.

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { type UserWithDashboardPreferences } from "~/models/user.server";
4949
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
5050
import { type FeedbackType } from "~/routes/resources.feedback";
5151
import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents";
52+
import { NotificationPanel } from "./NotificationPanel";
5253
import { cn } from "~/utils/cn";
5354
import {
5455
accountPath,
@@ -638,6 +639,12 @@ export function SideMenu({
638639
hasIncident={incidentStatus.hasIncident}
639640
isManagedCloud={incidentStatus.isManagedCloud}
640641
/>
642+
<NotificationPanel
643+
isCollapsed={isCollapsed}
644+
hasIncident={incidentStatus.hasIncident}
645+
organizationId={organization.id}
646+
projectId={project.id}
647+
/>
641648
<motion.div
642649
layout
643650
transition={{ duration: 0.2, ease: "easeInOut" }}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { type ActionFunctionArgs, json } from "@remix-run/server-runtime";
2+
import { err, ok, type Result } from "neverthrow";
3+
import { prisma } from "~/db.server";
4+
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
5+
import {
6+
createPlatformNotification,
7+
type CreatePlatformNotificationInput,
8+
} from "~/services/platformNotifications.server";
9+
10+
type AdminUser = { id: string; admin: boolean };
11+
type AuthError = { status: number; message: string };
12+
13+
async function authenticateAdmin(request: Request): Promise<Result<AdminUser, AuthError>> {
14+
const authResult = await authenticateApiRequestWithPersonalAccessToken(request);
15+
if (!authResult) {
16+
return err({ status: 401, message: "Invalid or Missing API key" });
17+
}
18+
19+
const user = await prisma.user.findUnique({
20+
where: { id: authResult.userId },
21+
select: { id: true, admin: true },
22+
});
23+
24+
if (!user) {
25+
return err({ status: 401, message: "Invalid or Missing API key" });
26+
}
27+
28+
if (!user.admin) {
29+
return err({ status: 403, message: "You must be an admin to perform this action" });
30+
}
31+
32+
return ok(user);
33+
}
34+
35+
export async function action({ request }: ActionFunctionArgs) {
36+
if (request.method !== "POST") {
37+
return json({ error: "Method not allowed" }, { status: 405 });
38+
}
39+
40+
const authResult = await authenticateAdmin(request);
41+
if (authResult.isErr()) {
42+
const { status, message } = authResult.error;
43+
return json({ error: message }, { status });
44+
}
45+
46+
const body = await request.json();
47+
const result = await createPlatformNotification(body as CreatePlatformNotificationInput);
48+
49+
if (result.isErr()) {
50+
const error = result.error;
51+
52+
if (error.type === "validation") {
53+
return json({ error: "Validation failed", details: error.issues }, { status: 400 });
54+
}
55+
56+
return json({ error: error.message }, { status: 500 });
57+
}
58+
59+
return json(result.value, { status: 201 });
60+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
2+
import { json } from "@remix-run/server-runtime";
3+
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
4+
import { getNextCliNotification } from "~/services/platformNotifications.server";
5+
6+
export async function loader({ request }: LoaderFunctionArgs) {
7+
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
8+
9+
if (!authenticationResult) {
10+
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
11+
}
12+
13+
const url = new URL(request.url);
14+
const projectRef = url.searchParams.get("projectRef") ?? undefined;
15+
16+
const notification = await getNextCliNotification({
17+
userId: authenticationResult.userId,
18+
projectRef,
19+
});
20+
21+
return json({ notification });
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { json } from "@remix-run/node";
2+
import type { ActionFunctionArgs } from "@remix-run/node";
3+
import { requireUserId } from "~/services/session.server";
4+
import { dismissNotification } from "~/services/platformNotifications.server";
5+
6+
export async function action({ request, params }: ActionFunctionArgs) {
7+
const userId = await requireUserId(request);
8+
const notificationId = params.id;
9+
10+
if (!notificationId) {
11+
return json({ success: false }, { status: 400 });
12+
}
13+
14+
await dismissNotification({ notificationId, userId });
15+
16+
return json({ success: true });
17+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { json } from "@remix-run/node";
2+
import type { ActionFunctionArgs } from "@remix-run/node";
3+
import { requireUserId } from "~/services/session.server";
4+
import { recordNotificationSeen } from "~/services/platformNotifications.server";
5+
6+
export async function action({ request, params }: ActionFunctionArgs) {
7+
const userId = await requireUserId(request);
8+
const notificationId = params.id;
9+
10+
if (!notificationId) {
11+
return json({ success: false }, { status: 400 });
12+
}
13+
14+
await recordNotificationSeen({ notificationId, userId });
15+
16+
return json({ success: true });
17+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { json } from "@remix-run/node";
2+
import type { LoaderFunctionArgs } from "@remix-run/node";
3+
import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react";
4+
import { useEffect, useRef } from "react";
5+
import { requireUserId } from "~/services/session.server";
6+
import {
7+
getActivePlatformNotifications,
8+
type PlatformNotificationWithPayload,
9+
} from "~/services/platformNotifications.server";
10+
11+
export const shouldRevalidate: ShouldRevalidateFunction = () => false;
12+
13+
export type PlatformNotificationsLoaderData = {
14+
notifications: PlatformNotificationWithPayload[];
15+
unreadCount: number;
16+
};
17+
18+
export async function loader({ request }: LoaderFunctionArgs) {
19+
const userId = await requireUserId(request);
20+
const url = new URL(request.url);
21+
const organizationId = url.searchParams.get("organizationId");
22+
const projectId = url.searchParams.get("projectId") ?? undefined;
23+
24+
if (!organizationId) {
25+
return json<PlatformNotificationsLoaderData>({ notifications: [], unreadCount: 0 });
26+
}
27+
28+
const result = await getActivePlatformNotifications({ userId, organizationId, projectId });
29+
30+
return json<PlatformNotificationsLoaderData>(result);
31+
}
32+
33+
const POLL_INTERVAL_MS = 60_000; // 1 minute
34+
35+
export function usePlatformNotifications(organizationId: string, projectId: string) {
36+
const fetcher = useFetcher<typeof loader>();
37+
const hasInitiallyFetched = useRef(false);
38+
39+
useEffect(() => {
40+
const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`;
41+
42+
if (!hasInitiallyFetched.current && fetcher.state === "idle") {
43+
hasInitiallyFetched.current = true;
44+
fetcher.load(url);
45+
}
46+
47+
const interval = setInterval(() => {
48+
if (fetcher.state === "idle") {
49+
fetcher.load(url);
50+
}
51+
}, POLL_INTERVAL_MS);
52+
53+
return () => clearInterval(interval);
54+
}, [organizationId, projectId]);
55+
56+
return {
57+
notifications: fetcher.data?.notifications ?? [],
58+
unreadCount: fetcher.data?.unreadCount ?? 0,
59+
isLoading: fetcher.state !== "idle",
60+
};
61+
}

0 commit comments

Comments
 (0)