diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 1626ec9f91..39a3d38678 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 0000000000..fcce21a9ab --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -0,0 +1,274 @@ +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 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`, + } + ); + }, []); + + // 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 = ( + + ); + + 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, +}: { + notification: Notification; + onDismiss: (id: string) => 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 = () => { + 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 && ( + + )} +
+
+ ); +} + +const markdownComponents = { + 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 89bf139c98..97c280a201 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/api.v1.platform-notifications.ts b/apps/webapp/app/routes/api.v1.platform-notifications.ts new file mode 100644 index 0000000000..7299c40738 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.platform-notifications.ts @@ -0,0 +1,22 @@ +import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; +import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server"; +import { getNextCliNotification } from "~/services/platformNotifications.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); + + if (!authenticationResult) { + return json({ error: "Invalid or Missing Access Token" }, { status: 401 }); + } + + const url = new URL(request.url); + const projectRef = url.searchParams.get("projectRef") ?? undefined; + + const notification = await getNextCliNotification({ + userId: authenticationResult.userId, + projectRef, + }); + + return json({ notification }); +} diff --git a/apps/webapp/app/routes/resources.platform-changelogs.tsx b/apps/webapp/app/routes/resources.platform-changelogs.tsx new file mode 100644 index 0000000000..da71df2e27 --- /dev/null +++ b/apps/webapp/app/routes/resources.platform-changelogs.tsx @@ -0,0 +1,49 @@ +import { json } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react"; +import { useEffect, useRef } from "react"; +import { requireUserId } from "~/services/session.server"; +import { getRecentChangelogs } from "~/services/platformNotifications.server"; + +export const shouldRevalidate: ShouldRevalidateFunction = () => false; + +export type PlatformChangelogsLoaderData = { + changelogs: Array<{ id: string; title: string; actionUrl?: string }>; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + await requireUserId(request); + + const changelogs = await getRecentChangelogs(); + + return json({ changelogs }); +} + +const POLL_INTERVAL_MS = 60_000; + +export function useRecentChangelogs() { + const fetcher = useFetcher(); + const hasInitiallyFetched = useRef(false); + + useEffect(() => { + const url = "/resources/platform-changelogs"; + + if (!hasInitiallyFetched.current && fetcher.state === "idle") { + hasInitiallyFetched.current = true; + fetcher.load(url); + } + + const interval = setInterval(() => { + if (fetcher.state === "idle") { + fetcher.load(url); + } + }, POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, []); + + return { + changelogs: fetcher.data?.changelogs ?? [], + isLoading: fetcher.state !== "idle", + }; +} diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx new file mode 100644 index 0000000000..e6a475692d --- /dev/null +++ b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx @@ -0,0 +1,17 @@ +import { json } from "@remix-run/node"; +import type { ActionFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; +import { dismissNotification } from "~/services/platformNotifications.server"; + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const notificationId = params.id; + + if (!notificationId) { + return json({ success: false }, { status: 400 }); + } + + await dismissNotification({ notificationId, userId }); + + return json({ success: true }); +} diff --git a/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx new file mode 100644 index 0000000000..652d4ee99c --- /dev/null +++ b/apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx @@ -0,0 +1,17 @@ +import { json } from "@remix-run/node"; +import type { ActionFunctionArgs } from "@remix-run/node"; +import { requireUserId } from "~/services/session.server"; +import { recordNotificationSeen } from "~/services/platformNotifications.server"; + +export async function action({ request, params }: ActionFunctionArgs) { + const userId = await requireUserId(request); + const notificationId = params.id; + + if (!notificationId) { + return json({ success: false }, { status: 400 }); + } + + await recordNotificationSeen({ notificationId, userId }); + + return json({ success: true }); +} diff --git a/apps/webapp/app/routes/resources.platform-notifications.tsx b/apps/webapp/app/routes/resources.platform-notifications.tsx new file mode 100644 index 0000000000..0a97a26d08 --- /dev/null +++ b/apps/webapp/app/routes/resources.platform-notifications.tsx @@ -0,0 +1,61 @@ +import { json } from "@remix-run/node"; +import type { LoaderFunctionArgs } from "@remix-run/node"; +import { useFetcher, type ShouldRevalidateFunction } from "@remix-run/react"; +import { useEffect, useRef } from "react"; +import { requireUserId } from "~/services/session.server"; +import { + getActivePlatformNotifications, + type PlatformNotificationWithPayload, +} from "~/services/platformNotifications.server"; + +export const shouldRevalidate: ShouldRevalidateFunction = () => false; + +export type PlatformNotificationsLoaderData = { + notifications: PlatformNotificationWithPayload[]; + unreadCount: number; +}; + +export async function loader({ request }: LoaderFunctionArgs) { + const userId = await requireUserId(request); + const url = new URL(request.url); + const organizationId = url.searchParams.get("organizationId"); + const projectId = url.searchParams.get("projectId") ?? undefined; + + if (!organizationId) { + return json({ notifications: [], unreadCount: 0 }); + } + + const result = await getActivePlatformNotifications({ userId, organizationId, projectId }); + + return json(result); +} + +const POLL_INTERVAL_MS = 60000; // 1 minute + +export function usePlatformNotifications(organizationId: string, projectId: string) { + const fetcher = useFetcher(); + const hasInitiallyFetched = useRef(false); + + useEffect(() => { + const url = `/resources/platform-notifications?organizationId=${encodeURIComponent(organizationId)}&projectId=${encodeURIComponent(projectId)}`; + + if (!hasInitiallyFetched.current && fetcher.state === "idle") { + hasInitiallyFetched.current = true; + fetcher.load(url); + } + + const interval = setInterval(() => { + if (fetcher.state === "idle") { + fetcher.load(url); + } + }, POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, [organizationId, projectId]); + + return { + notifications: fetcher.data?.notifications ?? [], + unreadCount: fetcher.data?.unreadCount ?? 0, + isLoading: fetcher.state !== "idle", + }; +} diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts new file mode 100644 index 0000000000..3a732664bd --- /dev/null +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -0,0 +1,513 @@ +import { z } from "zod"; +import { errAsync, fromPromise, type ResultAsync } from "neverthrow"; +import { prisma } from "~/db.server"; +import { type PlatformNotificationScope, type PlatformNotificationSurface } from "@trigger.dev/database"; + +// --- Payload schema (spec v1) --- + +const DiscoverySchema = z.object({ + filePatterns: z.array(z.string().min(1)).min(1), + contentPattern: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + try { + new RegExp(val); + return true; + } catch { + return false; + } + }, + { message: "contentPattern must be a valid regular expression" } + ), + matchBehavior: z.enum(["show-if-found", "show-if-not-found"]), +}); + +const CardDataV1Schema = z.object({ + type: z.enum(["card", "info", "warn", "error", "success", "changelog"]), + title: z.string(), + description: z.string(), + image: z.string().url().optional(), + actionLabel: z.string().optional(), + actionUrl: z.string().url().optional(), + dismissOnAction: z.boolean().optional(), + discovery: DiscoverySchema.optional(), +}); + +const PayloadV1Schema = z.object({ + version: z.literal("1"), + data: CardDataV1Schema, +}); + +export type PayloadV1 = z.infer; + +export type PlatformNotificationWithPayload = { + id: string; + friendlyId: string; + scope: string; + priority: number; + payload: PayloadV1; + isRead: boolean; +}; + +// --- Read: active notifications for webapp --- + +export async function getActivePlatformNotifications({ + userId, + organizationId, + projectId, +}: { + userId: string; + organizationId: string; + projectId?: string; +}) { + const now = new Date(); + + const notifications = await prisma.platformNotification.findMany({ + where: { + surface: "WEBAPP", + archivedAt: null, + startsAt: { lte: now }, + OR: [{ endsAt: null }, { endsAt: { gt: now } }], + AND: [ + { + OR: [ + { scope: "GLOBAL" }, + { scope: "ORGANIZATION", organizationId }, + ...(projectId ? [{ scope: "PROJECT" as const, projectId }] : []), + { scope: "USER", userId }, + ], + }, + ], + }, + include: { + interactions: { + where: { userId }, + }, + }, + orderBy: [{ priority: "desc" }, { createdAt: "desc" }], + }); + + type InternalNotification = PlatformNotificationWithPayload & { createdAt: Date }; + const result: InternalNotification[] = []; + + for (const n of notifications) { + const interaction = n.interactions[0] ?? null; + + if (interaction?.webappDismissedAt) continue; + + const parsed = PayloadV1Schema.safeParse(n.payload); + if (!parsed.success) continue; + + result.push({ + id: n.id, + friendlyId: n.friendlyId, + scope: n.scope, + priority: n.priority, + createdAt: n.createdAt, + payload: parsed.data, + isRead: !!interaction, + }); + } + + result.sort(compareNotifications); + + const unreadCount = result.filter((n) => !n.isRead).length; + const notifications_out: PlatformNotificationWithPayload[] = result.map( + ({ createdAt: _, ...rest }) => rest + ); + + return { notifications: notifications_out, unreadCount }; +} + +function compareNotifications( + a: { priority: number; createdAt: Date }, + b: { priority: number; createdAt: Date } +) { + const priorityDiff = b.priority - a.priority; + if (priorityDiff !== 0) return priorityDiff; + + return b.createdAt.getTime() - a.createdAt.getTime(); +} + +// --- Write: upsert interaction --- + +async function upsertInteraction({ + notificationId, + userId, + onUpdate, + onCreate, +}: { + notificationId: string; + userId: string; + onUpdate: Record; + onCreate: Record; +}) { + const existing = await prisma.platformNotificationInteraction.findUnique({ + where: { notificationId_userId: { notificationId, userId } }, + }); + + if (existing) { + await prisma.platformNotificationInteraction.update({ + where: { id: existing.id }, + data: onUpdate, + }); + return; + } + + await prisma.platformNotificationInteraction.create({ + data: { + notificationId, + userId, + firstSeenAt: new Date(), + showCount: 1, + ...onCreate, + }, + }); +} + +export async function recordNotificationSeen({ + notificationId, + userId, +}: { + notificationId: string; + userId: string; +}) { + return upsertInteraction({ + notificationId, + userId, + onUpdate: { showCount: { increment: 1 } }, + onCreate: {}, + }); +} + +export async function dismissNotification({ + notificationId, + userId, +}: { + notificationId: string; + userId: string; +}) { + const now = new Date(); + return upsertInteraction({ + notificationId, + userId, + onUpdate: { webappDismissedAt: now }, + onCreate: { webappDismissedAt: now }, + }); +} + +// --- Read: recent changelogs (for Help & Feedback) --- + +export async function getRecentChangelogs({ limit = 2 }: { limit?: number } = {}) { + const now = new Date(); + + const notifications = await prisma.platformNotification.findMany({ + where: { + surface: "WEBAPP", + archivedAt: null, + startsAt: { lte: now }, + OR: [{ endsAt: null }, { endsAt: { gt: now } }], + payload: { path: ["data", "type"], equals: "changelog" }, + }, + orderBy: [{ createdAt: "desc" }], + take: limit, + }); + + return notifications + .map((n) => { + const parsed = PayloadV1Schema.safeParse(n.payload); + if (!parsed.success) return null; + return { id: n.id, title: parsed.data.data.title, actionUrl: parsed.data.data.actionUrl }; + }) + .filter(Boolean) as Array<{ id: string; title: string; actionUrl?: string }>; +} + +// --- CLI: next notification for CLI surface --- + +function isCliNotificationExpired( + interaction: { firstSeenAt: Date; showCount: number } | null, + notification: { cliMaxDaysAfterFirstSeen: number | null; cliMaxShowCount: number | null } +): boolean { + if (!interaction) return false; + + if ( + notification.cliMaxShowCount !== null && + interaction.showCount >= notification.cliMaxShowCount + ) { + return true; + } + + if (notification.cliMaxDaysAfterFirstSeen !== null) { + const daysSinceFirstSeen = + (Date.now() - interaction.firstSeenAt.getTime()) / (1000 * 60 * 60 * 24); + if (daysSinceFirstSeen > notification.cliMaxDaysAfterFirstSeen) { + return true; + } + } + + return false; +} + +export async function getNextCliNotification({ + userId, + projectRef, +}: { + userId: string; + projectRef?: string; +}): Promise<{ + id: string; + payload: PayloadV1; + showCount: number; + firstSeenAt: string; +} | null> { + const now = new Date(); + + // Resolve organizationId and projectId from projectRef if provided + let organizationId: string | undefined; + let projectId: string | undefined; + + if (projectRef) { + const project = await prisma.project.findFirst({ + where: { + externalRef: projectRef, + deletedAt: null, + organization: { + deletedAt: null, + members: { some: { userId } }, + }, + }, + select: { id: true, organizationId: true }, + }); + + if (project) { + projectId = project.id; + organizationId = project.organizationId; + } + } + + // If no projectRef or project not found, get org from membership + if (!organizationId) { + const membership = await prisma.orgMember.findFirst({ + where: { userId }, + select: { organizationId: true }, + }); + if (membership) { + organizationId = membership.organizationId; + } + } + + const scopeFilter: Array> = [ + { scope: "GLOBAL" }, + { scope: "USER", userId }, + ]; + + if (organizationId) { + scopeFilter.push({ scope: "ORGANIZATION", organizationId }); + } + + if (projectId) { + scopeFilter.push({ scope: "PROJECT", projectId }); + } + + const notifications = await prisma.platformNotification.findMany({ + where: { + surface: "CLI", + archivedAt: null, + startsAt: { lte: now }, + OR: [{ endsAt: null }, { endsAt: { gt: now } }], + AND: [{ OR: scopeFilter }], + }, + include: { + interactions: { + where: { userId }, + }, + }, + orderBy: [{ priority: "desc" }, { createdAt: "desc" }], + }); + + const sorted = [...notifications].sort(compareNotifications); + + for (const n of sorted) { + const interaction = n.interactions[0] ?? null; + + if (interaction?.cliDismissedAt) continue; + if (isCliNotificationExpired(interaction, n)) continue; + + const parsed = PayloadV1Schema.safeParse(n.payload); + if (!parsed.success) continue; + + // Upsert interaction: increment showCount or create + if (interaction) { + await prisma.platformNotificationInteraction.update({ + where: { id: interaction.id }, + data: { showCount: { increment: 1 } }, + }); + + return { + id: n.id, + payload: parsed.data, + showCount: interaction.showCount + 1, + firstSeenAt: interaction.firstSeenAt.toISOString(), + }; + } else { + const newInteraction = await prisma.platformNotificationInteraction.create({ + data: { + notificationId: n.id, + userId, + firstSeenAt: now, + showCount: 1, + }, + }); + + return { + id: n.id, + payload: parsed.data, + showCount: 1, + firstSeenAt: newInteraction.firstSeenAt.toISOString(), + }; + } + } + + return null; +} + +// --- Create: admin endpoint support --- + +const SCOPE_REQUIRED_FK: Record = { + USER: "userId", + ORGANIZATION: "organizationId", + PROJECT: "projectId", +}; + +const ALL_FK_FIELDS = ["userId", "organizationId", "projectId"] as const; +const CLI_ONLY_FIELDS = ["cliMaxDaysAfterFirstSeen", "cliMaxShowCount"] as const; + +export const CreatePlatformNotificationSchema = z + .object({ + title: z.string().min(1), + payload: PayloadV1Schema, + surface: z.enum(["WEBAPP", "CLI"]), + scope: z.enum(["USER", "PROJECT", "ORGANIZATION", "GLOBAL"]), + userId: z.string().optional(), + organizationId: z.string().optional(), + projectId: z.string().optional(), + startsAt: z + .string() + .datetime() + .transform((s) => new Date(s)) + .optional(), + endsAt: z + .string() + .datetime() + .transform((s) => new Date(s)) + .optional(), + priority: z.number().int().default(0), + cliMaxDaysAfterFirstSeen: z.number().int().positive().optional(), + cliMaxShowCount: z.number().int().positive().optional(), + }) + .superRefine((data, ctx) => { + validateScopeForeignKeys(data, ctx); + validateSurfaceFields(data, ctx); + validateStartsAt(data, ctx); + }); + +function validateScopeForeignKeys( + data: { scope: string; userId?: string; organizationId?: string; projectId?: string }, + ctx: z.RefinementCtx +) { + const requiredFk = SCOPE_REQUIRED_FK[data.scope]; + + if (requiredFk && !data[requiredFk]) { + ctx.addIssue({ + code: "custom", + message: `${requiredFk} is required when scope is ${data.scope}`, + path: [requiredFk], + }); + } + + const forbiddenFks = ALL_FK_FIELDS.filter((fk) => fk !== requiredFk); + for (const fk of forbiddenFks) { + if (data[fk]) { + ctx.addIssue({ + code: "custom", + message: `${fk} must not be set when scope is ${data.scope}`, + path: [fk], + }); + } + } +} + +function validateSurfaceFields( + data: { surface: string; cliMaxDaysAfterFirstSeen?: number; cliMaxShowCount?: number }, + ctx: z.RefinementCtx +) { + if (data.surface !== "WEBAPP") return; + + for (const field of CLI_ONLY_FIELDS) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: "custom", + message: `${field} is not allowed for WEBAPP surface`, + path: [field], + }); + } + } +} + +function validateStartsAt(data: { startsAt?: Date }, ctx: z.RefinementCtx) { + if (!data.startsAt) return; + + const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000); + if (data.startsAt < oneHourAgo) { + ctx.addIssue({ + code: "custom", + message: "startsAt must be within the last hour or in the future", + path: ["startsAt"], + }); + } +} + +export type CreatePlatformNotificationInput = z.input; + +type CreateError = + | { type: "validation"; issues: z.ZodIssue[] } + | { type: "db"; message: string }; + +export function createPlatformNotification( + input: CreatePlatformNotificationInput +): ResultAsync<{ id: string; friendlyId: string }, CreateError> { + const parseResult = CreatePlatformNotificationSchema.safeParse(input); + + if (!parseResult.success) { + return errAsync({ type: "validation", issues: parseResult.error.issues }); + } + + const data = parseResult.data; + + return fromPromise( + prisma.platformNotification.create({ + data: { + title: data.title, + payload: data.payload, + surface: data.surface as PlatformNotificationSurface, + scope: data.scope as PlatformNotificationScope, + userId: data.userId, + organizationId: data.organizationId, + projectId: data.projectId, + startsAt: data.startsAt ?? new Date(), + endsAt: data.endsAt, + priority: data.priority, + cliMaxDaysAfterFirstSeen: data.cliMaxDaysAfterFirstSeen, + cliMaxShowCount: data.cliMaxShowCount, + }, + select: { id: true, friendlyId: true }, + }), + (e): CreateError => ({ + type: "db", + message: e instanceof Error ? e.message : String(e), + }) + ); +} diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 139a0ce2d0..f09e0fe209 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -189,6 +189,7 @@ "react-dom": "^18.2.0", "react-grid-layout": "^2.2.2", "react-hotkeys-hook": "^4.4.1", + "react-markdown": "^10.1.0", "react-popper": "^2.3.0", "react-resizable": "^3.1.3", "react-resizable-panels": "^2.0.9", diff --git a/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql b/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql new file mode 100644 index 0000000000..0b29b6400d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql @@ -0,0 +1,67 @@ +-- CreateEnum +CREATE TYPE "public"."PlatformNotificationSurface" AS ENUM ('WEBAPP', 'CLI'); + +-- CreateEnum +CREATE TYPE "public"."PlatformNotificationScope" AS ENUM ('USER', 'PROJECT', 'ORGANIZATION', 'GLOBAL'); + +-- CreateTable +CREATE TABLE "public"."PlatformNotification" ( + "id" TEXT NOT NULL, + "friendlyId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "surface" "public"."PlatformNotificationSurface" NOT NULL, + "scope" "public"."PlatformNotificationScope" NOT NULL, + "userId" TEXT, + "organizationId" TEXT, + "projectId" TEXT, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3), + "cliMaxDaysAfterFirstSeen" INTEGER, + "cliMaxShowCount" INTEGER, + "priority" INTEGER NOT NULL DEFAULT 0, + "archivedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlatformNotification_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."PlatformNotificationInteraction" ( + "id" TEXT NOT NULL, + "notificationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "showCount" INTEGER NOT NULL DEFAULT 1, + "webappDismissedAt" TIMESTAMP(3), + "cliDismissedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlatformNotificationInteraction_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PlatformNotification_friendlyId_key" ON "public"."PlatformNotification"("friendlyId"); + +-- CreateIndex +CREATE INDEX "PlatformNotification_surface_scope_startsAt_idx" ON "public"."PlatformNotification"("surface", "scope", "startsAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PlatformNotificationInteraction_notificationId_userId_key" ON "public"."PlatformNotificationInteraction"("notificationId", "userId"); + +-- AddForeignKey +ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlatformNotification" ADD CONSTRAINT "PlatformNotification_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "public"."PlatformNotification"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlatformNotificationInteraction" ADD CONSTRAINT "PlatformNotificationInteraction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index f6986be42c..de17ecc280 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -65,6 +65,9 @@ model User { impersonationsReceived ImpersonationAuditLog[] @relation("ImpersonationTarget") customerQueries CustomerQuery[] metricsDashboards MetricsDashboard[] + + platformNotifications PlatformNotification[] + platformNotificationInteractions PlatformNotificationInteraction[] } model MfaBackupCode { @@ -228,6 +231,8 @@ model Organization { githubAppInstallations GithubAppInstallation[] customerQueries CustomerQuery[] metricsDashboards MetricsDashboard[] + + platformNotifications PlatformNotification[] } model OrgMember { @@ -417,6 +422,8 @@ model Project { onboardingData Json? taskScheduleInstances TaskScheduleInstance[] metricsDashboards MetricsDashboard[] + + platformNotifications PlatformNotification[] } enum ProjectVersion { @@ -2577,3 +2584,96 @@ model MetricsDashboard { /// Fast lookup for the list @@index([projectId, createdAt(sort: Desc)]) } + +enum PlatformNotificationSurface { + WEBAPP + CLI +} + +enum PlatformNotificationScope { + USER + PROJECT + ORGANIZATION + GLOBAL +} + +/// Admin-created notification definitions +model PlatformNotification { + id String @id @default(cuid()) + + friendlyId String @unique @default(cuid()) + + /// Admin-facing title for identification in admin tools + title String + + /// Versioned JSON rendering content (see payload schema in spec) + payload Json + + surface PlatformNotificationSurface + scope PlatformNotificationScope + + /// Set when scope = USER + user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String? + + /// Set when scope = ORGANIZATION + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organizationId String? + + /// Set when scope = PROJECT + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String? + + /// When notification becomes active + startsAt DateTime + + /// When notification expires. Null = persists until dismissed or archived + endsAt DateTime? + + /// CLI: auto-expire N days after user first saw it + cliMaxDaysAfterFirstSeen Int? + + /// CLI: auto-expire after shown N times to user + cliMaxShowCount Int? + + /// Ordering within same scope level (higher = more important) + priority Int @default(0) + + /// Soft delete + archivedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + interactions PlatformNotificationInteraction[] + + @@index([surface, scope, startsAt]) +} + +/// Per-user tracking of notification views and dismissals +model PlatformNotificationInteraction { + id String @id @default(cuid()) + + notification PlatformNotification @relation(fields: [notificationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + notificationId String + + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) + userId String + + /// Set by beacon/CLI GET on first impression + firstSeenAt DateTime @default(now()) + + /// Times shown (incremented per beacon/CLI view) + showCount Int @default(1) + + /// User dismissed in webapp + webappDismissedAt DateTime? + + /// Auto-dismissed or expired in CLI + cliDismissedAt DateTime? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([notificationId, userId]) +} diff --git a/packages/cli-v3/src/apiClient.ts b/packages/cli-v3/src/apiClient.ts index 693b48d992..5cb4204687 100644 --- a/packages/cli-v3/src/apiClient.ts +++ b/packages/cli-v3/src/apiClient.ts @@ -58,6 +58,33 @@ import { z } from "zod"; import { logger } from "./utilities/logger.js"; import { VERSION } from "./version.js"; +const CliPlatformNotificationResponseSchema = z.object({ + notification: z + .object({ + id: z.string(), + payload: z.object({ + version: z.string(), + data: z.object({ + type: z.enum(["info", "warn", "error", "success"]), + title: z.string(), + description: z.string(), + actionLabel: z.string().optional(), + actionUrl: z.string().optional(), + discovery: z + .object({ + filePatterns: z.array(z.string()), + contentPattern: z.string().optional(), + matchBehavior: z.enum(["show-if-found", "show-if-not-found"]), + }) + .optional(), + }), + }), + showCount: z.number(), + firstSeenAt: z.string(), + }) + .nullable(), +}); + export class CliApiClient { private engineURL: string; @@ -537,6 +564,25 @@ export class CliApiClient { ); } + async getCliPlatformNotification(projectRef?: string, signal?: AbortSignal) { + if (!this.accessToken) { + return { success: true as const, data: { notification: null } }; + } + + const url = new URL("/api/v1/platform-notifications", this.apiURL); + if (projectRef) { + url.searchParams.set("projectRef", projectRef); + } + + return wrapZodFetch(CliPlatformNotificationResponseSchema, url.href, { + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + signal, + }); + } + async triggerTaskRun(taskId: string, body?: TriggerTaskRequestBody) { if (!this.accessToken) { throw new Error("triggerTaskRun: No access token"); diff --git a/packages/cli-v3/src/commands/dev.ts b/packages/cli-v3/src/commands/dev.ts index 5557e59581..73e79933dd 100644 --- a/packages/cli-v3/src/commands/dev.ts +++ b/packages/cli-v3/src/commands/dev.ts @@ -1,6 +1,7 @@ import { ResolvedConfig } from "@trigger.dev/core/v3/build"; import { Command, Option as CommandOption } from "commander"; import { z } from "zod"; +import { CliApiClient } from "../apiClient.js"; import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js"; import { watchConfig } from "../config.js"; import { DevSessionInstance, startDevSession } from "../dev/devSession.js"; @@ -9,6 +10,10 @@ import { chalkError } from "../utilities/cliOutput.js"; import { resolveLocalEnvVars } from "../utilities/localEnvVars.js"; import { printDevBanner, printStandloneInitialBanner } from "../utilities/initialBanner.js"; import { logger } from "../utilities/logger.js"; +import { + awaitAndDisplayPlatformNotification, + fetchPlatformNotification, +} from "../utilities/platformNotifications.js"; import { runtimeChecks } from "../utilities/runtimeCheck.js"; import { getProjectClient, LoginResultOk } from "../utilities/session.js"; import { login } from "./login.js"; @@ -28,6 +33,7 @@ const DevCommandOptions = CommonCommandOptions.extend({ config: z.string().optional(), projectRef: z.string().optional(), skipUpdateCheck: z.boolean().default(false), + skipPlatformNotifications: z.boolean().default(false), envFile: z.string().optional(), keepTmpFiles: z.boolean().default(false), maxConcurrentRuns: z.coerce.number().optional(), @@ -97,6 +103,12 @@ export function configureDevCommand(program: Command) { ).hideHelp() ) .addOption(new CommandOption("--disable-warnings", "Suppress warnings output").hideHelp()) + .addOption( + new CommandOption( + "--skip-platform-notifications", + "Skip showing platform notifications" + ).hideHelp() + ) ).action(async (options) => { wrapCommandAction("dev", DevCommandOptions, options, async (opts) => { await devCommand(opts); @@ -205,8 +217,17 @@ async function startDev(options: StartDevOptions) { logger.loggerLevel = options.logLevel; } + const notificationPromise = options.skipPlatformNotifications + ? undefined + : fetchPlatformNotification({ + apiClient: new CliApiClient(options.login.auth.apiUrl, options.login.auth.accessToken), + projectRef: options.projectRef, + }); + await printStandloneInitialBanner(true, options.profile); + await awaitAndDisplayPlatformNotification(notificationPromise); + let displayedUpdateMessage = false; if (!options.skipUpdateCheck) { diff --git a/packages/cli-v3/src/commands/login.ts b/packages/cli-v3/src/commands/login.ts index f3b46405a7..df195bca69 100644 --- a/packages/cli-v3/src/commands/login.ts +++ b/packages/cli-v3/src/commands/login.ts @@ -20,6 +20,10 @@ import { writeAuthConfigCurrentProfileName, } from "../utilities/configFiles.js"; import { printInitialBanner } from "../utilities/initialBanner.js"; +import { + awaitAndDisplayPlatformNotification, + fetchPlatformNotification, +} from "../utilities/platformNotifications.js"; import { LoginResult } from "../utilities/session.js"; import { whoAmI } from "./whoami.js"; import { logger } from "../utilities/logger.js"; @@ -285,6 +289,11 @@ export async function login(options?: LoginOptions): Promise { options?.profile ); + // Fetch platform notification in parallel with whoAmI + const notificationPromise = fetchPlatformNotification({ + apiClient: new CliApiClient(authConfig?.apiUrl ?? opts.defaultApiUrl, indexResult.token), + }); + const whoAmIResult = await whoAmI( { profile: options?.profile ?? "default", @@ -309,6 +318,8 @@ export async function login(options?: LoginOptions): Promise { outro("Logged in successfully"); } + await awaitAndDisplayPlatformNotification(notificationPromise); + span.end(); return { diff --git a/packages/cli-v3/src/utilities/discoveryCheck.test.ts b/packages/cli-v3/src/utilities/discoveryCheck.test.ts new file mode 100644 index 0000000000..8c4b75420c --- /dev/null +++ b/packages/cli-v3/src/utilities/discoveryCheck.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import path from "node:path"; +import fs from "node:fs/promises"; +import os from "node:os"; +import { evaluateDiscovery, type DiscoverySpec } from "./discoveryCheck.js"; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "discovery-test-")); +}); + +afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); +}); + +describe("evaluateDiscovery", () => { + describe("show-if-found with file existence", () => { + it("returns true when file exists", async () => { + await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}"); + + const spec: DiscoverySpec = { + filePatterns: ["trigger.config.ts"], + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + + it("returns false when file does not exist", async () => { + const spec: DiscoverySpec = { + filePatterns: ["trigger.config.ts"], + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(false); + }); + }); + + describe("show-if-not-found with file existence", () => { + it("returns false when file exists", async () => { + await fs.writeFile(path.join(tmpDir, ".mcp.json"), "{}"); + + const spec: DiscoverySpec = { + filePatterns: [".mcp.json"], + matchBehavior: "show-if-not-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(false); + }); + + it("returns true when file does not exist", async () => { + const spec: DiscoverySpec = { + filePatterns: [".mcp.json"], + matchBehavior: "show-if-not-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + }); + + describe("content pattern matching", () => { + it("show-if-found: returns true when content matches", async () => { + await fs.writeFile( + path.join(tmpDir, "trigger.config.ts"), + 'import { syncVercelEnvVars } from "@trigger.dev/build";' + ); + + const spec: DiscoverySpec = { + filePatterns: ["trigger.config.ts"], + contentPattern: "syncVercelEnvVars", + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + + it("show-if-found: returns false when file exists but content does not match", async () => { + await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}"); + + const spec: DiscoverySpec = { + filePatterns: ["trigger.config.ts"], + contentPattern: "syncVercelEnvVars", + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(false); + }); + + it("show-if-not-found: returns true when file exists but content does not match", async () => { + await fs.writeFile(path.join(tmpDir, ".mcp.json"), '{"mcpServers": {}}'); + + const spec: DiscoverySpec = { + filePatterns: [".mcp.json"], + contentPattern: "trigger", + matchBehavior: "show-if-not-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + + it("show-if-not-found: returns false when content matches", async () => { + await fs.writeFile( + path.join(tmpDir, ".mcp.json"), + '{"mcpServers": {"trigger": {"url": "https://mcp.trigger.dev"}}}' + ); + + const spec: DiscoverySpec = { + filePatterns: [".mcp.json"], + contentPattern: "trigger", + matchBehavior: "show-if-not-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(false); + }); + + it("supports regex content patterns", async () => { + await fs.writeFile(path.join(tmpDir, "config.ts"), "syncVercelEnvVars({ foo: true })"); + + const spec: DiscoverySpec = { + filePatterns: ["config.ts"], + contentPattern: "syncVercel\\w+", + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + }); + + describe("glob patterns", () => { + it("matches files with glob patterns", async () => { + await fs.writeFile(path.join(tmpDir, "trigger.config.ts"), "export default {}"); + + const spec: DiscoverySpec = { + filePatterns: ["trigger.config.*"], + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + + it("matches files in subdirectories with glob", async () => { + await fs.mkdir(path.join(tmpDir, ".cursor"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, ".cursor", "mcp.json"), "{}"); + + const spec: DiscoverySpec = { + filePatterns: [".cursor/mcp.json"], + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + }); + + describe("multiple file patterns", () => { + it("returns true if any pattern matches (show-if-found)", async () => { + await fs.writeFile(path.join(tmpDir, "trigger.config.js"), "module.exports = {}"); + + const spec: DiscoverySpec = { + filePatterns: ["trigger.config.ts", "trigger.config.js"], + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + + it("returns true only if no pattern matches (show-if-not-found)", async () => { + const spec: DiscoverySpec = { + filePatterns: [".mcp.json", ".cursor/mcp.json"], + matchBehavior: "show-if-not-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + + it("content match short-circuits on first matching file", async () => { + await fs.writeFile(path.join(tmpDir, "a.ts"), "no match here"); + await fs.writeFile(path.join(tmpDir, "b.ts"), "syncVercelEnvVars found"); + + const spec: DiscoverySpec = { + filePatterns: ["a.ts", "b.ts"], + contentPattern: "syncVercelEnvVars", + matchBehavior: "show-if-found", + }; + + expect(await evaluateDiscovery(spec, tmpDir)).toBe(true); + }); + }); + + describe("error handling (fail closed)", () => { + it("returns false when file cannot be read for content check", async () => { + // Create a file then make it unreadable + const filePath = path.join(tmpDir, "unreadable.ts"); + await fs.writeFile(filePath, "content"); + await fs.chmod(filePath, 0o000); + + const spec: DiscoverySpec = { + filePatterns: ["unreadable.ts"], + contentPattern: "content", + matchBehavior: "show-if-found", + }; + + // On some systems (e.g., running as root), chmod may not restrict reads + // So we just verify it doesn't throw + const result = await evaluateDiscovery(spec, tmpDir); + expect(typeof result).toBe("boolean"); + + // Restore permissions for cleanup + await fs.chmod(filePath, 0o644); + }); + }); +}); diff --git a/packages/cli-v3/src/utilities/discoveryCheck.ts b/packages/cli-v3/src/utilities/discoveryCheck.ts new file mode 100644 index 0000000000..d3170065c8 --- /dev/null +++ b/packages/cli-v3/src/utilities/discoveryCheck.ts @@ -0,0 +1,155 @@ +import { glob, isDynamicPattern } from "tinyglobby"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { expandTilde, pathExists, readFile } from "./fileSystem.js"; +import { logger } from "./logger.js"; +import path from "node:path"; + +export type DiscoverySpec = { + filePatterns: string[]; + contentPattern?: string; + matchBehavior: "show-if-found" | "show-if-not-found"; +}; + +const REGEX_METACHARACTERS = /[\\^$.|?*+(){}[\]]/; + +/** + * Evaluates a discovery spec against the local filesystem. + * Returns `true` if the notification should be shown, `false` otherwise. + * Fails closed: any error returns `false` (suppress notification). + */ +export async function evaluateDiscovery( + spec: DiscoverySpec, + projectRoot: string +): Promise { + const [error, result] = await tryCatch(doEvaluate(spec, projectRoot)); + + if (error) { + logger.debug("Discovery check failed, suppressing notification", { error }); + return false; + } + + return result; +} + +async function doEvaluate(spec: DiscoverySpec, projectRoot: string): Promise { + logger.debug("Discovery: starting evaluation", { + filePatterns: spec.filePatterns, + contentPattern: spec.contentPattern, + matchBehavior: spec.matchBehavior, + projectRoot, + }); + + const matchedFiles = await resolveFilePatterns(spec.filePatterns, projectRoot); + const hasFileMatch = matchedFiles.length > 0; + + if (!hasFileMatch) { + const result = spec.matchBehavior === "show-if-not-found"; + logger.debug("Discovery: no files matched any pattern", { result }); + return result; + } + + // Files matched — if no content pattern, decide based on file match alone + if (!spec.contentPattern) { + const result = spec.matchBehavior === "show-if-found"; + logger.debug("Discovery: files matched, no content pattern to check", { + matchedFiles, + result, + }); + return result; + } + + // Check content in matched files + const hasContentMatch = await checkContentPattern(matchedFiles, spec.contentPattern); + + const result = + spec.matchBehavior === "show-if-found" ? hasContentMatch : !hasContentMatch; + + logger.debug("Discovery: evaluation complete", { + matchedFiles, + contentPattern: spec.contentPattern, + hasContentMatch, + result, + }); + + return result; +} + +async function resolveFilePatterns( + patterns: string[], + projectRoot: string +): Promise { + const matched: string[] = []; + + for (const pattern of patterns) { + const isHomeDirPattern = pattern.startsWith("~/"); + const resolvedPattern = isHomeDirPattern ? expandTilde(pattern) : pattern; + const cwd = isHomeDirPattern ? "/" : projectRoot; + const isGlob = isDynamicPattern(resolvedPattern); + + logger.debug("Discovery: resolving pattern", { + pattern, + resolvedPattern, + cwd, + isGlob, + isHomeDirPattern, + }); + + if (isGlob) { + const files = await glob({ + patterns: [resolvedPattern], + cwd, + absolute: true, + dot: true, + }); + if (files.length > 0) { + logger.debug("Discovery: glob matched files", { pattern, files }); + } + matched.push(...files); + } else { + const absolutePath = isHomeDirPattern + ? resolvedPattern + : path.resolve(projectRoot, resolvedPattern); + const exists = await pathExists(absolutePath); + logger.debug("Discovery: literal path check", { pattern, absolutePath, exists }); + if (exists) { + matched.push(absolutePath); + } + } + } + + return matched; +} + +async function checkContentPattern( + files: string[], + contentPattern: string +): Promise { + const useFastPath = !REGEX_METACHARACTERS.test(contentPattern); + + logger.debug("Discovery: checking content pattern", { + contentPattern, + useFastPath, + fileCount: files.length, + }); + + for (const filePath of files) { + const [error, content] = await tryCatch(readFile(filePath)); + + if (error) { + logger.debug("Discovery: failed to read file, skipping", { filePath, error }); + continue; + } + + const matches = useFastPath + ? content.includes(contentPattern) + : new RegExp(contentPattern).test(content); + + logger.debug("Discovery: content check result", { filePath, matches }); + + if (matches) { + return true; + } + } + + return false; +} diff --git a/packages/cli-v3/src/utilities/platformNotifications.ts b/packages/cli-v3/src/utilities/platformNotifications.ts new file mode 100644 index 0000000000..2a2fe4ed2c --- /dev/null +++ b/packages/cli-v3/src/utilities/platformNotifications.ts @@ -0,0 +1,117 @@ +import { log } from "@clack/prompts"; +import chalk from "chalk"; +import { tryCatch } from "@trigger.dev/core/utils"; +import { CliApiClient } from "../apiClient.js"; +import { chalkGrey } from "./cliOutput.js"; +import { evaluateDiscovery } from "./discoveryCheck.js"; +import { logger } from "./logger.js"; +import { spinner } from "./windows.js"; + +type CliLogLevel = "info" | "warn" | "error" | "success"; + +type PlatformNotification = { + level: CliLogLevel; + title: string; + description: string; + actionUrl?: string; +}; + +type FetchNotificationOptions = { + apiClient: CliApiClient; + projectRef?: string; + projectRoot?: string; +}; + +export async function fetchPlatformNotification( + options: FetchNotificationOptions +): Promise { + const [error, result] = await tryCatch( + options.apiClient.getCliPlatformNotification( + options.projectRef, + AbortSignal.timeout(7000) + ) + ); + + if (error) { + logger.debug("Platform notifications failed silently", { error }); + return undefined; + } + + if (!result.success) { + logger.debug("Platform notification fetch failed", { result }); + return undefined; + } + + const notification = result.data.notification; + if (!notification) return undefined; + + const { type, discovery, title, description, actionUrl } = notification.payload.data; + + if (discovery) { + const root = options.projectRoot ?? process.cwd(); + const shouldShow = await evaluateDiscovery(discovery, root); + if (!shouldShow) { + logger.debug("Notification suppressed by discovery check", { + notificationId: notification.id, + discovery, + }); + return undefined; + } + } + + return { level: type, title, description, actionUrl }; +} + +function displayPlatformNotification( + notification: PlatformNotification | undefined +): void { + if (!notification) return; + + const message = formatNotificationMessage(notification); + log[notification.level](message); +} + +function formatNotificationMessage(notification: PlatformNotification): string { + const { title, description, actionUrl } = notification; + const lines = [chalk.bold(title), chalkGrey(description)]; + if (actionUrl) { + lines.push(chalk.underline(chalkGrey(actionUrl))); + } + return lines.join("\n"); +} + +const SPINNER_DELAY_MS = 200; + +/** + * Awaits a notification promise, showing a loading spinner if the fetch + * takes longer than 200ms. The spinner is replaced by the notification + * content, or removed cleanly if there's nothing to show. + */ +export async function awaitAndDisplayPlatformNotification( + notificationPromise: Promise | undefined +): Promise { + if (!notificationPromise) return; + + // Race against a short delay — if the promise resolves quickly, skip the spinner + const pending = Symbol("pending"); + const raceResult = await Promise.race([ + notificationPromise, + new Promise((resolve) => setTimeout(() => resolve(pending), SPINNER_DELAY_MS)), + ]); + + if (raceResult !== pending) { + displayPlatformNotification(raceResult); + return; + } + + // Still pending after delay — show a spinner while waiting + const $spinner = spinner(); + $spinner.start("Checking for notifications"); + const notification = await notificationPromise; + + if (notification) { + $spinner.stop(formatNotificationMessage(notification)); + } else { + $spinner.stop("No new notifications"); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88ac6ad542..2768d48ffd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -704,6 +704,9 @@ importers: react-hotkeys-hook: specifier: ^4.4.1 version: 4.4.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@18.2.69)(react@18.2.0) react-popper: specifier: ^2.3.0 version: 2.3.0(@popperjs/core@2.11.8)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)