diff --git a/.changeset/chilly-tips-explode.md b/.changeset/chilly-tips-explode.md new file mode 100644 index 00000000000..7a5235904a4 --- /dev/null +++ b/.changeset/chilly-tips-explode.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +Add platform notifications support to the CLI. The `trigger dev` and `trigger login` commands now fetch and display platform notifications (info, warn, error, success) from the server. Includes discovery-based filtering to conditionally show notifications based on project file patterns, color markup rendering for styled terminal output, and a non-blocking display flow with a spinner fallback for slow fetches. Use `--skip-platform-notifications` flag with `trigger dev` to disable the notification check. diff --git a/.server-changes/platform-notifications.md b/.server-changes/platform-notifications.md new file mode 100644 index 00000000000..54d52d77673 --- /dev/null +++ b/.server-changes/platform-notifications.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Add platform notifications to inform users about new features, changelogs, and platform events directly in the dashboard. 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..fdfbb2f8742 --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -0,0 +1,312 @@ +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 && ( + + )} +
+
+ ); +} + +/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */ +function sanitizeImageUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" || parsed.protocol === "http:") { + return parsed.href; + } + return ""; + } catch { + return ""; + } +} + +function getMarkdownComponents(onLinkClick: () => void) { + return { + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

+ ), + a: ({ href, children }: { href?: string; children?: React.ReactNode }) => ( + { + e.stopPropagation(); + onLinkClick(); + }} + > + {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 90f25fde788..d64fc96488c 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -53,6 +53,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, @@ -701,6 +702,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?.admin) { + return err({ + status: user ? 403 : 401, + message: user ? "You must be an admin to perform this action" : "Invalid or Missing API key", + }); + } + + 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 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return json({ error: "Invalid JSON body" }, { status: 400 }); + } + 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..d9b06816953 --- /dev/null +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -0,0 +1,1078 @@ +import { ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useFetcher, useSearchParams } 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 { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "~/components/primitives/Dialog"; +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(), + hideArchived: z.coerce.boolean().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, hideArchived } = searchParams.params.getAll(); + const page = rawPage ?? 1; + + const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideArchived: hideArchived ?? false }); + + 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") { + return handleCreateAction(formData, userId, _action === "create-preview"); + } + + if (_action === "archive") { + return handleArchiveAction(formData); + } + + return typedjson({ error: "Unknown action" }, { status: 400 }); +} + +async function handleCreateAction(formData: FormData, userId: string, isPreview: boolean) { + 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"); + + if (!adminLabel || !title || !description || !endsAt || !surface || !payloadType) { + return typedjson({ error: "Missing required fields" }, { status: 400 }); + } + + 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) || ""; + + 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 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 }); +} + +async function handleArchiveAction(formData: FormData) { + 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 }); +} + +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 [detailNotification, setDetailNotification] = useState<(typeof notifications)[number] | null>(null); + + 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]); + } + }; + + const [urlSearchParams, setUrlSearchParams] = useSearchParams(); + const hideArchived = urlSearchParams.get("hideArchived") === "true"; + + const toggleHideArchived = () => { + setUrlSearchParams((prev) => { + const next = new URLSearchParams(prev); + if (hideArchived) { + next.delete("hideArchived"); + } else { + next.set("hideArchived", "true"); + } + next.delete("page"); + return next; + }); + }; + + return ( +
+
+
+ +
+ + {showCreate && ( +
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {/* CLI live preview */} + {surface === "CLI" && (title || description) && ( +
+

+ CLI Preview +

+
+ {title && ( +

+ +

+ )} + {description && ( +

+ +

+ )} + {actionUrl && ( +

{actionUrl}

+ )} +
+
+ )} + +
+ + setTitle(e.target.value)} + /> +
+ + {/* Description + live preview (webapp only) */} +
+ + {surface === "WEBAPP" ? ( +
+