diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx new file mode 100644 index 00000000000..ee6e03e4e1c --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -0,0 +1,508 @@ +import { + BellAlertIcon, + ChevronDoubleLeftIcon, + ChevronDoubleRightIcon, + ChevronLeftIcon, + ChevronRightIcon, + MinusIcon, +} from "@heroicons/react/20/solid"; +import { useFetcher } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { Paragraph } from "~/components/primitives/Paragraph"; +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 prevNotificationIdsRef = useRef>(new Set()); + const [animateNew, setAnimateNew] = useState(false); + + const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); + + // Detect newly arrived notifications + useEffect(() => { + const currentIds = new Set(visibleNotifications.map((n) => n.id)); + const prevIds = prevNotificationIdsRef.current; + const hasNew = visibleNotifications.some((n) => !prevIds.has(n.id)); + + if (hasNew && prevIds.size > 0) { + setAnimateNew(true); + } + + prevNotificationIdsRef.current = currentIds; + }, [visibleNotifications]); + + // Auto-reset animation flag + useEffect(() => { + if (animateNew) { + const timer = setTimeout(() => setAnimateNew(false), 1000); + return () => clearTimeout(timer); + } + }, [animateNew]); + + const handleDismiss = useCallback( + (id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); + + dismissFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/dismiss`, + } + ); + }, + [] + ); + + if (visibleNotifications.length === 0) { + return null; + } + + return ( + +
+ + + + + + + + + {visibleNotifications.length > 0 && ( + + {visibleNotifications.length} + + )} + + + } + content="Notifications" + side="right" + sideOffset={8} + disableHoverableContent + asChild + /> + +
+ + + +
+ ); +} + +function NotificationPanelContent({ + notifications, + hasIncident, + organizationId, + onDismiss, + animateNew, +}: { + notifications: Notification[]; + hasIncident: boolean; + organizationId: string; + onDismiss: (id: string) => void; + animateNew: boolean; +}) { + const [isExpanded, setIsExpanded] = useState(true); + const [currentIndex, setCurrentIndex] = useState(0); + const seenIdsRef = useRef>(new Set()); + const seenFetcher = useFetcher(); + + // Clamp currentIndex if notifications change + const clampedIndex = Math.min(currentIndex, Math.max(0, notifications.length - 1)); + if (clampedIndex !== currentIndex) { + setCurrentIndex(clampedIndex); + } + + const currentNotification = notifications[clampedIndex]; + + // Fire seen beacon when a card comes into view + const fireSeenBeacon = useCallback( + (notification: Notification) => { + if (seenIdsRef.current.has(notification.id)) return; + seenIdsRef.current.add(notification.id); + + seenFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${notification.id}/seen`, + } + ); + }, + [] + ); + + // Beacon current card on mount and when carousel navigates + useEffect(() => { + if (currentNotification && isExpanded && !hasIncident) { + fireSeenBeacon(currentNotification); + } + }, [clampedIndex, isExpanded, hasIncident, currentNotification]); + + const handleDismiss = useCallback( + (notification: Notification) => { + onDismiss(notification.id); + + // Adjust index if dismissed card was before/at current position + if (clampedIndex >= notifications.length - 1) { + setCurrentIndex(Math.max(0, clampedIndex - 1)); + } + }, + [onDismiss, clampedIndex, notifications.length] + ); + + const handleAction = useCallback( + (notification: Notification) => { + if (notification.payload.data.dismissOnAction) { + handleDismiss(notification); + } + }, + [handleDismiss] + ); + + const effectiveExpanded = isExpanded && !hasIncident; + + return ( +
+ {/* Card area */} + {effectiveExpanded && currentNotification && ( + + {/* Header: title + minimize */} +
+ + {currentNotification.payload.data.title} + + +
+ + {/* Body */} +
+
+ + + {currentNotification.payload.data.image && ( + + )} + +
+ {currentNotification.payload.data.actionLabel && currentNotification.payload.data.actionUrl ? ( + handleAction(currentNotification)} + fullWidth + > + {currentNotification.payload.data.actionLabel} + + ) : ( + + )} + +
+
+
+ + {/* Carousel navigation */} + {notifications.length > 1 && ( +
+
+ {notifications.length > 5 && ( + + )} + +
+ + {clampedIndex + 1} / {notifications.length} + +
+ + {notifications.length > 5 && ( + + )} +
+
+ )} +
+ )} + + {/* Banner bar */} + +
+ ); +} + +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} + ), +}; + +function NotificationDescription({ + description, + hasImage, +}: { + description: string; + hasImage: boolean; +}) { + const charLimit = hasImage ? 140 : 280; + const needsTruncation = description.length > charLimit; + const [isShowingMore, setIsShowingMore] = useState(false); + + const displayText = useMemo(() => { + if (!needsTruncation || isShowingMore) return description; + return description.slice(0, charLimit) + "…"; + }, [description, charLimit, needsTruncation, isShowingMore]); + + const toggle = needsTruncation ? ( + <> + {" "} + + + ) : null; + + return ( +
+ +
+ ); +} + +function MarkdownWithSuffix({ text, suffix }: { text: string; suffix: React.ReactNode }) { + const pCountRef = useRef(0); + const totalParagraphs = useMemo(() => (text.match(/(?:^|\n\n)(?!\s*$)/g) || [""]).length, [text]); + + // Reset counter each render + pCountRef.current = 0; + + if (!suffix) { + return {text}; + } + + const components = { + ...markdownComponents, + p: ({ children }: { children?: React.ReactNode }) => { + pCountRef.current++; + const isLast = pCountRef.current >= totalParagraphs; + return ( +

+ {children} + {isLast && suffix} +

+ ); + }, + }; + + return {text}; +} 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/api.v1.platform-notifications.ts b/apps/webapp/app/routes/api.v1.platform-notifications.ts new file mode 100644 index 00000000000..7299c407383 --- /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-notifications.$id.dismiss.tsx b/apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx new file mode 100644 index 00000000000..e6a475692d9 --- /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 00000000000..652d4ee99c0 --- /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 00000000000..46f18059646 --- /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 = 60_000; // 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 00000000000..6d423c03bba --- /dev/null +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -0,0 +1,487 @@ +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"]), + 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 }, + }); +} + +// --- 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 b7f8cacebb0..69732165d9c 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 00000000000..0b29b6400dc --- /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 f6986be42c0..de17ecc280b 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 693b48d992e..5cb42046870 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 5557e595817..73e79933dd0 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 f3b46405a73..df195bca69c 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 00000000000..8c4b75420c1 --- /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 00000000000..d3170065c8a --- /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 00000000000..2a2fe4ed2ce --- /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 83e28308337..778c28b9653 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -701,6 +701,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)