From efcafdf388344487681ea388ed6fcfd4e953aa98 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 26 Mar 2026 07:59:39 +0000 Subject: [PATCH 01/39] fix(webapp): filter dev environments by userId in OrganizationsPresenter (#3273) fix: filter dev environments by userId in OrganizationsPresenter --- .server-changes/fix-dev-env-scope-wrong-member.md | 6 ++++++ apps/webapp/app/presenters/OrganizationsPresenter.server.ts | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .server-changes/fix-dev-env-scope-wrong-member.md diff --git a/.server-changes/fix-dev-env-scope-wrong-member.md b/.server-changes/fix-dev-env-scope-wrong-member.md new file mode 100644 index 00000000000..2bd3c92825c --- /dev/null +++ b/.server-changes/fix-dev-env-scope-wrong-member.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix `OrganizationsPresenter.#getEnvironment` matching the wrong development environment on teams with multiple members. All dev environments share the slug `"dev"`, so the previous `find` by slug alone could return another member's environment. Now filters DEVELOPMENT environments by `orgMember.userId` to ensure the logged-in user's dev environment is selected. diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index 52e629ffedb..f99164be5ae 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -210,7 +210,11 @@ export class OrganizationsPresenter { })[]; }) { if (environmentSlug) { - const env = environments.find((e) => e.slug === environmentSlug); + const env = environments.find( + (e) => + e.slug === environmentSlug && + (e.type !== "DEVELOPMENT" || e.orgMember?.userId === user.id) + ); if (env) { return env; } From 38559480c91910043ba983f0d3747f4b9c8ed9e2 Mon Sep 17 00:00:00 2001 From: Saadi Myftija Date: Thu, 26 Mar 2026 10:27:31 +0100 Subject: [PATCH 02/39] feat: replicate trigger_source, root_trigger_source, and is_warm_start to ClickHouse (#3274) Adds three new top-level columns to the ClickHouse task_runs_v2 table primarily for analytics: - `trigger_source` / `root_trigger_source` - extracted from the existing TaskRun.annotations JSON during WAL replication - `is_warm_start` - new nullable boolean on TaskRun in Postgres, set in the existing taskRun.update() at attempt start (no additional write). null until the first attempt starts. Run region is already available via the existing `worker_queue` column in ClickHouse. --- .../services/runsReplicationService.server.ts | 10 ++++++++++ .../test/runsReplicationService.part1.test.ts | 9 +++++++++ ..._source_and_warm_start_to_task_runs_v2.sql | 19 +++++++++++++++++++ .../clickhouse/src/taskRuns.test.ts | 12 ++++++++++++ internal-packages/clickhouse/src/taskRuns.ts | 12 ++++++++++++ .../migration.sql | 2 ++ .../database/prisma/schema.prisma | 12 +++++++----- .../src/engine/systems/runAttemptSystem.ts | 1 + 8 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 internal-packages/clickhouse/schema/028_add_trigger_source_and_warm_start_to_task_runs_v2.sql create mode 100644 internal-packages/database/prisma/migrations/20260325165730_add_is_warm_start_to_task_run/migration.sql diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 56e2be62d41..7930c05481f 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -22,6 +22,7 @@ import { Logger, type LogLevel } from "@trigger.dev/core/logger"; import { tryCatch } from "@trigger.dev/core/utils"; import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization"; import { unsafeExtractIdempotencyKeyScope, unsafeExtractIdempotencyKeyUser } from "@trigger.dev/core/v3/serverOnly"; +import { RunAnnotations } from "@trigger.dev/core/v3"; import { type TaskRun } from "@trigger.dev/database"; import { nanoid } from "nanoid"; import EventEmitter from "node:events"; @@ -866,6 +867,8 @@ export class RunsReplicationService { ? calculateErrorFingerprint(run.error) : ''; + const annotations = this.#parseAnnotations(run.annotations); + // Return array matching TASK_RUN_COLUMNS order return [ run.runtimeEnvironmentId, // environment_id @@ -916,9 +919,16 @@ export class RunsReplicationService { run.bulkActionGroupIds ?? [], // bulk_action_group_ids run.masterQueue ?? "", // worker_queue run.maxDurationInSeconds ?? null, // max_duration_in_seconds + annotations?.triggerSource ?? "", // trigger_source + annotations?.rootTriggerSource ?? "", // root_trigger_source + run.isWarmStart ?? null, // is_warm_start ]; } + #parseAnnotations(annotations: unknown) { + return RunAnnotations.safeParse(annotations).data; + } + async #preparePayloadInsert(run: TaskRun, _version: bigint): Promise { const payload = await this.#prepareJson(run.payload, run.payloadType); diff --git a/apps/webapp/test/runsReplicationService.part1.test.ts b/apps/webapp/test/runsReplicationService.part1.test.ts index 87ebd0cde2d..715d4583dc2 100644 --- a/apps/webapp/test/runsReplicationService.part1.test.ts +++ b/apps/webapp/test/runsReplicationService.part1.test.ts @@ -86,6 +86,12 @@ describe("RunsReplicationService (part 1/2)", () => { organizationId: organization.id, environmentType: "DEVELOPMENT", engine: "V2", + annotations: { + triggerSource: "api", + triggerAction: "trigger", + rootTriggerSource: "dashboard", + }, + isWarmStart: true, }, }); @@ -111,6 +117,9 @@ describe("RunsReplicationService (part 1/2)", () => { organization_id: organization.id, environment_type: "DEVELOPMENT", engine: "V2", + trigger_source: "api", + root_trigger_source: "dashboard", + is_warm_start: 1, }) ); diff --git a/internal-packages/clickhouse/schema/028_add_trigger_source_and_warm_start_to_task_runs_v2.sql b/internal-packages/clickhouse/schema/028_add_trigger_source_and_warm_start_to_task_runs_v2.sql new file mode 100644 index 00000000000..9381df8ddd5 --- /dev/null +++ b/internal-packages/clickhouse/schema/028_add_trigger_source_and_warm_start_to_task_runs_v2.sql @@ -0,0 +1,19 @@ +-- +goose Up +ALTER TABLE trigger_dev.task_runs_v2 + ADD COLUMN trigger_source LowCardinality(String) DEFAULT ''; + +ALTER TABLE trigger_dev.task_runs_v2 + ADD COLUMN root_trigger_source LowCardinality(String) DEFAULT ''; + +ALTER TABLE trigger_dev.task_runs_v2 + ADD COLUMN is_warm_start Nullable(UInt8) DEFAULT NULL; + +-- +goose Down +ALTER TABLE trigger_dev.task_runs_v2 + DROP COLUMN trigger_source; + +ALTER TABLE trigger_dev.task_runs_v2 + DROP COLUMN root_trigger_source; + +ALTER TABLE trigger_dev.task_runs_v2 + DROP COLUMN is_warm_start; diff --git a/internal-packages/clickhouse/src/taskRuns.test.ts b/internal-packages/clickhouse/src/taskRuns.test.ts index 51a2a8d996c..8bd403f14f0 100644 --- a/internal-packages/clickhouse/src/taskRuns.test.ts +++ b/internal-packages/clickhouse/src/taskRuns.test.ts @@ -82,6 +82,9 @@ describe("Task Runs V2", () => { ["bulk_action_group_id_1234", "bulk_action_group_id_1235"], // bulk_action_group_ids "", // worker_queue null, // max_duration_in_seconds + "", // trigger_source + "", // root_trigger_source + null, // is_warm_start ]; const [insertError, insertResult] = await insert([taskRunData]); @@ -210,6 +213,9 @@ describe("Task Runs V2", () => { [], // bulk_action_group_ids "", // worker_queue null, // max_duration_in_seconds + "", // trigger_source + "", // root_trigger_source + null, // is_warm_start ]; const run2: TaskRunInsertArray = [ @@ -261,6 +267,9 @@ describe("Task Runs V2", () => { [], // bulk_action_group_ids "", // worker_queue null, // max_duration_in_seconds + "", // trigger_source + "", // root_trigger_source + null, // is_warm_start ]; const [insertError, insertResult] = await insert([run1, run2]); @@ -359,6 +368,9 @@ describe("Task Runs V2", () => { [], // bulk_action_group_ids "", // worker_queue null, // max_duration_in_seconds + "", // trigger_source + "", // root_trigger_source + null, // is_warm_start ]; const [insertError, insertResult] = await insert([taskRun]); diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index 4162691ed7a..6a9f66d7844 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -49,6 +49,9 @@ export const TaskRunV2 = z.object({ bulk_action_group_ids: z.array(z.string()).default([]), worker_queue: z.string().default(""), max_duration_in_seconds: z.number().int().nullish(), + trigger_source: z.string().default(""), + root_trigger_source: z.string().default(""), + is_warm_start: z.boolean().nullish(), _version: z.string(), _is_deleted: z.number().int().default(0), }); @@ -105,6 +108,9 @@ export const TASK_RUN_COLUMNS = [ "bulk_action_group_ids", "worker_queue", "max_duration_in_seconds", + "trigger_source", + "root_trigger_source", + "is_warm_start", ] as const; export type TaskRunColumnName = (typeof TASK_RUN_COLUMNS)[number]; @@ -168,6 +174,9 @@ export type TaskRunFieldTypes = { bulk_action_group_ids: string[]; worker_queue: string; max_duration_in_seconds: number | null; + trigger_source: string; + root_trigger_source: string; + is_warm_start: boolean | null; }; /** @@ -302,6 +311,9 @@ export type TaskRunInsertArray = [ bulk_action_group_ids: string[], worker_queue: string, max_duration_in_seconds: number | null, + trigger_source: string, + root_trigger_source: string, + is_warm_start: boolean | null, ]; /** diff --git a/internal-packages/database/prisma/migrations/20260325165730_add_is_warm_start_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20260325165730_add_is_warm_start_to_task_run/migration.sql new file mode 100644 index 00000000000..29274427d3c --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260325165730_add_is_warm_start_to_task_run/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."TaskRun" ADD COLUMN "isWarmStart" BOOLEAN; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index bf3c946a985..ceb10f7549b 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -537,13 +537,13 @@ model BackgroundWorkerFile { } model Prompt { - id String @id @default(cuid()) - friendlyId String @unique @map("friendly_id") + id String @id @default(cuid()) + friendlyId String @unique @map("friendly_id") slug String description String? type String @default("text") // "text" | "chat" - organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) @@ -558,7 +558,7 @@ model Prompt { defaultModel String? defaultConfig Json? - tags String[] @default([]) + tags String[] @default([]) archivedAt DateTime? createdAt DateTime @default(now()) @@ -840,6 +840,9 @@ model TaskRun { /// Structured annotations: triggerSource, triggerAction, rootTriggerSource, rootScheduleId annotations Json? + /// Whether the latest attempt was a warm start. Null until first attempt starts. + isWarmStart Boolean? + /// Run output output String? outputType String @default("application/json") @@ -857,7 +860,6 @@ model TaskRun { /// Store the stream keys that are being used by the run realtimeStreams String[] @default([]) - @@unique([oneTimeUseToken]) @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) // Finding child runs diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index 067d00a14e0..8e95519241c 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -402,6 +402,7 @@ export class RunAttemptSystem { status: "EXECUTING", attemptNumber: nextAttemptNumber, executedAt: taskRun.attemptNumber === null ? new Date() : undefined, + isWarmStart: isWarmStart ?? false, }, select: { id: true, From 922a852a9beba13c25c0ae64a6b4901a94ef47c9 Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 26 Mar 2026 11:21:13 +0100 Subject: [PATCH 03/39] fix(Vercel): Initial deployment fix (#3263) --- .../integrations/VercelOnboardingModal.tsx | 26 ++++------------ apps/webapp/app/routes/login._index/route.tsx | 30 +++++++++++-------- ...cts.$projectParam.env.$envParam.github.tsx | 25 ++++++++++------ ...cts.$projectParam.env.$envParam.vercel.tsx | 2 ++ .../app/services/vercelIntegration.server.ts | 4 ++- .../vercel/vercelProjectIntegrationSchema.ts | 1 + 6 files changed, 45 insertions(+), 43 deletions(-) diff --git a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx index 9b285db81ec..7ff99d7d448 100644 --- a/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx +++ b/apps/webapp/app/components/integrations/VercelOnboardingModal.tsx @@ -146,6 +146,11 @@ export function VercelOnboardingModal({ } return "project-selection"; } + // If onboarding was already completed but GitHub is not connected, + // go directly to the github-connection step (e.g., returning from GitHub App installation) + if (onboardingData?.isOnboardingComplete && !onboardingData?.isGitHubConnected) { + return "github-connection"; + } // For marketplace origin, skip env-mapping step and go directly to env-var-sync if (!fromMarketplaceContext) { const customEnvs = (onboardingData?.customEnvironments?.length ?? 0) > 0 && hasStagingEnvironment; @@ -1159,26 +1164,7 @@ export function VercelOnboardingModal({ > Complete - ) : ( - - ) - } - cancelButton={ - isGitHubConnectedForOnboarding && fromMarketplaceContext && nextUrl ? ( + ) : !fromMarketplaceContext ? ( - - - Join our Slack -
-
- - - As a subscriber, you have access to a dedicated Slack channel for 1-to-1 - support with the Trigger.dev team. - -
-
-
- - - - Send us an email to this address from your Trigger.dev account email - address: - - - - - - - As soon as we can, we'll setup a Slack Connect channel and say hello! - - -
-
-
- - - )} - - +
+ What's new + {changelogs.map((entry) => ( + + ))} + +
); } + +function GrayDotIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/components/navigation/NotificationPanel.tsx b/apps/webapp/app/components/navigation/NotificationPanel.tsx new file mode 100644 index 00000000000..fdfbb2f8742 --- /dev/null +++ b/apps/webapp/app/components/navigation/NotificationPanel.tsx @@ -0,0 +1,312 @@ +import { BellAlertIcon, ChevronRightIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { useFetcher } from "@remix-run/react"; +import { motion } from "framer-motion"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import { Header3 } from "~/components/primitives/Headers"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { usePlatformNotifications } from "~/routes/resources.platform-notifications"; +import { cn } from "~/utils/cn"; + +type Notification = { + id: string; + friendlyId: string; + scope: string; + priority: number; + payload: { + version: string; + data: { + title: string; + description: string; + image?: string; + actionLabel?: string; + actionUrl?: string; + dismissOnAction?: boolean; + }; + }; + isRead: boolean; +}; + +export function NotificationPanel({ + isCollapsed, + hasIncident, + organizationId, + projectId, +}: { + isCollapsed: boolean; + hasIncident: boolean; + organizationId: string; + projectId: string; +}) { + const { notifications } = usePlatformNotifications(organizationId, projectId) as { + notifications: Notification[]; + }; + const [dismissedIds, setDismissedIds] = useState>(new Set()); + const dismissFetcher = useFetcher(); + const seenIdsRef = useRef>(new Set()); + const seenFetcher = useFetcher(); + const clickedIdsRef = useRef>(new Set()); + const clickFetcher = useFetcher(); + + const visibleNotifications = notifications.filter((n) => !dismissedIds.has(n.id)); + const notification = visibleNotifications[0] ?? null; + + const handleDismiss = useCallback((id: string) => { + setDismissedIds((prev) => new Set(prev).add(id)); + + dismissFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/dismiss`, + } + ); + }, []); + + const fireClickBeacon = useCallback((id: string) => { + if (clickedIdsRef.current.has(id)) return; + clickedIdsRef.current.add(id); + + clickFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${id}/clicked`, + } + ); + }, []); + + // Fire seen beacon + const fireSeenBeacon = useCallback((n: Notification) => { + if (seenIdsRef.current.has(n.id)) return; + seenIdsRef.current.add(n.id); + + seenFetcher.submit( + {}, + { + method: "POST", + action: `/resources/platform-notifications/${n.id}/seen`, + } + ); + }, []); + + // Beacon current notification on mount + useEffect(() => { + if (notification && !hasIncident) { + fireSeenBeacon(notification); + } + }, [notification?.id, hasIncident]); + + if (!notification) { + return null; + } + + const card = ( + fireClickBeacon(notification.id)} + /> + ); + + return ( + +
+ {/* Expanded sidebar: show card directly */} + + {card} + + + {/* Collapsed sidebar: show bell icon that opens popover */} + + +
+ + + {visibleNotifications.length} + +
+ + } + content="Notifications" + side="right" + sideOffset={8} + disableHoverableContent + asChild + /> +
+
+ + {card} + +
+ ); +} + +function NotificationCard({ + notification, + onDismiss, + onLinkClick, +}: { + notification: Notification; + onDismiss: (id: string) => void; + onLinkClick: () => void; +}) { + const { title, description, image, actionUrl, dismissOnAction } = notification.payload.data; + const [isExpanded, setIsExpanded] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + const descriptionRef = useRef(null); + + useLayoutEffect(() => { + const el = descriptionRef.current; + if (el) { + setIsOverflowing(el.scrollHeight > el.clientHeight); + } + }, [description]); + + const handleDismiss = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + onDismiss(notification.id); + }; + + const handleToggleExpand = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsExpanded((v) => !v); + }; + + const handleCardClick = () => { + onLinkClick(); + if (dismissOnAction) { + onDismiss(notification.id); + } + }; + + const Wrapper = actionUrl ? "a" : "div"; + const wrapperProps = actionUrl + ? { + href: actionUrl, + target: "_blank" as const, + rel: "noopener noreferrer" as const, + onClick: handleCardClick, + } + : {}; + + return ( + + {/* Header: title + dismiss */} +
+ + {title} + + +
+ + {/* Body: description + chevron */} +
+
+
+
+ {description} +
+ {(isOverflowing || isExpanded) && ( + + )} +
+ {actionUrl && ( +
+ +
+ )} +
+ + {image && ( + + )} +
+
+ ); +} + +/** Sanitize image URL to prevent XSS via javascript: or data: URIs. */ +function sanitizeImageUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:" || parsed.protocol === "http:") { + return parsed.href; + } + return ""; + } catch { + return ""; + } +} + +function getMarkdownComponents(onLinkClick: () => void) { + return { + p: ({ children }: { children?: React.ReactNode }) => ( +

{children}

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

+ CLI Preview +

+
+ {title && ( +

+ +

+ )} + {description && ( +

+ +

+ )} + {actionUrl && ( +

{actionUrl}

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