From 0833bd8cdba5cdcc4279fb68a7d1b94cddaa045b Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 23 Feb 2026 14:05:09 +0100 Subject: [PATCH 1/3] feat(db): add platform notification models and relations Add PlatformNotification and PlatformNotificationInteraction Prisma models, including enums PlatformNotificationSurface and PlatformNotificationScope, indexes, timestamps, and fields for scoping (user, project, organization), lifecycle (startsAt, endsAt, archivedAt) and delivery behavior (CLI-specific fields, priority). Wire new relations into User, Organization, Project, OrgMember and Workspace models so notifications and interactions can be queried from those entities. Add SQL migration to create the new enums and adjust schema constraints and indexes required for the migration. The change enables admin-created, scoped platform notifications with per-user interaction tracking for both webapp and CLI surfaces. --- .../migration.sql | 67 ++++++++++++ .../database/prisma/schema.prisma | 100 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20260223123615_add_platform_notification_tables/migration.sql 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]) +} From 6e9acca38e824c0905bac6defd1b6e0fe3fb7cbf Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Mon, 23 Feb 2026 18:14:30 +0100 Subject: [PATCH 2/3] feat(webapp): add dashboard platform notifications service & UI Introduce a new server-side service to read and record platform notifications targeted at the webapp. - Add payload schema (v1) using zod and typed PayloadV1. - Define PlatformNotificationWithPayload type and scope priority map. - Implement getActivePlatformNotifications to: - query active WEBAPP notifications with scope/org/project/user filters, - include user interactions and validate payloads, - filter dismissed items, compute unreadCount, and return sorted results. - Add helper functions: - findInteraction to match global/org interactions, - compareNotifications to sort by scope, priority, then recency. - Implement upsertInteraction to create or update platform notification interactions, handling GLOBAL-scoped interactions per organization. These changes centralize notification read/write logic, enforce payload validation, and provide deterministic ordering and unread counts for the webapp UI. --- .../navigation/NotificationPanel.tsx | 508 ++++++++++++++++++ .../app/components/navigation/SideMenu.tsx | 7 + .../admin.api.v1.platform-notifications.ts | 60 +++ .../routes/api.v1.platform-notifications.ts | 22 + ...ces.platform-notifications.$id.dismiss.tsx | 17 + ...ources.platform-notifications.$id.seen.tsx | 17 + .../resources.platform-notifications.tsx | 61 +++ .../services/platformNotifications.server.ts | 466 ++++++++++++++++ apps/webapp/package.json | 1 + pnpm-lock.yaml | 3 + 10 files changed, 1162 insertions(+) create mode 100644 apps/webapp/app/components/navigation/NotificationPanel.tsx create mode 100644 apps/webapp/app/routes/admin.api.v1.platform-notifications.ts create mode 100644 apps/webapp/app/routes/api.v1.platform-notifications.ts create mode 100644 apps/webapp/app/routes/resources.platform-notifications.$id.dismiss.tsx create mode 100644 apps/webapp/app/routes/resources.platform-notifications.$id.seen.tsx create mode 100644 apps/webapp/app/routes/resources.platform-notifications.tsx create mode 100644 apps/webapp/app/services/platformNotifications.server.ts diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx new file mode 100644 index 0000000000..ee6e03e4e1 --- /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 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-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..46f1805964 --- /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 0000000000..8bd8115c09 --- /dev/null +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -0,0 +1,466 @@ +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 CardDataV1Schema = z.object({ + type: z.literal("card"), + title: z.string(), + description: z.string(), + image: z.string().url().optional(), + actionLabel: z.string().optional(), + actionUrl: z.string().url().optional(), + dismissOnAction: z.boolean().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 b7f8cacebb..69732165d9 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/pnpm-lock.yaml b/pnpm-lock.yaml index 83e2830833..778c28b965 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) From 93a49a25ae0717e8f2a69ec0e1c17374f0226057 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Wed, 25 Feb 2026 17:41:53 +0100 Subject: [PATCH 3/3] feat(CLI): CLI notifications v1 --- .../services/platformNotifications.server.ts | 23 +- packages/cli-v3/src/apiClient.ts | 46 ++++ packages/cli-v3/src/commands/dev.ts | 21 ++ packages/cli-v3/src/commands/login.ts | 11 + .../src/utilities/discoveryCheck.test.ts | 212 ++++++++++++++++++ .../cli-v3/src/utilities/discoveryCheck.ts | 155 +++++++++++++ .../src/utilities/platformNotifications.ts | 117 ++++++++++ 7 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 packages/cli-v3/src/utilities/discoveryCheck.test.ts create mode 100644 packages/cli-v3/src/utilities/discoveryCheck.ts create mode 100644 packages/cli-v3/src/utilities/platformNotifications.ts diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts index 8bd8115c09..6d423c03bb 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -5,14 +5,35 @@ import { type PlatformNotificationScope, type PlatformNotificationSurface } from // --- 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.literal("card"), + 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({ 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"); + } +}