diff --git a/apps/web/components/add-document/connections.tsx b/apps/web/components/add-document/connections.tsx index c8e748ada..4c455c63f 100644 --- a/apps/web/components/add-document/connections.tsx +++ b/apps/web/components/add-document/connections.tsx @@ -9,9 +9,11 @@ import { useCustomer } from "autumn-js/react" import { Check, ChevronDown, - Clock, FolderOpen, + History, Loader, + Loader2, + Play, Trash2, Zap, } from "lucide-react" @@ -30,6 +32,11 @@ import { DropdownMenuTrigger, } from "@ui/components/dropdown-menu" import { RemoveConnectionDialog } from "@/components/remove-connection-dialog" +import { SyncStatusBadge } from "@/components/settings/sync-status-badge" +import { SyncHistoryPanel } from "@/components/settings/sync-history-panel" +import { useTriggerSync } from "@/hooks/use-trigger-sync" +import { formatRelativeTime } from "@/components/settings/sync-utils" +import type { ImportProvider } from "@/components/settings/sync-utils" type GDriveSyncScope = "scoped" | "full" @@ -71,17 +78,20 @@ const CONNECTORS: Record< }, } as const -function formatRelativeTime(date: string | null | undefined): string { - if (!date) return "Never" - const d = new Date(date) - const diffMs = Date.now() - d.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - const diffDays = Math.floor(diffHours / 24) - if (diffHours < 1) return "Just now" - if (diffHours < 24) return `${diffHours}h ago` - if (diffDays === 1) return "Yesterday" - if (diffDays < 7) return `${diffDays} days ago` - return d.toLocaleDateString() +/** Extract typed metadata from a connection, with runtime validation. */ +function getConnectionMeta(connection: Connection) { + const m = connection.metadata as Record | undefined + return { + syncInProgress: m?.syncInProgress === true, + lastSyncedAt: + typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined, + documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0, + } +} + +/** Check if a connection's auth token has expired. */ +function isConnectionExpired(connection: Connection): boolean { + return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date() } function ConnectionRow({ @@ -89,18 +99,23 @@ function ConnectionRow({ onDelete, isDeleting, projects, + onTriggerSync, + isSyncing, }: { connection: Connection onDelete: () => void isDeleting: boolean projects: Project[] + onTriggerSync: () => void + isSyncing: boolean }) { + const [historyOpen, setHistoryOpen] = useState(false) const config = CONNECTORS[connection.provider as ConnectorProvider] if (!config) return null const Icon = config.icon - const isConnected = - !connection.expiresAt || new Date(connection.expiresAt) > new Date() + const meta = getConnectionMeta(connection) + const expired = isConnectionExpired(connection) const getProjectName = (tag: string): string => { if (tag === DEFAULT_PROJECT_ID) return "Default" @@ -110,12 +125,8 @@ function ConnectionRow({ ) } - const documentCount = (connection.metadata?.documentCount as number) ?? 0 - const containerTags = ( - connection as Connection & { containerTags?: string[] } - ).containerTags - const projectName = containerTags?.[0] - ? getProjectName(containerTags[0]) + const projectName = connection.containerTags?.[0] + ? getProjectName(connection.containerTags[0]) : null return ( @@ -138,23 +149,11 @@ function ConnectionRow({ > {config.title} -
-
- - {isConnected ? "Connected" : "Disconnected"} - -
+
- +
+ + + +
@@ -186,17 +242,11 @@ function ConnectionRow({
)} -
- - - {formatRelativeTime(connection.createdAt)} - -
+ + Last synced: {formatRelativeTime(meta.lastSyncedAt)} +
- {documentCount} + {meta.documentCount}
+ + {historyOpen && ( +
+ +
+ )} ) @@ -236,6 +295,7 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { open: boolean connection: Connection | null }>({ open: false, connection: null }) + const triggerSync = useTriggerSync() const projects = (queryClient.getQueryData(["projects"]) || []) as Project[] @@ -282,7 +342,13 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { return response.data as Connection[] }, staleTime: 30 * 1000, - refetchInterval: 60 * 1000, + refetchInterval: (query) => { + const conns = query.state.data as Connection[] | undefined + if (conns?.some((c) => getConnectionMeta(c).syncInProgress)) { + return 5000 + } + return 60 * 1000 + }, refetchIntervalInBackground: true, }) @@ -644,6 +710,18 @@ export function ConnectContent({ selectedProject }: ConnectContentProps) { projects={projects} onDelete={() => setRemoveDialog({ open: true, connection })} isDeleting={deleteConnectionMutation.isPending} + onTriggerSync={() => + triggerSync.mutate({ + connectionId: connection.id, + provider: connection.provider as ImportProvider, + containerTags: connection.containerTags, + }) + } + isSyncing={ + (triggerSync.isPending && + triggerSync.variables?.connectionId === connection.id) || + getConnectionMeta(connection).syncInProgress + } /> ))} diff --git a/apps/web/components/settings/account.tsx b/apps/web/components/settings/account.tsx index a7ae2d63a..676e251a8 100644 --- a/apps/web/components/settings/account.tsx +++ b/apps/web/components/settings/account.tsx @@ -197,6 +197,8 @@ export default function Account() { } = useAuth() const autumn = useCustomer() const [isUpgrading, setIsUpgrading] = useState(false) + const [isCancelling, setIsCancelling] = useState(false) + const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false) const [emailConfirm, setEmailConfirm] = useState("") const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isClosingAccount, setIsClosingAccount] = useState(false) @@ -287,6 +289,33 @@ export default function Account() { } } + // Enterprise is contract-based — direct those users to the portal/sales. + const cancellablePlanId = + currentPlan === "pro" || currentPlan === "scale" + ? (`api_${currentPlan}` as const) + : null + + const handleCancelSubscription = async () => { + if (!cancellablePlanId) return + setIsCancelling(true) + try { + await autumn.updateSubscription({ + planId: cancellablePlanId, + cancelAction: "cancel_end_of_cycle", + }) + autumn.refetch?.() + setIsCancelDialogOpen(false) + toast.success( + `Subscription cancelled. ${planDisplayNames[currentPlan]} features remain active until the end of your billing period.`, + ) + } catch (error) { + console.error(error) + toast.error("Failed to cancel subscription. Please try again.") + } finally { + setIsCancelling(false) + } + } + const handleDeleteAccount = async () => { if (!user?.email || !emailMatches || membershipsPending) return setIsClosingAccount(true) @@ -546,25 +575,141 @@ export default function Account() {

