From b175d2cc2f7715325abd9add7f132990c41efb26 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 23 Feb 2026 14:05:09 +0100 Subject: [PATCH 1/5] feat(db): add platform notification models and relations Add PlatformNotification and PlatformNotificationInteraction Prisma models, including enums PlatformNotificationSurface and PlatformNotificationScope, indexes, timestamps, and fields for scoping (user, project, organization), lifecycle (startsAt, endsAt, archivedAt) and delivery behavior (CLI-specific fields, priority). Wire new relations into User, Organization, Project, OrgMember and Workspace models so notifications and interactions can be queried from those entities. Add SQL migration to create the new enums and adjust schema constraints and indexes required for the migration. The change enables admin-created, scoped platform notifications with per-user interaction tracking for both webapp and CLI surfaces. feat(notifications): Add a way to notify users on platform feat(notifications): Enforce notifications expired date, add CLI improvements CLI colors CLI show every n-th notification feat(webapp): Platform notifications ui/ux improvements feat(CLI): CLI notifications v1 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. --- .../navigation/HelpAndFeedbackPopover.tsx | 126 +-- .../navigation/NotificationPanel.tsx | 296 +++++++ .../app/components/navigation/SideMenu.tsx | 7 + .../admin.api.v1.platform-notifications.ts | 60 ++ .../webapp/app/routes/admin.notifications.tsx | 751 ++++++++++++++++++ apps/webapp/app/routes/admin.tsx | 4 + .../routes/api.v1.platform-notifications.ts | 22 + .../routes/resources.platform-changelogs.tsx | 49 ++ ...ces.platform-notifications.$id.clicked.tsx | 17 + ...ces.platform-notifications.$id.dismiss.tsx | 17 + ...ources.platform-notifications.$id.seen.tsx | 17 + .../resources.platform-notifications.tsx | 61 ++ .../services/platformNotifications.server.ts | 602 ++++++++++++++ apps/webapp/package.json | 1 + .../migration.sql | 69 ++ .../database/prisma/schema.prisma | 106 +++ packages/cli-v3/src/apiClient.ts | 46 ++ packages/cli-v3/src/commands/dev.ts | 21 + packages/cli-v3/src/commands/login.ts | 11 + .../cli-v3/src/utilities/colorMarkup.test.ts | 92 +++ packages/cli-v3/src/utilities/colorMarkup.ts | 139 ++++ .../src/utilities/discoveryCheck.test.ts | 212 +++++ .../cli-v3/src/utilities/discoveryCheck.ts | 155 ++++ .../src/utilities/platformNotifications.ts | 120 +++ pnpm-lock.yaml | 3 + 25 files changed, 2915 insertions(+), 89 deletions(-) create mode 100644 apps/webapp/app/components/navigation/NotificationPanel.tsx create mode 100644 apps/webapp/app/routes/admin.api.v1.platform-notifications.ts create mode 100644 apps/webapp/app/routes/admin.notifications.tsx create mode 100644 apps/webapp/app/routes/api.v1.platform-notifications.ts create mode 100644 apps/webapp/app/routes/resources.platform-changelogs.tsx create mode 100644 apps/webapp/app/routes/resources.platform-notifications.$id.clicked.tsx create mode 100644 apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx create mode 100644 apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx create mode 100644 apps/webapp/app/routes/resources.platform-notifications.tsx create mode 100644 apps/webapp/app/services/platformNotifications.server.ts create mode 100644 internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql create mode 100644 packages/cli-v3/src/utilities/colorMarkup.test.ts create mode 100644 packages/cli-v3/src/utilities/colorMarkup.ts create mode 100644 packages/cli-v3/src/utilities/discoveryCheck.test.ts create mode 100644 packages/cli-v3/src/utilities/discoveryCheck.ts create mode 100644 packages/cli-v3/src/utilities/platformNotifications.ts diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 1626ec9f910..39a3d386783 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -11,6 +11,7 @@ import { import { cn } from "~/utils/cn"; import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons"; import { Fragment, useState } from "react"; +import { useRecentChangelogs } from "~/routes/resources.platform-changelogs"; import { motion } from "framer-motion"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; @@ -38,6 +39,7 @@ export function HelpAndFeedback({ }) { const [isHelpMenuOpen, setHelpMenuOpen] = useState(false); const currentPlan = useCurrentPlan(); + const { changelogs } = useRecentChangelogs(); useShortcutKeys({ shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false }, @@ -140,96 +142,7 @@ export function HelpAndFeedback({ data-action="suggest-a-feature" target="_blank" /> - - -
- Need help? - {currentPlan?.v3Subscription?.plan?.limits.support === "slack" && ( -
- - - - - - Join our Slack -
-
- - - As a subscriber, you have access to a dedicated Slack channel for 1-to-1 - support with the Trigger.dev team. - -
-
-
- - - - Send us an email to this address from your Trigger.dev account email - address: - - - - - - - As soon as we can, we'll setup a Slack Connect channel and say hello! - - -
-
-
-
-
- )} - -
+
+ What's new + {changelogs.map((entry) => ( + + ))} + +
); } + +function GrayDotIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx new file mode 100644 index 00000000000..973b4fb151c --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -0,0 +1,296 @@ +import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useFetcher } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { Header3 } from "~/components/primitives/Headers"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { usePlatformNotifications } from "~/routes/resources.platform-notifications"; +import { cn } from "~/utils/cn"; + +type Notification = { + id: string; + friendlyId: string; + scope: string; + priority: number; + payload: { + version: string; + data: { + title: string; + description: string; + image?: string; + actionLabel?: string; + actionUrl?: string; + dismissOnAction?: boolean; + }; + }; + isRead: boolean; +}; + +export function NotificationPanel({ + isCollapsed, + hasIncident, + organizationId, + projectId, +}: { + isCollapsed: boolean; + hasIncident: boolean; + organizationId: string; + projectId: string; +}) { + const { notifications } = usePlatformNotifications(organizationId, projectId) as { + notifications: Notification[]; + }; + const [dismissedIds, setDismissedIds] = useState>(new Set()); + const dismissFetcher = useFetcher(); + const seenIdsRef = useRef>(new Set()); + const seenFetcher = useFetcher(); + const clickedIdsRef = useRef>(new Set()); + const clickFetcher = useFetcher(); + + const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); + const notification = visibleNotifications[0] ?? null; + + const handleDismiss = useCallback((id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); + + dismissFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/dismiss`, + } + ); + }, []); + + const fireClickBeacon = useCallback((id: string) => { + if (clickedIdsRef.current.has(id)) return; + clickedIdsRef.current.add(id); + + clickFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/clicked`, + } + ); + }, []); + + // Fire seen beacon + const fireSeenBeacon = useCallback((n: Notification) => { + if (seenIdsRef.current.has(n.id)) return; + seenIdsRef.current.add(n.id); + + seenFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${n.id}/seen`, + } + ); + }, []); + + // Beacon current notification on mount + useEffect(() => { + if (notification && !hasIncident) { + fireSeenBeacon(notification); + } + }, [notification?.id, hasIncident]); + + if (!notification) { + return null; + } + + const card = ( + fireClickBeacon(notification.id)} + /> + ); + + return ( + +
+ {/* Expanded sidebar: show card directly */} + + {card} + + + {/* Collapsed sidebar: show bell icon that opens popover */} + + +
+ + + {visibleNotifications.length} + +
+ + } + content="Notifications" + side="right" + sideOffset={8} + disableHoverableContent + asChild + /> +
+
+ + {card} + +
+ ); +} + +function NotificationCard({ + notification, + onDismiss, + onLinkClick, +}: { + notification: Notification; + onDismiss: (id: string) => void; + onLinkClick: () => void; +}) { + const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const descriptionRef = useRef(null); + + useLayoutEffect(() => { + const el = descriptionRef.current; + if (el) { + setIsOverflowing(el.scrollHeight > el.clientHeight); + } + }, [description]); + + const handleDismiss = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDismiss(notification.id); + }; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsExpanded((v) => !v); + }; + + const handleCardClick = () => { + onLinkClick(); + if (dismissOnAction) { + onDismiss(notification.id); + } + }; + + const Wrapper = actionUrl ? "a" : "div"; + const wrapperProps = actionUrl + ? { + href: actionUrl, + target: "_blank" as const, + rel: "noopener noreferrer" as const, + onClick: handleCardClick, + } + : {}; + + return ( + + {/* Header: title + dismiss */} +
+ + {title} + + +
+ + {/* Body: description + chevron */} +
+
+
+
+ {description} +
+ {(isOverflowing || isExpanded) && ( + + )} +
+ {actionUrl && ( +
+ +
+ )} +
+ + {image && ( + + )} +
+
+ ); +} + +function getMarkdownComponents(onLinkClick: () => void) { + return { + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + + {children} + + ), + strong: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + em: ({ children }: { children?: React.ReactNode }) => {children}, + code: ({ children }: { children?: React.ReactNode }) => ( + {children} + ), + }; +} diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 89bf139c981..97c280a201c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -50,6 +50,7 @@ import { type UserWithDashboardPreferences } from "~/models/user.server"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { type FeedbackType } from "~/routes/resources.feedback"; import { IncidentStatusPanel, useIncidentStatus } from "~/routes/resources.incidents"; +import { NotificationPanel } from "./NotificationPanel"; import { cn } from "~/utils/cn"; import { accountPath, @@ -652,6 +653,12 @@ export function SideMenu({ hasIncident={incidentStatus.hasIncident} isManagedCloud={incidentStatus.isManagedCloud} /> + > { + const authResult = await authenticateApiRequestWithPersonalAccessToken(request); + if (!authResult) { + return err({ status: 401, message: "Invalid or Missing API key" }); + } + + const user = await prisma.user.findUnique({ + where: { id: authResult.userId }, + select: { id: true, admin: true }, + }); + + if (!user) { + return err({ status: 401, message: "Invalid or Missing API key" }); + } + + if (!user.admin) { + return err({ status: 403, message: "You must be an admin to perform this action" }); + } + + return ok(user); +} + +export async function action({ request }: ActionFunctionArgs) { + if (request.method !== "POST") { + return json({ error: "Method not allowed" }, { status: 405 }); + } + + const authResult = await authenticateAdmin(request); + if (authResult.isErr()) { + const { status, message } = authResult.error; + return json({ error: message }, { status }); + } + + const body = await request.json(); + const result = await createPlatformNotification(body as CreatePlatformNotificationInput); + + if (result.isErr()) { + const error = result.error; + + if (error.type === "validation") { + return json({ error: "Validation failed", details: error.issues }, { status: 400 }); + } + + return json({ error: error.message }, { status: 500 }); + } + + return json(result.value, { status: 201 }); +} diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx new file mode 100644 index 00000000000..cc2177648f3 --- /dev/null +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -0,0 +1,751 @@ +import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { Form, useFetcher } from "@remix-run/react"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { redirect } from "@remix-run/server-runtime"; +import { useRef, useState, useLayoutEffect } from "react"; +import ReactMarkdown from "react-markdown"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { Button } from "~/components/primitives/Buttons"; +import { Header3 } from "~/components/primitives/Headers"; +import { Input } from "~/components/primitives/Input"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { prisma } from "~/db.server"; +import { requireUserId } from "~/services/session.server"; +import { + createPlatformNotification, + getAdminNotificationsList, +} from "~/services/platformNotifications.server"; +import { createSearchParams } from "~/utils/searchParams"; +import { cn } from "~/utils/cn"; + +const PAGE_SIZE = 20; + +const WEBAPP_TYPES = ["card", "changelog"] as const; +const CLI_TYPES = ["info", "warn", "error", "success"] as const; + +const SearchParams = z.object({ + page: z.coerce.number().optional(), +}); + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user?.admin) throw redirect("/"); + + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) throw new Error(searchParams.error); + const { page: rawPage } = searchParams.params.getAll(); + const page = rawPage ?? 1; + + const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE }); + + return typedjson({ ...data, userId }); +}; + +export async function action({ request }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user?.admin) throw redirect("/"); + + const formData = await request.formData(); + const _action = formData.get("_action"); + + if (_action === "create" || _action === "create-preview") { + const surface = formData.get("surface") as string; + const payloadType = formData.get("payloadType") as string; + const adminLabel = formData.get("adminLabel") as string; + const title = formData.get("title") as string; + const description = formData.get("description") as string; + const actionUrl = (formData.get("actionUrl") as string) || undefined; + const image = (formData.get("image") as string) || undefined; + const dismissOnAction = formData.get("dismissOnAction") === "true"; + const startsAt = formData.get("startsAt") as string; + const endsAt = formData.get("endsAt") as string; + const priority = Number(formData.get("priority") || "0"); + const cliMaxShowCount = formData.get("cliMaxShowCount") + ? Number(formData.get("cliMaxShowCount")) + : undefined; + const cliMaxDaysAfterFirstSeen = formData.get("cliMaxDaysAfterFirstSeen") + ? Number(formData.get("cliMaxDaysAfterFirstSeen")) + : undefined; + const cliShowEvery = formData.get("cliShowEvery") + ? Number(formData.get("cliShowEvery")) + : undefined; + + const discoveryFilePatterns = (formData.get("discoveryFilePatterns") as string) || ""; + const discoveryContentPattern = + (formData.get("discoveryContentPattern") as string) || undefined; + const discoveryMatchBehavior = (formData.get("discoveryMatchBehavior") as string) || ""; + + if (!adminLabel || !title || !description || !endsAt || !surface || !payloadType) { + return typedjson({ error: "Missing required fields" }, { status: 400 }); + } + + const discovery = + discoveryFilePatterns && discoveryMatchBehavior + ? { + filePatterns: discoveryFilePatterns + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ...(discoveryContentPattern ? { contentPattern: discoveryContentPattern } : {}), + matchBehavior: discoveryMatchBehavior as "show-if-found" | "show-if-not-found", + } + : undefined; + + const isPreview = _action === "create-preview"; + + const result = await createPlatformNotification({ + title: isPreview ? `[Preview] ${adminLabel}` : adminLabel, + payload: { + version: "1" as const, + data: { + type: payloadType as "info" | "warn" | "error" | "success" | "card" | "changelog", + title, + description, + ...(actionUrl ? { actionUrl } : {}), + ...(image ? { image } : {}), + ...(dismissOnAction ? { dismissOnAction: true } : {}), + ...(discovery ? { discovery } : {}), + }, + }, + surface: surface as "CLI" | "WEBAPP", + scope: isPreview ? "USER" : "GLOBAL", + ...(isPreview ? { userId } : {}), + startsAt: isPreview + ? new Date().toISOString() + : startsAt + ? new Date(startsAt + "Z").toISOString() + : new Date().toISOString(), + endsAt: isPreview + ? new Date(Date.now() + 60 * 60 * 1000).toISOString() + : new Date(endsAt + "Z").toISOString(), + priority, + ...(surface === "CLI" + ? isPreview + ? { cliMaxShowCount: 1 } + : { + cliMaxShowCount, + cliMaxDaysAfterFirstSeen, + cliShowEvery, + } + : {}), + }); + + if (result.isErr()) { + const err = result.error; + if (err.type === "validation") { + return typedjson( + { error: err.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ") }, + { status: 400 } + ); + } + return typedjson({ error: err.message }, { status: 500 }); + } + + if (isPreview) { + return typedjson({ success: true, previewId: result.value.id }); + } + return typedjson({ success: true, id: result.value.id }); + } + + if (_action === "archive") { + const notificationId = formData.get("notificationId") as string; + if (!notificationId) { + return typedjson({ error: "Missing notificationId" }, { status: 400 }); + } + + await prisma.platformNotification.update({ + where: { id: notificationId }, + data: { archivedAt: new Date() }, + }); + + return typedjson({ success: true }); + } + + return typedjson({ error: "Unknown action" }, { status: 400 }); +} + +export default function AdminNotificationsRoute() { + const { notifications, total, page, pageCount } = useTypedLoaderData(); + const [showCreate, setShowCreate] = useState(false); + const createFetcher = useFetcher<{ + success?: boolean; + error?: string; + id?: string; + previewId?: string; + }>(); + const archiveFetcher = useFetcher<{ success?: boolean; error?: string }>(); + const [surface, setSurface] = useState<"CLI" | "WEBAPP">("WEBAPP"); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [actionUrl, setActionUrl] = useState(""); + const [image, setImage] = useState(""); + const [payloadType, setPayloadType] = useState("card"); + + const typeOptions = surface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES; + + // Reset type when surface changes if current type isn't valid for new surface + const handleSurfaceChange = (newSurface: "CLI" | "WEBAPP") => { + setSurface(newSurface); + const newTypes = newSurface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES; + if (!newTypes.includes(payloadType as any)) { + setPayloadType(newTypes[0]); + } + }; + + return ( +
+
+
+ + {total} notifications (page {page} of {pageCount || 1}) + + +
+ + {showCreate && ( +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + setTitle(e.target.value)} + /> +
+ + {/* Description + live preview (webapp only) */} +
+ + {surface === "WEBAPP" ? ( +
+