import { TrashIcon } from "@heroicons/react/20/solid"; import { useFetcher, useSearchParams } from "@remix-run/react"; import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Alert, AlertCancel, AlertContent, AlertDescription, AlertFooter, AlertHeader, AlertTitle, AlertTrigger, } from "~/components/primitives/Alert"; import { Button } from "~/components/primitives/Buttons"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "~/components/primitives/Dialog"; import { Checkbox, CheckboxWithLabel } from "~/components/primitives/Checkbox"; import { Hint } from "~/components/primitives/Hint"; import { Input } from "~/components/primitives/Input"; import { Label } from "~/components/primitives/Label"; import { NotificationCard } from "~/components/navigation/NotificationCard"; import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Select, SelectItem } from "~/components/primitives/Select"; import { TextArea } from "~/components/primitives/TextArea"; import { Table, TableBlankRow, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow, } from "~/components/primitives/Table"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { logger } from "~/services/logger.server"; import { archivePlatformNotification, createPlatformNotification, deletePlatformNotification, getAdminNotificationsList, publishNowPlatformNotification, updatePlatformNotification, } from "~/services/platformNotifications.server"; import { createSearchParams } from "~/utils/searchParams"; const PAGE_SIZE = 20; const WEBAPP_TYPES = ["card", "changelog"] as const; const CLI_TYPES = ["info", "warn", "error", "success"] as const; /** Sentinel for the discovery "match behavior" select meaning "none / not configured". */ const DISCOVERY_MATCH_NONE = ""; const DISCOVERY_MATCH_LABEL = "— none —"; const SearchParams = z.object({ page: z.coerce.number().optional(), hideInactive: z.coerce.boolean().optional(), }); export const loader = dashboardLoader( { authorization: { requireSuper: true } }, async ({ user, request }) => { const searchParams = createSearchParams(request.url, SearchParams); if (!searchParams.success) throw new Error(searchParams.error); const { page: rawPage, hideInactive } = searchParams.params.getAll(); const page = rawPage ?? 1; const data = await getAdminNotificationsList({ page, pageSize: PAGE_SIZE, hideInactive: hideInactive ?? false, }); return typedjson({ ...data, userId: user.id }); } ); export const action = dashboardAction( { authorization: { requireSuper: true } }, async ({ user, request }) => { const userId = user.id; 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); } if (_action === "delete") { return handleDeleteAction(formData); } if (_action === "publish-now") { return handlePublishNowAction(formData); } if (_action === "edit") { return handleEditAction(formData); } return typedjson({ error: "Unknown action" }, { status: 400 }); } ); function parseNotificationFormData(formData: FormData) { 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"); const scope = (formData.get("scope") as string) || "GLOBAL"; const scopeUserId = (formData.get("scopeUserId") as string) || undefined; const scopeOrganizationId = (formData.get("scopeOrganizationId") as string) || undefined; const scopeProjectId = (formData.get("scopeProjectId") as string) || undefined; 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) || DISCOVERY_MATCH_NONE; const discovery = discoveryFilePatterns && discoveryMatchBehavior !== DISCOVERY_MATCH_NONE ? { filePatterns: discoveryFilePatterns .split(",") .map((s) => s.trim()) .filter(Boolean), ...(discoveryContentPattern ? { contentPattern: discoveryContentPattern } : {}), matchBehavior: discoveryMatchBehavior as "show-if-found" | "show-if-not-found", } : undefined; return { surface, payloadType, adminLabel, title, description, actionUrl, image, dismissOnAction, startsAt, endsAt, priority, scope, scopeUserId, scopeOrganizationId, scopeProjectId, cliMaxShowCount, cliMaxDaysAfterFirstSeen, cliShowEvery, discovery, }; } function buildPayloadInput(fields: ReturnType) { return { version: "1" as const, data: { type: fields.payloadType as "info" | "warn" | "error" | "success" | "card" | "changelog", title: fields.title, description: fields.description, ...(fields.actionUrl ? { actionUrl: fields.actionUrl } : {}), ...(fields.image ? { image: fields.image } : {}), ...(fields.dismissOnAction ? { dismissOnAction: true } : {}), ...(fields.discovery ? { discovery: fields.discovery } : {}), }, }; } async function handleCreateAction(formData: FormData, userId: string, isPreview: boolean) { const fields = parseNotificationFormData(formData); if ( !fields.adminLabel || !fields.title || !fields.description || !fields.endsAt || !fields.surface || !fields.payloadType ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } const result = await createPlatformNotification({ title: isPreview ? `[Preview] ${fields.adminLabel}` : fields.adminLabel, payload: buildPayloadInput(fields), surface: fields.surface as "CLI" | "WEBAPP", scope: isPreview ? "USER" : (fields.scope as "USER" | "PROJECT" | "ORGANIZATION" | "GLOBAL"), ...(isPreview ? { userId } : { ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId ? { organizationId: fields.scopeOrganizationId } : {}), ...(fields.scope === "PROJECT" && fields.scopeProjectId ? { projectId: fields.scopeProjectId } : {}), }), startsAt: isPreview ? new Date().toISOString() : fields.startsAt ? new Date(fields.startsAt + "Z").toISOString() : new Date().toISOString(), endsAt: isPreview ? new Date(Date.now() + 60 * 60 * 1000).toISOString() : new Date(fields.endsAt + "Z").toISOString(), priority: fields.priority, ...(fields.surface === "CLI" ? isPreview ? { cliMaxShowCount: 1 } : { cliMaxShowCount: fields.cliMaxShowCount, cliMaxDaysAfterFirstSeen: fields.cliMaxDaysAfterFirstSeen, cliShowEvery: fields.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 } ); } logger.error("Failed to create platform notification", { error: err }); return typedjson({ error: "Something went wrong, please try again." }, { 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 }); } try { await archivePlatformNotification(notificationId); return typedjson({ success: true }); } catch (error) { logger.error("Failed to archive platform notification", { error, notificationId }); return typedjson( { error: "Failed to archive notification, please try again." }, { status: 500 } ); } } async function handleDeleteAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; if (!notificationId) { return typedjson({ error: "Missing notificationId" }, { status: 400 }); } try { await deletePlatformNotification(notificationId); return typedjson({ success: true }); } catch (error) { logger.error("Failed to delete platform notification", { error, notificationId }); return typedjson( { error: "Failed to delete notification, please try again." }, { status: 500 } ); } } async function handlePublishNowAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; if (!notificationId) { return typedjson({ error: "Missing notificationId" }, { status: 400 }); } try { await publishNowPlatformNotification(notificationId); return typedjson({ success: true }); } catch (error) { logger.error("Failed to publish platform notification", { error, notificationId }); return typedjson( { error: "Failed to publish notification, please try again." }, { status: 500 } ); } } async function handleEditAction(formData: FormData) { const notificationId = formData.get("notificationId") as string; const fields = parseNotificationFormData(formData); if ( !notificationId || !fields.adminLabel || !fields.title || !fields.description || !fields.endsAt || !fields.surface || !fields.payloadType || !fields.startsAt ) { return typedjson({ error: "Missing required fields" }, { status: 400 }); } const result = await updatePlatformNotification({ id: notificationId, title: fields.adminLabel, payload: buildPayloadInput(fields), surface: fields.surface as "CLI" | "WEBAPP", scope: fields.scope as "USER" | "PROJECT" | "ORGANIZATION" | "GLOBAL", ...(fields.scope === "USER" && fields.scopeUserId ? { userId: fields.scopeUserId } : {}), ...(fields.scope === "ORGANIZATION" && fields.scopeOrganizationId ? { organizationId: fields.scopeOrganizationId } : {}), ...(fields.scope === "PROJECT" && fields.scopeProjectId ? { projectId: fields.scopeProjectId } : {}), startsAt: new Date(fields.startsAt + "Z").toISOString(), endsAt: new Date(fields.endsAt + "Z").toISOString(), priority: fields.priority, ...(fields.surface === "CLI" ? { cliMaxShowCount: fields.cliMaxShowCount, cliMaxDaysAfterFirstSeen: fields.cliMaxDaysAfterFirstSeen, cliShowEvery: fields.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 } ); } logger.error("Failed to update platform notification", { error: err }); return typedjson({ error: "Something went wrong, please try again." }, { status: 500 }); } return typedjson({ success: true, id: result.value.id }); } export default function AdminNotificationsRoute() { const { notifications, total, page, pageCount } = useTypedLoaderData(); const [showCreate, setShowCreate] = useState(false); const [detailNotification, setDetailNotification] = useState< (typeof notifications)[number] | null >(null); const [editNotification, setEditNotification] = useState<(typeof notifications)[number] | null>( null ); const [urlSearchParams, setUrlSearchParams] = useSearchParams(); const hideInactive = urlSearchParams.get("hideInactive") === "true"; const toggleHideInactive = () => { setUrlSearchParams((prev) => { const next = new URLSearchParams(prev); if (hideInactive) { next.delete("hideInactive"); } else { next.set("hideInactive", "true"); } next.delete("page"); return next; }); }; return (
{ if (!open) setShowCreate(false); }} > Create notification setShowCreate(false)} />
{total} notifications (page {page} of {pageCount || 1})
Title Surface Scope Type Starts (UTC) Ends (UTC) Seen Clicked Dismissed Status {notifications.length === 0 ? ( No notifications found ) : ( notifications.map((n) => { const status = getNotificationStatus(n); const _isActive = status === "active"; return ( {n.surface} {n.scope} {n.payloadType ?? "—"} {formatDate(n.startsAt)} {formatDate(n.endsAt)} {n.stats.seen} {n.stats.clicked} {n.stats.dismissed}
{status === "pending" && } {(status === "pending" || status === "releasing" || status === "active") && ( )} {status !== "archived" && }
); }) )}
{ if (!open) setDetailNotification(null); }} > {detailNotification && ( <> {detailNotification.title} )} { if (!open) setEditNotification(null); }} > {editNotification && ( <> Edit notification setEditNotification(null)} /> )}
); } function ArchiveButton({ notificationId }: { notificationId: string }) { const fetcher = useFetcher(); return ( ); } function PublishNowButton({ notificationId }: { notificationId: string }) { const [open, setOpen] = useState(false); const fetcher = useFetcher(); return ( Publish notification now This will make the notification immediately visible to users. setOpen(false)}> ); } function DeleteConfirmationButton({ notificationId }: { notificationId: string }) { const [open, setOpen] = useState(false); const fetcher = useFetcher(); return ( Delete notification This will permanently delete this notification and all its interaction data. This action cannot be undone. setOpen(false)}> ); } type NotificationFormDefaults = { id?: string; title?: string; surface?: string; scope?: string; userId?: string | null; organizationId?: string | null; projectId?: string | null; priority?: number; startsAt?: Date; endsAt?: Date; payloadTitle?: string | null; payloadType?: string | null; payloadDescription?: string | null; payloadActionUrl?: string | null; payloadImage?: string | null; payloadDismissOnAction?: boolean; payloadDiscovery?: { filePatterns: string[]; contentPattern?: string; matchBehavior: "show-if-found" | "show-if-not-found"; } | null; cliMaxShowCount?: number | null; cliMaxDaysAfterFirstSeen?: number | null; cliShowEvery?: number | null; }; function NotificationForm({ mode, notification: n, onClose, }: { mode: "create" | "edit"; notification?: NotificationFormDefaults; onClose: () => void; }) { const fetcher = useFetcher<{ success?: boolean; error?: string; previewId?: string }>(); const [surface, setSurface] = useState<"CLI" | "WEBAPP">( (n?.surface as "CLI" | "WEBAPP") ?? "WEBAPP" ); const [payloadType, setPayloadType] = useState(n?.payloadType ?? "card"); const [scope, setScope] = useState(n?.scope ?? "GLOBAL"); const [title, setTitle] = useState(n?.payloadTitle ?? ""); const [description, setDescription] = useState(n?.payloadDescription ?? ""); const [actionUrl, setActionUrl] = useState(n?.payloadActionUrl ?? ""); const [image, setImage] = useState(n?.payloadImage ?? ""); const [discoveryMatchBehavior, setDiscoveryMatchBehavior] = useState( n?.payloadDiscovery?.matchBehavior ?? DISCOVERY_MATCH_NONE ); const typeOptions = surface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES; const handleSurfaceChange = (newSurface: "CLI" | "WEBAPP") => { setSurface(newSurface); const newTypes = newSurface === "WEBAPP" ? WEBAPP_TYPES : CLI_TYPES; if (!newTypes.includes(payloadType as any)) { setPayloadType(newTypes[0]); } }; useEffect(() => { if (fetcher.data?.success && !fetcher.data.previewId) { onClose(); } }, [fetcher.data, onClose]); const isEdit = mode === "edit"; return ( {isEdit && ( <> )}
value={surface} setValue={(v) => handleSurfaceChange(v as "CLI" | "WEBAPP")} variant="tertiary/medium" items={["WEBAPP", "CLI"]} text={(v) => v} className="mt-1 w-full" > {(items) => items.map((item) => ( {item} )) }
value={payloadType} setValue={setPayloadType} variant="tertiary/medium" items={[...typeOptions]} text={(v) => v} className="mt-1 w-full" > {(items) => items.map((item) => ( {item} )) }
value={scope} setValue={setScope} variant="tertiary/medium" items={["GLOBAL", "USER", "ORGANIZATION", "PROJECT"]} text={(v) => v} className="mt-1 w-full" > {(items) => items.map((item) => ( {item} )) }
{scope === "USER" && (
)} {scope === "ORGANIZATION" && (
)} {scope === "PROJECT" && (
)} {/* CLI live preview */} {surface === "CLI" && (title || description) && (
{title && (

)} {description && (

)} {actionUrl &&

{actionUrl}

}
)}
{surface === "WEBAPP" ? (