- + {cancellablePlanId && ( + + + + + +
+
+
+

+ Cancel {planDisplayNames[currentPlan]}{" "} + subscription? +

+

+ You'll keep Pro features until the end of + your current billing period + {daysRemaining !== null + ? ` (${daysRemaining} day${daysRemaining !== 1 ? "s" : ""} remaining)` + : ""} + . After that, your account will switch to the + Free plan. +

+
+ + + +
+ +
+ + + + +
+
+
+ +
)} - > - - Manage billing -
- +
) : ( <> diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index 8c881498d..b2cbb389a 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -7,7 +7,16 @@ import { hasActivePlan } from "@lib/queries" import { GoogleDrive, Notion, OneDrive } from "@ui/assets/icons" import { useCustomer } from "autumn-js/react" import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" -import { Check, Plus, Trash2, Zap } from "lucide-react" +import { + Check, + ChevronDown, + History, + Loader2, + Play, + Plus, + Trash2, + Zap, +} from "lucide-react" import { useEffect, useState } from "react" import { toast } from "sonner" import { useQueryState } from "nuqs" @@ -20,9 +29,30 @@ import { RemoveConnectionDialog } from "@/components/remove-connection-dialog" import { addDocumentParam } from "@/lib/search-params" import { DEFAULT_PROJECT_ID } from "@lib/constants" import type { Project } from "@lib/types" +import { SyncStatusBadge } from "@/components/settings/sync-status-badge" +import { SyncHistoryPanel } from "@/components/settings/sync-history-panel" +import { useTriggerSync } from "@/hooks/use-trigger-sync" +import { formatRelativeTime } from "@/components/settings/sync-utils" +import type { ImportProvider } from "@/components/settings/sync-utils" type Connection = z.infer +/** Extract typed metadata from a connection, with runtime validation. */ +function getConnectionMeta(connection: Connection) { + const m = connection.metadata as Record | undefined + return { + syncInProgress: m?.syncInProgress === true, + lastSyncedAt: + typeof m?.lastSyncedAt === "number" ? m.lastSyncedAt : undefined, + documentCount: typeof m?.documentCount === "number" ? m.documentCount : 0, + } +} + +/** Check if a connection's auth token has expired. */ +function isConnectionExpired(connection: Connection): boolean { + return !!connection.expiresAt && new Date(connection.expiresAt) <= new Date() +} + const CONNECTORS = { "google-drive": { title: "Google Drive", @@ -128,64 +158,30 @@ function PillButton({ ) } -function ConnectionStatusBadge({ connected }: { connected: boolean }) { - return ( -
-
- - {connected ? "Connected" : "Disconnected"} - -
- ) -} - function ConnectionRow({ connection, onDelete, isDeleting, disabled, projects, + onTriggerSync, + isSyncing, }: { connection: Connection onDelete: () => void isDeleting: boolean disabled?: boolean projects: Project[] + onTriggerSync: () => void + isSyncing: boolean }) { + const [historyOpen, setHistoryOpen] = useState(false) const config = CONNECTORS[connection.provider as ConnectorProvider] if (!config) return null const Icon = config.icon - // Check if connection is active: if expiresAt exists and is in the future, or if no expiresAt - const isConnected = - !connection.expiresAt || new Date(connection.expiresAt) > new Date() - - // Format relative time - const formatRelativeTime = (date: string | null | undefined) => { - if (!date) return "Never" - const d = new Date(date) - const now = new Date() - const diffMs = now.getTime() - d.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - const diffDays = Math.floor(diffHours / 24) - - if (diffHours < 1) return "Just now" - if (diffHours < 24) return `${diffHours}h ago` - if (diffDays === 1) return "Yesterday" - if (diffDays < 7) return `${diffDays} days ago` - return d.toLocaleDateString() - } + const meta = getConnectionMeta(connection) + const expired = isConnectionExpired(connection) const getProjectDisplayName = (containerTag: string): string => { if (containerTag === DEFAULT_PROJECT_ID) return "Default Project" @@ -194,13 +190,11 @@ function ConnectionRow({ return containerTag.replace(/^sm_project_/, "") // if cached project is not found, remove the prefix } - const documentCount = (connection.metadata?.documentCount as number) ?? 0 - const containerTags = ( - connection as Connection & { containerTags?: string[] } - ).containerTags const projectName = - containerTags && containerTags.length > 0 && containerTags[0] - ? getProjectDisplayName(containerTags[0]) + connection.containerTags && + connection.containerTags.length > 0 && + connection.containerTags[0] + ? getProjectDisplayName(connection.containerTags[0]) : null return ( @@ -224,7 +218,11 @@ function ConnectionRow({ > {config.title} - +
- +
+ + + +
{/* Meta row */} @@ -267,7 +322,7 @@ function ConnectionRow({ "font-medium text-[14px] tracking-[-0.14px] text-[#737373]", )} > - Added: {formatRelativeTime(connection.createdAt)} + Last synced: {formatRelativeTime(meta.lastSyncedAt)}
- {documentCount} {config.documentLabel} connected + {meta.documentCount} {config.documentLabel} connected
+ + {historyOpen && ( +
+ +
+ )} ) @@ -342,6 +406,7 @@ export default function ConnectionsMCP() { open: boolean connection: Connection | null }>({ open: false, connection: null }) + const triggerSync = useTriggerSync() const projects = (queryClient.getQueryData(["projects"]) || []) as Project[] @@ -375,7 +440,13 @@ export default function ConnectionsMCP() { return response.data as Connection[] }, staleTime: 30 * 1000, - refetchInterval: 60 * 1000, + refetchInterval: (query) => { + const conns = query.state.data as Connection[] | undefined + if (conns?.some((c) => getConnectionMeta(c).syncInProgress)) { + return 5000 + } + return 60 * 1000 + }, enabled: hasProProduct, }) @@ -496,6 +567,19 @@ export default function ConnectionsMCP() { isDeleting={deleteConnectionMutation.isPending} disabled={!hasProProduct} projects={projects} + onTriggerSync={() => + triggerSync.mutate({ + connectionId: connection.id, + provider: connection.provider as ImportProvider, + containerTags: connection.containerTags, + }) + } + isSyncing={ + (triggerSync.isPending && + triggerSync.variables?.connectionId === + connection.id) || + getConnectionMeta(connection).syncInProgress + } /> )) ) : ( @@ -582,7 +666,9 @@ export default function ConnectionsMCP() { }} provider={removeDialog.connection?.provider} documentCount={ - (removeDialog.connection?.metadata?.documentCount as number) ?? 0 + removeDialog.connection + ? getConnectionMeta(removeDialog.connection).documentCount + : 0 } onConfirm={(deleteDocuments) => { if (removeDialog.connection) { diff --git a/apps/web/components/settings/sync-history-panel.tsx b/apps/web/components/settings/sync-history-panel.tsx new file mode 100644 index 000000000..04044acce --- /dev/null +++ b/apps/web/components/settings/sync-history-panel.tsx @@ -0,0 +1,267 @@ +"use client" + +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { useSyncRuns } from "@/hooks/use-sync-runs" +import type { SyncRun } from "@/hooks/use-sync-runs" +import { + formatRelativeTime, + TRIGGER_TYPE_LABELS, +} from "@/components/settings/sync-utils" + +const STATUS_COLORS: Record = { + completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" }, + failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" }, + running: { dot: "bg-[#4BA0FA] animate-pulse", text: "text-[#4BA0FA]" }, +} + +function pluralize(count: number, noun: string) { + return `${count} ${noun}${count === 1 ? "" : "s"}` +} + +/** Calendar-day bucket label for grouping runs in the timeline. */ +function dayLabel(date: string) { + const d = new Date(date) + if (Number.isNaN(d.getTime())) return "Unknown" + const today = new Date() + const startOfDay = (x: Date) => + new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime() + const diffDays = Math.round((startOfDay(today) - startOfDay(d)) / 86_400_000) + if (diffDays <= 0) return "Today" + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }) +} + +function StatTile({ value, label }: { value: string; label: string }) { + return ( +
+ + {value} + + + {label} + +
+ ) +} + +function SummaryStats({ runs }: { runs: SyncRun[] }) { + const totalItems = runs.reduce((sum, r) => sum + r.itemsProcessed, 0) + const finished = runs.filter((r) => r.status !== "running") + const succeeded = finished.filter((r) => r.status === "completed").length + const successRate = + finished.length > 0 ? Math.round((succeeded / finished.length) * 100) : null + + return ( +
+ + + +
+ ) +} + +function TimelineRow({ run, isLast }: { run: SyncRun; isLast: boolean }) { + const colors = STATUS_COLORS[run.status] ?? { + dot: "bg-[#4BA0FA] animate-pulse", + text: "text-[#4BA0FA]", + } + const triggerLabel = TRIGGER_TYPE_LABELS[run.triggerType] ?? run.triggerType + const statusLabel = run.status.charAt(0).toUpperCase() + run.status.slice(1) + + return ( +
+ {/* Timeline rail: dot + connecting line */} +
+
+ {!isLast &&
} +
+ + {/* Content */} +
+
+
+ + {statusLabel} + + + · {triggerLabel} + +
+ + {formatRelativeTime(run.startedAt)} + +
+ + {(run.itemsProcessed > 0 || run.itemsFailed > 0) && ( +
+ {pluralize(run.itemsProcessed, "item")} processed + {run.itemsFailed > 0 && ( + + {" "} + · {run.itemsFailed} failed + + )} +
+ )} + + {run.error && ( +

+ {run.error} +

+ )} +
+
+ ) +} + +function Timeline({ runs }: { runs: SyncRun[] }) { + // Group consecutive runs by calendar-day label, preserving server order. + const groups: { label: string; runs: SyncRun[] }[] = [] + for (const run of runs) { + const label = dayLabel(run.startedAt) + const last = groups.at(-1) + if (last && last.label === label) { + last.runs.push(run) + } else { + groups.push({ label, runs: [run] }) + } + } + + return ( +
+ {groups.map((group, gi) => ( +
+ + {group.label} + +
+ {group.runs.map((run, i) => ( + + ))} +
+
+ ))} +
+ ) +} + +interface SyncHistoryPanelProps { + connectionId: string + /** Only fetch / render when expanded. */ + isOpen: boolean +} + +/** Inline sync-history view (stats strip + timeline) rendered inside an expanded connection row. */ +export function SyncHistoryPanel({ + connectionId, + isOpen, +}: SyncHistoryPanelProps) { + const { + data: syncRuns, + isLoading, + error, + refetch, + } = useSyncRuns(isOpen ? connectionId : "") + + if (!isOpen) return null + + const hasRuns = !isLoading && !error && syncRuns && syncRuns.length > 0 + + return ( +
+ {isLoading && ( +
+
+
+ )} + + {error && !isLoading && ( +
+ + Failed to load sync history + + +
+ )} + + {!isLoading && !error && syncRuns && syncRuns.length === 0 && ( +
+ + No syncs yet — runs will appear here. + +
+ )} + + {hasRuns && ( + <> + + + + )} +
+ ) +} diff --git a/apps/web/components/settings/sync-status-badge.tsx b/apps/web/components/settings/sync-status-badge.tsx new file mode 100644 index 000000000..c4d64aa50 --- /dev/null +++ b/apps/web/components/settings/sync-status-badge.tsx @@ -0,0 +1,95 @@ +import { cn } from "@lib/utils" +import { dmSans125ClassName } from "@/lib/fonts" +import { formatRelativeTime } from "@/components/settings/sync-utils" + +function deriveStatus( + syncInProgress?: boolean, + lastSyncedAt?: number, + isExpired?: boolean, +): "syncing" | "synced" | "expired" | "idle" { + if (isExpired) return "expired" + if (syncInProgress) return "syncing" + if (lastSyncedAt) return "synced" + return "idle" +} + +interface SyncStatusBadgeProps { + syncInProgress?: boolean + lastSyncedAt?: number + isExpired?: boolean + className?: string +} + +export function SyncStatusBadge({ + syncInProgress, + lastSyncedAt, + isExpired, + className, +}: SyncStatusBadgeProps) { + const status = deriveStatus(syncInProgress, lastSyncedAt, isExpired) + + return ( +
+
+ {status === "syncing" && ( + + Syncing... + + )} + {status === "synced" && ( + <> + + Synced + +
+ + {formatRelativeTime(lastSyncedAt)} + + + )} + {status === "expired" && ( + + Disconnected + + )} + {status === "idle" && ( + + Waiting for first sync + + )} +
+ ) +} diff --git a/apps/web/components/settings/sync-utils.ts b/apps/web/components/settings/sync-utils.ts new file mode 100644 index 000000000..25a683ba8 --- /dev/null +++ b/apps/web/components/settings/sync-utils.ts @@ -0,0 +1,55 @@ +/** + * Format a date/timestamp into a human-readable relative time string. + * Accepts ISO string, epoch milliseconds (number), Date object, or null/undefined. + */ +export function formatRelativeTime( + date: string | number | Date | null | undefined, +): string { + if (!date) return "Never" + const d = typeof date === "number" ? new Date(date) : new Date(date) + if (Number.isNaN(d.getTime())) return "Never" + const now = new Date() + const diffMs = now.getTime() - d.getTime() + + // Handle future dates (e.g. slight server clock skew) + if (diffMs < 0) return "Just now" + + const diffMinutes = Math.floor(diffMs / (1000 * 60)) + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) + const diffDays = Math.floor(diffHours / 24) + + if (diffMinutes < 1) return "Just now" + if (diffMinutes < 60) return `${diffMinutes}m ago` + if (diffHours < 24) return `${diffHours}h ago` + if (diffDays === 1) return "Yesterday" + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString() +} + +/** Map backend trigger type enum to user-facing display label */ +export const TRIGGER_TYPE_LABELS: Record = { + event: "Webhook", + cron: "Scheduled", + manual: "Manual", +} + +/** Canonical provider → display name map. Import this everywhere instead of duplicating. */ +export const PROVIDER_DISPLAY_NAMES: Record = { + "google-drive": "Google Drive", + notion: "Notion", + onedrive: "OneDrive", + gmail: "Gmail", + github: "GitHub", + "web-crawler": "Web Crawler", + s3: "S3", +} + +/** Provider type union matching the backend import endpoint */ +export type ImportProvider = + | "google-drive" + | "notion" + | "onedrive" + | "gmail" + | "github" + | "web-crawler" + | "s3" diff --git a/apps/web/hooks/use-sync-runs.ts b/apps/web/hooks/use-sync-runs.ts new file mode 100644 index 000000000..664587abc --- /dev/null +++ b/apps/web/hooks/use-sync-runs.ts @@ -0,0 +1,46 @@ +"use client" + +import { $fetch } from "@lib/api" +import { useQuery } from "@tanstack/react-query" + +/** + * Mirrors the Zod schema at `apiSchema["@get/connections/:connectionId/sync-runs"].output`. + * Keep in sync with `packages/lib/api.ts` if fields are added/removed. + */ +export type SyncRun = { + id: string + connectionId: string + status: "running" | "completed" | "failed" + triggerType: "event" | "cron" | "manual" + startedAt: string + completedAt: string | null + itemsProcessed: number + itemsFailed: number + error: string | null +} + +export function useSyncRuns(connectionId: string) { + return useQuery({ + queryKey: ["sync-runs", connectionId], + queryFn: async () => { + const response = await $fetch( + "@get/connections/:connectionId/sync-runs", + { params: { connectionId } }, + ) + if (response.error) { + throw new Error("Failed to fetch sync runs") + } + return response.data as SyncRun[] + }, + enabled: !!connectionId, + staleTime: 30 * 1000, + refetchOnMount: "always", + refetchInterval: (query) => { + const runs = query.state.data as SyncRun[] | undefined + if (runs?.some((r) => r.status === "running")) { + return 5000 + } + return false + }, + }) +} diff --git a/apps/web/hooks/use-trigger-sync.ts b/apps/web/hooks/use-trigger-sync.ts new file mode 100644 index 000000000..5df0b8f02 --- /dev/null +++ b/apps/web/hooks/use-trigger-sync.ts @@ -0,0 +1,97 @@ +"use client" + +import { $fetch } from "@lib/api" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import type { ConnectionResponseSchema } from "@repo/validation/api" +import type { z } from "zod" +import type { ImportProvider } from "@/components/settings/sync-utils" +import type { SyncRun } from "@/hooks/use-sync-runs" + +type Connection = z.infer + +export function useTriggerSync() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + provider, + containerTags, + }: { + // connectionId isn't sent to the backend (import is keyed by provider) — kept for cache updates + connectionId: string + provider: ImportProvider + containerTags?: string[] + }) => { + const response = await $fetch("@post/connections/:provider/import", { + params: { provider }, + body: { containerTags }, + }) + if (response.error) { + throw new Error( + (response.error as { message?: string })?.message || + "Failed to trigger sync", + ) + } + return response.data + }, + // Optimistically flip to "syncing" so the badge/button update instantly; the 5s poll then converges on real state + onMutate: async (variables) => { + await queryClient.cancelQueries({ queryKey: ["connections"] }) + const previousConnections = queryClient.getQueryData([ + "connections", + ]) + queryClient.setQueryData(["connections"], (old) => + old?.map((c) => + c.provider === variables.provider + ? { + ...c, + metadata: { + ...((c.metadata as Record | null) ?? {}), + syncInProgress: true, + }, + } + : c, + ), + ) + + const syncRunsKey = ["sync-runs", variables.connectionId] + const previousSyncRuns = queryClient.getQueryData(syncRunsKey) + if (previousSyncRuns) { + const optimisticRun: SyncRun = { + id: `optimistic-${Date.now()}`, + connectionId: variables.connectionId, + status: "running", + triggerType: "manual", + startedAt: new Date().toISOString(), + completedAt: null, + itemsProcessed: 0, + itemsFailed: 0, + error: null, + } + queryClient.setQueryData(syncRunsKey, [ + optimisticRun, + ...previousSyncRuns, + ]) + } + + return { previousConnections, previousSyncRuns, syncRunsKey } + }, + // Don't invalidate connections/sync-runs here — an immediate refetch races the backend and clobbers the optimistic state; the 5s polls handle it + onSuccess: () => { + toast.success("Sync started") + queryClient.invalidateQueries({ queryKey: ["processing-documents"] }) + }, + onError: (error, _variables, context) => { + if (context?.previousConnections !== undefined) { + queryClient.setQueryData(["connections"], context.previousConnections) + } + if (context?.previousSyncRuns !== undefined) { + queryClient.setQueryData(context.syncRunsKey, context.previousSyncRuns) + } + toast.error("Failed to start sync", { + description: error instanceof Error ? error.message : "Unknown error", + }) + }, + }) +} diff --git a/bun.lock b/bun.lock index bda09023e..f1d890ca0 100644 --- a/bun.lock +++ b/bun.lock @@ -295,7 +295,7 @@ }, "packages/memory-graph": { "name": "@supermemory/memory-graph", - "version": "0.2.0", + "version": "0.2.1", "dependencies": { "d3-force": "^3.0.0", }, diff --git a/packages/lib/api.ts b/packages/lib/api.ts index f2d13b474..721f818f6 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -125,6 +125,41 @@ export const apiSchema = createSchema({ }), }, + "@get/connections/:connectionId/sync-runs": { + output: z.array( + z.object({ + id: z.string(), + connectionId: z.string(), + status: z.enum(["running", "completed", "failed"]), + triggerType: z.enum(["event", "cron", "manual"]), + startedAt: z.string(), + completedAt: z.string().nullable(), + itemsProcessed: z.number(), + itemsFailed: z.number(), + error: z.string().nullable(), + }), + ), + params: z.object({ connectionId: z.string() }), + }, + + "@post/connections/:provider/import": { + input: z.object({ + containerTags: z.array(z.string()).optional(), + }), + output: z.unknown(), + params: z.object({ + provider: z.enum([ + "google-drive", + "notion", + "onedrive", + "gmail", + "github", + "web-crawler", + "s3", + ]), + }), + }, + // Settings operations "@get/settings": { output: z.object({ settings: z.object({}).passthrough() }),