diff --git a/apps/web/components/settings/connections-mcp.tsx b/apps/web/components/settings/connections-mcp.tsx index c55ee1f6..cb8ebe14 100644 --- a/apps/web/components/settings/connections-mcp.tsx +++ b/apps/web/components/settings/connections-mcp.tsx @@ -28,6 +28,22 @@ 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", @@ -156,6 +172,8 @@ function ConnectionRow({ if (!config) return null const Icon = config.icon + const meta = getConnectionMeta(connection) + const expired = isConnectionExpired(connection) const getProjectDisplayName = (containerTag: string): string => { if (containerTag === DEFAULT_PROJECT_ID) return "Default Project" @@ -164,13 +182,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 ( @@ -195,14 +211,9 @@ function ConnectionRow({ {config.title} ) - ?.syncInProgress as boolean | undefined - } - lastSyncedAt={ - (connection.metadata as Record) - ?.lastSyncedAt as number | undefined - } + syncInProgress={meta.syncInProgress} + lastSyncedAt={meta.lastSyncedAt} + isExpired={expired} /> {isSyncing ? ( @@ -279,12 +302,7 @@ function ConnectionRow({ "font-medium text-[14px] tracking-[-0.14px] text-[#737373]", )} > - Last synced:{" "} - {formatRelativeTime( - (connection.metadata as Record)?.lastSyncedAt as - | number - | undefined, - )} + Last synced: {formatRelativeTime(meta.lastSyncedAt)}
- {documentCount} {config.documentLabel} connected + {meta.documentCount} {config.documentLabel} connected
@@ -399,14 +417,10 @@ export default function ConnectionsMCP() { staleTime: 30 * 1000, refetchInterval: (query) => { const conns = query.state.data as Connection[] | undefined - if ( - conns?.some( - (c) => (c.metadata as Record)?.syncInProgress, - ) - ) { + if (conns?.some((c) => getConnectionMeta(c).syncInProgress)) { return 5000 } - return false + return 60 * 1000 }, enabled: hasProProduct, }) @@ -532,19 +546,14 @@ export default function ConnectionsMCP() { triggerSync.mutate({ connectionId: connection.id, provider: connection.provider as ImportProvider, - containerTags: ( - connection as Connection & { - containerTags?: string[] - } - ).containerTags, + containerTags: connection.containerTags, }) } isSyncing={ (triggerSync.isPending && triggerSync.variables?.connectionId === connection.id) || - !!(connection.metadata as Record) - ?.syncInProgress + getConnectionMeta(connection).syncInProgress } onViewHistory={() => setSyncHistorySheet({ open: true, connection }) @@ -635,7 +644,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-sheet.tsx b/apps/web/components/settings/sync-history-sheet.tsx index 25c015f7..ac706c2e 100644 --- a/apps/web/components/settings/sync-history-sheet.tsx +++ b/apps/web/components/settings/sync-history-sheet.tsx @@ -14,22 +14,13 @@ import type { SyncRun } from "@/hooks/use-sync-runs" import { formatRelativeTime, TRIGGER_TYPE_LABELS, + PROVIDER_DISPLAY_NAMES, } from "@/components/settings/sync-utils" import type { ConnectionResponseSchema } from "@repo/validation/api" import type { z } from "zod" type Connection = z.infer -const PROVIDER_TITLES: Record = { - "google-drive": "Google Drive", - notion: "Notion", - onedrive: "OneDrive", - gmail: "Gmail", - github: "GitHub", - "web-crawler": "Web Crawler", - s3: "S3", -} - const STATUS_COLORS: Record = { completed: { dot: "bg-[#00AC3F]", text: "text-[#00AC3F]" }, failed: { dot: "bg-[#EF4444]", text: "text-[#EF4444]" }, @@ -122,7 +113,7 @@ export function SyncHistorySheet({ } = useSyncRuns(open && connection ? connection.id : "") const providerTitle = connection - ? (PROVIDER_TITLES[connection.provider] ?? connection.provider) + ? (PROVIDER_DISPLAY_NAMES[connection.provider] ?? connection.provider) : "" const email = connection?.email ?? "" @@ -130,7 +121,7 @@ export function SyncHistorySheet({ @@ -33,6 +35,7 @@ export function SyncStatusBadge({ "size-[7px] rounded-full", status === "syncing" && "bg-[#4BA0FA] animate-pulse", status === "synced" && "bg-[#00AC3F]", + status === "expired" && "bg-[#EF4444]", status === "idle" && "bg-[#737373]", )} /> @@ -67,6 +70,16 @@ export function SyncStatusBadge({ )} + {status === "expired" && ( + + Disconnected + + )} {status === "idle" && ( = { 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" diff --git a/apps/web/hooks/use-sync-runs.ts b/apps/web/hooks/use-sync-runs.ts index 9c4469d0..664587ab 100644 --- a/apps/web/hooks/use-sync-runs.ts +++ b/apps/web/hooks/use-sync-runs.ts @@ -3,6 +3,10 @@ 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 @@ -31,5 +35,12 @@ export function useSyncRuns(connectionId: string) { 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 index 149f4a2b..f7f701e9 100644 --- a/apps/web/hooks/use-trigger-sync.ts +++ b/apps/web/hooks/use-trigger-sync.ts @@ -13,6 +13,9 @@ export function useTriggerSync() { provider, containerTags, }: { + // connectionId is not sent to the backend — the import endpoint is keyed + // by provider, so it re-syncs all connections for that provider. + // It's kept here so onSuccess can target cache invalidation. connectionId: string provider: ImportProvider containerTags?: string[] diff --git a/packages/lib/api.ts b/packages/lib/api.ts index 5f2b9e3f..721f818f 100644 --- a/packages/lib/api.ts +++ b/packages/lib/api.ts @@ -146,7 +146,7 @@ export const apiSchema = createSchema({ input: z.object({ containerTags: z.array(z.string()).optional(), }), - output: z.any(), + output: z.unknown(), params: z.object({ provider: z.enum([ "google-drive",