-
+
-
+
Filter
handleSort(sortKey ?? 'workflowName')}
className='flex cursor-default items-center rounded-md px-2 py-1 text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-3)]'
>
-
+
Sort
@@ -266,7 +266,7 @@ export function LandingPreviewLogs() {
)}
>
{label}
- {sortKey === key &&
}
+ {sortKey === key &&
}
))}
@@ -281,7 +281,7 @@ export function LandingPreviewLogs() {
-
-
+
+
-
@@ -205,7 +205,7 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel({
Deploy
@@ -228,7 +228,7 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel({
Get started
-
+
,
document.body
@@ -290,7 +290,7 @@ export const LandingPreviewPanel = memo(function LandingPreviewPanel({
-
+
{BlockIcon && (
-
+
)}
@@ -405,7 +405,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
System Prompt
-
+
{visibleText}
{isTyping && (
@@ -430,7 +430,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
Model
- {ModelIcon && }
+ {ModelIcon && }
{model}
@@ -453,10 +453,10 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
>
{ToolIcon && (
-
+
)}
{tool.name}
@@ -476,7 +476,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
@@ -487,7 +487,7 @@ function EditorTabContent({ editorPrompt, typedLength }: EditorTabContentProps)
Response Format
-
diff --git a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx
index 74a029521f1..e79f5e63be8 100644
--- a/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx
+++ b/apps/sim/app/(landing)/components/landing-preview/components/landing-preview-sidebar/landing-preview-sidebar.tsx
@@ -169,7 +169,7 @@ export function LandingPreviewSidebar({
{/* Workspace */}
-
@@ -191,7 +191,7 @@ export function LandingPreviewSidebar({
{/* Workflows */}
-
diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/integration-faq.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/integration-faq.tsx
index 6c71bc3e8e5..339b3cc26e1 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/integration-faq.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/components/integration-faq.tsx
@@ -1,5 +1,5 @@
+import type { FAQItem } from '@/lib/integrations'
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
-import type { FAQItem } from '@/app/(landing)/integrations/data/types'
interface IntegrationFAQProps {
faqs: FAQItem[]
diff --git a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
index ce221e55df0..86f08e1fe9b 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/[slug]/page.tsx
@@ -3,16 +3,20 @@ import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { SITE_URL } from '@/lib/core/utils/urls'
+import {
+ type AuthType,
+ blockTypeToIconMap,
+ type FAQItem,
+ INTEGRATIONS,
+ type Integration,
+} from '@/lib/integrations'
import { IntegrationCtaButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-cta-button'
import { IntegrationFAQ } from '@/app/(landing)/integrations/(shell)/[slug]/components/integration-faq'
import { TemplateCardButton } from '@/app/(landing)/integrations/(shell)/[slug]/components/template-card-button'
import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
-import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
-import integrations from '@/app/(landing)/integrations/data/integrations.json'
-import type { AuthType, FAQItem, Integration } from '@/app/(landing)/integrations/data/types'
-import { TEMPLATES } from '@/app/workspace/[workspaceId]/home/components/template-prompts/consts'
+import { getTemplatesForBlock } from '@/blocks/registry'
-const allIntegrations = integrations as Integration[]
+const allIntegrations = INTEGRATIONS
const INTEGRATION_COUNT = allIntegrations.length
const baseUrl = SITE_URL
@@ -225,12 +229,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
const relatedIntegrations = relatedSlugs
.map((s) => bySlug.get(s))
.filter((i): i is Integration => i !== undefined)
- const baseType = integration.type.replace(/_v\d+$/, '')
- const matchingTemplates = TEMPLATES.filter(
- (t) =>
- t.integrationBlockTypes.includes(integration.type) ||
- t.integrationBlockTypes.includes(baseType)
- )
+ const matchingTemplates = getTemplatesForBlock(integration.type)
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
@@ -650,7 +649,7 @@ export default async function IntegrationPage({ params }: { params: Promise<{ sl
const resolveTypes = (template: (typeof matchingTemplates)[number]) => [
integration.type,
- ...template.integrationBlockTypes.filter((bt) => bt !== integration.type),
+ ...template.otherBlockTypes,
]
const renderIcons = (allTypes: string[]) =>
diff --git a/apps/sim/app/(landing)/integrations/(shell)/page.tsx b/apps/sim/app/(landing)/integrations/(shell)/page.tsx
index 5d25f6a335a..486ce937c98 100644
--- a/apps/sim/app/(landing)/integrations/(shell)/page.tsx
+++ b/apps/sim/app/(landing)/integrations/(shell)/page.tsx
@@ -1,15 +1,17 @@
import type { Metadata } from 'next'
import { Badge } from '@/components/emcn'
import { SITE_URL } from '@/lib/core/utils/urls'
+import {
+ blockTypeToIconMap,
+ INTEGRATIONS,
+ type Integration,
+ POPULAR_WORKFLOWS,
+} from '@/lib/integrations'
import { IntegrationCard } from '@/app/(landing)/integrations/components/integration-card'
import { IntegrationGrid } from '@/app/(landing)/integrations/components/integration-grid'
import { RequestIntegrationModal } from '@/app/(landing)/integrations/components/request-integration-modal'
-import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
-import integrations from '@/app/(landing)/integrations/data/integrations.json'
-import { POPULAR_WORKFLOWS } from '@/app/(landing)/integrations/data/popular-workflows'
-import type { Integration } from '@/app/(landing)/integrations/data/types'
-const allIntegrations = integrations as Integration[]
+const allIntegrations = INTEGRATIONS
const INTEGRATION_COUNT = allIntegrations.length
/**
diff --git a/apps/sim/app/(landing)/integrations/components/integration-card.tsx b/apps/sim/app/(landing)/integrations/components/integration-card.tsx
index ef067006eec..a845fa2ce8a 100644
--- a/apps/sim/app/(landing)/integrations/components/integration-card.tsx
+++ b/apps/sim/app/(landing)/integrations/components/integration-card.tsx
@@ -1,10 +1,12 @@
import type { ComponentType, SVGProps } from 'react'
import Link from 'next/link'
-import type { Integration } from '@/app/(landing)/integrations/data/types'
+import type { Integration } from '@/lib/integrations'
+import { IntegrationIcon } from '@/app/(landing)/integrations/components/integration-icon'
import { ChevronArrow } from '@/app/(landing)/models/components/model-primitives'
-import { IntegrationIcon } from './integration-icon'
-interface IntegrationCardProps {
+const HOVER_BG = 'transition-colors hover:bg-[var(--landing-bg-elevated)]' as const
+
+interface IntegrationItemProps {
integration: Integration
IconComponent?: ComponentType
>
}
@@ -13,13 +15,13 @@ interface IntegrationCardProps {
* Featured integration card — matches blog featured post pattern.
* Used in flex rows separated by border-l dividers.
*/
-export function IntegrationCard({ integration, IconComponent }: IntegrationCardProps) {
+export function IntegrationCard({ integration, IconComponent }: IntegrationItemProps) {
const { slug, name, description, bgColor } = integration
return (
>
-}
-
/**
* Integration list row — matches blog remaining post pattern.
* Each row followed by an h-px divider.
*/
-export function IntegrationRow({ integration, IconComponent }: IntegrationRowProps) {
+export function IntegrationRow({ integration, IconComponent }: IntegrationItemProps) {
const { slug, name, description, bgColor } = integration
return (
<>
- {/* Name + description */}
{name}
@@ -75,7 +71,6 @@ export function IntegrationRow({ integration, IconComponent }: IntegrationRowPro
- {/* Animated arrow */}
diff --git a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
index aad53904070..29f03e7290a 100644
--- a/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
+++ b/apps/sim/app/(landing)/integrations/components/integration-grid.tsx
@@ -1,76 +1,46 @@
'use client'
-import { useMemo, useState } from 'react'
+import { useState } from 'react'
import { Input } from '@/components/emcn'
-import { blockTypeToIconMap } from '@/app/(landing)/integrations/data/icon-mapping'
-import type { Integration } from '@/app/(landing)/integrations/data/types'
-import { IntegrationRow } from './integration-card'
+import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
+import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
-const CATEGORY_LABELS: Record = {
- ai: 'AI',
- analytics: 'Analytics',
- communication: 'Communication',
- crm: 'CRM',
- 'customer-support': 'Customer Support',
- databases: 'Databases',
- design: 'Design',
- 'developer-tools': 'Developer Tools',
- documents: 'Documents',
- ecommerce: 'E-commerce',
- email: 'Email',
- 'file-storage': 'File Storage',
- hr: 'HR',
- productivity: 'Productivity',
- sales: 'Sales',
- search: 'Search',
- security: 'Security',
- other: 'Other',
-} as const
+const PILL_BASE =
+ 'rounded-[5px] border border-[var(--landing-border-strong)] px-[9px] py-0.5 text-[13.5px] text-[var(--landing-text)] transition-colors' as const
+const PILL_ACTIVE = 'bg-[var(--landing-bg-elevated)]' as const
+const PILL_INACTIVE = 'hover:bg-[var(--landing-bg-elevated)]' as const
interface IntegrationGridProps {
- integrations: Integration[]
+ integrations: readonly Integration[]
}
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
const [query, setQuery] = useState('')
const [activeCategory, setActiveCategory] = useState(null)
- const availableCategories = useMemo(() => {
- const counts = new Map()
- for (const i of integrations) {
- if (i.integrationTypes) {
- for (const t of i.integrationTypes) {
- counts.set(t, (counts.get(t) || 0) + 1)
- }
- }
+ const counts = new Map()
+ for (const i of integrations) {
+ if (i.integrationType) {
+ counts.set(i.integrationType, (counts.get(i.integrationType) || 0) + 1)
}
- return Array.from(counts.entries())
- .sort((a, b) => b[1] - a[1])
- .map(([key]) => key)
- }, [integrations])
+ }
+ const availableCategories = Array.from(counts.entries())
+ .sort((a, b) => b[1] - a[1])
+ .map(([key]) => key)
- const filtered = useMemo(() => {
- let results = integrations
-
- if (activeCategory) {
- results = results.filter((i) => i.integrationTypes?.includes(activeCategory))
- }
-
- const q = query.trim().toLowerCase()
- if (q) {
- results = results.filter(
- (i) =>
- i.name.toLowerCase().includes(q) ||
- i.description.toLowerCase().includes(q) ||
- i.operations.some(
- (op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
- ) ||
- i.triggers.some((t) => t.name.toLowerCase().includes(q))
- )
- }
-
- return results
- }, [integrations, query, activeCategory])
+ const q = query.trim().toLowerCase()
+ const filtered = integrations.filter((i) => {
+ if (activeCategory && i.integrationType !== activeCategory) return false
+ if (!q) return true
+ return (
+ i.name.toLowerCase().includes(q) ||
+ i.description.toLowerCase().includes(q) ||
+ i.operations.some(
+ (op) => op.name.toLowerCase().includes(q) || op.description.toLowerCase().includes(q)
+ ) ||
+ i.triggers.some((t) => t.name.toLowerCase().includes(q))
+ )
+ })
return (
@@ -132,7 +95,7 @@ export function IntegrationGrid({ integrations }: IntegrationGridProps) {
No integrations found
{query ? <> for “{query}”> : null}
- {activeCategory ? <> in {CATEGORY_LABELS[activeCategory] || activeCategory}> : null}
+ {activeCategory ? <> in {formatIntegrationType(activeCategory)}> : null}
) : (
diff --git a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx
index e5fa0d5ca9f..4ef55f6d70f 100644
--- a/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx
+++ b/apps/sim/app/(landing)/integrations/components/request-integration-modal.tsx
@@ -2,16 +2,13 @@
import { useCallback, useState } from 'react'
import {
- Button,
- Input,
- Label,
- Modal,
- ModalBody,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
- Textarea,
+ Chip,
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
} from '@/components/emcn'
import { requestJson } from '@/lib/api/client/request'
import { integrationRequestContract } from '@/lib/api/contracts/common'
@@ -41,30 +38,26 @@ export function RequestIntegrationModal() {
[resetForm]
)
- const handleSubmit = useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault()
- if (!integrationName.trim() || !email.trim()) return
+ const handleSubmit = useCallback(async () => {
+ if (!integrationName.trim() || !email.trim()) return
- setStatus('submitting')
+ setStatus('submitting')
- try {
- await requestJson(integrationRequestContract, {
- body: {
- integrationName: integrationName.trim(),
- email: email.trim(),
- useCase: useCase.trim() || undefined,
- },
- })
+ try {
+ await requestJson(integrationRequestContract, {
+ body: {
+ integrationName: integrationName.trim(),
+ email: email.trim(),
+ useCase: useCase.trim() || undefined,
+ },
+ })
- setStatus('success')
- setTimeout(() => setOpen(false), 1500)
- } catch {
- setStatus('error')
- }
- },
- [integrationName, email, useCase]
- )
+ setStatus('success')
+ setTimeout(() => setOpen(false), 1500)
+ } catch {
+ setStatus('error')
+ }
+ }, [integrationName, email, useCase])
const canSubmit = integrationName.trim() && email.trim() && status === 'idle'
@@ -78,108 +71,100 @@ export function RequestIntegrationModal() {
Request an integration
-
-
- Request an Integration
+
+ handleOpenChange(false)}>
+ Request an Integration
+
+
{status === 'success' ? (
-
-
- Integration request submitted successfully
-
-
-
-
- Request submitted. We'll follow up at{' '}
- {email} .
-
+
+
-
+
+ Request submitted. We'll follow up at{' '}
+ {email} .
+
+
) : (
-
+
+ {status === 'success' ? (
+ handleOpenChange(false)}>
+ Done
+
+ ) : (
+ <>
+ setOpen(false)}
+ disabled={status === 'submitting'}
+ >
+ Cancel
+
+
+ {status === 'submitting' ? 'Submitting...' : 'Submit request'}
+
+ >
)}
-
-
+
+
>
)
}
diff --git a/apps/sim/app/(landing)/integrations/data/popular-workflows.ts b/apps/sim/app/(landing)/integrations/data/popular-workflows.ts
deleted file mode 100644
index 7e21789822d..00000000000
--- a/apps/sim/app/(landing)/integrations/data/popular-workflows.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-/**
- * Curated popular workflow pairs used on both the /integrations listing page
- * and individual /integrations/[slug] pages.
- *
- * Each pair targets specific long-tail search queries like "notion to slack automation".
- * The headline and description are written to be both human-readable and keyword-rich.
- */
-
-export interface WorkflowPair {
- /** Integration name (must match `name` field in integrations.json) */
- from: string
- /** Integration name (must match `name` field in integrations.json) */
- to: string
- headline: string
- description: string
-}
-
-export const POPULAR_WORKFLOWS: WorkflowPair[] = [
- {
- from: 'Slack',
- to: 'Notion',
- headline: 'Archive Slack conversations to Notion',
- description:
- 'Capture important Slack messages as Notion pages or database entries — ideal for meeting notes, decision logs, and knowledge bases.',
- },
- {
- from: 'Notion',
- to: 'Slack',
- headline: 'Notify your team from Notion',
- description:
- 'Post Slack messages automatically when Notion pages are created or updated so the whole team stays aligned without manual check-ins.',
- },
- {
- from: 'GitHub',
- to: 'Jira',
- headline: 'Link GitHub pull requests to Jira tickets',
- description:
- 'Transition Jira issues when PRs are opened or merged, keeping your project board accurate without any manual updates.',
- },
- {
- from: 'GitHub',
- to: 'Linear',
- headline: 'Sync GitHub events with Linear issues',
- description:
- 'Create Linear issues from GitHub activity, update status on merge, and keep your engineering workflow tightly connected.',
- },
- {
- from: 'Gmail',
- to: 'Notion',
- headline: 'Save incoming emails to Notion databases',
- description:
- 'Extract structured data from Gmail and store it in Notion — ideal for lead capture, support tickets, and meeting scheduling.',
- },
- {
- from: 'HubSpot',
- to: 'Slack',
- headline: 'Get HubSpot deal alerts in Slack',
- description:
- 'Receive instant Slack notifications when HubSpot deals advance, contacts are created, or revenue milestones are hit.',
- },
- {
- from: 'Google Sheets',
- to: 'Slack',
- headline: 'Send Slack messages from Google Sheets',
- description:
- 'Watch a spreadsheet for new rows or changes, then post formatted Slack updates to keep stakeholders informed in real time.',
- },
- {
- from: 'Salesforce',
- to: 'Slack',
- headline: 'Push Salesforce pipeline updates to Slack',
- description:
- 'Alert your sales team in Slack when Salesforce opportunities advance, close, or need immediate attention.',
- },
- {
- from: 'Airtable',
- to: 'Gmail',
- headline: 'Trigger Gmail from Airtable records',
- description:
- 'Send personalised Gmail messages when Airtable records are created or updated — great for onboarding flows and follow-up sequences.',
- },
- {
- from: 'Linear',
- to: 'Slack',
- headline: 'Linear issue updates in Slack',
- description:
- 'Post Slack messages when Linear issues are created, assigned, or completed so your team is always in the loop.',
- },
- {
- from: 'Jira',
- to: 'Confluence',
- headline: 'Auto-generate Confluence pages from Jira sprints',
- description:
- 'Create Confluence documentation from Jira sprint data automatically, eliminating manual reporting at the end of every sprint.',
- },
- {
- from: 'Google Sheets',
- to: 'Notion',
- headline: 'Sync Google Sheets data into Notion',
- description:
- 'Transform spreadsheet rows into structured Notion database entries for richer documentation and cross-team project tracking.',
- },
- {
- from: 'GitHub',
- to: 'Slack',
- headline: 'Get GitHub activity alerts in Slack',
- description:
- 'Post Slack notifications for new PRs, commits, issues, or deployments so your engineering team never misses a critical event.',
- },
- {
- from: 'HubSpot',
- to: 'Gmail',
- headline: 'Send personalised emails from HubSpot events',
- description:
- 'Trigger Gmail messages when HubSpot contacts enter a lifecycle stage, ensuring timely and relevant outreach without manual effort.',
- },
-]
diff --git a/apps/sim/app/_shell/providers/session-provider.tsx b/apps/sim/app/_shell/providers/session-provider.tsx
index a56af648f2a..b19ee64788a 100644
--- a/apps/sim/app/_shell/providers/session-provider.tsx
+++ b/apps/sim/app/_shell/providers/session-provider.tsx
@@ -5,7 +5,7 @@ import { createContext, useCallback, useEffect, useMemo, useState } from 'react'
import { createLogger } from '@sim/logger'
import { useQueryClient } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
-import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile'
+import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
import { client } from '@/lib/auth/auth-client'
import { extractSessionDataFromAuthClientResult } from '@/lib/auth/session-response'
diff --git a/apps/sim/app/_shell/providers/theme-provider.tsx b/apps/sim/app/_shell/providers/theme-provider.tsx
index e64ec9232c0..955f5067525 100644
--- a/apps/sim/app/_shell/providers/theme-provider.tsx
+++ b/apps/sim/app/_shell/providers/theme-provider.tsx
@@ -22,7 +22,6 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
pathname.startsWith('/chat') ||
pathname.startsWith('/blog') ||
pathname.startsWith('/resume') ||
- pathname.startsWith('/form') ||
pathname.startsWith('/oauth')
const isDarkModePage = pathname.startsWith('/academy')
diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css
index a80dc572e4d..ab696279ffe 100644
--- a/apps/sim/app/_styles/globals.css
+++ b/apps/sim/app/_styles/globals.css
@@ -12,7 +12,6 @@
:root {
--sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */
--panel-width: 320px; /* PANEL_WIDTH.DEFAULT */
- --toolbar-triggers-height: 300px; /* TOOLBAR_TRIGGERS_HEIGHT.DEFAULT */
--editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */
--terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */
--auth-primary-btn-bg: #ffffff;
@@ -119,6 +118,14 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
transition: none;
}
+/* Suppress width/transform transitions on the chrome wrappers during a
+ drag-resize so the outer overflow-hidden clip doesn't lag behind the inner
+ sidebar content, which is already at the correct width instantly. */
+html.sidebar-resizing .sidebar-shell-outer,
+html.sidebar-resizing .sidebar-shell-inner {
+ transition: none !important;
+}
+
.panel-container {
width: var(--panel-width);
}
@@ -184,7 +191,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
:root,
.light {
--bg: #fefefe; /* main canvas - neutral near-white */
- --surface-1: #f9f9f9; /* sidebar, panels */
+ --surface-1: #fbfbfb; /* sidebar, panels */
--surface-2: #ffffff; /* blocks, cards, modals - pure white */
--surface-3: #f7f7f7; /* popovers, headers */
--surface-4: #f5f5f5; /* buttons base */
@@ -205,7 +212,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--text-muted: #707070;
--text-subtle: #8c8c8c;
--text-body: #3b3b3b;
- --text-icon: #5e5e5e;
+ --text-icon: #525252;
--text-inverse: #ffffff;
--text-muted-inverse: #a0a0a0;
@@ -362,7 +369,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
--text-muted: #787878;
--text-subtle: #7d7d7d;
--text-body: #cdcdcd;
- --text-icon: #939393;
+ --text-icon: #a0a0a0;
--text-inverse: #1b1b1b;
--text-muted-inverse: #b3b3b3;
diff --git a/apps/sim/app/api/billing/invoices/route.ts b/apps/sim/app/api/billing/invoices/route.ts
new file mode 100644
index 00000000000..aaed9ce865c
--- /dev/null
+++ b/apps/sim/app/api/billing/invoices/route.ts
@@ -0,0 +1,93 @@
+import { db } from '@sim/db'
+import { user } from '@sim/db/schema'
+import { createLogger } from '@sim/logger'
+import { eq } from 'drizzle-orm'
+import { type NextRequest, NextResponse } from 'next/server'
+import { getInvoicesContract } from '@/lib/api/contracts/subscription'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { getOrganizationSubscription } from '@/lib/billing/core/billing'
+import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
+import { getStripeClient } from '@/lib/billing/stripe-client'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+const logger = createLogger('BillingInvoices')
+
+/** Cap the number of invoices returned to the most recent statements. */
+const MAX_INVOICES = 12
+
+/**
+ * Lists finalized Stripe invoices for the caller's billing customer (personal
+ * or organization-scoped). Returns an empty list when there is no Stripe
+ * customer yet or when Stripe is not configured, so the UI can simply hide the
+ * Invoices section instead of surfacing an error.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getInvoicesContract, request, {})
+ if (!parsed.success) return parsed.response
+
+ const { context, organizationId } = parsed.data.query
+
+ if (context === 'organization' && !organizationId) {
+ return NextResponse.json(
+ { error: 'organizationId is required when context=organization' },
+ { status: 400 }
+ )
+ }
+
+ let stripeCustomerId: string | null = null
+
+ if (context === 'organization') {
+ const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
+ if (!hasPermission) {
+ return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
+ }
+
+ // Resolve the org's customer via the canonical resolver so we deterministically
+ // pick the same subscription (most recent entitled, ordered) the rest of the
+ // billing UI uses — a bare limit(1) here could select a stale row.
+ const orgSubscription = await getOrganizationSubscription(organizationId!)
+ stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
+ } else {
+ const rows = await db
+ .select({ customer: user.stripeCustomerId })
+ .from(user)
+ .where(eq(user.id, session.user.id))
+ .limit(1)
+
+ stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
+ }
+
+ const stripe = getStripeClient()
+ if (!stripeCustomerId || !stripe) {
+ return NextResponse.json({ success: true, invoices: [], hasMore: false })
+ }
+
+ try {
+ const result = await stripe.invoices.list({ customer: stripeCustomerId, limit: MAX_INVOICES })
+
+ const invoices = result.data
+ .filter((invoice) => invoice.id && invoice.status && invoice.status !== 'draft')
+ .map((invoice) => ({
+ id: invoice.id as string,
+ number: invoice.number ?? null,
+ created: invoice.created,
+ total: invoice.total,
+ amountPaid: invoice.amount_paid,
+ currency: invoice.currency,
+ status: invoice.status ?? null,
+ hostedInvoiceUrl: invoice.hosted_invoice_url ?? null,
+ invoicePdf: invoice.invoice_pdf ?? null,
+ }))
+
+ return NextResponse.json({ success: true, invoices, hasMore: result.has_more })
+ } catch (error) {
+ logger.error('Failed to list invoices', { error, userId: session.user.id, context })
+ return NextResponse.json({ error: 'Failed to list invoices' }, { status: 500 })
+ }
+})
diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts
index dc3ae3430ba..14b5d2bd4a8 100644
--- a/apps/sim/app/api/billing/portal/route.ts
+++ b/apps/sim/app/api/billing/portal/route.ts
@@ -1,13 +1,13 @@
import { db } from '@sim/db'
-import { subscription as subscriptionTable, user } from '@sim/db/schema'
+import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, inArray, or } from 'drizzle-orm'
+import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { billingPortalBodySchema } from '@/lib/api/contracts/subscription'
import { getSession } from '@/lib/auth'
+import { getOrganizationSubscription } from '@/lib/billing/core/billing'
import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization'
import { requireStripeClient } from '@/lib/billing/stripe-client'
-import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -44,21 +44,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Permission denied' }, { status: 403 })
}
- const rows = await db
- .select({ customer: subscriptionTable.stripeCustomerId })
- .from(subscriptionTable)
- .where(
- and(
- eq(subscriptionTable.referenceId, organizationId),
- or(
- inArray(subscriptionTable.status, ENTITLED_SUBSCRIPTION_STATUSES),
- eq(subscriptionTable.cancelAtPeriodEnd, true)
- )
- )
- )
- .limit(1)
-
- stripeCustomerId = rows.length > 0 ? rows[0].customer || null : null
+ // Canonical resolver: deterministically selects the most recent entitled
+ // org subscription, matching the rest of the billing UI.
+ const orgSubscription = await getOrganizationSubscription(organizationId)
+ stripeCustomerId = orgSubscription?.stripeCustomerId ?? null
} else {
const rows = await db
.select({ customer: user.stripeCustomerId })
diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts
index d8b221a7875..5417fbe4a49 100644
--- a/apps/sim/app/api/copilot/chat/resources/route.ts
+++ b/apps/sim/app/api/copilot/chat/resources/route.ts
@@ -28,6 +28,7 @@ const VALID_RESOURCE_TYPES = new Set
([
'knowledgebase',
'folder',
'log',
+ 'integration',
])
const GENERIC_TITLES = new Set(['Table', 'File', 'Workflow', 'Knowledge Base', 'Folder', 'Log'])
diff --git a/apps/sim/app/api/creators/[id]/route.ts b/apps/sim/app/api/creators/[id]/route.ts
deleted file mode 100644
index b6ce9c781ae..00000000000
--- a/apps/sim/app/api/creators/[id]/route.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import { db } from '@sim/db'
-import { member, templateCreators } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, eq, or } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import {
- creatorProfileParamsSchema,
- updateCreatorProfileContract,
-} from '@/lib/api/contracts/creator-profile'
-import { parseRequest, validationErrorResponse } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-const logger = createLogger('CreatorProfileByIdAPI')
-
-type CreatorProfileRow = typeof templateCreators.$inferSelect
-type CreatorProfileUpdate = Partial<
- Pick
-> & {
- updatedAt: Date
-}
-
-// Helper to check if user has permission to manage profile
-async function hasPermission(userId: string, profile: CreatorProfileRow): Promise {
- if (profile.referenceType === 'user') {
- return profile.referenceId === userId
- }
- if (profile.referenceType === 'organization') {
- const membership = await db
- .select()
- .from(member)
- .where(
- and(
- eq(member.userId, userId),
- eq(member.organizationId, profile.referenceId),
- or(eq(member.role, 'owner'), eq(member.role, 'admin'))
- )
- )
- .limit(1)
- return membership.length > 0
- }
- return false
-}
-
-// GET /api/creators/[id] - Get a specific creator profile
-export const GET = withRouteHandler(
- async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const paramsResult = creatorProfileParamsSchema.safeParse(await params)
- if (!paramsResult.success) {
- return NextResponse.json({ error: 'Invalid route parameters' }, { status: 400 })
- }
- const { id } = paramsResult.data
-
- try {
- const profile = await db
- .select()
- .from(templateCreators)
- .where(eq(templateCreators.id, id))
- .limit(1)
-
- if (profile.length === 0) {
- logger.warn(`[${requestId}] Profile not found: ${id}`)
- return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
- }
-
- logger.info(`[${requestId}] Retrieved creator profile: ${id}`)
- return NextResponse.json({ data: profile[0] })
- } catch (error) {
- logger.error(`[${requestId}] Error fetching creator profile: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-// PUT /api/creators/[id] - Update a creator profile
-export const PUT = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized update attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(updateCreatorProfileContract, request, context, {
- validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid update data`, { errors: error.issues })
- return validationErrorResponse(error, 'Invalid update data')
- },
- })
- if (!parsed.success) return parsed.response
-
- const { id } = parsed.data.params
- const data = parsed.data.body
-
- // Check if profile exists
- const existing = await db
- .select()
- .from(templateCreators)
- .where(eq(templateCreators.id, id))
- .limit(1)
-
- const existingProfile = existing[0]
- if (!existingProfile) {
- logger.warn(`[${requestId}] Profile not found for update: ${id}`)
- return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
- }
-
- // Verification changes require super user permission
- if (data.verified !== undefined) {
- const { verifyEffectiveSuperUser } = await import('@/lib/templates/permissions')
- const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
- if (!effectiveSuperUser) {
- logger.warn(
- `[${requestId}] Non-super user attempted to change creator verification: ${id}`
- )
- return NextResponse.json(
- { error: 'Only super users can change verification status' },
- { status: 403 }
- )
- }
- }
-
- // For non-verified updates, check regular permissions
- const hasNonVerifiedUpdates =
- data.name !== undefined || data.profileImageUrl !== undefined || data.details !== undefined
-
- if (hasNonVerifiedUpdates) {
- const canEdit = await hasPermission(session.user.id, existingProfile)
- if (!canEdit) {
- logger.warn(`[${requestId}] User denied permission to update profile: ${id}`)
- return NextResponse.json({ error: 'Access denied' }, { status: 403 })
- }
- }
-
- const updateData: CreatorProfileUpdate = {
- updatedAt: new Date(),
- }
-
- if (data.name !== undefined) updateData.name = data.name
- if (data.profileImageUrl !== undefined) updateData.profileImageUrl = data.profileImageUrl
- if (data.details !== undefined) updateData.details = data.details
- if (data.verified !== undefined) updateData.verified = data.verified
-
- const updated = await db
- .update(templateCreators)
- .set(updateData)
- .where(eq(templateCreators.id, id))
- .returning()
-
- logger.info(`[${requestId}] Successfully updated creator profile: ${id}`)
-
- return NextResponse.json({ data: updated[0] })
- } catch (error) {
- logger.error(`[${requestId}] Error updating creator profile`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-// DELETE /api/creators/[id] - Delete a creator profile
-export const DELETE = withRouteHandler(
- async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const paramsResult = creatorProfileParamsSchema.safeParse(await params)
- if (!paramsResult.success) {
- return NextResponse.json({ error: 'Invalid route parameters' }, { status: 400 })
- }
- const { id } = paramsResult.data
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized delete attempt for profile: ${id}`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- // Check if profile exists
- const existing = await db
- .select()
- .from(templateCreators)
- .where(eq(templateCreators.id, id))
- .limit(1)
-
- const existingProfile = existing[0]
- if (!existingProfile) {
- logger.warn(`[${requestId}] Profile not found for delete: ${id}`)
- return NextResponse.json({ error: 'Profile not found' }, { status: 404 })
- }
-
- // Check permissions
- const canDelete = await hasPermission(session.user.id, existingProfile)
- if (!canDelete) {
- logger.warn(`[${requestId}] User denied permission to delete profile: ${id}`)
- return NextResponse.json({ error: 'Access denied' }, { status: 403 })
- }
-
- await db.delete(templateCreators).where(eq(templateCreators.id, id))
-
- logger.info(`[${requestId}] Successfully deleted creator profile: ${id}`)
- return NextResponse.json({ success: true })
- } catch (error) {
- logger.error(`[${requestId}] Error deleting creator profile: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/creators/route.ts b/apps/sim/app/api/creators/route.ts
deleted file mode 100644
index 8ae64c05773..00000000000
--- a/apps/sim/app/api/creators/route.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import { db } from '@sim/db'
-import { member, templateCreators } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { and, eq, or } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import {
- type CreatorProfileDetails,
- createCreatorProfileContract,
- listCreatorProfilesQuerySchema,
-} from '@/lib/api/contracts/creator-profile'
-import { parseRequest, validationErrorResponse } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-const logger = createLogger('CreatorProfilesAPI')
-
-// GET /api/creators - Get creator profiles for current user
-export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
- const queryResult = listCreatorProfilesQuerySchema.safeParse(
- Object.fromEntries(request.nextUrl.searchParams.entries())
- )
- if (!queryResult.success) {
- return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })
- }
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized access attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const requestedUserId = queryResult.data.userId
- if (requestedUserId && requestedUserId !== session.user.id) {
- return NextResponse.json({ profiles: [] })
- }
-
- if (requestedUserId) {
- const profiles = await db
- .select()
- .from(templateCreators)
- .where(
- and(
- eq(templateCreators.referenceType, 'user'),
- eq(templateCreators.referenceId, requestedUserId)
- )
- )
-
- logger.info(`[${requestId}] Retrieved ${profiles.length} creator profiles`)
-
- return NextResponse.json({ profiles })
- }
-
- // Get user's organizations where they're admin or owner
- const userOrgs = await db
- .select({ organizationId: member.organizationId })
- .from(member)
- .where(
- and(
- eq(member.userId, session.user.id),
- or(eq(member.role, 'owner'), eq(member.role, 'admin'))
- )
- )
-
- const orgIds = userOrgs.map((m) => m.organizationId)
-
- // Get creator profiles for user and their organizations
- const profiles = await db
- .select()
- .from(templateCreators)
- .where(
- or(
- and(
- eq(templateCreators.referenceType, 'user'),
- eq(templateCreators.referenceId, session.user.id)
- ),
- ...orgIds.map((orgId) =>
- and(
- eq(templateCreators.referenceType, 'organization'),
- eq(templateCreators.referenceId, orgId)
- )
- )
- )
- )
-
- logger.info(`[${requestId}] Retrieved ${profiles.length} creator profiles`)
-
- return NextResponse.json({ profiles })
- } catch (error) {
- logger.error(`[${requestId}] Error fetching creator profiles`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-})
-
-// POST /api/creators - Create a new creator profile
-export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized creation attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(
- createCreatorProfileContract,
- request,
- {},
- {
- validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid profile data`, { errors: error.issues })
- return validationErrorResponse(error, 'Invalid profile data')
- },
- }
- )
- if (!parsed.success) return parsed.response
- const data = parsed.data.body
-
- if (data.referenceType === 'user') {
- if (data.referenceId !== session.user.id) {
- logger.warn(`[${requestId}] User tried to create profile for another user`)
- return NextResponse.json(
- { error: 'Cannot create profile for another user' },
- { status: 403 }
- )
- }
- } else if (data.referenceType === 'organization') {
- // Check if user is admin/owner of the organization
- const membership = await db
- .select()
- .from(member)
- .where(
- and(
- eq(member.userId, session.user.id),
- eq(member.organizationId, data.referenceId),
- or(eq(member.role, 'owner'), eq(member.role, 'admin'))
- )
- )
- .limit(1)
-
- if (membership.length === 0) {
- logger.warn(`[${requestId}] User not authorized for organization: ${data.referenceId}`)
- return NextResponse.json(
- { error: 'You must be an admin or owner to create an organization profile' },
- { status: 403 }
- )
- }
- }
-
- // Check if profile already exists
- const existing = await db
- .select()
- .from(templateCreators)
- .where(
- and(
- eq(templateCreators.referenceType, data.referenceType),
- eq(templateCreators.referenceId, data.referenceId)
- )
- )
- .limit(1)
-
- if (existing.length > 0) {
- logger.warn(
- `[${requestId}] Profile already exists for ${data.referenceType}:${data.referenceId}`
- )
- return NextResponse.json({ error: 'Creator profile already exists' }, { status: 409 })
- }
-
- // Create the profile
- const profileId = generateId()
- const now = new Date()
-
- const details: CreatorProfileDetails = {}
- if (data.details?.about) details.about = data.details.about
- if (data.details?.xUrl) details.xUrl = data.details.xUrl
- if (data.details?.linkedinUrl) details.linkedinUrl = data.details.linkedinUrl
- if (data.details?.websiteUrl) details.websiteUrl = data.details.websiteUrl
- if (data.details?.contactEmail) details.contactEmail = data.details.contactEmail
-
- const newProfile = {
- id: profileId,
- referenceType: data.referenceType,
- referenceId: data.referenceId,
- name: data.name,
- profileImageUrl: data.profileImageUrl || null,
- details: Object.keys(details).length > 0 ? details : null,
- verified: false,
- createdBy: session.user.id,
- createdAt: now,
- updatedAt: now,
- }
-
- await db.insert(templateCreators).values(newProfile)
-
- logger.info(`[${requestId}] Successfully created creator profile: ${profileId}`)
-
- return NextResponse.json({ data: newProfile }, { status: 201 })
- } catch (error) {
- logger.error(`[${requestId}] Error creating creator profile`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-})
diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts
index 8b1768ac2be..57fdae774ad 100644
--- a/apps/sim/app/api/credentials/[id]/members/route.ts
+++ b/apps/sim/app/api/credentials/[id]/members/route.ts
@@ -18,7 +18,7 @@ interface RouteContext {
async function requireWorkspaceAdminMembership(credentialId: string, userId: string) {
const [cred] = await db
- .select({ id: credential.id, workspaceId: credential.workspaceId })
+ .select({ id: credential.id, workspaceId: credential.workspaceId, type: credential.type })
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
@@ -39,7 +39,7 @@ async function requireWorkspaceAdminMembership(credentialId: string, userId: str
if (!membership || membership.status !== 'active' || membership.role !== 'admin') {
return null
}
- return membership
+ return { ...membership, credentialType: cred.type }
}
export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => {
@@ -104,6 +104,9 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Route
if (!admin) {
return NextResponse.json({ error: 'Admin access required' }, { status: 403 })
}
+ if (admin.credentialType === 'env_personal') {
+ return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 })
+ }
const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context)
if (!parsed.success) return parsed.response
diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts
index 64a3d3f9511..3b964b18e0b 100644
--- a/apps/sim/app/api/credentials/route.ts
+++ b/apps/sim/app/api/credentials/route.ts
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { account, credential, credentialMember, workspace } from '@sim/db/schema'
+import { account, credential, credentialMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -22,7 +22,7 @@ import {
normalizeAtlassianDomain,
validateAtlassianServiceAccount,
} from '@/lib/credentials/atlassian-service-account'
-import { getWorkspaceMemberUserIds } from '@/lib/credentials/environment'
+import { getWorkspaceMembership } from '@/lib/credentials/environment'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import { getServiceConfigByProviderId } from '@/lib/oauth'
import {
@@ -498,11 +498,11 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const now = new Date()
const credentialId = generateId()
- const [workspaceRow] = await db
- .select({ ownerId: workspace.ownerId })
- .from(workspace)
- .where(eq(workspace.id, workspaceId))
- .limit(1)
+ const {
+ ownerId: workspaceOwnerId,
+ memberUserIds: workspaceMemberUserIds,
+ adminUserIds: workspaceAdminUserIds,
+ } = await getWorkspaceMembership(workspaceId)
await db.transaction(async (tx) => {
// service_account has no DB-level unique index on (workspaceId, providerId,
@@ -534,18 +534,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
updatedAt: now,
})
- if ((type === 'env_workspace' || type === 'service_account') && workspaceRow?.ownerId) {
- const workspaceUserIds = await getWorkspaceMemberUserIds(workspaceId)
- if (workspaceUserIds.length > 0) {
- for (const memberUserId of workspaceUserIds) {
+ if ((type === 'env_workspace' || type === 'service_account') && workspaceOwnerId) {
+ if (workspaceMemberUserIds.length > 0) {
+ for (const memberUserId of workspaceMemberUserIds) {
+ const isAdmin =
+ memberUserId === session.user.id || workspaceAdminUserIds.has(memberUserId)
await tx.insert(credentialMember).values({
id: generateId(),
credentialId,
userId: memberUserId,
- role:
- memberUserId === workspaceRow.ownerId || memberUserId === session.user.id
- ? 'admin'
- : 'member',
+ role: isAdmin ? 'admin' : 'member',
status: 'active',
joinedAt: now,
invitedBy: session.user.id,
diff --git a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts
index 52c9420916c..99c395d644b 100644
--- a/apps/sim/app/api/cron/cleanup-stale-executions/route.ts
+++ b/apps/sim/app/api/cron/cleanup-stale-executions/route.ts
@@ -1,5 +1,5 @@
import { asyncJobs, db } from '@sim/db'
-import { workflowExecutionLogs } from '@sim/db/schema'
+import { userTableDefinitions, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { and, eq, inArray, lt, sql } from 'drizzle-orm'
@@ -110,6 +110,37 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
})
}
+ // Mark stale table imports as failed. Imports run detached on the web container and
+ // are lost if the pod is killed mid-load. `updatedAt` is bumped by progress updates, so
+ // an `importing` table with no recent update has stalled (not merely slow). Rows are
+ // left in place (no rollback); the user re-imports.
+ let staleImportsMarkedFailed = 0
+ try {
+ const staleImports = await db
+ .update(userTableDefinitions)
+ .set({
+ importStatus: 'failed',
+ importError: `Import terminated: no progress for more than ${STALE_THRESHOLD_MINUTES} minutes (worker timeout or crash)`,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(userTableDefinitions.importStatus, 'importing'),
+ lt(userTableDefinitions.updatedAt, staleThreshold)
+ )
+ )
+ .returning({ id: userTableDefinitions.id })
+
+ staleImportsMarkedFailed = staleImports.length
+ if (staleImportsMarkedFailed > 0) {
+ logger.info(`Marked ${staleImportsMarkedFailed} stale table imports as failed`)
+ }
+ } catch (error) {
+ logger.error('Failed to clean up stale table imports:', {
+ error: toError(error).message,
+ })
+ }
+
// Clean up stale pending jobs (never started, e.g., due to server crash before startJob())
let stalePendingJobsMarkedFailed = 0
@@ -179,6 +210,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
staleThresholdMinutes: STALE_THRESHOLD_MINUTES,
retentionHours: JOB_RETENTION_HOURS,
},
+ tableImports: {
+ staleMarkedFailed: staleImportsMarkedFailed,
+ },
})
} catch (error) {
logger.error('Error in stale execution cleanup job:', error)
diff --git a/apps/sim/app/api/cron/reconcile-billing-seats/route.test.ts b/apps/sim/app/api/cron/reconcile-billing-seats/route.test.ts
new file mode 100644
index 00000000000..54437d2b5ea
--- /dev/null
+++ b/apps/sim/app/api/cron/reconcile-billing-seats/route.test.ts
@@ -0,0 +1,92 @@
+/**
+ * Tests for the billing seat reconciliation cron route.
+ *
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockVerifyCronAuth, mockReconcileTeamSeatDrift, mockFindDeadLetteredEvents } = vi.hoisted(
+ () => ({
+ mockVerifyCronAuth: vi.fn().mockReturnValue(null),
+ mockReconcileTeamSeatDrift: vi.fn(),
+ mockFindDeadLetteredEvents: vi.fn(),
+ })
+)
+
+vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
+vi.mock('@/lib/billing/organizations/seat-drift', () => ({
+ reconcileTeamSeatDrift: mockReconcileTeamSeatDrift,
+}))
+vi.mock('@/lib/core/outbox/service', () => ({ findDeadLetteredEvents: mockFindDeadLetteredEvents }))
+vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
+ OUTBOX_EVENT_TYPES: {
+ STRIPE_SYNC_SUBSCRIPTION_SEATS: 'stripe.sync-subscription-seats',
+ STRIPE_SYNC_CANCEL_AT_PERIOD_END: 'stripe.sync-cancel-at-period-end',
+ },
+}))
+
+import { GET } from './route'
+
+function createRequest() {
+ return createMockRequest(
+ 'GET',
+ undefined,
+ {},
+ 'http://localhost:3000/api/cron/reconcile-billing-seats'
+ )
+}
+
+describe('reconcile-billing-seats cron route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockVerifyCronAuth.mockReturnValue(null)
+ mockReconcileTeamSeatDrift.mockResolvedValue({ drifted: 0, reconciled: 0 })
+ mockFindDeadLetteredEvents.mockResolvedValue([])
+ })
+
+ it('returns the auth error when cron auth fails', async () => {
+ mockVerifyCronAuth.mockReturnValueOnce(new Response(null, { status: 401 }) as never)
+
+ const response = await GET(createRequest())
+
+ expect(response.status).toBe(401)
+ expect(mockReconcileTeamSeatDrift).not.toHaveBeenCalled()
+ })
+
+ it('runs the drift sweep and reports dead-lettered billing syncs', async () => {
+ mockReconcileTeamSeatDrift.mockResolvedValue({ drifted: 2, reconciled: 1 })
+ mockFindDeadLetteredEvents.mockResolvedValue([
+ {
+ id: 'evt-1',
+ eventType: 'stripe.sync-subscription-seats',
+ payload: { subscriptionId: 'sub-1' },
+ lastError: 'card declined',
+ },
+ ])
+
+ const response = await GET(createRequest())
+
+ expect(response.status).toBe(200)
+ const data = await response.json()
+ expect(data).toMatchObject({
+ success: true,
+ drift: { drifted: 2, reconciled: 1 },
+ deadLetteredBillingSyncs: 1,
+ })
+ expect(mockFindDeadLetteredEvents).toHaveBeenCalledWith([
+ 'stripe.sync-subscription-seats',
+ 'stripe.sync-cancel-at-period-end',
+ ])
+ })
+
+ it('returns 500 when the sweep throws', async () => {
+ mockReconcileTeamSeatDrift.mockRejectedValueOnce(new Error('boom'))
+
+ const response = await GET(createRequest())
+
+ expect(response.status).toBe(500)
+ const data = await response.json()
+ expect(data.success).toBe(false)
+ })
+})
diff --git a/apps/sim/app/api/cron/reconcile-billing-seats/route.ts b/apps/sim/app/api/cron/reconcile-billing-seats/route.ts
new file mode 100644
index 00000000000..133ae7378d4
--- /dev/null
+++ b/apps/sim/app/api/cron/reconcile-billing-seats/route.ts
@@ -0,0 +1,79 @@
+import { createLogger } from '@sim/logger'
+import { toError } from '@sim/utils/errors'
+import { type NextRequest, NextResponse } from 'next/server'
+import { verifyCronAuth } from '@/lib/auth/internal'
+import { reconcileTeamSeatDrift } from '@/lib/billing/organizations/seat-drift'
+import { OUTBOX_EVENT_TYPES } from '@/lib/billing/webhooks/outbox-handlers'
+import { findDeadLetteredEvents } from '@/lib/core/outbox/service'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+
+const logger = createLogger('BillingSeatReconcileCron')
+
+export const dynamic = 'force-dynamic'
+
+const BILLING_SYNC_EVENT_TYPES = [
+ OUTBOX_EVENT_TYPES.STRIPE_SYNC_SUBSCRIPTION_SEATS,
+ OUTBOX_EVENT_TYPES.STRIPE_SYNC_CANCEL_AT_PERIOD_END,
+]
+
+/**
+ * Periodic billing-seat reconciliation. Self-heals Team organizations whose
+ * stored seat count drifted from their member count, and reports any
+ * dead-lettered Stripe seat/cancel sync events so a member who has access but
+ * whose seat charge never synced is surfaced for manual remediation rather than
+ * silently under-billed.
+ *
+ * Scheduled in helm/sim/values.yaml under cronjobs.jobs.reconcileBillingSeats.
+ */
+export const GET = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ const authError = verifyCronAuth(request, 'Billing seat reconciliation')
+ if (authError) {
+ return authError
+ }
+
+ try {
+ const drift = await reconcileTeamSeatDrift()
+
+ const deadLettered = await findDeadLetteredEvents(BILLING_SYNC_EVENT_TYPES)
+ if (deadLettered.length > 0) {
+ logger.error(
+ 'Dead-lettered billing sync events require manual remediation — a billing state change (seat charge or cancellation) never reached Stripe',
+ {
+ requestId,
+ count: deadLettered.length,
+ events: deadLettered.map((event) => ({
+ id: event.id,
+ eventType: event.eventType,
+ subscriptionId: (event.payload as { subscriptionId?: string } | null)?.subscriptionId,
+ lastError: event.lastError,
+ })),
+ }
+ )
+ }
+
+ logger.info('Billing seat reconciliation completed', {
+ requestId,
+ ...drift,
+ deadLetteredBillingSyncs: deadLettered.length,
+ })
+
+ return NextResponse.json({
+ success: true,
+ requestId,
+ drift,
+ deadLetteredBillingSyncs: deadLettered.length,
+ })
+ } catch (error) {
+ logger.error('Billing seat reconciliation failed', {
+ requestId,
+ error: toError(error).message,
+ })
+ return NextResponse.json(
+ { success: false, requestId, error: toError(error).message },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/form/[identifier]/otp/route.test.ts b/apps/sim/app/api/form/[identifier]/otp/route.test.ts
deleted file mode 100644
index 5a0a9eb1033..00000000000
--- a/apps/sim/app/api/form/[identifier]/otp/route.test.ts
+++ /dev/null
@@ -1,691 +0,0 @@
-/**
- * Tests for form OTP API route
- *
- * @vitest-environment node
- */
-import {
- redisConfigMock,
- redisConfigMockFns,
- requestUtilsMockFns,
- workflowsApiUtilsMock,
- workflowsApiUtilsMockFns,
-} from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-
-const {
- mockRedisSet,
- mockRedisGet,
- mockRedisDel,
- mockRedisTtl,
- mockRedisEval,
- mockRedisClient,
- mockDbSelect,
- mockDbInsert,
- mockDbDelete,
- mockDbUpdate,
- mockSendEmail,
- mockRenderOTPEmail,
- mockSetFormAuthCookie,
- mockGetStorageMethod,
- mockZodParse,
- mockGetEnv,
-} = vi.hoisted(() => {
- const mockRedisSet = vi.fn()
- const mockRedisGet = vi.fn()
- const mockRedisDel = vi.fn()
- const mockRedisTtl = vi.fn()
- const mockRedisEval = vi.fn()
- const mockRedisClient = {
- set: mockRedisSet,
- get: mockRedisGet,
- del: mockRedisDel,
- ttl: mockRedisTtl,
- eval: mockRedisEval,
- }
- return {
- mockRedisSet,
- mockRedisGet,
- mockRedisDel,
- mockRedisTtl,
- mockRedisEval,
- mockRedisClient,
- mockDbSelect: vi.fn(),
- mockDbInsert: vi.fn(),
- mockDbDelete: vi.fn(),
- mockDbUpdate: vi.fn(),
- mockSendEmail: vi.fn(),
- mockRenderOTPEmail: vi.fn(),
- mockSetFormAuthCookie: vi.fn(),
- mockGetStorageMethod: vi.fn(),
- mockZodParse: vi.fn(),
- mockGetEnv: vi.fn(),
- }
-})
-
-const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
-const mockCreateSuccessResponse = workflowsApiUtilsMockFns.mockCreateSuccessResponse
-const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
-
-vi.mock('@/lib/core/config/redis', () => redisConfigMock)
-
-vi.mock('@sim/db', () => ({
- db: {
- select: mockDbSelect,
- insert: mockDbInsert,
- delete: mockDbDelete,
- update: mockDbUpdate,
- transaction: vi.fn(async (callback: (tx: Record) => unknown) => {
- return callback({
- select: mockDbSelect,
- insert: mockDbInsert,
- delete: mockDbDelete,
- update: mockDbUpdate,
- })
- }),
- },
-}))
-
-vi.mock('drizzle-orm', () => ({
- eq: vi.fn((field: string, value: string) => ({ field, value, type: 'eq' })),
- and: vi.fn((...conditions: unknown[]) => ({ conditions, type: 'and' })),
- gt: vi.fn((field: string, value: string) => ({ field, value, type: 'gt' })),
- lt: vi.fn((field: string, value: string) => ({ field, value, type: 'lt' })),
- isNull: vi.fn((field: unknown) => ({ field, type: 'isNull' })),
-}))
-
-vi.mock('@/lib/core/storage', () => ({
- getStorageMethod: mockGetStorageMethod,
-}))
-
-const { mockCheckRateLimitDirect } = vi.hoisted(() => ({
- mockCheckRateLimitDirect: vi.fn(),
-}))
-
-vi.mock('@/lib/core/rate-limiter', () => ({
- RateLimiter: class {
- checkRateLimitDirect = mockCheckRateLimitDirect
- },
-}))
-
-vi.mock('@/lib/messaging/email/mailer', () => ({
- sendEmail: mockSendEmail,
-}))
-
-vi.mock('@/components/emails', () => ({
- renderOTPEmail: mockRenderOTPEmail,
-}))
-
-vi.mock('@/lib/core/security/deployment', () => ({
- isEmailAllowed: (email: string, allowedEmails: string[]) => {
- if (allowedEmails.includes(email)) return true
- const atIndex = email.indexOf('@')
- if (atIndex > 0) {
- const domain = email.substring(atIndex + 1)
- if (domain && allowedEmails.some((allowed: string) => allowed === `@${domain}`)) return true
- }
- return false
- },
-}))
-
-vi.mock('@/app/api/form/utils', () => ({
- setFormAuthCookie: mockSetFormAuthCookie,
-}))
-
-vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
-
-vi.mock('@/lib/core/config/env', () => ({
- env: {
- NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
- NODE_ENV: 'test',
- },
- getEnv: mockGetEnv,
- isTruthy: vi.fn().mockReturnValue(false),
- isFalsy: vi.fn().mockReturnValue(true),
-}))
-
-vi.mock('zod', () => {
- class ZodError extends Error {
- errors: Array<{ message: string }>
- constructor(issues: Array<{ message: string }>) {
- super('ZodError')
- this.errors = issues
- }
- }
- const chainable: Record = {}
- const proxy: Record = new Proxy(chainable, {
- get(target, prop) {
- if (prop === 'parse') return mockZodParse
- if (prop === 'safeParse') {
- return (data: unknown) => ({ success: true, data })
- }
- if (prop === 'then') return undefined
- if (typeof prop === 'symbol') return Reflect.get(target, prop)
- if (!(prop in target)) {
- target[prop as string] = vi.fn().mockReturnValue(proxy)
- }
- return target[prop as string]
- },
- })
- const makeChain = vi.fn(() => proxy)
- return {
- z: new Proxy(
- { ZodError },
- {
- get(target, prop) {
- if (prop === 'ZodError') return ZodError
- if (typeof prop === 'symbol') return Reflect.get(target, prop)
- return makeChain
- },
- }
- ),
- }
-})
-
-import { POST, PUT } from './route'
-
-describe('Form OTP API Route', () => {
- const mockEmail = 'user@example.com'
- const mockFormId = 'form-123'
- const mockIdentifier = 'test-form'
- const mockOTP = '123456'
-
- const deploymentRow = {
- id: mockFormId,
- authType: 'email',
- allowedEmails: [mockEmail],
- title: 'Test Form',
- isActive: true,
- }
-
- const verifyDeploymentRow = {
- id: mockFormId,
- authType: 'email',
- password: null,
- allowedEmails: [mockEmail],
- isActive: true,
- }
-
- const selectOnce = (rows: unknown[]) =>
- mockDbSelect.mockImplementationOnce(() => ({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockReturnValue({
- limit: vi.fn().mockResolvedValue(rows),
- }),
- }),
- }))
-
- beforeEach(() => {
- vi.clearAllMocks()
-
- vi.spyOn(Math, 'random').mockReturnValue(0.123456)
- vi.spyOn(Date, 'now').mockReturnValue(1640995200000)
-
- vi.stubGlobal('crypto', {
- ...crypto,
- randomUUID: vi.fn().mockReturnValue('test-uuid-1234'),
- })
-
- mockGetRedisClient.mockReturnValue(mockRedisClient)
- mockRedisSet.mockResolvedValue('OK')
- mockRedisGet.mockResolvedValue(null)
- mockRedisDel.mockResolvedValue(1)
- mockRedisTtl.mockResolvedValue(600)
-
- mockDbSelect.mockImplementation(() => ({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockReturnValue({
- limit: vi.fn().mockResolvedValue([]),
- }),
- }),
- }))
- mockDbInsert.mockImplementation(() => ({ values: vi.fn().mockResolvedValue(undefined) }))
- mockDbDelete.mockImplementation(() => ({ where: vi.fn().mockResolvedValue(undefined) }))
- mockDbUpdate.mockImplementation(() => ({
- set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }),
- }))
-
- mockGetStorageMethod.mockReturnValue('redis')
-
- mockSendEmail.mockResolvedValue({ success: true })
- mockRenderOTPEmail.mockResolvedValue('OTP Email')
-
- mockCreateSuccessResponse.mockImplementation((data: unknown) => ({
- json: () => Promise.resolve(data),
- status: 200,
- }))
- mockCreateErrorResponse.mockImplementation((message: string, status: number) => ({
- json: () => Promise.resolve({ error: message }),
- status,
- }))
-
- requestUtilsMockFns.mockGenerateRequestId.mockReturnValue('req-123')
- requestUtilsMockFns.mockGetClientIp.mockReturnValue('1.2.3.4')
-
- mockCheckRateLimitDirect.mockResolvedValue({
- allowed: true,
- remaining: 10,
- resetAt: new Date(Date.now() + 60_000),
- })
-
- mockZodParse.mockImplementation((data: unknown) => data)
- mockGetEnv.mockReturnValue('http://localhost:3000')
- })
-
- afterEach(() => {
- vi.restoreAllMocks()
- })
-
- describe('POST /otp - request code', () => {
- it('stores OTP in Redis when storage is redis and sends email', async () => {
- selectOnce([deploymentRow])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockRedisSet).toHaveBeenCalledWith(
- `form-otp:${mockEmail}:${mockFormId}`,
- expect.stringMatching(/^\d{6}:0$/),
- 'EX',
- 900
- )
- expect(mockSendEmail).toHaveBeenCalledWith(
- expect.objectContaining({ to: mockEmail, subject: expect.stringContaining('Test Form') })
- )
- expect(mockDbInsert).not.toHaveBeenCalled()
- })
-
- it('stores OTP in database when storage is database', async () => {
- mockGetStorageMethod.mockReturnValue('database')
- mockGetRedisClient.mockReturnValue(null)
- selectOnce([deploymentRow])
- const insertValues = vi.fn().mockResolvedValue(undefined)
- mockDbInsert.mockImplementationOnce(() => ({ values: insertValues }))
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(insertValues).toHaveBeenCalledWith(
- expect.objectContaining({
- identifier: `form-otp:${mockFormId}:${mockEmail}`,
- value: expect.stringMatching(/^\d{6}:0$/),
- })
- )
- expect(mockRedisSet).not.toHaveBeenCalled()
- })
-
- it('returns 404 when form is not found', async () => {
- selectOnce([])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith('Form not found', 404)
- expect(mockSendEmail).not.toHaveBeenCalled()
- })
-
- it('returns 403 when form is inactive', async () => {
- selectOnce([{ ...deploymentRow, isActive: false }])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'This form is currently unavailable',
- 403
- )
- expect(mockSendEmail).not.toHaveBeenCalled()
- })
-
- it('returns 400 when form authType is not email', async () => {
- selectOnce([{ ...deploymentRow, authType: 'public' }])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'This form does not use email authentication',
- 400
- )
- expect(mockSendEmail).not.toHaveBeenCalled()
- })
-
- it('returns 403 when email is not in allowedEmails', async () => {
- selectOnce([{ ...deploymentRow, allowedEmails: ['other@example.com'] }])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'Email not authorized for this form',
- 403
- )
- expect(mockSendEmail).not.toHaveBeenCalled()
- })
-
- it('authorizes by domain match in allowedEmails', async () => {
- selectOnce([{ ...deploymentRow, allowedEmails: ['@example.com'] }])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockSendEmail).toHaveBeenCalled()
- })
-
- it('returns 429 with Retry-After when IP rate limit is exceeded', async () => {
- mockCheckRateLimitDirect.mockResolvedValueOnce({
- allowed: false,
- remaining: 0,
- resetAt: new Date(Date.now() + 900_000),
- retryAfterMs: 900_000,
- })
- const headerSet = vi.fn()
- mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
- json: () => Promise.resolve({ error: message }),
- status,
- headers: { set: headerSet },
- }))
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- const response = await POST(request, {
- params: Promise.resolve({ identifier: mockIdentifier }),
- })
-
- expect(response.status).toBe(429)
- expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
- expect(mockSendEmail).not.toHaveBeenCalled()
- expect(mockDbSelect).not.toHaveBeenCalled()
- })
-
- it('returns 429 with Retry-After when email rate limit is exceeded', async () => {
- mockCheckRateLimitDirect
- .mockResolvedValueOnce({
- allowed: true,
- remaining: 9,
- resetAt: new Date(Date.now() + 60_000),
- })
- .mockResolvedValueOnce({
- allowed: false,
- remaining: 0,
- resetAt: new Date(Date.now() + 900_000),
- retryAfterMs: 900_000,
- })
- const headerSet = vi.fn()
- mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
- json: () => Promise.resolve({ error: message }),
- status,
- headers: { set: headerSet },
- }))
- selectOnce([deploymentRow])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- const response = await POST(request, {
- params: Promise.resolve({ identifier: mockIdentifier }),
- })
-
- expect(response.status).toBe(429)
- expect(headerSet).toHaveBeenCalledWith('Retry-After', '900')
- expect(mockSendEmail).not.toHaveBeenCalled()
- })
-
- it('rate-limits the IP bucket before reading the deployment row', async () => {
- mockCheckRateLimitDirect.mockResolvedValueOnce({
- allowed: false,
- remaining: 0,
- resetAt: new Date(Date.now() + 900_000),
- retryAfterMs: 900_000,
- })
- mockCreateErrorResponse.mockImplementationOnce((message: string, status: number) => ({
- json: () => Promise.resolve({ error: message }),
- status,
- headers: { set: vi.fn() },
- }))
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockDbSelect).not.toHaveBeenCalled()
- })
-
- it('returns 500 when email send fails', async () => {
- selectOnce([deploymentRow])
- mockSendEmail.mockResolvedValueOnce({ success: false, message: 'smtp down' })
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'POST',
- body: JSON.stringify({ email: mockEmail }),
- })
-
- await POST(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith('Failed to send verification email', 500)
- })
- })
-
- describe('PUT /otp - verify code', () => {
- it('verifies OTP, deletes it, and sets the form auth cookie on success', async () => {
- selectOnce([verifyDeploymentRow])
- mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockRedisGet).toHaveBeenCalledWith(`form-otp:${mockEmail}:${mockFormId}`)
- expect(mockRedisDel).toHaveBeenCalledWith(`form-otp:${mockEmail}:${mockFormId}`)
- expect(mockSetFormAuthCookie).toHaveBeenCalledWith(
- expect.any(Object),
- mockFormId,
- 'email',
- null
- )
- expect(mockCreateSuccessResponse).toHaveBeenCalledWith({ authenticated: true })
- })
-
- it('returns 404 when form is not found', async () => {
- selectOnce([])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith('Form not found', 404)
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('returns 403 when form is inactive at verify time', async () => {
- selectOnce([{ ...verifyDeploymentRow, isActive: false }])
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'This form is currently unavailable',
- 403
- )
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('returns 403 when email is no longer in allowedEmails at verify time', async () => {
- selectOnce([{ ...verifyDeploymentRow, allowedEmails: ['other@example.com'] }])
- mockRedisGet.mockResolvedValue(`${mockOTP}:0`)
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'Email not authorized for this form',
- 403
- )
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('returns 400 when no OTP is stored', async () => {
- selectOnce([verifyDeploymentRow])
- mockRedisGet.mockResolvedValue(null)
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'No verification code found, request a new one',
- 400
- )
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('atomically increments attempts on wrong OTP and returns 400', async () => {
- selectOnce([verifyDeploymentRow])
- mockRedisGet.mockResolvedValue('654321:0')
- mockRedisEval.mockResolvedValue('654321:1')
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: 'wrong1' }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockRedisEval).toHaveBeenCalledWith(
- expect.any(String),
- 1,
- `form-otp:${mockEmail}:${mockFormId}`,
- 5
- )
- expect(mockCreateErrorResponse).toHaveBeenCalledWith('Invalid verification code', 400)
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('invalidates OTP and returns 429 after max failed attempts', async () => {
- selectOnce([verifyDeploymentRow])
- mockRedisGet.mockResolvedValue('654321:4')
- mockRedisEval.mockResolvedValue('LOCKED')
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: 'wrong5' }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'Too many failed attempts. Please request a new code.',
- 429
- )
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('rejects when stored OTP is already at max attempts', async () => {
- selectOnce([verifyDeploymentRow])
- mockRedisGet.mockResolvedValue(`${mockOTP}:5`)
- const deleteWhere = vi.fn().mockResolvedValue(undefined)
- mockDbDelete.mockImplementation(() => ({ where: deleteWhere }))
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockCreateErrorResponse).toHaveBeenCalledWith(
- 'Too many failed attempts. Please request a new code.',
- 429
- )
- expect(mockSetFormAuthCookie).not.toHaveBeenCalled()
- })
-
- it('uses database storage path when configured', async () => {
- mockGetStorageMethod.mockReturnValue('database')
- mockGetRedisClient.mockReturnValue(null)
- let selectCallCount = 0
- mockDbSelect.mockImplementation(() => ({
- from: vi.fn().mockReturnValue({
- where: vi.fn().mockReturnValue({
- limit: vi.fn().mockImplementation(() => {
- selectCallCount++
- if (selectCallCount === 1) return Promise.resolve([verifyDeploymentRow])
- return Promise.resolve([
- {
- value: `${mockOTP}:0`,
- expiresAt: new Date(Date.now() + 10 * 60 * 1000),
- },
- ])
- }),
- }),
- }),
- }))
- const deleteWhere = vi.fn().mockResolvedValue(undefined)
- mockDbDelete.mockImplementation(() => ({ where: deleteWhere }))
-
- const request = new NextRequest('http://localhost:3000/api/form/test/otp', {
- method: 'PUT',
- body: JSON.stringify({ email: mockEmail, otp: mockOTP }),
- })
-
- await PUT(request, { params: Promise.resolve({ identifier: mockIdentifier }) })
-
- expect(mockDbDelete).toHaveBeenCalled()
- expect(mockRedisDel).not.toHaveBeenCalled()
- expect(mockSetFormAuthCookie).toHaveBeenCalled()
- })
- })
-})
diff --git a/apps/sim/app/api/form/[identifier]/otp/route.ts b/apps/sim/app/api/form/[identifier]/otp/route.ts
deleted file mode 100644
index 55f3f493ca0..00000000000
--- a/apps/sim/app/api/form/[identifier]/otp/route.ts
+++ /dev/null
@@ -1,225 +0,0 @@
-import { db } from '@sim/db'
-import { form } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, eq, isNull } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
-import { renderOTPEmail } from '@/components/emails'
-import { requestFormEmailOtpContract, verifyFormEmailOtpContract } from '@/lib/api/contracts/forms'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { RateLimiter } from '@/lib/core/rate-limiter'
-import { isEmailAllowed } from '@/lib/core/security/deployment'
-import {
- decodeOTPValue,
- deleteOTP,
- generateOTP,
- getOTP,
- incrementOTPAttempts,
- MAX_OTP_ATTEMPTS,
- OTP_EMAIL_RATE_LIMIT,
- OTP_IP_RATE_LIMIT,
- storeOTP,
-} from '@/lib/core/security/otp'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { sendEmail } from '@/lib/messaging/email/mailer'
-import { setFormAuthCookie } from '@/app/api/form/utils'
-import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('FormOtpAPI')
-
-const rateLimiter = new RateLimiter()
-
-export const POST = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
- const { identifier } = await context.params
- const requestId = generateRequestId()
-
- try {
- const ip = getClientIp(request)
- const ipRateLimit = await rateLimiter.checkRateLimitDirect(
- `form-otp:ip:${identifier}:${ip}`,
- OTP_IP_RATE_LIMIT
- )
- if (!ipRateLimit.allowed) {
- logger.warn(`[${requestId}] OTP IP rate limit exceeded for ${identifier} from ${ip}`)
- const retryAfter = Math.ceil(
- (ipRateLimit.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs) / 1000
- )
- const response = createErrorResponse('Too many requests. Please try again later.', 429)
- response.headers.set('Retry-After', String(retryAfter))
- return response
- }
-
- const parsed = await parseRequest(requestFormEmailOtpContract, request, context, {
- validationErrorResponse: (error) =>
- createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
- })
- if (!parsed.success) return parsed.response
- const { email } = parsed.data.body
-
- const deploymentResult = await db
- .select({
- id: form.id,
- authType: form.authType,
- allowedEmails: form.allowedEmails,
- title: form.title,
- isActive: form.isActive,
- })
- .from(form)
- .where(and(eq(form.identifier, identifier), isNull(form.archivedAt)))
- .limit(1)
-
- if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
- return createErrorResponse('Form not found', 404)
- }
-
- const deployment = deploymentResult[0]
-
- if (!deployment.isActive) {
- return createErrorResponse('This form is currently unavailable', 403)
- }
-
- if (deployment.authType !== 'email') {
- return createErrorResponse('This form does not use email authentication', 400)
- }
-
- const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
- ? (deployment.allowedEmails as string[])
- : []
-
- if (!isEmailAllowed(email, allowedEmails)) {
- return createErrorResponse('Email not authorized for this form', 403)
- }
-
- const emailRateLimit = await rateLimiter.checkRateLimitDirect(
- `form-otp:email:${deployment.id}:${email.toLowerCase()}`,
- OTP_EMAIL_RATE_LIMIT
- )
- if (!emailRateLimit.allowed) {
- logger.warn(
- `[${requestId}] OTP email rate limit exceeded for ${email} on form ${deployment.id}`
- )
- const retryAfter = Math.ceil(
- (emailRateLimit.retryAfterMs ?? OTP_EMAIL_RATE_LIMIT.refillIntervalMs) / 1000
- )
- const response = createErrorResponse(
- 'Too many verification code requests. Please try again later.',
- 429
- )
- response.headers.set('Retry-After', String(retryAfter))
- return response
- }
-
- const otp = generateOTP()
- await storeOTP('form', deployment.id, email, otp)
-
- const emailHtml = await renderOTPEmail(
- otp,
- email,
- 'email-verification',
- deployment.title || 'Form'
- )
-
- const emailResult = await sendEmail({
- to: email,
- subject: `Verification code for ${deployment.title || 'Form'}`,
- html: emailHtml,
- })
-
- if (!emailResult.success) {
- logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message)
- return createErrorResponse('Failed to send verification email', 500)
- }
-
- logger.info(`[${requestId}] OTP sent to ${email} for form ${deployment.id}`)
- return createSuccessResponse({ message: 'Verification code sent' })
- } catch (error) {
- logger.error(`[${requestId}] Error processing OTP request:`, error)
- return createErrorResponse('Failed to process request', 500)
- }
- }
-)
-
-export const PUT = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
- const { identifier } = await context.params
- const requestId = generateRequestId()
-
- try {
- const parsed = await parseRequest(verifyFormEmailOtpContract, request, context, {
- validationErrorResponse: (error) =>
- createErrorResponse(getValidationErrorMessage(error, 'Invalid request'), 400),
- })
- if (!parsed.success) return parsed.response
- const { email, otp } = parsed.data.body
-
- const deploymentResult = await db
- .select({
- id: form.id,
- authType: form.authType,
- password: form.password,
- allowedEmails: form.allowedEmails,
- isActive: form.isActive,
- })
- .from(form)
- .where(and(eq(form.identifier, identifier), isNull(form.archivedAt)))
- .limit(1)
-
- if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
- return createErrorResponse('Form not found', 404)
- }
-
- const deployment = deploymentResult[0]
-
- if (!deployment.isActive) {
- return createErrorResponse('This form is currently unavailable', 403)
- }
-
- if (deployment.authType !== 'email') {
- return createErrorResponse('This form does not use email authentication', 400)
- }
-
- const allowedEmails: string[] = Array.isArray(deployment.allowedEmails)
- ? (deployment.allowedEmails as string[])
- : []
-
- if (!isEmailAllowed(email, allowedEmails)) {
- return createErrorResponse('Email not authorized for this form', 403)
- }
-
- const storedValue = await getOTP('form', deployment.id, email)
- if (!storedValue) {
- return createErrorResponse('No verification code found, request a new one', 400)
- }
-
- const { otp: storedOTP, attempts } = decodeOTPValue(storedValue)
-
- if (attempts >= MAX_OTP_ATTEMPTS) {
- await deleteOTP('form', deployment.id, email)
- logger.warn(`[${requestId}] OTP already locked out for ${email}`)
- return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
- }
-
- if (storedOTP !== otp) {
- const result = await incrementOTPAttempts('form', deployment.id, email, storedValue)
- if (result === 'locked') {
- logger.warn(`[${requestId}] OTP invalidated after max failed attempts for ${email}`)
- return createErrorResponse('Too many failed attempts. Please request a new code.', 429)
- }
- return createErrorResponse('Invalid verification code', 400)
- }
-
- await deleteOTP('form', deployment.id, email)
-
- const response = createSuccessResponse({ authenticated: true })
- setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password)
-
- return response
- } catch (error) {
- logger.error(`[${requestId}] Error verifying OTP:`, error)
- return createErrorResponse('Failed to process request', 500)
- }
- }
-)
diff --git a/apps/sim/app/api/form/[identifier]/route.ts b/apps/sim/app/api/form/[identifier]/route.ts
deleted file mode 100644
index 46b0f1e068f..00000000000
--- a/apps/sim/app/api/form/[identifier]/route.ts
+++ /dev/null
@@ -1,361 +0,0 @@
-import { db } from '@sim/db'
-import { form, workflow, workflowBlocks } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { and, eq, isNull } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { formSubmitBodySchema } from '@/lib/api/contracts/forms'
-import { parseJsonBody } from '@/lib/api/server'
-import { validateAuthToken } from '@/lib/core/security/deployment'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { preprocessExecution } from '@/lib/execution/preprocessing'
-import { LoggingSession } from '@/lib/logs/execution/logging-session'
-import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow'
-import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
-import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
-import { isInputDefinitionTrigger } from '@/lib/workflows/triggers/input-definition-triggers'
-import { setFormAuthCookie, validateFormAuth } from '@/app/api/form/utils'
-import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('FormIdentifierAPI')
-
-export const dynamic = 'force-dynamic'
-export const runtime = 'nodejs'
-
-/**
- * Get the input format schema from the workflow's start block
- */
-async function getWorkflowInputSchema(workflowId: string): Promise {
- try {
- const blocks = await db
- .select()
- .from(workflowBlocks)
- .where(eq(workflowBlocks.workflowId, workflowId))
-
- const startBlock = blocks.find((block) => isInputDefinitionTrigger(block.type))
-
- if (!startBlock) {
- return []
- }
-
- const subBlocks = startBlock.subBlocks as Record | null
- return normalizeInputFormatValue(subBlocks?.inputFormat?.value)
- } catch (error) {
- logger.error('Error fetching workflow input schema:', error)
- return []
- }
-}
-
-export const POST = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => {
- const { identifier } = await params
- const requestId = generateRequestId()
-
- try {
- const parsedJson = await parseJsonBody(request)
- if (!parsedJson.success) {
- return createErrorResponse('Invalid request body', 400)
- }
-
- const bodyValidation = formSubmitBodySchema.safeParse(parsedJson.data)
- if (!bodyValidation.success) {
- const errorMessage = bodyValidation.error.issues
- .map((err) => `${err.path.join('.')}: ${err.message}`)
- .join(', ')
- logger.warn(`[${requestId}] Validation error: ${errorMessage}`)
- return createErrorResponse(`Invalid request body: ${errorMessage}`, 400)
- }
-
- const parsedBody = bodyValidation.data
-
- const deploymentResult = await db
- .select({
- id: form.id,
- workflowId: form.workflowId,
- userId: form.userId,
- isActive: form.isActive,
- authType: form.authType,
- password: form.password,
- allowedEmails: form.allowedEmails,
- customizations: form.customizations,
- })
- .from(form)
- .where(and(eq(form.identifier, identifier), isNull(form.archivedAt)))
- .limit(1)
-
- if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
- return createErrorResponse('Form not found', 404)
- }
-
- const deployment = deploymentResult[0]
-
- if (!deployment.isActive) {
- logger.warn(`[${requestId}] Form is not active: ${identifier}`)
-
- const [workflowRecord] = await db
- .select({ workspaceId: workflow.workspaceId })
- .from(workflow)
- .where(and(eq(workflow.id, deployment.workflowId), isNull(workflow.archivedAt)))
- .limit(1)
-
- const workspaceId = workflowRecord?.workspaceId
- if (!workspaceId) {
- logger.warn(
- `[${requestId}] Cannot log: workflow ${deployment.workflowId} has no workspace`
- )
- return createErrorResponse('This form is currently unavailable', 403)
- }
-
- const executionId = generateId()
- const loggingSession = new LoggingSession(
- deployment.workflowId,
- executionId,
- 'form',
- requestId
- )
-
- await loggingSession.safeStart({
- userId: deployment.userId,
- workspaceId,
- variables: {},
- })
-
- await loggingSession.safeCompleteWithError({
- error: {
- message: 'This form is currently unavailable. The form has been disabled.',
- stackTrace: undefined,
- },
- traceSpans: [],
- })
-
- return createErrorResponse('This form is currently unavailable', 403)
- }
-
- const authResult = await validateFormAuth(requestId, deployment, request, parsedBody)
- if (!authResult.authorized) {
- return createErrorResponse(authResult.error || 'Authentication required', 401)
- }
-
- const { formData, password, email } = parsedBody
-
- // If only authentication credentials provided (no form data), just return authenticated
- if ((password || email) && !formData) {
- const response = createSuccessResponse({ authenticated: true })
- setFormAuthCookie(response, deployment.id, deployment.authType, deployment.password)
- return response
- }
-
- if (!formData || Object.keys(formData).length === 0) {
- return createErrorResponse('No form data provided', 400)
- }
-
- const executionId = generateId()
- const loggingSession = new LoggingSession(
- deployment.workflowId,
- executionId,
- 'form',
- requestId
- )
-
- const preprocessResult = await preprocessExecution({
- workflowId: deployment.workflowId,
- userId: deployment.userId,
- triggerType: 'form',
- executionId,
- requestId,
- checkRateLimit: true,
- checkDeployment: true,
- loggingSession,
- })
-
- if (!preprocessResult.success) {
- logger.warn(`[${requestId}] Preprocessing failed: ${preprocessResult.error?.message}`)
- return createErrorResponse(
- preprocessResult.error?.message || 'Failed to process request',
- preprocessResult.error?.statusCode || 500
- )
- }
-
- const { actorUserId, workflowRecord } = preprocessResult
- const workspaceOwnerId = actorUserId!
- const workspaceId = workflowRecord?.workspaceId
- if (!workspaceId) {
- logger.error(`[${requestId}] Workflow ${deployment.workflowId} has no workspaceId`)
- return createErrorResponse('Workflow has no associated workspace', 500)
- }
-
- try {
- const workflowForExecution = {
- id: deployment.workflowId,
- userId: deployment.userId,
- workspaceId,
- isDeployed: workflowRecord?.isDeployed ?? false,
- variables: (workflowRecord?.variables ?? {}) as Record,
- }
-
- // Pass form data as the workflow input
- const workflowInput = {
- input: formData,
- ...formData, // Spread form fields at top level for convenience
- }
-
- const stream = await createStreamingResponse({
- requestId,
- streamConfig: {
- selectedOutputs: [],
- isSecureMode: true,
- workflowTriggerType: 'api',
- },
- executionId,
- workspaceId,
- workflowId: deployment.workflowId,
- userId: workspaceOwnerId,
- executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
- executeWorkflow(
- workflowForExecution,
- requestId,
- workflowInput,
- workspaceOwnerId,
- {
- enabled: true,
- selectedOutputs: [],
- isSecureMode: true,
- workflowTriggerType: 'api',
- onStream,
- onBlockComplete,
- skipLoggingComplete: true,
- abortSignal,
- executionMode: 'sync',
- },
- executionId
- ),
- })
-
- const reader = stream.getReader()
- try {
- while (!(await reader.read()).done) {
- /* drain to let the workflow run to completion */
- }
- } finally {
- reader.releaseLock()
- }
-
- logger.info(`[${requestId}] Form submission successful for ${identifier}`)
-
- // Return success with customizations for thank you screen
- const customizations = deployment.customizations as Record | null
- return createSuccessResponse({
- success: true,
- executionId,
- thankYouTitle: customizations?.thankYouTitle || 'Thank you!',
- thankYouMessage:
- customizations?.thankYouMessage || 'Your response has been submitted successfully.',
- })
- } catch (error: any) {
- logger.error(`[${requestId}] Error processing form submission:`, error)
- return createErrorResponse(error.message || 'Failed to process form submission', 500)
- }
- } catch (error: any) {
- logger.error(`[${requestId}] Error processing form submission:`, error)
- return createErrorResponse(error.message || 'Failed to process form submission', 500)
- }
- }
-)
-
-export const GET = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ identifier: string }> }) => {
- const { identifier } = await params
- const requestId = generateRequestId()
-
- try {
- const deploymentResult = await db
- .select({
- id: form.id,
- title: form.title,
- description: form.description,
- customizations: form.customizations,
- isActive: form.isActive,
- workflowId: form.workflowId,
- authType: form.authType,
- password: form.password,
- allowedEmails: form.allowedEmails,
- showBranding: form.showBranding,
- })
- .from(form)
- .where(and(eq(form.identifier, identifier), isNull(form.archivedAt)))
- .limit(1)
-
- if (deploymentResult.length === 0) {
- logger.warn(`[${requestId}] Form not found for identifier: ${identifier}`)
- return createErrorResponse('Form not found', 404)
- }
-
- const deployment = deploymentResult[0]
-
- if (!deployment.isActive) {
- logger.warn(`[${requestId}] Form is not active: ${identifier}`)
- return createErrorResponse('This form is currently unavailable', 403)
- }
-
- // Get the workflow's input schema
- const inputSchema = await getWorkflowInputSchema(deployment.workflowId)
-
- const cookieName = `form_auth_${deployment.id}`
- const authCookie = request.cookies.get(cookieName)
-
- // If authenticated (via cookie), return full form config
- if (
- deployment.authType !== 'public' &&
- authCookie &&
- validateAuthToken(authCookie.value, deployment.id, deployment.password)
- ) {
- return createSuccessResponse({
- id: deployment.id,
- title: deployment.title,
- description: deployment.description,
- customizations: deployment.customizations,
- authType: deployment.authType,
- showBranding: deployment.showBranding,
- inputSchema,
- })
- }
-
- // Check authentication requirement
- const authResult = await validateFormAuth(requestId, deployment, request)
- if (!authResult.authorized) {
- // Return limited info for auth required forms
- logger.info(
- `[${requestId}] Authentication required for form: ${identifier}, type: ${deployment.authType}`
- )
- return NextResponse.json(
- {
- success: false,
- error: authResult.error || 'Authentication required',
- authType: deployment.authType,
- title: deployment.title,
- customizations: {
- primaryColor: (deployment.customizations as any)?.primaryColor,
- logoUrl: (deployment.customizations as any)?.logoUrl,
- },
- },
- { status: 401 }
- )
- }
-
- return createSuccessResponse({
- id: deployment.id,
- title: deployment.title,
- description: deployment.description,
- customizations: deployment.customizations,
- authType: deployment.authType,
- showBranding: deployment.showBranding,
- inputSchema,
- })
- } catch (error: any) {
- logger.error(`[${requestId}] Error fetching form info:`, error)
- return createErrorResponse(error.message || 'Failed to fetch form information', 500)
- }
- }
-)
diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts
deleted file mode 100644
index 982a8b36bda..00000000000
--- a/apps/sim/app/api/form/manage/[id]/route.ts
+++ /dev/null
@@ -1,226 +0,0 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { db } from '@sim/db'
-import { form } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { and, eq, isNull } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
-import { formIdParamsSchema, updateFormContract } from '@/lib/api/contracts/forms'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { encryptSecret } from '@/lib/core/security/encryption'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
-import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('FormManageAPI')
-
-export const GET = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- try {
- const session = await getSession()
-
- if (!session) {
- return createErrorResponse('Unauthorized', 401)
- }
-
- const { id } = formIdParamsSchema.parse(await params)
-
- const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
-
- if (!hasAccess || !formRecord) {
- return createErrorResponse('Form not found or access denied', 404)
- }
-
- const { password: _password, ...formWithoutPassword } = formRecord
-
- return createSuccessResponse({
- form: {
- ...formWithoutPassword,
- hasPassword: !!formRecord.password,
- },
- })
- } catch (error) {
- logger.error('Error fetching form:', error)
- return createErrorResponse(getErrorMessage(error, 'Failed to fetch form'), 500)
- }
- }
-)
-
-export const PATCH = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- try {
- const session = await getSession()
-
- if (!session) {
- return createErrorResponse('Unauthorized', 401)
- }
-
- const parsed = await parseRequest(updateFormContract, request, context, {
- validationErrorResponse: (error) =>
- createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'),
- })
- if (!parsed.success) return parsed.response
-
- const { id } = parsed.data.params
- const {
- identifier,
- title,
- description,
- customizations,
- authType,
- password,
- allowedEmails,
- showBranding,
- isActive,
- } = parsed.data.body
-
- const {
- hasAccess,
- form: formRecord,
- workspaceId: formWorkspaceId,
- } = await checkFormAccess(id, session.user.id)
-
- if (!hasAccess || !formRecord) {
- return createErrorResponse('Form not found or access denied', 404)
- }
-
- if (identifier && identifier !== formRecord.identifier) {
- const existingIdentifier = await db
- .select()
- .from(form)
- .where(and(eq(form.identifier, identifier), isNull(form.archivedAt)))
- .limit(1)
-
- if (existingIdentifier.length > 0) {
- return createErrorResponse('Identifier already in use', 400)
- }
- }
-
- if (authType === 'password' && !password && !formRecord.password) {
- return createErrorResponse('Password is required when using password protection', 400)
- }
-
- if (
- authType === 'email' &&
- (!allowedEmails || allowedEmails.length === 0) &&
- (!formRecord.allowedEmails || (formRecord.allowedEmails as string[]).length === 0)
- ) {
- return createErrorResponse(
- 'At least one email or domain is required when using email access control',
- 400
- )
- }
-
- const updateData: Record = {
- updatedAt: new Date(),
- }
-
- if (identifier !== undefined) updateData.identifier = identifier
- if (title !== undefined) updateData.title = title
- if (description !== undefined) updateData.description = description
- if (showBranding !== undefined) updateData.showBranding = showBranding
- if (isActive !== undefined) updateData.isActive = isActive
- if (authType !== undefined) updateData.authType = authType
- if (allowedEmails !== undefined) updateData.allowedEmails = allowedEmails
-
- if (customizations !== undefined) {
- const existingCustomizations = (formRecord.customizations as Record) || {}
- updateData.customizations = {
- ...DEFAULT_FORM_CUSTOMIZATIONS,
- ...existingCustomizations,
- ...customizations,
- }
- }
-
- if (password) {
- const { encrypted } = await encryptSecret(password)
- updateData.password = encrypted
- } else if (authType && authType !== 'password') {
- updateData.password = null
- }
-
- await db.update(form).set(updateData).where(eq(form.id, id))
-
- logger.info(`Form ${id} updated successfully`)
-
- recordAudit({
- workspaceId: formWorkspaceId ?? null,
- actorId: session.user.id,
- action: AuditAction.FORM_UPDATED,
- resourceType: AuditResourceType.FORM,
- resourceId: id,
- actorName: session.user.name ?? undefined,
- actorEmail: session.user.email ?? undefined,
- resourceName: (title || formRecord.title) ?? undefined,
- description: `Updated form "${title || formRecord.title}"`,
- metadata: {
- identifier: identifier || formRecord.identifier,
- workflowId: formRecord.workflowId,
- authType: authType || formRecord.authType,
- updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'),
- },
- request,
- })
-
- return createSuccessResponse({
- message: 'Form updated successfully',
- })
- } catch (error) {
- logger.error('Error updating form:', error)
- return createErrorResponse(getErrorMessage(error, 'Failed to update form'), 500)
- }
- }
-)
-
-export const DELETE = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- try {
- const session = await getSession()
-
- if (!session) {
- return createErrorResponse('Unauthorized', 401)
- }
-
- const { id } = formIdParamsSchema.parse(await params)
-
- const {
- hasAccess,
- form: formRecord,
- workspaceId: formWorkspaceId,
- } = await checkFormAccess(id, session.user.id)
-
- if (!hasAccess || !formRecord) {
- return createErrorResponse('Form not found or access denied', 404)
- }
-
- await db
- .update(form)
- .set({ archivedAt: new Date(), isActive: false, updatedAt: new Date() })
- .where(eq(form.id, id))
-
- logger.info(`Form ${id} soft deleted`)
-
- recordAudit({
- workspaceId: formWorkspaceId ?? null,
- actorId: session.user.id,
- action: AuditAction.FORM_DELETED,
- resourceType: AuditResourceType.FORM,
- resourceId: id,
- actorName: session.user.name ?? undefined,
- actorEmail: session.user.email ?? undefined,
- resourceName: formRecord.title ?? undefined,
- description: `Deleted form "${formRecord.title}"`,
- metadata: { identifier: formRecord.identifier, workflowId: formRecord.workflowId },
- request,
- })
-
- return createSuccessResponse({
- message: 'Form deleted successfully',
- })
- } catch (error) {
- logger.error('Error deleting form:', error)
- return createErrorResponse(getErrorMessage(error, 'Failed to delete form'), 500)
- }
- }
-)
diff --git a/apps/sim/app/api/form/route.test.ts b/apps/sim/app/api/form/route.test.ts
deleted file mode 100644
index 4be49a941eb..00000000000
--- a/apps/sim/app/api/form/route.test.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-/**
- * @vitest-environment node
- */
-import {
- authMockFns,
- dbChainMock,
- dbChainMockFns,
- resetDbChainMock,
- workflowsApiUtilsMock,
- workflowsApiUtilsMockFns,
- workflowsOrchestrationMock,
- workflowsOrchestrationMockFns,
-} from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockCheckWorkflowAccessForFormCreation } = vi.hoisted(() => ({
- mockCheckWorkflowAccessForFormCreation: vi.fn(),
-}))
-
-const mockCreateErrorResponse = workflowsApiUtilsMockFns.mockCreateErrorResponse
-const mockPerformFullDeploy = workflowsOrchestrationMockFns.mockPerformFullDeploy
-
-vi.mock('@sim/db', () => dbChainMock)
-
-vi.mock('@sim/utils/id', () => ({
- generateId: vi.fn(() => 'form-123'),
-}))
-
-vi.mock('@/app/api/form/utils', () => ({
- checkWorkflowAccessForFormCreation: mockCheckWorkflowAccessForFormCreation,
- DEFAULT_FORM_CUSTOMIZATIONS: {},
-}))
-
-vi.mock('@/app/api/workflows/utils', () => workflowsApiUtilsMock)
-
-vi.mock('@/lib/core/config/feature-flags', () => ({
- isDev: true,
-}))
-
-vi.mock('@/lib/core/utils/urls', () => ({
- getEmailDomain: vi.fn(() => 'localhost:3000'),
-}))
-
-vi.mock('@/lib/workflows/orchestration', () => workflowsOrchestrationMock)
-
-import { POST } from '@/app/api/form/route'
-
-describe('Form API Route', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- resetDbChainMock()
-
- authMockFns.mockGetSession.mockResolvedValue({
- user: {
- id: 'user-123',
- email: 'user@example.com',
- name: 'Test User',
- },
- })
- mockCreateErrorResponse.mockImplementation((message, status = 500) => {
- return new Response(JSON.stringify({ error: message }), {
- status,
- headers: { 'Content-Type': 'application/json' },
- })
- })
- mockCheckWorkflowAccessForFormCreation.mockResolvedValue({
- hasAccess: true,
- workflow: {
- id: 'workflow-123',
- isDeployed: false,
- workspaceId: 'workspace-123',
- },
- })
- dbChainMockFns.limit.mockResolvedValue([])
- })
-
- it('cleans up inserted form when deploy throws', async () => {
- mockPerformFullDeploy.mockRejectedValue(new Error('Deploy exploded'))
-
- const request = new NextRequest('http://localhost:3000/api/form', {
- method: 'POST',
- body: JSON.stringify({
- workflowId: 'workflow-123',
- identifier: 'test-form',
- title: 'Test Form',
- }),
- })
-
- const response = await POST(request)
-
- expect(response.status).toBe(500)
- expect(dbChainMockFns.insert).toHaveBeenCalled()
- expect(dbChainMockFns.delete).toHaveBeenCalled()
- expect(mockCreateErrorResponse).toHaveBeenCalledWith('Deploy exploded', 500)
- })
-})
diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts
deleted file mode 100644
index 2b232c9132f..00000000000
--- a/apps/sim/app/api/form/route.ts
+++ /dev/null
@@ -1,206 +0,0 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { db } from '@sim/db'
-import { form } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { generateId } from '@sim/utils/id'
-import { and, eq, isNull } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
-import { createFormContract } from '@/lib/api/contracts/forms'
-import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { isDev } from '@/lib/core/config/feature-flags'
-import { encryptSecret } from '@/lib/core/security/encryption'
-import { getEmailDomain } from '@/lib/core/utils/urls'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { performFullDeploy } from '@/lib/workflows/orchestration'
-import {
- checkWorkflowAccessForFormCreation,
- DEFAULT_FORM_CUSTOMIZATIONS,
-} from '@/app/api/form/utils'
-import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('FormAPI')
-export const maxDuration = 120
-
-async function cleanupFormAfterDeployFailure(formId: string) {
- try {
- await db.delete(form).where(eq(form.id, formId))
- } catch (cleanupError) {
- logger.error('Failed to clean up form after deploy failure:', cleanupError)
- }
-}
-
-export const GET = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
-
- if (!session) {
- return createErrorResponse('Unauthorized', 401)
- }
-
- const deployments = await db
- .select()
- .from(form)
- .where(and(eq(form.userId, session.user.id), isNull(form.archivedAt)))
-
- return createSuccessResponse({ deployments })
- } catch (error) {
- logger.error('Error fetching form deployments:', error)
- return createErrorResponse(getErrorMessage(error, 'Failed to fetch form deployments'), 500)
- }
-})
-
-export const POST = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
-
- if (!session) {
- return createErrorResponse('Unauthorized', 401)
- }
-
- const parsed = await parseRequest(
- createFormContract,
- request,
- {},
- {
- validationErrorResponse: (error) =>
- createErrorResponse(getValidationErrorMessage(error), 400, 'VALIDATION_ERROR'),
- }
- )
- if (!parsed.success) return parsed.response
-
- const {
- workflowId,
- identifier,
- title,
- description = '',
- customizations,
- authType = 'public',
- password,
- allowedEmails = [],
- showBranding = true,
- } = parsed.data.body
-
- if (authType === 'password' && !password) {
- return createErrorResponse('Password is required when using password protection', 400)
- }
-
- if (authType === 'email' && (!Array.isArray(allowedEmails) || allowedEmails.length === 0)) {
- return createErrorResponse(
- 'At least one email or domain is required when using email access control',
- 400
- )
- }
-
- // Check identifier availability and workflow access in parallel
- const [existingIdentifier, { hasAccess, workflow: workflowRecord }] = await Promise.all([
- db
- .select()
- .from(form)
- .where(and(eq(form.identifier, identifier), isNull(form.archivedAt)))
- .limit(1),
- checkWorkflowAccessForFormCreation(workflowId, session.user.id),
- ])
-
- if (existingIdentifier.length > 0) {
- return createErrorResponse('Identifier already in use', 400)
- }
-
- if (!hasAccess || !workflowRecord) {
- return createErrorResponse('Workflow not found or access denied', 404)
- }
-
- let encryptedPassword = null
- if (authType === 'password' && password) {
- const { encrypted } = await encryptSecret(password)
- encryptedPassword = encrypted
- }
-
- const id = generateId()
-
- logger.info('Creating form deployment with values:', {
- workflowId,
- identifier,
- title,
- authType,
- hasPassword: !!encryptedPassword,
- emailCount: allowedEmails?.length || 0,
- showBranding,
- })
-
- const mergedCustomizations = {
- ...DEFAULT_FORM_CUSTOMIZATIONS,
- ...(customizations || {}),
- }
-
- await db.insert(form).values({
- id,
- workflowId,
- userId: session.user.id,
- identifier,
- title,
- description: description || null,
- customizations: mergedCustomizations,
- isActive: true,
- authType,
- password: encryptedPassword,
- allowedEmails: authType === 'email' ? allowedEmails : [],
- showBranding,
- createdAt: new Date(),
- updatedAt: new Date(),
- })
-
- let result: Awaited>
- try {
- result = await performFullDeploy({
- workflowId,
- userId: session.user.id,
- request,
- })
- } catch (error) {
- await cleanupFormAfterDeployFailure(id)
- throw error
- }
-
- if (!result.success) {
- await cleanupFormAfterDeployFailure(id)
- const status =
- result.errorCode === 'validation' ? 400 : result.errorCode === 'not_found' ? 404 : 500
- return createErrorResponse(result.error || 'Failed to deploy workflow', status)
- }
-
- logger.info(
- `${workflowRecord.isDeployed ? 'Redeployed' : 'Auto-deployed'} workflow ${workflowId} for form (v${result.version})`
- )
-
- const baseDomain = getEmailDomain()
- const protocol = isDev ? 'http' : 'https'
- const formUrl = `${protocol}://${baseDomain}/form/${identifier}`
-
- logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
-
- recordAudit({
- workspaceId: workflowRecord.workspaceId ?? null,
- actorId: session.user.id,
- action: AuditAction.FORM_CREATED,
- resourceType: AuditResourceType.FORM,
- resourceId: id,
- actorName: session.user.name ?? undefined,
- actorEmail: session.user.email ?? undefined,
- resourceName: title,
- description: `Created form "${title}" for workflow ${workflowId}`,
- metadata: { identifier, workflowId, authType, formUrl, showBranding },
- request,
- })
-
- return createSuccessResponse({
- id,
- formUrl,
- message: 'Form deployment created successfully',
- })
- } catch (error) {
- logger.error('Error creating form deployment:', error)
- return createErrorResponse(getErrorMessage(error, 'Failed to create form deployment'), 500)
- }
-})
diff --git a/apps/sim/app/api/form/utils.test.ts b/apps/sim/app/api/form/utils.test.ts
deleted file mode 100644
index d6b51c2d778..00000000000
--- a/apps/sim/app/api/form/utils.test.ts
+++ /dev/null
@@ -1,296 +0,0 @@
-/**
- * Tests for form API utils
- *
- * @vitest-environment node
- */
-import { encryptionMock, encryptionMockFns, workflowsUtilsMock } from '@sim/testing'
-import type { NextResponse } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-const { mockValidateAuthToken, mockSetDeploymentAuthCookie, mockIsEmailAllowed } = vi.hoisted(
- () => ({
- mockValidateAuthToken: vi.fn().mockReturnValue(false),
- mockSetDeploymentAuthCookie: vi.fn(),
- mockIsEmailAllowed: vi.fn(),
- })
-)
-
-const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
-
-vi.mock('@/lib/core/security/encryption', () => encryptionMock)
-
-vi.mock('@/lib/core/security/deployment', () => ({
- validateAuthToken: mockValidateAuthToken,
- setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
- isEmailAllowed: mockIsEmailAllowed,
-}))
-
-vi.mock('@/lib/core/config/feature-flags', () => ({
- isDev: true,
- isHosted: false,
- isProd: false,
-}))
-
-vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
-
-import { decryptSecret } from '@/lib/core/security/encryption'
-import {
- DEFAULT_FORM_CUSTOMIZATIONS,
- setFormAuthCookie,
- validateFormAuth,
-} from '@/app/api/form/utils'
-
-describe('Form API Utils', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- describe('Auth token utils', () => {
- it('should accept valid auth cookie via validateFormAuth', async () => {
- mockValidateAuthToken.mockReturnValue(true)
-
- const deployment = {
- id: 'form-id',
- authType: 'password',
- password: 'encrypted-password',
- }
-
- const mockRequest = {
- method: 'POST',
- cookies: {
- get: vi.fn().mockReturnValue({ value: 'valid-token' }),
- },
- } as any
-
- const result = await validateFormAuth('request-id', deployment, mockRequest)
- expect(mockValidateAuthToken).toHaveBeenCalledWith(
- 'valid-token',
- 'form-id',
- 'encrypted-password'
- )
- expect(result.authorized).toBe(true)
- })
-
- it('should reject invalid auth cookie via validateFormAuth', async () => {
- mockValidateAuthToken.mockReturnValue(false)
-
- const deployment = {
- id: 'form-id',
- authType: 'password',
- password: 'encrypted-password',
- }
-
- const mockRequest = {
- method: 'GET',
- cookies: {
- get: vi.fn().mockReturnValue({ value: 'invalid-token' }),
- },
- } as any
-
- const result = await validateFormAuth('request-id', deployment, mockRequest)
- expect(result.authorized).toBe(false)
- })
- })
-
- describe('Cookie handling', () => {
- it('should delegate to setDeploymentAuthCookie', () => {
- const mockResponse = {
- cookies: { set: vi.fn() },
- } as unknown as NextResponse
-
- setFormAuthCookie(mockResponse, 'test-form-id', 'password')
-
- expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith(
- mockResponse,
- 'form',
- 'test-form-id',
- 'password',
- undefined
- )
- })
- })
-
- describe('Form auth validation', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- mockDecryptSecret.mockResolvedValue({ decrypted: 'correct-password' })
- })
-
- it('should allow access to public forms', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'public',
- }
-
- const mockRequest = {
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- const result = await validateFormAuth('request-id', deployment, mockRequest)
-
- expect(result.authorized).toBe(true)
- })
-
- it('should request password auth for GET requests', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'password',
- }
-
- const mockRequest = {
- method: 'GET',
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- const result = await validateFormAuth('request-id', deployment, mockRequest)
-
- expect(result.authorized).toBe(false)
- expect(result.error).toBe('auth_required_password')
- })
-
- it('should validate password for POST requests', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'password',
- password: 'encrypted-password',
- }
-
- const mockRequest = {
- method: 'POST',
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- const parsedBody = {
- password: 'correct-password',
- }
-
- const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody)
-
- expect(decryptSecret).toHaveBeenCalledWith('encrypted-password')
- expect(result.authorized).toBe(true)
- })
-
- it('should reject incorrect password', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'password',
- password: 'encrypted-password',
- }
-
- const mockRequest = {
- method: 'POST',
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- const parsedBody = {
- password: 'wrong-password',
- }
-
- const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody)
-
- expect(result.authorized).toBe(false)
- expect(result.error).toBe('Invalid password')
- })
-
- it('should request email auth for email-protected forms', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'email',
- allowedEmails: ['user@example.com', '@company.com'],
- }
-
- const mockRequest = {
- method: 'GET',
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- const result = await validateFormAuth('request-id', deployment, mockRequest)
-
- expect(result.authorized).toBe(false)
- expect(result.error).toBe('auth_required_email')
- })
-
- it('should check allowed emails for email auth', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'email',
- allowedEmails: ['user@example.com', '@company.com'],
- }
-
- const mockRequest = {
- method: 'POST',
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- // Exact email match should require OTP verification, not authorize directly
- mockIsEmailAllowed.mockReturnValue(true)
- const result1 = await validateFormAuth('request-id', deployment, mockRequest, {
- email: 'user@example.com',
- })
- expect(result1.authorized).toBe(false)
- expect(result1.error).toBe('otp_required')
-
- // Domain match should also require OTP verification
- const result2 = await validateFormAuth('request-id', deployment, mockRequest, {
- email: 'other@company.com',
- })
- expect(result2.authorized).toBe(false)
- expect(result2.error).toBe('otp_required')
-
- // Unknown email should not authorize
- mockIsEmailAllowed.mockReturnValue(false)
- const result3 = await validateFormAuth('request-id', deployment, mockRequest, {
- email: 'user@unknown.com',
- })
- expect(result3.authorized).toBe(false)
- expect(result3.error).toBe('Email not authorized for this form')
- })
-
- it('should require password when formData is present without password', async () => {
- const deployment = {
- id: 'form-id',
- authType: 'password',
- password: 'encrypted-password',
- }
-
- const mockRequest = {
- method: 'POST',
- cookies: {
- get: vi.fn().mockReturnValue(null),
- },
- } as any
-
- const parsedBody = {
- formData: { field1: 'value1' },
- // No password provided
- }
-
- const result = await validateFormAuth('request-id', deployment, mockRequest, parsedBody)
-
- expect(result.authorized).toBe(false)
- expect(result.error).toBe('auth_required_password')
- })
- })
-
- describe('Default customizations', () => {
- it.concurrent('should have correct default values', () => {
- expect(DEFAULT_FORM_CUSTOMIZATIONS).toEqual({
- welcomeMessage: '',
- thankYouTitle: 'Thank you!',
- thankYouMessage: 'Your response has been submitted successfully.',
- })
- })
- })
-})
diff --git a/apps/sim/app/api/form/utils.ts b/apps/sim/app/api/form/utils.ts
deleted file mode 100644
index 7b1f1df54dc..00000000000
--- a/apps/sim/app/api/form/utils.ts
+++ /dev/null
@@ -1,194 +0,0 @@
-import { db } from '@sim/db'
-import { form, workflow } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
-import { and, eq, isNull } from 'drizzle-orm'
-import type { NextRequest, NextResponse } from 'next/server'
-import {
- isEmailAllowed,
- setDeploymentAuthCookie,
- validateAuthToken,
-} from '@/lib/core/security/deployment'
-import { decryptSecret } from '@/lib/core/security/encryption'
-
-const logger = createLogger('FormAuthUtils')
-
-export function setFormAuthCookie(
- response: NextResponse,
- formId: string,
- type: string,
- encryptedPassword?: string | null
-): void {
- setDeploymentAuthCookie(response, 'form', formId, type, encryptedPassword)
-}
-
-/**
- * Check if user has permission to create a form for a specific workflow
- */
-export async function checkWorkflowAccessForFormCreation(
- workflowId: string,
- userId: string
-): Promise<{ hasAccess: boolean; workflow?: any }> {
- const authorization = await authorizeWorkflowByWorkspacePermission({
- workflowId,
- userId,
- action: 'admin',
- })
-
- if (!authorization.workflow) {
- return { hasAccess: false }
- }
-
- if (authorization.allowed) {
- return { hasAccess: true, workflow: authorization.workflow }
- }
-
- return { hasAccess: false }
-}
-
-/**
- * Check if user has access to view/edit/delete a specific form
- */
-export async function checkFormAccess(
- formId: string,
- userId: string
-): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> {
- const formData = await db
- .select({ form: form, workflowWorkspaceId: workflow.workspaceId })
- .from(form)
- .innerJoin(workflow, eq(form.workflowId, workflow.id))
- .where(and(eq(form.id, formId), isNull(form.archivedAt)))
- .limit(1)
-
- if (formData.length === 0) {
- return { hasAccess: false }
- }
-
- const { form: formRecord, workflowWorkspaceId } = formData[0]
- if (!workflowWorkspaceId) {
- return { hasAccess: false }
- }
-
- const authorization = await authorizeWorkflowByWorkspacePermission({
- workflowId: formRecord.workflowId,
- userId,
- action: 'admin',
- })
-
- return authorization.allowed
- ? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId }
- : { hasAccess: false }
-}
-
-export async function validateFormAuth(
- requestId: string,
- deployment: any,
- request: NextRequest,
- parsedBody?: any
-): Promise<{ authorized: boolean; error?: string }> {
- const authType = deployment.authType || 'public'
-
- if (authType === 'public') {
- return { authorized: true }
- }
-
- const cookieName = `form_auth_${deployment.id}`
- const authCookie = request.cookies.get(cookieName)
-
- if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
- return { authorized: true }
- }
-
- if (authType === 'password') {
- if (request.method === 'GET') {
- return { authorized: false, error: 'auth_required_password' }
- }
-
- try {
- if (!parsedBody) {
- return { authorized: false, error: 'Password is required' }
- }
-
- const { password, formData } = parsedBody
-
- if (formData && !password) {
- return { authorized: false, error: 'auth_required_password' }
- }
-
- if (!password) {
- return { authorized: false, error: 'Password is required' }
- }
-
- if (!deployment.password) {
- logger.error(`[${requestId}] No password set for password-protected form: ${deployment.id}`)
- return { authorized: false, error: 'Authentication configuration error' }
- }
-
- const { decrypted } = await decryptSecret(deployment.password)
- if (password !== decrypted) {
- return { authorized: false, error: 'Invalid password' }
- }
-
- return { authorized: true }
- } catch (error) {
- logger.error(`[${requestId}] Error validating password:`, error)
- return { authorized: false, error: 'Authentication error' }
- }
- }
-
- if (authType === 'email') {
- if (request.method === 'GET') {
- return { authorized: false, error: 'auth_required_email' }
- }
-
- try {
- if (!parsedBody) {
- return { authorized: false, error: 'Email is required' }
- }
-
- const { email, formData } = parsedBody
-
- if (formData && !email) {
- return { authorized: false, error: 'auth_required_email' }
- }
-
- if (!email) {
- return { authorized: false, error: 'Email is required' }
- }
-
- const allowedEmails: string[] = deployment.allowedEmails || []
-
- if (isEmailAllowed(email, allowedEmails)) {
- return { authorized: false, error: 'otp_required' }
- }
-
- return { authorized: false, error: 'Email not authorized for this form' }
- } catch (error) {
- logger.error(`[${requestId}] Error validating email:`, error)
- return { authorized: false, error: 'Authentication error' }
- }
- }
-
- return { authorized: false, error: 'Unsupported authentication type' }
-}
-
-/**
- * Form customizations interface
- */
-export interface FormCustomizations {
- primaryColor?: string
- welcomeMessage?: string
- thankYouTitle?: string
- thankYouMessage?: string
- logoUrl?: string
-}
-
-/**
- * Default form customizations
- * Note: primaryColor is intentionally undefined to allow thank you screen to use its green default
- */
-export const DEFAULT_FORM_CUSTOMIZATIONS: FormCustomizations = {
- welcomeMessage: '',
- thankYouTitle: 'Thank you!',
- thankYouMessage: 'Your response has been submitted successfully.',
-}
diff --git a/apps/sim/app/api/form/validate/route.ts b/apps/sim/app/api/form/validate/route.ts
deleted file mode 100644
index 0db7f37ee96..00000000000
--- a/apps/sim/app/api/form/validate/route.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { db } from '@sim/db'
-import { form } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { and, eq, isNull } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
-import { formIdentifierValidationQuerySchema } from '@/lib/api/contracts/forms'
-import { getValidationErrorMessage } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('FormValidateAPI')
-
-/**
- * GET endpoint to validate form identifier availability
- */
-export const GET = withRouteHandler(async (request: NextRequest) => {
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return createErrorResponse('Unauthorized', 401)
- }
- const { searchParams } = new URL(request.url)
- const identifier = searchParams.get('identifier')
-
- const validation = formIdentifierValidationQuerySchema.safeParse({ identifier })
-
- if (!validation.success) {
- const errorMessage = getValidationErrorMessage(validation.error, 'Invalid identifier')
- logger.warn(`Validation error: ${errorMessage}`)
-
- if (identifier && !/^[a-z0-9-]+$/.test(identifier)) {
- return createSuccessResponse({
- available: false,
- error: errorMessage,
- })
- }
-
- return createErrorResponse(errorMessage, 400)
- }
-
- const { identifier: validatedIdentifier } = validation.data
-
- const existingForm = await db
- .select({ id: form.id })
- .from(form)
- .where(and(eq(form.identifier, validatedIdentifier), isNull(form.archivedAt)))
- .limit(1)
-
- const isAvailable = existingForm.length === 0
-
- logger.debug(
- `Identifier "${validatedIdentifier}" availability check: ${isAvailable ? 'available' : 'taken'}`
- )
-
- return createSuccessResponse({
- available: isAvailable,
- error: isAvailable ? null : 'This identifier is already in use',
- })
- } catch (error: unknown) {
- const message = getErrorMessage(error, 'Failed to validate identifier')
- logger.error('Error validating form identifier:', error)
- return createErrorResponse(message, 500)
- }
-})
diff --git a/apps/sim/app/api/invitations/[id]/accept/route.ts b/apps/sim/app/api/invitations/[id]/accept/route.ts
index 147e297898d..abbd16f1656 100644
--- a/apps/sim/app/api/invitations/[id]/accept/route.ts
+++ b/apps/sim/app/api/invitations/[id]/accept/route.ts
@@ -38,6 +38,7 @@ export const POST = withRouteHandler(
'email-mismatch': 403,
'already-in-organization': 409,
'no-seats-available': 400,
+ 'upgrade-required': 402,
'server-error': 500,
}
const status = statusMap[result.kind] ?? 500
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
index fec6bc6c192..ed2bd5b59e9 100644
--- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
+++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
@@ -88,7 +88,7 @@ export const POST = withRouteHandler(
: []
const newId = generateId()
- const baseTitle = (parent.title ?? 'New task').replace(/^Fork \| /, '')
+ const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '')
const title = `Fork | ${baseTitle}`
const now = new Date()
diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts
index 677124dca52..bfbdbb409b3 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts
@@ -10,6 +10,8 @@ import {
} from '@/lib/api/contracts/organization'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { getOrganizationSubscription } from '@/lib/billing/core/billing'
+import { isEnterprise } from '@/lib/billing/plan-helpers'
import {
validateBulkInvitations,
validateSeatAvailability,
@@ -287,8 +289,12 @@ export const POST = withRouteHandler(
)
}
- const seatValidation = await validateSeatAvailability(organizationId, emailsToInvite.length)
- if (!seatValidation.canInvite) {
+ const orgSubscription = await getOrganizationSubscription(organizationId)
+ const enforceFixedSeats = !!orgSubscription && isEnterprise(orgSubscription.plan)
+ const seatValidation = enforceFixedSeats
+ ? await validateSeatAvailability(organizationId, emailsToInvite.length)
+ : null
+ if (seatValidation && !seatValidation.canInvite) {
return NextResponse.json(
{
error: seatValidation.reason,
@@ -394,11 +400,15 @@ export const POST = withRouteHandler(
(email) => !quickValidateEmail(email.trim().toLowerCase()).isValid
),
workspaceGrantsPerInvite: validGrants.length,
- seatInfo: {
- seatsUsed: seatValidation.currentSeats + sentInvitations.length,
- maxSeats: seatValidation.maxSeats,
- availableSeats: seatValidation.availableSeats - sentInvitations.length,
- },
+ ...(seatValidation
+ ? {
+ seatInfo: {
+ seatsUsed: seatValidation.currentSeats + sentInvitations.length,
+ maxSeats: seatValidation.maxSeats,
+ availableSeats: seatValidation.availableSeats - sentInvitations.length,
+ },
+ }
+ : {}),
}
if (failedInvitations.length > 0 && sentInvitations.length === 0) {
diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
index ed6bcf4d5b8..270d62374fc 100644
--- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
+++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts
@@ -4,10 +4,7 @@ import { member, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
-import {
- removeOrganizationMemberQuerySchema,
- updateOrganizationMemberRoleContract,
-} from '@/lib/api/contracts/organization'
+import { updateOrganizationMemberRoleContract } from '@/lib/api/contracts/organization'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
@@ -17,7 +14,7 @@ import {
removeExternalUserFromOrganizationWorkspaces,
removeUserFromOrganization,
} from '@/lib/billing/organizations/membership'
-import { reduceOrganizationSeatsByOne } from '@/lib/billing/organizations/seats'
+import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
const logger = createLogger('OrganizationMemberAPI')
@@ -292,12 +289,6 @@ export const DELETE = withRouteHandler(
}
const { id: organizationId, memberId: targetUserId } = await params
- const queryResult = removeOrganizationMemberQuerySchema.safeParse(
- Object.fromEntries(request.nextUrl.searchParams.entries())
- )
- const shouldReduceSeats = queryResult.success
- ? queryResult.data.shouldReduceSeats === true
- : false
const userMember = await db
.select()
@@ -418,25 +409,22 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: result.error }, { status: 500 })
}
- let seatReduction: Awaited> | null = null
- if (shouldReduceSeats && session.user.id !== targetUserId) {
- try {
- seatReduction = await reduceOrganizationSeatsByOne({
- organizationId,
- actorUserId: session.user.id,
- removedUserId: targetUserId,
- })
- } catch (seatError) {
- logger.error('Failed to reduce seats after member removal', {
- organizationId,
- removedMemberId: targetUserId,
- removedBy: session.user.id,
- error: seatError,
- })
- seatReduction = {
- reduced: false,
- reason: 'Failed to reduce seats after member removal',
- }
+ let seatReduction: Awaited> | null = null
+ try {
+ seatReduction = await reconcileOrganizationSeats({
+ organizationId,
+ reason: 'member-removed',
+ })
+ } catch (seatError) {
+ logger.error('Failed to reduce seats after member removal', {
+ organizationId,
+ removedMemberId: targetUserId,
+ removedBy: session.user.id,
+ error: seatError,
+ })
+ seatReduction = {
+ changed: false,
+ reason: 'Failed to reduce seats after member removal',
}
}
diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts
index e412a7e635f..a8b23088fa1 100644
--- a/apps/sim/app/api/organizations/[id]/members/route.ts
+++ b/apps/sim/app/api/organizations/[id]/members/route.ts
@@ -243,7 +243,7 @@ export const POST = withRouteHandler(
if (!seatValidation.canInvite) {
return NextResponse.json(
{
- error: `Cannot invite member. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`,
+ error: `Cannot invite teammate. Using ${seatValidation.currentSeats} of ${seatValidation.maxSeats} seats.`,
details: seatValidation,
},
{ status: 400 }
diff --git a/apps/sim/app/api/organizations/[id]/route.ts b/apps/sim/app/api/organizations/[id]/route.ts
index d0c8d6d5cf0..d44cd97e1c0 100644
--- a/apps/sim/app/api/organizations/[id]/route.ts
+++ b/apps/sim/app/api/organizations/[id]/route.ts
@@ -119,7 +119,6 @@ export const GET = withRouteHandler(
/**
* PUT /api/organizations/[id]
* Update organization settings (name, slug, logo)
- * Note: For seat updates, use PUT /api/organizations/[id]/seats instead
*/
export const PUT = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts
deleted file mode 100644
index 594c0ef093d..00000000000
--- a/apps/sim/app/api/organizations/[id]/seats/route.ts
+++ /dev/null
@@ -1,276 +0,0 @@
-import { db } from '@sim/db'
-import { invitation, member, organization, subscription } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { and, count, eq, gt, inArray, ne } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { updateSeatsContract } from '@/lib/api/contracts/organization'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
-import { getPlanPricing } from '@/lib/billing/core/billing'
-import { isTeam } from '@/lib/billing/plan-helpers'
-import { requireStripeClient } from '@/lib/billing/stripe-client'
-import {
- hasUsableSubscriptionStatus,
- USABLE_SUBSCRIPTION_STATUSES,
-} from '@/lib/billing/subscriptions/utils'
-import { toDecimal, toNumber } from '@/lib/billing/utils/decimal'
-import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
-import { isBillingEnabled } from '@/lib/core/config/feature-flags'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-const logger = createLogger('OrganizationSeatsAPI')
-
-/**
- * PUT /api/organizations/[id]/seats
- * Update organization seat count using Stripe's subscription.update API.
- * This is the recommended approach for per-seat billing changes.
- */
-export const PUT = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- try {
- const session = await getSession()
-
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- if (!isBillingEnabled) {
- return NextResponse.json({ error: 'Billing is not enabled' }, { status: 400 })
- }
-
- const parsed = await parseRequest(updateSeatsContract, request, context)
- if (!parsed.success) return parsed.response
-
- const { id: organizationId } = parsed.data.params
- const { seats: newSeatCount } = parsed.data.body
-
- const memberEntry = await db
- .select()
- .from(member)
- .where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
- .limit(1)
-
- if (memberEntry.length === 0) {
- return NextResponse.json(
- { error: 'Forbidden - Not a member of this organization' },
- { status: 403 }
- )
- }
-
- if (!['owner', 'admin'].includes(memberEntry[0].role)) {
- return NextResponse.json({ error: 'Forbidden - Admin access required' }, { status: 403 })
- }
-
- const subscriptionRecord = await db
- .select()
- .from(subscription)
- .where(
- and(
- eq(subscription.referenceId, organizationId),
- inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
- )
- )
- .limit(1)
-
- if (subscriptionRecord.length === 0) {
- return NextResponse.json({ error: 'No active subscription found' }, { status: 404 })
- }
-
- const orgSubscription = subscriptionRecord[0]
-
- if (await isOrganizationBillingBlocked(organizationId)) {
- return NextResponse.json({ error: 'An active subscription is required' }, { status: 400 })
- }
-
- if (!isTeam(orgSubscription.plan)) {
- return NextResponse.json(
- { error: 'Seat changes are only available for Team plans' },
- { status: 400 }
- )
- }
-
- if (!orgSubscription.stripeSubscriptionId) {
- return NextResponse.json(
- { error: 'No Stripe subscription found for this organization' },
- { status: 400 }
- )
- }
-
- const [memberCountRow] = await db
- .select({ count: count() })
- .from(member)
- .where(eq(member.organizationId, organizationId))
-
- const [pendingCountRow] = await db
- .select({ count: count() })
- .from(invitation)
- .where(
- and(
- eq(invitation.organizationId, organizationId),
- eq(invitation.status, 'pending'),
- ne(invitation.membershipIntent, 'external'),
- gt(invitation.expiresAt, new Date())
- )
- )
-
- const memberCount = memberCountRow?.count ?? 0
- const pendingCount = pendingCountRow?.count ?? 0
- const occupiedSeats = memberCount + pendingCount
-
- if (newSeatCount < occupiedSeats) {
- return NextResponse.json(
- {
- error: `Cannot reduce seats below current occupancy (${memberCount} member${memberCount === 1 ? '' : 's'} + ${pendingCount} pending invite${pendingCount === 1 ? '' : 's'}). Cancel pending invites first or remove members.`,
- currentMembers: memberCount,
- pendingInvitations: pendingCount,
- occupiedSeats,
- },
- { status: 400 }
- )
- }
-
- const currentSeats = orgSubscription.seats || 1
-
- if (newSeatCount === currentSeats) {
- return NextResponse.json({
- success: true,
- message: 'No change in seat count',
- data: {
- seats: currentSeats,
- stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
- },
- })
- }
-
- const stripe = requireStripeClient()
-
- const stripeSubscription = await stripe.subscriptions.retrieve(
- orgSubscription.stripeSubscriptionId
- )
-
- if (!hasUsableSubscriptionStatus(stripeSubscription.status)) {
- return NextResponse.json({ error: 'Stripe subscription is not active' }, { status: 400 })
- }
-
- const subscriptionItem = stripeSubscription.items.data[0]
-
- if (!subscriptionItem) {
- return NextResponse.json(
- { error: 'No subscription item found in Stripe subscription' },
- { status: 500 }
- )
- }
-
- logger.info('Updating Stripe subscription quantity', {
- organizationId,
- stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
- subscriptionItemId: subscriptionItem.id,
- currentSeats,
- newSeatCount,
- userId: session.user.id,
- })
-
- const updatedSubscription = await stripe.subscriptions.update(
- orgSubscription.stripeSubscriptionId,
- {
- items: [
- {
- id: subscriptionItem.id,
- quantity: newSeatCount,
- },
- ],
- proration_behavior: 'always_invoice',
- },
- { idempotencyKey: `seats-update:${orgSubscription.stripeSubscriptionId}:${newSeatCount}` }
- )
-
- await syncSeatsFromStripeQuantity(
- orgSubscription.id,
- orgSubscription.seats,
- updatedSubscription.items.data[0]?.quantity ?? newSeatCount
- )
-
- const { basePrice } = getPlanPricing(orgSubscription.plan)
- const newMinimumLimit = newSeatCount * basePrice
-
- const orgData = await db
- .select({ orgUsageLimit: organization.orgUsageLimit })
- .from(organization)
- .where(eq(organization.id, organizationId))
- .limit(1)
-
- const currentOrgLimit =
- orgData.length > 0 && orgData[0].orgUsageLimit
- ? toNumber(toDecimal(orgData[0].orgUsageLimit))
- : 0
-
- if (newMinimumLimit > currentOrgLimit) {
- await db
- .update(organization)
- .set({
- orgUsageLimit: newMinimumLimit.toFixed(2),
- updatedAt: new Date(),
- })
- .where(eq(organization.id, organizationId))
-
- logger.info('Updated organization usage limit for seat change', {
- organizationId,
- newSeatCount,
- newMinimumLimit,
- previousLimit: currentOrgLimit,
- })
- }
-
- logger.info('Successfully updated seat count', {
- organizationId,
- stripeSubscriptionId: orgSubscription.stripeSubscriptionId,
- oldSeats: currentSeats,
- newSeats: newSeatCount,
- updatedBy: session.user.id,
- prorationBehavior: 'always_invoice',
- })
-
- return NextResponse.json({
- success: true,
- message:
- newSeatCount > currentSeats
- ? `Added ${newSeatCount - currentSeats} seat(s). Your billing has been adjusted.`
- : `Removed ${currentSeats - newSeatCount} seat(s). You'll receive a prorated credit.`,
- data: {
- seats: newSeatCount,
- previousSeats: currentSeats,
- stripeSubscriptionId: updatedSubscription.id,
- stripeStatus: updatedSubscription.status,
- },
- })
- } catch (error) {
- const { id: organizationId } = await context.params
-
- if (error instanceof Error && 'type' in error) {
- const stripeError = error as Error & { type?: unknown; code?: unknown }
- logger.error('Stripe error updating seats', {
- organizationId,
- type: stripeError.type,
- code: stripeError.code,
- message: stripeError.message,
- })
-
- return NextResponse.json(
- {
- error: stripeError.message || 'Failed to update seats in Stripe',
- code: typeof stripeError.code === 'string' ? stripeError.code : undefined,
- },
- { status: 400 }
- )
- }
-
- logger.error('Failed to update organization seats', {
- organizationId,
- error,
- })
-
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts
index aa87abbcb7d..5a2aaabb2d2 100644
--- a/apps/sim/app/api/organizations/route.ts
+++ b/apps/sim/app/api/organizations/route.ts
@@ -6,8 +6,8 @@ import { getErrorMessage } from '@sim/utils/errors'
import { and, eq, inArray, or } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
-import { listCreatorOrganizationsContract } from '@/lib/api/contracts/creator-profile'
import { createOrganizationBodySchema } from '@/lib/api/contracts/organization'
+import { listCreatorOrganizationsContract } from '@/lib/api/contracts/organizations'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization'
diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts
index 07ab98932be..8cce8c24b55 100644
--- a/apps/sim/app/api/superuser/import-workflow/route.ts
+++ b/apps/sim/app/api/superuser/import-workflow/route.ts
@@ -10,7 +10,7 @@ import { getSession } from '@/lib/auth'
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
+import { verifyEffectiveSuperUser } from '@/lib/permissions/super-user'
import { parseWorkflowJson } from '@/lib/workflows/operations/import-export'
import {
loadWorkflowFromNormalizedTables,
diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts
index 197a1722b1b..5b9f960896a 100644
--- a/apps/sim/app/api/table/[tableId]/groups/route.ts
+++ b/apps/sim/app/api/table/[tableId]/groups/route.ts
@@ -116,6 +116,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R
...(validated.inputMappings !== undefined
? { inputMappings: validated.inputMappings }
: {}),
+ ...(validated.deploymentMode !== undefined
+ ? { deploymentMode: validated.deploymentMode }
+ : {}),
...(validated.type !== undefined ? { type: validated.type } : {}),
...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}),
},
diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.test.ts b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts
new file mode 100644
index 00000000000..18fa93aca80
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/import-async/route.test.ts
@@ -0,0 +1,144 @@
+/**
+ * @vitest-environment node
+ */
+import { hybridAuthMockFns } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TableDefinition } from '@/lib/table'
+
+const { mockCheckAccess, mockMarkTableImporting, mockRunTableImport } = vi.hoisted(() => ({
+ mockCheckAccess: vi.fn(),
+ mockMarkTableImporting: vi.fn(),
+ mockRunTableImport: vi.fn(),
+}))
+
+vi.mock('@sim/utils/id', () => ({
+ generateId: vi.fn().mockReturnValue('import-id-xyz'),
+ generateShortId: vi.fn().mockReturnValue('short-id'),
+}))
+vi.mock('@/lib/table/service', () => ({ markTableImporting: mockMarkTableImporting }))
+vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport }))
+vi.mock('@/lib/core/utils/background', () => ({
+ runDetached: (_label: string, work: () => Promise) => {
+ void work()
+ },
+}))
+vi.mock('@/app/api/table/utils', async () => {
+ const { NextResponse } = await import('next/server')
+ return {
+ checkAccess: mockCheckAccess,
+ accessError: (result: { status: number }) =>
+ NextResponse.json({ error: 'denied' }, { status: result.status }),
+ }
+})
+
+import { POST } from '@/app/api/table/[tableId]/import-async/route'
+
+function buildTable(overrides: Partial = {}): TableDefinition {
+ return {
+ id: 'tbl_1',
+ name: 'People',
+ description: null,
+ schema: { columns: [{ name: 'name', type: 'string' }] },
+ metadata: null,
+ rowCount: 0,
+ maxRows: 1_000_000,
+ workspaceId: 'workspace-1',
+ createdBy: 'user-1',
+ archivedAt: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ }
+}
+
+function makeRequest(body: unknown, tableId = 'tbl_1') {
+ const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import-async`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return POST(req, { params: Promise.resolve({ tableId }) })
+}
+
+const validBody = {
+ workspaceId: 'workspace-1',
+ fileKey: 'workspace/workspace-1/123-data.csv',
+ fileName: 'data.csv',
+ mode: 'append',
+}
+
+describe('POST /api/table/[tableId]/import-async', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'session',
+ })
+ mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
+ mockMarkTableImporting.mockResolvedValue(true)
+ mockRunTableImport.mockResolvedValue(undefined)
+ })
+
+ it('marks the table importing and kicks off the worker with mode + mapping', async () => {
+ const response = await makeRequest({
+ ...validBody,
+ mode: 'replace',
+ mapping: { Name: 'name' },
+ createColumns: ['Extra'],
+ })
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.data).toEqual({ tableId: 'tbl_1', importId: 'import-id-xyz' })
+ expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'import-id-xyz')
+ expect(mockRunTableImport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ tableId: 'tbl_1',
+ mode: 'replace',
+ delimiter: ',',
+ mapping: { Name: 'name' },
+ createColumns: ['Extra'],
+ })
+ )
+ })
+
+ it('returns 409 when the table is already importing (claim lost)', async () => {
+ mockMarkTableImporting.mockResolvedValue(false)
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(409)
+ expect(mockRunTableImport).not.toHaveBeenCalled()
+ })
+
+ it('returns 401 when unauthenticated', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(401)
+ expect(mockMarkTableImporting).not.toHaveBeenCalled()
+ })
+
+ it('returns the access error status when access is denied', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(403)
+ expect(mockRunTableImport).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when the target table is archived', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ archivedAt: new Date() }) })
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(400)
+ expect(mockRunTableImport).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 on workspace mismatch', async () => {
+ const response = await makeRequest({ ...validBody, workspaceId: 'other-ws' })
+ expect(response.status).toBe(400)
+ })
+
+ it('returns 400 for an invalid mode', async () => {
+ const response = await makeRequest({ ...validBody, mode: 'bogus' })
+ expect(response.status).toBe(400)
+ })
+})
diff --git a/apps/sim/app/api/table/[tableId]/import-async/route.ts b/apps/sim/app/api/table/[tableId]/import-async/route.ts
new file mode 100644
index 00000000000..46190cbfb06
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/import-async/route.ts
@@ -0,0 +1,92 @@
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { importIntoTableAsyncContract } from '@/lib/api/contracts/tables'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { runDetached } from '@/lib/core/utils/background'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { runTableImport } from '@/lib/table/import-runner'
+import { markTableImporting } from '@/lib/table/service'
+import { accessError, checkAccess } from '@/app/api/table/utils'
+
+const logger = createLogger('TableImportIntoAsync')
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+interface RouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
+ const requestId = generateRequestId()
+
+ const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!authResult.success || !authResult.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const userId = authResult.userId
+
+ const parsed = await parseRequest(importIntoTableAsyncContract, request, { params })
+ if (!parsed.success) return parsed.response
+ const { tableId } = parsed.data.params
+ const { workspaceId, fileKey, fileName, mode, mapping, createColumns } = parsed.data.body
+
+ const access = await checkAccess(tableId, userId, 'write')
+ if (!access.ok) return accessError(access, requestId, tableId)
+ const { table } = access
+
+ if (table.workspaceId !== workspaceId) {
+ return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
+ }
+ // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a
+ // caller can't import another workspace's uploaded object.
+ if (!fileKey.startsWith(`workspace/${workspaceId}/`)) {
+ return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 })
+ }
+ if (table.archivedAt) {
+ return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 })
+ }
+
+ const ext = fileName.split('.').pop()?.toLowerCase()
+ if (ext !== 'csv' && ext !== 'tsv') {
+ return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
+ }
+ const delimiter = ext === 'tsv' ? '\t' : ','
+
+ // Atomically claim the table — the single concurrency gate. If another import already holds it,
+ // this returns false (no overlapping workers writing colliding row positions).
+ const importId = generateId()
+ const claimed = await markTableImporting(tableId, importId)
+ if (!claimed) {
+ return NextResponse.json(
+ { error: 'An import is already in progress for this table' },
+ { status: 409 }
+ )
+ }
+
+ runDetached('table-import', () =>
+ runTableImport({
+ importId,
+ tableId,
+ workspaceId,
+ userId,
+ fileKey,
+ fileName,
+ delimiter,
+ mode,
+ mapping,
+ createColumns,
+ })
+ )
+
+ logger.info(`[${requestId}] Async CSV import into existing table started`, {
+ tableId,
+ importId,
+ mode,
+ fileName,
+ })
+ return NextResponse.json({ success: true, data: { tableId, importId } })
+})
diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts b/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts
new file mode 100644
index 00000000000..d45baae77e2
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/import/cancel/route.test.ts
@@ -0,0 +1,110 @@
+/**
+ * @vitest-environment node
+ */
+import { hybridAuthMockFns } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import type { TableDefinition } from '@/lib/table'
+
+const { mockCheckAccess, mockMarkImportCanceled, mockAppendTableEvent } = vi.hoisted(() => ({
+ mockCheckAccess: vi.fn(),
+ mockMarkImportCanceled: vi.fn(),
+ mockAppendTableEvent: vi.fn(),
+}))
+
+vi.mock('@/lib/table/service', () => ({ markImportCanceled: mockMarkImportCanceled }))
+vi.mock('@/lib/table/events', () => ({ appendTableEvent: mockAppendTableEvent }))
+vi.mock('@/app/api/table/utils', async () => {
+ const { NextResponse } = await import('next/server')
+ return {
+ checkAccess: mockCheckAccess,
+ accessError: (result: { status: number }) =>
+ NextResponse.json({ error: 'denied' }, { status: result.status }),
+ }
+})
+
+import { POST } from '@/app/api/table/[tableId]/import/cancel/route'
+
+function buildTable(overrides: Partial = {}): TableDefinition {
+ return {
+ id: 'tbl_1',
+ name: 'People',
+ description: null,
+ schema: { columns: [{ name: 'name', type: 'string' }] },
+ metadata: null,
+ rowCount: 0,
+ maxRows: 1_000_000,
+ workspaceId: 'workspace-1',
+ createdBy: 'user-1',
+ archivedAt: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ ...overrides,
+ }
+}
+
+function makeRequest(body: unknown, tableId = 'tbl_1') {
+ const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import/cancel`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ return POST(req, { params: Promise.resolve({ tableId }) })
+}
+
+const validBody = { workspaceId: 'workspace-1', importId: 'import-id-xyz' }
+
+describe('POST /api/table/[tableId]/import/cancel', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'session',
+ })
+ mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
+ mockMarkImportCanceled.mockResolvedValue(true)
+ })
+
+ it('cancels the import and emits a canceled event', async () => {
+ const response = await makeRequest(validBody)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.data).toEqual({ canceled: true })
+ expect(mockMarkImportCanceled).toHaveBeenCalledWith('tbl_1', 'import-id-xyz')
+ expect(mockAppendTableEvent).toHaveBeenCalledWith(
+ expect.objectContaining({ kind: 'import', status: 'canceled', importId: 'import-id-xyz' })
+ )
+ })
+
+ it('does not emit an event when nothing was importing', async () => {
+ mockMarkImportCanceled.mockResolvedValue(false)
+ const response = await makeRequest(validBody)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.data).toEqual({ canceled: false })
+ expect(mockAppendTableEvent).not.toHaveBeenCalled()
+ })
+
+ it('returns 401 when unauthenticated', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(401)
+ expect(mockMarkImportCanceled).not.toHaveBeenCalled()
+ })
+
+ it('returns the access error status when access is denied', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: false, status: 403 })
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(403)
+ })
+
+ it('returns 400 on workspace mismatch', async () => {
+ mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable({ workspaceId: 'other-ws' }) })
+ const response = await makeRequest(validBody)
+ expect(response.status).toBe(400)
+ expect(mockMarkImportCanceled).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/table/[tableId]/import/cancel/route.ts b/apps/sim/app/api/table/[tableId]/import/cancel/route.ts
new file mode 100644
index 00000000000..62ab7310f47
--- /dev/null
+++ b/apps/sim/app/api/table/[tableId]/import/cancel/route.ts
@@ -0,0 +1,54 @@
+import { createLogger } from '@sim/logger'
+import { type NextRequest, NextResponse } from 'next/server'
+import { cancelTableImportContract } from '@/lib/api/contracts/tables'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { appendTableEvent } from '@/lib/table/events'
+import { markImportCanceled } from '@/lib/table/service'
+import { accessError, checkAccess } from '@/app/api/table/utils'
+
+const logger = createLogger('TableImportCancelAPI')
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+interface RouteParams {
+ params: Promise<{ tableId: string }>
+}
+
+/**
+ * POST /api/table/[tableId]/import/cancel
+ *
+ * Cancels an in-flight async CSV import. Flips the table's import status to `canceled`, which makes
+ * the detached worker's next ownership check fail so it stops inserting. Committed rows are left in
+ * place (no rollback) — the user can delete the table. No-op if the import already finished.
+ */
+export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
+ const requestId = generateRequestId()
+
+ const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!authResult.success || !authResult.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(cancelTableImportContract, request, { params })
+ if (!parsed.success) return parsed.response
+ const { tableId } = parsed.data.params
+ const { workspaceId, importId } = parsed.data.body
+
+ const access = await checkAccess(tableId, authResult.userId, 'write')
+ if (!access.ok) return accessError(access, requestId, tableId)
+ if (access.table.workspaceId !== workspaceId) {
+ return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
+ }
+
+ const canceled = await markImportCanceled(tableId, importId)
+ if (canceled) {
+ void appendTableEvent({ kind: 'import', tableId, importId, status: 'canceled' })
+ }
+ logger.info(`[${requestId}] Import cancel requested`, { tableId, importId, canceled })
+
+ return NextResponse.json({ success: true, data: { canceled } })
+})
diff --git a/apps/sim/app/api/table/[tableId]/import/route.test.ts b/apps/sim/app/api/table/[tableId]/import/route.test.ts
index 1a551745402..438f74e035e 100644
--- a/apps/sim/app/api/table/[tableId]/import/route.test.ts
+++ b/apps/sim/app/api/table/[tableId]/import/route.test.ts
@@ -8,16 +8,18 @@ import type { TableDefinition } from '@/lib/table'
const {
mockCheckAccess,
- mockBatchInsertRowsWithTx,
- mockReplaceTableRowsWithTx,
- mockAddTableColumnsWithTx,
+ mockImportAppendRows,
+ mockImportReplaceRows,
mockDispatchAfterBatchInsert,
+ mockMarkTableImporting,
+ mockReleaseImportClaim,
} = vi.hoisted(() => ({
mockCheckAccess: vi.fn(),
- mockBatchInsertRowsWithTx: vi.fn(),
- mockReplaceTableRowsWithTx: vi.fn(),
- mockAddTableColumnsWithTx: vi.fn(),
+ mockImportAppendRows: vi.fn(),
+ mockImportReplaceRows: vi.fn(),
mockDispatchAfterBatchInsert: vi.fn(),
+ mockMarkTableImporting: vi.fn(),
+ mockReleaseImportClaim: vi.fn(),
}))
vi.mock('@sim/utils/id', () => ({
@@ -33,20 +35,28 @@ vi.mock('@/app/api/table/utils', async () => {
const message = result.status === 404 ? 'Table not found' : 'Access denied'
return NextResponse.json({ error: message }, { status: result.status })
},
+ csvProxyBodyCapResponse: () => null,
+ multipartErrorResponse: (error: { code: string; message: string }) =>
+ NextResponse.json(
+ { error: error.message },
+ { status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
+ ),
}
})
/**
- * The route imports `batchInsertRows` and `replaceTableRows` from the barrel,
- * which forwards them from `./service`. Mocking the service module replaces
- * both without having to touch the other real helpers (`parseCsvBuffer`,
- * `coerceRowsForTable`, etc.) exported through the barrel.
+ * The route imports `importAppendRows` / `importReplaceRows` from the barrel,
+ * which forwards them from `./service`. These functions own the import
+ * transaction (column adds + row writes); mocking the service module replaces
+ * them without touching the other real helpers (`coerceRowsForTable`,
+ * `createCsvParser`, etc.) exported through the barrel.
*/
vi.mock('@/lib/table/service', () => ({
- batchInsertRowsWithTx: mockBatchInsertRowsWithTx,
- replaceTableRowsWithTx: mockReplaceTableRowsWithTx,
- addTableColumnsWithTx: mockAddTableColumnsWithTx,
+ importAppendRows: mockImportAppendRows,
+ importReplaceRows: mockImportReplaceRows,
dispatchAfterBatchInsert: mockDispatchAfterBatchInsert,
+ markTableImporting: mockMarkTableImporting,
+ releaseImportClaim: mockReleaseImportClaim,
}))
import { POST } from '@/app/api/table/[tableId]/import/route'
@@ -64,8 +74,8 @@ function createFormData(
createColumns?: unknown
}
): FormData {
+ // Text fields must precede the file part for the streaming parser.
const form = new FormData()
- form.append('file', file)
if (options?.workspaceId !== null) {
form.append('workspaceId', options?.workspaceId ?? 'workspace-1')
}
@@ -86,6 +96,7 @@ function createFormData(
: JSON.stringify(options.createColumns)
)
}
+ form.append('file', file)
return form
}
@@ -112,10 +123,21 @@ function buildTable(overrides: Partial = {}): TableDefinition {
}
}
+/** Additions array the route passed to importAppendRows (2nd positional arg). */
+function appendAdditions(): { name: string; type: string }[] {
+ return mockImportAppendRows.mock.calls[0][1] as { name: string; type: string }[]
+}
+
+/** Rows array the route passed to importAppendRows (3rd positional arg). */
+function appendRows(): unknown[] {
+ return mockImportAppendRows.mock.calls[0][2] as unknown[]
+}
+
async function callPost(form: FormData, { tableId }: { tableId: string } = { tableId: 'tbl_1' }) {
+ // Building the request from a FormData body gives a real multipart stream and
+ // boundary, exercising the streaming `readMultipart` parser end-to-end.
const req = new NextRequest(`http://localhost:3000/api/table/${tableId}/import`, {
method: 'POST',
- headers: { 'content-length': '1024' },
body: form,
})
return POST(req, { params: Promise.resolve({ tableId }) })
@@ -130,25 +152,15 @@ describe('POST /api/table/[tableId]/import', () => {
authType: 'session',
})
mockCheckAccess.mockResolvedValue({ ok: true, table: buildTable() })
- mockBatchInsertRowsWithTx.mockImplementation(async (_trx, data: { rows: unknown[] }) =>
- data.rows.map((_, i) => ({ id: `row_${i}` }))
- )
- mockReplaceTableRowsWithTx.mockResolvedValue({ deletedCount: 0, insertedCount: 0 })
- mockAddTableColumnsWithTx.mockImplementation(
- async (
- _trx,
- table: { schema: { columns: { name: string; type: string }[] } },
- columns: { name: string; type: string }[]
- ) => ({
- ...table,
- schema: {
- columns: [
- ...table.schema.columns,
- ...columns.map((c) => ({ name: c.name, type: c.type as 'string' })),
- ],
- },
+ mockImportAppendRows.mockImplementation(
+ async (table: TableDefinition, _additions: unknown, rows: unknown[]) => ({
+ inserted: rows.map((_, i) => ({ id: `row_${i}` })),
+ table,
})
)
+ mockImportReplaceRows.mockResolvedValue({ deletedCount: 0, insertedCount: 0 })
+ mockMarkTableImporting.mockResolvedValue(true)
+ mockReleaseImportClaim.mockResolvedValue(undefined)
})
it('returns 401 when the user is not authenticated', async () => {
@@ -160,6 +172,22 @@ describe('POST /api/table/[tableId]/import', () => {
expect(response.status).toBe(401)
})
+ it('returns 409 when a background import already holds the table (claim lost)', async () => {
+ mockMarkTableImporting.mockResolvedValueOnce(false)
+ const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30')))
+ expect(response.status).toBe(409)
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
+ expect(mockImportReplaceRows).not.toHaveBeenCalled()
+ expect(mockReleaseImportClaim).not.toHaveBeenCalled()
+ })
+
+ it('releases the import claim after a successful write', async () => {
+ const response = await callPost(createFormData(createCsvFile('name,age\nAlice,30')))
+ expect(response.status).toBe(200)
+ expect(mockMarkTableImporting).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d')
+ expect(mockReleaseImportClaim).toHaveBeenCalledWith('tbl_1', 'deadbeefcafef00d')
+ })
+
it('returns 400 when the mode is invalid', async () => {
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'bogus' })
@@ -186,24 +214,32 @@ describe('POST /api/table/[tableId]/import', () => {
expect(data.error).toMatch(/archived/i)
})
- it('returns 413 for oversized CSV files before reading their contents', async () => {
- const file = createCsvFile('name,age\nAlice,30')
- Object.defineProperty(file, 'size', {
- value: 26 * 1024 * 1024,
- })
- const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer')
-
+ it('returns 400 when the file part precedes the required fields', async () => {
+ // Build a raw multipart body with the file BEFORE workspaceId.
+ const boundary = '----orderboundary'
+ const body = Buffer.concat([
+ Buffer.from(
+ `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="data.csv"\r\nContent-Type: text/csv\r\n\r\nname,age\nAlice,30\r\n`
+ ),
+ Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="workspaceId"\r\n\r\n`),
+ Buffer.from('workspace-1\r\n'),
+ Buffer.from(`--${boundary}--\r\n`),
+ ])
const req = {
- formData: async () => createFormData(file),
+ headers: new Headers({ 'content-type': `multipart/form-data; boundary=${boundary}` }),
+ body: new ReadableStream({
+ start(controller) {
+ controller.enqueue(new Uint8Array(body))
+ controller.close()
+ },
+ }),
+ signal: undefined,
} as unknown as NextRequest
const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
- expect(response.status).toBe(413)
- const data = await response.json()
- expect(data.error).toMatch(/CSV import file exceeds maximum size/)
- expect(arrayBufferSpy).not.toHaveBeenCalled()
- expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled()
- expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled()
+ expect(response.status).toBe(400)
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
+ expect(mockImportReplaceRows).not.toHaveBeenCalled()
})
it('returns 400 when the CSV is missing a required column', async () => {
@@ -212,10 +248,10 @@ describe('POST /api/table/[tableId]/import', () => {
const data = await response.json()
expect(data.error).toMatch(/missing required columns/i)
expect(data.details?.missingRequired).toEqual(['name'])
- expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled()
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
})
- it('appends rows via batchInsertRows', async () => {
+ it('appends rows via importAppendRows', async () => {
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'append' })
)
@@ -223,13 +259,12 @@ describe('POST /api/table/[tableId]/import', () => {
const data = await response.json()
expect(data.data.mode).toBe('append')
expect(data.data.insertedCount).toBe(2)
- expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1)
- const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] }
- expect(callArgs.rows).toEqual([
+ expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
+ expect(appendRows()).toEqual([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
])
- expect(mockReplaceTableRowsWithTx).not.toHaveBeenCalled()
+ expect(mockImportReplaceRows).not.toHaveBeenCalled()
})
it('accepts chunked multipart imports without a content-length header', async () => {
@@ -244,7 +279,7 @@ describe('POST /api/table/[tableId]/import', () => {
const response = await POST(req, { params: Promise.resolve({ tableId: 'tbl_1' }) })
expect(response.status).toBe(200)
- expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1)
+ expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
})
it('rejects append when it would exceed maxRows', async () => {
@@ -258,11 +293,11 @@ describe('POST /api/table/[tableId]/import', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toMatch(/exceed table row limit/)
- expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled()
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
})
- it('replaces rows via replaceTableRows', async () => {
- mockReplaceTableRowsWithTx.mockResolvedValueOnce({ deletedCount: 5, insertedCount: 2 })
+ it('replaces rows via importReplaceRows', async () => {
+ mockImportReplaceRows.mockResolvedValueOnce({ deletedCount: 5, insertedCount: 2 })
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30\nBob,40'), { mode: 'replace' })
)
@@ -271,8 +306,8 @@ describe('POST /api/table/[tableId]/import', () => {
expect(data.data.mode).toBe('replace')
expect(data.data.deletedCount).toBe(5)
expect(data.data.insertedCount).toBe(2)
- expect(mockReplaceTableRowsWithTx).toHaveBeenCalledTimes(1)
- expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled()
+ expect(mockImportReplaceRows).toHaveBeenCalledTimes(1)
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
})
it('uses an explicit mapping when provided', async () => {
@@ -285,8 +320,7 @@ describe('POST /api/table/[tableId]/import', () => {
expect(response.status).toBe(200)
const data = await response.json()
expect(data.data.mappedColumns).toEqual(['First Name', 'Years'])
- const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] }
- expect(callArgs.rows).toEqual([
+ expect(appendRows()).toEqual([
{ name: 'Alice', age: 30 },
{ name: 'Bob', age: 40 },
])
@@ -316,8 +350,8 @@ describe('POST /api/table/[tableId]/import', () => {
expect(data.error).toMatch(/Mapping values must be/)
})
- it('surfaces unique violations from batchInsertRows as 400', async () => {
- mockBatchInsertRowsWithTx.mockRejectedValueOnce(
+ it('surfaces unique violations from importAppendRows as 400', async () => {
+ mockImportAppendRows.mockRejectedValueOnce(
new Error('Row 1: Column "name" must be unique. Value "Alice" already exists in row row_xxx')
)
const response = await callPost(
@@ -337,7 +371,7 @@ describe('POST /api/table/[tableId]/import', () => {
)
)
expect(response.status).toBe(200)
- expect(mockBatchInsertRowsWithTx).toHaveBeenCalledTimes(1)
+ expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
})
it('returns 400 for unsupported file extensions', async () => {
@@ -358,12 +392,9 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
- expect(mockAddTableColumnsWithTx).toHaveBeenCalledTimes(1)
- const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0]
- expect(columns).toEqual([{ name: 'email', type: 'string' }])
-
- const callArgs = mockBatchInsertRowsWithTx.mock.calls[0][1] as { rows: unknown[] }
- expect(callArgs.rows).toEqual([
+ expect(mockImportAppendRows).toHaveBeenCalledTimes(1)
+ expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
+ expect(appendRows()).toEqual([
{ name: 'Alice', age: 30, email: 'a@x.io' },
{ name: 'Bob', age: 40, email: 'b@x.io' },
])
@@ -377,8 +408,7 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
- const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0]
- expect(columns).toEqual([{ name: 'score', type: 'number' }])
+ expect(appendAdditions()).toEqual([{ name: 'score', type: 'number' }])
})
it('dedupes when sanitized name collides with an existing column', async () => {
@@ -401,8 +431,7 @@ describe('POST /api/table/[tableId]/import', () => {
})
)
expect(response.status).toBe(200)
- const [, , columns] = mockAddTableColumnsWithTx.mock.calls[0]
- expect(columns).toEqual([{ name: 'Email_2', type: 'string' }])
+ expect(appendAdditions()).toEqual([{ name: 'Email_2', type: 'string' }])
})
it('returns 400 when createColumns references a header not in the CSV', async () => {
@@ -415,8 +444,7 @@ describe('POST /api/table/[tableId]/import', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toMatch(/unknown CSV headers/)
- expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled()
- expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled()
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
})
it('returns 400 when createColumns is not an array of strings', async () => {
@@ -429,7 +457,7 @@ describe('POST /api/table/[tableId]/import', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toMatch(/createColumns must be a JSON array/)
- expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled()
+ expect(mockImportAppendRows).not.toHaveBeenCalled()
})
it('returns 400 when createColumns is invalid JSON', async () => {
@@ -444,8 +472,8 @@ describe('POST /api/table/[tableId]/import', () => {
expect(data.error).toMatch(/createColumns must be valid JSON/)
})
- it('surfaces addTableColumns failures as 400', async () => {
- mockAddTableColumnsWithTx.mockRejectedValueOnce(new Error('Column "email" already exists'))
+ it('surfaces column-creation failures from importAppendRows as 400', async () => {
+ mockImportAppendRows.mockRejectedValueOnce(new Error('Column "email" already exists'))
const response = await callPost(
createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), {
mode: 'append',
@@ -455,30 +483,30 @@ describe('POST /api/table/[tableId]/import', () => {
expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toMatch(/already exists/)
- expect(mockBatchInsertRowsWithTx).not.toHaveBeenCalled()
})
it('surfaces row insert failures without success when schema was mutated', async () => {
- mockBatchInsertRowsWithTx.mockRejectedValueOnce(new Error('must be unique'))
+ mockImportAppendRows.mockRejectedValueOnce(new Error('must be unique'))
const response = await callPost(
createFormData(createCsvFile('name,age,email\nAlice,30,a@x.io'), {
mode: 'append',
createColumns: ['email'],
})
)
- expect(mockAddTableColumnsWithTx).toHaveBeenCalled()
+ // Route forwarded the column addition into the (now atomic) import op.
+ expect(appendAdditions()).toEqual([{ name: 'email', type: 'string' }])
expect(response.status).toBe(400)
const data = await response.json()
expect(data.success).toBeUndefined()
expect(data.error).toMatch(/must be unique/)
})
- it('does not call addTableColumns when createColumns is omitted', async () => {
+ it('passes no additions when createColumns is omitted', async () => {
const response = await callPost(
createFormData(createCsvFile('name,age\nAlice,30'), { mode: 'append' })
)
expect(response.status).toBe(200)
- expect(mockAddTableColumnsWithTx).not.toHaveBeenCalled()
+ expect(appendAdditions()).toEqual([])
})
})
})
diff --git a/apps/sim/app/api/table/[tableId]/import/route.ts b/apps/sim/app/api/table/[tableId]/import/route.ts
index e097723c023..5fd11d20426 100644
--- a/apps/sim/app/api/table/[tableId]/import/route.ts
+++ b/apps/sim/app/api/table/[tableId]/import/route.ts
@@ -1,4 +1,4 @@
-import { db } from '@sim/db'
+import type { Readable } from 'node:stream'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -13,36 +13,39 @@ import {
} from '@/lib/api/contracts/tables'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
import { generateRequestId } from '@/lib/core/utils/request'
-import {
- isPayloadSizeLimitError,
- readFileToBufferWithLimit,
- readFormDataWithLimit,
-} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
- addTableColumnsWithTx,
- batchInsertRowsWithTx,
buildAutoMapping,
- CSV_MAX_BATCH_SIZE,
CSV_MAX_FILE_SIZE_BYTES,
type CsvHeaderMapping,
CsvImportValidationError,
coerceRowsForTable,
+ createCsvParser,
dispatchAfterBatchInsert,
+ importAppendRows,
+ importReplaceRows,
inferColumnType,
- parseCsvBuffer,
- replaceTableRowsWithTx,
+ markTableImporting,
+ releaseImportClaim,
sanitizeName,
type TableDefinition,
- type TableRow,
type TableSchema,
validateMapping,
} from '@/lib/table'
-import { accessError, checkAccess } from '@/app/api/table/utils'
+import {
+ accessError,
+ checkAccess,
+ csvProxyBodyCapResponse,
+ multipartErrorResponse,
+} from '@/app/api/table/utils'
const logger = createLogger('TableImportCSVExisting')
-const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export const maxDuration = 300
interface RouteParams {
params: Promise<{ tableId: string }>
@@ -51,6 +54,8 @@ interface RouteParams {
export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => {
const requestId = generateRequestId()
const { tableId } = tableIdParamsSchema.parse(await params)
+ let fileStream: Readable | undefined
+ let claimedImportId: string | null = null
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
@@ -58,29 +63,37 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
- const formData = await readFormDataWithLimit(request, {
- maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES,
- label: 'CSV import body',
- })
- const formValidation = csvImportFormSchema.safeParse({
- file: formData.get('file'),
- workspaceId: formData.get('workspaceId'),
- })
- const rawMode = formData.get('mode') ?? 'append'
- const rawMapping = formData.get('mapping')
- const rawCreateColumns = formData.get('createColumns')
-
- if (!formValidation.success) {
- const message = getValidationErrorMessage(formValidation.error)
- const isSizeLimit = message.includes('File exceeds maximum allowed size')
+ const oversize = csvProxyBodyCapResponse(request)
+ if (oversize) return oversize
+
+ let parsed: Awaited>
+ try {
+ parsed = await readMultipart(request, {
+ maxFileBytes: CSV_MAX_FILE_SIZE_BYTES,
+ requiredFieldsBeforeFile: ['workspaceId'],
+ signal: request.signal,
+ })
+ } catch (err) {
+ if (isMultipartError(err)) return multipartErrorResponse(err)
+ throw err
+ }
+
+ const { fields, file } = parsed
+ if (!file) {
+ return NextResponse.json({ error: 'CSV file is required' }, { status: 400 })
+ }
+ fileStream = file.stream
+
+ const workspaceIdResult = csvImportFormSchema.shape.workspaceId.safeParse(fields.workspaceId)
+ if (!workspaceIdResult.success) {
return NextResponse.json(
- { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message },
- { status: isSizeLimit ? 413 : 400 }
+ { error: getValidationErrorMessage(workspaceIdResult.error) },
+ { status: 400 }
)
}
+ const workspaceId = workspaceIdResult.data
- const { file, workspaceId } = formValidation.data
-
+ const rawMode = fields.mode ?? 'append'
const modeValidation = csvImportModeSchema.safeParse(rawMode)
if (!modeValidation.success) {
return NextResponse.json(
@@ -90,7 +103,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
}
const mode = modeValidation.data
- const ext = file.name.split('.').pop()?.toLowerCase()
+ const ext = file.filename.split('.').pop()?.toLowerCase()
const extensionValidation = csvExtensionSchema.safeParse(ext)
if (!extensionValidation.success) {
return NextResponse.json(
@@ -114,10 +127,18 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
if (table.archivedAt) {
return NextResponse.json({ error: 'Cannot import into an archived table' }, { status: 400 })
}
+ // Don't run a sync import on top of an in-flight background import — concurrent writers
+ // would insert at colliding row positions.
+ if (table.importStatus === 'importing') {
+ return NextResponse.json(
+ { error: 'An import is already in progress for this table' },
+ { status: 409 }
+ )
+ }
let mapping: CsvHeaderMapping | undefined
- if (rawMapping) {
- const mappingValidation = csvImportMappingSchema.safeParse(rawMapping)
+ if (fields.mapping) {
+ const mappingValidation = csvImportMappingSchema.safeParse(fields.mapping)
if (!mappingValidation.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(mappingValidation.error) },
@@ -128,8 +149,8 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
}
let createColumns: string[] | undefined
- if (rawCreateColumns) {
- const createColumnsValidation = csvImportCreateColumnsSchema.safeParse(rawCreateColumns)
+ if (fields.createColumns) {
+ const createColumnsValidation = csvImportCreateColumnsSchema.safeParse(fields.createColumns)
if (!createColumnsValidation.success) {
return NextResponse.json(
{ error: getValidationErrorMessage(createColumnsValidation.error) },
@@ -139,12 +160,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
createColumns = createColumnsValidation.data
}
- const buffer = await readFileToBufferWithLimit(file, {
- maxBytes: CSV_MAX_FILE_SIZE_BYTES,
- label: 'CSV import file',
- })
const delimiter = extensionValidation.data === 'tsv' ? '\t' : ','
- const { headers, rows } = await parseCsvBuffer(buffer, delimiter)
+ const parser = createCsvParser(delimiter)
+ // `.pipe` doesn't forward source errors; forward them so the iterator throws.
+ file.stream.on('error', (streamErr) => parser.destroy(streamErr))
+ file.stream.pipe(parser)
+ const rows: Record[] = []
+ for await (const record of parser as AsyncIterable>) {
+ rows.push(record)
+ }
+ if (rows.length === 0) {
+ return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
+ }
+ const headers = Object.keys(rows[0])
let effectiveMapping = mapping ?? buildAutoMapping(headers, table.schema)
let prospectiveTable: TableDefinition = table
@@ -218,6 +246,19 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
const coerced = coerceRowsForTable(rows, prospectiveTable.schema, validation.effectiveMap)
+ // Atomically claim the table before writing. The pre-check above reads a checkAccess snapshot
+ // taken before the parse/validation; a background import could claim the table in that window.
+ // markTableImporting is the single atomic gate (same one the async kickoff uses) — released in
+ // the finally so a sync import can't write concurrently with a background one (corrupts replace).
+ const syncImportId = generateId()
+ if (!(await markTableImporting(tableId, syncImportId))) {
+ return NextResponse.json(
+ { error: 'An import is already in progress for this table' },
+ { status: 409 }
+ )
+ }
+ claimedImportId = syncImportId
+
if (mode === 'append') {
if (prospectiveTable.rowCount + coerced.length > prospectiveTable.maxRows) {
const deficit = prospectiveTable.rowCount + coerced.length - prospectiveTable.maxRows
@@ -230,32 +271,12 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
}
try {
- const txResult = await db.transaction(async (trx) => {
- let working = table
- if (additions.length > 0) {
- working = await addTableColumnsWithTx(trx, table, additions, requestId)
- }
-
- const allInserted: TableRow[] = []
- for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) {
- const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE)
- const batchRequestId = generateId().slice(0, 8)
- const result = await batchInsertRowsWithTx(
- trx,
- {
- tableId: working.id,
- rows: batch,
- workspaceId,
- userId: authResult.userId,
- },
- working,
- batchRequestId
- )
- allInserted.push(...result)
- }
- return { inserted: allInserted, working }
- })
- const { inserted: insertedRows, working: finalTable } = txResult
+ const { inserted: insertedRows, table: finalTable } = await importAppendRows(
+ table,
+ additions,
+ coerced,
+ { workspaceId, userId: authResult.userId, requestId }
+ )
const inserted = insertedRows.length
// Fire trigger + scheduler AFTER the tx commits — both read through the
// global db connection and would otherwise see no rows.
@@ -263,7 +284,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
logger.info(`[${requestId}] Append CSV imported`, {
tableId: table.id,
- fileName: file.name,
+ fileName: file.filename,
mode,
inserted,
createdColumns: additions.length,
@@ -280,7 +301,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
mappedColumns: validation.mappedHeaders,
skippedHeaders: validation.skippedHeaders,
unmappedColumns: validation.unmappedColumns,
- sourceFile: file.name,
+ sourceFile: file.filename,
},
})
} catch (err) {
@@ -310,22 +331,16 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
}
try {
- const result = await db.transaction(async (trx) => {
- let working = table
- if (additions.length > 0) {
- working = await addTableColumnsWithTx(trx, table, additions, requestId)
- }
- return replaceTableRowsWithTx(
- trx,
- { tableId: working.id, rows: coerced, workspaceId, userId: authResult.userId },
- working,
- requestId
- )
- })
+ const result = await importReplaceRows(
+ table,
+ additions,
+ { rows: coerced, workspaceId, userId: authResult.userId },
+ requestId
+ )
logger.info(`[${requestId}] Replace CSV imported`, {
tableId: table.id,
- fileName: file.name,
+ fileName: file.filename,
mode,
deleted: result.deletedCount,
inserted: result.insertedCount,
@@ -343,7 +358,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
mappedColumns: validation.mappedHeaders,
skippedHeaders: validation.skippedHeaders,
unmappedColumns: validation.unmappedColumns,
- sourceFile: file.name,
+ sourceFile: file.filename,
},
})
} catch (err) {
@@ -362,22 +377,23 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
throw err
}
} catch (error) {
+ if (isMultipartError(error)) return multipartErrorResponse(error)
+
const message = toError(error).message
logger.error(`[${requestId}] CSV import into existing table failed:`, error)
- const isSizeLimitError =
- isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size')
const isClientError =
message.includes('CSV file has no') ||
message.includes('already exists') ||
- message.includes('Invalid column name') ||
- isSizeLimitError
+ message.includes('Invalid column name')
return NextResponse.json(
{ error: isClientError ? message : 'Failed to import CSV' },
- {
- status: isSizeLimitError ? 413 : isClientError ? 400 : 500,
- }
+ { status: isClientError ? 400 : 500 }
)
+ } finally {
+ fileStream?.destroy()
+ // Release before the response returns, so a client refetch never observes the transient claim.
+ if (claimedImportId) await releaseImportClaim(tableId, claimedImportId).catch(() => {})
}
})
diff --git a/apps/sim/app/api/table/[tableId]/route.ts b/apps/sim/app/api/table/[tableId]/route.ts
index 0e73ecaaeba..c0b018f854e 100644
--- a/apps/sim/app/api/table/[tableId]/route.ts
+++ b/apps/sim/app/api/table/[tableId]/route.ts
@@ -68,6 +68,10 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Tab
table.updatedAt instanceof Date
? table.updatedAt.toISOString()
: String(table.updatedAt),
+ importStatus: table.importStatus ?? null,
+ importId: table.importId ?? null,
+ importError: table.importError ?? null,
+ importRowsProcessed: table.importRowsProcessed ?? 0,
},
},
})
diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts
index 8e29e12005c..9b5ad9492db 100644
--- a/apps/sim/app/api/table/[tableId]/rows/route.ts
+++ b/apps/sim/app/api/table/[tableId]/rows/route.ts
@@ -1,8 +1,5 @@
-import { db } from '@sim/db'
-import { tableRowExecutions, userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { and, eq, inArray, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
type BatchInsertTableRowsBodyInput,
@@ -17,27 +14,20 @@ import { isZodError, validationErrorResponse } from '@/lib/api/server/validation
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import type {
- Filter,
- RowData,
- RowExecutionMetadata,
- RowExecutions,
- Sort,
- TableSchema,
-} from '@/lib/table'
+import type { Filter, RowData, Sort, TableSchema } from '@/lib/table'
import {
batchInsertRows,
batchUpdateRows,
deleteRowsByFilter,
deleteRowsByIds,
insertRow,
- USER_TABLE_ROWS_SQL_NAME,
updateRowsByFilter,
validateBatchRows,
validateRowData,
validateRowSize,
} from '@/lib/table'
-import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql'
+import { queryRows } from '@/lib/table/service'
+import { TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'
const logger = createLogger('TableRowsAPI')
@@ -268,113 +258,35 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- const baseConditions = [
- eq(userTableRows.tableId, tableId),
- eq(userTableRows.workspaceId, validated.workspaceId),
- ]
-
- const schema = table.schema as TableSchema
-
- if (validated.filter) {
- const filterClause = buildFilterClause(
- validated.filter as Filter,
- USER_TABLE_ROWS_SQL_NAME,
- schema.columns
- )
- if (filterClause) {
- baseConditions.push(filterClause)
- }
- }
-
- let query = db
- .select({
- id: userTableRows.id,
- data: userTableRows.data,
- position: userTableRows.position,
- createdAt: userTableRows.createdAt,
- updatedAt: userTableRows.updatedAt,
- })
- .from(userTableRows)
- .where(and(...baseConditions))
-
- if (validated.sort) {
- const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
- if (sortClause) {
- query = query.orderBy(sortClause) as typeof query
- } else {
- query = query.orderBy(userTableRows.position) as typeof query
- }
- } else {
- query = query.orderBy(userTableRows.position) as typeof query
- }
-
- let totalCount: number | null = null
- if (validated.includeTotal) {
- const [{ count }] = await db
- .select({ count: sql`count(*)` })
- .from(userTableRows)
- .where(and(...baseConditions))
- totalCount = Number(count)
- }
-
- const rows = await query.limit(validated.limit).offset(validated.offset)
-
- // Sidecar: fetch per-(row, group) execution state and group into a map
- // so the response preserves the legacy `row.executions[groupId]` wire
- // shape. One indexed-IN scan against table_row_executions.
- const executionsByRow = new Map()
- if (rows.length > 0) {
- const execRows = await db
- .select()
- .from(tableRowExecutions)
- .where(
- inArray(
- tableRowExecutions.rowId,
- rows.map((r) => r.id)
- )
- )
- for (const e of execRows) {
- const existing = executionsByRow.get(e.rowId) ?? {}
- const meta: RowExecutionMetadata = {
- status: e.status as RowExecutionMetadata['status'],
- executionId: e.executionId ?? null,
- jobId: e.jobId ?? null,
- workflowId: e.workflowId,
- error: e.error ?? null,
- ...(e.runningBlockIds && e.runningBlockIds.length > 0
- ? { runningBlockIds: e.runningBlockIds }
- : {}),
- ...(e.blockErrors && Object.keys(e.blockErrors as Record).length > 0
- ? { blockErrors: e.blockErrors as Record }
- : {}),
- ...(e.cancelledAt ? { cancelledAt: e.cancelledAt.toISOString() } : {}),
- }
- existing[e.groupId] = meta
- executionsByRow.set(e.rowId, existing)
- }
- }
-
- logger.info(
- `[${requestId}] Queried ${rows.length} rows from table ${tableId} (total: ${totalCount ?? 'n/a'})`
+ const result = await queryRows(
+ table,
+ {
+ filter: validated.filter as Filter | undefined,
+ sort: validated.sort,
+ limit: validated.limit,
+ offset: validated.offset,
+ includeTotal: validated.includeTotal,
+ },
+ requestId
)
return NextResponse.json({
success: true,
data: {
- rows: rows.map((r) => ({
+ rows: result.rows.map((r) => ({
id: r.id,
data: r.data,
- executions: executionsByRow.get(r.id) ?? {},
+ executions: r.executions,
position: r.position,
createdAt:
r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt:
r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
- rowCount: rows.length,
- totalCount,
- limit: validated.limit,
- offset: validated.offset,
+ rowCount: result.rowCount,
+ totalCount: result.totalCount,
+ limit: result.limit,
+ offset: result.offset,
},
})
} catch (error) {
diff --git a/apps/sim/app/api/table/import-async/route.test.ts b/apps/sim/app/api/table/import-async/route.test.ts
new file mode 100644
index 00000000000..8ecdd2a923a
--- /dev/null
+++ b/apps/sim/app/api/table/import-async/route.test.ts
@@ -0,0 +1,123 @@
+/**
+ * @vitest-environment node
+ */
+import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockCreateTable,
+ mockGetLimits,
+ mockListTables,
+ mockRunTableImport,
+ mockRunDetached,
+ MockTableConflictError,
+} = vi.hoisted(() => ({
+ mockCreateTable: vi.fn(),
+ mockGetLimits: vi.fn(),
+ mockListTables: vi.fn(),
+ mockRunTableImport: vi.fn(),
+ mockRunDetached: vi.fn(),
+ MockTableConflictError: class extends Error {
+ readonly code = 'TABLE_EXISTS' as const
+ },
+}))
+
+vi.mock('@sim/utils/id', () => ({
+ generateId: vi.fn().mockReturnValue('import-id-123'),
+ generateShortId: vi.fn().mockReturnValue('short-id'),
+}))
+
+vi.mock('@/lib/table', () => ({
+ createTable: mockCreateTable,
+ getWorkspaceTableLimits: mockGetLimits,
+ listTables: mockListTables,
+ sanitizeName: (name: string) => name.replace(/[^a-zA-Z0-9_]/g, '_'),
+ TABLE_LIMITS: { MAX_TABLE_NAME_LENGTH: 128 },
+ TableConflictError: MockTableConflictError,
+}))
+vi.mock('@/lib/table/import-runner', () => ({ runTableImport: mockRunTableImport }))
+vi.mock('@/lib/core/utils/background', () => ({
+ runDetached: mockRunDetached.mockImplementation(
+ (_label: string, work: () => Promise) => {
+ void work()
+ }
+ ),
+}))
+vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
+
+import { POST } from '@/app/api/table/import-async/route'
+
+function makeRequest(body: unknown): NextRequest {
+ return new NextRequest('http://localhost:3000/api/table/import-async', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+}
+
+const validBody = {
+ workspaceId: 'workspace-1',
+ fileKey: 'workspace/workspace-1/123-data.csv',
+ fileName: 'data.csv',
+}
+
+describe('POST /api/table/import-async', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
+ success: true,
+ userId: 'user-1',
+ authType: 'session',
+ })
+ permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
+ mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 })
+ mockListTables.mockResolvedValue([])
+ mockCreateTable.mockResolvedValue({ id: 'tbl_async', name: 'data' })
+ mockRunTableImport.mockResolvedValue(undefined)
+ })
+
+ it('creates an importing table and kicks off the background import', async () => {
+ const response = await POST(makeRequest(validBody))
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.data).toEqual({ tableId: 'tbl_async', importId: 'import-id-123' })
+ expect(mockCreateTable).toHaveBeenCalledWith(
+ expect.objectContaining({ importStatus: 'importing', importId: 'import-id-123' }),
+ expect.any(String)
+ )
+ expect(mockRunTableImport).toHaveBeenCalledWith(
+ expect.objectContaining({ tableId: 'tbl_async', mode: 'create', delimiter: ',' })
+ )
+ })
+
+ it('uses a tab delimiter for .tsv files', async () => {
+ await POST(makeRequest({ ...validBody, fileName: 'data.tsv' }))
+ expect(mockRunTableImport).toHaveBeenCalledWith(expect.objectContaining({ delimiter: '\t' }))
+ })
+
+ it('returns 400 for unsupported extensions', async () => {
+ const response = await POST(makeRequest({ ...validBody, fileName: 'data.json' }))
+ expect(response.status).toBe(400)
+ expect(mockCreateTable).not.toHaveBeenCalled()
+ })
+
+ it('returns 401 when unauthenticated', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
+ const response = await POST(makeRequest(validBody))
+ expect(response.status).toBe(401)
+ })
+
+ it('returns 403 without write permission', async () => {
+ permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
+ const response = await POST(makeRequest(validBody))
+ expect(response.status).toBe(403)
+ expect(mockCreateTable).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when the body is missing required fields', async () => {
+ const response = await POST(makeRequest({ workspaceId: 'workspace-1' }))
+ expect(response.status).toBe(400)
+ })
+})
diff --git a/apps/sim/app/api/table/import-async/route.ts b/apps/sim/app/api/table/import-async/route.ts
new file mode 100644
index 00000000000..43fefeca9a6
--- /dev/null
+++ b/apps/sim/app/api/table/import-async/route.ts
@@ -0,0 +1,115 @@
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { importTableAsyncContract } from '@/lib/api/contracts/tables'
+import { parseRequest } from '@/lib/api/server'
+import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { runDetached } from '@/lib/core/utils/background'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import {
+ createTable,
+ getWorkspaceTableLimits,
+ listTables,
+ sanitizeName,
+ TABLE_LIMITS,
+ TableConflictError,
+} from '@/lib/table'
+import { runTableImport } from '@/lib/table/import-runner'
+import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
+
+const logger = createLogger('TableImportAsync')
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateRequestId()
+
+ const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
+ if (!authResult.success || !authResult.userId) {
+ return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
+ }
+ const userId = authResult.userId
+
+ const parsed = await parseRequest(importTableAsyncContract, request, {})
+ if (!parsed.success) return parsed.response
+ const { workspaceId, fileKey, fileName } = parsed.data.body
+
+ const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
+ if (permission !== 'write' && permission !== 'admin') {
+ return NextResponse.json({ error: 'Access denied' }, { status: 403 })
+ }
+ // The fileKey is client-supplied — ensure it points at this workspace's storage prefix so a
+ // caller can't import another workspace's uploaded object.
+ if (!fileKey.startsWith(`workspace/${workspaceId}/`)) {
+ return NextResponse.json({ error: 'Invalid file key for workspace' }, { status: 400 })
+ }
+
+ const ext = fileName.split('.').pop()?.toLowerCase()
+ if (ext !== 'csv' && ext !== 'tsv') {
+ return NextResponse.json({ error: 'Only CSV and TSV files are supported' }, { status: 400 })
+ }
+ const delimiter = ext === 'tsv' ? '\t' : ','
+
+ const planLimits = await getWorkspaceTableLimits(workspaceId)
+ const baseName = sanitizeName(fileName.replace(/\.[^.]+$/, ''), 'imported_table').slice(
+ 0,
+ TABLE_LIMITS.MAX_TABLE_NAME_LENGTH
+ )
+ // Re-importing the same file shouldn't fail on a name collision — pick the next free
+ // `name_2`, `name_3`, … (matching how "New table" auto-names), keeping under the cap.
+ const existingNames = new Set(
+ (await listTables(workspaceId, { scope: 'all' })).map((t) => t.name.toLowerCase())
+ )
+ let tableName = baseName
+ for (let n = 2; existingNames.has(tableName.toLowerCase()); n++) {
+ const suffix = `_${n}`
+ tableName = `${baseName.slice(0, TABLE_LIMITS.MAX_TABLE_NAME_LENGTH - suffix.length)}${suffix}`
+ }
+ const importId = generateId()
+
+ // Placeholder schema satisfies createTable's validation; the import worker infers the
+ // real columns from the file and overwrites it before any rows become visible.
+ let table: Awaited>
+ try {
+ table = await createTable(
+ {
+ name: tableName,
+ description: `Imported from ${fileName}`,
+ schema: { columns: [{ name: 'column_1', type: 'string' }] },
+ workspaceId,
+ userId,
+ maxRows: planLimits.maxRowsPerTable,
+ maxTables: planLimits.maxTables,
+ importStatus: 'importing',
+ importId,
+ },
+ requestId
+ )
+ } catch (error) {
+ if (error instanceof TableConflictError) {
+ return NextResponse.json({ error: error.message }, { status: 409 })
+ }
+ if (error instanceof Error && error.message.includes('maximum table limit')) {
+ return NextResponse.json({ error: error.message }, { status: 400 })
+ }
+ throw error
+ }
+
+ runDetached('table-import', () =>
+ runTableImport({
+ importId,
+ tableId: table.id,
+ workspaceId,
+ userId,
+ fileKey,
+ fileName,
+ delimiter,
+ mode: 'create',
+ })
+ )
+
+ logger.info(`[${requestId}] Async CSV import started`, { tableId: table.id, importId, fileName })
+ return NextResponse.json({ success: true, data: { tableId: table.id, importId } })
+})
diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts
index 9844bf69664..dc0bb0a53a5 100644
--- a/apps/sim/app/api/table/import-csv/route.test.ts
+++ b/apps/sim/app/api/table/import-csv/route.test.ts
@@ -5,10 +5,11 @@ import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/tes
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockCreateTable, mockParseCsvBuffer, mockGetWorkspaceTableLimits } = vi.hoisted(() => ({
+const { mockCreateTable, mockBatchInsertRows, mockDeleteTable, mockGetLimits } = vi.hoisted(() => ({
mockCreateTable: vi.fn(),
- mockParseCsvBuffer: vi.fn(),
- mockGetWorkspaceTableLimits: vi.fn(),
+ mockBatchInsertRows: vi.fn(),
+ mockDeleteTable: vi.fn(),
+ mockGetLimits: vi.fn(),
}))
vi.mock('@sim/utils/id', () => ({
@@ -16,46 +17,83 @@ vi.mock('@sim/utils/id', () => ({
generateShortId: vi.fn().mockReturnValue('short-id'),
}))
-vi.mock('@/lib/table', () => ({
- batchInsertRows: vi.fn(),
- CSV_MAX_BATCH_SIZE: 1000,
- CSV_MAX_FILE_SIZE_BYTES: 25 * 1024 * 1024,
- coerceRowsForTable: vi.fn(),
+// Mock only the DB-backed service/billing functions; the real `./import` helpers
+// (createCsvParser, inferSchemaFromCsv, coerceRowsForTable, …) run for real so the
+// streaming multipart + CSV pipeline is exercised end-to-end.
+vi.mock('@/lib/table/service', () => ({
createTable: mockCreateTable,
- deleteTable: vi.fn(),
- getWorkspaceTableLimits: mockGetWorkspaceTableLimits,
- inferSchemaFromCsv: vi.fn(),
- parseCsvBuffer: mockParseCsvBuffer,
- sanitizeName: vi.fn((name: string) => name),
- TABLE_LIMITS: {
- MAX_TABLE_NAME_LENGTH: 64,
- },
+ batchInsertRows: mockBatchInsertRows,
+ deleteTable: mockDeleteTable,
}))
-
-vi.mock('@/app/api/table/utils', () => ({
- normalizeColumn: vi.fn((column) => column),
-}))
-
+vi.mock('@/lib/table/billing', () => ({ getWorkspaceTableLimits: mockGetLimits }))
+vi.mock('@/app/api/table/utils', async () => {
+ const { NextResponse } = await import('next/server')
+ return {
+ normalizeColumn: (column: unknown) => column,
+ csvProxyBodyCapResponse: () => null,
+ multipartErrorResponse: (error: { code: string; message: string }) =>
+ NextResponse.json(
+ { error: error.message },
+ { status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
+ ),
+ }
+})
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
import { POST } from '@/app/api/table/import-csv/route'
-function createCsvFile(contents: string, name = 'data.csv', type = 'text/csv'): File {
- return new File([contents], name, { type })
+type Part =
+ | { name: string; value: string }
+ | { name: string; filename: string; value: string; contentType?: string }
+
+const BOUNDARY = '----testboundaryCSV'
+
+function buildBody(parts: Part[]): Buffer {
+ const segments: Buffer[] = []
+ for (const part of parts) {
+ let header = `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.name}"`
+ if ('filename' in part) {
+ header += `; filename="${part.filename}"\r\nContent-Type: ${part.contentType ?? 'text/csv'}`
+ }
+ header += '\r\n\r\n'
+ segments.push(Buffer.from(header, 'utf8'), Buffer.from(part.value, 'utf8'), Buffer.from('\r\n'))
+ }
+ segments.push(Buffer.from(`--${BOUNDARY}--\r\n`, 'utf8'))
+ return Buffer.concat(segments)
}
-function createFormData(file: File): FormData {
- const form = new FormData()
- form.append('file', file)
- form.append('workspaceId', 'workspace-1')
- return form
+function makeRequest(parts: Part[], chunkSize?: number): NextRequest {
+ const body = buildBody(parts)
+ const stream = new ReadableStream({
+ start(controller) {
+ if (chunkSize) {
+ for (let i = 0; i < body.length; i += chunkSize) {
+ controller.enqueue(new Uint8Array(body.subarray(i, i + chunkSize)))
+ }
+ } else {
+ controller.enqueue(new Uint8Array(body))
+ }
+ controller.close()
+ },
+ })
+ return {
+ headers: new Headers({ 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }),
+ body: stream,
+ signal: undefined,
+ } as unknown as NextRequest
}
-async function callPost(form: FormData) {
- const req = {
- formData: async () => form,
- } as unknown as NextRequest
- return POST(req)
+function csvWithRows(count: number): string {
+ const lines = ['name,age']
+ for (let i = 0; i < count; i++) lines.push(`Person${i},${20 + (i % 50)}`)
+ return `${lines.join('\n')}\n`
+}
+
+function uploadParts(csv: string): Part[] {
+ return [
+ { name: 'workspaceId', value: 'workspace-1' },
+ { name: 'file', filename: 'data.csv', value: csv },
+ ]
}
describe('POST /api/table/import-csv', () => {
@@ -67,38 +105,93 @@ describe('POST /api/table/import-csv', () => {
authType: 'session',
})
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
- mockGetWorkspaceTableLimits.mockResolvedValue({
- maxRowsPerTable: 1000,
- maxTables: 10,
- })
+ mockGetLimits.mockResolvedValue({ maxRowsPerTable: 1_000_000, maxTables: 50 })
+ mockCreateTable.mockImplementation(async (data) => ({
+ id: 'tbl_1',
+ name: data.name,
+ description: data.description ?? null,
+ schema: data.schema,
+ workspaceId: data.workspaceId,
+ maxRows: data.maxRows,
+ rowCount: 0,
+ createdBy: 'user-1',
+ archivedAt: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }))
+ mockBatchInsertRows.mockImplementation(async ({ rows }: { rows: unknown[] }) =>
+ rows.map((_, i) => ({ id: `row-${i}` }))
+ )
+ mockDeleteTable.mockResolvedValue(undefined)
})
- it('returns 413 for oversized CSV files before reading their contents or creating a table', async () => {
- const file = createCsvFile('name,age\nAlice,30')
- Object.defineProperty(file, 'size', {
- value: 26 * 1024 * 1024,
- })
- const arrayBufferSpy = vi.spyOn(file, 'arrayBuffer')
+ it('streams a CSV upload into a new table and reports the row count', async () => {
+ const response = await POST(makeRequest(uploadParts(csvWithRows(250))))
+ const data = await response.json()
- const response = await callPost(createFormData(file))
+ expect(response.status).toBe(200)
+ expect(mockCreateTable).toHaveBeenCalledTimes(1)
+ expect(data.data.table.id).toBe('tbl_1')
+ expect(data.data.table.rowCount).toBe(250)
+ // 250 rows = a 100-row schema-sample batch + a 150-row remainder batch.
+ expect(mockBatchInsertRows).toHaveBeenCalledTimes(2)
+ })
+
+ it('parses a body delivered in tiny chunks (regression: missing final boundary)', async () => {
+ const response = await POST(makeRequest(uploadParts(csvWithRows(5)), 7))
const data = await response.json()
- expect(response.status).toBe(413)
- expect(data.error).toMatch(/CSV import file exceeds maximum size/)
- expect(arrayBufferSpy).not.toHaveBeenCalled()
- expect(mockParseCsvBuffer).not.toHaveBeenCalled()
+ expect(response.status).toBe(200)
+ expect(data.data.table.rowCount).toBe(5)
+ })
+
+ it('returns 400 for a CSV with no data rows', async () => {
+ const response = await POST(makeRequest(uploadParts('name,age\n')))
+ const data = await response.json()
+
+ expect(response.status).toBe(400)
+ expect(data.error).toMatch(/no data rows/i)
+ expect(mockCreateTable).not.toHaveBeenCalled()
+ })
+
+ it('returns 400 when the file precedes required fields', async () => {
+ const response = await POST(
+ makeRequest([
+ { name: 'file', filename: 'data.csv', value: csvWithRows(3) },
+ { name: 'workspaceId', value: 'workspace-1' },
+ ])
+ )
+
+ expect(response.status).toBe(400)
expect(mockCreateTable).not.toHaveBeenCalled()
})
- it('accepts chunked multipart requests without a content-length header', async () => {
- const req = {
- headers: new Headers({ 'transfer-encoding': 'chunked' }),
- formData: vi.fn(async () => createFormData(createCsvFile('name\nAlice'))),
- } as unknown as NextRequest
+ it('returns 400 when no file part is present', async () => {
+ const response = await POST(makeRequest([{ name: 'workspaceId', value: 'workspace-1' }]))
+ expect(response.status).toBe(400)
+ expect(mockCreateTable).not.toHaveBeenCalled()
+ })
+
+ it('rolls back the created table when a batch insert fails mid-stream', async () => {
+ mockBatchInsertRows
+ .mockResolvedValueOnce(Array.from({ length: 100 }, () => ({ id: 'row' })))
+ .mockRejectedValueOnce(new Error('insert boom'))
+
+ const response = await POST(makeRequest(uploadParts(csvWithRows(250))))
- const response = await POST(req)
+ expect(response.status).toBe(500)
+ expect(mockDeleteTable).toHaveBeenCalledWith('tbl_1', expect.any(String))
+ })
+
+ it('returns 401 when unauthenticated', async () => {
+ hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({ success: false })
+ const response = await POST(makeRequest(uploadParts(csvWithRows(3))))
+ expect(response.status).toBe(401)
+ })
- expect(response.status).not.toBe(411)
- expect(req.formData).toHaveBeenCalled()
+ it('returns 403 without write permission', async () => {
+ permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read')
+ const response = await POST(makeRequest(uploadParts(csvWithRows(3))))
+ expect(response.status).toBe(403)
})
})
diff --git a/apps/sim/app/api/table/import-csv/route.ts b/apps/sim/app/api/table/import-csv/route.ts
index 31927889202..4ab4d26920e 100644
--- a/apps/sim/app/api/table/import-csv/route.ts
+++ b/apps/sim/app/api/table/import-csv/route.ts
@@ -1,3 +1,4 @@
+import type { Readable } from 'node:stream'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -5,163 +6,213 @@ import { type NextRequest, NextResponse } from 'next/server'
import { csvExtensionSchema, csvImportFormSchema } from '@/lib/api/contracts/tables'
import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
+import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
import { generateRequestId } from '@/lib/core/utils/request'
-import {
- isPayloadSizeLimitError,
- readFileToBufferWithLimit,
- readFormDataWithLimit,
-} from '@/lib/core/utils/stream-limits'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
batchInsertRows,
CSV_MAX_BATCH_SIZE,
CSV_MAX_FILE_SIZE_BYTES,
+ CSV_SCHEMA_SAMPLE_SIZE,
coerceRowsForTable,
+ createCsvParser,
createTable,
deleteTable,
getWorkspaceTableLimits,
inferSchemaFromCsv,
- parseCsvBuffer,
sanitizeName,
TABLE_LIMITS,
+ type TableDefinition,
type TableSchema,
} from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
-import { normalizeColumn } from '@/app/api/table/utils'
+import {
+ csvProxyBodyCapResponse,
+ multipartErrorResponse,
+ normalizeColumn,
+} from '@/app/api/table/utils'
const logger = createLogger('TableImportCSV')
-const MAX_MULTIPART_OVERHEAD_BYTES = 1024 * 1024
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export const maxDuration = 300
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
+ let fileStream: Readable | undefined
try {
const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success || !authResult.userId) {
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
}
+ const userId = authResult.userId
- const formData = await readFormDataWithLimit(request, {
- maxBytes: CSV_MAX_FILE_SIZE_BYTES + MAX_MULTIPART_OVERHEAD_BYTES,
- label: 'CSV import body',
- })
- const validation = csvImportFormSchema.safeParse({
- file: formData.get('file'),
- workspaceId: formData.get('workspaceId'),
- })
+ const oversize = csvProxyBodyCapResponse(request)
+ if (oversize) return oversize
+
+ let parsed: Awaited>
+ try {
+ parsed = await readMultipart(request, {
+ maxFileBytes: CSV_MAX_FILE_SIZE_BYTES,
+ requiredFieldsBeforeFile: ['workspaceId'],
+ signal: request.signal,
+ })
+ } catch (err) {
+ if (isMultipartError(err)) return multipartErrorResponse(err)
+ throw err
+ }
- if (!validation.success) {
- const message = getValidationErrorMessage(validation.error)
- const isSizeLimit = message.includes('File exceeds maximum allowed size')
+ const { fields, file } = parsed
+ if (!file) {
+ return NextResponse.json({ error: 'CSV file is required' }, { status: 400 })
+ }
+ fileStream = file.stream
+
+ const workspaceIdResult = csvImportFormSchema.shape.workspaceId.safeParse(fields.workspaceId)
+ if (!workspaceIdResult.success) {
return NextResponse.json(
- { error: isSizeLimit ? 'CSV import file exceeds maximum size' : message },
- { status: isSizeLimit ? 413 : 400 }
+ { error: getValidationErrorMessage(workspaceIdResult.error) },
+ { status: 400 }
)
}
+ const workspaceId = workspaceIdResult.data
- const { file, workspaceId } = validation.data
-
- const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId)
+ const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
- const ext = file.name.split('.').pop()?.toLowerCase()
- const extensionValidation = csvExtensionSchema.safeParse(ext)
- if (!extensionValidation.success) {
+ const ext = file.filename.split('.').pop()?.toLowerCase()
+ const extensionResult = csvExtensionSchema.safeParse(ext)
+ if (!extensionResult.success) {
return NextResponse.json(
- { error: getValidationErrorMessage(extensionValidation.error) },
+ { error: getValidationErrorMessage(extensionResult.error) },
{ status: 400 }
)
}
+ const delimiter = extensionResult.data === 'tsv' ? '\t' : ','
- const buffer = await readFileToBufferWithLimit(file, {
- maxBytes: CSV_MAX_FILE_SIZE_BYTES,
- label: 'CSV import file',
- })
- const delimiter = extensionValidation.data === 'tsv' ? '\t' : ','
- const { headers, rows } = await parseCsvBuffer(buffer, delimiter)
+ const parser = createCsvParser(delimiter)
+ // `.pipe` doesn't forward source errors; forward them so the iterator throws.
+ file.stream.on('error', (err) => parser.destroy(err))
+ file.stream.pipe(parser)
- const { columns, headerToColumn } = inferSchemaFromCsv(headers, rows)
- const tableName = sanitizeName(file.name.replace(/\.[^.]+$/, ''), 'imported_table').slice(
- 0,
- TABLE_LIMITS.MAX_TABLE_NAME_LENGTH
- )
- const planLimits = await getWorkspaceTableLimits(workspaceId)
+ interface ImportState {
+ table: TableDefinition
+ schema: TableSchema
+ headerToColumn: Map
+ }
- const normalizedSchema: TableSchema = {
- columns: columns.map(normalizeColumn),
+ const insertRows = async (rows: Record[], state: ImportState) => {
+ if (rows.length === 0) return 0
+ const coerced = coerceRowsForTable(rows, state.schema, state.headerToColumn)
+ const result = await batchInsertRows(
+ { tableId: state.table.id, rows: coerced, workspaceId, userId },
+ state.table,
+ generateId().slice(0, 8)
+ )
+ return result.length
}
- const table = await createTable(
- {
- name: tableName,
- description: `Imported from ${file.name}`,
- schema: normalizedSchema,
- workspaceId,
- userId: authResult.userId,
- maxRows: planLimits.maxRowsPerTable,
- maxTables: planLimits.maxTables,
- },
- requestId
- )
+ /** Infer the schema from the buffered sample and create the (empty) table. */
+ const buildTable = async (sampleRows: Record[]): Promise => {
+ const inferred = inferSchemaFromCsv(Object.keys(sampleRows[0]), sampleRows)
+ const schema: TableSchema = { columns: inferred.columns.map(normalizeColumn) }
+ const planLimits = await getWorkspaceTableLimits(workspaceId)
+ const tableName = sanitizeName(file.filename.replace(/\.[^.]+$/, ''), 'imported_table').slice(
+ 0,
+ TABLE_LIMITS.MAX_TABLE_NAME_LENGTH
+ )
+ const table = await createTable(
+ {
+ name: tableName,
+ description: `Imported from ${file.filename}`,
+ schema,
+ workspaceId,
+ userId,
+ maxRows: planLimits.maxRowsPerTable,
+ maxTables: planLimits.maxTables,
+ },
+ requestId
+ )
+ return { table, schema, headerToColumn: inferred.headerToColumn }
+ }
+
+ let state: ImportState | null = null
+ let inserted = 0
+ const sample: Record[] = []
+ let batch: Record[] = []
try {
- const coerced = coerceRowsForTable(rows, normalizedSchema, headerToColumn)
- let inserted = 0
- for (let i = 0; i < coerced.length; i += CSV_MAX_BATCH_SIZE) {
- const batch = coerced.slice(i, i + CSV_MAX_BATCH_SIZE)
- const batchRequestId = generateId().slice(0, 8)
- const result = await batchInsertRows(
- { tableId: table.id, rows: batch, workspaceId, userId: authResult.userId },
- table,
- batchRequestId
- )
- inserted += result.length
+ for await (const record of parser as AsyncIterable>) {
+ if (!state) {
+ sample.push(record)
+ if (sample.length >= CSV_SCHEMA_SAMPLE_SIZE) {
+ state = await buildTable(sample)
+ inserted += await insertRows(sample, state)
+ }
+ continue
+ }
+ batch.push(record)
+ if (batch.length >= CSV_MAX_BATCH_SIZE) {
+ inserted += await insertRows(batch, state)
+ batch = []
+ }
}
- logger.info(`[${requestId}] CSV imported`, {
- tableId: table.id,
- fileName: file.name,
- columns: columns.length,
- rows: inserted,
- })
+ if (!state) {
+ if (sample.length === 0) {
+ return NextResponse.json({ error: 'CSV file has no data rows' }, { status: 400 })
+ }
+ state = await buildTable(sample)
+ inserted += await insertRows(sample, state)
+ } else {
+ inserted += await insertRows(batch, state)
+ }
+ } catch (streamError) {
+ if (state) await deleteTable(state.table.id, requestId).catch(() => {})
+ throw streamError
+ }
- return NextResponse.json({
- success: true,
- data: {
- table: {
- id: table.id,
- name: table.name,
- description: table.description,
- schema: normalizedSchema,
- rowCount: inserted,
- },
+ logger.info(`[${requestId}] CSV imported`, {
+ tableId: state.table.id,
+ fileName: file.filename,
+ columns: state.schema.columns.length,
+ rows: inserted,
+ })
+
+ return NextResponse.json({
+ success: true,
+ data: {
+ table: {
+ id: state.table.id,
+ name: state.table.name,
+ description: state.table.description,
+ schema: state.schema,
+ rowCount: inserted,
},
- })
- } catch (insertError) {
- await deleteTable(table.id, requestId).catch(() => {})
- throw insertError
- }
+ },
+ })
} catch (error) {
+ if (isMultipartError(error)) return multipartErrorResponse(error)
+
const message = toError(error).message
logger.error(`[${requestId}] CSV import failed:`, error)
- const isSizeLimitError =
- isPayloadSizeLimitError(error) || message.includes('CSV import file exceeds maximum size')
const isClientError =
message.includes('maximum table limit') ||
message.includes('CSV file has no') ||
message.includes('Invalid table name') ||
message.includes('Invalid schema') ||
- message.includes('already exists') ||
- isSizeLimitError
+ message.includes('already exists')
return NextResponse.json(
{ error: isClientError ? message : 'Failed to import CSV' },
- {
- status: isSizeLimitError ? 413 : isClientError ? 400 : 500,
- }
+ { status: isClientError ? 400 : 500 }
)
+ } finally {
+ fileStream?.destroy()
}
})
diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts
index 89a48b80896..2d97dc4f639 100644
--- a/apps/sim/app/api/table/route.ts
+++ b/apps/sim/app/api/table/route.ts
@@ -203,6 +203,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
: t.archivedAt
? String(t.archivedAt)
: null,
+ importStatus: t.importStatus ?? null,
+ importId: t.importId ?? null,
+ importError: t.importError ?? null,
+ importRowsProcessed: t.importRowsProcessed ?? 0,
}
})
diff --git a/apps/sim/app/api/table/utils.ts b/apps/sim/app/api/table/utils.ts
index 114271a9401..eef507c94ba 100644
--- a/apps/sim/app/api/table/utils.ts
+++ b/apps/sim/app/api/table/utils.ts
@@ -5,12 +5,46 @@ import {
deleteTableColumnBodySchema,
updateTableColumnBodySchema,
} from '@/lib/api/contracts/tables'
+import type { MultipartError } from '@/lib/core/utils/multipart'
import type { ColumnDefinition, TableDefinition } from '@/lib/table'
import { getTableById } from '@/lib/table'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('TableUtils')
+/**
+ * Next.js buffers the request body for the proxy and silently truncates it past this
+ * size (`experimental.proxyClientMaxBodySize`, default 10MB). The synchronous CSV
+ * import routes reject bodies over the cap up front; larger files use the async
+ * direct-to-storage path instead.
+ */
+export const CSV_IMPORT_PROXY_BODY_CAP_BYTES = 10 * 1024 * 1024
+
+/** 413 response when a synchronous CSV upload would exceed (and be truncated at) the proxy cap; `null` otherwise. */
+export function csvProxyBodyCapResponse(request: { headers: Headers }): NextResponse | null {
+ const contentLength = Number(request.headers.get('content-length') ?? 0)
+ if (contentLength > CSV_IMPORT_PROXY_BODY_CAP_BYTES) {
+ return NextResponse.json(
+ {
+ error:
+ 'File too large to import through the server. Files over 10MB import in the background.',
+ },
+ { status: 413 }
+ )
+ }
+ return null
+}
+
+/** Maps a {@link MultipartError} from the streaming CSV parser to its HTTP response. */
+export function multipartErrorResponse(error: MultipartError): NextResponse {
+ if (error.code === 'FILE_TOO_LARGE') {
+ return NextResponse.json({ error: 'CSV import file exceeds maximum size' }, { status: 413 })
+ }
+ const message =
+ error.code === 'NO_FILE' ? 'CSV file is required' : `Invalid CSV upload: ${error.message}`
+ return NextResponse.json({ error: message }, { status: 400 })
+}
+
interface TableAccessResult {
hasAccess: true
table: TableDefinition
diff --git a/apps/sim/app/api/templates/[id]/og-image/route.ts b/apps/sim/app/api/templates/[id]/og-image/route.ts
deleted file mode 100644
index 94c429ad6dc..00000000000
--- a/apps/sim/app/api/templates/[id]/og-image/route.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import { db } from '@sim/db'
-import { templates } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { eq } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { updateTemplateOgImageContract } from '@/lib/api/contracts/templates'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { verifyTemplateOwnership } from '@/lib/templates/permissions'
-import { uploadFile } from '@/lib/uploads/core/storage-service'
-import { isValidPng } from '@/lib/uploads/utils/validation'
-
-const logger = createLogger('TemplateOGImageAPI')
-
-/**
- * PUT /api/templates/[id]/og-image
- * Upload a pre-generated OG image for a template.
- * Accepts base64-encoded image data in the request body.
- */
-export const PUT = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized OG image upload attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(updateTemplateOgImageContract, request, context)
- if (!parsed.success) return parsed.response
- const { id } = parsed.data.params
- const { imageData } = parsed.data.body
-
- const { authorized, error, status } = await verifyTemplateOwnership(
- id,
- session.user.id,
- 'admin'
- )
- if (!authorized) {
- logger.warn(`[${requestId}] User denied permission to upload OG image for template ${id}`)
- return NextResponse.json({ error }, { status: status || 403 })
- }
-
- const base64Data = imageData.includes(',') ? imageData.split(',')[1] : imageData
- const imageBuffer = Buffer.from(base64Data, 'base64')
-
- if (!isValidPng(imageBuffer)) {
- return NextResponse.json({ error: 'Invalid PNG image data' }, { status: 400 })
- }
-
- const maxSize = 5 * 1024 * 1024
- if (imageBuffer.length > maxSize) {
- return NextResponse.json(
- { error: 'Image too large. Maximum size is 5MB.' },
- { status: 400 }
- )
- }
-
- const timestamp = Date.now()
- const storageKey = `og-images/templates/${id}/${timestamp}.png`
-
- logger.info(`[${requestId}] Uploading OG image for template ${id}: ${storageKey}`)
-
- const uploadResult = await uploadFile({
- file: imageBuffer,
- fileName: storageKey,
- contentType: 'image/png',
- context: 'og-images',
- preserveKey: true,
- customKey: storageKey,
- })
-
- const baseUrl = getBaseUrl()
- const ogImageUrl = `${baseUrl}${uploadResult.path}?context=og-images`
-
- await db
- .update(templates)
- .set({
- ogImageUrl,
- updatedAt: new Date(),
- })
- .where(eq(templates.id, id))
-
- logger.info(`[${requestId}] Successfully uploaded OG image for template ${id}: ${ogImageUrl}`)
-
- return NextResponse.json({
- success: true,
- ogImageUrl,
- })
- } catch (error: unknown) {
- logger.error(`[${requestId}] Error uploading OG image:`, error)
- return NextResponse.json({ error: 'Failed to upload OG image' }, { status: 500 })
- }
- }
-)
-
-/**
- * DELETE /api/templates/[id]/og-image
- * Remove the OG image for a template.
- */
-export const DELETE = withRouteHandler(
- async (_request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const { id } = await params
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const { authorized, error, status } = await verifyTemplateOwnership(
- id,
- session.user.id,
- 'admin'
- )
- if (!authorized) {
- logger.warn(`[${requestId}] User denied permission to delete OG image for template ${id}`)
- return NextResponse.json({ error }, { status: status || 403 })
- }
-
- await db
- .update(templates)
- .set({
- ogImageUrl: null,
- updatedAt: new Date(),
- })
- .where(eq(templates.id, id))
-
- logger.info(`[${requestId}] Removed OG image for template ${id}`)
-
- return NextResponse.json({ success: true })
- } catch (error: unknown) {
- logger.error(`[${requestId}] Error removing OG image for template ${id}:`, error)
- return NextResponse.json({ error: 'Failed to remove OG image' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts
deleted file mode 100644
index 8f4c0e367c5..00000000000
--- a/apps/sim/app/api/templates/[id]/route.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { db } from '@sim/db'
-import { templateCreators, templates, workflow } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { eq, sql } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { templateIdParamsSchema, updateTemplateContract } from '@/lib/api/contracts/templates'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { RateLimiter } from '@/lib/core/rate-limiter'
-import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { canAccessTemplate } from '@/lib/templates/permissions'
-import {
- extractRequiredCredentials,
- sanitizeCredentials,
-} from '@/lib/workflows/credentials/credential-extractor'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-const logger = createLogger('TemplateByIdAPI')
-
-const viewRateLimiter = new RateLimiter()
-
-/**
- * Per-IP, per-template view-counter dedup bucket: one increment per 10 minutes.
- * Prevents scripted inflation of `templates.views` from the public GET handler.
- */
-const TEMPLATE_VIEW_DEDUP = {
- maxTokens: 1,
- refillRate: 1,
- refillIntervalMs: 10 * 60_000,
-}
-
-export const revalidate = 0
-
-export const GET = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const { id } = templateIdParamsSchema.parse(await params)
-
- try {
- const session = await getSession()
-
- const access = await canAccessTemplate(id, session?.user?.id)
- if (!access.allowed || !access.template) {
- logger.warn(`[${requestId}] Template not found: ${id}`)
- return NextResponse.json({ error: 'Template not found' }, { status: 404 })
- }
-
- const result = await db
- .select({
- template: templates,
- creator: templateCreators,
- })
- .from(templates)
- .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
- .where(eq(templates.id, id))
- .limit(1)
-
- const { template, creator } = result[0]
- const templateWithCreator = {
- ...template,
- creator: creator || undefined,
- }
-
- let isStarred = false
- if (session?.user?.id) {
- const { templateStars } = await import('@sim/db/schema')
- const starResult = await db
- .select()
- .from(templateStars)
- .where(
- sql`${templateStars.templateId} = ${id} AND ${templateStars.userId} = ${session.user.id}`
- )
- .limit(1)
- isStarred = starResult.length > 0
- }
-
- let shouldIncrementView = template.status === 'approved'
-
- if (shouldIncrementView) {
- const viewer = session?.user?.id ?? `ip:${getClientIp(request)}`
- const dedupKey = `template-view:${id}:${viewer}`
- const { allowed } = await viewRateLimiter.checkRateLimitDirect(
- dedupKey,
- TEMPLATE_VIEW_DEDUP
- )
- if (!allowed) {
- shouldIncrementView = false
- } else {
- try {
- await db
- .update(templates)
- .set({
- views: sql`${templates.views} + 1`,
- })
- .where(eq(templates.id, id))
- } catch (viewError) {
- logger.warn(
- `[${requestId}] Failed to increment view count for template: ${id}`,
- viewError
- )
- }
- }
- }
-
- logger.info(`[${requestId}] Successfully retrieved template: ${id}`)
-
- return NextResponse.json({
- data: {
- ...templateWithCreator,
- views: template.views + (shouldIncrementView ? 1 : 0),
- isStarred,
- },
- })
- } catch (error) {
- logger.error(`[${requestId}] Error fetching template: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-// PUT /api/templates/[id] - Update a template
-export const PUT = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized template update attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(updateTemplateContract, request, context, {
- validationErrorResponse: (error) => {
- logger.warn(`[${requestId}] Invalid template data for update`, error)
- return NextResponse.json(
- { error: 'Invalid template data', details: error.issues },
- { status: 400 }
- )
- },
- })
- if (!parsed.success) return parsed.response
-
- const { id } = parsed.data.params
- const { name, details, creatorId, tags, updateState, status } = parsed.data.body
-
- const existingTemplate = await db
- .select()
- .from(templates)
- .where(eq(templates.id, id))
- .limit(1)
-
- if (existingTemplate.length === 0) {
- logger.warn(`[${requestId}] Template not found for update: ${id}`)
- return NextResponse.json({ error: 'Template not found' }, { status: 404 })
- }
-
- const template = existingTemplate[0]
-
- // Status changes require super user permission
- if (status !== undefined) {
- const { verifyEffectiveSuperUser } = await import('@/lib/templates/permissions')
- const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
- if (!effectiveSuperUser) {
- logger.warn(`[${requestId}] Non-super user attempted to change template status: ${id}`)
- return NextResponse.json(
- { error: 'Only super users can change template status' },
- { status: 403 }
- )
- }
- }
-
- // For non-status updates, verify creator permission
- const hasNonStatusUpdates =
- name !== undefined ||
- details !== undefined ||
- creatorId !== undefined ||
- tags !== undefined ||
- updateState
-
- if (hasNonStatusUpdates) {
- if (!template.creatorId) {
- logger.warn(`[${requestId}] Template ${id} has no creator, denying update`)
- return NextResponse.json({ error: 'Access denied' }, { status: 403 })
- }
-
- const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
- const { hasPermission, error: permissionError } = await verifyCreatorPermission(
- session.user.id,
- template.creatorId,
- 'admin'
- )
-
- if (!hasPermission) {
- logger.warn(`[${requestId}] User denied permission to update template ${id}`)
- return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
- }
- }
-
- const updateData: Record = {
- updatedAt: new Date(),
- }
-
- if (name !== undefined) updateData.name = name
- if (details !== undefined) updateData.details = details
- if (tags !== undefined) updateData.tags = tags
- if (creatorId !== undefined) updateData.creatorId = creatorId
- if (status !== undefined) updateData.status = status
-
- if (updateState && template.workflowId) {
- const { authorizeWorkflowByWorkspacePermission } = await import('@sim/workflow-authz')
- const authorization = await authorizeWorkflowByWorkspacePermission({
- userId: session.user.id,
- workflowId: template.workflowId,
- action: 'read',
- })
-
- if (!authorization.allowed) {
- logger.warn(`[${requestId}] User denied workflow access for state sync on template ${id}`)
- return NextResponse.json({ error: 'Access denied to workflow' }, { status: 403 })
- }
-
- const { loadWorkflowFromNormalizedTables } = await import(
- '@/lib/workflows/persistence/utils'
- )
- const normalizedData = await loadWorkflowFromNormalizedTables(template.workflowId)
-
- if (normalizedData) {
- const [workflowRecord] = await db
- .select({ variables: workflow.variables })
- .from(workflow)
- .where(eq(workflow.id, template.workflowId))
- .limit(1)
-
- const currentState: Partial = {
- blocks: normalizedData.blocks,
- edges: normalizedData.edges,
- loops: normalizedData.loops,
- parallels: normalizedData.parallels,
- variables: (workflowRecord?.variables as WorkflowState['variables']) ?? undefined,
- lastSaved: Date.now(),
- }
-
- const requiredCredentials = extractRequiredCredentials(currentState)
-
- const sanitizedState = sanitizeCredentials(currentState)
-
- updateData.state = sanitizedState
- updateData.requiredCredentials = requiredCredentials
-
- logger.info(
- `[${requestId}] Updating template state and credentials from current workflow: ${template.workflowId}`
- )
- } else {
- logger.warn(`[${requestId}] Could not load workflow state for template: ${id}`)
- }
- }
-
- const updatedTemplate = await db
- .update(templates)
- .set(updateData)
- .where(eq(templates.id, id))
- .returning()
-
- logger.info(`[${requestId}] Successfully updated template: ${id}`)
-
- recordAudit({
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.TEMPLATE_UPDATED,
- resourceType: AuditResourceType.TEMPLATE,
- resourceId: id,
- resourceName: name ?? template.name,
- description: `Updated template "${name ?? template.name}"`,
- metadata: {
- templateName: name ?? template.name,
- updatedFields: Object.keys(parsed.data.body).filter(
- (k) => parsed.data.body[k as keyof typeof parsed.data.body] !== undefined
- ),
- statusChange: status !== undefined ? { from: template.status, to: status } : undefined,
- stateUpdated: updateState || false,
- workflowId: template.workflowId || undefined,
- },
- request,
- })
-
- return NextResponse.json({
- data: updatedTemplate[0],
- message: 'Template updated successfully',
- })
- } catch (error) {
- logger.error(`[${requestId}] Error updating template`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-// DELETE /api/templates/[id] - Delete a template
-export const DELETE = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const { id } = templateIdParamsSchema.parse(await params)
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const existing = await db
- .select({
- name: templates.name,
- workflowId: templates.workflowId,
- creatorId: templates.creatorId,
- status: templates.status,
- tags: templates.tags,
- })
- .from(templates)
- .where(eq(templates.id, id))
- .limit(1)
- if (existing.length === 0) {
- logger.warn(`[${requestId}] Template not found for delete: ${id}`)
- return NextResponse.json({ error: 'Template not found' }, { status: 404 })
- }
-
- const template = existing[0]
-
- if (!template.creatorId) {
- logger.warn(`[${requestId}] Template ${id} has no creator, denying delete`)
- return NextResponse.json({ error: 'Access denied' }, { status: 403 })
- }
-
- const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
- const { hasPermission, error: permissionError } = await verifyCreatorPermission(
- session.user.id,
- template.creatorId,
- 'admin'
- )
-
- if (!hasPermission) {
- logger.warn(`[${requestId}] User denied permission to delete template ${id}`)
- return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
- }
-
- await db.delete(templates).where(eq(templates.id, id))
-
- logger.info(`[${requestId}] Deleted template: ${id}`)
-
- recordAudit({
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.TEMPLATE_DELETED,
- resourceType: AuditResourceType.TEMPLATE,
- resourceId: id,
- resourceName: template.name,
- description: `Deleted template "${template.name}"`,
- metadata: {
- templateName: template.name,
- workflowId: template.workflowId || undefined,
- creatorId: template.creatorId || undefined,
- status: template.status,
- tags: template.tags,
- },
- request,
- })
-
- return NextResponse.json({ success: true })
- } catch (error) {
- logger.error(`[${requestId}] Error deleting template: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/templates/[id]/star/route.ts b/apps/sim/app/api/templates/[id]/star/route.ts
deleted file mode 100644
index 0d45a7ed48d..00000000000
--- a/apps/sim/app/api/templates/[id]/star/route.ts
+++ /dev/null
@@ -1,180 +0,0 @@
-import { db } from '@sim/db'
-import { templateStars, templates } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { and, eq, sql } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { templateIdParamsSchema } from '@/lib/api/contracts/templates'
-import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-
-const logger = createLogger('TemplateStarAPI')
-
-export const dynamic = 'force-dynamic'
-export const revalidate = 0
-
-function getErrorCode(error: unknown): string | undefined {
- if (!error || typeof error !== 'object' || !('code' in error)) return undefined
-
- const { code } = error as { code?: unknown }
- return typeof code === 'string' ? code : undefined
-}
-
-// GET /api/templates/[id]/star - Check if user has starred this template
-export const GET = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const { id } = templateIdParamsSchema.parse(await params)
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- logger.debug(
- `[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}`
- )
-
- // Check if the user has starred this template
- const starRecord = await db
- .select({ id: templateStars.id })
- .from(templateStars)
- .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
- .limit(1)
-
- const isStarred = starRecord.length > 0
-
- logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`)
-
- return NextResponse.json({ data: { isStarred } })
- } catch (error) {
- logger.error(`[${requestId}] Error checking star status for template: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-// POST /api/templates/[id]/star - Add a star to the template
-export const POST = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const { id } = templateIdParamsSchema.parse(await params)
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- // Verify the template exists
- const templateExists = await db
- .select({ id: templates.id })
- .from(templates)
- .where(eq(templates.id, id))
- .limit(1)
-
- if (templateExists.length === 0) {
- logger.warn(`[${requestId}] Template not found: ${id}`)
- return NextResponse.json({ error: 'Template not found' }, { status: 404 })
- }
-
- // Check if user has already starred this template
- const existingStar = await db
- .select({ id: templateStars.id })
- .from(templateStars)
- .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
- .limit(1)
-
- if (existingStar.length > 0) {
- logger.info(`[${requestId}] Template already starred: ${id}`)
- return NextResponse.json({ message: 'Template already starred' }, { status: 200 })
- }
-
- // Use a transaction to ensure consistency
- await db.transaction(async (tx) => {
- // Add the star record
- await tx.insert(templateStars).values({
- id: generateId(),
- userId: session.user.id,
- templateId: id,
- starredAt: new Date(),
- createdAt: new Date(),
- })
-
- // Increment the star count
- await tx
- .update(templates)
- .set({
- stars: sql`${templates.stars} + 1`,
- })
- .where(eq(templates.id, id))
- })
-
- logger.info(`[${requestId}] Successfully starred template: ${id}`)
- return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 })
- } catch (error) {
- // Handle unique constraint violations gracefully
- if (getErrorCode(error) === '23505') {
- logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`)
- return NextResponse.json({ message: 'Template already starred' }, { status: 200 })
- }
-
- logger.error(`[${requestId}] Error starring template: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
-
-// DELETE /api/templates/[id]/star - Remove a star from the template
-export const DELETE = withRouteHandler(
- async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
- const { id } = templateIdParamsSchema.parse(await params)
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- // Check if the star exists
- const existingStar = await db
- .select({ id: templateStars.id })
- .from(templateStars)
- .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
- .limit(1)
-
- if (existingStar.length === 0) {
- logger.info(`[${requestId}] No star found to remove for template: ${id}`)
- return NextResponse.json({ message: 'Template not starred' }, { status: 200 })
- }
-
- // Use a transaction to ensure consistency
- await db.transaction(async (tx) => {
- // Remove the star record
- await tx
- .delete(templateStars)
- .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id)))
-
- // Decrement the star count (prevent negative values)
- await tx
- .update(templates)
- .set({
- stars: sql`GREATEST(${templates.stars} - 1, 0)`,
- })
- .where(eq(templates.id, id))
- })
-
- logger.info(`[${requestId}] Successfully unstarred template: ${id}`)
- return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 })
- } catch (error) {
- logger.error(`[${requestId}] Error unstarring template: ${id}`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/templates/[id]/use/route.ts b/apps/sim/app/api/templates/[id]/use/route.ts
deleted file mode 100644
index 874093c7773..00000000000
--- a/apps/sim/app/api/templates/[id]/use/route.ts
+++ /dev/null
@@ -1,235 +0,0 @@
-import { db } from '@sim/db'
-import { templates, workflow, workflowDeploymentVersion } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { eq, sql } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { useTemplateContract } from '@/lib/api/contracts/templates'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { canAccessTemplate, verifyTemplateOwnership } from '@/lib/templates/permissions'
-import {
- type RegenerateStateInput,
- regenerateWorkflowStateIds,
-} from '@/lib/workflows/persistence/utils'
-import { deduplicateWorkflowName } from '@/lib/workflows/utils'
-import { getUserEntityPermissions, getWorkspaceById } from '@/lib/workspaces/permissions/utils'
-
-const logger = createLogger('TemplateUseAPI')
-
-export const dynamic = 'force-dynamic'
-export const revalidate = 0
-
-// Type for template details
-interface TemplateDetails {
- tagline?: string
- about?: string
-}
-
-// POST /api/templates/[id]/use - Use a template (increment views and create workflow)
-export const POST = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized template use attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(useTemplateContract, request, context)
- if (!parsed.success) return parsed.response
- const { id } = parsed.data.params
- const { workspaceId, connectToTemplate = false } = parsed.data.body
-
- if (!workspaceId) {
- logger.warn(`[${requestId}] Missing workspaceId in request body`)
- return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 })
- }
-
- const workspace = await getWorkspaceById(workspaceId)
- if (!workspace) {
- return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
- }
-
- const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
- if (permission !== 'admin' && permission !== 'write') {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
-
- logger.debug(
- `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}, connect: ${connectToTemplate}`
- )
-
- // Get the template
- const templateAccess = await canAccessTemplate(id, session.user.id)
- if (!templateAccess.allowed) {
- logger.warn(`[${requestId}] Template not found: ${id}`)
- return NextResponse.json({ error: 'Template not found' }, { status: 404 })
- }
-
- if (connectToTemplate) {
- const ownership = await verifyTemplateOwnership(id, session.user.id, 'admin')
- if (!ownership.authorized) {
- return NextResponse.json(
- { error: ownership.error || 'Access denied' },
- { status: ownership.status || 403 }
- )
- }
- }
-
- const template = await db
- .select({
- id: templates.id,
- name: templates.name,
- details: templates.details,
- state: templates.state,
- workflowId: templates.workflowId,
- })
- .from(templates)
- .where(eq(templates.id, id))
- .limit(1)
-
- const templateData = template[0]
-
- // Create a new workflow ID
- const newWorkflowId = generateId()
- const now = new Date()
-
- // Extract variables from the template state and remap to the new workflow
- const templateVariables = (templateData.state as any)?.variables as
- | Record
- | undefined
- const remappedVariables: Record = (() => {
- if (!templateVariables || typeof templateVariables !== 'object') return {}
- const mapped: Record = {}
- for (const [, variable] of Object.entries(templateVariables)) {
- const newVarId = generateId()
- mapped[newVarId] = { ...variable, id: newVarId, workflowId: newWorkflowId }
- }
- return mapped
- })()
-
- const rawName =
- connectToTemplate && !templateData.workflowId
- ? templateData.name
- : `${templateData.name} (copy)`
- const dedupedName = await deduplicateWorkflowName(rawName, workspaceId, null)
-
- await db.insert(workflow).values({
- id: newWorkflowId,
- workspaceId: workspaceId,
- name: dedupedName,
- description: (templateData.details as TemplateDetails | null)?.tagline || null,
- userId: session.user.id,
- variables: remappedVariables, // Remap variable IDs and workflowId for the new workflow
- createdAt: now,
- updatedAt: now,
- lastSynced: now,
- isDeployed: connectToTemplate && !templateData.workflowId,
- deployedAt: connectToTemplate && !templateData.workflowId ? now : null,
- })
-
- // Step 2: Regenerate IDs when creating a copy (not when connecting/editing template)
- // When connecting to template (edit mode), keep original IDs
- // When using template (copy mode), regenerate all IDs to avoid conflicts
- const templateState = templateData.state as RegenerateStateInput
- const workflowState = connectToTemplate
- ? templateState
- : regenerateWorkflowStateIds(templateState)
-
- // Step 3: Save the workflow state using the existing state endpoint (like imports do)
- // Ensure variables in state are remapped for the new workflow as well
- const workflowStateWithVariables = { ...workflowState, variables: remappedVariables }
- const stateResponse = await fetch(
- `${getInternalApiBaseUrl()}/api/workflows/${newWorkflowId}/state`,
- {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- // Forward the session cookie for authentication
- cookie: request.headers.get('cookie') || '',
- },
- body: JSON.stringify(workflowStateWithVariables),
- }
- )
-
- if (!stateResponse.ok) {
- logger.error(`[${requestId}] Failed to save workflow state for template use`)
- // Clean up the workflow we created
- await db.delete(workflow).where(eq(workflow.id, newWorkflowId))
- return NextResponse.json(
- { error: 'Failed to create workflow from template' },
- { status: 500 }
- )
- }
-
- // Use a transaction for template updates and deployment version
- const result = await db.transaction(async (tx) => {
- // Prepare template update data
- const updateData: any = {
- views: sql`${templates.views} + 1`,
- }
-
- // If connecting to template for editing, also update the workflowId
- // Also create a new deployment version for this workflow with the same state
- if (connectToTemplate && !templateData.workflowId) {
- updateData.workflowId = newWorkflowId
-
- // Create a deployment version for the new workflow
- if (templateData.state) {
- const newDeploymentVersionId = generateId()
- await tx.insert(workflowDeploymentVersion).values({
- id: newDeploymentVersionId,
- workflowId: newWorkflowId,
- version: 1,
- state: templateData.state,
- isActive: true,
- createdAt: now,
- createdBy: session.user.id,
- })
- }
- }
-
- // Update template with view count and potentially new workflow connection
- await tx.update(templates).set(updateData).where(eq(templates.id, id))
-
- return { id: newWorkflowId }
- })
-
- logger.info(
- `[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}`
- )
-
- try {
- const { PlatformEvents } = await import('@/lib/core/telemetry')
- const templateState = templateData.state as any
- PlatformEvents.templateUsed({
- templateId: id,
- templateName: templateData.name,
- newWorkflowId,
- blocksCount: templateState?.blocks ? Object.keys(templateState.blocks).length : 0,
- workspaceId,
- })
- } catch (_e) {
- // Silently fail
- }
-
- return NextResponse.json(
- {
- message: 'Template used successfully',
- workflowId: newWorkflowId,
- workspaceId: workspaceId,
- },
- { status: 201 }
- )
- } catch (error: any) {
- logger.error(`[${requestId}] Error using template`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
- }
-)
diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts
deleted file mode 100644
index 6fa0e69d7f5..00000000000
--- a/apps/sim/app/api/templates/approved/sanitized/route.ts
+++ /dev/null
@@ -1,133 +0,0 @@
-import { db } from '@sim/db'
-import { templates } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { eq } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { noInputSchema } from '@/lib/api/contracts/primitives'
-import { validationErrorResponse } from '@/lib/api/server'
-import { checkInternalApiKey } from '@/lib/copilot/request/http'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
-
-const logger = createLogger('TemplatesSanitizedAPI')
-
-export const revalidate = 0
-
-/**
- * GET /api/templates/approved/sanitized
- * Returns all approved templates with their sanitized JSONs, names, and descriptions
- * Requires internal API secret authentication via X-API-Key header
- */
-export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const queryValidation = noInputSchema.safeParse(
- Object.fromEntries(request.nextUrl.searchParams.entries())
- )
- if (!queryValidation.success) return validationErrorResponse(queryValidation.error)
- const hasApiKey = !!request.headers.get('x-api-key')
-
- // Check internal API key authentication
- const authResult = checkInternalApiKey(request)
- if (!authResult.success) {
- logger.warn(`[${requestId}] Authentication failed for approved sanitized templates`, {
- error: authResult.error,
- hasApiKey,
- howToUse: 'Add header: X-API-Key: ',
- })
- return NextResponse.json(
- {
- error: authResult.error,
- hint: 'Include X-API-Key header with INTERNAL_API_SECRET value',
- },
- { status: 401 }
- )
- }
-
- // Fetch all approved templates
- const approvedTemplates = await db
- .select({
- id: templates.id,
- name: templates.name,
- details: templates.details,
- state: templates.state,
- tags: templates.tags,
- requiredCredentials: templates.requiredCredentials,
- })
- .from(templates)
- .where(eq(templates.status, 'approved'))
-
- // Process each template to sanitize for copilot
- const sanitizedTemplates = approvedTemplates
- .map((template) => {
- try {
- const copilotSanitized = sanitizeForCopilot(template.state as any)
-
- if (copilotSanitized?.blocks) {
- Object.values(copilotSanitized.blocks).forEach((block: any) => {
- if (block && typeof block === 'object') {
- block.outputs = undefined
- block.position = undefined
- block.height = undefined
- block.layout = undefined
- block.horizontalHandles = undefined
-
- // Also clean nested nodes recursively
- if (block.nestedNodes) {
- Object.values(block.nestedNodes).forEach((nestedBlock: any) => {
- if (nestedBlock && typeof nestedBlock === 'object') {
- nestedBlock.outputs = undefined
- nestedBlock.position = undefined
- nestedBlock.height = undefined
- nestedBlock.layout = undefined
- nestedBlock.horizontalHandles = undefined
- }
- })
- }
- }
- })
- }
-
- const details = template.details as { tagline?: string; about?: string } | null
- const description = details?.tagline || details?.about || ''
-
- return {
- id: template.id,
- name: template.name,
- description,
- tags: template.tags,
- requiredCredentials: template.requiredCredentials,
- sanitizedJson: copilotSanitized,
- }
- } catch (error) {
- logger.error(`[${requestId}] Error sanitizing template ${template.id}`, {
- error: toError(error).message,
- })
- return null
- }
- })
- .filter((t): t is NonNullable => t !== null)
-
- const response = {
- templates: sanitizedTemplates,
- count: sanitizedTemplates.length,
- }
-
- return NextResponse.json(response)
- } catch (error) {
- logger.error(`[${requestId}] Error fetching approved sanitized templates`, {
- error: toError(error).message,
- stack: error instanceof Error ? error.stack : undefined,
- })
- return NextResponse.json(
- {
- error: 'Internal server error',
- requestId,
- },
- { status: 500 }
- )
- }
-})
diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts
deleted file mode 100644
index 8a7b1e9aac3..00000000000
--- a/apps/sim/app/api/templates/route.ts
+++ /dev/null
@@ -1,342 +0,0 @@
-import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
-import { db } from '@sim/db'
-import {
- templateCreators,
- templateStars,
- templates,
- workflow,
- workflowDeploymentVersion,
-} from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { generateId } from '@sim/utils/id'
-import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
-import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
-import { type NextRequest, NextResponse } from 'next/server'
-import { createTemplateContract, listTemplatesContract } from '@/lib/api/contracts/templates'
-import { parseRequest } from '@/lib/api/server'
-import { getSession } from '@/lib/auth'
-import { generateRequestId } from '@/lib/core/utils/request'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { canAccessTemplate, verifyEffectiveSuperUser } from '@/lib/templates/permissions'
-import {
- extractRequiredCredentials,
- sanitizeCredentials,
-} from '@/lib/workflows/credentials/credential-extractor'
-import type { WorkflowState } from '@/stores/workflows/workflow/types'
-
-const logger = createLogger('TemplatesAPI')
-
-export const revalidate = 0
-
-function sanitizeWorkflowState(state: Partial | null | undefined): unknown {
- return sanitizeCredentials(state)
-}
-
-// GET /api/templates - Retrieve templates
-export const GET = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized templates access attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(listTemplatesContract, request, {})
- if (!parsed.success) return parsed.response
- const params = parsed.data.query
-
- // Check if user is a super user
- const { effectiveSuperUser } = await verifyEffectiveSuperUser(session.user.id)
- const isSuperUser = effectiveSuperUser
-
- // Build query conditions
- const conditions = []
-
- // Apply workflow filter if provided (for getting template by workflow)
- // When fetching by workflowId, we want to get the template regardless of status
- // This is used by the deploy modal to check if a template exists
- if (params.workflowId) {
- const authorization = await authorizeWorkflowByWorkspacePermission({
- workflowId: params.workflowId,
- userId: session.user.id,
- action: 'write',
- })
- if (!authorization.allowed) {
- return NextResponse.json(
- {
- data: [],
- pagination: {
- total: 0,
- limit: params.limit,
- offset: params.offset,
- page: 1,
- totalPages: 0,
- },
- },
- { status: 200 }
- )
- }
- conditions.push(eq(templates.workflowId, params.workflowId))
- } else {
- // Apply status filter - only approved templates for non-super users
- if (params.status) {
- if (!isSuperUser && params.status !== 'approved') {
- return NextResponse.json(
- {
- data: [],
- pagination: {
- total: 0,
- limit: params.limit,
- offset: params.offset,
- page: 1,
- totalPages: 0,
- },
- },
- { status: 200 }
- )
- }
- conditions.push(eq(templates.status, params.status))
- } else if (!isSuperUser || !params.includeAllStatuses) {
- // Non-super users and super users without includeAllStatuses flag see only approved templates
- conditions.push(eq(templates.status, 'approved'))
- }
- }
-
- // Apply search filter if provided
- if (params.search) {
- const searchTerm = `%${params.search}%`
- conditions.push(
- or(
- ilike(templates.name, searchTerm),
- sql`${templates.details}->>'tagline' ILIKE ${searchTerm}`
- )
- )
- }
-
- // Combine conditions
- const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
-
- // Apply ordering, limit, and offset with star information
- const results = await db
- .select({
- id: templates.id,
- workflowId: templates.workflowId,
- name: templates.name,
- details: templates.details,
- creatorId: templates.creatorId,
- creator: templateCreators,
- views: templates.views,
- stars: templates.stars,
- status: templates.status,
- tags: templates.tags,
- requiredCredentials: templates.requiredCredentials,
- state: templates.state,
- createdAt: templates.createdAt,
- updatedAt: templates.updatedAt,
- isStarred: sql`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`,
- isSuperUser: sql`${isSuperUser}`, // Include super user status in response
- })
- .from(templates)
- .leftJoin(
- templateStars,
- and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id))
- )
- .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
- .where(whereCondition)
- .orderBy(desc(templates.views), desc(templates.createdAt))
- .limit(params.limit)
- .offset(params.offset)
-
- // Get total count for pagination
- const totalCount = await db
- .select({ count: sql`count(*)` })
- .from(templates)
- .where(whereCondition)
-
- const total = Number(totalCount[0]?.count ?? 0)
-
- const visibleResults =
- params.workflowId && !isSuperUser
- ? (
- await Promise.all(
- results.map(async (template) => {
- if (template.status === 'approved') {
- return template
- }
- const access = await canAccessTemplate(template.id, session.user.id)
- return access.allowed ? template : null
- })
- )
- ).filter((template): template is (typeof results)[number] => template !== null)
- : results
-
- logger.info(`[${requestId}] Successfully retrieved ${visibleResults.length} templates`)
-
- return NextResponse.json({
- data: visibleResults,
- pagination: {
- total: params.workflowId && !isSuperUser ? visibleResults.length : total,
- limit: params.limit,
- offset: params.offset,
- page: Math.floor(params.offset / params.limit) + 1,
- totalPages: Math.ceil(
- (params.workflowId && !isSuperUser ? visibleResults.length : total) / params.limit
- ),
- },
- })
- } catch (error) {
- logger.error(`[${requestId}] Error fetching templates`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-})
-
-// POST /api/templates - Create a new template
-export const POST = withRouteHandler(async (request: NextRequest) => {
- const requestId = generateRequestId()
-
- try {
- const session = await getSession()
- if (!session?.user?.id) {
- logger.warn(`[${requestId}] Unauthorized template creation attempt`)
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
- }
-
- const parsed = await parseRequest(createTemplateContract, request, {})
- if (!parsed.success) return parsed.response
- const data = parsed.data.body
-
- const workflowAuthorization = await authorizeWorkflowByWorkspacePermission({
- workflowId: data.workflowId,
- userId: session.user.id,
- action: 'write',
- })
-
- if (!workflowAuthorization.workflow) {
- logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`)
- return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
- }
-
- if (!workflowAuthorization.allowed) {
- logger.warn(`[${requestId}] User denied permission to template workflow ${data.workflowId}`)
- return NextResponse.json(
- { error: workflowAuthorization.message || 'Access denied' },
- { status: workflowAuthorization.status || 403 }
- )
- }
-
- const { verifyCreatorPermission } = await import('@/lib/templates/permissions')
- const { hasPermission, error: permissionError } = await verifyCreatorPermission(
- session.user.id,
- data.creatorId,
- 'member'
- )
-
- if (!hasPermission) {
- logger.warn(`[${requestId}] User cannot use creator profile: ${data.creatorId}`)
- return NextResponse.json({ error: permissionError || 'Access denied' }, { status: 403 })
- }
-
- const templateId = generateId()
- const now = new Date()
-
- // Get the active deployment version for the workflow to copy its state
- const activeVersion = await db
- .select({
- id: workflowDeploymentVersion.id,
- state: workflowDeploymentVersion.state,
- })
- .from(workflowDeploymentVersion)
- .where(
- and(
- eq(workflowDeploymentVersion.workflowId, data.workflowId),
- eq(workflowDeploymentVersion.isActive, true)
- )
- )
- .limit(1)
-
- if (activeVersion.length === 0) {
- logger.warn(
- `[${requestId}] No active deployment version found for workflow: ${data.workflowId}`
- )
- return NextResponse.json(
- { error: 'Workflow must be deployed before creating a template' },
- { status: 400 }
- )
- }
-
- // Ensure the state includes workflow variables (if not already included)
- let stateWithVariables = activeVersion[0].state as Partial | null | undefined
- if (stateWithVariables && !stateWithVariables.variables) {
- // Fetch workflow variables if not in deployment version
- const [workflowRecord] = await db
- .select({ variables: workflow.variables })
- .from(workflow)
- .where(eq(workflow.id, data.workflowId))
- .limit(1)
-
- stateWithVariables = {
- ...stateWithVariables,
- variables: (workflowRecord?.variables as WorkflowState['variables']) || undefined,
- }
- }
-
- // Extract credential requirements before sanitizing
- const requiredCredentials = extractRequiredCredentials(stateWithVariables)
-
- // Sanitize the workflow state to remove all credential values
- const sanitizedState = sanitizeWorkflowState(stateWithVariables)
-
- const newTemplate = {
- id: templateId,
- workflowId: data.workflowId,
- name: data.name,
- details: data.details || null,
- creatorId: data.creatorId,
- views: 0,
- stars: 0,
- status: 'pending' as const, // All new templates start as pending
- tags: data.tags || [],
- requiredCredentials: requiredCredentials, // Store the extracted credential requirements
- state: sanitizedState, // Store the sanitized state without credential values
- createdAt: now,
- updatedAt: now,
- }
-
- await db.insert(templates).values(newTemplate)
-
- logger.info(`[${requestId}] Successfully created template: ${templateId}`)
-
- recordAudit({
- actorId: session.user.id,
- actorName: session.user.name,
- actorEmail: session.user.email,
- action: AuditAction.TEMPLATE_CREATED,
- resourceType: AuditResourceType.TEMPLATE,
- resourceId: templateId,
- resourceName: data.name,
- description: `Created template "${data.name}"`,
- metadata: {
- templateName: data.name,
- workflowId: data.workflowId,
- creatorId: data.creatorId,
- tags: data.tags,
- tagline: data.details?.tagline || undefined,
- status: 'pending',
- },
- request,
- })
-
- return NextResponse.json(
- {
- id: templateId,
- message: 'Template submitted for approval successfully',
- },
- { status: 201 }
- )
- } catch (error) {
- logger.error(`[${requestId}] Error creating template`, error)
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
- }
-})
diff --git a/apps/sim/app/api/tools/clickhouse/count-rows/route.ts b/apps/sim/app/api/tools/clickhouse/count-rows/route.ts
new file mode 100644
index 00000000000..5b7b90821ca
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/count-rows/route.ts
@@ -0,0 +1,42 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseCountRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseCountRows } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseCountRowsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse count rows attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseCountRowsContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const count = await executeClickHouseCountRows(params, params.table, params.where)
+
+ return NextResponse.json({
+ message: `Table contains ${count} row(s).`,
+ count,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse count rows failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse count rows failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/create-database/route.ts b/apps/sim/app/api/tools/clickhouse/create-database/route.ts
new file mode 100644
index 00000000000..b748a20595e
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/create-database/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseCreateDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseCreateDatabase } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseCreateDatabaseAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse create database attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseCreateDatabaseContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseCreateDatabase(params, params.name)
+
+ return NextResponse.json({
+ message: `Database '${params.name}' created.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse create database failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse create database failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/create-table/route.ts b/apps/sim/app/api/tools/clickhouse/create-table/route.ts
new file mode 100644
index 00000000000..47cc3ff5f7f
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/create-table/route.ts
@@ -0,0 +1,50 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseCreateTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseCreateTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse create table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseCreateTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseCreateTable(
+ params,
+ params.table,
+ params.columns,
+ params.engine,
+ params.orderBy,
+ params.partitionBy
+ )
+
+ return NextResponse.json({
+ message: `Table '${params.table}' created.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse create table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse create table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/delete/route.ts b/apps/sim/app/api/tools/clickhouse/delete/route.ts
new file mode 100644
index 00000000000..f773aabba4a
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/delete/route.ts
@@ -0,0 +1,49 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseDeleteContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseDelete } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseDeleteAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse delete attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseDeleteContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ logger.info(
+ `[${requestId}] Deleting data from ${params.table} on ${params.host}:${params.port}/${params.database}`
+ )
+
+ const result = await executeClickHouseDelete(params, params.table, params.where)
+
+ logger.info(`[${requestId}] Delete mutation submitted, ${result.rowCount} row(s) affected`)
+
+ return NextResponse.json({
+ message: `Delete mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) affected.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse delete failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse delete failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/describe-table/route.ts b/apps/sim/app/api/tools/clickhouse/describe-table/route.ts
new file mode 100644
index 00000000000..e258d781bc1
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/describe-table/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseDescribeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseDescribeTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseDescribeTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse describe table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseDescribeTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseDescribeTable(params, params.table)
+
+ return NextResponse.json({
+ message: `Described table with ${result.rowCount} column(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse describe table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse describe table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/drop-database/route.ts b/apps/sim/app/api/tools/clickhouse/drop-database/route.ts
new file mode 100644
index 00000000000..e06f897b337
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/drop-database/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseDropDatabaseContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseDropDatabase } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseDropDatabaseAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse drop database attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseDropDatabaseContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseDropDatabase(params, params.name)
+
+ return NextResponse.json({
+ message: `Database '${params.name}' dropped.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse drop database failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse drop database failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts b/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts
new file mode 100644
index 00000000000..790526586ba
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/drop-partition/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseDropPartitionContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseDropPartition } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseDropPartitionAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse drop partition attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseDropPartitionContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseDropPartition(params, params.table, params.partition)
+
+ return NextResponse.json({
+ message: `Dropped partition from table '${params.table}'.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse drop partition failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse drop partition failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/drop-table/route.ts b/apps/sim/app/api/tools/clickhouse/drop-table/route.ts
new file mode 100644
index 00000000000..1ae9f6832a8
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/drop-table/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseDropTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseDropTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseDropTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse drop table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseDropTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseDropTable(params, params.table)
+
+ return NextResponse.json({
+ message: `Table '${params.table}' dropped.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse drop table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse drop table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/execute/route.ts b/apps/sim/app/api/tools/clickhouse/execute/route.ts
new file mode 100644
index 00000000000..3e2c4baacf6
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/execute/route.ts
@@ -0,0 +1,49 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseExecuteContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseExecuteAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse execute attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseExecuteContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ logger.info(
+ `[${requestId}] Executing ClickHouse statement on ${params.host}:${params.port}/${params.database}`
+ )
+
+ const result = await executeClickHouseQuery(params, params.query)
+
+ logger.info(`[${requestId}] Statement executed successfully, ${result.rowCount} row(s)`)
+
+ return NextResponse.json({
+ message: `Statement executed successfully. ${result.rowCount} row(s) returned or affected.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse execute failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse execute failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts b/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts
new file mode 100644
index 00000000000..fb4f90b8634
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/insert-rows/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseInsertRowsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseInsertRows } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseInsertRowsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse insert rows attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseInsertRowsContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseInsertRows(params, params.table, params.rows)
+
+ return NextResponse.json({
+ message: `Inserted ${result.rowCount} row(s) into '${params.table}'.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse insert rows failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse insert rows failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/insert/route.ts b/apps/sim/app/api/tools/clickhouse/insert/route.ts
new file mode 100644
index 00000000000..a7cc4ed908f
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/insert/route.ts
@@ -0,0 +1,49 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseInsertContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseInsert } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseInsertAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse insert attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseInsertContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ logger.info(
+ `[${requestId}] Inserting data into ${params.table} on ${params.host}:${params.port}/${params.database}`
+ )
+
+ const result = await executeClickHouseInsert(params, params.table, params.data)
+
+ logger.info(`[${requestId}] Insert executed successfully, ${result.rowCount} row(s) inserted`)
+
+ return NextResponse.json({
+ message: `Data inserted successfully. ${result.rowCount} row(s) affected.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse insert failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse insert failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/introspect/route.ts b/apps/sim/app/api/tools/clickhouse/introspect/route.ts
new file mode 100644
index 00000000000..cd3257c6275
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/introspect/route.ts
@@ -0,0 +1,50 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseIntrospectContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseIntrospect } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseIntrospectAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse introspect attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseIntrospectContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ logger.info(
+ `[${requestId}] Introspecting ClickHouse schema on ${params.host}:${params.port}/${params.database}`
+ )
+
+ const result = await executeClickHouseIntrospect(params)
+
+ logger.info(
+ `[${requestId}] Introspection completed successfully, found ${result.tables.length} tables`
+ )
+
+ return NextResponse.json({
+ message: `Schema introspection completed. Found ${result.tables.length} table(s) in database '${params.database}'.`,
+ tables: result.tables,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse introspection failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse introspection failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/kill-query/route.ts b/apps/sim/app/api/tools/clickhouse/kill-query/route.ts
new file mode 100644
index 00000000000..c46f6d1393c
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/kill-query/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseKillQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseKillQuery } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseKillQueryAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse kill query attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseKillQueryContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseKillQuery(params, params.queryId)
+
+ return NextResponse.json({
+ message: `Kill command executed for query '${params.queryId}'.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse kill query failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse kill query failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts b/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts
new file mode 100644
index 00000000000..643c7be9621
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/list-clusters/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseListClustersContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseListClusters } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseListClustersAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse list clusters attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseListClustersContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseListClusters(params)
+
+ return NextResponse.json({
+ message: `Found ${result.rowCount} cluster node(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse list clusters failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse list clusters failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/list-databases/route.ts b/apps/sim/app/api/tools/clickhouse/list-databases/route.ts
new file mode 100644
index 00000000000..c524b162474
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/list-databases/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseListDatabasesContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseListDatabases } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseListDatabasesAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse list databases attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseListDatabasesContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseListDatabases(params)
+
+ return NextResponse.json({
+ message: `Found ${result.rowCount} database(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse list databases failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse list databases failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts b/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts
new file mode 100644
index 00000000000..84034b42436
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/list-mutations/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseListMutationsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseListMutations } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseListMutationsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse list mutations attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseListMutationsContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseListMutations(params, params.table, params.onlyRunning)
+
+ return NextResponse.json({
+ message: `Found ${result.rowCount} mutation(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse list mutations failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse list mutations failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts b/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts
new file mode 100644
index 00000000000..d064850ad1f
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/list-partitions/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseListPartitionsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseListPartitions } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseListPartitionsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse list partitions attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseListPartitionsContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseListPartitions(params, params.table)
+
+ return NextResponse.json({
+ message: `Found ${result.rowCount} partition(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse list partitions failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse list partitions failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts b/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts
new file mode 100644
index 00000000000..d542966d5d0
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/list-running-queries/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseListRunningQueriesContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseListRunningQueries } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseListRunningQueriesAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse list running queries attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseListRunningQueriesContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseListRunningQueries(params)
+
+ return NextResponse.json({
+ message: `Found ${result.rowCount} running query(ies).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse list running queries failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse list running queries failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/list-tables/route.ts b/apps/sim/app/api/tools/clickhouse/list-tables/route.ts
new file mode 100644
index 00000000000..4d9df7a2dc7
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/list-tables/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseListTablesContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseListTables } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseListTablesAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse list tables attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseListTablesContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseListTables(params)
+
+ return NextResponse.json({
+ message: `Found ${result.rowCount} table(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse list tables failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse list tables failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts b/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts
new file mode 100644
index 00000000000..3d22b8b3788
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/optimize-table/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseOptimizeTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseOptimizeTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseOptimizeTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse optimize table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseOptimizeTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseOptimizeTable(params, params.table, params.final)
+
+ return NextResponse.json({
+ message: `Optimize submitted for table '${params.table}'.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse optimize table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse optimize table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/query/route.ts b/apps/sim/app/api/tools/clickhouse/query/route.ts
new file mode 100644
index 00000000000..4d70b48b55b
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/query/route.ts
@@ -0,0 +1,46 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseQueryContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseQuery } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseQueryAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse query attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseQueryContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ logger.info(
+ `[${requestId}] Executing ClickHouse query on ${params.host}:${params.port}/${params.database}`
+ )
+
+ const result = await executeClickHouseQuery(params, params.query, { enforceReadOnly: true })
+
+ logger.info(`[${requestId}] Query executed successfully, returned ${result.rowCount} rows`)
+
+ return NextResponse.json({
+ message: `Query executed successfully. ${result.rowCount} row(s) returned.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse query failed:`, error)
+
+ return NextResponse.json({ error: `ClickHouse query failed: ${errorMessage}` }, { status: 500 })
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/rename-table/route.ts b/apps/sim/app/api/tools/clickhouse/rename-table/route.ts
new file mode 100644
index 00000000000..eec1f7ec436
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/rename-table/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseRenameTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseRenameTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseRenameTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse rename table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseRenameTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseRenameTable(params, params.table, params.newTable)
+
+ return NextResponse.json({
+ message: `Renamed table '${params.table}' to '${params.newTable}'.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse rename table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse rename table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts b/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts
new file mode 100644
index 00000000000..8c93d402803
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/show-create-table/route.ts
@@ -0,0 +1,42 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseShowCreateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseShowCreateTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseShowCreateTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse show create table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseShowCreateTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const ddl = await executeClickHouseShowCreateTable(params, params.table)
+
+ return NextResponse.json({
+ message: 'Retrieved CREATE statement.',
+ ddl,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse show create table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse show create table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/table-stats/route.ts b/apps/sim/app/api/tools/clickhouse/table-stats/route.ts
new file mode 100644
index 00000000000..405fbaf06cc
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/table-stats/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseTableStatsContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseTableStats } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseTableStatsAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse table stats attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseTableStatsContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ const result = await executeClickHouseTableStats(params, params.table)
+
+ return NextResponse.json({
+ message: `Retrieved stats for ${result.rowCount} table(s).`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse table stats failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse table stats failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts b/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts
new file mode 100644
index 00000000000..27452eb9849
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/truncate-table/route.ts
@@ -0,0 +1,43 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseTruncateTableContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseTruncateTable } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseTruncateTableAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse truncate table attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseTruncateTableContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ await executeClickHouseTruncateTable(params, params.table)
+
+ return NextResponse.json({
+ message: `Table '${params.table}' truncated.`,
+ rows: [],
+ rowCount: 0,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse truncate table failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse truncate table failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/update/route.ts b/apps/sim/app/api/tools/clickhouse/update/route.ts
new file mode 100644
index 00000000000..9d43755da4c
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/update/route.ts
@@ -0,0 +1,49 @@
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { generateId } from '@sim/utils/id'
+import { type NextRequest, NextResponse } from 'next/server'
+import { clickhouseUpdateContract } from '@/lib/api/contracts/tools/databases/clickhouse'
+import { parseToolRequest } from '@/lib/api/server'
+import { checkInternalAuth } from '@/lib/auth/hybrid'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { executeClickHouseUpdate } from '@/app/api/tools/clickhouse/utils'
+
+const logger = createLogger('ClickHouseUpdateAPI')
+
+export const POST = withRouteHandler(async (request: NextRequest) => {
+ const requestId = generateId().slice(0, 8)
+
+ try {
+ const auth = await checkInternalAuth(request)
+ if (!auth.success || !auth.userId) {
+ logger.warn(`[${requestId}] Unauthorized ClickHouse update attempt`)
+ return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseToolRequest(clickhouseUpdateContract, request, { logger })
+ if (!parsed.success) return parsed.response
+ const params = parsed.data.body
+
+ logger.info(
+ `[${requestId}] Updating data in ${params.table} on ${params.host}:${params.port}/${params.database}`
+ )
+
+ const result = await executeClickHouseUpdate(params, params.table, params.data, params.where)
+
+ logger.info(`[${requestId}] Update mutation submitted, ${result.rowCount} row(s) written`)
+
+ return NextResponse.json({
+ message: `Update mutation submitted. ClickHouse mutations run asynchronously. ${result.rowCount} row(s) written.`,
+ rows: result.rows,
+ rowCount: result.rowCount,
+ })
+ } catch (error) {
+ const errorMessage = getErrorMessage(error, 'Unknown error occurred')
+ logger.error(`[${requestId}] ClickHouse update failed:`, error)
+
+ return NextResponse.json(
+ { error: `ClickHouse update failed: ${errorMessage}` },
+ { status: 500 }
+ )
+ }
+})
diff --git a/apps/sim/app/api/tools/clickhouse/utils.ts b/apps/sim/app/api/tools/clickhouse/utils.ts
new file mode 100644
index 00000000000..71853591048
--- /dev/null
+++ b/apps/sim/app/api/tools/clickhouse/utils.ts
@@ -0,0 +1,863 @@
+import { validateDatabaseHost } from '@/lib/core/security/input-validation.server'
+import type { ClickHouseConnectionConfig } from '@/tools/clickhouse/types'
+
+const REQUEST_TIMEOUT_MS = 30_000
+
+interface ClickHouseSummary {
+ read_rows?: string
+ written_rows?: string
+ result_rows?: string
+}
+
+interface ClickHouseHttpResult {
+ text: string
+ summary: ClickHouseSummary | null
+}
+
+export interface ClickHouseRowsResult {
+ rows: unknown[]
+ rowCount: number
+}
+
+interface ClickHouseColumnRow {
+ table: string
+ name: string
+ type: string
+ default_kind?: string
+ default_expression?: string
+ is_in_primary_key?: number | string
+ is_in_sorting_key?: number | string
+ position?: number | string
+}
+
+interface ClickHouseTableRow {
+ name: string
+ engine?: string
+ total_rows?: number | string | null
+}
+
+export interface ClickHouseIntrospectionResult {
+ tables: Array<{
+ name: string
+ database: string
+ engine: string
+ totalRows?: number
+ columns: Array<{
+ name: string
+ type: string
+ defaultKind?: string
+ defaultExpression?: string
+ isInPrimaryKey: boolean
+ isInSortingKey: boolean
+ }>
+ }>
+}
+
+/**
+ * Sends a single statement to the ClickHouse HTTP interface and returns the raw
+ * response body alongside the parsed `X-ClickHouse-Summary` header.
+ * @see https://clickhouse.com/docs/interfaces/http
+ */
+async function clickhouseRequest(
+ config: ClickHouseConnectionConfig,
+ statement: string
+): Promise {
+ const hostValidation = await validateDatabaseHost(config.host, 'host')
+ if (!hostValidation.isValid) {
+ throw new Error(hostValidation.error)
+ }
+
+ const protocol = config.secure ? 'https' : 'http'
+ const url = new URL(`${protocol}://${config.host}:${config.port}/`)
+ url.searchParams.set('database', config.database)
+
+ const controller = new AbortController()
+ const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS)
+
+ let response: Response
+ try {
+ response = await fetch(url.toString(), {
+ method: 'POST',
+ headers: {
+ 'X-ClickHouse-User': config.username,
+ 'X-ClickHouse-Key': config.password,
+ 'Content-Type': 'text/plain; charset=utf-8',
+ },
+ body: statement,
+ signal: controller.signal,
+ })
+ } finally {
+ clearTimeout(timeout)
+ }
+
+ const text = await response.text()
+
+ if (!response.ok) {
+ throw new Error(text.trim() || `ClickHouse request failed with status ${response.status}`)
+ }
+
+ return { text, summary: parseSummary(response.headers.get('x-clickhouse-summary')) }
+}
+
+function parseSummary(header: string | null): ClickHouseSummary | null {
+ if (!header) return null
+ try {
+ return JSON.parse(header) as ClickHouseSummary
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Parses a ClickHouse `FORMAT JSON` response body into rows, falling back to the
+ * summary header's row counts for statements that do not return a result set.
+ */
+function parseRowsResult(result: ClickHouseHttpResult): ClickHouseRowsResult {
+ const trimmed = result.text.trim()
+ if (trimmed) {
+ try {
+ const parsed = JSON.parse(trimmed) as { data?: unknown[]; rows?: number }
+ if (parsed && Array.isArray(parsed.data)) {
+ const rowCount = typeof parsed.rows === 'number' ? parsed.rows : parsed.data.length
+ return { rows: parsed.data, rowCount }
+ }
+ } catch {
+ // Body was not JSON (e.g. a non-SELECT statement); fall through to summary.
+ }
+ }
+
+ const written = Number(result.summary?.written_rows ?? 0)
+ const read = Number(result.summary?.read_rows ?? 0)
+ return { rows: [], rowCount: written || read || 0 }
+}
+
+/** Read-only statement leaders that return a result set and never mutate data. */
+const READ_ONLY_STATEMENT = /^(select|with|show|describe|desc|explain|exists)\b/i
+
+/**
+ * Normalizes the output format of a read statement to JSON so the HTTP response
+ * can always be parsed into rows. Strips every `FORMAT ` clause — wherever
+ * it sits relative to a trailing `SETTINGS` clause — and appends a single canonical
+ * `FORMAT JSON`. The `format()` function and `FORMAT`/format names appearing inside
+ * strings or comments are ignored (the scan runs on comment/string-masked SQL).
+ * Non-read statements are returned untouched (their own FORMAT, e.g. JSONEachRow
+ * for inserts, is preserved).
+ */
+function ensureJsonFormat(query: string): string {
+ const trimmed = query.trim().replace(/;+\s*$/, '')
+ if (!READ_ONLY_STATEMENT.test(trimmed)) {
+ return trimmed
+ }
+ const masked = maskSqlNoise(trimmed)
+ const formatClause = /\bformat\s+[a-z0-9_]+\b/gi
+ const spans: Array<[number, number]> = []
+ for (let match = formatClause.exec(masked); match !== null; match = formatClause.exec(masked)) {
+ spans.push([match.index, match.index + match[0].length])
+ }
+ let result = trimmed
+ for (let i = spans.length - 1; i >= 0; i--) {
+ result = result.slice(0, spans[i][0]) + result.slice(spans[i][1])
+ }
+ return `${result.replace(/\s+$/, '')}\nFORMAT JSON`
+}
+
+/**
+ * Replaces string literals ('...'), quoted identifiers ("..." / `...`), and SQL
+ * comments (`-- …` and `/* … */`) with spaces so that structural scans (e.g. for
+ * statement-chaining semicolons) only see actual SQL code, not data or comments.
+ */
+function maskSqlNoise(sql: string): string {
+ let out = ''
+ let i = 0
+ while (i < sql.length) {
+ const ch = sql[i]
+ if (ch === "'" || ch === '"' || ch === '`') {
+ out += ' '
+ i++
+ while (i < sql.length && sql[i] !== ch) {
+ if (ch !== '`' && sql[i] === '\\') {
+ out += ' '
+ i += 2
+ continue
+ }
+ out += ' '
+ i++
+ }
+ if (i < sql.length) {
+ out += ' '
+ i++
+ }
+ continue
+ }
+ if (ch === '-' && sql[i + 1] === '-') {
+ const newline = sql.indexOf('\n', i + 2)
+ const end = newline === -1 ? sql.length : newline
+ out += ' '.repeat(end - i)
+ i = end
+ continue
+ }
+ if (ch === '/' && sql[i + 1] === '*') {
+ const close = sql.indexOf('*/', i + 2)
+ const end = close === -1 ? sql.length : close + 2
+ out += ' '.repeat(end - i)
+ i = end
+ continue
+ }
+ out += ch
+ i++
+ }
+ return out
+}
+
+/**
+ * Detects whether a statement chains a second statement after a `;`, ignoring
+ * semicolons inside string literals, quoted identifiers, and comments. A trailing
+ * semicolon (with only whitespace/comments after it) is allowed.
+ */
+function hasChainedStatement(sql: string): boolean {
+ return /;\s*\S/.test(maskSqlNoise(sql))
+}
+
+/**
+ * Write/DDL statement shapes that must never run under the read-only query
+ * operation, even when wrapped by a leading `WITH` CTE (e.g. `WITH … INSERT INTO …`).
+ * Patterns require the keyword's statement context (e.g. `insert into`, `alter table`)
+ * so SQL functions/columns like `truncate(x)` or `created_at` are not false-positives.
+ */
+const MUTATING_STATEMENT = [
+ /\binsert\s+into\b/i,
+ /\bdelete\s+from\b/i,
+ /\bupdate\s+[\w.`"]+\s+set\b/i,
+ /\balter\s+table\b/i,
+ /\b(?:create|attach)\s+(?:or\s+replace\s+)?(?:temporary\s+)?(?:table|database|dictionary|view|materialized\s+view|live\s+view|function|user|role)\b/i,
+ /\bdrop\s+(?:table|database|dictionary|view|column|partition|index|function|user|role)\b/i,
+ /\btruncate\s+table\b/i,
+ /\brename\s+(?:table|database|dictionary)\b/i,
+ /\bdetach\s+(?:table|database|dictionary|view|permanently)\b/i,
+ /\b(?:grant|revoke)\b/i,
+ /\boptimize\s+table\b/i,
+]
+
+/** Whether a statement performs a write/DDL anywhere (comments and strings masked out). */
+function isMutatingStatement(sql: string): boolean {
+ const masked = maskSqlNoise(sql)
+ return MUTATING_STATEMENT.some((pattern) => pattern.test(masked))
+}
+
+/**
+ * Strips leading whitespace, `--`/`/* … */` comments, and opening parens from a
+ * statement so the read-only leader keyword can be detected even when a query
+ * starts with a comment (e.g. `-- note\nSELECT …`) or wrapping parens.
+ */
+function stripLeadingNoise(sql: string): string {
+ let s = sql.trim()
+ for (;;) {
+ if (s.startsWith('--')) {
+ const newline = s.indexOf('\n')
+ s = (newline === -1 ? '' : s.slice(newline + 1)).trim()
+ } else if (s.startsWith('/*')) {
+ const close = s.indexOf('*/')
+ s = (close === -1 ? '' : s.slice(close + 2)).trim()
+ } else if (s.startsWith('(')) {
+ s = s.slice(1).trim()
+ } else {
+ return s
+ }
+ }
+}
+
+export async function executeClickHouseQuery(
+ config: ClickHouseConnectionConfig,
+ query: string,
+ options: { enforceReadOnly?: boolean } = {}
+): Promise {
+ if (options.enforceReadOnly) {
+ // Strip leading comments/parens so wrapped or commented selects still validate.
+ const leader = stripLeadingNoise(query)
+ if (!READ_ONLY_STATEMENT.test(leader)) {
+ throw new Error(
+ 'The query operation only allows read-only statements (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN, EXISTS). Use the Execute Raw SQL operation to run writes or DDL.'
+ )
+ }
+ if (hasChainedStatement(query)) {
+ throw new Error(
+ 'The query operation only allows a single statement; chained statements separated by ";" are not allowed. Use the Execute Raw SQL operation to run multiple statements.'
+ )
+ }
+ if (isMutatingStatement(query)) {
+ throw new Error(
+ 'The query operation only allows read-only statements; a write or DDL statement (e.g. INSERT/ALTER/DROP, including after a WITH clause) was detected. Use the Execute Raw SQL operation instead.'
+ )
+ }
+ }
+ const result = await clickhouseRequest(config, ensureJsonFormat(query))
+ return parseRowsResult(result)
+}
+
+export async function executeClickHouseInsert(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ data: Record
+): Promise {
+ const sanitizedTable = sanitizeIdentifier(table)
+ const statement = `INSERT INTO ${sanitizedTable} FORMAT JSONEachRow\n${JSON.stringify(data)}`
+ const result = await clickhouseRequest(config, statement)
+ const written = Number(result.summary?.written_rows ?? 0)
+ return { rows: [], rowCount: written || 1 }
+}
+
+export async function executeClickHouseUpdate(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ data: Record,
+ where: string
+): Promise {
+ validateWhereClause(where)
+ const sanitizedTable = sanitizeIdentifier(table)
+ const assignments = Object.entries(data)
+ .map(([column, value]) => `${sanitizeIdentifier(column)} = ${formatValue(value)}`)
+ .join(', ')
+
+ if (!assignments) {
+ throw new Error('Update data object cannot be empty')
+ }
+
+ const statement = `ALTER TABLE ${sanitizedTable} UPDATE ${assignments} WHERE ${where}`
+ const result = await clickhouseRequest(config, statement)
+ return { rows: [], rowCount: Number(result.summary?.written_rows ?? 0) }
+}
+
+export async function executeClickHouseDelete(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ where: string
+): Promise {
+ validateWhereClause(where)
+ const sanitizedTable = sanitizeIdentifier(table)
+ const statement = `ALTER TABLE ${sanitizedTable} DELETE WHERE ${where}`
+ const result = await clickhouseRequest(config, statement)
+ return { rows: [], rowCount: Number(result.summary?.written_rows ?? 0) }
+}
+
+export async function executeClickHouseIntrospect(
+ config: ClickHouseConnectionConfig
+): Promise {
+ const database = quoteString(config.database)
+
+ const tablesResult = await clickhouseRequest(
+ config,
+ `SELECT name, engine, total_rows FROM system.tables WHERE database = ${database} ORDER BY name FORMAT JSON`
+ )
+ const tableRows = parseDataArray(tablesResult.text)
+
+ const columnsResult = await clickhouseRequest(
+ config,
+ `SELECT table, name, type, default_kind, default_expression, is_in_primary_key, is_in_sorting_key, position FROM system.columns WHERE database = ${database} ORDER BY table, position FORMAT JSON`
+ )
+ const columnRows = parseDataArray(columnsResult.text)
+
+ const columnsByTable = new Map<
+ string,
+ ClickHouseIntrospectionResult['tables'][number]['columns']
+ >()
+ for (const column of columnRows) {
+ const columns = columnsByTable.get(column.table) ?? []
+ columns.push({
+ name: column.name,
+ type: column.type,
+ defaultKind: column.default_kind || undefined,
+ defaultExpression: column.default_expression || undefined,
+ isInPrimaryKey: toBoolean(column.is_in_primary_key),
+ isInSortingKey: toBoolean(column.is_in_sorting_key),
+ })
+ columnsByTable.set(column.table, columns)
+ }
+
+ const tables = tableRows.map((table) => ({
+ name: table.name,
+ database: config.database,
+ engine: table.engine ?? '',
+ totalRows: table.total_rows != null ? Number(table.total_rows) : undefined,
+ columns: columnsByTable.get(table.name) ?? [],
+ }))
+
+ return { tables }
+}
+
+function parseDataArray(text: string): T[] {
+ const trimmed = text.trim()
+ if (!trimmed) return []
+ try {
+ const parsed = JSON.parse(trimmed) as { data?: T[] }
+ return Array.isArray(parsed.data) ? parsed.data : []
+ } catch {
+ return []
+ }
+}
+
+function toBoolean(value: number | string | undefined): boolean {
+ return value === 1 || value === '1'
+}
+
+/**
+ * Quotes and escapes a value for inline use in a ClickHouse statement.
+ * Strings use ClickHouse's backslash escaping for single quotes and backslashes.
+ */
+function formatValue(value: unknown): string {
+ if (value === null || value === undefined) {
+ return 'NULL'
+ }
+ if (typeof value === 'number') {
+ return Number.isFinite(value) ? String(value) : 'NULL'
+ }
+ if (typeof value === 'boolean') {
+ return value ? '1' : '0'
+ }
+ if (typeof value === 'object') {
+ return quoteString(JSON.stringify(value))
+ }
+ return quoteString(String(value))
+}
+
+function quoteString(value: string): string {
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`
+}
+
+/**
+ * Validates and backtick-quotes a ClickHouse identifier, supporting
+ * `database.table` qualified names.
+ */
+export function sanitizeIdentifier(identifier: string): string {
+ if (identifier.includes('.')) {
+ return identifier
+ .split('.')
+ .map((part) => sanitizeSingleIdentifier(part))
+ .join('.')
+ }
+ return sanitizeSingleIdentifier(identifier)
+}
+
+function sanitizeSingleIdentifier(identifier: string): string {
+ const cleaned = identifier.replace(/`/g, '')
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(cleaned)) {
+ throw new Error(
+ `Invalid identifier: ${identifier}. Identifiers must start with a letter or underscore and contain only letters, numbers, and underscores.`
+ )
+ }
+ return `\`${cleaned}\``
+}
+
+/**
+ * Rejects WHERE clauses containing patterns commonly used in SQL injection so
+ * that user-supplied conditions cannot escape the intended mutation.
+ */
+function validateWhereClause(where: string): void {
+ const dangerousPatterns = [
+ /;\s*(drop|delete|insert|alter|create|truncate|rename|grant|revoke)/i,
+ /union\s+(all\s+)?select/i,
+ /into\s+outfile/i,
+ /--/,
+ /\/\*/,
+ /\*\//,
+ /\bor\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
+ /\bor\s+true\b/i,
+ /\bor\s+false\b/i,
+ /\band\s+(['"]?)(\w+)\1\s*=\s*\1\2\1/i,
+ /\band\s+true\b/i,
+ /\band\s+false\b/i,
+ /\bsleep\s*\(/i,
+ /;\s*\w+/,
+ // Constant / tautological conditions that don't reference columns and would
+ // broaden a mutation to all rows (e.g. "1=1", "1 < 2", "'a'='a'", bare "1"/"true").
+ /\b\d+\s*(?:=|==|<>|!=|<=|>=|<|>)\s*\d+\b/,
+ /(['"])([^'"]*)\1\s*(?:=|==|<>|!=)\s*\1\2\1/,
+ /\b(\w+)\s*=\s*\1\b/i,
+ /^\s*(?:\d+|true|false)\s*$/i,
+ ]
+
+ for (const pattern of dangerousPatterns) {
+ if (pattern.test(where)) {
+ throw new Error('WHERE clause contains potentially dangerous operation')
+ }
+ }
+}
+
+/**
+ * Runs a SELECT statement (which must already include `FORMAT JSON`) and returns
+ * the parsed rows and row count.
+ */
+async function runSelect(
+ config: ClickHouseConnectionConfig,
+ statement: string
+): Promise {
+ const result = await clickhouseRequest(config, statement)
+ return parseRowsResult(result)
+}
+
+/**
+ * Runs a statement that does not return a result set (DDL or mutation) and
+ * returns the number of written rows reported by the summary header.
+ */
+async function runStatement(
+ config: ClickHouseConnectionConfig,
+ statement: string
+): Promise {
+ const result = await clickhouseRequest(config, statement)
+ return Number(result.summary?.written_rows ?? 0)
+}
+
+/**
+ * Validates a free-form SQL expression (ORDER BY, PARTITION BY, engine args)
+ * rejecting statement terminators and comment sequences.
+ */
+function validateExpression(expression: string, label: string): void {
+ if (/;|--|\/\*|\*\//.test(expression)) {
+ throw new Error(`${label} contains a disallowed character`)
+ }
+}
+
+/**
+ * Validates an ORDER BY / PARTITION BY expression that is spliced inside wrapping
+ * parentheses in the generated DDL. In addition to rejecting terminators/comments,
+ * it requires balanced parentheses (quote-aware) so the expression cannot close
+ * the wrapping `(...)` early and append extra clauses (e.g. `id) SETTINGS …`).
+ */
+function validateClauseExpression(expression: string, label: string): void {
+ const trimmed = expression.trim()
+ if (!trimmed) {
+ throw new Error(`${label} is required`)
+ }
+ if (/;|--|\/\*|\*\//.test(trimmed)) {
+ throw new Error(`${label} contains a disallowed sequence`)
+ }
+ let depth = 0
+ let inString = false
+ for (let i = 0; i < trimmed.length; i++) {
+ const ch = trimmed[i]
+ if (inString) {
+ if (ch === '\\') i++
+ else if (ch === "'") inString = false
+ continue
+ }
+ if (ch === "'") inString = true
+ else if (ch === '(') depth++
+ else if (ch === ')') {
+ depth--
+ if (depth < 0) {
+ throw new Error(`${label} has unbalanced parentheses`)
+ }
+ }
+ }
+ if (inString || depth !== 0) {
+ throw new Error(`${label} has unbalanced parentheses or quotes`)
+ }
+}
+
+/**
+ * Validates a partition value for `DROP PARTITION`. ClickHouse partition values
+ * are literals (signed numbers or single-quoted strings) or a parenthesised tuple
+ * of such literals, so anything else is rejected — barewords like `ALL`, function
+ * calls, operators, and extra tokens that could broaden the statement beyond
+ * dropping a single partition.
+ */
+function validatePartitionExpression(partition: string): void {
+ const partitionPattern =
+ /^\(?\s*(?:'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?)(?:\s*,\s*(?:'(?:[^'\\]|\\.)*'|-?\d+(?:\.\d+)?))*\s*\)?$/
+ if (!partitionPattern.test(partition.trim())) {
+ throw new Error(
+ "Partition must be a literal value or a tuple of literals (number or single-quoted string), e.g. 202401, '2024-01', or (2024, 'EU')"
+ )
+ }
+}
+
+export function executeClickHouseListDatabases(
+ config: ClickHouseConnectionConfig
+): Promise {
+ return runSelect(
+ config,
+ 'SELECT name, engine, comment FROM system.databases ORDER BY name FORMAT JSON'
+ )
+}
+
+export function executeClickHouseListTables(
+ config: ClickHouseConnectionConfig
+): Promise {
+ return runSelect(
+ config,
+ `SELECT name, engine, total_rows AS totalRows, total_bytes AS totalBytes, comment FROM system.tables WHERE database = ${quoteString(config.database)} ORDER BY name FORMAT JSON`
+ )
+}
+
+export function executeClickHouseDescribeTable(
+ config: ClickHouseConnectionConfig,
+ table: string
+): Promise {
+ const tableName = stripDatabasePrefix(table)
+ return runSelect(
+ config,
+ `SELECT name, type, default_kind AS defaultKind, default_expression AS defaultExpression, comment, is_in_primary_key AS isInPrimaryKey, is_in_sorting_key AS isInSortingKey FROM system.columns WHERE database = ${quoteString(config.database)} AND table = ${quoteString(tableName)} ORDER BY position FORMAT JSON`
+ )
+}
+
+export async function executeClickHouseShowCreateTable(
+ config: ClickHouseConnectionConfig,
+ table: string
+): Promise {
+ const result = await runSelect(
+ config,
+ `SHOW CREATE TABLE ${sanitizeIdentifier(table)} FORMAT JSON`
+ )
+ const firstRow = result.rows[0] as Record | undefined
+ if (!firstRow) {
+ return ''
+ }
+ // ClickHouse returns the DDL in a single String column (named `statement`);
+ // fall back to the first column value to stay robust to column-name changes.
+ const value = firstRow.statement ?? Object.values(firstRow)[0]
+ return typeof value === 'string' ? value : ''
+}
+
+export async function executeClickHouseCountRows(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ where?: string
+): Promise {
+ let statement = `SELECT count() AS count FROM ${sanitizeIdentifier(table)}`
+ if (where?.trim()) {
+ validateWhereClause(where)
+ statement += ` WHERE ${where}`
+ }
+ const result = await runSelect(config, `${statement} FORMAT JSON`)
+ const firstRow = result.rows[0] as { count?: number | string } | undefined
+ return firstRow?.count != null ? Number(firstRow.count) : 0
+}
+
+export function executeClickHouseListPartitions(
+ config: ClickHouseConnectionConfig,
+ table: string
+): Promise {
+ const tableName = stripDatabasePrefix(table)
+ return runSelect(
+ config,
+ `SELECT partition, count() AS parts, sum(rows) AS rows, sum(bytes_on_disk) AS bytesOnDisk FROM system.parts WHERE database = ${quoteString(config.database)} AND table = ${quoteString(tableName)} AND active GROUP BY partition ORDER BY partition FORMAT JSON`
+ )
+}
+
+export function executeClickHouseListMutations(
+ config: ClickHouseConnectionConfig,
+ table?: string,
+ onlyRunning = false
+): Promise {
+ const filters = [`database = ${quoteString(config.database)}`]
+ if (table?.trim()) {
+ filters.push(`table = ${quoteString(stripDatabasePrefix(table))}`)
+ }
+ if (onlyRunning) {
+ filters.push('is_done = 0')
+ }
+ return runSelect(
+ config,
+ `SELECT table, mutation_id AS mutationId, command, create_time AS createTime, is_done AS isDone, parts_to_do AS partsToDo, latest_fail_reason AS latestFailReason FROM system.mutations WHERE ${filters.join(' AND ')} ORDER BY create_time DESC FORMAT JSON`
+ )
+}
+
+export function executeClickHouseListRunningQueries(
+ config: ClickHouseConnectionConfig
+): Promise {
+ return runSelect(
+ config,
+ 'SELECT query_id AS queryId, user, toFloat64(elapsed) AS elapsedSeconds, formatReadableSize(memory_usage) AS memoryUsage, query FROM system.processes ORDER BY elapsed DESC FORMAT JSON'
+ )
+}
+
+export function executeClickHouseTableStats(
+ config: ClickHouseConnectionConfig,
+ table?: string
+): Promise {
+ const filters = ['active', `database = ${quoteString(config.database)}`]
+ if (table?.trim()) {
+ filters.push(`table = ${quoteString(stripDatabasePrefix(table))}`)
+ }
+ return runSelect(
+ config,
+ `SELECT database, table, sum(rows) AS rows, sum(bytes_on_disk) AS bytesOnDisk, formatReadableSize(sum(bytes_on_disk)) AS sizeOnDisk, count() AS parts FROM system.parts WHERE ${filters.join(' AND ')} GROUP BY database, table ORDER BY sum(bytes_on_disk) DESC FORMAT JSON`
+ )
+}
+
+export function executeClickHouseListClusters(
+ config: ClickHouseConnectionConfig
+): Promise {
+ return runSelect(
+ config,
+ 'SELECT cluster, shard_num AS shardNum, replica_num AS replicaNum, host_name AS hostName, port, is_local AS isLocal FROM system.clusters ORDER BY cluster, shard_num, replica_num FORMAT JSON'
+ )
+}
+
+export async function executeClickHouseCreateDatabase(
+ config: ClickHouseConnectionConfig,
+ name: string
+): Promise {
+ await clickhouseRequest(config, `CREATE DATABASE IF NOT EXISTS ${sanitizeIdentifier(name)}`)
+}
+
+export async function executeClickHouseDropDatabase(
+ config: ClickHouseConnectionConfig,
+ name: string
+): Promise {
+ await clickhouseRequest(config, `DROP DATABASE IF EXISTS ${sanitizeIdentifier(name)}`)
+}
+
+/**
+ * Validates a single ClickHouse column type. Types may legitimately contain
+ * commas, single-quoted strings, `=`, and `-` inside their parameter parentheses
+ * (e.g. `Decimal(10, 2)`, `Enum8('a' = 1, 'b' = -2)`, `Map(String, UInt64)`,
+ * `Array(Tuple(a UInt8, b String))`). We allow those but reject anything that
+ * could break out of the single type literal and inject another column or SQL:
+ * comment/terminator sequences, a top-level (unparenthesised) comma, or an
+ * unbalanced closing paren.
+ */
+function validateColumnType(type: string): void {
+ const trimmed = type.trim()
+ if (!trimmed || !/^[A-Za-z_]/.test(trimmed)) {
+ throw new Error(`Invalid column type: ${type}`)
+ }
+ if (!/^[A-Za-z0-9_(),.\s'"=-]+$/.test(trimmed) || /--|;/.test(trimmed)) {
+ throw new Error(`Invalid column type: ${type}`)
+ }
+ let depth = 0
+ let inString = false
+ for (let i = 0; i < trimmed.length; i++) {
+ const ch = trimmed[i]
+ if (inString) {
+ if (ch === '\\') i++
+ else if (ch === "'") inString = false
+ continue
+ }
+ if (ch === "'") inString = true
+ else if (ch === '(') depth++
+ else if (ch === ')') {
+ depth--
+ if (depth < 0) throw new Error(`Invalid column type: ${type}`)
+ } else if (ch === ',' && depth === 0) {
+ throw new Error(`Invalid column type: ${type}`)
+ }
+ }
+ if (inString || depth !== 0) {
+ throw new Error(`Invalid column type: ${type}`)
+ }
+}
+
+export async function executeClickHouseCreateTable(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ columns: Array<{ name: string; type: string }>,
+ engine: string,
+ orderBy: string,
+ partitionBy?: string
+): Promise {
+ if (!Array.isArray(columns) || columns.length === 0) {
+ throw new Error('At least one column definition is required')
+ }
+
+ const columnDefs = columns.map((column) => {
+ if (!column?.name || !column?.type) {
+ throw new Error('Each column requires a name and type')
+ }
+ validateColumnType(column.type)
+ return `${sanitizeIdentifier(column.name)} ${column.type.trim()}`
+ })
+
+ if (!/^[A-Za-z][A-Za-z0-9]*(\(.*\))?$/.test(engine.trim())) {
+ throw new Error(`Invalid table engine: ${engine}`)
+ }
+ validateExpression(engine, 'Engine')
+
+ if (!orderBy?.trim()) {
+ throw new Error('ORDER BY expression is required')
+ }
+ validateClauseExpression(orderBy, 'ORDER BY')
+
+ let statement = `CREATE TABLE IF NOT EXISTS ${sanitizeIdentifier(table)} (${columnDefs.join(', ')}) ENGINE = ${engine.trim()}`
+ if (partitionBy?.trim()) {
+ validateClauseExpression(partitionBy, 'PARTITION BY')
+ statement += ` PARTITION BY (${partitionBy.trim()})`
+ }
+ statement += ` ORDER BY (${orderBy.trim()})`
+
+ await clickhouseRequest(config, statement)
+}
+
+export async function executeClickHouseDropTable(
+ config: ClickHouseConnectionConfig,
+ table: string
+): Promise {
+ await clickhouseRequest(config, `DROP TABLE IF EXISTS ${sanitizeIdentifier(table)}`)
+}
+
+export async function executeClickHouseTruncateTable(
+ config: ClickHouseConnectionConfig,
+ table: string
+): Promise {
+ await clickhouseRequest(config, `TRUNCATE TABLE IF EXISTS ${sanitizeIdentifier(table)}`)
+}
+
+export async function executeClickHouseRenameTable(
+ config: ClickHouseConnectionConfig,
+ fromTable: string,
+ toTable: string
+): Promise {
+ await clickhouseRequest(
+ config,
+ `RENAME TABLE ${sanitizeIdentifier(fromTable)} TO ${sanitizeIdentifier(toTable)}`
+ )
+}
+
+export async function executeClickHouseOptimizeTable(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ final: boolean
+): Promise {
+ await clickhouseRequest(
+ config,
+ `OPTIMIZE TABLE ${sanitizeIdentifier(table)}${final ? ' FINAL' : ''}`
+ )
+}
+
+export async function executeClickHouseDropPartition(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ partition: string
+): Promise {
+ validatePartitionExpression(partition)
+ await clickhouseRequest(
+ config,
+ `ALTER TABLE ${sanitizeIdentifier(table)} DROP PARTITION ${partition.trim()}`
+ )
+}
+
+export function executeClickHouseKillQuery(
+ config: ClickHouseConnectionConfig,
+ queryId: string
+): Promise {
+ return runSelect(config, `KILL QUERY WHERE query_id = ${quoteString(queryId)} SYNC FORMAT JSON`)
+}
+
+export async function executeClickHouseInsertRows(
+ config: ClickHouseConnectionConfig,
+ table: string,
+ rows: Array>
+): Promise {
+ if (!Array.isArray(rows) || rows.length === 0) {
+ throw new Error('At least one row is required')
+ }
+ const sanitizedTable = sanitizeIdentifier(table)
+ const payload = rows.map((row) => JSON.stringify(row)).join('\n')
+ const statement = `INSERT INTO ${sanitizedTable} FORMAT JSONEachRow\n${payload}`
+ const written = await runStatement(config, statement)
+ return { rows: [], rowCount: written || rows.length }
+}
+
+function stripDatabasePrefix(table: string): string {
+ const parts = table.split('.')
+ return parts[parts.length - 1].replace(/`/g, '')
+}
diff --git a/apps/sim/app/api/tools/image/route.ts b/apps/sim/app/api/tools/image/route.ts
index 6476b53f5c9..e6560c71f64 100644
--- a/apps/sim/app/api/tools/image/route.ts
+++ b/apps/sim/app/api/tools/image/route.ts
@@ -39,7 +39,12 @@ const MAX_IMAGE_BYTES = 25 * 1024 * 1024
const MAX_IMAGE_JSON_BYTES = Math.ceil((MAX_IMAGE_BYTES * 4) / 3) + 256 * 1024
export const dynamic = 'force-dynamic'
-export const maxDuration = 600
+/**
+ * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
+ * `getMaxExecutionTimeout()` for the provider polling loop below. Next.js requires a
+ * static literal for `maxDuration`, so this value must be kept in sync with that source.
+ */
+export const maxDuration = 5400
type ImageProvider = (typeof imageProviders)[number]
diff --git a/apps/sim/app/api/tools/stt/route.ts b/apps/sim/app/api/tools/stt/route.ts
index a320bcce008..fd9a6dc12d7 100644
--- a/apps/sim/app/api/tools/stt/route.ts
+++ b/apps/sim/app/api/tools/stt/route.ts
@@ -7,7 +7,7 @@ import { sttToolContract } from '@/lib/api/contracts/tools/media/stt'
import { getValidationErrorMessage, parseRequest, validationErrorResponse } from '@/lib/api/server'
import { extractAudioFromVideo, isVideoFile } from '@/lib/audio/extractor'
import { checkInternalAuth } from '@/lib/auth/hybrid'
-import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
+import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
secureFetchWithPinnedIP,
validateUrlWithDNS,
@@ -25,7 +25,12 @@ const logger = createLogger('SttProxyAPI')
const ELEVENLABS_STT_MODEL = 'scribe_v2'
export const dynamic = 'force-dynamic'
-export const maxDuration = 300 // 5 minutes for large files
+/**
+ * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
+ * `getMaxExecutionTimeout()` for the transcript polling loop below. Next.js requires a
+ * static literal for `maxDuration`, so this value must be kept in sync with that source.
+ */
+export const maxDuration = 5400
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId()
@@ -629,7 +634,7 @@ async function transcribeWithAssemblyAI(
let transcript: any
let attempts = 0
const pollIntervalMs = 5000
- const maxAttempts = Math.ceil(DEFAULT_EXECUTION_TIMEOUT_MS / pollIntervalMs)
+ const maxAttempts = Math.ceil(getMaxExecutionTimeout() / pollIntervalMs)
while (attempts < maxAttempts) {
const statusResponse = await fetch(`https://api.assemblyai.com/v2/transcript/${id}`, {
diff --git a/apps/sim/app/api/tools/textract/parse/route.ts b/apps/sim/app/api/tools/textract/parse/route.ts
index 48e6f07899f..b93cbbed4d9 100644
--- a/apps/sim/app/api/tools/textract/parse/route.ts
+++ b/apps/sim/app/api/tools/textract/parse/route.ts
@@ -6,7 +6,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { textractParseContract } from '@/lib/api/contracts/tools/media/document-parse'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
-import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
+import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import { validateS3BucketName } from '@/lib/core/security/input-validation'
import {
secureFetchWithPinnedIP,
@@ -22,7 +22,12 @@ import {
import { assertToolFileAccess } from '@/app/api/files/authorization'
export const dynamic = 'force-dynamic'
-export const maxDuration = 300 // 5 minutes for large multi-page PDF processing
+/**
+ * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
+ * `getMaxExecutionTimeout()` for the job polling loop below. Next.js requires a static
+ * literal for `maxDuration`, so this value must be kept in sync with that source.
+ */
+export const maxDuration = 5400
const logger = createLogger('TextractParseAPI')
@@ -184,7 +189,7 @@ async function pollForJobCompletion(
requestId: string
): Promise> {
const pollIntervalMs = 5000
- const maxPollTimeMs = DEFAULT_EXECUTION_TIMEOUT_MS
+ const maxPollTimeMs = getMaxExecutionTimeout()
const maxAttempts = Math.ceil(maxPollTimeMs / pollIntervalMs)
const getTarget = useAnalyzeDocument
diff --git a/apps/sim/app/api/tools/video/route.ts b/apps/sim/app/api/tools/video/route.ts
index 1110432a473..9980121ed4c 100644
--- a/apps/sim/app/api/tools/video/route.ts
+++ b/apps/sim/app/api/tools/video/route.ts
@@ -28,7 +28,12 @@ const MAX_VIDEO_REFERENCE_IMAGE_BYTES = 25 * 1024 * 1024
const MAX_VIDEO_JSON_BYTES = 2 * 1024 * 1024
export const dynamic = 'force-dynamic'
-export const maxDuration = 600 // 10 minutes for video generation
+/**
+ * Mirrors the maximum plan execution timeout (enterprise async, 90 minutes) used by
+ * `getMaxExecutionTimeout()` for the provider polling loops below. Next.js requires a
+ * static literal for `maxDuration`, so this value must be kept in sync with that source.
+ */
+export const maxDuration = 5400
async function readVideoResponseBuffer(response: Response, label: string): Promise {
return readResponseToBufferWithLimit(response, {
diff --git a/apps/sim/app/api/v1/audit-logs/query.ts b/apps/sim/app/api/v1/audit-logs/query.ts
index 14e24c65427..307457346c5 100644
--- a/apps/sim/app/api/v1/audit-logs/query.ts
+++ b/apps/sim/app/api/v1/audit-logs/query.ts
@@ -38,7 +38,11 @@ export function buildFilterConditions(params: AuditLogFilterParams): SQL[] = []
if (params.action) conditions.push(eq(auditLog.action, params.action))
- if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType))
+ if (params.resourceType) {
+ const types = params.resourceType.split(',').filter(Boolean)
+ if (types.length === 1) conditions.push(eq(auditLog.resourceType, types[0]))
+ else if (types.length > 1) conditions.push(inArray(auditLog.resourceType, types))
+ }
if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId))
if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId))
if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId))
diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts
index 0ac8b9ab43f..365381b0751 100644
--- a/apps/sim/app/api/v1/audit-logs/route.ts
+++ b/apps/sim/app/api/v1/audit-logs/route.ts
@@ -6,7 +6,7 @@
*
* Query Parameters:
* - action: string (optional) - Filter by action (e.g., "workflow.created")
- * - resourceType: string (optional) - Filter by resource type (e.g., "workflow")
+ * - resourceType: string (optional) - Filter by resource type(s), comma-separated (e.g., "workflow,api_key")
* - resourceId: string (optional) - Filter by resource ID
* - workspaceId: string (optional) - Filter by workspace ID
* - actorId: string (optional) - Filter by actor user ID (must be an org member)
diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
index e736a859eaa..55cf776dc7b 100644
--- a/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
+++ b/apps/sim/app/api/v1/tables/[tableId]/rows/route.ts
@@ -1,8 +1,5 @@
-import { db } from '@sim/db'
-import { userTableRows } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
-import { and, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
type V1BatchInsertTableRowsBody,
@@ -24,13 +21,13 @@ import {
deleteRowsByFilter,
deleteRowsByIds,
insertRow,
- USER_TABLE_ROWS_SQL_NAME,
updateRowsByFilter,
validateBatchRows,
validateRowData,
validateRowSize,
} from '@/lib/table'
-import { buildFilterClause, buildSortClause, TableQueryValidationError } from '@/lib/table/sql'
+import { queryRows } from '@/lib/table/service'
+import { TableQueryValidationError } from '@/lib/table/sql'
import { accessError, checkAccess } from '@/app/api/table/utils'
import {
checkRateLimit,
@@ -153,92 +150,33 @@ export const GET = withRouteHandler(async (request: NextRequest, context: TableR
return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 })
}
- const baseConditions = [
- eq(userTableRows.tableId, tableId),
- eq(userTableRows.workspaceId, validated.workspaceId),
- ]
-
- const schema = table.schema as TableSchema
-
- if (validated.filter) {
- const filterClause = buildFilterClause(
- validated.filter as Filter,
- USER_TABLE_ROWS_SQL_NAME,
- schema.columns
- )
- if (filterClause) {
- baseConditions.push(filterClause)
- }
- }
-
- let query = db
- .select({
- id: userTableRows.id,
- data: userTableRows.data,
- position: userTableRows.position,
- createdAt: userTableRows.createdAt,
- updatedAt: userTableRows.updatedAt,
- })
- .from(userTableRows)
- .where(and(...baseConditions))
-
- if (validated.sort) {
- const sortClause = buildSortClause(validated.sort, USER_TABLE_ROWS_SQL_NAME, schema.columns)
- if (sortClause) {
- query = query.orderBy(sortClause) as typeof query
- } else {
- query = query.orderBy(userTableRows.position) as typeof query
- }
- } else {
- query = query.orderBy(userTableRows.position) as typeof query
- }
-
- const rowsPromise = query.limit(validated.limit).offset(validated.offset)
-
- let totalCount: number | null = null
- if (validated.includeTotal) {
- const countQuery = db
- .select({ count: sql`count(*)` })
- .from(userTableRows)
- .where(and(...baseConditions))
- const [countResult, rows] = await Promise.all([countQuery, rowsPromise])
- totalCount = Number(countResult[0].count)
- return NextResponse.json({
- success: true,
- data: {
- rows: rows.map((r) => ({
- id: r.id,
- data: r.data,
- position: r.position,
- createdAt:
- r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
- updatedAt:
- r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
- })),
- rowCount: rows.length,
- totalCount,
- limit: validated.limit,
- offset: validated.offset,
- },
- })
- }
-
- const rows = await rowsPromise
+ const result = await queryRows(
+ table,
+ {
+ filter: validated.filter as Filter | undefined,
+ sort: validated.sort,
+ limit: validated.limit,
+ offset: validated.offset,
+ includeTotal: validated.includeTotal,
+ withExecutions: false,
+ },
+ requestId
+ )
return NextResponse.json({
success: true,
data: {
- rows: rows.map((r) => ({
+ rows: result.rows.map((r) => ({
id: r.id,
data: r.data,
position: r.position,
createdAt: r.createdAt instanceof Date ? r.createdAt.toISOString() : String(r.createdAt),
updatedAt: r.updatedAt instanceof Date ? r.updatedAt.toISOString() : String(r.updatedAt),
})),
- rowCount: rows.length,
- totalCount,
- limit: validated.limit,
- offset: validated.offset,
+ rowCount: result.rowCount,
+ totalCount: result.totalCount,
+ limit: result.limit,
+ offset: result.offset,
},
})
} catch (error) {
diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts b/apps/sim/app/api/workflows/[id]/form/status/route.test.ts
deleted file mode 100644
index 3fa8cd76157..00000000000
--- a/apps/sim/app/api/workflows/[id]/form/status/route.test.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-/**
- * Tests for workflow form status route auth and access.
- *
- * @vitest-environment node
- */
-import {
- dbChainMock,
- dbChainMockFns,
- hybridAuthMockFns,
- resetDbChainMock,
- workflowAuthzMockFns,
- workflowsUtilsMock,
-} from '@sim/testing'
-import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-vi.mock('@sim/db', () => dbChainMock)
-vi.mock('drizzle-orm', () => ({
- and: vi.fn(),
- eq: vi.fn(),
-}))
-
-vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)
-
-import { GET } from '@/app/api/workflows/[id]/form/status/route'
-
-describe('Workflow Form Status Route', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- resetDbChainMock()
- })
-
- it('returns 401 when unauthenticated', async () => {
- hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({ success: false })
-
- const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status')
- const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
-
- expect(response.status).toBe(401)
- })
-
- it('returns 403 when user lacks workspace access', async () => {
- hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
- success: true,
- userId: 'user-1',
- authType: 'session',
- })
- workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
- allowed: false,
- status: 403,
- message: 'Access denied',
- workflow: { id: 'wf-1', workspaceId: 'ws-1' },
- workspacePermission: null,
- })
-
- const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status')
- const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
-
- expect(response.status).toBe(403)
- })
-
- it('returns deployed form when authorized', async () => {
- hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValueOnce({
- success: true,
- userId: 'user-1',
- authType: 'session',
- })
- workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValueOnce({
- allowed: true,
- status: 200,
- workflow: { id: 'wf-1', workspaceId: 'ws-1' },
- workspacePermission: 'read',
- })
- dbChainMockFns.limit.mockResolvedValueOnce([
- {
- id: 'form-1',
- identifier: 'feedback-form',
- title: 'Feedback',
- isActive: true,
- },
- ])
-
- const req = new NextRequest('http://localhost:3000/api/workflows/wf-1/form/status')
- const response = await GET(req, { params: Promise.resolve({ id: 'wf-1' }) })
-
- expect(response.status).toBe(200)
- const data = await response.json()
- expect(data.isDeployed).toBe(true)
- expect(data.form.id).toBe('form-1')
- })
-})
diff --git a/apps/sim/app/api/workflows/[id]/form/status/route.ts b/apps/sim/app/api/workflows/[id]/form/status/route.ts
deleted file mode 100644
index cb77489a39e..00000000000
--- a/apps/sim/app/api/workflows/[id]/form/status/route.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { db } from '@sim/db'
-import { form } from '@sim/db/schema'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
-import { and, eq } from 'drizzle-orm'
-import type { NextRequest } from 'next/server'
-import { getFormStatusContract } from '@/lib/api/contracts/forms'
-import { parseRequest } from '@/lib/api/server'
-import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
-import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
-
-const logger = createLogger('FormStatusAPI')
-
-export const GET = withRouteHandler(
- async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
- try {
- const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false })
- if (!auth.success || !auth.userId) {
- return createErrorResponse('Unauthorized', 401)
- }
-
- const parsed = await parseRequest(getFormStatusContract, request, context)
- if (!parsed.success) return parsed.response
- const { id: workflowId } = parsed.data.params
- const authorization = await authorizeWorkflowByWorkspacePermission({
- workflowId,
- userId: auth.userId,
- action: 'read',
- })
- if (!authorization.allowed) {
- return createErrorResponse(
- authorization.message || 'Access denied',
- authorization.status || 403
- )
- }
-
- const formResult = await db
- .select({
- id: form.id,
- identifier: form.identifier,
- title: form.title,
- isActive: form.isActive,
- })
- .from(form)
- .where(and(eq(form.workflowId, workflowId), eq(form.isActive, true)))
- .limit(1)
-
- if (formResult.length === 0) {
- return createSuccessResponse({
- isDeployed: false,
- form: null,
- })
- }
-
- return createSuccessResponse({
- isDeployed: true,
- form: formResult[0],
- })
- } catch (error) {
- logger.error('Error fetching form status:', error)
- return createErrorResponse(getErrorMessage(error, 'Failed to fetch form status'), 500)
- }
- }
-)
diff --git a/apps/sim/app/api/workflows/[id]/route.ts b/apps/sim/app/api/workflows/[id]/route.ts
index a69fffc2715..993968f3bbc 100644
--- a/apps/sim/app/api/workflows/[id]/route.ts
+++ b/apps/sim/app/api/workflows/[id]/route.ts
@@ -207,40 +207,10 @@ export const DELETE = withRouteHandler(
await assertWorkflowMutable(workflowId)
- const { searchParams } = new URL(request.url)
- const checkTemplates = searchParams.get('check-templates') === 'true'
- const deleteTemplatesParam = searchParams.get('deleteTemplates')
-
- if (checkTemplates) {
- const { templates } = await import('@sim/db/schema')
- const publishedTemplates = await db
- .select({
- id: templates.id,
- name: templates.name,
- views: templates.views,
- stars: templates.stars,
- status: templates.status,
- })
- .from(templates)
- .where(eq(templates.workflowId, workflowId))
-
- return NextResponse.json({
- hasPublishedTemplates: publishedTemplates.length > 0,
- count: publishedTemplates.length,
- publishedTemplates: publishedTemplates.map((t) => ({
- id: t.id,
- name: t.name,
- views: t.views,
- stars: t.stars,
- })),
- })
- }
-
const result = await performDeleteWorkflow({
workflowId,
userId,
requestId,
- templateAction: deleteTemplatesParam === 'delete' ? 'delete' : 'orphan',
})
if (!result.success) {
diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts
index 47c6f4a1e59..387b1aad955 100644
--- a/apps/sim/app/api/workspaces/[id]/environment/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts
@@ -18,6 +18,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import {
createWorkspaceEnvCredentials,
deleteWorkspaceEnvCredentials,
+ getWorkspaceEnvKeyAdminAccess,
} from '@/lib/credentials/environment'
import {
getPersonalAndWorkspaceEnv,
@@ -91,15 +92,46 @@ export const PUT = withRouteHandler(
}
const userId = session.user.id
- const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
- if (!permission || (permission !== 'admin' && permission !== 'write')) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
const parsed = await parseRequest(upsertWorkspaceEnvironmentContract, request, context)
if (!parsed.success) return parsed.response
const { variables } = parsed.data.body
+ // Caller must have workspace access at all (blocks non-member writes);
+ // per-key gating below then requires credential-admin to edit existing
+ // secrets and write/admin to add brand-new keys.
+ const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
+ if (!permission) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const incomingKeys = Object.keys(variables)
+ if (incomingKeys.length === 0) {
+ return NextResponse.json({ success: true })
+ }
+ const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
+ workspaceId,
+ envKeys: incomingKeys,
+ userId,
+ })
+ const forbiddenExisting = incomingKeys.filter((k) => knownKeys.has(k) && !adminKeys.has(k))
+ if (forbiddenExisting.length > 0) {
+ return NextResponse.json(
+ { error: 'You must be an admin of these secrets to edit them' },
+ { status: 403 }
+ )
+ }
+ if (
+ incomingKeys.some((k) => !knownKeys.has(k)) &&
+ permission !== 'admin' &&
+ permission !== 'write'
+ ) {
+ return NextResponse.json(
+ { error: 'Write access is required to add new secrets' },
+ { status: 403 }
+ )
+ }
+
const encryptedIncoming = await Promise.all(
Object.entries(variables).map(async ([key, value]) => {
const { encrypted } = await encryptSecret(value)
@@ -184,15 +216,38 @@ export const DELETE = withRouteHandler(
}
const userId = session.user.id
- const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
- if (!permission || (permission !== 'admin' && permission !== 'write')) {
- return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
- }
const parsed = await parseRequest(removeWorkspaceEnvironmentContract, request, context)
if (!parsed.success) return parsed.response
const { keys } = parsed.data.body
+ // Caller must have workspace access at all; deleting an existing secret then
+ // requires being its credential admin, while a key with no credential yet
+ // (legacy) falls back to workspace write/admin.
+ const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
+ if (!permission) {
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+ }
+
+ const { adminKeys, knownKeys } = await getWorkspaceEnvKeyAdminAccess({
+ workspaceId,
+ envKeys: keys,
+ userId,
+ })
+ const forbiddenExisting = keys.filter((k) => knownKeys.has(k) && !adminKeys.has(k))
+ if (forbiddenExisting.length > 0) {
+ return NextResponse.json(
+ { error: 'You must be an admin of these secrets to delete them' },
+ { status: 403 }
+ )
+ }
+ if (keys.some((k) => !knownKeys.has(k)) && permission !== 'admin' && permission !== 'write') {
+ return NextResponse.json(
+ { error: 'Write access is required to remove these secrets' },
+ { status: 403 }
+ )
+ }
+
const result = await db.transaction(async (tx) => {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${workspaceId}))`)
diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts
index 61bddb78267..1e8bbac5b82 100644
--- a/apps/sim/app/api/workspaces/[id]/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/route.ts
@@ -1,7 +1,7 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { and, eq, inArray, isNull } from 'drizzle-orm'
+import { and, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteWorkspaceBodySchema, updateWorkspaceContract } from '@/lib/api/contracts'
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
@@ -12,7 +12,7 @@ import { archiveWorkspace } from '@/lib/workspaces/lifecycle'
const logger = createLogger('WorkspaceByIdAPI')
import { db } from '@sim/db'
-import { permissions, templates, workspace } from '@sim/db/schema'
+import { permissions, workspace } from '@sim/db/schema'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -26,8 +26,6 @@ export const GET = withRouteHandler(
}
const workspaceId = id
- const url = new URL(request.url)
- const checkTemplates = url.searchParams.get('check-templates') === 'true'
// Check if user has any access to this workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -35,46 +33,6 @@ export const GET = withRouteHandler(
return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 })
}
- // If checking for published templates before deletion
- if (checkTemplates) {
- try {
- // Get all workflows in this workspace
- const workspaceWorkflows = await db
- .select({ id: workflow.id })
- .from(workflow)
- .where(eq(workflow.workspaceId, workspaceId))
-
- if (workspaceWorkflows.length === 0) {
- return NextResponse.json({
- hasPublishedTemplates: false,
- publishedTemplates: [],
- count: 0,
- })
- }
-
- const workflowIds = workspaceWorkflows.map((w) => w.id)
-
- // Check for published templates that reference these workflows
- const publishedTemplates = await db
- .select({
- id: templates.id,
- name: templates.name,
- workflowId: templates.workflowId,
- })
- .from(templates)
- .where(inArray(templates.workflowId, workflowIds))
-
- return NextResponse.json({
- hasPublishedTemplates: publishedTemplates.length > 0,
- publishedTemplates,
- count: publishedTemplates.length,
- })
- } catch (error) {
- logger.error(`Error checking published templates for workspace ${workspaceId}:`, error)
- return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 })
- }
- }
-
// Get workspace details
const workspaceDetails = await db
.select()
@@ -287,7 +245,6 @@ export const DELETE = withRouteHandler(
const rawBody = await request.json().catch(() => ({}))
const bodyValidation = deleteWorkspaceBodySchema.safeParse(rawBody)
if (!bodyValidation.success) return validationErrorResponse(bodyValidation.error)
- const { deleteTemplates } = bodyValidation.data // User's choice: false = keep templates (recommended), true = delete templates
// Check if user has admin permissions to delete workspace
const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)
@@ -320,9 +277,7 @@ export const DELETE = withRouteHandler(
return NextResponse.json({ error: 'Cannot delete the only workspace' }, { status: 400 })
}
- logger.info(
- `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
- )
+ logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`)
const workspaceWorkflows = await db
.select({ id: workflow.id })
@@ -331,17 +286,6 @@ export const DELETE = withRouteHandler(
const workflowIds = workspaceWorkflows.map((entry) => entry.id)
- if (workflowIds.length > 0) {
- if (deleteTemplates) {
- await db.delete(templates).where(inArray(templates.workflowId, workflowIds))
- } else {
- await db
- .update(templates)
- .set({ workflowId: null })
- .where(inArray(templates.workflowId, workflowIds))
- }
- }
-
const archiveResult = await archiveWorkspace(workspaceId, {
requestId: `workspace-${workspaceId}`,
})
@@ -365,7 +309,6 @@ export const DELETE = withRouteHandler(
workflows: workflowIds.length,
},
archived: archiveResult.archived,
- deleteTemplates,
},
request,
})
diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts
index 9b37bb2c74a..74b3c33ed64 100644
--- a/apps/sim/app/api/workspaces/members/[id]/route.ts
+++ b/apps/sim/app/api/workspaces/members/[id]/route.ts
@@ -1,6 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
-import { permissionGroupMember, permissions, workspace } from '@sim/db/schema'
+import { member, permissionGroupMember, permissions, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
@@ -8,6 +8,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { removeWorkspaceMemberContract } from '@/lib/api/contracts/invitations'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
+import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -34,6 +36,7 @@ export const DELETE = withRouteHandler(
.select({
ownerId: workspace.ownerId,
billedAccountUserId: workspace.billedAccountUserId,
+ organizationId: workspace.organizationId,
})
.from(workspace)
.where(eq(workspace.id, workspaceId))
@@ -111,6 +114,8 @@ export const DELETE = withRouteHandler(
}
}
+ const organizationId = workspaceRow[0].organizationId
+
const ownershipTransferred = await db.transaction(async (tx) => {
let didTransferOwnership = false
@@ -184,6 +189,65 @@ export const DELETE = withRouteHandler(
return didTransferOwnership
})
+ /**
+ * Seats are tied to organization membership (one per member), so a
+ * single-workspace removal only drops a seat when it leaves the member
+ * with no access to any of the org's workspaces — at which point their
+ * org membership is removed too. Members still in other org workspaces
+ * keep their membership and seat.
+ */
+ let organizationRemoval = false
+ let seatReduction: Awaited> | null = null
+
+ if (organizationId && userId !== workspaceRow[0].billedAccountUserId) {
+ const [orgMembership] = await db
+ .select({ id: member.id })
+ .from(member)
+ .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
+ .limit(1)
+
+ if (orgMembership) {
+ /**
+ * Remove the org membership + seat only when this is the member's last
+ * access to any org workspace. The remaining-access check and the
+ * deletion happen atomically under a `(user, org)` advisory lock inside
+ * `removeUserFromOrganization` (`requireNoOrgWorkspaceAccess`), so a
+ * concurrent invite acceptance can't be raced into a "workspace access
+ * but no org membership" state.
+ */
+ const removal = await removeUserFromOrganization({
+ userId,
+ organizationId,
+ memberId: orgMembership.id,
+ requireNoOrgWorkspaceAccess: true,
+ })
+
+ if (removal.success && removal.removed) {
+ organizationRemoval = true
+ try {
+ seatReduction = await reconcileOrganizationSeats({
+ organizationId,
+ reason: 'member-removed',
+ })
+ } catch (seatError) {
+ logger.error('Failed to reduce seats after workspace member removal', {
+ organizationId,
+ workspaceId,
+ removedUserId: userId,
+ error: seatError,
+ })
+ }
+ } else if (!removal.success) {
+ logger.error('Failed to remove org membership after last workspace removal', {
+ organizationId,
+ workspaceId,
+ removedUserId: userId,
+ error: removal.error,
+ })
+ }
+ }
+ }
+
captureServerEvent(
session.user.id,
'workspace_member_removed',
@@ -199,12 +263,20 @@ export const DELETE = withRouteHandler(
action: AuditAction.MEMBER_REMOVED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
- description: isSelf ? 'Left the workspace' : `Removed member ${userId} from the workspace`,
+ description: isSelf
+ ? organizationRemoval
+ ? 'Left the organization'
+ : 'Left the workspace'
+ : organizationRemoval
+ ? `Removed member ${userId} from the organization`
+ : `Removed member ${userId} from the workspace`,
metadata: {
removedUserId: userId,
removedUserRole: userPermission?.permissionType ?? 'owner',
selfRemoval: isSelf,
ownershipTransferred,
+ organizationRemoval,
+ seatReduction,
},
request: req,
})
diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts
index 4855893e97d..b0f3902fe54 100644
--- a/apps/sim/app/api/workspaces/route.ts
+++ b/apps/sim/app/api/workspaces/route.ts
@@ -9,6 +9,7 @@ import { listWorkspacesQuerySchema } from '@/lib/api/contracts'
import { createWorkspaceContract } from '@/lib/api/contracts/workspaces'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
+import type { PlanCategory } from '@/lib/billing/plan-helpers'
import { PlatformEvents } from '@/lib/core/telemetry'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -18,9 +19,10 @@ import { getRandomWorkspaceColor } from '@/lib/workspaces/colors'
import {
CONTACT_OWNER_TO_UPGRADE_REASON,
evaluateWorkspaceInvitePolicy,
+ getInvitePlanCategoryForOrganization,
+ getInvitePlanCategoryForUser,
getWorkspaceCreationPolicy,
getWorkspaceInvitePolicy,
- hasActiveTeamOrEnterpriseSubscription,
UPGRADE_TO_INVITE_REASON,
WORKSPACE_MODE,
} from '@/lib/workspaces/policy'
@@ -117,26 +119,43 @@ export const GET = withRouteHandler(async (request: Request) => {
await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id)
}
- const grandfatheredBilledUserIds = [
+ const nonOrgBilledUserIds = [
...new Set(
userWorkspaces
- .filter(({ workspace: ws }) => ws.workspaceMode === WORKSPACE_MODE.GRANDFATHERED_SHARED)
+ .filter(({ workspace: ws }) => ws.workspaceMode !== WORKSPACE_MODE.ORGANIZATION)
.map(({ workspace: ws }) => ws.billedAccountUserId)
),
]
- const teamOrEnterpriseByUser = new Map()
- await Promise.all(
- grandfatheredBilledUserIds.map(async (userId) => {
- teamOrEnterpriseByUser.set(userId, await hasActiveTeamOrEnterpriseSubscription(userId))
- })
- )
+ const orgIds = [
+ ...new Set(
+ userWorkspaces
+ .filter(
+ ({ workspace: ws }) =>
+ ws.workspaceMode === WORKSPACE_MODE.ORGANIZATION && ws.organizationId
+ )
+ .map(({ workspace: ws }) => ws.organizationId as string)
+ ),
+ ]
+ const planCategoryByBilledUser = new Map()
+ const planCategoryByOrg = new Map()
+ await Promise.all([
+ ...nonOrgBilledUserIds.map(async (userId) => {
+ planCategoryByBilledUser.set(userId, await getInvitePlanCategoryForUser(userId))
+ }),
+ ...orgIds.map(async (orgId) => {
+ planCategoryByOrg.set(orgId, await getInvitePlanCategoryForOrganization(orgId))
+ }),
+ ])
const workspacesWithPermissions = userWorkspaces.map(
({ workspace: workspaceDetails, permissionType }) => {
- const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, {
- billedUserHasTeamOrEnterprise:
- teamOrEnterpriseByUser.get(workspaceDetails.billedAccountUserId) ?? false,
- })
+ const billedPlanCategory: PlanCategory =
+ workspaceDetails.workspaceMode === WORKSPACE_MODE.ORGANIZATION
+ ? workspaceDetails.organizationId
+ ? (planCategoryByOrg.get(workspaceDetails.organizationId) ?? 'free')
+ : 'free'
+ : (planCategoryByBilledUser.get(workspaceDetails.billedAccountUserId) ?? 'free')
+ const invitePolicy = evaluateWorkspaceInvitePolicy(workspaceDetails, { billedPlanCategory })
const callerIsBilledUser = workspaceDetails.billedAccountUserId === session.user.id
const canActOnUpgrade = invitePolicy.upgradeRequired && callerIsBilledUser
diff --git a/apps/sim/app/chat/components/input/input.tsx b/apps/sim/app/chat/components/input/input.tsx
index c49944cfcd3..c31342c1e64 100644
--- a/apps/sim/app/chat/components/input/input.tsx
+++ b/apps/sim/app/chat/components/input/input.tsx
@@ -193,7 +193,7 @@ export const ChatInput: React.FC<{
handleKeyboardActivation(event, focusTextarea)
}}
className={cn(
- 'relative z-10 cursor-text rounded-[20px] border border-[var(--border-1)] bg-[var(--landing-bg-elevated)] px-2.5 py-2',
+ 'relative z-10 cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--landing-bg-elevated)] px-2.5 py-2',
isDragOver && 'border-purple-500'
)}
onDragEnter={(e) => {
diff --git a/apps/sim/app/chat/components/message/components/file-download.tsx b/apps/sim/app/chat/components/message/components/file-download.tsx
index 4126dd204d4..bd5aa880dcc 100644
--- a/apps/sim/app/chat/components/message/components/file-download.tsx
+++ b/apps/sim/app/chat/components/message/components/file-download.tsx
@@ -3,8 +3,8 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { sleep } from '@sim/utils/helpers'
-import { ArrowDown, Download, Music } from 'lucide-react'
-import { Button, Loader } from '@/components/emcn'
+import { Music } from 'lucide-react'
+import { Button, Download, Loader } from '@/components/emcn'
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
import type { ChatFile } from '@/app/chat/components/message/message'
@@ -98,14 +98,14 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
const renderIcon = () => {
if (isAudioFile(file.type, file.name)) {
- return
+ return
}
if (isImageFile(file.type)) {
const ImageIcon = DefaultFileIcon
- return
+ return
}
const DocumentIcon = getDocumentIcon(file.type, file.name)
- return
+ return
}
return (
@@ -117,7 +117,7 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
disabled={isDownloading}
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
>
- {renderIcon()}
+ {renderIcon()}
{file.name}
@@ -126,9 +126,9 @@ export function ChatFileDownload({ file }: ChatFileDownloadProps) {
{isDownloading ? (
-
+
) : (
-
)}
@@ -176,9 +176,9 @@ export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
className='text-[var(--landing-text-muted)] transition-colors hover:bg-[var(--landing-bg-elevated)] disabled:opacity-50'
>
{isDownloading ? (
-
+
) : (
-
+
)}
)
diff --git a/apps/sim/app/chat/components/message/message.tsx b/apps/sim/app/chat/components/message/message.tsx
index 5bd6ac264bf..a005c7257a5 100644
--- a/apps/sim/app/chat/components/message/message.tsx
+++ b/apps/sim/app/chat/components/message/message.tsx
@@ -1,15 +1,15 @@
'use client'
import { memo, useState } from 'react'
-import { Check, Copy, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
-import { Tooltip } from '@/components/emcn'
+import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
+import { Duplicate, Tooltip } from '@/components/emcn'
import {
ChatFileDownload,
ChatFileDownloadAll,
} from '@/app/chat/components/message/components/file-download'
import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer'
-interface ChatAttachment {
+export interface ChatAttachment {
id: string
name: string
type: string
@@ -38,6 +38,49 @@ export interface ChatMessage {
files?: ChatFile[]
}
+const HTML_ESCAPES: Record
= {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+} as const
+
+/**
+ * Escapes HTML entities so untrusted strings are safe to interpolate into markup.
+ */
+function escapeHtml(value: string): string {
+ return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] || c)
+}
+
+/**
+ * Opens an image attachment preview in a new tab via a blob URL,
+ * escaping the user-controlled filename and data URL to prevent XSS.
+ */
+function openAttachmentPreview(name: string, dataUrl: string): void {
+ const safeName = escapeHtml(name)
+ const safeUrl = escapeHtml(dataUrl)
+ const html = `
+
+
+
+ ${safeName}
+
+
+
+
+
+
+ `
+ const blob = new Blob([html], { type: 'text/html' })
+ const blobUrl = URL.createObjectURL(blob)
+ window.open(blobUrl, '_blank', 'noopener,noreferrer')
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
+}
+
export const ClientChatMessage = memo(
function ClientChatMessage({ message }: { message: ChatMessage }) {
const [isCopied, setIsCopied] = useState(false)
@@ -83,63 +126,58 @@ export const ClientChatMessage = memo(
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
}
+ const isInteractive =
+ !!attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
+
+ const openAttachmentPreview = () => {
+ const validDataUrl = attachment.dataUrl?.trim()
+ if (!validDataUrl?.startsWith('data:')) return
+ const newWindow = window.open('', '_blank')
+ if (newWindow) {
+ newWindow.document.write(`
+
+
+
+ ${attachment.name}
+
+
+
+
+
+
+ `)
+ newWindow.document.close()
+ }
+ }
+
return (
{
- const validDataUrl = attachment.dataUrl?.trim()
- if (validDataUrl?.startsWith('data:')) {
+ if (!isInteractive) return
+ e.preventDefault()
+ e.stopPropagation()
+ openAttachmentPreview()
+ }}
+ onKeyDown={(e) => {
+ if (!isInteractive) return
+ if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
- const newWindow = window.open('', '_blank')
- if (newWindow) {
- newWindow.document.write(`
-
-
-
-
${attachment.name}
-
-
-
-
-
-
- `)
- newWindow.document.close()
- }
- }
- }}
- onKeyDown={(event) => {
- const validDataUrl = attachment.dataUrl?.trim()
- if (!validDataUrl?.startsWith('data:')) return
- if (event.key === 'Enter' || event.key === ' ') {
- event.preventDefault()
- const newWindow = window.open('', '_blank')
- if (newWindow) {
- newWindow.document.write(`
-
-
${attachment.name}
-
-
-
-
- `)
- }
+ openAttachmentPreview()
}
}}
>
@@ -194,7 +232,7 @@ export const ClientChatMessage = memo(
) : (
-
+
{/* Direct content rendering - tool calls are now handled via SSE events */}
@@ -215,7 +253,7 @@ export const ClientChatMessage = memo(
)}
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
-
+
{/* Copy Button - Only show when not streaming */}
{!message.isStreaming && (
@@ -233,9 +271,9 @@ export const ClientChatMessage = memo(
}}
>
{isCopied ? (
-
+
) : (
-
+
)}
diff --git a/apps/sim/app/form/[identifier]/components/email-auth.tsx b/apps/sim/app/form/[identifier]/components/email-auth.tsx
deleted file mode 100644
index 7fdc78b5aff..00000000000
--- a/apps/sim/app/form/[identifier]/components/email-auth.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
-import { Input, InputOTP, InputOTPGroup, InputOTPSlot, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { quickValidateEmail } from '@/lib/messaging/email/validation'
-import AuthBackground from '@/app/(auth)/components/auth-background'
-import { AUTH_SUBMIT_BTN, AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
-import { SupportFooter } from '@/app/(auth)/components/support-footer'
-import Navbar from '@/app/(landing)/components/navbar/navbar'
-import { useFormEmailOtpRequest, useFormEmailOtpVerify } from '@/hooks/queries/forms'
-
-const logger = createLogger('FormEmailAuth')
-
-interface EmailAuthProps {
- identifier: string
- onAuthenticated: () => void
-}
-
-function validateEmailField(emailValue: string): string[] {
- const errors: string[] = []
-
- if (!emailValue || !emailValue.trim()) {
- errors.push('Email is required.')
- return errors
- }
-
- const validation = quickValidateEmail(emailValue.trim().toLowerCase())
- if (!validation.isValid) {
- errors.push(validation.reason || 'Please enter a valid email address.')
- }
-
- return errors
-}
-
-export function EmailAuth({ identifier, onAuthenticated }: EmailAuthProps) {
- const [email, setEmail] = useState('')
- const [authError, setAuthError] = useState(null)
- const [emailErrors, setEmailErrors] = useState([])
- const [showEmailValidationError, setShowEmailValidationError] = useState(false)
-
- const [showOtpVerification, setShowOtpVerification] = useState(false)
- const [otpValue, setOtpValue] = useState('')
- const [countdown, setCountdown] = useState(0)
-
- const requestOtp = useFormEmailOtpRequest(identifier)
- const verifyOtp = useFormEmailOtpVerify(identifier)
-
- useEffect(() => {
- if (countdown <= 0) return
- const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
- return () => clearTimeout(timer)
- }, [countdown])
-
- const handleEmailChange = (e: React.ChangeEvent) => {
- const newEmail = e.target.value
- setEmail(newEmail)
- const errors = validateEmailField(newEmail)
- setEmailErrors(errors)
- setShowEmailValidationError(false)
- }
-
- const handleSendOtp = async () => {
- const emailValidationErrors = validateEmailField(email)
- setEmailErrors(emailValidationErrors)
- setShowEmailValidationError(emailValidationErrors.length > 0)
-
- if (emailValidationErrors.length > 0) return
-
- setAuthError(null)
-
- try {
- await requestOtp.mutateAsync({ email })
- setShowOtpVerification(true)
- } catch (error) {
- logger.error('Error sending OTP:', error)
- setEmailErrors([toError(error).message || 'Failed to send verification code'])
- setShowEmailValidationError(true)
- }
- }
-
- const handleVerifyOtp = async (otp?: string) => {
- const codeToVerify = otp || otpValue
- if (!codeToVerify || codeToVerify.length !== 6) return
-
- setAuthError(null)
-
- try {
- await verifyOtp.mutateAsync({ email, otp: codeToVerify })
- onAuthenticated()
- } catch (error) {
- logger.error('Error verifying OTP:', error)
- setAuthError(toError(error).message || 'Invalid verification code')
- }
- }
-
- const handleResendOtp = async () => {
- setAuthError(null)
- setCountdown(30)
-
- try {
- await requestOtp.mutateAsync({ email })
- setOtpValue('')
- } catch (error) {
- logger.error('Error resending OTP:', error)
- setAuthError(toError(error).message || 'Failed to resend verification code')
- setCountdown(0)
- }
- }
-
- return (
-
-
-
-
-
-
-
-
- {showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
-
-
- {showOtpVerification
- ? `A verification code has been sent to ${email}`
- : 'This form requires email verification'}
-
-
-
-
- {!showOtpVerification ? (
-
{
- e.preventDefault()
- handleSendOtp()
- }}
- className='space-y-6'
- >
-
-
Email
-
0 &&
- 'border-[var(--text-error)] focus:border-[var(--text-error)]'
- )}
- />
- {showEmailValidationError && emailErrors.length > 0 && (
-
- {emailErrors.map((error) => (
-
{error}
- ))}
-
- )}
-
-
-
- {requestOtp.isPending ? (
-
-
- Sending Code…
-
- ) : (
- 'Continue'
- )}
-
-
- ) : (
-
-
- Enter the 6-digit code to verify your account. If you don't see it in your
- inbox, check your spam folder.
-
-
-
- {
- setOtpValue(value)
- if (value.length === 6) {
- handleVerifyOtp(value)
- }
- }}
- disabled={verifyOtp.isPending}
- className={cn('gap-2', authError && 'otp-error')}
- >
-
- {[0, 1, 2, 3, 4, 5].map((index) => (
-
- ))}
-
-
-
-
- {authError && (
-
- )}
-
-
handleVerifyOtp()}
- disabled={otpValue.length !== 6 || verifyOtp.isPending}
- className={AUTH_SUBMIT_BTN}
- >
- {verifyOtp.isPending ? (
-
-
- Verifying…
-
- ) : (
- 'Verify Email'
- )}
-
-
-
-
- Didn't receive a code?{' '}
- {countdown > 0 ? (
-
- Resend in{' '}
-
- {countdown}s
-
-
- ) : (
-
- Resend
-
- )}
-
-
-
-
- {
- setShowOtpVerification(false)
- setOtpValue('')
- setAuthError(null)
- }}
- className={AUTH_TEXT_LINK}
- >
- Change email
-
-
-
- )}
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/components/error-state.tsx b/apps/sim/app/form/[identifier]/components/error-state.tsx
deleted file mode 100644
index 043026806d3..00000000000
--- a/apps/sim/app/form/[identifier]/components/error-state.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-'use client'
-
-import { useRouter } from 'next/navigation'
-import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
-import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
-
-interface FormErrorStateProps {
- error: string
-}
-
-export function FormErrorState({ error }: FormErrorStateProps) {
- const router = useRouter()
-
- return (
-
- router.push('/workspace')} className={AUTH_SUBMIT_BTN}>
- Return to Workspace
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/components/form-field.tsx b/apps/sim/app/form/[identifier]/components/form-field.tsx
deleted file mode 100644
index 71928b751cf..00000000000
--- a/apps/sim/app/form/[identifier]/components/form-field.tsx
+++ /dev/null
@@ -1,229 +0,0 @@
-'use client'
-
-import { useCallback, useRef, useState } from 'react'
-import { Upload, X } from 'lucide-react'
-import { Input, Label, Switch, Textarea } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
-
-interface InputField {
- name: string
- type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
- description?: string
- value?: unknown
- required?: boolean
-}
-
-interface FormFieldProps {
- field: InputField
- value: unknown
- onChange: (value: unknown) => void
- primaryColor?: string
- label?: string
- description?: string
- required?: boolean
-}
-
-function formatFileSize(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
-}
-
-export function FormField({
- field,
- value,
- onChange,
- primaryColor,
- label,
- description,
- required,
-}: FormFieldProps) {
- const [isDragging, setIsDragging] = useState(false)
- const fileInputRef = useRef(null)
-
- const formatLabel = (name: string) => {
- return name
- .replace(/([A-Z])/g, ' $1')
- .replace(/_/g, ' ')
- .replace(/^./, (str) => str.toUpperCase())
- .trim()
- }
-
- const displayLabel = label || formatLabel(field.name)
- const placeholder = description || field.description || ''
- const isRequired = required ?? field.required
-
- const handleFileDrop = useCallback(
- (e: React.DragEvent) => {
- e.preventDefault()
- setIsDragging(false)
- const files = Array.from(e.dataTransfer.files)
- if (files.length > 0) {
- onChange(files)
- }
- },
- [onChange]
- )
-
- const handleFileChange = useCallback(
- (e: React.ChangeEvent) => {
- const files = Array.from(e.target.files || [])
- if (files.length > 0) {
- onChange(files)
- }
- },
- [onChange]
- )
-
- const removeFile = useCallback(
- (index: number) => {
- if (Array.isArray(value)) {
- const newFiles = value.filter((_, i) => i !== index)
- onChange(newFiles.length > 0 ? newFiles : undefined)
- }
- },
- [value, onChange]
- )
-
- const renderInput = () => {
- switch (field.type) {
- case 'boolean':
- return (
-
-
- {value ? 'Yes' : 'No'}
-
- )
-
- case 'number':
- return (
- {
- const val = e.target.value
- onChange(val === '' ? '' : Number(val))
- }}
- placeholder={placeholder || 'Enter a number'}
- className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
- />
- )
-
- case 'object':
- case 'array':
- return (
- ) => onChange(e.target.value)}
- placeholder={
- placeholder || (field.type === 'array' ? '["item1", "item2"]' : '{"key": "value"}')
- }
- className='min-h-[100px] rounded-[10px] font-mono text-small shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
- />
- )
-
- case 'files': {
- const files = Array.isArray(value) ? (value as File[]) : []
- return (
-
-
{
- e.preventDefault()
- setIsDragging(true)
- }}
- onDragLeave={() => setIsDragging(false)}
- onDrop={handleFileDrop}
- onClick={() => fileInputRef.current?.click()}
- onKeyDown={(event) =>
- handleKeyboardActivation(event, () => fileInputRef.current?.click())
- }
- className={cn(
- 'flex cursor-pointer flex-col items-center justify-center rounded-[10px] border-2 border-dashed px-6 py-8 transition-colors',
- isDragging
- ? 'border-[var(--brand)] bg-[color-mix(in_srgb,var(--brand)_5%,transparent)]'
- : 'border-border hover:border-muted-foreground/50'
- )}
- >
-
-
-
-
- Click to upload
- {' '}
- or drag and drop
-
-
-
- {files.length > 0 && (
-
- {files.map((file, idx) => (
-
-
-
- {file.name}
-
-
- {formatFileSize(file.size)}
-
-
-
{
- e.stopPropagation()
- removeFile(idx)
- }}
- className='ml-2 rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground'
- >
-
-
-
- ))}
-
- )}
-
- )
- }
-
- default:
- return (
- onChange(e.target.value)}
- placeholder={placeholder || 'Enter text'}
- className='rounded-[10px] shadow-sm transition-colors focus:border-gray-400 focus:ring-2 focus:ring-gray-100'
- />
- )
- }
- }
-
- return (
-
-
- {displayLabel}
- {isRequired && * }
-
- {renderInput()}
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/components/index.ts b/apps/sim/app/form/[identifier]/components/index.ts
deleted file mode 100644
index e888196967c..00000000000
--- a/apps/sim/app/form/[identifier]/components/index.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export { EmailAuth } from './email-auth'
-export { FormErrorState } from './error-state'
-export { FormField } from './form-field'
-export { FormLoadingState } from './loading-state'
-export { PasswordAuth } from './password-auth'
-export { PoweredBySim } from './powered-by-sim'
-export { ThankYouScreen } from './thank-you-screen'
diff --git a/apps/sim/app/form/[identifier]/components/loading-state.tsx b/apps/sim/app/form/[identifier]/components/loading-state.tsx
deleted file mode 100644
index a77bbab23d0..00000000000
--- a/apps/sim/app/form/[identifier]/components/loading-state.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { Skeleton } from '@/components/emcn'
-import AuthBackground from '@/app/(auth)/components/auth-background'
-import Navbar from '@/app/(landing)/components/navbar/navbar'
-
-export function FormLoadingState() {
- return (
-
-
-
-
-
-
- {/* Title skeleton */}
-
-
-
-
-
- {/* Form skeleton */}
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/components/password-auth.tsx b/apps/sim/app/form/[identifier]/components/password-auth.tsx
deleted file mode 100644
index bc2e94ba437..00000000000
--- a/apps/sim/app/form/[identifier]/components/password-auth.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-'use client'
-
-import { useState } from 'react'
-import { Eye, EyeOff } from 'lucide-react'
-import { Input, Label, Loader } from '@/components/emcn'
-import { cn } from '@/lib/core/utils/cn'
-import AuthBackground from '@/app/(auth)/components/auth-background'
-import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
-import { SupportFooter } from '@/app/(auth)/components/support-footer'
-import Navbar from '@/app/(landing)/components/navbar/navbar'
-
-interface PasswordAuthProps {
- onSubmit: (password: string) => void
- error?: string | null
-}
-
-export function PasswordAuth({ onSubmit, error }: PasswordAuthProps) {
- const [password, setPassword] = useState('')
- const [showPassword, setShowPassword] = useState(false)
- const [isSubmitting, setIsSubmitting] = useState(false)
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault()
- if (!password.trim()) return
-
- setIsSubmitting(true)
- try {
- await onSubmit(password)
- } finally {
- setIsSubmitting(false)
- }
- }
-
- return (
-
-
-
-
-
-
-
-
- Password Required
-
-
- Enter the password to access this form.
-
-
-
-
-
-
Password
-
- setPassword(e.target.value)}
- placeholder='Enter password'
- className={cn(error && 'border-red-500 focus:border-red-500')}
- />
- setShowPassword(!showPassword)}
- className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--landing-text-muted)] hover:text-[var(--landing-text)]'
- >
- {showPassword ? : }
-
-
- {error &&
{error}
}
-
-
-
- {isSubmitting ? (
-
-
- Verifying…
-
- ) : (
- 'Continue'
- )}
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/components/powered-by-sim.tsx b/apps/sim/app/form/[identifier]/components/powered-by-sim.tsx
deleted file mode 100644
index 3ce11b36889..00000000000
--- a/apps/sim/app/form/[identifier]/components/powered-by-sim.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-'use client'
-
-import Image from 'next/image'
-import { useBrandConfig } from '@/ee/whitelabeling'
-
-export function PoweredBySim() {
- const brandConfig = useBrandConfig()
-
- return (
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/components/thank-you-screen.tsx b/apps/sim/app/form/[identifier]/components/thank-you-screen.tsx
deleted file mode 100644
index 5f67a84f212..00000000000
--- a/apps/sim/app/form/[identifier]/components/thank-you-screen.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { CheckCircle2 } from 'lucide-react'
-
-interface ThankYouScreenProps {
- title: string
- message: string
- primaryColor?: string
-}
-
-/** Default green color matching --brand-accent */
-const DEFAULT_THANK_YOU_COLOR = '#33C482'
-
-/** Legacy blue default that should be treated as "no custom color" */
-const LEGACY_BLUE_DEFAULT = '#3972F6'
-
-export function ThankYouScreen({ title, message, primaryColor }: ThankYouScreenProps) {
- // Treat legacy blue default as no custom color, fall back to green
- const thankYouColor =
- primaryColor && primaryColor !== LEGACY_BLUE_DEFAULT ? primaryColor : DEFAULT_THANK_YOU_COLOR
-
- return (
-
-
-
-
-
-
- {title}
-
-
- {message}
-
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/error.tsx b/apps/sim/app/form/[identifier]/error.tsx
deleted file mode 100644
index b8ae73653da..00000000000
--- a/apps/sim/app/form/[identifier]/error.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-'use client'
-
-import { useEffect } from 'react'
-import { createLogger } from '@sim/logger'
-import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
-import { StatusPageLayout } from '@/app/(auth)/components/status-page-layout'
-
-const logger = createLogger('FormError')
-
-interface FormErrorProps {
- error: Error & { digest?: string }
- reset: () => void
-}
-
-export default function FormError({ error, reset }: FormErrorProps) {
- useEffect(() => {
- logger.error('Form page error:', { error: error.message, digest: error.digest })
- }, [error])
-
- return (
-
-
- Try again
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/form.tsx b/apps/sim/app/form/[identifier]/form.tsx
deleted file mode 100644
index 4e809096b55..00000000000
--- a/apps/sim/app/form/[identifier]/form.tsx
+++ /dev/null
@@ -1,357 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { getErrorMessage } from '@sim/utils/errors'
-import { Loader } from '@/components/emcn'
-import { martianMono } from '@/app/_styles/fonts/martian-mono/martian-mono'
-import AuthBackground from '@/app/(auth)/components/auth-background'
-import { AUTH_SUBMIT_BTN } from '@/app/(auth)/components/auth-button-classes'
-import { SupportFooter } from '@/app/(auth)/components/support-footer'
-import Navbar from '@/app/(landing)/components/navbar/navbar'
-import {
- EmailAuth,
- FormErrorState,
- FormField,
- FormLoadingState,
- PasswordAuth,
- PoweredBySim,
- ThankYouScreen,
-} from '@/app/form/[identifier]/components'
-
-const logger = createLogger('Form')
-
-interface FieldConfig {
- name: string
- type: string
- label: string
- description?: string
- required?: boolean
-}
-
-interface FormConfig {
- id: string
- title: string
- description?: string
- customizations: {
- primaryColor?: string
- thankYouMessage?: string
- logoUrl?: string
- fieldConfigs?: FieldConfig[]
- }
- authType?: 'public' | 'password' | 'email'
- showBranding?: boolean
- inputSchema?: InputField[]
-}
-
-interface InputField {
- name: string
- type?: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'files'
- description?: string
- value?: unknown
- required?: boolean
-}
-
-export default function Form({ identifier }: { identifier: string }) {
- const [formConfig, setFormConfig] = useState(null)
- const [formData, setFormData] = useState>({})
- const [isLoading, setIsLoading] = useState(true)
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isSubmitted, setIsSubmitted] = useState(false)
- const [error, setError] = useState(null)
- const [authRequired, setAuthRequired] = useState<'password' | 'email' | null>(null)
- const [thankYouData, setThankYouData] = useState<{
- title: string
- message: string
- } | null>(null)
-
- const abortControllerRef = useRef(null)
-
- const fetchFormConfig = useCallback(
- async (signal?: AbortSignal) => {
- try {
- setIsLoading(true)
- setError(null)
-
- // boundary-raw-fetch: GET /api/form/[identifier] is a polymorphic form-discovery endpoint that returns either a form config OR a 401 envelope carrying `error: 'auth_required_password' | 'auth_required_email'` plus partial title/customizations for the auth gate; modelling this as a contract requires a discriminated response schema and a custom error path that surfaces 401-as-data without throwing
- const response = await fetch(`/api/form/${identifier}`, { signal })
- if (signal?.aborted) return
-
- const data = await response.json()
-
- if (!response.ok) {
- if (response.status === 401) {
- const authError = data.error
- if (authError === 'auth_required_password') {
- setAuthRequired('password')
- setFormConfig({
- id: '',
- title: data.title || 'Form',
- customizations: data.customizations || {},
- })
- return
- }
- if (authError === 'auth_required_email') {
- setAuthRequired('email')
- setFormConfig({
- id: '',
- title: data.title || 'Form',
- customizations: data.customizations || {},
- })
- return
- }
- }
- throw new Error(data.error || 'Failed to load form')
- }
-
- setFormConfig(data)
- setAuthRequired(null)
-
- // Initialize form data from input schema
- const fields = data.inputSchema || []
- if (fields.length > 0) {
- const initialData: Record = {}
- for (const field of fields) {
- if (field.value !== undefined) {
- initialData[field.name] = field.value
- } else {
- switch (field.type) {
- case 'boolean':
- initialData[field.name] = false
- break
- case 'number':
- initialData[field.name] = ''
- break
- case 'array':
- case 'files':
- initialData[field.name] = []
- break
- case 'object':
- initialData[field.name] = {}
- break
- default:
- initialData[field.name] = ''
- }
- }
- }
- setFormData(initialData)
- }
- } catch (err: unknown) {
- if (err instanceof Error && err.name === 'AbortError') return
- logger.error('Error fetching form config:', err)
- setError(getErrorMessage(err, 'Failed to load form'))
- } finally {
- setIsLoading(false)
- }
- },
- [identifier]
- )
-
- useEffect(() => {
- abortControllerRef.current?.abort()
- const controller = new AbortController()
- abortControllerRef.current = controller
- fetchFormConfig(controller.signal)
- return () => controller.abort()
- }, [fetchFormConfig])
-
- const handleFieldChange = useCallback((fieldName: string, value: unknown) => {
- setFormData((prev) => ({ ...prev, [fieldName]: value }))
- }, [])
-
- const handleSubmit = useCallback(
- async (e: React.FormEvent) => {
- e.preventDefault()
- if (!formConfig) return
-
- try {
- setIsSubmitting(true)
- setError(null)
-
- // boundary-raw-fetch: POST /api/form/[identifier] is the public form submission endpoint; the same route also accepts `{ password }` or `{ email }` auth gate bodies (handled by fetchFormConfig/handlePasswordAuth) and runs workflow execution with CORS headers/streaming envelopes that don't fit the current `requestJson` contract surface
- const response = await fetch(`/api/form/${identifier}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ formData }),
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- throw new Error(data.error || 'Failed to submit form')
- }
-
- setThankYouData({
- title: data.thankYouTitle || 'Thank you!',
- message:
- data.thankYouMessage ||
- formConfig.customizations.thankYouMessage ||
- 'Your response has been submitted successfully.',
- })
- setIsSubmitted(true)
- } catch (err: unknown) {
- logger.error('Error submitting form:', err)
- setError(getErrorMessage(err, 'Failed to submit form'))
- } finally {
- setIsSubmitting(false)
- }
- },
- [identifier, formConfig, formData]
- )
-
- const handlePasswordAuth = useCallback(
- async (password: string) => {
- try {
- setIsLoading(true)
- setError(null)
-
- // boundary-raw-fetch: POST /api/form/[identifier] doubles as the password auth gate; same polymorphic route as the form submission above (separate `{ password }` body branch on the server)
- const response = await fetch(`/api/form/${identifier}`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ password }),
- })
-
- const data = await response.json()
-
- if (!response.ok) {
- throw new Error(data.error || 'Invalid password')
- }
-
- await fetchFormConfig()
- } catch (err: unknown) {
- logger.error('Error authenticating:', err)
- setError(getErrorMessage(err, 'Invalid password'))
- setIsLoading(false)
- }
- },
- [identifier, fetchFormConfig]
- )
-
- const primaryColor = formConfig?.customizations?.primaryColor || 'var(--brand)'
-
- if (isLoading && !authRequired) {
- return
- }
-
- if (error && !authRequired) {
- return
- }
-
- if (authRequired === 'password') {
- return
- }
-
- if (authRequired === 'email') {
- return fetchFormConfig()} />
- }
-
- if (isSubmitted && thankYouData) {
- return (
-
-
-
-
-
-
- {formConfig?.showBranding !== false ? (
-
- ) : (
-
- )}
-
-
- )
- }
-
- if (!formConfig) {
- return
- }
-
- // Get fields from input schema
- const fields = formConfig.inputSchema || []
-
- // Create a map of field configs for quick lookup
- const fieldConfigMap = new Map(
- (formConfig.customizations?.fieldConfigs || []).map((fc) => [fc.name, fc])
- )
-
- return (
-
-
-
-
-
- {/* Form title */}
-
-
- {formConfig.title}
-
- {formConfig.description && (
-
- {formConfig.description}
-
- )}
-
-
-
- {fields.length === 0 ? (
-
- This form has no fields configured.
-
- ) : (
- fields.map((field) => {
- const config = fieldConfigMap.get(field.name)
- return (
- handleFieldChange(field.name, value)}
- primaryColor={primaryColor}
- label={config?.label}
- description={config?.description}
- required={config?.required}
- />
- )
- })
- )}
-
- {error && (
-
- {error}
-
- )}
-
- {fields.length > 0 && (
-
- {isSubmitting ? (
-
-
- Submitting…
-
- ) : (
- 'Submit'
- )}
-
- )}
-
-
-
- {formConfig.showBranding !== false ? (
-
- ) : (
-
- )}
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/loading.tsx b/apps/sim/app/form/[identifier]/loading.tsx
deleted file mode 100644
index 6c8fd36d0ee..00000000000
--- a/apps/sim/app/form/[identifier]/loading.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Skeleton } from '@/components/emcn'
-
-export default function FormLoading() {
- return (
-
-
-
- )
-}
diff --git a/apps/sim/app/form/[identifier]/page.tsx b/apps/sim/app/form/[identifier]/page.tsx
deleted file mode 100644
index 10f5a6e7fce..00000000000
--- a/apps/sim/app/form/[identifier]/page.tsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import type { Metadata } from 'next'
-import Form from '@/app/form/[identifier]/form'
-
-export const metadata: Metadata = {
- title: 'Form',
-}
-
-export default async function FormPage({ params }: { params: Promise<{ identifier: string }> }) {
- const { identifier } = await params
- return
-}
diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx
index 2cb517709ea..b84e1f50fcb 100644
--- a/apps/sim/app/invite/[id]/invite.tsx
+++ b/apps/sim/app/invite/[id]/invite.tsx
@@ -29,6 +29,7 @@ type InviteErrorCode =
| 'already-member'
| 'already-in-organization'
| 'no-seats-available'
+ | 'upgrade-required'
| 'invalid-invitation'
| 'missing-invitation-id'
| 'server-error'
@@ -89,7 +90,14 @@ function getInviteError(code: string): InviteError {
'no-seats-available': {
code: 'no-seats-available',
message:
- 'This organization has no available seats right now. Ask an admin to add seats or retry after capacity changes.',
+ 'This organization has reached its seat limit. Ask an admin to contact support to add seats, then try again.',
+ canRetry: true,
+ },
+ 'upgrade-required': {
+ code: 'upgrade-required',
+ message:
+ 'The workspace owner needs an active paid plan with billing set up before you can join. Ask them to update their plan, then try again.',
+ canRetry: true,
},
'invalid-invitation': {
code: 'invalid-invitation',
diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx
index 326a18a6f00..8c20a3e8b41 100644
--- a/apps/sim/app/layout.tsx
+++ b/apps/sim/app/layout.tsx
@@ -73,8 +73,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return;
}
- // Sidebar width
- var defaultSidebarWidth = '248px';
+ // Sidebar width. Mirror clampSidebarWidth() in stores/sidebar/store.ts:
+ // the upper bound can never fall below the 248px minimum, so a narrow
+ // window yields a width >= MIN instead of a sub-minimum sliver.
+ var defaultSidebarWidth = 248;
try {
var stored = localStorage.getItem('sidebar-state');
if (stored) {
@@ -87,21 +89,18 @@ export default function RootLayout({ children }: { children: React.ReactNode })
document.documentElement.setAttribute('data-sidebar-collapsed', '');
} else {
var width = state && state.sidebarWidth;
- var maxSidebarWidth = window.innerWidth * 0.3;
-
- if (width >= 248 && width <= maxSidebarWidth) {
- document.documentElement.style.setProperty('--sidebar-width', width + 'px');
- } else if (width > maxSidebarWidth) {
- document.documentElement.style.setProperty('--sidebar-width', maxSidebarWidth + 'px');
- } else {
- document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
- }
+ var maxSidebarWidth = Math.max(248, window.innerWidth * 0.3);
+ var finalWidth =
+ typeof width === 'number' && isFinite(width)
+ ? Math.min(Math.max(width, 248), maxSidebarWidth)
+ : defaultSidebarWidth;
+ document.documentElement.style.setProperty('--sidebar-width', finalWidth + 'px');
}
} else {
- document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
+ document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');
}
} catch (e) {
- document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth);
+ document.documentElement.style.setProperty('--sidebar-width', defaultSidebarWidth + 'px');
}
// Panel width and active tab
@@ -128,28 +127,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
// Fallback handled by CSS defaults
}
- // Toolbar triggers height
- try {
- var toolbarStored = localStorage.getItem('toolbar-state');
- if (toolbarStored) {
- var toolbarParsed = JSON.parse(toolbarStored);
- var toolbarState = toolbarParsed && toolbarParsed.state;
- var toolbarTriggersHeight = toolbarState && toolbarState.toolbarTriggersHeight;
- if (
- toolbarTriggersHeight !== undefined &&
- toolbarTriggersHeight >= 30 &&
- toolbarTriggersHeight <= 800
- ) {
- document.documentElement.style.setProperty(
- '--toolbar-triggers-height',
- toolbarTriggersHeight + 'px'
- );
- }
- }
- } catch (e) {
- // Fallback handled by CSS defaults
- }
-
// Editor connections height
try {
var editorStored = localStorage.getItem('panel-editor-state');
diff --git a/apps/sim/app/playground/page.tsx b/apps/sim/app/playground/page.tsx
index 716bb0e85d8..2b426c677cd 100644
--- a/apps/sim/app/playground/page.tsx
+++ b/apps/sim/app/playground/page.tsx
@@ -20,7 +20,6 @@ import {
Code,
Combobox,
Connections,
- Copy,
Cursor,
DatePicker,
DocumentAttachment,
@@ -86,7 +85,6 @@ import {
TableHead,
TableHeader,
TableRow,
- Tag,
TagInput,
type TagItem,
Textarea,
@@ -382,16 +380,6 @@ export default function PlaygroundPage() {
/>
-
-
-
-
-
-
- {}} />
- {}} />
- {}} />
-
{}}
placeholder='Add tags'
placeholderWithTags='Add another'
- tagVariant='secondary'
triggerKeys={['Enter', ',']}
/>
@@ -1007,7 +994,6 @@ export default function PlaygroundPage() {
{ Icon: CardIcon, name: 'Card' },
{ Icon: ChevronDown, name: 'ChevronDown' },
{ Icon: Connections, name: 'Connections' },
- { Icon: Copy, name: 'Copy' },
{ Icon: Cursor, name: 'Cursor' },
{ Icon: DocumentAttachment, name: 'DocumentAttachment' },
{ Icon: Download, name: 'Download' },
diff --git a/apps/sim/app/robots.ts b/apps/sim/app/robots.ts
index 7cc090c9b97..8cf6bf9a2e4 100644
--- a/apps/sim/app/robots.ts
+++ b/apps/sim/app/robots.ts
@@ -10,14 +10,13 @@ const DISALLOWED_PATHS = [
'/invite/',
'/unsubscribe/',
'/w/',
- '/form/',
'/credential-account/',
'/_next/',
'/private/',
'/blog*tag=',
]
-/** Looser disallow than the wildcard so OG previews can fetch /chat/ and /form/. */
+/** Looser disallow than the wildcard so OG previews can fetch /chat/. */
const LINK_PREVIEW_DISALLOWED_PATHS = [
'/api/',
'/workspace/',
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index 8107f3010c0..9920019a0f2 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -2,9 +2,17 @@ import type { MetadataRoute } from 'next'
import { COURSES } from '@/lib/academy/content'
import { getAllPostMeta } from '@/lib/blog/registry'
import { SITE_URL } from '@/lib/core/utils/urls'
-import integrations from '@/app/(landing)/integrations/data/integrations.json'
+import { INTEGRATIONS } from '@/lib/integrations'
import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
+/**
+ * Generate the public sitemap by composing static landing pages with the
+ * dynamic catalogs (blog posts, authors, integrations, model providers,
+ * individual models, and academy courses). Per-integration entries are
+ * emitted under `/integrations/{slug}` to match the landing route at
+ * `app/(landing)/integrations/(shell)/[slug]`; slugs are guaranteed unique
+ * by the catalog generator in `scripts/generate-docs.ts`.
+ */
export default async function sitemap(): Promise {
const baseUrl = SITE_URL
const posts = await getAllPostMeta()
@@ -79,7 +87,7 @@ export default async function sitemap(): Promise {
lastModified: date,
}))
- const integrationPages: MetadataRoute.Sitemap = integrations.map((integration) => ({
+ const integrationPages: MetadataRoute.Sitemap = INTEGRATIONS.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
}))
diff --git a/apps/sim/app/templates/[id]/layout.tsx b/apps/sim/app/templates/[id]/layout.tsx
deleted file mode 100644
index b815db12d7e..00000000000
--- a/apps/sim/app/templates/[id]/layout.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-// import { db } from '@sim/db'
-// import { permissions, workspace } from '@sim/db/schema'
-// import { and, desc, eq } from 'drizzle-orm'
-// import { redirect } from 'next/navigation'
-// import { getSession } from '@/lib/auth'
-
-// export const dynamic = 'force-dynamic'
-// export const revalidate = 0
-
-interface TemplateLayoutProps {
- children: React.ReactNode
-}
-
-/**
- * Template detail layout (public scope) — currently disabled.
- * Previously redirected authenticated users to the workspace-scoped template detail.
- */
-export default function TemplateDetailLayout({ children }: TemplateLayoutProps) {
- return children
-}
diff --git a/apps/sim/app/templates/[id]/loading.tsx b/apps/sim/app/templates/[id]/loading.tsx
deleted file mode 100644
index bcfda0aadd8..00000000000
--- a/apps/sim/app/templates/[id]/loading.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-import { Skeleton } from '@/components/emcn'
-
-export default function TemplateDetailLoading() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
diff --git a/apps/sim/app/templates/[id]/page.tsx b/apps/sim/app/templates/[id]/page.tsx
deleted file mode 100644
index 0c2557e4ad7..00000000000
--- a/apps/sim/app/templates/[id]/page.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { notFound } from 'next/navigation'
-
-// import { db } from '@sim/db'
-// import { templateCreators, templates } from '@sim/db/schema'
-// import { createLogger } from '@sim/logger'
-// import { eq } from 'drizzle-orm'
-// import type { Metadata } from 'next'
-// import { getBaseUrl } from '@/lib/core/utils/urls'
-// import TemplateDetails from '@/app/templates/[id]/template'
-
-// const logger = createLogger('TemplateMetadata')
-
-// /**
-// * Generate dynamic metadata for template pages.
-// * This provides OpenGraph images for social media sharing.
-// */
-// export async function generateMetadata({
-// params,
-// }: {
-// params: Promise<{ id: string }>
-// }): Promise {
-// const { id } = await params
-//
-// try {
-// const result = await db
-// .select({
-// template: templates,
-// creator: templateCreators,
-// })
-// .from(templates)
-// .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
-// .where(eq(templates.id, id))
-// .limit(1)
-//
-// if (result.length === 0) {
-// return {
-// title: 'Template Not Found',
-// description: 'The requested template could not be found.',
-// }
-// }
-//
-// const { template, creator } = result[0]
-// const baseUrl = getBaseUrl()
-//
-// const details = template.details as { tagline?: string; about?: string } | null
-// const description = details?.tagline || 'AI workflow template on Sim'
-//
-// const hasOgImage = !!template.ogImageUrl
-// const ogImageUrl = template.ogImageUrl || `${baseUrl}/logo/primary/rounded.png`
-//
-// return {
-// title: template.name,
-// description,
-// openGraph: {
-// title: template.name,
-// description,
-// type: 'website',
-// url: `${baseUrl}/templates/${id}`,
-// siteName: 'Sim',
-// images: [
-// {
-// url: ogImageUrl,
-// width: hasOgImage ? 1200 : 512,
-// height: hasOgImage ? 630 : 512,
-// alt: `${template.name} - Workflow Preview`,
-// },
-// ],
-// },
-// twitter: {
-// card: hasOgImage ? 'summary_large_image' : 'summary',
-// title: template.name,
-// description,
-// images: [ogImageUrl],
-// creator: creator?.details
-// ? ((creator.details as Record).xHandle as string) || undefined
-// : undefined,
-// },
-// }
-// } catch (error) {
-// logger.error('Failed to generate template metadata:', error)
-// return {
-// title: 'Template',
-// description: 'AI workflow template on Sim',
-// }
-// }
-// }
-
-/**
- * Public template detail page — currently disabled, returns 404.
- */
-export default function TemplatePage() {
- notFound()
-}
diff --git a/apps/sim/app/templates/[id]/template.tsx b/apps/sim/app/templates/[id]/template.tsx
deleted file mode 100644
index fe8d435e83b..00000000000
--- a/apps/sim/app/templates/[id]/template.tsx
+++ /dev/null
@@ -1,1065 +0,0 @@
-'use client'
-
-import { useEffect, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { formatDistanceToNow } from 'date-fns'
-import {
- ChartNoAxesColumn,
- ChevronDown,
- Globe,
- Linkedin,
- Mail,
- Share2,
- Star,
- User,
-} from 'lucide-react'
-import { useParams, useRouter, useSearchParams } from 'next/navigation'
-import { Streamdown } from 'streamdown'
-import 'streamdown/styles.css'
-import {
- Breadcrumb,
- Button,
- Copy,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
- Popover,
- PopoverContent,
- PopoverItem,
- PopoverTrigger,
- Skeleton,
-} from '@/components/emcn'
-import { VerifiedBadge } from '@/components/ui/verified-badge'
-import { requestJson } from '@/lib/api/client/request'
-import {
- listCreatorOrganizationsContract,
- updateCreatorProfileContract,
-} from '@/lib/api/contracts/creator-profile'
-import { updateTemplateContract, useTemplateContract } from '@/lib/api/contracts/templates'
-import { listWorkspacesContract } from '@/lib/api/contracts/workspaces'
-import { useSession } from '@/lib/auth/auth-client'
-import { cn } from '@/lib/core/utils/cn'
-import { getBaseUrl } from '@/lib/core/utils/urls'
-import type { CredentialRequirement } from '@/lib/workflows/credentials/credential-extractor'
-import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview'
-import { getBlock } from '@/blocks/registry'
-import { useStarTemplate, useTemplate } from '@/hooks/queries/templates'
-
-const logger = createLogger('TemplateDetails')
-
-interface TemplateDetailsLoadingProps {
- isWorkspaceContext?: boolean
- workspaceId?: string | null
-}
-
-function TemplateDetailsLoading({ isWorkspaceContext, workspaceId }: TemplateDetailsLoadingProps) {
- const breadcrumbItems = [
- {
- label: 'Templates',
- href:
- isWorkspaceContext && workspaceId ? `/workspace/${workspaceId}/templates` : '/templates',
- },
- { label: 'Template' },
- ]
-
- return (
-
-
-
- {/* Breadcrumb navigation */}
-
-
- {/* Template name and action buttons */}
-
-
- {/* Template tagline */}
-
-
-
-
- {/* Creator and stats row */}
-
- {/* Star icon and count */}
-
-
-
- {/* Views icon and count */}
-
-
-
- {/* Vertical divider */}
-
-
- {/* Creator profile pic */}
-
- {/* Creator name */}
-
-
-
- {/* Credentials needed */}
-
-
-
-
- {/* Canvas preview */}
-
-
-
-
- {/* About this Workflow */}
-
-
-
-
- )
-}
-
-interface TemplateDetailsProps {
- isWorkspaceContext?: boolean
-}
-
-export default function TemplateDetails({ isWorkspaceContext = false }: TemplateDetailsProps) {
- const router = useRouter()
- const searchParams = useSearchParams()
- const params = useParams()
- const templateId = params?.id as string
- const workspaceId = isWorkspaceContext ? (params?.workspaceId as string) : null
- const { data: session } = useSession()
-
- const { data: template, isLoading: loading } = useTemplate(templateId)
- const starTemplate = useStarTemplate()
-
- const [currentUserOrgs, setCurrentUserOrgs] = useState([])
- const [currentUserOrgRoles, setCurrentUserOrgRoles] = useState<
- Array<{ organizationId: string; role: string }>
- >([])
- const isSuperUser = session?.user?.role === 'admin'
- const [isUsing, setIsUsing] = useState(false)
- const [isEditing, setIsEditing] = useState(false)
- const [isApproving, setIsApproving] = useState(false)
- const [isRejecting, setIsRejecting] = useState(false)
- const [isVerifying, setIsVerifying] = useState(false)
- const [hasWorkspaceAccess, setHasWorkspaceAccess] = useState(null)
- const [workspaces, setWorkspaces] = useState<
- Array<{ id: string; name: string; permissions: string }>
- >([])
- const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
- const [showWorkspaceSelectorForEdit, setShowWorkspaceSelectorForEdit] = useState(false)
- const [sharePopoverOpen, setSharePopoverOpen] = useState(false)
-
- const currentUserId = session?.user?.id || null
-
- useEffect(() => {
- const fetchUserOrganizations = async () => {
- if (!currentUserId) return
-
- try {
- const data = await requestJson(listCreatorOrganizationsContract, {})
- const orgs = data.organizations
- setCurrentUserOrgs(orgs.map((org) => org.id))
- setCurrentUserOrgRoles(
- orgs.map((org) => ({
- organizationId: org.id,
- role: org.role,
- }))
- )
- } catch (error) {
- logger.error('Error fetching organizations:', error)
- }
- }
-
- fetchUserOrganizations()
- }, [currentUserId])
-
- useEffect(() => {
- if (!currentUserId) return
-
- const fetchWorkspaces = async () => {
- try {
- setIsLoadingWorkspaces(true)
- const data = await requestJson(listWorkspacesContract, { query: { scope: 'active' } })
- const availableWorkspaces = data.workspaces.flatMap((ws) =>
- ws.permissions === 'write' || ws.permissions === 'admin'
- ? [{ id: ws.id, name: ws.name, permissions: ws.permissions }]
- : []
- )
- setWorkspaces(availableWorkspaces)
- } catch (error) {
- logger.error('Error fetching workspaces:', error)
- } finally {
- setIsLoadingWorkspaces(false)
- }
- }
-
- fetchWorkspaces()
- }, [currentUserId])
-
- useEffect(() => {
- if (template && searchParams?.get('use') === 'true' && currentUserId) {
- if (isWorkspaceContext && workspaceId) {
- handleWorkspaceSelectForUse(workspaceId)
- router.replace(`/workspace/${workspaceId}/templates/${template.id}`)
- } else {
- router.replace(`/templates/${template.id}`)
- }
- }
- }, [searchParams, currentUserId, template, isWorkspaceContext, workspaceId, router])
-
- const canEditTemplate = (() => {
- if (!currentUserId || !template?.creator) return false
-
- if (template.creator.referenceType === 'user') {
- return template.creator.referenceId === currentUserId
- }
-
- if (template.creator.referenceType === 'organization' && template.creator.referenceId) {
- const isOrgMember = currentUserOrgs.includes(template.creator.referenceId)
-
- if (template.workflowId) {
- return isOrgMember
- }
-
- const orgMembership = currentUserOrgRoles.find(
- (org) => org.organizationId === template.creator?.referenceId
- )
- const isAdminOrOwner = orgMembership?.role === 'admin' || orgMembership?.role === 'owner'
-
- return isOrgMember && isAdminOrOwner
- }
-
- return false
- })()
-
- useEffect(() => {
- const checkWorkspaceAccess = async () => {
- if (!template?.workflowId || !currentUserId || !canEditTemplate) {
- setHasWorkspaceAccess(null)
- return
- }
-
- try {
- // boundary-raw-fetch: workflow access probe needs HTTP status discrimination (200 vs 403 vs other) without parsing the body
- const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
- if (checkResponse.status === 403) {
- setHasWorkspaceAccess(false)
- } else if (checkResponse.ok) {
- setHasWorkspaceAccess(true)
- } else {
- setHasWorkspaceAccess(null)
- }
- } catch (error) {
- logger.error('Error checking workspace access:', error)
- setHasWorkspaceAccess(null)
- }
- }
-
- checkWorkspaceAccess()
- }, [template?.workflowId, currentUserId, canEditTemplate])
-
- if (loading) {
- return (
-
- )
- }
-
- if (!template) {
- return (
-
-
-
Template Not Found
-
- The template you're looking for doesn't exist.
-
-
-
- )
- }
-
- const renderWorkflowPreview = () => {
- if (!template.state) {
- return (
-
-
-
⚠️ No Workflow Data
-
- This template doesn't contain workflow state data.
-
-
-
- )
- }
-
- try {
- return (
-
- )
- } catch (error) {
- logger.error('Error rendering workflow preview:', error)
- return (
-
-
-
⚠️ Preview Error
-
Unable to render workflow preview
-
-
- )
- }
- }
-
- const breadcrumbItems = [
- {
- label: 'Templates',
- href: isWorkspaceContext ? `/workspace/${workspaceId}/templates` : '/templates',
- },
- { label: template?.name || 'Template' },
- ]
- /**
- * Intercepts wheel events over the workflow preview so that the page handles scrolling
- * instead of the underlying canvas. We stop propagation in the capture phase to prevent
- * React Flow from consuming the event, but intentionally avoid preventDefault so the
- * browser can perform its normal scroll behavior.
- *
- * We allow zoom gestures (Ctrl/Cmd + wheel) to pass through unmodified.
- *
- * @param event - The wheel event fired when the user scrolls over the preview area.
- */
- const handleCanvasWheelCapture = (event: React.WheelEvent) => {
- if (event.ctrlKey || event.metaKey) {
- return
- }
-
- event.stopPropagation()
- }
-
- const handleStarToggle = async () => {
- if (!currentUserId || !template) return
-
- starTemplate.mutate({
- templateId: template.id,
- action: template.isStarred ? 'remove' : 'add',
- })
- }
-
- const handleUseTemplate = () => {
- if (!currentUserId) {
- const callbackUrl =
- isWorkspaceContext && workspaceId
- ? encodeURIComponent(`/workspace/${workspaceId}/templates/${template.id}?use=true`)
- : encodeURIComponent(`/templates/${template.id}`)
- router.push(`/login?callbackUrl=${callbackUrl}`)
- return
- }
-
- if (isWorkspaceContext && workspaceId) {
- handleWorkspaceSelectForUse(workspaceId)
- }
- }
-
- const handleEditTemplate = async () => {
- if (!currentUserId || !template) return
-
- if (isWorkspaceContext && workspaceId && template.workflowId) {
- setIsEditing(true)
- try {
- // boundary-raw-fetch: workflow access probe needs HTTP status check (200 vs not-ok) without parsing the body
- const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
-
- if (checkResponse.ok) {
- router.push(`/workspace/${workspaceId}/w/${template.workflowId}`)
- return
- }
- } catch (error) {
- logger.error('Error checking workflow:', error)
- } finally {
- setIsEditing(false)
- }
- }
-
- if (template.workflowId && !isWorkspaceContext) {
- setIsEditing(true)
- try {
- // boundary-raw-fetch: workflow probe reads passthrough data.workspaceId (not in getWorkflowStateContract typed response) and discriminates 403 vs 200
- const checkResponse = await fetch(`/api/workflows/${template.workflowId}`)
-
- if (checkResponse.status === 403) {
- alert("You don't have access to the workspace containing this template")
- return
- }
-
- if (checkResponse.ok) {
- const result = await checkResponse.json()
- const templateWorkspaceId = result.data?.workspaceId
- if (templateWorkspaceId) {
- window.location.href = `/workspace/${templateWorkspaceId}/w/${template.workflowId}`
- return
- }
- }
- } catch (error) {
- logger.error('Error checking workflow:', error)
- } finally {
- setIsEditing(false)
- }
- }
-
- if (isWorkspaceContext && workspaceId) {
- handleWorkspaceSelectForEdit(workspaceId)
- } else {
- setShowWorkspaceSelectorForEdit(true)
- }
- }
-
- const handleWorkspaceSelectForUse = async (workspaceId: string) => {
- if (isUsing || !template) return
-
- setIsUsing(true)
- try {
- const { workflowId } = await requestJson(useTemplateContract, {
- params: { id: template.id },
- body: { workspaceId },
- })
-
- window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
- } catch (error) {
- logger.error('Error using template:', error)
- } finally {
- setIsUsing(false)
- }
- }
-
- const handleWorkspaceSelectForEdit = async (workspaceId: string) => {
- if (isUsing || !template) return
-
- setIsUsing(true)
- setShowWorkspaceSelectorForEdit(false)
- try {
- const { workflowId } = await requestJson(useTemplateContract, {
- params: { id: template.id },
- body: { workspaceId, connectToTemplate: true },
- })
-
- window.location.href = `/workspace/${workspaceId}/w/${workflowId}`
- } catch (error) {
- logger.error('Error importing template for editing:', error)
- } finally {
- setIsUsing(false)
- }
- }
-
- const handleApprove = async () => {
- if (isApproving || !template) return
-
- setIsApproving(true)
- try {
- await requestJson(updateTemplateContract, {
- params: { id: template.id },
- body: { status: 'approved' },
- })
- if (isWorkspaceContext && workspaceId) {
- router.push(`/workspace/${workspaceId}/templates`)
- } else {
- router.push('/templates')
- }
- } catch (error) {
- logger.error('Error approving template:', error)
- } finally {
- setIsApproving(false)
- }
- }
-
- const handleReject = async () => {
- if (isRejecting || !template) return
-
- setIsRejecting(true)
- try {
- await requestJson(updateTemplateContract, {
- params: { id: template.id },
- body: { status: 'rejected' },
- })
- if (isWorkspaceContext && workspaceId) {
- router.push(`/workspace/${workspaceId}/templates`)
- } else {
- router.push('/templates')
- }
- } catch (error) {
- logger.error('Error rejecting template:', error)
- } finally {
- setIsRejecting(false)
- }
- }
-
- const handleToggleVerification = async () => {
- if (isVerifying || !template?.creator?.id) return
-
- setIsVerifying(true)
- try {
- await requestJson(updateCreatorProfileContract, {
- params: { id: template.creator.id },
- body: { verified: !template.creator.verified },
- })
- window.location.reload()
- } catch (error) {
- logger.error('Error toggling verification:', error)
- alert(`Failed to ${template.creator.verified ? 'unverify' : 'verify'} creator`)
- } finally {
- setIsVerifying(false)
- }
- }
-
- /**
- * Shares the template to X (Twitter)
- */
- const handleShareToTwitter = () => {
- if (!template) return
-
- setSharePopoverOpen(false)
- const templateUrl = `${getBaseUrl()}/templates/${template.id}`
-
- let tweetText = `🚀 Check out this workflow template: ${template.name}`
-
- if (template.details?.tagline) {
- const taglinePreview =
- template.details.tagline.length > 100
- ? `${template.details.tagline.substring(0, 100)}...`
- : template.details.tagline
- tweetText += `\n\n${taglinePreview}`
- }
-
- const maxTextLength = 280 - 23 - 1
- if (tweetText.length > maxTextLength) {
- tweetText = `${tweetText.substring(0, maxTextLength - 3)}...`
- }
-
- const twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweetText)}&url=${encodeURIComponent(templateUrl)}`
- window.open(twitterUrl, '_blank', 'noopener,noreferrer')
- }
-
- /**
- * Shares the template to LinkedIn.
- */
- const handleShareToLinkedIn = () => {
- if (!template) return
-
- setSharePopoverOpen(false)
- const templateUrl = `${getBaseUrl()}/templates/${template.id}`
- const linkedInUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(templateUrl)}`
- window.open(linkedInUrl, '_blank', 'noopener,noreferrer')
- }
-
- const handleCopyLink = async () => {
- setSharePopoverOpen(false)
- const templateUrl = `${getBaseUrl()}/templates/${template?.id}`
- try {
- await navigator.clipboard.writeText(templateUrl)
- logger.info('Template link copied to clipboard')
- } catch (error) {
- logger.error('Failed to copy link:', error)
- }
- }
-
- return (
-
-
-
- {/* Breadcrumb navigation */}
-
-
- {/* Template name and action buttons */}
-
-
{template.name}
-
- {/* Action buttons */}
-
- {/* Approve/Reject buttons for super users */}
- {isSuperUser && template.status === 'pending' && (
- <>
-
- {isApproving ? 'Approving...' : 'Approve'}
-
-
- {isRejecting ? 'Rejecting...' : 'Reject'}
-
- >
- )}
-
- {/* Edit button - for template owners */}
- {canEditTemplate && currentUserId && (
- <>
- {(isWorkspaceContext || template.workflowId) && !showWorkspaceSelectorForEdit ? (
-
- {isEditing ? 'Opening...' : 'Edit'}
-
- ) : (
-
-
- setShowWorkspaceSelectorForEdit(true)}
- disabled={isUsing || isLoadingWorkspaces}
- className='h-[32px] rounded-md'
- >
- {isUsing ? 'Importing...' : isLoadingWorkspaces ? 'Loading...' : 'Edit'}
-
-
-
-
- {workspaces.length === 0 ? (
-
- No workspaces with write access
-
- ) : (
- workspaces.map((workspace) => (
- handleWorkspaceSelectForEdit(workspace.id)}
- >
-
- {workspace.name}
-
- {workspace.permissions} access
-
-
-
- ))
- )}
-
-
- )}
- >
- )}
-
- {/* Use template button - only for approved templates and non-owners */}
- {template.status === 'approved' && !canEditTemplate && (
- <>
- {!currentUserId ? (
-
{
- const callbackUrl =
- isWorkspaceContext && workspaceId
- ? encodeURIComponent(
- `/workspace/${workspaceId}/templates/${template.id}?use=true`
- )
- : encodeURIComponent(`/templates/${template.id}`)
- router.push(`/login?callbackUrl=${callbackUrl}`)
- }}
- className='h-[32px] rounded-md'
- >
- Sign in to use
-
- ) : isWorkspaceContext ? (
-
- {isUsing ? 'Creating...' : 'Use template'}
-
- ) : null}
- >
- )}
-
- {/* Share button */}
-
-
-
-
-
-
-
-
-
- Copy link
-
-
-
-
-
- Share on X
-
-
-
- Share on LinkedIn
-
-
-
-
-
-
- {/* Template tagline */}
- {template.details?.tagline && (
-
- {template.details.tagline}
-
- )}
-
- {/* Creator and stats row */}
-
- {/* Star icon and count */}
-
-
- {template.stars || 0}
-
-
- {/* Users icon and count */}
-
-
{template.views}
-
- {/* Vertical divider */}
-
-
- {/* Creator profile pic */}
- {template.creator?.profileImageUrl ? (
-
-
-
- ) : (
-
-
-
- )}
- {/* Creator name */}
-
-
- {template.creator?.name || 'Unknown'}
-
- {template.creator?.verified && }
-
-
-
- {/* Credentials needed */}
- {Array.isArray(template.requiredCredentials) &&
- template.requiredCredentials.length > 0 && (
-
- Credentials needed:{' '}
- {template.requiredCredentials
- .map((cred: CredentialRequirement) => {
- const blockName =
- getBlock(cred.blockType)?.name ||
- cred.blockType.charAt(0).toUpperCase() + cred.blockType.slice(1)
- const alreadyHasBlock = cred.label
- .toLowerCase()
- .includes(` for ${blockName.toLowerCase()}`)
- return alreadyHasBlock ? cred.label : `${cred.label} for ${blockName}`
- })
- .join(', ')}
-
- )}
-
- {/* Canvas preview */}
-
- {renderWorkflowPreview()}
-
- {/* Last updated overlay */}
- {template.updatedAt && (
-
-
- Last updated{' '}
- {formatDistanceToNow(new Date(template.updatedAt), {
- addSuffix: true,
- })}
-
-
- )}
-
-
- {/* About this Workflow */}
- {template.details?.about && (
-
-
- About this Workflow
-
-
-
(
-
- {children}
-
- ),
- h1: ({ children }) => (
-
- {children}
-
- ),
- h2: ({ children }) => (
-
- {children}
-
- ),
- h3: ({ children }) => (
-
- {children}
-
- ),
- h4: ({ children }) => (
-
- {children}
-
- ),
- ul: ({ children }) => (
-
- ),
- ol: ({ children }) => (
-
- {children}
-
- ),
- li: ({ children }) => {children} ,
- inlineCode: ({ children }) => (
-
- {children}
-
- ),
- code: ({ children }) => (
-
- {children}
-
- ),
- a: ({ href, children }) => (
-
- {children}
-
- ),
- strong: ({ children }) => (
-
- {children}
-
- ),
- em: ({ children }) => {children} ,
- }}
- >
- {template.details.about}
-
-
-
- )}
-
- {/* About the Creator */}
- {template.creator &&
- (template.creator.details?.about ||
- template.creator.details?.xUrl ||
- template.creator.details?.linkedinUrl ||
- template.creator.details?.websiteUrl ||
- template.creator.details?.contactEmail) && (
-
-
-
- About the Creator
-
- {isSuperUser && template.creator && (
-
- {isVerifying
- ? 'Updating...'
- : template.creator.verified
- ? 'Unverify Creator'
- : 'Verify Creator'}
-
- )}
-
-
- {/* Creator profile image */}
- {template.creator.profileImageUrl ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {/* Creator details */}
-
-
-
-
- {template.creator.name}
-
- {template.creator.verified && }
-
-
- {/* Social links */}
-
- {template.creator.details?.websiteUrl && (
-
-
-
- )}
- {template.creator.details?.xUrl && (
-
-
-
-
-
- )}
- {template.creator.details?.linkedinUrl && (
-
-
-
- )}
- {template.creator.details?.contactEmail && (
-
-
-
- )}
-
-
-
- {/* Creator bio */}
- {template.creator.details?.about && (
-
-
(
-
- {children}
-
- ),
- a: ({ href, children }) => (
-
- {children}
-
- ),
- strong: ({ children }) => (
-
- {children}
-
- ),
- }}
- >
- {template.creator.details.about}
-
-
- )}
-
-
-
- )}
-
-
-
- )
-}
diff --git a/apps/sim/app/templates/layout-client.tsx b/apps/sim/app/templates/layout-client.tsx
deleted file mode 100644
index e89327ab2d4..00000000000
--- a/apps/sim/app/templates/layout-client.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { season } from '@/app/_styles/fonts/season/season'
-
-export default function TemplatesLayoutClient({ children }: { children: React.ReactNode }) {
- return (
-
- )
-}
diff --git a/apps/sim/app/templates/layout.tsx b/apps/sim/app/templates/layout.tsx
deleted file mode 100644
index 8a9df800515..00000000000
--- a/apps/sim/app/templates/layout.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import TemplatesLayoutClient from '@/app/templates/layout-client'
-
-/**
- * Templates layout - server component wrapper for client layout.
- * Redirect logic is handled by individual pages to preserve paths.
- */
-export default function TemplatesLayout({ children }: { children: React.ReactNode }) {
- return {children}
-}
diff --git a/apps/sim/app/templates/loading.tsx b/apps/sim/app/templates/loading.tsx
deleted file mode 100644
index ae6b87ac503..00000000000
--- a/apps/sim/app/templates/loading.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Skeleton } from '@/components/emcn'
-
-const SKELETON_CARD_COUNT = 8
-
-export default function TemplatesLoading() {
- return (
-
-
-
-
-
-
-
-
- {Array.from({ length: SKELETON_CARD_COUNT }).map((_, i) => (
-
- ))}
-
-
-
- )
-}
diff --git a/apps/sim/app/templates/page.tsx b/apps/sim/app/templates/page.tsx
deleted file mode 100644
index 22ca921f093..00000000000
--- a/apps/sim/app/templates/page.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { notFound } from 'next/navigation'
-
-// import { db } from '@sim/db'
-// import { permissions, templateCreators, templates, workspace } from '@sim/db/schema'
-// import { and, desc, eq } from 'drizzle-orm'
-// import type { Metadata } from 'next'
-// import { redirect } from 'next/navigation'
-// import { getSession } from '@/lib/auth'
-// import type { Template } from '@/app/templates/templates'
-// import Templates from '@/app/templates/templates'
-
-// export const metadata: Metadata = {
-// title: 'Templates',
-// description:
-// 'Browse pre-built workflow templates to get started quickly with AI agents, automations, and integrations.',
-// }
-
-/**
- * Public templates list page.
- * Currently disabled — returns 404.
- */
-export default function TemplatesPage() {
- notFound()
-
- // Redirects authenticated users to their workspace-scoped templates page.
- // Allows unauthenticated users to view templates for SEO and discovery.
- //
- // const session = await getSession()
- //
- // // Authenticated users: redirect to workspace-scoped templates
- // if (session?.user?.id) {
- // const userWorkspaces = await db
- // .select({
- // workspace: workspace,
- // })
- // .from(permissions)
- // .innerJoin(workspace, eq(permissions.entityId, workspace.id))
- // .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace')))
- // .orderBy(desc(workspace.createdAt))
- // .limit(1)
- //
- // if (userWorkspaces.length > 0) {
- // const firstWorkspace = userWorkspaces[0].workspace
- // redirect(`/workspace/${firstWorkspace.id}/templates`)
- // }
- // }
- //
- // // Unauthenticated users: show public templates
- // const templatesData = await db
- // .select({
- // id: templates.id,
- // workflowId: templates.workflowId,
- // name: templates.name,
- // details: templates.details,
- // creatorId: templates.creatorId,
- // creator: templateCreators,
- // views: templates.views,
- // stars: templates.stars,
- // status: templates.status,
- // tags: templates.tags,
- // requiredCredentials: templates.requiredCredentials,
- // state: templates.state,
- // createdAt: templates.createdAt,
- // updatedAt: templates.updatedAt,
- // })
- // .from(templates)
- // .leftJoin(templateCreators, eq(templates.creatorId, templateCreators.id))
- // .where(eq(templates.status, 'approved'))
- // .orderBy(desc(templates.views), desc(templates.createdAt))
- // .then((rows) => rows.map((row) => ({ ...row, isStarred: false })))
- //
- // return
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
new file mode 100644
index 00000000000..1d7ad4f2f82
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -0,0 +1,455 @@
+'use client'
+
+import { type ComponentType, type KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import {
+ Badge,
+ Chip,
+ ChipModal,
+ ChipModalBody,
+ ChipModalError,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ InfoCard,
+ InfoCardItem,
+ InfoCardList,
+} from '@/components/emcn'
+import { useSession } from '@/lib/auth/auth-client'
+import type { OAuthReturnContext } from '@/lib/credentials/client-state'
+import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
+import {
+ getProviderIdFromServiceId,
+ OAUTH_PROVIDERS,
+ type OAuthProvider,
+ parseProvider,
+} from '@/lib/oauth'
+import { getScopeDescription } from '@/lib/oauth/utils'
+import { useCreateCredentialDraft, useWorkspaceCredentials } from '@/hooks/queries/credentials'
+import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
+
+const logger = createLogger('ConnectOAuthModal')
+
+/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */
+const DISPLAY_NAME_MAX_LENGTH = 255
+
+/**
+ * Reserved tail budget when truncating the username so the auto-numbering
+ * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}.
+ */
+const COLLISION_SUFFIX_RESERVATION = 5
+
+/** Upper bound for the auto-numbering search — pathological if ever reached. */
+const MAX_COLLISION_INDEX = 10000
+
+const EMPTY_SCOPES: readonly string[] = []
+
+type ServiceIcon = ComponentType<{ className?: string }>
+
+/** Scopes hidden from the permissions list — always present on Google flows. */
+function isHiddenScope(scope: string): boolean {
+ return scope.includes('userinfo.email') || scope.includes('userinfo.profile')
+}
+
+/**
+ * Default credential display name. Produces `"{Name}'s {Service}"` when the
+ * user's name is known, falling back to `"My {Service}"` otherwise. The
+ * username is truncated so the full string (including any auto-numbering
+ * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}.
+ *
+ * When the base name collides with an existing credential in `takenNames`,
+ * `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison
+ * is case-insensitive to match the duplicate-detection used elsewhere in the
+ * modal.
+ */
+function defaultDisplayName(
+ userName: string | null | undefined,
+ serviceName: string,
+ takenNames: ReadonlySet
+): string {
+ const trimmed = userName?.trim()
+ let base: string
+ if (trimmed) {
+ const suffix = `'s ${serviceName}`
+ const nameBudget = Math.max(
+ 0,
+ DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION
+ )
+ const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed
+ base = `${safeName}${suffix}`
+ } else {
+ base = `My ${serviceName}`
+ }
+
+ if (!takenNames.has(base.toLowerCase())) return base
+ for (let n = 2; n < MAX_COLLISION_INDEX; n++) {
+ const candidate = `${base} ${n}`
+ if (!takenNames.has(candidate.toLowerCase())) return candidate
+ }
+ return base
+}
+
+/**
+ * Resolves the display name + icon for an OAuth `provider`/`serviceId` pair,
+ * preferring the most specific service entry and falling back to the base
+ * provider config, then to the raw provider id. Used when the caller does not
+ * supply explicit `serviceName`/`serviceIcon`.
+ */
+function resolveService(
+ provider: OAuthProvider,
+ serviceId: string
+): { providerName: string; ProviderIcon: ServiceIcon } {
+ const { baseProvider } = parseProvider(provider)
+ const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
+ let providerName = baseProviderConfig?.name || provider
+ let ProviderIcon: ServiceIcon = baseProviderConfig?.icon || (() => null)
+ if (baseProviderConfig) {
+ for (const [key, service] of Object.entries(baseProviderConfig.services)) {
+ if (key === serviceId || service.providerId === provider) {
+ providerName = service.name
+ ProviderIcon = service.icon
+ break
+ }
+ }
+ }
+ return { providerName, ProviderIcon }
+}
+
+interface ConnectOAuthModalBaseProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ /**
+ * Canonical provider id (e.g. `google-email`). When omitted it is derived
+ * from `serviceId`. Used for the credential draft and return context.
+ */
+ providerId?: string
+ /**
+ * Optional explicit display name/icon. When omitted, both are resolved from
+ * `provider` + `serviceId`. The integrations catalog supplies these directly;
+ * workflow/KB callers rely on resolution.
+ */
+ serviceName?: string
+ serviceIcon?: ServiceIcon
+ /** Used to resolve display metadata and the provider id when not supplied directly. */
+ provider?: OAuthProvider
+ serviceId?: string
+}
+
+/**
+ * Connect mode. Creates the credential draft and writes the origin-specific
+ * OAuth return context before handing off to the provider via
+ * {@link useConnectOAuthService}.
+ */
+type ConnectOAuthModalConnectProps = ConnectOAuthModalBaseProps & {
+ mode: 'connect'
+ workspaceId: string
+ requiredScopes: readonly string[]
+} & (
+ | { origin: 'workflow'; workflowId: string }
+ | { origin: 'kb-connectors'; knowledgeBaseId: string; connectorType?: string }
+ | { origin: 'integrations' }
+ )
+
+/**
+ * Reauthorize mode. Updates the scopes on an existing credential for
+ * `toolName`. `newScopes` are surfaced with a "New" badge. An optional
+ * `onConnect` override short-circuits the default provider hand-off.
+ */
+interface ConnectOAuthModalReauthorizeProps extends ConnectOAuthModalBaseProps {
+ mode: 'reauthorize'
+ toolName: string
+ requiredScopes?: readonly string[]
+ newScopes?: readonly string[]
+ onConnect?: () => Promise | void
+}
+
+export type ConnectOAuthModalProps =
+ | ConnectOAuthModalConnectProps
+ | ConnectOAuthModalReauthorizeProps
+
+/**
+ * Unified connect/reauthorize OAuth credential modal (ChipModal UI). Mounted by
+ * the integrations catalog, the workflow editor's credential selectors, and the
+ * knowledge-base connector flows. After the redirect lands back on
+ * `window.location.href`, the host page's OAuth return router consumes the
+ * context written here.
+ */
+export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
+ const { open, onOpenChange, mode } = props
+ const isConnect = mode === 'connect'
+
+ const providerId = useMemo(
+ () => props.providerId ?? (props.serviceId ? getProviderIdFromServiceId(props.serviceId) : ''),
+ [props.providerId, props.serviceId]
+ )
+
+ const [displayName, setDisplayName] = useState('')
+ const [description, setDescription] = useState('')
+ const [validationError, setValidationError] = useState(null)
+ const [submitError, setSubmitError] = useState(null)
+
+ const { data: session } = useSession()
+ const userName = session?.user?.name
+
+ const { providerName, ProviderIcon } = useMemo(() => {
+ if (props.serviceName && props.serviceIcon) {
+ return { providerName: props.serviceName, ProviderIcon: props.serviceIcon }
+ }
+ const provider = (props.provider ?? providerId) as OAuthProvider
+ return resolveService(provider, props.serviceId ?? providerId)
+ }, [props.serviceName, props.serviceIcon, props.provider, props.serviceId, providerId])
+
+ const workspaceId = isConnect ? props.workspaceId : ''
+ const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
+ workspaceId,
+ enabled: isConnect && Boolean(workspaceId) && open,
+ })
+ const createDraft = useCreateCredentialDraft()
+ const connectOAuthService = useConnectOAuthService()
+
+ /**
+ * Lowercased set of OAuth credential names already in the workspace. Drives
+ * both the prefill's auto-numbering and the inline duplicate-name error.
+ */
+ const takenNames = useMemo(
+ () =>
+ new Set(
+ credentials
+ .filter((credential) => credential.type === 'oauth')
+ .map((credential) => credential.displayName.toLowerCase())
+ ),
+ [credentials]
+ )
+
+ const requiredScopes = props.requiredScopes ?? EMPTY_SCOPES
+ const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
+
+ const newScopesSet = useMemo(
+ () => new Set([...newScopes].filter((scope) => !isHiddenScope(scope))),
+ [newScopes]
+ )
+
+ const displayScopes = useMemo(() => {
+ const filtered = [...requiredScopes].filter((scope) => !isHiddenScope(scope))
+ if (isConnect) return filtered
+ return filtered.sort((a, b) => {
+ const aIsNew = newScopesSet.has(a)
+ const bIsNew = newScopesSet.has(b)
+ if (aIsNew && !bIsNew) return -1
+ if (!aIsNew && bIsNew) return 1
+ return 0
+ })
+ }, [isConnect, requiredScopes, newScopesSet])
+
+ /**
+ * Initialize the connect form once per open session, after credentials have
+ * loaded so auto-numbering can see them. The `prefilled` ref ensures session
+ * refetches or other prop churn while the modal is open won't overwrite the
+ * user's typed value.
+ */
+ const prefilled = useRef(false)
+ useEffect(() => {
+ if (!open) {
+ prefilled.current = false
+ return
+ }
+ if (!isConnect || prefilled.current || credentialsLoading) return
+ prefilled.current = true
+ setDisplayName(defaultDisplayName(userName, providerName, takenNames))
+ setDescription('')
+ setValidationError(null)
+ setSubmitError(null)
+ }, [open, isConnect, credentialsLoading, userName, providerName, takenNames])
+
+ const existingCredential = useMemo(() => {
+ if (!isConnect) return null
+ const name = displayName.trim().toLowerCase()
+ if (!name || !takenNames.has(name)) return null
+ return (
+ credentials.find((row) => row.type === 'oauth' && row.displayName.toLowerCase() === name) ??
+ null
+ )
+ }, [isConnect, credentials, displayName, takenNames])
+
+ const handleClose = () => {
+ setSubmitError(null)
+ onOpenChange(false)
+ }
+
+ const handleConnect = async () => {
+ setValidationError(null)
+ setSubmitError(null)
+ try {
+ let connectorType: string | undefined
+
+ if (isConnect) {
+ const trimmed = displayName.trim()
+ if (!trimmed) {
+ setValidationError('Display name is required.')
+ return
+ }
+
+ await createDraft.mutateAsync({
+ workspaceId,
+ providerId,
+ displayName: trimmed,
+ description: description.trim() || undefined,
+ })
+
+ const preCount = credentials.filter(
+ (c) => c.type === 'oauth' && c.providerId === providerId
+ ).length
+
+ const baseContext = {
+ displayName: trimmed,
+ providerId,
+ preCount,
+ workspaceId,
+ requestedAt: Date.now(),
+ }
+
+ let returnContext: OAuthReturnContext
+ if (props.origin === 'kb-connectors') {
+ connectorType = props.connectorType
+ returnContext = {
+ ...baseContext,
+ origin: 'kb-connectors',
+ knowledgeBaseId: props.knowledgeBaseId,
+ connectorType: props.connectorType,
+ }
+ } else if (props.origin === 'workflow') {
+ returnContext = { ...baseContext, origin: 'workflow', workflowId: props.workflowId }
+ } else {
+ returnContext = { ...baseContext, origin: 'integrations' }
+ }
+
+ writeOAuthReturnContext(returnContext)
+ } else if (props.onConnect) {
+ await props.onConnect()
+ handleClose()
+ return
+ } else {
+ logger.info('Reauthorizing OAuth2', {
+ providerId,
+ requiredScopes,
+ hasNewScopes: newScopes.length > 0,
+ })
+ }
+
+ const callbackURL = new URL(window.location.href)
+ if (connectorType) {
+ callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
+ }
+
+ await connectOAuthService.mutateAsync({
+ providerId,
+ callbackURL: callbackURL.toString(),
+ })
+ handleClose()
+ } catch (err: unknown) {
+ const message = getErrorMessage(err, 'Failed to start OAuth connection')
+ setSubmitError(message)
+ logger.error('Failed to connect OAuth service', err)
+ }
+ }
+
+ const isPending = (isConnect && createDraft.isPending) || connectOAuthService.isPending
+ const isDisabled = isConnect
+ ? !displayName.trim() || isPending || Boolean(existingCredential)
+ : isPending
+
+ /**
+ * Submits the connect form on Enter, mirroring the Connect button's enabled
+ * state and excluding the multi-line description. Restores the keyboard
+ * affordance the pre-consolidation workflow modal provided.
+ */
+ const handleBodyKeyDown = (event: KeyboardEvent) => {
+ if (event.key !== 'Enter' || !isConnect || isDisabled) return
+ if (event.target instanceof HTMLTextAreaElement) return
+ event.preventDefault()
+ void handleConnect()
+ }
+
+ const displayNameError =
+ validationError ??
+ (existingCredential
+ ? `An integration named "${existingCredential.displayName}" already exists.`
+ : undefined)
+
+ const title = `Connect ${providerName}`
+
+ return (
+
+
+ {title}
+
+
+ {!isConnect && (
+
+ The "{props.toolName}" tool requires access to your account.
+
+ )}
+
+ {isConnect && (
+ {
+ setDisplayName(value)
+ if (validationError) setValidationError(null)
+ }}
+ placeholder='Integration name'
+ autoComplete='off'
+ required
+ error={displayNameError}
+ />
+ )}
+
+ {isConnect && (
+
+ )}
+
+ {displayScopes.length > 0 && (
+
+
+
+ {displayScopes.map((scope) => (
+
+
+ {getScopeDescription(scope)}
+ {!isConnect && newScopesSet.has(scope) && (
+
+ New
+
+ )}
+
+
+ ))}
+
+
+
+ )}
+
+ {submitError}
+
+
+
+ Cancel
+
+
+ {isPending ? 'Connecting...' : 'Connect'}
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/index.ts b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/index.ts
new file mode 100644
index 00000000000..02665eada44
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/index.ts
@@ -0,0 +1,4 @@
+export {
+ ConnectOAuthModal,
+ type ConnectOAuthModalProps,
+} from './connect-oauth-modal'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx
similarity index 50%
rename from apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
rename to apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx
index a08333666df..57dfeb989af 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/conversation-list-item.tsx
@@ -1,5 +1,4 @@
import type { ReactNode } from 'react'
-import { Blimp } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface ConversationListItemProps {
@@ -21,28 +20,19 @@ export function ConversationListItem({
statusIndicatorClassName,
actions,
}: ConversationListItemProps) {
+ const showStatusDot = isActive || isUnread
return (
-
-
- {isActive && (
-
- )}
- {!isActive && isUnread && (
-
- )}
-
{title}
+ {showStatusDot && (
+
+ )}
{actions &&
{actions}
}
)
diff --git a/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/index.ts b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/index.ts
new file mode 100644
index 00000000000..ac3fcf7948a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/conversation-list-item/index.ts
@@ -0,0 +1 @@
+export { ConversationListItem } from './conversation-list-item'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx
new file mode 100644
index 00000000000..47b2e5b0209
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/add-people-modal.tsx
@@ -0,0 +1,168 @@
+'use client'
+
+import { useCallback, useMemo, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import {
+ Chip,
+ ChipModal,
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ toast,
+} from '@/components/emcn'
+import { useWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
+import {
+ useUpsertWorkspaceCredentialMember,
+ useWorkspaceCredentialMembers,
+ type WorkspaceCredentialRole,
+} from '@/hooks/queries/credentials'
+import { ROLE_OPTIONS } from '../roles'
+import { partitionSettledFailures, resolveAddEmail } from '../sharing'
+
+const logger = createLogger('AddPeopleModal')
+
+interface AddPeopleModalProps {
+ credentialId: string
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+/**
+ * Shared "Add people" modal: grants existing workspace members access to a
+ * credential with a chosen role. Emails are validated against the workspace
+ * roster and current membership; each add is an idempotent upsert and partial
+ * failures keep only the people that still need adding.
+ */
+export function AddPeopleModal({ credentialId, open, onOpenChange }: AddPeopleModalProps) {
+ const { workspacePermissions } = useWorkspacePermissionsContext()
+ const { data: members = [] } = useWorkspaceCredentialMembers(credentialId)
+ const upsertMember = useUpsertWorkspaceCredentialMember()
+
+ const [emailsToAdd, setEmailsToAdd] = useState([])
+ const [roleToAdd, setRoleToAdd] = useState('member')
+ const [isAdding, setIsAdding] = useState(false)
+
+ const workspaceUserIdByEmail = useMemo(
+ () =>
+ new Map(
+ (workspacePermissions?.users ?? []).map((user) => [user.email.toLowerCase(), user.userId])
+ ),
+ [workspacePermissions?.users]
+ )
+
+ const existingMemberEmails = useMemo(
+ () =>
+ new Set(
+ members
+ .filter((member) => member.status === 'active')
+ .map((member) => (member.userEmail ?? '').toLowerCase())
+ .filter(Boolean)
+ ),
+ [members]
+ )
+
+ const validateAddEmail = useCallback(
+ (email: string): string | null => {
+ const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
+ return 'error' in result ? result.error : null
+ },
+ [workspaceUserIdByEmail, existingMemberEmails]
+ )
+
+ const handleClose = useCallback(() => {
+ setEmailsToAdd([])
+ setRoleToAdd('member')
+ onOpenChange(false)
+ }, [onOpenChange])
+
+ const handleAddPeople = useCallback(async () => {
+ if (emailsToAdd.length === 0 || isAdding) return
+ const targets = emailsToAdd
+ .map((email) => {
+ const result = resolveAddEmail(email, { workspaceUserIdByEmail, existingMemberEmails })
+ return 'userId' in result ? { email, userId: result.userId } : null
+ })
+ .filter((target): target is { email: string; userId: string } => target !== null)
+ if (targets.length === 0) return
+
+ setIsAdding(true)
+ try {
+ const results = await Promise.allSettled(
+ targets.map((target) =>
+ upsertMember.mutateAsync({ credentialId, userId: target.userId, role: roleToAdd })
+ )
+ )
+ const failures = partitionSettledFailures(targets, results)
+ if (failures.length === 0) {
+ handleClose()
+ return
+ }
+ setEmailsToAdd(failures.map((target) => target.email))
+ const firstError = results.find(
+ (result): result is PromiseRejectedResult => result.status === 'rejected'
+ )
+ logger.error('Failed to add some credential members', firstError?.reason)
+ toast.error(
+ failures.length === targets.length
+ ? "Couldn't add people"
+ : `Couldn't add ${failures.length} of ${targets.length} people`,
+ { description: getErrorMessage(firstError?.reason, 'Please try again in a moment.') }
+ )
+ } finally {
+ setIsAdding(false)
+ }
+ }, [
+ credentialId,
+ emailsToAdd,
+ isAdding,
+ workspaceUserIdByEmail,
+ existingMemberEmails,
+ roleToAdd,
+ upsertMember,
+ handleClose,
+ ])
+
+ return (
+ {
+ if (!next) handleClose()
+ }}
+ srTitle='Add people'
+ >
+ Add people
+
+
+ setRoleToAdd(role as WorkspaceCredentialRole)}
+ disabled={isAdding}
+ />
+
+
+
+ {isAdding ? 'Adding...' : 'Add'}
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
new file mode 100644
index 00000000000..3cd6bca892c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/chip-field.ts
@@ -0,0 +1,19 @@
+/**
+ * Shared chip-field chrome for the credential and secret detail surfaces.
+ *
+ * These mirror `Input variant='chip'` exactly (30px tall, `rounded-lg`,
+ * `border-1`, `surface-5`/`surface-4`, `font-medium` body text, and a
+ * `border-focus` ring on focus) but as a wrapper + inner-input pair, so a field
+ * can host a borderless input alongside a trailing slot (a copy button, a
+ * reveal toggle). Using one definition keeps every chip field — list rows,
+ * copyable IDs, secret values, display-name/description editors — pixel-identical
+ * to the canonical chip input instead of each re-deriving the tokens.
+ */
+
+/** Pill wrapper. Override height/alignment (e.g. a textarea) via `cn`. */
+export const CHIP_FIELD_SHELL =
+ 'flex h-[30px] items-center gap-2 rounded-lg border border-[var(--border-1)] bg-[var(--surface-5)] px-2 transition-colors focus-within:border-[var(--border-focus)] dark:bg-[var(--surface-4)]'
+
+/** Borderless input/textarea hosted inside {@link CHIP_FIELD_SHELL}. */
+export const CHIP_FIELD_INPUT =
+ 'h-full w-full bg-transparent font-medium text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] focus:outline-none'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/copyable-value-field.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/copyable-value-field.tsx
new file mode 100644
index 00000000000..42956100eaa
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/copyable-value-field.tsx
@@ -0,0 +1,48 @@
+'use client'
+
+import { useState } from 'react'
+import { Button, Tooltip } from '@/components/emcn'
+import { Check, Duplicate } from '@/components/emcn/icons'
+import { cn } from '@/lib/core/utils/cn'
+import {
+ CHIP_FIELD_INPUT,
+ CHIP_FIELD_SHELL,
+} from '@/app/workspace/[workspaceId]/components/credential-detail/components/chip-field'
+
+interface CopyableValueFieldProps {
+ value: string
+ /** Accessible label and tooltip for the copy button (e.g. 'Copy credential ID'). */
+ copyLabel: string
+ id?: string
+}
+
+/**
+ * Read-only value row with a trailing copy-to-clipboard button. Shared field
+ * shell for identifiers such as a credential ID or a secret key.
+ */
+export function CopyableValueField({ value, copyLabel, id }: CopyableValueFieldProps) {
+ const [copied, setCopied] = useState(false)
+
+ return (
+
+
+
+
+ {
+ navigator.clipboard.writeText(value)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }}
+ aria-label={copyLabel}
+ >
+ {copied ? : }
+
+
+ {copied ? 'Copied!' : copyLabel}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-heading.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-heading.tsx
new file mode 100644
index 00000000000..b3571c86bab
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-heading.tsx
@@ -0,0 +1,30 @@
+import type { ReactNode } from 'react'
+
+interface CredentialDetailHeadingProps {
+ /** Leading visual (icon tile or brand tile). */
+ leading: ReactNode
+ title: ReactNode
+ subtitle?: ReactNode
+}
+
+/**
+ * Header row shared by credential detail surfaces: a leading visual beside a
+ * title over a muted subtitle.
+ */
+export function CredentialDetailHeading({
+ leading,
+ title,
+ subtitle,
+}: CredentialDetailHeadingProps) {
+ return (
+
+ {leading}
+
+ {title}
+ {subtitle ? (
+ {subtitle}
+ ) : null}
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx
new file mode 100644
index 00000000000..5753feabdcb
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-detail-layout.tsx
@@ -0,0 +1,29 @@
+import type { ReactNode } from 'react'
+
+interface CredentialDetailLayoutProps {
+ /** Back link rendered at the start of the fixed action bar. */
+ back: ReactNode
+ /** Optional controls grouped at the end of the action bar. */
+ actions?: ReactNode
+ children: ReactNode
+}
+
+/**
+ * Page shell shared by the credential detail surfaces: a fixed action bar
+ * (back link + grouped actions) above a scrollable, centered body. Surfaces
+ * supply the slots and body sections; all layout chrome lives here so callsites
+ * stay free of bespoke styling.
+ */
+export function CredentialDetailLayout({ back, actions, children }: CredentialDetailLayoutProps) {
+ return (
+
+
+ {back}
+ {actions ?
{actions}
: null}
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx
new file mode 100644
index 00000000000..6177c1bf8a1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/credential-members-section.tsx
@@ -0,0 +1,116 @@
+'use client'
+
+import { createLogger } from '@sim/logger'
+import { Avatar, AvatarFallback, Chip, ChipDropdown } from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+import { getUserColor } from '@/lib/workspaces/colors'
+import {
+ useRemoveWorkspaceCredentialMember,
+ useUpsertWorkspaceCredentialMember,
+ useWorkspaceCredentialMembers,
+ type WorkspaceCredentialRole,
+} from '@/hooks/queries/credentials'
+import { ROLE_OPTIONS } from '../roles'
+import { DetailSection } from './detail-section'
+
+const logger = createLogger('CredentialMembersSection')
+
+interface CredentialMembersSectionProps {
+ credentialId: string
+ isAdmin: boolean
+}
+
+/**
+ * Active-member list for a credential: avatar + identity, a role dropdown, and a
+ * remove action. The last remaining admin cannot be demoted or removed. Shared
+ * by every credential detail surface.
+ */
+export function CredentialMembersSection({ credentialId, isAdmin }: CredentialMembersSectionProps) {
+ const { data: members = [], isPending: membersLoading } =
+ useWorkspaceCredentialMembers(credentialId)
+ const upsertMember = useUpsertWorkspaceCredentialMember()
+ const removeMember = useRemoveWorkspaceCredentialMember()
+
+ const activeMembers = members.filter((member) => member.status === 'active')
+ const adminMemberCount = activeMembers.filter((member) => member.role === 'admin').length
+
+ const handleChangeMemberRole = async (userId: string, role: WorkspaceCredentialRole) => {
+ const current = activeMembers.find((member) => member.userId === userId)
+ if (current?.role === role) return
+ try {
+ await upsertMember.mutateAsync({ credentialId, userId, role })
+ } catch (error) {
+ logger.error('Failed to change member role', error)
+ }
+ }
+
+ const handleRemoveMember = async (userId: string) => {
+ try {
+ await removeMember.mutateAsync({ credentialId, userId })
+ } catch (error) {
+ logger.error('Failed to remove credential member', error)
+ }
+ }
+
+ return (
+
+ {membersLoading ? null : (
+
+ {activeMembers.map((member) => {
+ const roleLocked = member.role === 'admin' && adminMemberCount <= 1
+ const roleDisabled = !isAdmin || roleLocked
+ return (
+
+
+
+
+ {(member.userName || member.userEmail || '?').charAt(0).toUpperCase()}
+
+
+
+
+ {member.userName || member.userEmail || member.userId}
+
+
+ {member.userEmail || member.userId}
+
+
+
+
+ handleChangeMemberRole(member.userId, role as WorkspaceCredentialRole)
+ }
+ />
+ {isAdmin && (
+ handleRemoveMember(member.userId)}
+ disabled={roleLocked}
+ flush
+ className='justify-self-end'
+ >
+ Remove
+
+ )}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-icon-tile.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-icon-tile.tsx
new file mode 100644
index 00000000000..10d17ccc0f6
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-icon-tile.tsx
@@ -0,0 +1,14 @@
+import type { ComponentType } from 'react'
+
+interface DetailIconTileProps {
+ icon: ComponentType<{ className?: string }>
+}
+
+/** Square tile with a centered icon, used as a detail header's leading visual. */
+export function DetailIconTile({ icon: Icon }: DetailIconTileProps) {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-section.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-section.tsx
new file mode 100644
index 00000000000..3038dae285f
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/detail-section.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react'
+
+interface DetailSectionProps {
+ title: ReactNode
+ children: ReactNode
+}
+
+/**
+ * Labeled section with a muted title and a thin inset divider above the body.
+ * Shared by the credential detail surfaces so every section keeps the same
+ * vertical rhythm without repeating markup at the callsites.
+ */
+export function DetailSection({ title, children }: DetailSectionProps) {
+ return (
+
+ {title}
+
+ {children}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx
new file mode 100644
index 00000000000..93209511656
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/components/unsaved-changes-modal.tsx
@@ -0,0 +1,34 @@
+import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@/components/emcn'
+
+interface UnsavedChangesModalProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ /** Confirmed discard: abandon edits and continue (navigate away / close). */
+ onDiscard: () => void
+}
+
+/**
+ * Confirmation shown when leaving a surface with unsaved edits. Shared by the
+ * credential detail surfaces and the secrets list so the copy and affordances
+ * stay identical.
+ */
+export function UnsavedChangesModal({ open, onOpenChange, onDiscard }: UnsavedChangesModalProps) {
+ return (
+
+ Unsaved Changes
+
+
+ You have unsaved changes. Are you sure you want to discard them?
+
+
+
+ onOpenChange(false)}>
+ Keep Editing
+
+
+ Discard Changes
+
+
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
new file mode 100644
index 00000000000..f945d9c18ea
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts
@@ -0,0 +1,87 @@
+'use client'
+
+import { useCallback, useEffect, useState } from 'react'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+import { toast } from '@/components/emcn'
+import { useUpdateWorkspaceCredential, type WorkspaceCredential } from '@/hooks/queries/credentials'
+import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
+
+const logger = createLogger('CredentialDetailForm')
+
+interface UseCredentialDetailFormParams {
+ credential: WorkspaceCredential | null
+ isAdmin: boolean
+ /** Where the back link / discard navigates to. */
+ backHref: string
+}
+
+/**
+ * Shared editable-metadata controller for a credential detail page: Display Name
+ * and Description drafts seeded from the credential, dirty tracking, an
+ * admin-only save, and the shared unsaved-changes guard.
+ */
+export function useCredentialDetailForm({
+ credential,
+ isAdmin,
+ backHref,
+}: UseCredentialDetailFormParams) {
+ const updateCredential = useUpdateWorkspaceCredential()
+
+ const [displayNameDraft, setDisplayNameDraft] = useState('')
+ const [descriptionDraft, setDescriptionDraft] = useState('')
+
+ useEffect(() => {
+ setDisplayNameDraft(credential?.displayName ?? '')
+ setDescriptionDraft(credential?.description ?? '')
+ }, [credential?.id, credential?.displayName, credential?.description])
+
+ const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
+ const isDescriptionDirty = credential
+ ? descriptionDraft !== (credential.description || '')
+ : false
+ const isDirty = isDisplayNameDirty || isDescriptionDirty
+
+ const guard = useUnsavedChangesGuard({ isDirty, backHref })
+
+ const save = useCallback(async () => {
+ if (!credential || !isAdmin || !isDirty || updateCredential.isPending) return
+ try {
+ await updateCredential.mutateAsync({
+ credentialId: credential.id,
+ ...(isDisplayNameDirty ? { displayName: displayNameDraft.trim() } : {}),
+ ...(isDescriptionDirty ? { description: descriptionDraft.trim() || null } : {}),
+ })
+ if (isDisplayNameDirty) setDisplayNameDraft((value) => value.trim())
+ if (isDescriptionDirty) setDescriptionDraft((value) => value.trim())
+ } catch (error) {
+ toast.error("Couldn't save changes", {
+ description: getErrorMessage(error, 'Please try again in a moment.'),
+ })
+ logger.error('Failed to save credential details', error)
+ }
+ }, [
+ credential,
+ isAdmin,
+ isDirty,
+ isDisplayNameDirty,
+ isDescriptionDirty,
+ displayNameDraft,
+ descriptionDraft,
+ updateCredential,
+ ])
+
+ return {
+ displayNameDraft,
+ setDisplayNameDraft,
+ descriptionDraft,
+ setDescriptionDraft,
+ isDirty,
+ save,
+ isSaving: updateCredential.isPending,
+ handleBackClick: guard.handleBackClick,
+ showUnsavedAlert: guard.showUnsavedAlert,
+ setShowUnsavedAlert: guard.setShowUnsavedAlert,
+ confirmDiscard: guard.confirmDiscard,
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts
new file mode 100644
index 00000000000..7e3ac184867
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts
@@ -0,0 +1,79 @@
+'use client'
+
+import type { MouseEvent } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useRouter } from 'next/navigation'
+
+interface UseUnsavedChangesGuardParams {
+ isDirty: boolean
+ /** Where a confirmed discard navigates to. */
+ backHref: string
+}
+
+/**
+ * Guards a detail surface against losing unsaved edits: warns on browser unload
+ * while dirty, intercepts the in-app back link, and traps the browser
+ * back/forward button to confirm before leaving. Shared by every credential
+ * detail surface so the behavior is identical.
+ *
+ * Native Back is trapped by seeding a single same-URL history entry while dirty,
+ * so Back pops that entry (no route change) and fires popstate. The seed is
+ * removed once the form is clean again (save/revert) so it never accumulates
+ * across edit cycles — and that removal runs in the effect body (only while
+ * still mounted), never in cleanup, so an intentional discard/navigation away is
+ * not reversed.
+ */
+export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) {
+ const router = useRouter()
+ const [showUnsavedAlert, setShowUnsavedAlert] = useState(false)
+ const hasSentinelRef = useRef(false)
+
+ useEffect(() => {
+ if (!isDirty) {
+ // Clean again while still mounted (saved/reverted): pop the seeded entry so
+ // it can't pile up across edit/save cycles. This runs in the effect body,
+ // never on unmount, so navigating away mid-edit is never reversed.
+ if (hasSentinelRef.current) {
+ hasSentinelRef.current = false
+ window.history.back()
+ }
+ return
+ }
+ // Seed one same-URL entry so Back pops it (no route change) and fires
+ // popstate, letting us confirm before the page actually leaves.
+ if (!hasSentinelRef.current) {
+ window.history.pushState(null, '', window.location.href)
+ hasSentinelRef.current = true
+ }
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault()
+ }
+ const handlePopState = () => {
+ window.history.pushState(null, '', window.location.href)
+ setShowUnsavedAlert(true)
+ }
+ window.addEventListener('beforeunload', handleBeforeUnload)
+ window.addEventListener('popstate', handlePopState)
+ return () => {
+ window.removeEventListener('beforeunload', handleBeforeUnload)
+ window.removeEventListener('popstate', handlePopState)
+ }
+ }, [isDirty])
+
+ const handleBackClick = useCallback(
+ (event: MouseEvent) => {
+ if (isDirty) {
+ event.preventDefault()
+ setShowUnsavedAlert(true)
+ }
+ },
+ [isDirty]
+ )
+
+ const confirmDiscard = useCallback(() => {
+ setShowUnsavedAlert(false)
+ router.push(backHref)
+ }, [router, backHref])
+
+ return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts
new file mode 100644
index 00000000000..0c9b0befe52
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts
@@ -0,0 +1,11 @@
+export { AddPeopleModal } from './components/add-people-modal'
+export { CHIP_FIELD_INPUT, CHIP_FIELD_SHELL } from './components/chip-field'
+export { CopyableValueField } from './components/copyable-value-field'
+export { CredentialDetailHeading } from './components/credential-detail-heading'
+export { CredentialDetailLayout } from './components/credential-detail-layout'
+export { CredentialMembersSection } from './components/credential-members-section'
+export { DetailIconTile } from './components/detail-icon-tile'
+export { DetailSection } from './components/detail-section'
+export { UnsavedChangesModal } from './components/unsaved-changes-modal'
+export { useCredentialDetailForm } from './hooks/use-credential-detail-form'
+export { useUnsavedChangesGuard } from './hooks/use-unsaved-changes-guard'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts
new file mode 100644
index 00000000000..2956f8ed07d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/roles.ts
@@ -0,0 +1,15 @@
+import type { WorkspaceCredentialRole } from '@/hooks/queries/credentials'
+
+export interface CredentialRoleOption {
+ value: WorkspaceCredentialRole
+ label: string
+}
+
+/**
+ * Roles assignable to a credential member. Shared by every credential detail
+ * surface (Integrations, Secrets) so role choices never drift between them.
+ */
+export const ROLE_OPTIONS: readonly CredentialRoleOption[] = [
+ { value: 'member', label: 'Member' },
+ { value: 'admin', label: 'Admin' },
+] as const
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts
new file mode 100644
index 00000000000..c762ac9f0fc
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.test.ts
@@ -0,0 +1,69 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { partitionSettledFailures, resolveAddEmail } from './sharing'
+
+describe('resolveAddEmail', () => {
+ const ctx = {
+ workspaceUserIdByEmail: new Map([
+ ['ada@sim.dev', 'user-ada'],
+ ['grace@sim.dev', 'user-grace'],
+ ]),
+ existingMemberEmails: new Set(['grace@sim.dev']),
+ }
+
+ it('returns the userId for a workspace member who is not already on the credential', () => {
+ expect(resolveAddEmail('ada@sim.dev', ctx)).toEqual({ userId: 'user-ada' })
+ })
+
+ it('rejects an email that does not belong to any workspace member', () => {
+ expect(resolveAddEmail('nope@sim.dev', ctx)).toEqual({
+ error: "nope@sim.dev isn't a member of this workspace",
+ })
+ })
+
+ it('rejects an email that already has access to the credential', () => {
+ expect(resolveAddEmail('grace@sim.dev', ctx)).toEqual({
+ error: 'grace@sim.dev already has access',
+ })
+ })
+
+ it('matches case-insensitively while echoing the original email in errors', () => {
+ expect(resolveAddEmail('ADA@Sim.dev', ctx)).toEqual({ userId: 'user-ada' })
+ expect(resolveAddEmail('Grace@SIM.dev', ctx)).toEqual({
+ error: 'Grace@SIM.dev already has access',
+ })
+ })
+})
+
+describe('partitionSettledFailures', () => {
+ const targets = [{ email: 'a' }, { email: 'b' }, { email: 'c' }]
+
+ it('returns no failures when every result is fulfilled', () => {
+ const results: PromiseSettledResult[] = [
+ { status: 'fulfilled', value: 1 },
+ { status: 'fulfilled', value: 2 },
+ { status: 'fulfilled', value: 3 },
+ ]
+ expect(partitionSettledFailures(targets, results)).toEqual([])
+ })
+
+ it('returns only the rejected targets (index-aligned) on partial failure', () => {
+ const results: PromiseSettledResult[] = [
+ { status: 'fulfilled', value: 1 },
+ { status: 'rejected', reason: new Error('boom') },
+ { status: 'fulfilled', value: 3 },
+ ]
+ expect(partitionSettledFailures(targets, results)).toEqual([{ email: 'b' }])
+ })
+
+ it('returns all targets when every result rejected', () => {
+ const results: PromiseSettledResult[] = [
+ { status: 'rejected', reason: new Error('1') },
+ { status: 'rejected', reason: new Error('2') },
+ { status: 'rejected', reason: new Error('3') },
+ ]
+ expect(partitionSettledFailures(targets, results)).toEqual(targets)
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts
new file mode 100644
index 00000000000..f315ba0eedf
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/credential-detail/sharing.ts
@@ -0,0 +1,37 @@
+export interface ResolveAddEmailContext {
+ /** Lowercased email -> workspace userId for every workspace member. */
+ workspaceUserIdByEmail: Map
+ /** Lowercased emails that already have access to the credential. */
+ existingMemberEmails: Set
+}
+
+export type ResolveAddEmailResult = { userId: string } | { error: string }
+
+/**
+ * Decide whether a format-valid email can be added to a credential: it must
+ * belong to a workspace member and not already have access. Matching is
+ * case-insensitive (the context map/set are keyed by lowercased email) while
+ * error messages echo the email as the user typed it. Returns the resolved
+ * `userId` on success, or a user-facing `error` message.
+ */
+export function resolveAddEmail(
+ email: string,
+ { workspaceUserIdByEmail, existingMemberEmails }: ResolveAddEmailContext
+): ResolveAddEmailResult {
+ const normalized = email.toLowerCase()
+ const userId = workspaceUserIdByEmail.get(normalized)
+ if (!userId) return { error: `${email} isn't a member of this workspace` }
+ if (existingMemberEmails.has(normalized)) return { error: `${email} already has access` }
+ return { userId }
+}
+
+/**
+ * Given the targets passed to a batch add and the index-aligned
+ * `Promise.allSettled` results, return the targets whose settle rejected.
+ */
+export function partitionSettledFailures(
+ targets: readonly T[],
+ results: readonly PromiseSettledResult[]
+): T[] {
+ return targets.filter((_, index) => results[index]?.status === 'rejected')
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
index 77b481c7e53..f6279b6f9f3 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/error/error.tsx
@@ -1,64 +1,95 @@
'use client'
-import { useEffect } from 'react'
+import { type ReactNode, useEffect } from 'react'
import { createLogger } from '@sim/logger'
-import { RefreshCw } from 'lucide-react'
+import { TriangleAlert } from 'lucide-react'
import { Button } from '@/components/emcn'
-interface ErrorAction {
- label: string
- icon?: React.ReactNode
- onClick: () => void
- variant?: 'default' | 'ghost'
-}
-
-export interface ErrorStateProps {
+/** Props shape required by Next.js error boundary files (`error.tsx`). */
+export interface ErrorBoundaryProps {
error: Error & { digest?: string }
reset: () => void
+}
+
+export interface ErrorStateProps extends ErrorBoundaryProps {
title: string
description: string
loggerName: string
- secondaryAction?: ErrorAction
+ /** Optional glyph for the framed mark. Defaults to `TriangleAlert`. */
+ icon?: ReactNode
+ /** Extra action buttons rendered before the default "Try again". */
+ children?: ReactNode
}
+interface ErrorShellProps {
+ title: string
+ description: string
+ icon?: ReactNode
+ digest?: string
+ children: ReactNode
+}
+
+/**
+ * Centered layout shared by the workspace error boundary and not-found page.
+ * Renders a framed glyph, serif headline, supporting paragraph, optional
+ * digest pill, and a row of action buttons.
+ */
+export function ErrorShell({ title, description, icon, digest, children }: ErrorShellProps) {
+ return (
+
+
+
+
+
+ {title}
+
+
+ {description}
+
+
+ {digest && (
+
+ digest
+ {digest}
+
+ )}
+
{children}
+
+
+ )
+}
+
+/**
+ * Workspace error boundary view. Logs the error once per occurrence and renders
+ * `ErrorShell` with a primary "Try again" action. Pass extra buttons (e.g. "Go
+ * back") via `children` — they render before the "Try again" button.
+ */
export function ErrorState({
error,
reset,
title,
description,
loggerName,
- secondaryAction,
+ icon,
+ children,
}: ErrorStateProps) {
- const logger = createLogger(loggerName)
-
useEffect(() => {
- logger.error(`${loggerName} error:`, { error: error.message, digest: error.digest })
- }, [error, logger, loggerName])
+ createLogger(loggerName).error(`${loggerName} error:`, {
+ error: error.message,
+ digest: error.digest,
+ })
+ }, [error.message, error.digest, loggerName])
return (
-
-
-
-
{title}
-
{description}
-
-
- {secondaryAction && (
-
- {secondaryAction.icon}
- {secondaryAction.label}
-
- )}
-
-
- Try again
-
-
-
-
+
+ {children}
+
+ Refresh
+
+
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/error/index.ts b/apps/sim/app/workspace/[workspaceId]/components/error/index.ts
index 42787fb8c4f..6d39bb59d5c 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/error/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/error/index.ts
@@ -1 +1,2 @@
-export { ErrorState } from './error'
+export type { ErrorBoundaryProps, ErrorStateProps } from './error'
+export { ErrorShell, ErrorState } from './error'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner.tsx b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/components/impersonation-banner.tsx
rename to apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/impersonation-banner.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts
new file mode 100644
index 00000000000..dcfa353780c
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/impersonation-banner/index.ts
@@ -0,0 +1 @@
+export { ImpersonationBanner } from './impersonation-banner'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts
index bc76e7d77ad..d0876513a64 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts
@@ -1,8 +1,9 @@
export { ConversationListItem } from './conversation-list-item'
-export { ErrorState } from './error'
+export type { ErrorBoundaryProps, ErrorStateProps } from './error'
+export { ErrorShell, ErrorState } from './error'
export { InlineRenameInput } from './inline-rename-input'
export { MessageActions } from './message-actions'
-export { ownerCell } from './resource/components/owner-cell/owner-cell'
+export { ownerCell } from './resource/components/owner-cell'
export type {
BreadcrumbItem,
CreateAction,
@@ -16,7 +17,7 @@ export type {
SortConfig,
} from './resource/components/resource-options-bar'
export { ResourceOptionsBar } from './resource/components/resource-options-bar'
-export { timeCell } from './resource/components/time-cell/time-cell'
+export { timeCell } from './resource/components/time-cell'
export type {
PaginationConfig,
ResourceCell,
diff --git a/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/index.ts b/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/index.ts
new file mode 100644
index 00000000000..e9bef5fe7f7
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/index.ts
@@ -0,0 +1 @@
+export { InlineRenameInput } from './inline-rename-input'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input.tsx b/apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/components/inline-rename-input.tsx
rename to apps/sim/app/workspace/[workspaceId]/components/inline-rename-input/inline-rename-input.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
index 359f152fa0f..f5b2ebf22d0 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx
@@ -4,16 +4,14 @@ import { memo, useEffect, useRef, useState } from 'react'
import { GitBranch } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import {
- Button,
Check,
- Copy,
- Modal,
- ModalBody,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
- Textarea,
+ Chip,
+ ChipModal,
+ ChipModalBody,
+ ChipModalField,
+ ChipModalFooter,
+ ChipModalHeader,
+ Duplicate,
ThumbsDown,
ThumbsUp,
Tooltip,
@@ -46,9 +44,9 @@ function toPlainText(raw: string): string {
)
}
-const ICON_CLASS = 'h-[14px] w-[14px]'
+const ICON_CLASS = 'size-[14px]'
const BUTTON_CLASS =
- 'flex h-[26px] w-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
+ 'flex size-[26px] items-center justify-center rounded-[6px] text-[var(--text-icon)] transition-colors hover-hover:bg-[var(--surface-hover)] focus-visible:outline-none'
interface MessageActionsProps {
content: string
@@ -180,7 +178,7 @@ export const MessageActions = memo(function MessageActions({
onClick={copyToClipboard}
className={BUTTON_CLASS}
>
- {copied ? : }
+ {copied ? : }
@@ -236,62 +234,60 @@ export const MessageActions = memo(function MessageActions({
)}
-
-
- Give feedback
-
-
- Submit feedback about this response
-
-
-
-
- {pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
-
- {pendingFeedback === 'down' && requestId && (
-
-
-
- {copiedRequestId ? (
-
- ) : (
-
- )}
-
-
-
- {copiedRequestId ? 'Copied request ID' : 'Copy request ID'}
-
-
- )}
-
-
setFeedbackText(e.target.value)}
- rows={3}
- />
-
-
-
- handleModalClose(false)}>
- Cancel
-
-
- Submit feedback
-
-
-
-
+
+ handleModalClose(false)}>Give feedback
+
+
+
+ {pendingFeedback === 'up' ? 'What did you like?' : 'What could be improved?'}
+
+ {pendingFeedback === 'down' && requestId && (
+
+
+
+ {copiedRequestId ? (
+
+ ) : (
+
+ )}
+
+
+
+ {copiedRequestId ? 'Copied request ID' : 'Copy request ID'}
+
+
+ )}
+
+
+
+
+ handleModalClose(false)}>
+ Cancel
+
+
+ Submit
+
+
+
>
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.test.ts b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.test.ts
deleted file mode 100644
index d0ca2abfe6f..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.test.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-/**
- * @vitest-environment node
- */
-import { beforeEach, describe, expect, it, vi } from 'vitest'
-
-vi.mock('@/lib/auth/auth-client', () => ({
- client: { oauth2: { link: vi.fn() } },
- useSession: vi.fn(() => ({ data: null, isPending: false, error: null })),
-}))
-
-vi.mock('@/lib/credentials/client-state', () => ({
- writeOAuthReturnContext: vi.fn(),
-}))
-
-vi.mock('@/hooks/queries/credentials', () => ({
- useCreateCredentialDraft: vi.fn(() => ({ mutateAsync: vi.fn(), isPending: false })),
-}))
-
-vi.mock('@/lib/oauth', () => ({
- getCanonicalScopesForProvider: vi.fn(() => []),
- getProviderIdFromServiceId: vi.fn((id: string) => id),
- OAUTH_PROVIDERS: {},
- parseProvider: vi.fn((p: string) => ({ baseProvider: p, variant: null })),
-}))
-
-vi.mock('@/lib/oauth/utils', () => ({
- getScopeDescription: vi.fn((s: string) => s),
-}))
-
-import { getDefaultCredentialName } from '@/app/workspace/[workspaceId]/components/oauth-modal'
-
-describe('getDefaultCredentialName', () => {
- beforeEach(() => {
- vi.clearAllMocks()
- })
-
- it('uses the user name when available', () => {
- expect(getDefaultCredentialName('Waleed', 'Google Drive', 0)).toBe("Waleed's Google Drive 1")
- })
-
- it('increments the number based on existing credential count', () => {
- expect(getDefaultCredentialName('Waleed', 'Google Drive', 2)).toBe("Waleed's Google Drive 3")
- })
-
- it('falls back to "My" when user name is null', () => {
- expect(getDefaultCredentialName(null, 'Slack', 0)).toBe('My Slack 1')
- })
-
- it('falls back to "My" when user name is undefined', () => {
- expect(getDefaultCredentialName(undefined, 'Gmail', 1)).toBe('My Gmail 2')
- })
-
- it('falls back to "My" when user name is empty string', () => {
- expect(getDefaultCredentialName('', 'GitHub', 0)).toBe('My GitHub 1')
- })
-
- it('falls back to "My" when user name is whitespace-only', () => {
- expect(getDefaultCredentialName(' ', 'Notion', 0)).toBe('My Notion 1')
- })
-
- it('trims whitespace from user name', () => {
- expect(getDefaultCredentialName(' Waleed ', 'Linear', 0)).toBe("Waleed's Linear 1")
- })
-
- it('works with zero existing credentials', () => {
- expect(getDefaultCredentialName('Alice', 'Jira', 0)).toBe("Alice's Jira 1")
- })
-
- it('works with many existing credentials', () => {
- expect(getDefaultCredentialName('Bob', 'Slack', 9)).toBe("Bob's Slack 10")
- })
-})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx
deleted file mode 100644
index 4f028948794..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/oauth-modal.tsx
+++ /dev/null
@@ -1,313 +0,0 @@
-'use client'
-
-import { useMemo, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { Check } from 'lucide-react'
-import {
- Badge,
- Button,
- Input,
- Label,
- Modal,
- ModalBody,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
-} from '@/components/emcn'
-import { client, useSession } from '@/lib/auth/auth-client'
-import type { OAuthReturnContext } from '@/lib/credentials/client-state'
-import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
-import {
- getCanonicalScopesForProvider,
- getProviderIdFromServiceId,
- OAUTH_PROVIDERS,
- type OAuthProvider,
- parseProvider,
-} from '@/lib/oauth'
-import { getScopeDescription } from '@/lib/oauth/utils'
-import { useCreateCredentialDraft } from '@/hooks/queries/credentials'
-
-const logger = createLogger('OAuthModal')
-const EMPTY_SCOPES: string[] = []
-
-/**
- * Generates a default credential display name.
- * Format: "{User}'s {Provider} {N}" or "My {Provider} {N}" when no user name is available.
- */
-export function getDefaultCredentialName(
- userName: string | null | undefined,
- providerName: string,
- credentialCount: number
-): string {
- const trimmed = userName?.trim()
- const num = credentialCount + 1
- if (trimmed) {
- return `${trimmed}'s ${providerName} ${num}`
- }
- return `My ${providerName} ${num}`
-}
-
-interface OAuthModalBaseProps {
- isOpen: boolean
- onClose: () => void
- provider: OAuthProvider
- serviceId: string
-}
-
-type OAuthModalConnectProps = OAuthModalBaseProps & {
- mode: 'connect'
- workspaceId: string
- credentialCount: number
-} & (
- | { workflowId: string; knowledgeBaseId?: never; connectorType?: never }
- | { workflowId?: never; knowledgeBaseId: string; connectorType?: string }
- )
-
-interface OAuthModalReauthorizeProps extends OAuthModalBaseProps {
- mode: 'reauthorize'
- toolName: string
- requiredScopes?: string[]
- newScopes?: string[]
- onConnect?: () => Promise
| void
-}
-
-export type OAuthModalProps = OAuthModalConnectProps | OAuthModalReauthorizeProps
-
-export function OAuthModal(props: OAuthModalProps) {
- const { isOpen, onClose, provider, serviceId, mode } = props
-
- const isConnect = mode === 'connect'
- const credentialCount = isConnect ? props.credentialCount : 0
- const workspaceId = isConnect ? props.workspaceId : ''
- const workflowId = isConnect ? props.workflowId : undefined
- const knowledgeBaseId = isConnect ? props.knowledgeBaseId : undefined
- const connectorType = isConnect ? props.connectorType : undefined
- const toolName = !isConnect ? props.toolName : ''
- const requiredScopes = !isConnect ? (props.requiredScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
- const newScopes = !isConnect ? (props.newScopes ?? EMPTY_SCOPES) : EMPTY_SCOPES
- const onConnectOverride = !isConnect ? props.onConnect : undefined
-
- const { data: session } = useSession()
- const [error, setError] = useState(null)
- const createDraft = useCreateCredentialDraft()
-
- const { providerName, ProviderIcon } = useMemo(() => {
- const { baseProvider } = parseProvider(provider)
- const baseProviderConfig = OAUTH_PROVIDERS[baseProvider]
- let name = baseProviderConfig?.name || provider
- let Icon = baseProviderConfig?.icon || (() => null)
- if (baseProviderConfig) {
- for (const [key, service] of Object.entries(baseProviderConfig.services)) {
- if (key === serviceId || service.providerId === provider) {
- name = service.name
- Icon = service.icon
- break
- }
- }
- }
- return { providerName: name, ProviderIcon: Icon }
- }, [provider, serviceId])
-
- const providerId = getProviderIdFromServiceId(serviceId)
-
- const [displayName, setDisplayName] = useState(() =>
- isConnect ? getDefaultCredentialName(session?.user?.name, providerName, credentialCount) : ''
- )
-
- const newScopesSet = useMemo(
- () =>
- new Set(
- newScopes.filter(
- (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
- )
- ),
- [newScopes]
- )
-
- const displayScopes = useMemo(() => {
- if (isConnect) {
- return getCanonicalScopesForProvider(providerId).filter(
- (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
- )
- }
- const filtered = requiredScopes.filter(
- (scope) => !scope.includes('userinfo.email') && !scope.includes('userinfo.profile')
- )
- return filtered.sort((a, b) => {
- const aIsNew = newScopesSet.has(a)
- const bIsNew = newScopesSet.has(b)
- if (aIsNew && !bIsNew) return -1
- if (!aIsNew && bIsNew) return 1
- return 0
- })
- }, [isConnect, providerId, requiredScopes, newScopesSet])
-
- const handleClose = () => {
- setError(null)
- onClose()
- }
-
- const handleConnect = async () => {
- setError(null)
-
- try {
- if (isConnect) {
- const trimmedName = displayName.trim()
- if (!trimmedName) {
- setError('Display name is required.')
- return
- }
-
- await createDraft.mutateAsync({
- workspaceId,
- providerId,
- displayName: trimmedName,
- })
-
- const baseContext = {
- displayName: trimmedName,
- providerId,
- preCount: credentialCount,
- workspaceId,
- requestedAt: Date.now(),
- }
-
- const returnContext: OAuthReturnContext = knowledgeBaseId
- ? { ...baseContext, origin: 'kb-connectors' as const, knowledgeBaseId, connectorType }
- : { ...baseContext, origin: 'workflow' as const, workflowId: workflowId! }
-
- writeOAuthReturnContext(returnContext)
- }
-
- if (!isConnect && onConnectOverride) {
- await onConnectOverride()
- onClose()
- return
- }
-
- if (!isConnect) {
- logger.info('Linking OAuth2:', {
- providerId,
- requiredScopes,
- hasNewScopes: newScopes.length > 0,
- })
- }
-
- if (providerId === 'trello') {
- if (!isConnect) onClose()
- window.location.href = '/api/auth/trello/authorize'
- return
- }
-
- if (providerId === 'shopify') {
- if (!isConnect) onClose()
- const returnUrl = encodeURIComponent(window.location.href)
- window.location.href = `/api/auth/shopify/authorize?returnUrl=${returnUrl}`
- return
- }
-
- const callbackURL = new URL(window.location.href)
- if (connectorType) {
- callbackURL.searchParams.set(ADD_CONNECTOR_SEARCH_PARAM, connectorType)
- }
- await client.oauth2.link({ providerId, callbackURL: callbackURL.toString() })
- handleClose()
- } catch (err) {
- logger.error('Failed to initiate OAuth connection', { error: err })
- setError('Failed to connect. Please try again.')
- }
- }
-
- const isPending = isConnect && createDraft.isPending
- const isConnectDisabled = isConnect ? !displayName.trim() || Boolean(isPending) : false
-
- const subtitle = isConnect
- ? `Grant access to use ${providerName} in your ${knowledgeBaseId ? 'knowledge base' : 'workflow'}`
- : `The "${toolName}" tool requires access to your account`
-
- return (
- !open && handleClose()}>
-
- Connect {providerName}
-
-
- Connect your {providerName} account to grant access
-
-
-
-
-
-
- Connect your {providerName} account
-
-
{subtitle}
-
-
-
- {displayScopes.length > 0 && (
-
-
-
- Permissions requested
-
-
-
-
- )}
-
- {isConnect && (
-
-
- Display name *
-
- {
- setDisplayName(e.target.value)
- setError(null)
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !isPending) void handleConnect()
- }}
- placeholder={`My ${providerName} account`}
- autoComplete='off'
- data-lpignore='true'
- className='mt-1.5'
- />
-
- )}
-
- {error &&
{error}
}
-
-
-
-
- Cancel
-
-
- {isPending ? 'Connecting...' : 'Connect'}
-
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/index.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/index.ts
deleted file mode 100644
index 38779f28cbb..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { NavTour, START_NAV_TOUR_EVENT } from './product-tour'
-export { START_WORKFLOW_TOUR_EVENT, WorkflowTour } from './workflow-tour'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts
deleted file mode 100644
index c18aae9734e..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import type { Step } from 'react-joyride'
-
-export const navTourSteps: Step[] = [
- {
- target: '[data-tour="nav-home"]',
- title: 'Home',
- content:
- 'Your starting point. Describe what you want to build in plain language or pick a template to get started.',
- placement: 'right',
- disableBeacon: true,
- spotlightPadding: 0,
- },
- {
- target: '[data-tour="nav-search"]',
- title: 'Search',
- content: 'Quickly find workflows, blocks, and tools. Use Cmd+K to open it from anywhere.',
- placement: 'right',
- disableBeacon: true,
- spotlightPadding: 0,
- },
- {
- target: '[data-tour="nav-tables"]',
- title: 'Tables',
- content:
- 'Store and query structured data. Your workflows can read and write to tables directly.',
- placement: 'right',
- disableBeacon: true,
- },
- {
- target: '[data-tour="nav-files"]',
- title: 'Files',
- content: 'Upload and manage files that your workflows can process, transform, or reference.',
- placement: 'right',
- disableBeacon: true,
- },
- {
- target: '[data-tour="nav-knowledge-base"]',
- title: 'Knowledge Base',
- content:
- 'Build knowledge bases from your documents. Set up connectors to give your agents realtime access to your data sources from sources like Notion, Drive, Slack, Confluence, and more.',
- placement: 'right',
- disableBeacon: true,
- },
- {
- target: '[data-tour="nav-scheduled-tasks"]',
- title: 'Scheduled Tasks',
- content:
- 'View and manage background tasks. Set up new tasks, or view the tasks the Mothership is monitoring for upcoming or past executions.',
- placement: 'right',
- disableBeacon: true,
- },
- {
- target: '[data-tour="nav-logs"]',
- title: 'Logs',
- content:
- 'Monitor every workflow execution. See inputs, outputs, errors, and timing for each run. View analytics on performance and costs, filter previous runs, and view snapshots of the workflow at the time of execution.',
- placement: 'right',
- disableBeacon: true,
- },
- {
- target: '[data-tour="nav-tasks"]',
- title: 'Tasks',
- content:
- 'Tasks that work for you. Mothership can create, edit, and delete resources throughout the platform. It can also perform actions on your behalf, like sending emails, creating tasks, and more.',
- placement: 'right',
- disableBeacon: true,
- },
- {
- target: '[data-tour="nav-workflows"]',
- title: 'Workflows',
- content:
- 'All your workflows live here. Create new ones with the + button and organize them into folders. Deploy your workflows as API, webhook, schedule, or chat widget. Then hit Run to test it out.',
- placement: 'right',
- disableBeacon: true,
- },
-]
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/product-tour.tsx b/apps/sim/app/workspace/[workspaceId]/components/product-tour/product-tour.tsx
deleted file mode 100644
index 11487f191db..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/product-tour.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-'use client'
-
-import { useMemo } from 'react'
-import dynamic from 'next/dynamic'
-import { usePathname } from 'next/navigation'
-import { navTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/nav-tour-steps'
-import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
-import {
- getSharedJoyrideProps,
- TourStateContext,
- TourTooltipAdapter,
-} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
-import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
-
-const Joyride = dynamic(() => import('react-joyride'), {
- ssr: false,
-})
-
-export const START_NAV_TOUR_EVENT = 'start-nav-tour'
-
-export function NavTour() {
- const pathname = usePathname()
- const isWorkflowPage = /\/w\/[^/]+/.test(pathname)
-
- const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
- steps: navTourSteps,
- triggerEvent: START_NAV_TOUR_EVENT,
- tourName: 'Navigation tour',
- tourType: 'nav',
- disabled: isWorkflowPage,
- })
-
- const tourState = useMemo(
- () => ({
- isTooltipVisible,
- isEntrance,
- totalSteps: navTourSteps.length,
- }),
- [isTooltipVisible, isEntrance]
- )
-
- return (
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx b/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx
deleted file mode 100644
index 774ed8ad876..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/tour-shared.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client'
-
-import { createContext, useCallback, useContext } from 'react'
-import type { TooltipRenderProps } from 'react-joyride'
-import { TourTooltip } from '@/components/emcn'
-
-/** Shared state passed from the tour component to the tooltip adapter via context */
-export interface TourState {
- isTooltipVisible: boolean
- isEntrance: boolean
- totalSteps: number
-}
-
-export const TourStateContext = createContext({
- isTooltipVisible: true,
- isEntrance: true,
- totalSteps: 0,
-})
-
-/**
- * Maps Joyride placement strings to TourTooltip placement values.
- */
-function mapPlacement(placement?: string): 'top' | 'right' | 'bottom' | 'left' | 'center' {
- switch (placement) {
- case 'top':
- case 'top-start':
- case 'top-end':
- return 'top'
- case 'right':
- case 'right-start':
- case 'right-end':
- return 'right'
- case 'bottom':
- case 'bottom-start':
- case 'bottom-end':
- return 'bottom'
- case 'left':
- case 'left-start':
- case 'left-end':
- return 'left'
- case 'center':
- return 'center'
- default:
- return 'bottom'
- }
-}
-
-/**
- * Adapter that bridges Joyride's tooltip render props to the EMCN TourTooltip component.
- * Reads transition state from TourStateContext to coordinate fade animations.
- */
-export function TourTooltipAdapter({
- step,
- index,
- isLastStep,
- tooltipProps,
- primaryProps,
- backProps,
- closeProps,
-}: TooltipRenderProps) {
- const { isTooltipVisible, isEntrance, totalSteps } = useContext(TourStateContext)
-
- const { target } = step
- const targetEl =
- typeof target === 'string'
- ? document.querySelector(target)
- : target instanceof HTMLElement
- ? target
- : null
-
- /**
- * Forwards the Joyride tooltip ref safely, handling both
- * callback refs and RefObject refs from the library.
- * Memoized to prevent ref churn (null → node cycling) on re-renders.
- */
- const setJoyrideRef = useCallback(
- (node: HTMLDivElement | null) => {
- const { ref } = tooltipProps
- if (!ref) return
- if (typeof ref === 'function') {
- ref(node)
- } else {
- ;(ref as React.MutableRefObject).current = node
- }
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [tooltipProps.ref]
- )
-
- const placement = mapPlacement(step.placement)
-
- return (
- <>
-
- void}
- onBack={backProps.onClick as () => void}
- onClose={closeProps.onClick as () => void}
- />
- >
- )
-}
-
-const SPOTLIGHT_TRANSITION =
- 'top 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), left 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), width 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94), height 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94)'
-
-/**
- * Returns the shared Joyride floaterProps and styles config used by both tours.
- * Only `spotlightPadding` and spotlight `borderRadius` differ between tours.
- */
-export function getSharedJoyrideProps(overrides: { spotlightBorderRadius: number }) {
- return {
- floaterProps: {
- disableAnimation: true,
- hideArrow: true,
- styles: {
- floater: {
- filter: 'none',
- opacity: 0,
- pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
- width: 0,
- height: 0,
- },
- },
- },
- styles: {
- options: {
- zIndex: 10000,
- },
- spotlight: {
- backgroundColor: 'transparent',
- border: '1px solid rgba(255, 255, 255, 0.1)',
- borderRadius: overrides.spotlightBorderRadius,
- boxShadow: '0 0 0 9999px rgba(0, 0, 0, 0.55)',
- position: 'fixed' as React.CSSProperties['position'],
- transition: SPOTLIGHT_TRANSITION,
- },
- overlay: {
- backgroundColor: 'transparent',
- mixBlendMode: 'unset' as React.CSSProperties['mixBlendMode'],
- position: 'fixed' as React.CSSProperties['position'],
- height: '100%',
- overflow: 'visible',
- pointerEvents: 'none' as React.CSSProperties['pointerEvents'],
- },
- },
- } as const
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts
deleted file mode 100644
index dc41bf013fe..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/use-tour.ts
+++ /dev/null
@@ -1,236 +0,0 @@
-'use client'
-
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { createLogger } from '@sim/logger'
-import { usePostHog } from 'posthog-js/react'
-import { ACTIONS, type CallBackProps, EVENTS, STATUS, type Step } from 'react-joyride'
-import { captureEvent } from '@/lib/posthog/client'
-
-const logger = createLogger('useTour')
-
-/** Transition delay before updating step index (ms) */
-const FADE_OUT_MS = 80
-
-interface UseTourOptions {
- /** Tour step definitions */
- steps: Step[]
- /** Custom event name to listen for manual triggers */
- triggerEvent?: string
- /** Identifier for logging */
- tourName?: string
- /** Analytics tour type for PostHog events */
- tourType?: 'nav' | 'workflow'
- /** When true, stops a running tour (e.g. navigating away from the relevant page) */
- disabled?: boolean
-}
-
-interface UseTourReturn {
- /** Whether the tour is currently running */
- run: boolean
- /** Current step index */
- stepIndex: number
- /** Key to force Joyride remount on retrigger */
- tourKey: number
- /** Whether the tooltip is visible (false during step transitions) */
- isTooltipVisible: boolean
- /** Whether this is the initial entrance animation */
- isEntrance: boolean
- /** Joyride callback handler */
- handleCallback: (data: CallBackProps) => void
-}
-
-/**
- * Shared hook for managing product tour state with smooth transitions.
- *
- * Handles manual triggering via custom events and coordinated fade
- * transitions between steps to prevent layout shift.
- */
-export function useTour({
- steps,
- triggerEvent,
- tourName = 'tour',
- tourType,
- disabled = false,
-}: UseTourOptions): UseTourReturn {
- const posthog = usePostHog()
- const [run, setRun] = useState(false)
- const [stepIndex, setStepIndex] = useState(0)
- const [tourKey, setTourKey] = useState(0)
- const [isTooltipVisible, setIsTooltipVisible] = useState(true)
- const [isEntrance, setIsEntrance] = useState(true)
-
- const retriggerTimerRef = useRef | null>(null)
- const transitionTimerRef = useRef | null>(null)
- const rafRef = useRef(null)
-
- /**
- * Schedules a two-frame rAF to reveal the tooltip after the browser
- * finishes repositioning. Stores the outer frame ID in `rafRef` so
- * it can be cancelled on unmount or when the tour is interrupted.
- */
- const scheduleReveal = useCallback(() => {
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current)
- }
- rafRef.current = requestAnimationFrame(() => {
- rafRef.current = requestAnimationFrame(() => {
- rafRef.current = null
- setIsTooltipVisible(true)
- })
- })
- }, [])
-
- /** Cancels any pending transition timer and rAF reveal */
- const cancelPendingTransitions = useCallback(() => {
- if (transitionTimerRef.current) {
- clearTimeout(transitionTimerRef.current)
- transitionTimerRef.current = null
- }
- if (rafRef.current) {
- cancelAnimationFrame(rafRef.current)
- rafRef.current = null
- }
- }, [])
-
- const stopTour = useCallback(() => {
- cancelPendingTransitions()
- setRun(false)
- setIsTooltipVisible(true)
- setIsEntrance(true)
- }, [cancelPendingTransitions])
-
- /** Transition to a new step with a coordinated fade-out/fade-in */
- const transitionToStep = useCallback(
- (newIndex: number) => {
- if (newIndex < 0 || newIndex >= steps.length) {
- stopTour()
- return
- }
-
- setIsTooltipVisible(false)
- cancelPendingTransitions()
-
- transitionTimerRef.current = setTimeout(() => {
- transitionTimerRef.current = null
- setStepIndex(newIndex)
- setIsEntrance(false)
- scheduleReveal()
- }, FADE_OUT_MS)
- },
- [steps.length, stopTour, cancelPendingTransitions, scheduleReveal]
- )
-
- useEffect(() => {
- if (!run) return
- const html = document.documentElement
- const prev = html.style.scrollbarGutter
- html.style.scrollbarGutter = 'stable'
- return () => {
- html.style.scrollbarGutter = prev
- }
- }, [run])
-
- /** Stop the tour when disabled becomes true (e.g. navigating away from the relevant page) */
- useEffect(() => {
- if (disabled && run) {
- stopTour()
- logger.info(`${tourName} paused — disabled became true`)
- }
- }, [disabled, run, tourName, stopTour])
-
- /** Listen for manual trigger events */
- useEffect(() => {
- if (!triggerEvent) return
-
- const handleTrigger = () => {
- setRun(false)
- setTourKey((k) => k + 1)
-
- if (retriggerTimerRef.current) {
- clearTimeout(retriggerTimerRef.current)
- }
-
- retriggerTimerRef.current = setTimeout(() => {
- retriggerTimerRef.current = null
- setStepIndex(0)
- setIsEntrance(true)
- setIsTooltipVisible(false)
- setRun(true)
- logger.info(`${tourName} triggered via event`)
- scheduleReveal()
- if (tourType) {
- captureEvent(posthog, 'tour_started', { tour_type: tourType })
- }
- }, 50)
- }
-
- window.addEventListener(triggerEvent, handleTrigger)
- return () => {
- window.removeEventListener(triggerEvent, handleTrigger)
- if (retriggerTimerRef.current) {
- clearTimeout(retriggerTimerRef.current)
- }
- }
- }, [triggerEvent, tourName, scheduleReveal])
-
- /** Clean up all pending async work on unmount */
- useEffect(() => {
- return () => {
- cancelPendingTransitions()
- if (retriggerTimerRef.current) {
- clearTimeout(retriggerTimerRef.current)
- }
- }
- }, [cancelPendingTransitions])
-
- const handleCallback = useCallback(
- (data: CallBackProps) => {
- const { action, index, status, type } = data
-
- if (status === STATUS.FINISHED || status === STATUS.SKIPPED) {
- stopTour()
- logger.info(`${tourName} ended`, { status })
- if (tourType) {
- if (status === STATUS.FINISHED) {
- captureEvent(posthog, 'tour_completed', { tour_type: tourType })
- } else {
- captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index })
- }
- }
- return
- }
-
- if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) {
- if (action === ACTIONS.CLOSE) {
- stopTour()
- logger.info(`${tourName} closed by user`)
- if (tourType) {
- captureEvent(posthog, 'tour_skipped', { tour_type: tourType, step_index: index })
- }
- return
- }
-
- const nextIndex = index + (action === ACTIONS.PREV ? -1 : 1)
-
- if (type === EVENTS.TARGET_NOT_FOUND) {
- logger.info(`${tourName} step target not found, skipping`, {
- stepIndex: index,
- target: steps[index]?.target,
- })
- }
-
- transitionToStep(nextIndex)
- }
- },
- [stopTour, transitionToStep, steps, tourName, tourType, posthog]
- )
-
- return {
- run,
- stepIndex,
- tourKey,
- isTooltipVisible,
- isEntrance,
- handleCallback,
- }
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps.ts b/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps.ts
deleted file mode 100644
index cb7105eaf68..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { Step } from 'react-joyride'
-
-export const workflowTourSteps: Step[] = [
- {
- target: '[data-tour="canvas"]',
- title: 'The Canvas',
- content:
- 'This is where you build visually. Drag blocks onto the canvas and connect them to create AI workflows.',
- placement: 'center',
- disableBeacon: true,
- },
- {
- target: '[data-tour="tab-copilot"]',
- title: 'AI Copilot',
- content:
- 'Build and debug workflows using natural language. Describe what you want and Copilot creates the blocks for you.',
- placement: 'bottom',
- disableBeacon: true,
- spotlightPadding: 0,
- },
- {
- target: '[data-tour="tab-toolbar"]',
- title: 'Block Library',
- content:
- 'Browse all available blocks and triggers. Drag them onto the canvas to build your workflow step by step.',
- placement: 'bottom',
- disableBeacon: true,
- spotlightPadding: 0,
- },
- {
- target: '[data-tour="tab-editor"]',
- title: 'Block Editor',
- content:
- 'Click any block on the canvas to configure it here. Set inputs, credentials, and fine-tune behavior.',
- placement: 'bottom',
- disableBeacon: true,
- spotlightPadding: 0,
- },
- {
- target: '[data-tour="deploy-run"]',
- title: 'Deploy & Run',
- content:
- 'Deploy your workflow as an API, webhook, schedule, or chat widget. Then hit Run to test it out.',
- placement: 'bottom',
- disableBeacon: true,
- },
- {
- target: '[data-tour="workflow-controls"]',
- title: 'Canvas Controls',
- content:
- 'Switch between pointer and hand mode, undo or redo changes, and fit the canvas to your view.',
- placement: 'top',
- spotlightPadding: 0,
- disableBeacon: true,
- },
-]
diff --git a/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx b/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx
deleted file mode 100644
index d9c7f334549..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/components/product-tour/workflow-tour.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-'use client'
-
-import { useMemo } from 'react'
-import dynamic from 'next/dynamic'
-import type { TourState } from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
-import {
- getSharedJoyrideProps,
- TourStateContext,
- TourTooltipAdapter,
-} from '@/app/workspace/[workspaceId]/components/product-tour/tour-shared'
-import { useTour } from '@/app/workspace/[workspaceId]/components/product-tour/use-tour'
-import { workflowTourSteps } from '@/app/workspace/[workspaceId]/components/product-tour/workflow-tour-steps'
-
-const Joyride = dynamic(() => import('react-joyride'), {
- ssr: false,
-})
-
-export const START_WORKFLOW_TOUR_EVENT = 'start-workflow-tour'
-
-/**
- * Workflow tour that covers the canvas, blocks, copilot, and deployment.
- * Triggered via "Take a tour" in the sidebar menu.
- */
-export function WorkflowTour() {
- const { run, stepIndex, tourKey, isTooltipVisible, isEntrance, handleCallback } = useTour({
- steps: workflowTourSteps,
- triggerEvent: START_WORKFLOW_TOUR_EVENT,
- tourName: 'Workflow tour',
- tourType: 'workflow',
- })
-
- const tourState = useMemo(
- () => ({
- isTooltipVisible,
- isEntrance,
- totalSteps: workflowTourSteps.length,
- }),
- [isTooltipVisible, isEntrance]
- )
-
- return (
-
-
-
- )
-}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx
new file mode 100644
index 00000000000..c05757d2fb2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text.tsx
@@ -0,0 +1,57 @@
+'use client'
+
+import type React from 'react'
+import { memo } from 'react'
+import {
+ FloatingTooltip,
+ isTextClipped,
+ useFloatingTooltip,
+ useIsOverflowing,
+} from '@/components/emcn'
+import { cn } from '@/lib/core/utils/cn'
+
+interface FloatingOverflowTextProps {
+ /** Full text shown in the tooltip and used as the default visible content. */
+ label: string
+ /** Optional custom visible content (e.g. highlighted text); defaults to `label`. */
+ children?: React.ReactNode
+ className?: string
+ /** Forces the tooltip even when the text is not visually clipped (e.g. content truncated upstream). */
+ showWhen?: boolean
+}
+
+/**
+ * Truncating text that fades its clipped edge and reveals the full value in a
+ * pointer-reactive floating tooltip on hover or focus.
+ */
+export const FloatingOverflowText = memo(function FloatingOverflowText({
+ label,
+ children,
+ className,
+ showWhen,
+}: FloatingOverflowTextProps) {
+ const { ref: textRef, node, isOverflowing } = useIsOverflowing()
+ const { state, handlers } = useFloatingTooltip(() => {
+ const element = node.current
+ if (!element || label.length === 0) return false
+ return Boolean(showWhen) || isTextClipped(element)
+ })
+
+ return (
+ <>
+
+ {children ?? label}
+
+
+ >
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts
new file mode 100644
index 00000000000..fa102e05d3a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts
@@ -0,0 +1 @@
+export { ownerCell } from './owner-cell'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
index 84f5b7f2485..5fa3411e222 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx
@@ -1,15 +1,28 @@
-import { Fragment, memo } from 'react'
+import { Fragment, forwardRef, memo, useEffect, useRef, useState } from 'react'
+import { ArrowUpLeft } from 'lucide-react'
+import { createPortal } from 'react-dom'
import {
Button,
ChevronDown,
+ chipVariants,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
+ FloatingTooltip,
Plus,
+ POPOVER_ANIMATION_CLASSES,
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+ PopoverItem,
+ PopoverSection,
+ useFloatingTooltip,
+ useIsOverflowing,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input'
+import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
const HEADER_PLUS_ICON =
@@ -30,9 +43,15 @@ export interface BreadcrumbEditing {
export interface BreadcrumbItem {
label: string
+ icon?: React.ElementType
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
+ /**
+ * Marks a non-navigable trailing crumb (e.g. "New Chunk", "Loading...") so the
+ * header sizes it as the terminal segment rather than the current resource.
+ */
+ terminal?: boolean
}
export interface HeaderAction {
@@ -77,38 +96,76 @@ export const ResourceHeader = memo(function ResourceHeader({
trailingActions,
createTrigger,
}: ResourceHeaderProps) {
+ const headerRef = useRef(null)
const hasBreadcrumbs = breadcrumbs && breadcrumbs.length > 0
+ const terminalBreadcrumbIndex =
+ hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1
+ const currentResourceIndex =
+ terminalBreadcrumbIndex > -1
+ ? terminalBreadcrumbIndex - 1
+ : hasBreadcrumbs && breadcrumbs.length > 2
+ ? breadcrumbs.length - 1
+ : -1
return (
-
-
+
+
{hasBreadcrumbs ? (
- breadcrumbs.map((crumb, i) => (
-
- {i > 0 && / }
-
-
- ))
+ breadcrumbs.map((crumb, i) => {
+ const segmentClassName = getBreadcrumbSegmentClassName(
+ i,
+ breadcrumbs.length,
+ currentResourceIndex,
+ terminalBreadcrumbIndex
+ )
+ const LocationIcon = i === 0 ? (crumb.icon ?? Icon) : undefined
+
+ return (
+
+ {i > 0 && (
+
+ /
+
+ )}
+ {LocationIcon ? (
+
+ ) : (
+
+ )}
+
+ )
+ })
) : (
<>
- {Icon && }
- {title &&
{title} }
+ {Icon && }
+ {title && (
+
+
+
+ )}
>
)}
-
+
{leadingActions}
{actions?.map((action) => {
const ActionIcon = action.icon
@@ -119,7 +176,7 @@ export const ResourceHeader = memo(function ResourceHeader({
disabled={action.disabled}
variant='subtle'
className={cn(
- 'px-2 py-1 text-caption',
+ 'whitespace-nowrap px-2 py-1 text-caption',
action.active !== undefined && 'rounded-lg',
action.active === true &&
'bg-[var(--surface-active)] hover-hover:bg-[var(--surface-active)]',
@@ -128,10 +185,7 @@ export const ResourceHeader = memo(function ResourceHeader({
>
{ActionIcon && (
)}
{action.label}
@@ -145,7 +199,7 @@ export const ResourceHeader = memo(function ResourceHeader({
onClick={create.onClick}
disabled={create.disabled}
variant='subtle'
- className='px-2 py-1 text-caption'
+ className='whitespace-nowrap px-2 py-1 text-caption'
>
{HEADER_PLUS_ICON}
{create.label}
@@ -157,12 +211,42 @@ export const ResourceHeader = memo(function ResourceHeader({
)
})
+function getBreadcrumbSegmentClassName(
+ index: number,
+ total: number,
+ currentResourceIndex: number,
+ terminalBreadcrumbIndex: number
+): string {
+ if (index === terminalBreadcrumbIndex) {
+ return 'shrink-0 max-w-[10rem]'
+ }
+
+ if (index === 0) {
+ return 'shrink-0'
+ }
+
+ if (currentResourceIndex > -1) {
+ if (index === currentResourceIndex) {
+ return 'min-w-0 flex-[0_1_auto] max-w-[56%]'
+ }
+
+ return 'min-w-0 flex-[0_1_auto] max-w-[34%]'
+ }
+
+ if (total > 2) {
+ return 'min-w-0 flex-[0_1_auto] max-w-[42%]'
+ }
+
+ return 'min-w-0 flex-[0_1_auto] max-w-[min(32rem,55vw)]'
+}
+
interface BreadcrumbSegmentProps {
icon?: React.ElementType
label: string
onClick?: () => void
dropdownItems?: DropdownOption[]
editing?: BreadcrumbEditing
+ className?: string
}
const BreadcrumbSegment = memo(function BreadcrumbSegment({
@@ -171,10 +255,16 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
onClick,
dropdownItems,
editing,
+ className,
}: BreadcrumbSegmentProps) {
+ const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing
()
+ const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) =>
+ isBreadcrumbTextClipped(labelNode.current, target)
+ )
+
if (editing?.isEditing) {
return (
-
+
{Icon && }
- {Icon && }
- {label}
+ {Icon && }
+
>
)
+ const triggerClassName = cn(
+ chipVariants({ variant: 'ghost', flush: true }),
+ 'group min-w-0 max-w-full justify-start font-medium transition-colors'
+ )
if (dropdownItems && dropdownItems.length > 0) {
return (
-
-
-
- {content}
-
-
-
-
- {dropdownItems.map((item) => {
- const ItemIcon = item.icon
- return (
-
- {ItemIcon && }
- {item.label}
-
- )
- })}
-
-
+ <>
+
+
+
+
+ {content}
+
+
+
+
+ {dropdownItems.map((item) => {
+ const ItemIcon = item.icon
+ return (
+
+ {ItemIcon && }
+ {item.label}
+
+ )
+ })}
+
+
+ >
)
}
if (onClick) {
return (
-
- {content}
-
+ <>
+
+
+ {content}
+
+ >
)
}
return (
-
- {content}
-
+ <>
+
+
+ {content}
+
+ >
)
})
+
+interface BreadcrumbLocationPopoverProps {
+ icon: React.ElementType
+ breadcrumbs: BreadcrumbItem[]
+ className?: string
+ veilBoundaryRef: React.RefObject
+}
+
+function BreadcrumbLocationPopover({
+ icon: Icon,
+ breadcrumbs,
+ className,
+ veilBoundaryRef,
+}: BreadcrumbLocationPopoverProps) {
+ const [open, setOpen] = useState(false)
+ const closeTimeoutRef = useRef | null>(null)
+ const rootBreadcrumb = breadcrumbs[0]
+
+ const openPopover = () => {
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current)
+ closeTimeoutRef.current = null
+ }
+ setOpen(true)
+ }
+
+ const scheduleClose = () => {
+ if (closeTimeoutRef.current) {
+ clearTimeout(closeTimeoutRef.current)
+ }
+ closeTimeoutRef.current = setTimeout(() => {
+ setOpen(false)
+ closeTimeoutRef.current = null
+ }, 120)
+ }
+
+ useEffect(() => {
+ return () => {
+ if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current)
+ }
+ }, [])
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {rootBreadcrumb?.label && (
+
+ {rootBreadcrumb.label}
+
+ )}
+
+
+
+
+
+ Path
+ /
+
+
+
+ {breadcrumbs.map((crumb, index) => (
+
+ ))}
+
+
+
+ >
+ )
+}
+
+function LocationFocusVeil({
+ visible,
+ boundaryRef,
+}: {
+ visible: boolean
+ boundaryRef: React.RefObject
+}) {
+ const [bounds, setBounds] = useState({ top: 0, left: 0 })
+
+ useEffect(() => {
+ if (!visible) return
+
+ const updateBounds = () => {
+ const boundary = boundaryRef.current
+ if (!boundary) return
+
+ const rect = boundary.getBoundingClientRect()
+ setBounds({ top: rect.top, left: rect.left })
+ }
+
+ updateBounds()
+ window.addEventListener('resize', updateBounds)
+ window.addEventListener('scroll', updateBounds, true)
+
+ return () => {
+ window.removeEventListener('resize', updateBounds)
+ window.removeEventListener('scroll', updateBounds, true)
+ }
+ }, [boundaryRef, visible])
+
+ if (typeof document === 'undefined') return null
+
+ return createPortal(
+
,
+ document.body
+ )
+}
+
+interface BreadcrumbLocationItemProps {
+ icon?: React.ElementType
+ label: string
+ onClick?: () => void
+ active: boolean
+}
+
+function BreadcrumbLocationItem({
+ icon: Icon,
+ label,
+ onClick,
+ active,
+}: BreadcrumbLocationItemProps) {
+ const labelContent = (
+ <>
+
+ {Icon ? (
+
+ ) : (
+
+ )}
+
+ {label}
+ >
+ )
+
+ if (onClick) {
+ return (
+
+ {labelContent}
+
+ )
+ }
+
+ return (
+
+ {labelContent}
+
+ )
+}
+
+const BreadcrumbLabel = memo(
+ forwardRef(function BreadcrumbLabel(
+ { isOverflowing, label },
+ ref
+ ) {
+ return (
+
+ {label}
+
+ )
+ })
+)
+
+interface BreadcrumbLabelProps {
+ isOverflowing: boolean
+ label: string
+}
+
+function isBreadcrumbTextClipped(
+ labelElement: HTMLSpanElement | null,
+ triggerElement: HTMLElement
+): boolean {
+ if (!labelElement) return false
+
+ const labelWidth = labelElement.getBoundingClientRect().width
+ const triggerWidth = triggerElement.getBoundingClientRect().width
+ const visibleLabelWidth = Math.min(labelWidth, triggerWidth)
+
+ return (
+ labelElement.scrollWidth > labelElement.clientWidth + 1 ||
+ triggerElement.scrollWidth > triggerElement.clientWidth + 1 ||
+ labelElement.scrollWidth > visibleLabelWidth + 1
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts
index ba2be6c912c..ca526f68108 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/index.ts
@@ -5,4 +5,4 @@ export type {
SearchTag,
SortConfig,
} from './resource-options-bar'
-export { ResourceOptionsBar } from './resource-options-bar'
+export { ResourceOptionsBar, SortDropdown } from './resource-options-bar'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx
index 853349ed363..e1258d13558 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx
@@ -1,4 +1,4 @@
-import { memo, type ReactNode } from 'react'
+import { memo, type ReactNode, useState } from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import {
ArrowDown,
@@ -11,15 +11,19 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
ListFilter,
+ POPOVER_ANIMATION_CLASSES,
Search,
X,
} from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
+import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
const SEARCH_ICON = (
)
+const RESOURCE_MENU_EDGE_OFFSET = 6
+
type SortDirection = 'asc' | 'desc'
export interface ColumnOption {
@@ -89,6 +93,14 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
extras,
trailing,
}: ResourceOptionsBarProps) {
+ /**
+ * Coordinates the Filter popover and Sort menu as a single menu bar: clicking
+ * one while the other is open switches to it in a single click. Functional
+ * updates make the close→open ordering race-proof, so whichever menu the click
+ * targets wins regardless of which `onOpenChange` fires first.
+ */
+ const [openMenu, setOpenMenu] = useState<'filter' | 'sort' | null>(null)
+
const hasContent =
search ||
sort ||
@@ -112,7 +124,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
className='max-w-[280px] px-2 py-1 text-caption'
onClick={tag.onRemove}
>
- {tag.label}
+
✕
))}
@@ -127,32 +139,57 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({
>
Filter
) : filter ? (
-
-
-
-
- Filter
-
-
+
+ setOpenMenu((current) => (open ? 'filter' : current === 'filter' ? null : current))
+ }
+ >
+
+
+
+
+
+ Filter
+
+
+ {sort && (
+
+ setOpenMenu((current) =>
+ open ? 'sort' : current === 'sort' ? null : current
+ )
+ }
+ />
+ )}
+
+
{filter}
) : null}
- {sort && }
+ {sort && (onFilterToggle || !filter) && }
{trailing &&
{trailing}
}
@@ -170,12 +207,14 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
key={`${tag.label}-${tag.value}`}
variant='subtle'
className={cn(
- 'shrink-0 px-2 py-1 text-caption',
+ 'max-w-[280px] shrink-0 px-2 py-1 text-caption',
search.highlightedTagIndex === i && 'ring-1 ring-[var(--border-focus)] ring-offset-1'
)}
onClick={tag.onRemove}
>
- {tag.label}: {tag.value}
+
+ {tag.label}: {tag.value}
+
✕
))}
@@ -212,11 +251,23 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo
)
})
-const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) {
+interface SortDropdownProps {
+ config: SortConfig
+ /** Controlled open state — omit for standalone (uncontrolled) usage. */
+ open?: boolean
+ /** Controlled open-change handler, paired with {@link SortDropdownProps.open}. */
+ onOpenChange?: (open: boolean) => void
+}
+
+export const SortDropdown = memo(function SortDropdown({
+ config,
+ open,
+ onOpenChange,
+}: SortDropdownProps) {
const { options, active, onSort, onClear } = config
return (
-
+
Sort
-
+
{options.map((option) => {
const isActive = active?.column === option.id
const Icon = option.icon
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell/time-cell.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell.ts
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell/time-cell.ts
rename to apps/sim/app/workspace/[workspaceId]/components/resource/components/time-cell.ts
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
index 93512d9467f..4f4785e9a11 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx
@@ -12,6 +12,7 @@ import {
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { ArrowDown, ArrowUp, Button, Checkbox, Loader, Plus, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
+import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components/resource/components/floating-overflow-text'
import type { BreadcrumbItem, CreateAction, HeaderAction } from './components/resource-header'
import { ResourceHeader } from './components/resource-header'
import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar'
@@ -344,7 +345,7 @@ export const ResourceTable = memo(function ResourceTable({
handleSort(
col.id,
@@ -478,7 +479,7 @@ const CellContent = memo(function CellContent({ icon, label, content, primary }:
)}
>
{icon && {icon} }
- {label}
+
)
})
@@ -706,7 +707,7 @@ const DataTableSkeleton = memo(function DataTableSkeleton({
{columns.map((col) => (
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
new file mode 100644
index 00000000000..f568d2dec2d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/index.ts
@@ -0,0 +1 @@
+export { WorkspaceChrome } from './workspace-chrome'
diff --git a/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
new file mode 100644
index 00000000000..16b51832dbc
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/components/workspace-chrome/workspace-chrome.tsx
@@ -0,0 +1,122 @@
+'use client'
+
+import { useEffect } from 'react'
+import { usePathname } from 'next/navigation'
+import { cn } from '@/lib/core/utils/cn'
+import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
+import { useFullscreenOriginStore } from '@/stores/fullscreen-origin'
+import { useSidebarStore } from '@/stores/sidebar/store'
+
+const FULLSCREEN_SUFFIXES = ['/upgrade'] as const
+
+/** Slide timing for the fullscreen sidebar collapse and content shift. */
+const SLIDE_TRANSITION =
+ 'duration-[175ms] ease-[cubic-bezier(0.25,0.1,0.25,1)] motion-reduce:transition-none'
+
+interface WorkspaceChromeProps {
+ children: React.ReactNode
+}
+
+function isFullscreenPath(pathname: string | null): boolean {
+ return FULLSCREEN_SUFFIXES.some((s) => pathname?.endsWith(s))
+}
+
+/**
+ * Renders the workspace chrome as a single persistent tree. The sidebar is
+ * always mounted; on a fullscreen route (`/upgrade`) its wrapper collapses to
+ * zero width while the inner shell slides off the left edge, revealing the route
+ * content. Because this component lives in the workspace layout it persists
+ * across navigations, so the pathname-driven class toggle animates smoothly.
+ *
+ * Leaving a fullscreen route is instant: App Router swaps `children` to the
+ * origin page and the fullscreen page is simply unmounted, while the sidebar
+ * slides back in. There is no exit fade — the new page just loads in place.
+ *
+ * Because the chrome observes every pathname transition, it records the page a
+ * fullscreen route was launched from into {@link useFullscreenOriginStore}. The
+ * route's Back control reads that origin to return deterministically, so any
+ * trigger that merely pushes a fullscreen route gets correct return-to-origin
+ * without per-call-site wiring.
+ *
+ * On a direct load of a fullscreen route the wrapper mounts already collapsed,
+ * so no slide plays (CSS transitions don't run on mount).
+ */
+export function WorkspaceChrome({ children }: WorkspaceChromeProps) {
+ const pathname = usePathname()
+ const isFullscreen = isFullscreenPath(pathname)
+
+ const setOrigin = useFullscreenOriginStore((s) => s.setOrigin)
+
+ const hasHydrated = useSidebarStore((s) => s._hasHydrated)
+ const syncSidebarWidth = useSidebarStore((s) => s.syncWidth)
+
+ // Remember the last non-fullscreen page so a fullscreen route's Back control
+ // can return there, deterministically and for any trigger.
+ useEffect(() => {
+ if (pathname && !isFullscreen) setOrigin(pathname)
+ }, [pathname, isFullscreen, setOrigin])
+
+ // Re-apply the sidebar width whenever this persistent shell sees a navigation.
+ // The blocking script in the document head only runs on full page loads and
+ // store rehydration only fires once, so a soft navigation can leave
+ // `--sidebar-width` stuck at its `0px` default — collapsing the sidebar to
+ // nothing with no reachable control to bring it back. Re-syncing here recovers
+ // that state. Gated on hydration so it never clobbers the persisted value with
+ // store defaults during the pre-hydration window.
+ useEffect(() => {
+ if (hasHydrated) syncSidebarWidth()
+ }, [pathname, hasHydrated, syncSidebarWidth])
+
+ // Re-clamp the width when the window shrinks below what the persisted width
+ // allows, so the sidebar can never grow wider than the viewport permits.
+ useEffect(() => {
+ let rafId: number | null = null
+ const onResize = () => {
+ if (rafId !== null) return
+ rafId = requestAnimationFrame(() => {
+ rafId = null
+ syncSidebarWidth()
+ })
+ }
+ window.addEventListener('resize', onResize)
+ return () => {
+ if (rafId !== null) cancelAnimationFrame(rafId)
+ window.removeEventListener('resize', onResize)
+ }
+ }, [syncSidebarWidth])
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/error.tsx b/apps/sim/app/workspace/[workspaceId]/error.tsx
index c681cf70ee3..ac034cb6323 100644
--- a/apps/sim/app/workspace/[workspaceId]/error.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/error.tsx
@@ -1,13 +1,8 @@
'use client'
-import { ErrorState } from '@/app/workspace/[workspaceId]/components'
+import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components'
-interface WorkspaceErrorProps {
- error: Error & { digest?: string }
- reset: () => void
-}
-
-export default function WorkspaceError({ error, reset }: WorkspaceErrorProps) {
+export default function WorkspaceError({ error, reset }: ErrorBoundaryProps) {
return (
-
+
Delete
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
index 045884086a0..25b9142263a 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/delete-confirm-modal/delete-confirm-modal.tsx
@@ -1,15 +1,7 @@
'use client'
import { memo } from 'react'
-import {
- Button,
- Modal,
- ModalBody,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
-} from '@/components/emcn'
+import { Chip, ChipModal, ChipModalBody, ChipModalFooter, ChipModalHeader } from '@/components/emcn'
interface DeleteConfirmModalProps {
open: boolean
@@ -42,29 +34,29 @@ export const DeleteConfirmModal = memo(function DeleteConfirmModal({
: 'You can restore it from Recently Deleted in Settings.'
return (
-
-
- {title}
-
-
- Are you sure you want to delete{' '}
- {fileName ? (
- {fileName}
- ) : (
- `${totalCount} item${totalCount === 1 ? '' : 's'}`
- )}
- ? {consequence}
-
-
-
- onOpenChange(false)} disabled={isPending}>
- Cancel
-
-
- {isPending ? 'Deleting...' : 'Delete'}
-
-
-
-
+
+ onOpenChange(false)} showDivider={false}>
+ {title}
+
+
+
+ Are you sure you want to delete{' '}
+ {fileName ? (
+ {fileName}
+ ) : (
+ `${totalCount} item${totalCount === 1 ? '' : 's'}`
+ )}
+ ? {consequence}
+
+
+
+ onOpenChange(false)} disabled={isPending}>
+ Cancel
+
+
+ {isPending ? 'Deleting...' : 'Delete'}
+
+
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx
index 1d545d0b9eb..db0853d2a3c 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx
@@ -2,7 +2,6 @@
import { memo } from 'react'
import {
- Download,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
@@ -15,8 +14,8 @@ import {
Folder,
FolderInput,
Pencil,
- Trash2,
} from '@/components/emcn'
+import { Download, Trash } from '@/components/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'
@@ -103,7 +102,7 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
)}
-
+
{isMultiSelect ? `Delete ${selectedCount} items` : 'Delete'}
>
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
index 77b5ed2cfdd..27ed6d8464c 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/editor-context-menu.tsx
@@ -9,7 +9,7 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/emcn'
-import { Clipboard, Copy, Search, SelectAll } from '@/components/emcn/icons'
+import { Clipboard, Duplicate, Search, SelectAll } from '@/components/emcn/icons'
interface EditorContextMenuProps {
isOpen: boolean
@@ -68,12 +68,12 @@ export function EditorContextMenu({
)}
-
+
Copy
⌘C
-
+
Copy all
{canEdit && (
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
index 4b960d1bcef..091ae68c3c5 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx
@@ -43,6 +43,7 @@ interface FileViewerProps {
workspaceId: string
canEdit: boolean
previewMode?: PreviewMode
+ autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise) | null>
@@ -57,6 +58,7 @@ export function FileViewer({
workspaceId,
canEdit,
previewMode,
+ autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
@@ -74,6 +76,7 @@ export function FileViewer({
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode ?? 'editor'}
+ autoFocus={autoFocus}
onDirtyChange={onDirtyChange}
onSaveStatusChange={onSaveStatusChange}
saveRef={saveRef}
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
index 75c22b98e79..6462ca251db 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/text-editor.tsx
@@ -392,6 +392,7 @@ interface TextEditorProps {
workspaceId: string
canEdit: boolean
previewMode: PreviewMode
+ autoFocus?: boolean
onDirtyChange?: (isDirty: boolean) => void
onSaveStatusChange?: (status: 'idle' | 'saving' | 'saved' | 'error') => void
saveRef?: React.MutableRefObject<(() => Promise) | null>
@@ -406,6 +407,7 @@ export const TextEditor = memo(function TextEditor({
workspaceId,
canEdit,
previewMode,
+ autoFocus,
onDirtyChange,
onSaveStatusChange,
saveRef,
@@ -417,6 +419,7 @@ export const TextEditor = memo(function TextEditor({
const containerRef = useRef(null)
const monacoEditorRef = useRef[0] | null>(null)
const lastSyncedContentRef = useRef('')
+ const hasAutoFocusedRef = useRef(false)
const contentRef = useRef('')
const textareaStuckRef = useRef(false)
const suppressScrollListenerRef = useRef(false)
@@ -633,6 +636,11 @@ export const TextEditor = memo(function TextEditor({
lastSyncedContentRef.current = currentContent
}
+ if (autoFocus && !hasAutoFocusedRef.current) {
+ hasAutoFocusedRef.current = true
+ editor.focus()
+ }
+
const contextMenuDisposable = editor.onContextMenu((e) => {
e.event.preventDefault()
const sel = editor.getSelection()
diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
index 6aa7d5a40b1..d93c16bf0af 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx
@@ -6,26 +6,25 @@ import { toError } from '@sim/utils/errors'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
Button,
+ Chip,
+ ChipCombobox,
+ ChipModal,
+ ChipModalBody,
+ ChipModalFooter,
+ ChipModalHeader,
Columns2,
- Combobox,
type ComboboxOption,
- Download,
Eye,
File as FilesIcon,
Folder,
FolderPlus,
Loader,
- Modal,
- ModalBody,
- ModalContent,
- ModalDescription,
- ModalFooter,
- ModalHeader,
Pencil,
- Trash2,
+ Trash,
toast,
Upload,
} from '@/components/emcn'
+import { Download } from '@/components/emcn/icons'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { triggerFileDownload } from '@/lib/uploads/client/download'
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
@@ -1071,7 +1070,7 @@ export function Files() {
...(canEdit
? [
{ label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename },
- { label: 'Delete', icon: Trash2, onClick: handleDeleteSelected },
+ { label: 'Delete', icon: Trash, onClick: handleDeleteSelected },
]
: []),
],
@@ -1457,7 +1456,7 @@ export function Files() {
? [
{
label: 'Delete',
- icon: Trash2,
+ icon: Trash,
onClick: handleDeleteSelected,
},
]
@@ -1709,7 +1708,7 @@ export function Files() {
File Type
-
Size
-
{memberOptions.length > 0 && (
@@ -1753,7 +1750,7 @@ export function Files() {
Uploaded By
-
)}
@@ -1852,29 +1848,32 @@ export function Files() {
workspaceId={workspaceId}
canEdit={canEdit}
previewMode={previewMode}
+ autoFocus={isNewFile || justCreatedFileIdRef.current === selectedFile.id}
onDirtyChange={setIsDirty}
onSaveStatusChange={setSaveStatus}
saveRef={saveRef}
/>
-
-
- Unsaved Changes
-
-
- You have unsaved changes. Are you sure you want to discard them?
-
-
-
- setShowUnsavedChangesAlert(false)}>
- Keep Editing
-
-
- Discard Changes
-
-
-
-
+
+ Unsaved Changes
+
+
+ You have unsaved changes. Are you sure you want to discard them?
+
+
+
+ setShowUnsavedChangesAlert(false)}>
+ Keep Editing
+
+
+ Discard Changes
+
+
+
+}
+
/**
* Single source of truth for the icon and label associated with each
* {@link ChatContextKind}. The `Record` typing forces a
@@ -75,13 +93,17 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record ,
+ renderIcon: ({ className }) => ,
},
logs: {
label: 'Logs',
renderIcon: ({ className }) => ,
},
- templates: { label: 'Templates', renderIcon: () => null },
docs: { label: 'Docs', renderIcon: () => null },
slash_command: { label: 'Command', renderIcon: () => null },
+ integration: { label: 'Integration', renderIcon: renderIntegrationTile },
+ skill: {
+ label: 'Skill',
+ renderIcon: ({ className }) => ,
+ },
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/index.ts
new file mode 100644
index 00000000000..3cbf769ac5b
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-context-kind-registry/index.ts
@@ -0,0 +1 @@
+export { CHAT_CONTEXT_KIND_REGISTRY } from './chat-context-kind-registry'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
similarity index 96%
rename from apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
index 5dbcf63f90c..df922d9715e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/chat-message-attachments.tsx
@@ -1,6 +1,6 @@
import { getDocumentIcon } from '@/components/icons/document-icons'
import { cn } from '@/lib/core/utils/cn'
-import type { ChatMessageAttachment } from '../types'
+import type { ChatMessageAttachment } from '@/app/workspace/[workspaceId]/home/types'
function FileAttachmentPill(props: { mediaType: string; filename: string }) {
const Icon = getDocumentIcon(props.mediaType, props.filename)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/index.ts
new file mode 100644
index 00000000000..8962430abcf
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-message-attachments/index.ts
@@ -0,0 +1 @@
+export { ChatMessageAttachments } from './chat-message-attachments'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon/context-mention-icon.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon/context-mention-icon.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon/index.ts
new file mode 100644
index 00000000000..e8c0863e4f8
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/context-mention-icon/index.ts
@@ -0,0 +1 @@
+export { ContextMentionIcon } from './context-mention-icon'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
new file mode 100644
index 00000000000..aa6d45c884d
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/credits-chip.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+import { useCallback } from 'react'
+import { useQueryClient } from '@tanstack/react-query'
+import { useParams, useRouter } from 'next/navigation'
+import { Chip } from '@/components/emcn'
+import { Credit } from '@/components/emcn/icons'
+import { ON_DEMAND_UNLIMITED } from '@/lib/billing/constants'
+import { formatCredits } from '@/lib/billing/credits/conversion'
+import { isBillingEnabled } from '@/app/workspace/[workspaceId]/settings/navigation'
+import { usePlanView } from '@/hooks/queries/plan-view'
+import { prefetchUpgradeBillingData, useSubscriptionData } from '@/hooks/queries/subscription'
+import { prefetchWorkspaceSettings } from '@/hooks/queries/workspace'
+
+export function CreditsChip() {
+ if (!isBillingEnabled) return null
+
+ return
+}
+
+function CreditsChipInner() {
+ const { planView, isLoading, hasData } = usePlanView()
+ /**
+ * `usePlanView` is built on top of `useSubscriptionData`, so the second call
+ * dedups against the same React Query cache entry. We read the raw usage
+ * fields here because `planView` intentionally only exposes plan-derived
+ * decisions, not display math.
+ */
+ const { data } = useSubscriptionData()
+ const router = useRouter()
+ const queryClient = useQueryClient()
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+
+ const upgradeHref = `/workspace/${workspaceId}/upgrade`
+
+ /**
+ * Warm the route bundle and the exact queries the Upgrade page gates on, so
+ * the click navigates into already-cached data instead of a blank, loading page.
+ */
+ const prefetchUpgrade = useCallback(() => {
+ router.prefetch(upgradeHref)
+ prefetchUpgradeBillingData(queryClient)
+ prefetchWorkspaceSettings(queryClient, workspaceId)
+ }, [router, queryClient, upgradeHref, workspaceId])
+
+ if (isLoading || !hasData || !data?.data) return null
+ if (!planView.showCredits) return null
+
+ const { usageLimit, currentUsage, creditBalance } = data.data
+
+ /**
+ * Credits remaining = unused plan allowance plus any purchased credit balance.
+ * Uncapped plans (limit at/above the on-demand threshold) render as ∞ via
+ * `formatCredits`, so short-circuit instead of subtracting usage from it.
+ */
+ const remainingCredits =
+ usageLimit >= ON_DEMAND_UNLIMITED
+ ? ON_DEMAND_UNLIMITED
+ : Math.max(0, usageLimit + creditBalance - currentUsage)
+
+ return (
+ router.push(upgradeHref)}
+ onMouseEnter={prefetchUpgrade}
+ onFocus={prefetchUpgrade}
+ leftIcon={Credit}
+ >
+ {formatCredits(remainingCredits)}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/index.ts
new file mode 100644
index 00000000000..ba8f8d5aee4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/credits-chip/index.ts
@@ -0,0 +1 @@
+export { CreditsChip } from './credits-chip'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts
index 654b0d573bd..222b75ef39e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/index.ts
@@ -1,5 +1,12 @@
export { ChatMessageAttachments } from './chat-message-attachments'
-export { MothershipChat } from './mothership-chat/mothership-chat'
+export { ContextMentionIcon } from './context-mention-icon'
+export { CreditsChip } from './credits-chip'
+export {
+ assistantMessageHasRenderableContent,
+ MessageContent,
+} from './message-content'
+export { MothershipChat } from './mothership-chat'
export { MothershipView } from './mothership-view'
-export { TemplatePrompts } from './template-prompts'
+export { QueuedMessages } from './queued-messages'
+export { SuggestedActions } from './suggested-actions'
export { UserInput } from './user-input'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
index 59b530aabee..85513baaa74 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/agent-group.tsx
@@ -42,7 +42,6 @@ export function AgentGroup({
}: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
const hasItems = items.length > 0
- const isSubagent = agentName !== 'mothership'
const toolItems = items.filter(
(item): item is Extract => item.type === 'tool'
)
@@ -89,7 +88,7 @@ export function AgentGroup({
)}
- {agentLabel}
+ {agentLabel}
)}
- {agentLabel}
+ {agentLabel}
)}
{hasItems && (
-
+
{items.map((item, idx) => {
if (item.type === 'tool') {
return (
@@ -129,7 +128,7 @@ export function AgentGroup({
return (
{item.content.trim()}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
index 081899a2b7b..5b6f1c56ce8 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx
@@ -98,7 +98,7 @@ export function ToolCallItem({ toolName, displayTitle, status, streamingArgs }:
-
+
{liveWorkspaceFileTitle || displayTitle}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
index 6307683e347..851f8cd54fa 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx
@@ -21,7 +21,7 @@ import { useTablesList } from '@/hooks/queries/tables'
import { useWorkflows } from '@/hooks/queries/workflows'
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
-interface OptionsItemData {
+export interface OptionsItemData {
title: string
description: string
}
@@ -401,7 +401,7 @@ export function PendingTagIndicator() {
/>
))}
- Thinking…
+ Thinking…
)
}
@@ -429,7 +429,7 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
aria-expanded={expanded}
className='flex items-center gap-2'
>
- Suggested follow-ups
+ Suggested follow-ups
) : (
- Suggested follow-ups
+ Suggested follow-ups
)}
@@ -459,9 +459,9 @@ function OptionsDisplay({ data, onSelect }: OptionsDisplayProps) {
)}
>
- {i + 1}
+ {i + 1}
- {title}
+ {title}
)
@@ -615,10 +615,8 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
rel='noopener noreferrer'
className='flex items-center gap-2 rounded-lg border border-[var(--divider)] px-3 py-2.5 transition-colors hover-hover:bg-[var(--surface-5)]'
>
- {createElement(Icon, { className: 'h-[16px] w-[16px] shrink-0' })}
-
- Connect {data.provider}
-
+ {createElement(Icon, { className: 'size-[16px] shrink-0' })}
+ Connect {data.provider}
)
@@ -634,16 +632,12 @@ function CredentialDisplay({ data }: { data: CredentialTagData }) {
function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) {
const detail = data.code ? `${data.message} (${data.code})` : data.message
- return (
-
- {detail}
-
- )
+ return {detail}
}
function UsageUpgradeDisplay({ data }: { data: UsageUpgradeTagData }) {
const { workspaceId } = useParams<{ workspaceId: string }>()
- const settingsPath = `/workspace/${workspaceId}/settings/subscription`
+ const settingsPath = `/workspace/${workspaceId}/settings/billing`
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'
return (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx
index b0140778f27..ce7788cb136 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/thinking-block/thinking-block.tsx
@@ -100,7 +100,7 @@ export function ThinkingBlock({
- {label}
+ {label}
-
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
index 725d9f08df4..3b06373505f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx
@@ -1,7 +1,9 @@
'use client'
import { memo, useMemo } from 'react'
+import { stripVersionSuffix } from '@sim/utils/string'
import { Read as ReadTool, WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1'
+import { isToolHiddenInUi } from '@/lib/copilot/tools/client/hidden-tools'
import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils'
import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-call-state'
import type { ContentBlock, MothershipResource, OptionItem, ToolCallData } from '../../types'
@@ -17,11 +19,6 @@ import {
} from './components'
const FILE_SUBAGENT_ID = 'file'
-const HIDDEN_TOOL_NAMES = new Set([
- 'tool_search_tool_regex',
- 'load_agent_skill',
- 'load_custom_tool',
-])
interface TextSegment {
type: 'text'
@@ -79,13 +76,8 @@ function isToolResultRead(params?: Record
): boolean {
return typeof path === 'string' && path.startsWith('internal/tool-results/')
}
-function isHiddenToolCall(toolName: string | undefined): boolean {
- return !!toolName && HIDDEN_TOOL_NAMES.has(toolName)
-}
-
function formatToolName(name: string): string {
- return name
- .replace(/_v\d+$/, '')
+ return stripVersionSuffix(name)
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ')
@@ -309,7 +301,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
if (block.type === 'tool_call') {
if (!block.toolCall) continue
const tc = block.toolCall
- if (isHiddenToolCall(tc.name)) continue
+ if (isToolHiddenInUi(tc.name)) continue
if (tc.name === ReadTool.id && isToolResultRead(tc.params)) continue
const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy
@@ -528,9 +520,7 @@ function MessageContentInner({
return (
-
- Stopped by user
-
+ Stopped by user
)
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
index dcfa27a3b20..e09f02b93f2 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts
@@ -3,7 +3,6 @@ import {
Asterisk,
Blimp,
Bug,
- Calendar,
Database,
Eye,
File,
@@ -20,7 +19,7 @@ import {
TerminalWindow,
Wrench,
} from '@/components/emcn'
-import { Table as TableIcon } from '@/components/emcn/icons'
+import { Calendar, Table as TableIcon } from '@/components/emcn/icons'
import { AgentIcon } from '@/components/icons'
export type IconComponent = ComponentType>
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/index.ts
new file mode 100644
index 00000000000..34541b1a149
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/index.ts
@@ -0,0 +1 @@
+export { MothershipChatSkeleton } from './mothership-chat-skeleton'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/index.ts
new file mode 100644
index 00000000000..34541b1a149
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/index.ts
@@ -0,0 +1 @@
+export { MothershipChatSkeleton } from './mothership-chat-skeleton'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat-skeleton.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx
similarity index 96%
rename from apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat-skeleton.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx
index 6a940e8bc35..9e543a57e6e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat-skeleton.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/components/mothership-chat-skeleton/mothership-chat-skeleton.tsx
@@ -2,7 +2,7 @@ import { Skeleton } from '@/components/emcn'
const LAYOUT_SKELETON_STYLES = {
'mothership-view': {
- content: 'mx-auto max-w-[42rem] space-y-6',
+ content: 'mx-auto max-w-[48rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
},
'copilot-view': {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts
new file mode 100644
index 00000000000..dd1e5d9efd4
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts
@@ -0,0 +1,2 @@
+export { MothershipChatSkeleton } from './components'
+export { MothershipChat } from './mothership-chat'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
index 79fc042f5de..89bea30c4af 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx
@@ -27,7 +27,7 @@ import type {
import { useAutoScroll } from '@/hooks/use-auto-scroll'
import { useProgressiveList } from '@/hooks/use-progressive-list'
import type { ChatContext } from '@/stores/panel'
-import { MothershipChatSkeleton } from './mothership-chat-skeleton'
+import { MothershipChatSkeleton } from './components/mothership-chat-skeleton'
interface MothershipChatProps {
messages: ChatMessage[]
@@ -64,13 +64,13 @@ const LAYOUT_STYLES = {
'mothership-view': {
scrollContainer:
'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-6 pt-4 pb-8 [scrollbar-gutter:stable_both-edges]',
- content: 'mx-auto max-w-[42rem] space-y-6',
+ content: 'mx-auto max-w-[48rem] space-y-6',
userRow: 'flex flex-col items-end gap-[6px] pt-3',
attachmentWidth: 'max-w-[70%]',
userBubble: 'max-w-[70%] overflow-hidden rounded-[16px] bg-[var(--surface-5)] px-3.5 py-2',
assistantRow: 'group/msg',
footer: 'flex-shrink-0 px-[24px] pb-[16px]',
- footerInner: 'mx-auto max-w-[42rem]',
+ footerInner: 'mx-auto max-w-[48rem]',
},
'copilot-view': {
scrollContainer: 'min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-3 pt-2 pb-4',
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx
index f5ebb862fe6..fd640c63798 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx
@@ -25,6 +25,7 @@ import type {
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
+import { listIntegrations } from '@/blocks/integration-matcher'
import { useFolders } from '@/hooks/queries/folders'
import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
import { useLogsList } from '@/hooks/queries/logs'
@@ -142,6 +143,16 @@ export function useAvailableResources(
isOpen: existingKeys.has(`knowledgebase:${kb.id}`),
})),
},
+ {
+ type: 'integration' as const,
+ items: listIntegrations().map((integration) => ({
+ id: integration.blockType,
+ name: integration.name,
+ iconComponent: integration.icon,
+ bgColor: integration.bgColor,
+ isOpen: existingKeys.has(`integration:${integration.blockType}`),
+ })),
+ },
{
type: 'task' as const,
items: tasks.map((t) => ({
@@ -379,7 +390,10 @@ export function AddResourceDropdown({
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
- const available = useAvailableResources(workspaceId, existingKeys, excludeTypes)
+ const available = useAvailableResources(workspaceId, existingKeys, [
+ ...(excludeTypes ?? []),
+ 'integration',
+ ])
const handleOpenChange = (next: boolean) => {
setOpen(next)
if (!next) {
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/generic-resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/generic-resource-content.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/generic-resource-content.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/index.ts
new file mode 100644
index 00000000000..679bc28cda5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content/index.ts
@@ -0,0 +1 @@
+export { GenericResourceContent } from './generic-resource-content'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts
new file mode 100644
index 00000000000..679bc28cda5
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/index.ts
@@ -0,0 +1 @@
+export { GenericResourceContent } from './generic-resource-content'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
index 1bfc2a5043c..45e144ade0d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx
@@ -27,7 +27,7 @@ import {
FileViewer,
type PreviewMode,
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
-import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/generic-resource-content'
+import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content'
import {
RESOURCE_TAB_ICON_BUTTON_CLASS,
RESOURCE_TAB_ICON_CLASS,
@@ -276,7 +276,7 @@ export function EmbeddedWorkflowActions({ workspaceId, workflowId }: EmbeddedWor
}
if (usageExceeded) {
- navigateToSettings({ section: 'subscription' })
+ navigateToSettings({ section: 'billing' })
return
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
index 73d312c42ef..88393c1285d 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx
@@ -4,12 +4,13 @@ import { type ElementType, type ReactNode, useMemo } from 'react'
import type { QueryClient } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import {
- Blimp,
+ Connections,
Database,
File as FileIcon,
Folder as FolderIcon,
Library,
Table as TableIcon,
+ Task,
TerminalWindow,
} from '@/components/emcn/icons'
import { WorkflowIcon } from '@/components/icons'
@@ -20,6 +21,7 @@ import type {
MothershipResource,
MothershipResourceType,
} from '@/app/workspace/[workspaceId]/home/types'
+import { getBareIconStyle, type StyleableIcon } from '@/blocks/icon-color'
import { knowledgeKeys } from '@/hooks/queries/kb/knowledge'
import { logKeys } from '@/hooks/queries/logs'
import { tableKeys } from '@/hooks/queries/tables'
@@ -101,6 +103,26 @@ function IconDropdownItem({ item, icon: Icon }: DropdownItemRenderProps & { icon
)
}
+/**
+ * Renders an integration mention candidate using the block's own brand icon at
+ * the standard 14px dropdown size. Single-fill icons drawn with
+ * `fill='currentColor'` (e.g. HubSpot) are tinted with the block's brand
+ * {@link BlockConfig.iconColor}; multi-color brand icons keep their own SVG fills.
+ */
+function IntegrationDropdownItem({ item }: DropdownItemRenderProps) {
+ const Icon = item.iconComponent as StyleableIcon | undefined
+ if (!Icon) return {item.name}
+ return (
+ <>
+
+ {item.name}
+ >
+ )
+}
+
function LogDropdownItem({ item }: DropdownItemRenderProps) {
const color = (item.color as string) ?? '#888'
const workflowName = (item.workflowName as string) ?? item.name
@@ -192,12 +214,12 @@ export const RESOURCE_REGISTRY: Record (
-
+
),
- renderDropdownItem: (props) => ,
+ renderDropdownItem: (props) => ,
},
log: {
type: 'log',
@@ -208,6 +230,15 @@ export const RESOURCE_REGISTRY: Record ,
},
+ integration: {
+ type: 'integration',
+ label: 'Integrations',
+ icon: Connections,
+ renderTabIcon: (_resource, className) => (
+
+ ),
+ renderDropdownItem: (props) => ,
+ },
} as const
export const RESOURCE_TYPES = Object.values(RESOURCE_REGISTRY)
@@ -252,6 +283,12 @@ const RESOURCE_INVALIDATORS: Record<
qc.invalidateQueries({ queryKey: logKeys.details() })
qc.invalidateQueries({ queryKey: logKeys.detail(id) })
},
+ /**
+ * Integrations are sourced from the static integration catalog
+ * (`listIntegrations()`), not a server-backed query, so there is nothing to
+ * invalidate when one is added.
+ */
+ integration: () => {},
}
/**
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
index f58373a2262..3397ffdf15f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts
@@ -2,4 +2,4 @@ export const RESOURCE_TAB_GAP_CLASS = 'gap-1.5'
export const RESOURCE_TAB_ICON_BUTTON_CLASS = 'shrink-0 bg-transparent px-2 py-[5px] text-caption'
-export const RESOURCE_TAB_ICON_CLASS = 'h-[16px] w-[16px] text-[var(--text-icon)]'
+export const RESOURCE_TAB_ICON_CLASS = 'size-[16px] text-[var(--text-icon)]'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
index 0bcaf3c591a..2c3a2210f73 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx
@@ -202,7 +202,7 @@ const ResourceTabItem = memo(function ResourceTabItem({
isDragging && 'opacity-30'
)}
>
- {config.renderTabIcon(resource, 'mr-1.5 h-[14px] w-[14px]')}
+ {config.renderTabIcon(resource, 'mr-1.5 size-[14px]')}
{displayName}
{(isHovered || isActive) && chatId && (
-
+
{msg.fileAttachments && msg.fileAttachments.length > 0 && (
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/index.ts
new file mode 100644
index 00000000000..bedde2535f8
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/index.ts
@@ -0,0 +1 @@
+export { SuggestedActions } from './suggested-actions'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
new file mode 100644
index 00000000000..f1fda6c482a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
@@ -0,0 +1,440 @@
+'use client'
+
+import { type ComponentType, type CSSProperties, useMemo, useState } from 'react'
+import { stripVersionSuffix } from '@sim/utils/string'
+import { useParams } from 'next/navigation'
+import { usePostHog } from 'posthog-js/react'
+import {
+ ArrowRight,
+ ChevronDown,
+ chipVariants,
+ Expandable,
+ ExpandableContent,
+} from '@/components/emcn'
+import { Shuffle, Table } from '@/components/emcn/icons'
+import { GmailIcon, SlackIcon } from '@/components/icons'
+import { cn } from '@/lib/core/utils/cn'
+import {
+ getAllBlockMeta,
+ INTEGRATIONS,
+ type OAuthServiceMatch,
+ resolveOAuthServiceForIntegration,
+ resolveOAuthServiceForSlug,
+} from '@/lib/integrations'
+import { captureEvent } from '@/lib/posthog/client'
+import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
+import { getBareIconStyle } from '@/blocks/icon-color'
+import type { ModuleTag } from '@/blocks/types'
+import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
+import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge'
+import { useOAuthConnections } from '@/hooks/queries/oauth/oauth-connections'
+import { useTablesList } from '@/hooks/queries/tables'
+
+type Icon = ComponentType<{ className?: string; style?: CSSProperties }>
+
+type Action =
+ | { kind: 'prompt'; id: string; label: string; prompt: string; icon: Icon }
+ | { kind: 'integration'; id: string; label: string; icon: Icon; slug: string }
+
+/** Lookup integration slug by OAuth service display name (case-insensitive). */
+const SLUG_BY_LOWER_NAME: ReadonlyMap = new Map(
+ INTEGRATIONS.map((i) => [i.name.toLowerCase(), i.slug])
+)
+
+/** Lookup base block type by catalog slug, for the connect-row popularity weight. */
+const TYPE_BY_SLUG: ReadonlyMap = new Map(
+ INTEGRATIONS.map((i) => [i.slug, stripVersionSuffix(i.type)])
+)
+
+/**
+ * A scored suggestion candidate derived from the block template catalog (plus
+ * a few generic table starters). `providerId` is set when the owning block is
+ * an OAuth integration, enabling connectivity-aware scoring.
+ */
+interface Candidate {
+ id: string
+ /** Diversity key — at most one suggestion per block is ever shown. */
+ blockType: string
+ label: string
+ prompt: string
+ icon: Icon
+ modules: readonly ModuleTag[]
+ featured: boolean
+ popular: boolean
+ providerId: string | null
+}
+
+/** Generic table starters for workspaces without integration context. */
+const TABLE_STARTERS: readonly Candidate[] = [
+ { label: 'Create a CRM with sample data', prompt: 'Create a CRM with sample data.' },
+ { label: 'Build a project tracker', prompt: 'Build a project tracker table.' },
+ { label: 'Create a content calendar', prompt: 'Create a content calendar table.' },
+ { label: 'Build an expense tracker', prompt: 'Build an expense tracker table.' },
+ { label: 'Create a bug tracker', prompt: 'Create a bug tracker table.' },
+].map(({ label, prompt }, i) => ({
+ id: `table-starter-${i}`,
+ blockType: `table-starter-${i}`,
+ label,
+ prompt,
+ icon: Table,
+ modules: ['tables'] as const,
+ featured: false,
+ popular: true,
+ providerId: null,
+}))
+
+/**
+ * The full suggestion pool, built once at module load from the curated block
+ * template catalog (`getAllBlockMeta`). Each block's templates are hand-written
+ * catalog prompts; the owning block links a template to its integration so
+ * connectivity can inform scoring. Blocks without a catalog entry (internal
+ * blocks) are skipped. Catalog types may carry version suffixes (`gmail_v2`)
+ * while meta-registry keys are base types (`gmail`), so the integration map
+ * is keyed by both forms.
+ */
+const CANDIDATES: readonly Candidate[] = (() => {
+ const integrationByType = new Map(
+ INTEGRATIONS.flatMap((i) => [[i.type, i] as const, [stripVersionSuffix(i.type), i] as const])
+ )
+ const out: Candidate[] = [...TABLE_STARTERS]
+ for (const [blockType, meta] of Object.entries(getAllBlockMeta())) {
+ const integration = integrationByType.get(blockType)
+ if (!integration) continue
+ const providerId = resolveOAuthServiceForIntegration(integration)?.providerId ?? null
+ for (const [i, template] of (meta.templates ?? []).entries()) {
+ out.push({
+ id: `${blockType}-${i}`,
+ blockType,
+ label: template.title,
+ prompt: template.prompt,
+ icon: template.icon as Icon,
+ modules: template.modules,
+ featured: template.featured ?? false,
+ popular: template.category === 'popular',
+ providerId,
+ })
+ }
+ }
+ return out
+})()
+
+/** Template count per block type — a data-driven popularity proxy for connect rows. */
+const TEMPLATE_COUNT_BY_TYPE: ReadonlyMap = (() => {
+ const counts = new Map()
+ for (const c of CANDIDATES) {
+ if (c.providerId) counts.set(c.blockType, (counts.get(c.blockType) ?? 0) + 1)
+ }
+ return counts
+})()
+
+interface Signals {
+ connectedProviders: ReadonlySet
+ hasTables: boolean
+ hasKnowledgeBases: boolean
+}
+
+/**
+ * Scores a candidate against workspace signals. Connected-provider prompts get
+ * the largest boost — they are runnable immediately, with no OAuth detour —
+ * while unconnected OAuth prompts are discounted (but kept, since they still
+ * teach capability). Resource gaps nudge the mix: workspaces without tables
+ * see more table starters; workspaces that already run knowledge bases see
+ * fewer "create a knowledge base" prompts.
+ */
+function scoreCandidate(c: Candidate, signals: Signals): number {
+ let weight = 1
+ if (c.featured) weight *= 3
+ if (c.popular) weight *= 1.5
+ if (c.providerId) {
+ weight *= signals.connectedProviders.has(c.providerId) ? 4 : 0.4
+ }
+ if (c.modules.includes('tables') && !signals.hasTables) weight *= 1.5
+ if (c.modules.includes('knowledge-base') && signals.hasKnowledgeBases) weight *= 0.6
+ return weight
+}
+
+/**
+ * Weighted sampling without replacement. Each pick's probability is
+ * proportional to its weight, so shuffles stay fresh while staying relevant.
+ */
+function weightedSample(pool: readonly T[], n: number, weightOf: (item: T) => number): T[] {
+ const remaining = pool.map((item) => ({ item, weight: Math.max(weightOf(item), 0) }))
+ const out: T[] = []
+ while (out.length < n && remaining.length > 0) {
+ const total = remaining.reduce((sum, entry) => sum + entry.weight, 0)
+ if (total <= 0) break
+ let roll = Math.random() * total
+ const index = remaining.findIndex((entry) => {
+ roll -= entry.weight
+ return roll <= 0
+ })
+ const [picked] = remaining.splice(index === -1 ? remaining.length - 1 : index, 1)
+ out.push(picked.item)
+ }
+ return out
+}
+
+const EMPTY_CREDENTIALS: NonNullable['data']> = []
+const EMPTY_SERVICES: NonNullable['data']> = []
+
+type ServiceInfo = NonNullable['data']>[number]
+
+function toPromptAction(c: Candidate): Action {
+ return { kind: 'prompt', id: c.id, label: c.label, prompt: c.prompt, icon: c.icon }
+}
+
+function toIntegrationAction(service: ServiceInfo, slug: string): Action {
+ return {
+ kind: 'integration',
+ id: `integrate-${service.providerId}`,
+ label: `Integrate with ${service.name}`,
+ icon: service.icon,
+ slug,
+ }
+}
+
+/**
+ * Builds a fresh set of four suggested actions: "Integrate with X" rows for
+ * unconnected services (weighted by how many catalog templates the service
+ * has — a data-driven popularity proxy), then prompt rows weighted by
+ * {@link scoreCandidate}. At most one prompt per block keeps the set diverse.
+ * Workspaces with at least one connection get a single connect row and three
+ * prompts; fresh workspaces get two of each.
+ */
+function computeActions(services: readonly ServiceInfo[], signals: Signals): Action[] {
+ const connectCandidates = services.flatMap((s) => {
+ if (signals.connectedProviders.has(s.providerId)) return []
+ const slug = SLUG_BY_LOWER_NAME.get(s.name.toLowerCase())
+ return slug ? [{ service: s, slug }] : []
+ })
+ const connectCount = signals.connectedProviders.size === 0 ? 2 : 1
+ const integrations = weightedSample(
+ connectCandidates,
+ connectCount,
+ ({ slug }) => (TEMPLATE_COUNT_BY_TYPE.get(TYPE_BY_SLUG.get(slug) ?? '') ?? 0) + 1
+ ).map(({ service, slug }) => toIntegrationAction(service, slug))
+
+ const scored = CANDIDATES.map((c) => ({ c, weight: scoreCandidate(c, signals) })).filter(
+ (entry) => entry.weight > 0
+ )
+ const prompts: Action[] = []
+ const usedBlockTypes = new Set()
+ while (prompts.length < 4 - integrations.length) {
+ const available = scored.filter((entry) => !usedBlockTypes.has(entry.c.blockType))
+ const [pick] = weightedSample(available, 1, (entry) => entry.weight)
+ if (!pick) break
+ usedBlockTypes.add(pick.c.blockType)
+ prompts.push(toPromptAction(pick.c))
+ }
+
+ return [...integrations, ...prompts]
+}
+
+/**
+ * Initial actions rendered on first paint, before OAuth/credentials queries
+ * resolve. For users with no connections this is also the final result, so the
+ * section never flashes. Users with existing connections briefly see this
+ * before the personalized recompute replaces it.
+ */
+const INITIAL_ACTIONS: Action[] = [
+ {
+ kind: 'integration',
+ id: 'integrate-slack',
+ label: 'Integrate with Slack',
+ icon: SlackIcon,
+ slug: 'slack',
+ },
+ {
+ kind: 'integration',
+ id: 'integrate-gmail',
+ label: 'Integrate with Gmail',
+ icon: GmailIcon,
+ slug: 'gmail',
+ },
+ toPromptAction(TABLE_STARTERS[0]),
+ ...CANDIDATES.filter((c) => c.blockType === 'github' && c.featured)
+ .slice(0, 1)
+ .map(toPromptAction),
+]
+
+interface SuggestedActionsProps {
+ onSelectPrompt: (prompt: string) => void
+}
+
+export function SuggestedActions({ onSelectPrompt }: SuggestedActionsProps) {
+ const { workspaceId } = useParams<{ workspaceId: string }>()
+ const posthog = usePostHog()
+
+ const { data: credentials = EMPTY_CREDENTIALS } = useWorkspaceCredentials({
+ workspaceId,
+ enabled: Boolean(workspaceId),
+ })
+ const { data: services = EMPTY_SERVICES } = useOAuthConnections()
+ const { data: tables = [] } = useTablesList(workspaceId)
+ const { data: knowledgeBases = [] } = useKnowledgeBasesQuery(workspaceId, {
+ enabled: Boolean(workspaceId),
+ })
+
+ const [expanded, setExpanded] = useState(true)
+ /**
+ * Collapsible animations are enabled only after the first user toggle, so
+ * the initially-open, server-rendered panel appears at full height on first
+ * paint instead of replaying the open animation and shifting the input
+ * above it.
+ */
+ const [animationsEnabled, setAnimationsEnabled] = useState(false)
+ /** Incremented by the shuffle control to re-roll the weighted sample. */
+ const [shuffleNonce, setShuffleNonce] = useState(0)
+ /**
+ * OAuth connect modal target. Setting this opens the modal; setting it back
+ * to `null` (via `onOpenChange(false)`) closes it. Mirrors the local-state
+ * pattern used by the integrations detail page.
+ */
+ const [oauthTarget, setOAuthTarget] = useState(null)
+
+ const connectedProviders = useMemo(
+ () =>
+ new Set(
+ credentials
+ .filter((c) => c.type === 'oauth' || c.type === 'service_account')
+ .map((c) => c.providerId)
+ .filter((id): id is string => Boolean(id))
+ ),
+ [credentials]
+ )
+
+ const signals = useMemo(
+ () => ({
+ connectedProviders,
+ hasTables: tables.length > 0,
+ hasKnowledgeBases: knowledgeBases.length > 0,
+ }),
+ [connectedProviders, tables.length, knowledgeBases.length]
+ )
+
+ /**
+ * Personalized suggestions, re-sampled whenever signals resolve or the user
+ * shuffles. Falls back to {@link INITIAL_ACTIONS} until the credential and
+ * service queries have loaded (and stays there for users with no
+ * connections, unless they shuffle), so first paint never flashes.
+ */
+ const actions = useMemo(() => {
+ const personalized = services.length > 0 && connectedProviders.size > 0
+ if (!personalized && shuffleNonce === 0) return INITIAL_ACTIONS
+ return computeActions(services, signals)
+ }, [connectedProviders, services, signals, shuffleNonce])
+
+ const handleSelect = (action: Action, position: number) => {
+ captureEvent(posthog, 'suggested_action_clicked', {
+ workspace_id: workspaceId,
+ kind: action.kind,
+ action_id: action.id,
+ label: action.label,
+ position,
+ connected_provider_count: connectedProviders.size,
+ })
+ if (action.kind === 'prompt') {
+ onSelectPrompt(action.prompt)
+ return
+ }
+ const match = resolveOAuthServiceForSlug(action.slug)
+ if (match) setOAuthTarget(match)
+ }
+
+ const handleShuffle = () => {
+ captureEvent(posthog, 'suggested_actions_shuffled', {
+ workspace_id: workspaceId,
+ connected_provider_count: connectedProviders.size,
+ })
+ setShuffleNonce((n) => n + 1)
+ }
+
+ const handleToggleExpanded = () => {
+ captureEvent(posthog, 'suggested_actions_toggled', {
+ workspace_id: workspaceId,
+ expanded: !expanded,
+ })
+ setAnimationsEnabled(true)
+ setExpanded((prev) => !prev)
+ }
+
+ return (
+
+
+
+ Suggested actions
+
+
+
+ Shuffle
+
+
+
+
+
+
+ {actions.map((action, i) => {
+ const Icon = action.icon
+ return (
+
handleSelect(action, i)}
+ className={cn(
+ 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors hover-hover:bg-[var(--surface-5)]',
+ i > 0 && 'border-t'
+ )}
+ >
+
+
+ {action.label}
+
+
+
+ )
+ })}
+
+
+
+ {oauthTarget && workspaceId && (
+
{
+ if (!open) setOAuthTarget(null)
+ }}
+ workspaceId={workspaceId}
+ providerId={oauthTarget.providerId}
+ requiredScopes={oauthTarget.requiredScopes}
+ serviceName={oauthTarget.serviceName}
+ serviceIcon={oauthTarget.serviceIcon}
+ />
+ )}
+
+ )
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/consts.ts b/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/consts.ts
deleted file mode 100644
index 0588298133f..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/consts.ts
+++ /dev/null
@@ -1,969 +0,0 @@
-import type { ComponentType, SVGProps } from 'react'
-import {
- BookOpen,
- Bug,
- Calendar,
- Card,
- ClipboardList,
- DocumentAttachment,
- File,
- FolderCode,
- Hammer,
- Integration,
- Layout,
- Mail,
- Pencil,
- Rocket,
- Search,
- Send,
- ShieldCheck,
- Table,
- Users,
- Wrench,
-} from '@/components/emcn/icons'
-import {
- AirtableIcon,
- AmplitudeIcon,
- ApolloIcon,
- CalendlyIcon,
- ConfluenceIcon,
- DatadogIcon,
- DiscordIcon,
- FirecrawlIcon,
- GithubIcon,
- GmailIcon,
- GongIcon,
- GoogleCalendarIcon,
- GoogleDriveIcon,
- GoogleSheetsIcon,
- GreenhouseIcon,
- HubspotIcon,
- IntercomIcon,
- JiraIcon,
- LemlistIcon,
- LinearIcon,
- LinkedInIcon,
- MicrosoftTeamsIcon,
- NotionIcon,
- PagerDutyIcon,
- RedditIcon,
- SalesforceIcon,
- ShopifyIcon,
- SlackIcon,
- StripeIcon,
- TwilioIcon,
- TypeformIcon,
- WebflowIcon,
- WordpressIcon,
- YouTubeIcon,
- ZendeskIcon,
-} from '@/components/icons'
-import { MarkdownIcon } from '@/components/icons/document-icons'
-
-/**
- * Modules that a template leverages.
- * Used to show pill badges so users understand what platform features are involved.
- */
-export const MODULE_META = {
- 'knowledge-base': { label: 'Knowledge Base' },
- tables: { label: 'Tables' },
- files: { label: 'Files' },
- workflows: { label: 'Workflows' },
- scheduled: { label: 'Scheduled Tasks' },
- agent: { label: 'Agent' },
-} as const
-
-export type ModuleTag = keyof typeof MODULE_META
-
-/**
- * Categories for grouping templates in the UI.
- */
-export const CATEGORY_META = {
- popular: { label: 'Popular' },
- sales: { label: 'Sales & CRM' },
- support: { label: 'Support' },
- engineering: { label: 'Engineering' },
- marketing: { label: 'Marketing & Content' },
- productivity: { label: 'Productivity' },
- operations: { label: 'Operations' },
-} as const
-
-export type Category = keyof typeof CATEGORY_META
-
-/**
- * Freeform tags for cross-cutting concerns that don't fit neatly into a single category.
- * Use these to filter templates by persona, pattern, or domain in the future.
- *
- * Persona tags: founder, sales, engineering, marketing, support, hr, finance, product, community, devops
- * Pattern tags: monitoring, reporting, automation, research, sync, communication, analysis
- * Domain tags: ecommerce, legal, recruiting, infrastructure, content, crm
- */
-export type Tag = string
-
-export interface TemplatePrompt {
- icon: ComponentType>
- title: string
- prompt: string
- image?: string
- // Base block type keys from `blocks/registry.ts` for integrations used by this template.
- integrationBlockTypes: string[]
- modules: ModuleTag[]
- category: Category
- tags: Tag[]
- featured?: boolean
-}
-
-/**
- * To add a new template:
- * 1. Add an entry to this array with the required fields.
- * 2. Set `featured: true` if it should appear in the initial grid.
- * 3. Optionally add a screenshot to `/public/templates/` and reference it in `image`.
- * 4. Add relevant `tags` for cross-cutting filtering (persona, pattern, domain).
- */
-export const TEMPLATES: TemplatePrompt[] = [
- // ── Popular / Featured ──────────────────────────────────────────────────
- {
- icon: Table,
- title: 'Self-populating CRM',
- prompt:
- 'Create a self-healing CRM table that keeps track of all my customers by integrating with my existing data sources. Schedule a recurring job every morning to automatically pull updates from all relevant data sources and keep my CRM up to date.',
- image: '/templates/crm-light.png',
- integrationBlockTypes: [],
- modules: ['tables', 'scheduled', 'workflows'],
- category: 'popular',
- tags: ['founder', 'sales', 'crm', 'sync', 'automation'],
- featured: true,
- },
- {
- icon: GoogleCalendarIcon,
- title: 'Meeting prep agent',
- prompt:
- 'Create an agent that checks my Google Calendar each morning, researches every attendee and topic on the web, and prepares a brief for each meeting so I walk in fully prepared. Schedule it to run every weekday morning.',
- image: '/templates/meeting-prep-dark.png',
- integrationBlockTypes: ['google_calendar'],
- modules: ['agent', 'scheduled', 'workflows'],
- category: 'popular',
- tags: ['founder', 'sales', 'research', 'automation'],
- featured: true,
- },
- {
- icon: MarkdownIcon,
- title: 'Resolve todo list',
- prompt:
- 'Create a file of all my todos then go one by one and check off every time a todo is done. Look at my calendar and see what I have to do.',
- image: '/templates/todo-list-light.png',
- integrationBlockTypes: [],
- modules: ['files', 'agent', 'workflows'],
- category: 'popular',
- tags: ['individual', 'automation'],
- featured: true,
- },
- {
- icon: Search,
- title: 'Research assistant',
- prompt:
- 'Build an agent that takes a topic, searches the web for the latest information, summarizes key findings, and compiles them into a clean document I can review.',
- image: '/templates/research-assistant-dark.png',
- integrationBlockTypes: [],
- modules: ['agent', 'files', 'workflows'],
- category: 'popular',
- tags: ['founder', 'research', 'content', 'individual'],
- featured: true,
- },
- {
- icon: GmailIcon,
- title: 'Auto-reply agent',
- prompt:
- 'Create a workflow that reads my Gmail inbox, identifies emails that need a response, and drafts contextual replies for each one. Schedule it to run every hour.',
- image: '/templates/gmail-agent-dark.png',
- integrationBlockTypes: ['gmail'],
- modules: ['agent', 'workflows'],
- category: 'popular',
- tags: ['individual', 'communication', 'automation'],
- featured: true,
- },
- {
- icon: Table,
- title: 'Expense tracker',
- prompt:
- 'Create a table that tracks all my expenses by pulling transactions from my connected accounts. Categorize each expense automatically and generate a weekly summary report.',
- image: '/templates/expense-tracker-light.png',
- integrationBlockTypes: [],
- modules: ['tables', 'scheduled', 'workflows'],
- category: 'popular',
- tags: ['finance', 'individual', 'reporting'],
- featured: true,
- },
-
- // ── Sales & CRM ────────────────────────────────────────────────────────
- {
- icon: FolderCode,
- title: 'RFP and proposal drafter',
- prompt:
- 'Create a knowledge base from my past proposals, case studies, and company information. Then build an agent that drafts responses to new RFPs by matching requirements to relevant past work, generating tailored sections, and compiling a complete proposal file.',
- integrationBlockTypes: [],
- modules: ['knowledge-base', 'files', 'agent'],
- category: 'sales',
- tags: ['sales', 'content', 'enterprise'],
- },
- {
- icon: File,
- title: 'Competitive battle cards',
- prompt:
- 'Create an agent that deep-researches each of my competitors using web search — their product features, pricing, positioning, strengths, and weaknesses — and generates a structured battle card document for each one that my sales team can reference during calls.',
- integrationBlockTypes: [],
- modules: ['agent', 'files', 'workflows'],
- category: 'sales',
- tags: ['sales', 'research', 'content'],
- },
- {
- icon: ClipboardList,
- title: 'QBR prep agent',
- prompt:
- 'Build a workflow that compiles everything needed for a quarterly business review — pulling customer usage data, support ticket history, billing summary, and key milestones from my tables — and generates a polished QBR document ready to present.',
- integrationBlockTypes: [],
- modules: ['tables', 'files', 'agent', 'workflows'],
- category: 'sales',
- tags: ['sales', 'support', 'reporting'],
- },
- {
- icon: SalesforceIcon,
- title: 'CRM knowledge search',
- prompt:
- 'Create a knowledge base connected to my Salesforce account so all deals, contacts, notes, and activities are automatically synced and searchable. Then build an agent I can ask things like "what\'s the history with Acme Corp?" or "who was involved in the last enterprise deal?" and get instant answers with CRM record citations.',
- integrationBlockTypes: ['salesforce'],
- modules: ['knowledge-base', 'agent'],
- category: 'sales',
- tags: ['sales', 'crm', 'research'],
- },
- {
- icon: HubspotIcon,
- title: 'HubSpot deal search',
- prompt:
- 'Create a knowledge base connected to my HubSpot account so all deals, contacts, and activity history are automatically synced and searchable. Then build an agent I can ask things like "what happened with the Stripe integration deal?" or "which deals closed last quarter over $50k?" and get answers with HubSpot record links.',
- integrationBlockTypes: ['hubspot'],
- modules: ['knowledge-base', 'agent'],
- category: 'sales',
- tags: ['sales', 'crm', 'research'],
- },
- {
- icon: Users,
- title: 'Lead enrichment pipeline',
- prompt:
- 'Build a workflow that watches my leads table for new entries, enriches each lead with company size, funding, tech stack, and decision-maker contacts using Apollo and web search, then updates the table with the enriched information.',
- integrationBlockTypes: ['apollo'],
- modules: ['tables', 'agent', 'workflows'],
- category: 'sales',
- tags: ['sales', 'crm', 'automation', 'research'],
- },
- {
- icon: ApolloIcon,
- title: 'Prospect researcher',
- prompt:
- 'Create an agent that takes a company name, deep-researches them across the web, finds key decision-makers, recent news, funding rounds, and pain points, then compiles a prospect brief I can review before outreach.',
- integrationBlockTypes: [],
- modules: ['agent', 'files', 'workflows'],
- category: 'sales',
- tags: ['sales', 'research'],
- },
- {
- icon: LemlistIcon,
- title: 'Outbound sequence builder',
- prompt:
- 'Build a workflow that reads leads from my table, researches each prospect and their company on the web, writes a personalized cold email tailored to their role and pain points, and sends it via Gmail. Schedule it to run daily to process new leads automatically.',
- integrationBlockTypes: ['gmail'],
- modules: ['tables', 'agent', 'workflows'],
- category: 'sales',
- tags: ['sales', 'communication', 'automation'],
- },
- {
- icon: SalesforceIcon,
- title: 'Deal pipeline tracker',
- prompt:
- 'Create a table with columns for deal name, stage, amount, close date, and next steps. Build a workflow that syncs open deals from Salesforce into this table daily, and sends me a Slack summary each morning of deals that need attention or are at risk of slipping.',
- integrationBlockTypes: ['salesforce', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'sales',
- tags: ['sales', 'crm', 'monitoring', 'reporting'],
- },
- {
- icon: HubspotIcon,
- title: 'Win/loss analyzer',
- prompt:
- 'Build a workflow that pulls closed deals from HubSpot each week, analyzes patterns in wins vs losses — deal size, industry, sales cycle length, objections — and generates a report file with actionable insights on what to change. Schedule it to run every Monday.',
- integrationBlockTypes: ['hubspot'],
- modules: ['agent', 'files', 'scheduled', 'workflows'],
- category: 'sales',
- tags: ['sales', 'crm', 'analysis', 'reporting'],
- },
- {
- icon: GongIcon,
- title: 'Sales call analyzer',
- prompt:
- 'Build a workflow that pulls call transcripts from Gong after each sales call, identifies key objections raised, action items promised, and competitor mentions, updates the deal record in my CRM, and posts a call summary with next steps to the Slack deal channel.',
- integrationBlockTypes: ['gong', 'slack'],
- modules: ['agent', 'tables', 'workflows'],
- category: 'sales',
- tags: ['sales', 'analysis', 'communication'],
- },
- {
- icon: WebflowIcon,
- title: 'Webflow lead capture pipeline',
- prompt:
- 'Create a workflow that monitors new Webflow form submissions, enriches each lead with company and contact data using Apollo and web search, adds them to a tracking table with a lead score, and sends a Slack notification to the sales team for high-potential leads.',
- integrationBlockTypes: ['webflow', 'apollo', 'slack'],
- modules: ['tables', 'agent', 'workflows'],
- category: 'sales',
- tags: ['sales', 'crm', 'automation'],
- },
-
- // ── Support ─────────────────────────────────────────────────────────────
- {
- icon: Send,
- title: 'Customer support bot',
- prompt:
- 'Create a knowledge base and connect it to my Notion or Google Docs so it stays synced with my product documentation automatically. Then build an agent that answers customer questions using it with sourced citations and deploy it as a chat endpoint.',
- integrationBlockTypes: ['notion', 'google_docs'],
- modules: ['knowledge-base', 'agent', 'workflows'],
- category: 'support',
- tags: ['support', 'communication', 'automation'],
- },
- {
- icon: SlackIcon,
- title: 'Slack Q&A bot',
- prompt:
- 'Create a knowledge base connected to my Notion workspace so it stays synced with my company wiki. Then build a workflow that monitors Slack channels for questions and answers them using the knowledge base with source citations.',
- integrationBlockTypes: ['notion', 'slack'],
- modules: ['knowledge-base', 'agent', 'workflows'],
- category: 'support',
- tags: ['support', 'communication', 'team'],
- },
- {
- icon: IntercomIcon,
- title: 'Customer feedback analyzer',
- prompt:
- 'Build a scheduled workflow that pulls support tickets and conversations from Intercom daily, categorizes them by theme and sentiment, tracks trends in a table, and sends a weekly Slack report highlighting the top feature requests and pain points.',
- integrationBlockTypes: ['intercom', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'support',
- tags: ['support', 'product', 'analysis', 'reporting'],
- },
- {
- icon: Table,
- title: 'Churn risk detector',
- prompt:
- 'Create a workflow that monitors customer activity — support ticket frequency, response sentiment, usage patterns — scores each account for churn risk in a table, and triggers a Slack alert to the account team when a customer crosses the risk threshold.',
- integrationBlockTypes: ['slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'support',
- tags: ['support', 'sales', 'monitoring', 'analysis'],
- },
- {
- icon: DiscordIcon,
- title: 'Discord community manager',
- prompt:
- 'Create a knowledge base connected to my Google Docs or Notion with product documentation. Then build a workflow that monitors my Discord server for unanswered questions, answers them using the knowledge base, tracks common questions in a table, and sends a weekly community summary to Slack.',
- integrationBlockTypes: ['discord', 'google_docs', 'notion', 'slack'],
- modules: ['knowledge-base', 'tables', 'agent', 'scheduled', 'workflows'],
- category: 'support',
- tags: ['community', 'support', 'communication'],
- },
- {
- icon: TypeformIcon,
- title: 'Survey response analyzer',
- prompt:
- 'Create a workflow that pulls new Typeform responses daily, categorizes feedback by theme and sentiment, logs structured results to a table, and sends a Slack digest when a new batch of responses comes in with the key takeaways.',
- integrationBlockTypes: ['typeform', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'support',
- tags: ['product', 'analysis', 'reporting'],
- },
- {
- icon: GmailIcon,
- title: 'Email knowledge search',
- prompt:
- 'Create a knowledge base connected to my Gmail so all my emails are automatically synced, chunked, and searchable. Then build an agent I can ask things like "what did Sarah say about the pricing proposal?" or "find the contract John sent last month" and get instant answers with the original email cited.',
- integrationBlockTypes: ['gmail'],
- modules: ['knowledge-base', 'agent'],
- category: 'support',
- tags: ['individual', 'research', 'communication'],
- },
- {
- icon: ZendeskIcon,
- title: 'Support ticket knowledge search',
- prompt:
- 'Create a knowledge base connected to my Zendesk account so all past tickets, resolutions, and agent notes are automatically synced and searchable. Then build an agent my support team can ask things like "how do we usually resolve the SSO login issue?" or "has anyone reported this billing bug before?" to find past solutions instantly.',
- integrationBlockTypes: ['zendesk'],
- modules: ['knowledge-base', 'agent'],
- category: 'support',
- tags: ['support', 'research', 'team'],
- },
-
- // ── Engineering ─────────────────────────────────────────────────────────
- {
- icon: Wrench,
- title: 'Feature spec writer',
- prompt:
- 'Create an agent that takes a rough feature idea or user story, researches how similar features work in competing products, and writes a complete product requirements document with user stories, acceptance criteria, edge cases, and technical considerations.',
- integrationBlockTypes: [],
- modules: ['agent', 'files', 'workflows'],
- category: 'engineering',
- tags: ['product', 'engineering', 'research', 'content'],
- },
- {
- icon: JiraIcon,
- title: 'Jira knowledge search',
- prompt:
- 'Create a knowledge base connected to my Jira project so all tickets, comments, and resolutions are automatically synced and searchable. Then build an agent I can ask things like "how did we fix the auth timeout issue?" or "what was decided about the API redesign?" and get answers with ticket citations.',
- integrationBlockTypes: ['jira'],
- modules: ['knowledge-base', 'agent'],
- category: 'engineering',
- tags: ['engineering', 'research'],
- },
- {
- icon: LinearIcon,
- title: 'Linear knowledge search',
- prompt:
- 'Create a knowledge base connected to my Linear workspace so all issues, comments, project updates, and decisions are automatically synced and searchable. Then build an agent I can ask things like "why did we deprioritize the mobile app?" or "what was the root cause of the checkout bug?" and get answers traced back to specific issues.',
- integrationBlockTypes: ['linear'],
- modules: ['knowledge-base', 'agent'],
- category: 'engineering',
- tags: ['engineering', 'research', 'product'],
- },
- {
- icon: Bug,
- title: 'Bug triage agent',
- prompt:
- 'Build an agent that monitors Sentry for new errors, automatically triages them by severity and affected users, creates Linear tickets for critical issues with full stack traces, and sends a Slack notification to the on-call channel.',
- integrationBlockTypes: ['sentry', 'linear', 'slack'],
- modules: ['agent', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'devops', 'automation'],
- },
- {
- icon: GithubIcon,
- title: 'PR review assistant',
- prompt:
- 'Create a knowledge base connected to my GitHub repo so it stays synced with my style guide and coding standards. Then build a workflow that reviews new pull requests against it, checks for common issues and security vulnerabilities, and posts a review comment with specific suggestions.',
- integrationBlockTypes: ['github'],
- modules: ['knowledge-base', 'agent', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'automation'],
- },
- {
- icon: GithubIcon,
- title: 'Changelog generator',
- prompt:
- 'Build a scheduled workflow that runs every Friday, pulls all merged PRs from GitHub for the week, categorizes changes as features, fixes, or improvements, and generates a user-facing changelog document with clear descriptions.',
- integrationBlockTypes: ['github'],
- modules: ['scheduled', 'agent', 'files', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'product', 'reporting', 'content'],
- },
- {
- icon: LinearIcon,
- title: 'Incident postmortem writer',
- prompt:
- 'Create a workflow that when triggered after an incident, pulls the Slack thread from the incident channel, gathers relevant Sentry errors and deployment logs, and drafts a structured postmortem with timeline, root cause, and action items.',
- integrationBlockTypes: ['slack', 'sentry'],
- modules: ['agent', 'files', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'devops', 'analysis'],
- },
- {
- icon: NotionIcon,
- title: 'Documentation auto-updater',
- prompt:
- 'Create a knowledge base connected to my GitHub repository so code and docs stay synced. Then build a scheduled weekly workflow that detects API changes, compares them against the knowledge base to find outdated documentation, and either updates Notion pages directly or creates Linear tickets for the needed changes.',
- integrationBlockTypes: ['github', 'notion', 'linear'],
- modules: ['scheduled', 'agent', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'sync', 'automation'],
- },
- {
- icon: PagerDutyIcon,
- title: 'Incident response coordinator',
- prompt:
- 'Create a knowledge base connected to my Confluence or Notion with runbooks and incident procedures. Then build a workflow triggered by PagerDuty incidents that searches the runbooks, gathers related Datadog alerts, identifies the on-call rotation, and posts a comprehensive incident brief to Slack.',
- integrationBlockTypes: ['confluence', 'notion', 'pagerduty', 'datadog', 'slack'],
- modules: ['knowledge-base', 'agent', 'workflows'],
- category: 'engineering',
- tags: ['devops', 'engineering', 'automation'],
- },
- {
- icon: JiraIcon,
- title: 'Sprint report generator',
- prompt:
- 'Create a scheduled workflow that runs at the end of each sprint, pulls all completed, in-progress, and blocked Jira tickets, calculates velocity and carry-over, and generates a sprint summary document with charts and trends to share with the team.',
- integrationBlockTypes: ['jira'],
- modules: ['scheduled', 'agent', 'files', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'reporting', 'team'],
- },
- {
- icon: ConfluenceIcon,
- title: 'Knowledge base sync',
- prompt:
- 'Create a knowledge base connected to my Confluence workspace so all wiki pages are automatically synced and searchable. Then build a scheduled workflow that identifies stale pages not updated in 90 days and sends a Slack reminder to page owners to review them.',
- integrationBlockTypes: ['confluence', 'slack'],
- modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
- category: 'engineering',
- tags: ['engineering', 'sync', 'team'],
- },
-
- // ── Marketing & Content ─────────────────────────────────────────────────
- {
- icon: Pencil,
- title: 'Long-form content writer',
- prompt:
- 'Build a workflow that takes a topic or brief, researches it deeply across the web, generates a detailed outline, then writes a full long-form article with sections, examples, and a conclusion. Save the final draft as a document for review.',
- integrationBlockTypes: [],
- modules: ['agent', 'files', 'workflows'],
- category: 'marketing',
- tags: ['content', 'research', 'marketing'],
- },
- {
- icon: Layout,
- title: 'Case study generator',
- prompt:
- 'Create a knowledge base from my customer data and interview notes, then build a workflow that generates a polished case study file with the challenge, solution, results, and a pull quote — formatted and ready to publish.',
- integrationBlockTypes: [],
- modules: ['knowledge-base', 'files', 'agent'],
- category: 'marketing',
- tags: ['marketing', 'content', 'sales'],
- },
- {
- icon: Table,
- title: 'Social media content calendar',
- prompt:
- 'Build a workflow that generates a full month of social media content for my brand. Research trending topics in my industry, create a table with post dates, platforms, copy drafts, and hashtags, then schedule a weekly refresh to keep the calendar filled with fresh ideas.',
- integrationBlockTypes: [],
- modules: ['tables', 'agent', 'scheduled', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'content', 'automation'],
- },
- {
- icon: Integration,
- title: 'Multi-language content translator',
- prompt:
- 'Create a workflow that takes a document or blog post and translates it into multiple target languages while preserving tone, formatting, and brand voice. Save each translation as a separate file and flag sections that may need human review for cultural nuance.',
- integrationBlockTypes: [],
- modules: ['files', 'agent', 'workflows'],
- category: 'marketing',
- tags: ['content', 'enterprise', 'automation'],
- },
- {
- icon: YouTubeIcon,
- title: 'Content repurposer',
- prompt:
- 'Build a workflow that takes a YouTube video URL, pulls the video details and description, researches the topic on the web for additional context, and generates a Twitter thread, LinkedIn post, and blog summary optimized for each platform.',
- integrationBlockTypes: ['youtube'],
- modules: ['agent', 'files', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'content', 'automation'],
- },
- {
- icon: RedditIcon,
- title: 'Social mention tracker',
- prompt:
- 'Create a scheduled workflow that monitors Reddit and X for mentions of my brand and competitors, scores each mention by sentiment and reach, logs them to a table, and sends a daily Slack digest of notable mentions.',
- integrationBlockTypes: ['reddit', 'x', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'monitoring', 'analysis'],
- },
- {
- icon: FirecrawlIcon,
- title: 'SEO content brief generator',
- prompt:
- 'Build a workflow that takes a target keyword, scrapes the top 10 ranking pages, analyzes their content structure and subtopics, then generates a detailed content brief with outline, word count target, questions to answer, and internal linking suggestions.',
- integrationBlockTypes: ['firecrawl'],
- modules: ['agent', 'files', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'content', 'research'],
- },
- {
- icon: Mail,
- title: 'Newsletter curator',
- prompt:
- 'Create a scheduled weekly workflow that scrapes my favorite industry news sites and blogs, picks the top stories relevant to my audience, writes summaries for each, and drafts a ready-to-send newsletter in Mailchimp.',
- integrationBlockTypes: ['mailchimp'],
- modules: ['scheduled', 'agent', 'files', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'content', 'communication'],
- },
- {
- icon: LinkedInIcon,
- title: 'LinkedIn content engine',
- prompt:
- 'Build a workflow that scrapes my company blog for new posts, generates LinkedIn posts with hooks, insights, and calls-to-action optimized for engagement, and saves drafts as files for my review before posting to LinkedIn.',
- integrationBlockTypes: ['linkedin'],
- modules: ['agent', 'files', 'scheduled', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'content', 'automation'],
- },
- {
- icon: WordpressIcon,
- title: 'Blog auto-publisher',
- prompt:
- 'Build a workflow that takes a draft document, optimizes it for SEO by researching target keywords, formats it for WordPress with proper headings and meta description, and publishes it as a draft post for final review.',
- integrationBlockTypes: ['wordpress'],
- modules: ['agent', 'files', 'workflows'],
- category: 'marketing',
- tags: ['marketing', 'content', 'automation'],
- },
-
- // ── Productivity ────────────────────────────────────────────────────────
- {
- icon: BookOpen,
- title: 'Personal knowledge assistant',
- prompt:
- 'Create a knowledge base and connect it to my Google Drive, Notion, or Obsidian so all my notes, docs, and articles are automatically synced and embedded. Then build an agent that I can ask anything — it should answer with citations and deploy as a chat endpoint.',
- integrationBlockTypes: ['google_drive', 'notion', 'obsidian'],
- modules: ['knowledge-base', 'agent'],
- category: 'productivity',
- tags: ['individual', 'research', 'team'],
- },
- {
- icon: SlackIcon,
- title: 'Slack knowledge search',
- prompt:
- 'Create a knowledge base connected to my Slack workspace so all channel conversations and threads are automatically synced and searchable. Then build an agent I can ask things like "what did the team decide about the launch date?" or "what was the outcome of the design review?" and get answers with links to the original messages.',
- integrationBlockTypes: ['slack'],
- modules: ['knowledge-base', 'agent'],
- category: 'productivity',
- tags: ['team', 'research', 'communication'],
- },
- {
- icon: NotionIcon,
- title: 'Notion knowledge search',
- prompt:
- 'Create a knowledge base connected to my Notion workspace so all pages, databases, meeting notes, and wikis are automatically synced and searchable. Then build an agent I can ask things like "what\'s our refund policy?" or "what was decided in the Q3 planning doc?" and get instant answers with page links.',
- integrationBlockTypes: ['notion'],
- modules: ['knowledge-base', 'agent'],
- category: 'productivity',
- tags: ['team', 'research'],
- },
- {
- icon: GoogleDriveIcon,
- title: 'Google Drive knowledge search',
- prompt:
- 'Create a knowledge base connected to my Google Drive so all documents, spreadsheets, and presentations are automatically synced and searchable. Then build an agent I can ask things like "find the board deck from last quarter" or "what were the KPIs in the marketing plan?" and get answers with doc links.',
- integrationBlockTypes: ['google_drive'],
- modules: ['knowledge-base', 'agent'],
- category: 'productivity',
- tags: ['individual', 'team', 'research'],
- },
- {
- icon: DocumentAttachment,
- title: 'Document summarizer',
- prompt:
- 'Create a workflow that takes any uploaded document — PDF, contract, report, research paper — and generates a structured summary with key takeaways, action items, important dates, and a one-paragraph executive overview.',
- integrationBlockTypes: [],
- modules: ['files', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['individual', 'analysis', 'team'],
- },
- {
- icon: Table,
- title: 'Bulk data classifier',
- prompt:
- 'Build a workflow that takes a table of unstructured data — support tickets, feedback, survey responses, leads, or any text — runs each row through an agent to classify, tag, score, and enrich it, then writes the structured results back to the table.',
- integrationBlockTypes: [],
- modules: ['tables', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['analysis', 'automation', 'team'],
- },
- {
- icon: File,
- title: 'Automated narrative report',
- prompt:
- 'Build a scheduled workflow that pulls key data from my tables every week, analyzes trends and anomalies, and writes a narrative report — not just charts and numbers, but written insights explaining what changed, why it matters, and what to do next. Save it as a document and send a summary to Slack.',
- integrationBlockTypes: ['slack'],
- modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
- category: 'productivity',
- tags: ['founder', 'reporting', 'analysis'],
- },
- {
- icon: Rocket,
- title: 'Investor update writer',
- prompt:
- 'Build a workflow that pulls key metrics from my tables — revenue, growth, burn rate, headcount, milestones — and drafts a concise investor update with highlights, lowlights, asks, and KPIs. Save it as a file I can review before sending. Schedule it to run on the first of each month.',
- integrationBlockTypes: [],
- modules: ['tables', 'scheduled', 'agent', 'files', 'workflows'],
- category: 'productivity',
- tags: ['founder', 'reporting', 'communication'],
- },
- {
- icon: BookOpen,
- title: 'Email digest curator',
- prompt:
- 'Create a scheduled daily workflow that searches the web for the latest articles, papers, and news on topics I care about, picks the top 5 most relevant pieces, writes a one-paragraph summary for each, and delivers a curated reading digest to my inbox or Slack.',
- integrationBlockTypes: ['slack'],
- modules: ['scheduled', 'agent', 'files', 'workflows'],
- category: 'productivity',
- tags: ['individual', 'research', 'content'],
- },
- {
- icon: Search,
- title: 'Knowledge extractor',
- prompt:
- 'Build a workflow that takes raw meeting notes, brainstorm dumps, or research transcripts, extracts the key insights, decisions, and facts, organizes them by topic, and saves them into my knowledge base so they are searchable and reusable in future conversations.',
- integrationBlockTypes: [],
- modules: ['files', 'knowledge-base', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['individual', 'team', 'research'],
- },
- {
- icon: Calendar,
- title: 'Weekly team digest',
- prompt:
- "Build a scheduled workflow that runs every Friday, pulls the week's GitHub commits, closed Linear issues, and key Slack conversations, then emails a formatted weekly summary to the team.",
- integrationBlockTypes: ['github', 'linear', 'slack'],
- modules: ['scheduled', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['engineering', 'team', 'reporting'],
- },
- {
- icon: ClipboardList,
- title: 'Daily standup summary',
- prompt:
- 'Create a scheduled workflow that reads the #standup Slack channel each morning, summarizes what everyone is working on, identifies blockers, and posts a structured recap to a Google Doc.',
- integrationBlockTypes: ['slack', 'google_docs'],
- modules: ['scheduled', 'agent', 'files', 'workflows'],
- category: 'productivity',
- tags: ['team', 'reporting', 'communication'],
- },
- {
- icon: GmailIcon,
- title: 'Email triage assistant',
- prompt:
- 'Build a workflow that scans my Gmail inbox every hour, categorizes emails by urgency and type (action needed, FYI, follow-up), drafts replies for routine messages, and sends me a prioritized summary in Slack so I only open what matters. Schedule it to run hourly.',
- integrationBlockTypes: ['gmail', 'slack'],
- modules: ['agent', 'scheduled', 'workflows'],
- category: 'productivity',
- tags: ['individual', 'communication', 'automation'],
- },
- {
- icon: SlackIcon,
- title: 'Meeting notes to action items',
- prompt:
- 'Create a workflow that takes meeting notes or a transcript, extracts action items with owners and due dates, creates tasks in Linear or Asana for each one, and posts a summary to the relevant Slack channel.',
- integrationBlockTypes: ['linear', 'asana', 'slack'],
- modules: ['agent', 'workflows'],
- category: 'productivity',
- tags: ['team', 'automation'],
- },
- {
- icon: GoogleSheetsIcon,
- title: 'Weekly metrics report',
- prompt:
- 'Build a scheduled workflow that pulls data from Stripe and my database every Monday, calculates key metrics like MRR, churn, new subscriptions, and failed payments, populates a Google Sheet, and Slacks the team a summary with week-over-week trends.',
- integrationBlockTypes: ['stripe', 'google_sheets', 'slack'],
- modules: ['scheduled', 'tables', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['founder', 'finance', 'reporting'],
- },
- {
- icon: AmplitudeIcon,
- title: 'Product analytics digest',
- prompt:
- 'Create a scheduled weekly workflow that pulls key product metrics from Amplitude — active users, feature adoption rates, retention cohorts, and top events — generates an executive summary with week-over-week trends, and posts it to Slack.',
- integrationBlockTypes: ['amplitude', 'slack'],
- modules: ['scheduled', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['product', 'reporting', 'analysis'],
- },
- {
- icon: CalendlyIcon,
- title: 'Scheduling follow-up automator',
- prompt:
- 'Build a workflow that monitors new Calendly bookings, researches each attendee and their company, prepares a pre-meeting brief with relevant context, and sends a personalized confirmation email with an agenda and any prep materials.',
- integrationBlockTypes: ['calendly'],
- modules: ['agent', 'workflows'],
- category: 'productivity',
- tags: ['sales', 'research', 'automation'],
- },
- {
- icon: TwilioIcon,
- title: 'SMS appointment reminders',
- prompt:
- 'Create a scheduled workflow that checks Google Calendar each morning for appointments in the next 24 hours, and sends an SMS reminder to each attendee via Twilio with the meeting time, location, and any prep notes.',
- integrationBlockTypes: ['google_calendar', 'twilio_sms'],
- modules: ['scheduled', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['individual', 'communication', 'automation'],
- },
- {
- icon: MicrosoftTeamsIcon,
- title: 'Microsoft Teams daily brief',
- prompt:
- 'Build a scheduled workflow that pulls updates from your project tools — GitHub commits, Jira ticket status changes, and calendar events — and posts a formatted daily brief to your Microsoft Teams channel each morning.',
- integrationBlockTypes: ['github', 'jira', 'microsoft_teams'],
- modules: ['scheduled', 'agent', 'workflows'],
- category: 'productivity',
- tags: ['team', 'reporting', 'enterprise'],
- },
-
- // ── Operations ──────────────────────────────────────────────────────────
- {
- icon: Table,
- title: 'Data cleanup agent',
- prompt:
- 'Create a workflow that takes a messy table — inconsistent formatting, duplicates, missing fields, typos — and cleans it up by standardizing values, merging duplicates, filling gaps where possible, and flagging rows that need human review.',
- integrationBlockTypes: [],
- modules: ['tables', 'agent', 'workflows'],
- category: 'operations',
- tags: ['automation', 'analysis'],
- },
- {
- icon: Hammer,
- title: 'Training material generator',
- prompt:
- 'Create a knowledge base from my product documentation, then build a workflow that generates training materials from it — onboarding guides, FAQ documents, step-by-step tutorials, and quiz questions. Schedule it to regenerate weekly so materials stay current as docs change.',
- integrationBlockTypes: [],
- modules: ['knowledge-base', 'files', 'agent', 'scheduled'],
- category: 'operations',
- tags: ['hr', 'content', 'team', 'automation'],
- },
- {
- icon: File,
- title: 'SOP generator',
- prompt:
- 'Create an agent that takes a brief description of any business process — from employee onboarding to incident response to content publishing — and generates a detailed standard operating procedure document with numbered steps, responsible roles, decision points, and checklists.',
- integrationBlockTypes: [],
- modules: ['files', 'agent'],
- category: 'operations',
- tags: ['team', 'enterprise', 'content'],
- },
- {
- icon: Card,
- title: 'Invoice processor',
- prompt:
- 'Build a workflow that processes invoice PDFs from Gmail, extracts vendor name, amount, due date, and line items, then logs everything to a tracking table and sends a Slack alert for invoices due within 7 days.',
- integrationBlockTypes: ['gmail', 'slack'],
- modules: ['files', 'tables', 'agent', 'workflows'],
- category: 'operations',
- tags: ['finance', 'automation'],
- },
- {
- icon: File,
- title: 'Contract analyzer',
- prompt:
- 'Create a knowledge base from my standard contract terms, then build a workflow that reviews uploaded contracts against it — extracting key clauses like payment terms, liability caps, and termination conditions, flagging deviations, and outputting a summary to a table.',
- integrationBlockTypes: [],
- modules: ['knowledge-base', 'files', 'tables', 'agent'],
- category: 'operations',
- tags: ['legal', 'analysis'],
- },
- {
- icon: FirecrawlIcon,
- title: 'Competitive intel monitor',
- prompt:
- 'Build a scheduled workflow that scrapes competitor websites, pricing pages, and changelog pages weekly using Firecrawl, compares against previous snapshots, summarizes any changes, logs them to a tracking table, and sends a Slack alert for major updates.',
- integrationBlockTypes: ['firecrawl', 'slack'],
- modules: ['scheduled', 'tables', 'agent', 'workflows'],
- category: 'operations',
- tags: ['founder', 'product', 'monitoring', 'research'],
- },
- {
- icon: StripeIcon,
- title: 'Revenue operations dashboard',
- prompt:
- 'Create a scheduled daily workflow that pulls payment data from Stripe, calculates MRR, net revenue, failed payments, and new subscriptions, logs everything to a table with historical tracking, and sends a daily Slack summary with trends and anomalies.',
- integrationBlockTypes: ['stripe', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['finance', 'founder', 'reporting', 'monitoring'],
- },
- {
- icon: ShopifyIcon,
- title: 'E-commerce order monitor',
- prompt:
- 'Build a workflow that monitors Shopify orders, flags high-value or unusual orders for review, tracks fulfillment status in a table, and sends daily inventory and sales summaries to Slack with restock alerts when items run low.',
- integrationBlockTypes: ['shopify', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['ecommerce', 'monitoring', 'reporting'],
- },
- {
- icon: ShieldCheck,
- title: 'Compliance document checker',
- prompt:
- 'Create a knowledge base from my compliance requirements and policies, then build an agent that reviews uploaded policy documents and SOC 2 evidence against it, identifies gaps or outdated sections, and generates a remediation checklist file with priority levels.',
- integrationBlockTypes: [],
- modules: ['knowledge-base', 'files', 'agent'],
- category: 'operations',
- tags: ['legal', 'enterprise', 'analysis'],
- },
- {
- icon: Users,
- title: 'New hire onboarding automation',
- prompt:
- "Build a workflow that when triggered with a new hire's info, creates their accounts, sends a personalized welcome message in Slack, schedules 1:1s with their team on Google Calendar, shares relevant onboarding docs from the knowledge base, and tracks completion in a table.",
- integrationBlockTypes: ['slack', 'google_calendar'],
- modules: ['knowledge-base', 'tables', 'agent', 'workflows'],
- category: 'operations',
- tags: ['hr', 'automation', 'team'],
- },
- {
- icon: ClipboardList,
- title: 'Candidate screening assistant',
- prompt:
- 'Create a knowledge base from my job descriptions and hiring criteria, then build a workflow that takes uploaded resumes, evaluates candidates against the requirements, scores them on experience, skills, and culture fit, and populates a comparison table with a summary and recommendation for each.',
- integrationBlockTypes: [],
- modules: ['knowledge-base', 'files', 'tables', 'agent'],
- category: 'operations',
- tags: ['hr', 'recruiting', 'analysis'],
- },
- {
- icon: GreenhouseIcon,
- title: 'Recruiting pipeline automator',
- prompt:
- 'Build a scheduled workflow that syncs open jobs and candidates from Greenhouse to a tracking table daily, flags candidates who have been in the same stage for more than 5 days, and sends a Slack summary to hiring managers with pipeline stats and bottlenecks.',
- integrationBlockTypes: ['greenhouse', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['hr', 'recruiting', 'monitoring', 'reporting'],
- },
- {
- icon: DatadogIcon,
- title: 'Infrastructure health report',
- prompt:
- 'Create a scheduled daily workflow that queries Datadog for key infrastructure metrics — error rates, latency percentiles, CPU and memory usage — logs them to a table for trend tracking, and sends a morning Slack report highlighting any anomalies or degradations.',
- integrationBlockTypes: ['datadog', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['devops', 'infrastructure', 'monitoring', 'reporting'],
- },
- {
- icon: AirtableIcon,
- title: 'Airtable data sync',
- prompt:
- 'Create a scheduled workflow that syncs records from my Airtable base into a Sim table every hour, keeping both in sync. Use an agent to detect changes, resolve conflicts, and flag any discrepancies for review in Slack.',
- integrationBlockTypes: ['airtable', 'slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['sync', 'automation'],
- },
- {
- icon: Search,
- title: 'Multi-source knowledge hub',
- prompt:
- 'Create a knowledge base and connect it to Confluence, Notion, and Google Drive so all my company documentation is automatically synced, chunked, and embedded. Then deploy a Q&A agent that can answer questions across all sources with citations.',
- integrationBlockTypes: ['confluence', 'notion', 'google_drive'],
- modules: ['knowledge-base', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['enterprise', 'team', 'sync', 'automation'],
- },
- {
- icon: Table,
- title: 'Customer 360 view',
- prompt:
- 'Create a comprehensive customer table that aggregates data from my CRM, support tickets, billing history, and product usage into a single unified view per customer. Schedule it to sync daily and send a Slack alert when any customer shows signs of trouble across multiple signals.',
- integrationBlockTypes: ['slack'],
- modules: ['tables', 'scheduled', 'agent', 'workflows'],
- category: 'operations',
- tags: ['founder', 'sales', 'support', 'enterprise', 'sync'],
- },
-]
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/index.ts
deleted file mode 100644
index 17388866dc9..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export type { Category, ModuleTag, Tag, TemplatePrompt } from './consts'
-export { CATEGORY_META, MODULE_META, TEMPLATES } from './consts'
-export { TemplatePrompts } from './template-prompts'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx
deleted file mode 100644
index e7a0fa3b91f..00000000000
--- a/apps/sim/app/workspace/[workspaceId]/home/components/template-prompts/template-prompts.tsx
+++ /dev/null
@@ -1,392 +0,0 @@
-'use client'
-
-import { type ComponentType, memo, type SVGProps } from 'react'
-import Image from 'next/image'
-import { AgentIcon, ScheduleIcon, StartIcon } from '@/components/icons'
-import type { Category, ModuleTag } from './consts'
-import { CATEGORY_META, TEMPLATES } from './consts'
-
-const FEATURED_TEMPLATES = TEMPLATES.filter((t) => t.featured)
-const EXTRA_TEMPLATES = TEMPLATES.filter((t) => !t.featured)
-
-function getGroupedExtras() {
- const groups: { category: Category; label: string; templates: typeof TEMPLATES }[] = []
- const byCategory = new Map()
-
- for (const t of EXTRA_TEMPLATES) {
- const existing = byCategory.get(t.category)
- if (existing) {
- existing.push(t)
- } else {
- const arr = [t]
- byCategory.set(t.category, arr)
- }
- }
-
- for (const [key, meta] of Object.entries(CATEGORY_META)) {
- const cat = key as Category
- if (cat === 'popular') continue
- const items = byCategory.get(cat)
- if (items?.length) {
- groups.push({ category: cat, label: meta.label, templates: items })
- }
- }
-
- return groups
-}
-
-const GROUPED_EXTRAS = getGroupedExtras()
-
-const MINI_TABLE_DATA = [
- ['Sarah Chen', 'sarah@acme.co', 'Acme Inc', 'Qualified'],
- ['James Park', 'james@globex.io', 'Globex', 'New'],
- ['Maria Santos', 'maria@initech.com', 'Initech', 'Contacted'],
- ['Alex Kim', 'alex@umbrella.co', 'Umbrella', 'Qualified'],
- ['Emma Wilson', 'emma@stark.io', 'Stark Ind', 'New'],
-] as const
-
-const STATUS_DOT: Record = {
- Qualified: 'bg-emerald-400',
- New: 'bg-blue-400',
- Contacted: 'bg-amber-400',
-}
-
-const MINI_KB_DATA = [
- ['product-specs.pdf', '4.2 MB', '12.4k', 'Enabled'],
- ['eng-handbook.md', '1.8 MB', '8.2k', 'Enabled'],
- ['api-reference.json', '920 KB', '4.1k', 'Enabled'],
- ['release-notes.md', '340 KB', '2.8k', 'Enabled'],
- ['onboarding.pdf', '2.1 MB', '6.5k', 'Processing'],
-] as const
-
-const KB_BADGE: Record = {
- Enabled: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400',
- Processing: 'bg-violet-500/15 text-violet-700 dark:text-violet-400',
-}
-
-interface WorkflowBlockDef {
- color: string
- name: string
- icon: ComponentType>
- rows: { title: string; value: string }[]
-}
-
-function PreviewTable() {
- return (
-
-
- {['Name', 'Email', 'Company', 'Status'].map((col) => (
-
- {col}
-
- ))}
-
- {MINI_TABLE_DATA.map((row, i) => (
-
- {row.map((cell, j) => (
-
- {j === 3 ? (
-
- ) : (
-
- {cell}
-
- )}
-
- ))}
-
- ))}
-
- )
-}
-
-function PreviewKnowledge() {
- return (
-
-
- {['Name', 'Size', 'Tokens', 'Status'].map((col) => (
-
- {col}
-
- ))}
-
- {MINI_KB_DATA.map((row, i) => (
-
-
-
- {row[0]}
-
-
-
- {row[1]}
-
-
- {row[2]}
-
-
-
- {row[3]}
-
-
-
- ))}
-
- )
-}
-
-function PreviewFile() {
- return (
-
-
- Files
- /
- meeting-notes.md
-
-
-
Meeting Notes
-
Action Items
-
- • Review Q1 metrics with Sarah
-
-
• Update API documentation
-
- • Schedule design review for v2.0
-
-
Discussion Points
-
- The team agreed to prioritize the new onboarding flow…
-
-
Next Steps
-
- Follow up with engineering on the API v2 migration.
-
-
-
- )
-}
-
-const WorkflowMiniBlock = memo(function WorkflowMiniBlock({
- color,
- name,
- icon: Icon,
- rows,
-}: WorkflowBlockDef) {
- const hasRows = rows.length > 0
- return (
-
-
- {rows.map((row) => (
-
- {row.title}
- {row.value}
-
- ))}
-
- )
-})
-
-function buildWorkflowBlocks(template: (typeof TEMPLATES)[number]): WorkflowBlockDef[] {
- const modules = template.modules
- const toolName = template.title.split(' ')[0]
- const hasAgent = modules.includes('agent')
- const isScheduled = modules.includes('scheduled')
-
- const starter: WorkflowBlockDef = isScheduled
- ? {
- color: '#6366F1',
- name: 'Schedule',
- icon: ScheduleIcon,
- rows: [{ title: 'Cron', value: '0 9 * * 1' }],
- }
- : {
- color: '#2FB3FF',
- name: 'Starter',
- icon: StartIcon,
- rows: [{ title: 'Trigger', value: 'Manual' }],
- }
-
- const agent: WorkflowBlockDef = {
- color: '#802FFF',
- name: 'Agent',
- icon: AgentIcon,
- rows: [{ title: 'Model', value: 'gpt-4o' }],
- }
-
- const tool: WorkflowBlockDef = {
- color: '#3B3B3B',
- name: toolName,
- icon: template.icon,
- rows: [{ title: 'Action', value: 'Run' }],
- }
-
- if (hasAgent) return [starter, agent, tool]
- return [starter, tool]
-}
-
-const BLOCK_W = 76
-const EDGE_W = 14
-
-function PreviewWorkflow({ template }: { template: (typeof TEMPLATES)[number] }) {
- const blocks = buildWorkflowBlocks(template)
- const goesUp = template.title.charCodeAt(0) % 2 === 0
-
- const twoBlock = blocks.length === 2
- const offsets = twoBlock
- ? goesUp
- ? [-10, 10]
- : [10, -10]
- : goesUp
- ? [-12, 12, -12]
- : [12, -12, 12]
-
- const totalW = blocks.length * BLOCK_W + (blocks.length - 1) * EDGE_W
-
- return (
-
-
-
- {blocks.slice(1).map((_, i) => {
- const x1 = i * (BLOCK_W + EDGE_W) + BLOCK_W
- const y1 = 35 + offsets[i]
- const x2 = (i + 1) * (BLOCK_W + EDGE_W)
- const y2 = 35 + offsets[i + 1]
- const midX = (x1 + x2) / 2
- return (
-
- )
- })}
-
-
- {blocks.map((block, i) => {
- const x = i * (BLOCK_W + EDGE_W)
- const yCenter = 35 + offsets[i]
- return (
-
-
-
- )
- })}
-
-
- )
-}
-
-function TemplatePreview({
- modules,
- template,
-}: {
- modules: ModuleTag[]
- template: (typeof TEMPLATES)[number]
-}) {
- if (modules.includes('tables')) return
- if (modules.includes('knowledge-base')) return
- if (modules.includes('files')) return
- return
-}
-
-interface TemplatePromptsProps {
- onSelect: (prompt: string) => void
-}
-
-export function TemplatePrompts({ onSelect }: TemplatePromptsProps) {
- return (
-
-
- {FEATURED_TEMPLATES.map((template) => (
-
- ))}
-
-
- {GROUPED_EXTRAS.map((group) => (
-
-
{group.label}
-
- {group.templates.map((template) => (
-
- ))}
-
-
- ))}
-
- )
-}
-
-interface TemplateCardProps {
- template: (typeof TEMPLATES)[number]
- onSelect: (prompt: string) => void
-}
-
-const TemplateCard = memo(function TemplateCard({ template, onSelect }: TemplateCardProps) {
- const Icon = template.icon
-
- return (
- {
- import('@/lib/posthog/client')
- .then(({ captureClientEvent }) => {
- captureClientEvent('template_used', {
- template_title: template.title,
- template_modules: template.modules.join(' '),
- })
- })
- .catch(() => {})
- onSelect(template.prompt)
- }}
- aria-label={`Select template: ${template.title}`}
- className='group flex cursor-pointer flex-col text-left'
- >
-
-
- {template.image ? (
-
- ) : (
-
- )}
-
-
-
- {template.title}
-
-
-
- )
-})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect/animated-placeholder-effect.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect/animated-placeholder-effect.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect/index.ts
new file mode 100644
index 00000000000..239b767b3f1
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/animated-placeholder-effect/index.ts
@@ -0,0 +1 @@
+export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/attached-files-list.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/index.ts
new file mode 100644
index 00000000000..e4537f00892
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/attached-files-list/index.ts
@@ -0,0 +1 @@
+export { AttachedFilesList } from './attached-files-list'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
new file mode 100644
index 00000000000..443c1222892
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/chip-clipboard-codec.ts
@@ -0,0 +1,225 @@
+import {
+ computeMentionHighlightRanges,
+ extractContextTokens,
+ restoreSkillTriggerText,
+ stripMentionTrigger,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
+import type { ChatContext } from '@/stores/panel'
+
+/** URI scheme for portable chip links (`[label](sim:kind/id)`). Custom so only
+ * our own links — never generic markdown — are parsed back into chips. */
+const CHIP_LINK_SCHEME = 'sim'
+
+/**
+ * Every chip kind that carries a single stable identifier → the
+ * {@link ChatContext} id field encoded in `sim:/`. This is the one map
+ * that drives BOTH serialization and parsing, so every chip — resource, skill,
+ * integration, slash command — round-trips through the exact same mechanism by
+ * its true id (not by name). `satisfies Partial>` keeps it union-synced: rename a kind's id field and this stops
+ * type-checking.
+ *
+ * Excluded kinds (`current_workflow`, `blocks`, `workflow_block`, `docs`) carry
+ * no single portable id (an array / two ids / none) and degrade to plain text.
+ */
+const PORTABLE_KIND_TO_ID_FIELD = {
+ table: 'tableId',
+ file: 'fileId',
+ folder: 'folderId',
+ filefolder: 'fileFolderId',
+ knowledge: 'knowledgeId',
+ past_chat: 'chatId',
+ workflow: 'workflowId',
+ logs: 'executionId',
+ skill: 'skillId',
+ integration: 'blockType',
+ slash_command: 'command',
+} as const satisfies Partial>
+
+/**
+ * The subset of {@link ChatContext} kinds that serialize to a portable
+ * `sim:/` markdown link.
+ */
+export type PortableKind = keyof typeof PORTABLE_KIND_TO_ID_FIELD
+
+/**
+ * Matches a portable chip markdown link: `[label](sim:kind/id)`.
+ * - group 1: label (any non-`]` chars)
+ * - group 2: kind (lowercase letters / underscores, e.g. `past_chat`)
+ * - group 3: id (any non-`)` / non-whitespace chars)
+ */
+const CHIP_LINK_PATTERN = new RegExp(
+ `\\[([^\\]]+)\\]\\(${CHIP_LINK_SCHEME}:([a-z_]+)\\/([^)\\s]+)\\)`,
+ 'g'
+)
+
+/**
+ * Parsed result of a single portable chip markdown link, including the source
+ * span so callers can rewrite the surrounding text.
+ */
+export interface ParsedChipLink {
+ kind: PortableKind
+ id: string
+ label: string
+ start: number
+ end: number
+}
+
+/**
+ * Type guard narrowing an arbitrary kind string to a {@link PortableKind}.
+ */
+function isPortableKind(kind: string): kind is PortableKind {
+ return Object.hasOwn(PORTABLE_KIND_TO_ID_FIELD, kind)
+}
+
+/**
+ * Reads the portable id off a context for its kind, or `undefined` when the
+ * context isn't a portable kind. Centralizes the id-field lookup so the
+ * `PORTABLE_KIND_TO_ID_FIELD` map stays the single source of truth.
+ */
+function getPortableId(context: ChatContext): string | undefined {
+ if (!isPortableKind(context.kind)) return undefined
+ const field = PORTABLE_KIND_TO_ID_FIELD[context.kind]
+ const value = (context as Record)[field]
+ return typeof value === 'string' && value.length > 0 ? value : undefined
+}
+
+/**
+ * Serializes a context to a portable `[label](sim:kind/id)` markdown link.
+ *
+ * @param context - The chat context to serialize.
+ * @returns The markdown link string, or `null` when the context isn't a
+ * portable kind or its id field is missing/empty.
+ */
+function serializeChipContext(context: ChatContext): string | null {
+ if (!isPortableKind(context.kind)) return null
+ const id = getPortableId(context)
+ if (!id) return null
+ return `[${context.label}](${CHIP_LINK_SCHEME}:${context.kind}/${id})`
+}
+
+/**
+ * The textarea token to insert when re-creating a chip on paste. Delegates to
+ * {@link extractContextTokens} so the per-kind prefix (skill EM-SPACE sentinel,
+ * slash `/`, `@` for everything else) has a single source of truth. The `??`
+ * fallback is unreachable for portable contexts (they always have a label and
+ * are never `current_workflow`); it only satisfies the optional return type.
+ */
+export function chipDisplayToken(context: ChatContext): string {
+ return extractContextTokens([context])[0] ?? `@${context.label}`
+}
+
+/**
+ * Serializes a selected slice of input text for the clipboard.
+ *
+ * Reuses the overlay's exact tokenization
+ * ({@link computeMentionHighlightRanges} over {@link extractContextTokens}) so
+ * a chip that renders as a highlighted token is the one that gets converted.
+ * Every portable chip — resource, skill, integration, slash command — becomes a
+ * `[label](sim:kind/id)` markdown link carrying its true id. Only non-portable
+ * tokens and the plain text around chips fall through {@link
+ * restoreSkillTriggerText} (mapping any stray skill sentinel back to `/`).
+ *
+ * When there is nothing to convert the output is byte-identical to
+ * `restoreSkillTriggerText(selectedText)`; for a selection with no skill
+ * sentinels that equals the raw selection, letting callers detect "no change".
+ *
+ * @param selectedText - The raw selected substring from the textarea.
+ * @param contexts - The currently selected contexts (mention sources).
+ * @returns The clipboard-ready string.
+ */
+export function serializeSelectionForClipboard(
+ selectedText: string,
+ contexts: ChatContext[]
+): string {
+ const ranges = computeMentionHighlightRanges(selectedText, extractContextTokens(contexts))
+ if (ranges.length === 0) return restoreSkillTriggerText(selectedText)
+
+ let result = ''
+ let lastIndex = 0
+
+ for (const range of ranges) {
+ if (range.start > lastIndex) {
+ result += restoreSkillTriggerText(selectedText.slice(lastIndex, range.start))
+ }
+
+ const label = stripMentionTrigger(range.token)
+ const matched = contexts.find((c) => c.label === label)
+ const serialized = matched ? serializeChipContext(matched) : null
+ result += serialized ?? restoreSkillTriggerText(range.token)
+
+ lastIndex = range.end
+ }
+
+ if (lastIndex < selectedText.length) {
+ result += restoreSkillTriggerText(selectedText.slice(lastIndex))
+ }
+
+ return result
+}
+
+/**
+ * Parses all portable chip markdown links from a string, in source order.
+ *
+ * Pure string→data: never fetches or executes. Matches whose kind is not a
+ * {@link PortableKind} are skipped so non-portable `sim:` shapes don't leak
+ * into the chip pipeline.
+ *
+ * @param text - The text to scan (e.g. pasted clipboard content).
+ * @returns Parsed links with their `start`/`end` source spans.
+ */
+export function parseChipLinks(text: string): ParsedChipLink[] {
+ const links: ParsedChipLink[] = []
+ const pattern = new RegExp(CHIP_LINK_PATTERN.source, 'g')
+ let match: RegExpExecArray | null
+
+ while ((match = pattern.exec(text)) !== null) {
+ const [full, label, kind, id] = match
+ if (!isPortableKind(kind)) continue
+ links.push({
+ kind,
+ id,
+ label,
+ start: match.index,
+ end: match.index + full.length,
+ })
+ }
+
+ return links
+}
+
+/**
+ * Reconstructs the exact {@link ChatContext} shape for a parsed chip link.
+ *
+ * The `switch` over the literal kind narrows the return so each branch builds
+ * a fully-typed context with no cast.
+ *
+ * @param link - A link produced by {@link parseChipLinks}.
+ * @returns The matching chat context.
+ */
+export function chipLinkToContext(link: ParsedChipLink): ChatContext {
+ switch (link.kind) {
+ case 'table':
+ return { kind: 'table', tableId: link.id, label: link.label }
+ case 'file':
+ return { kind: 'file', fileId: link.id, label: link.label }
+ case 'folder':
+ return { kind: 'folder', folderId: link.id, label: link.label }
+ case 'filefolder':
+ return { kind: 'filefolder', fileFolderId: link.id, label: link.label }
+ case 'knowledge':
+ return { kind: 'knowledge', knowledgeId: link.id, label: link.label }
+ case 'past_chat':
+ return { kind: 'past_chat', chatId: link.id, label: link.label }
+ case 'workflow':
+ return { kind: 'workflow', workflowId: link.id, label: link.label }
+ case 'logs':
+ return { kind: 'logs', executionId: link.id, label: link.label }
+ case 'skill':
+ return { kind: 'skill', skillId: link.id, label: link.label }
+ case 'integration':
+ return { kind: 'integration', blockType: link.id, label: link.label }
+ case 'slash_command':
+ return { kind: 'slash_command', command: link.id, label: link.label }
+ }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
index 7f6a6c76544..3cf2bcc2a4e 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/constants.ts
@@ -93,6 +93,7 @@ const RESOURCE_TO_CONTEXT: Record<
filefolder: (r) => ({ kind: 'filefolder', fileFolderId: r.id, label: r.title }),
task: (r) => ({ kind: 'past_chat', chatId: r.id, label: r.title }),
log: (r) => ({ kind: 'logs', executionId: r.id, label: r.title }),
+ integration: (r) => ({ kind: 'integration', blockType: r.id, label: r.title }),
generic: (r) => ({ kind: 'docs', label: r.title }),
}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx
similarity index 100%
rename from apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/drop-overlay.tsx
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/index.ts
new file mode 100644
index 00000000000..21c8bbfa0a2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/drop-overlay/index.ts
@@ -0,0 +1 @@
+export { DropOverlay } from './drop-overlay'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts
index a0e71aee024..9b1c2d4d3e4 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/index.ts
@@ -1,5 +1,12 @@
export { AnimatedPlaceholderEffect } from './animated-placeholder-effect'
export { AttachedFilesList } from './attached-files-list'
+export type { ParsedChipLink, PortableKind } from './chip-clipboard-codec'
+export {
+ chipDisplayToken,
+ chipLinkToContext,
+ parseChipLinks,
+ serializeSelectionForClipboard,
+} from './chip-clipboard-codec'
export type {
PlusMenuHandle,
SpeechRecognitionErrorEvent,
@@ -20,3 +27,5 @@ export { MicButton } from './mic-button'
export type { AvailableResourceGroup } from './plus-menu-dropdown'
export { PlusMenuDropdown } from './plus-menu-dropdown'
export { SendButton } from './send-button'
+export type { SkillsMenuHandle } from './skills-menu-dropdown/skills-menu-dropdown'
+export { SkillsMenuDropdown } from './skills-menu-dropdown/skills-menu-dropdown'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/index.ts
new file mode 100644
index 00000000000..8ac16c554b9
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/index.ts
@@ -0,0 +1 @@
+export { MicButton } from './mic-button'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx
similarity index 88%
rename from apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx
index 344a0f84c72..00ca759cd25 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/mic-button/mic-button.tsx
@@ -1,7 +1,7 @@
'use client'
import React from 'react'
-import { Mic } from 'lucide-react'
+import { Mic } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
interface MicButtonProps {
@@ -22,7 +22,7 @@ export const MicButton = React.memo(function MicButton({ isListening, onToggle }
)}
title={isListening ? 'Stop listening' : 'Voice input'}
>
-
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts
new file mode 100644
index 00000000000..00d1cf03946
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/index.ts
@@ -0,0 +1 @@
+export { type AvailableResourceGroup, PlusMenuDropdown } from './plus-menu-dropdown'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx
similarity index 88%
rename from apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx
index dcdaf517ebe..7cc83b38993 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown.tsx
@@ -22,10 +22,20 @@ import {
} from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components/constants'
-import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
+import type {
+ MothershipResource,
+ MothershipResourceType,
+} from '@/app/workspace/[workspaceId]/home/types'
export type AvailableResourceGroup = ReturnType[number]
+/**
+ * Resource types that are only offered via `@`-mention autocomplete and hidden
+ * from the `+` browse menu. Integrations are searchable inline (e.g. typing
+ * `@sla` surfaces Slack) but should not clutter the explicit attach menu.
+ */
+const MENTION_ONLY_RESOURCE_TYPES = new Set(['integration'])
+
interface PlusMenuDropdownProps {
availableResources: AvailableResourceGroup[]
onResourceSelect: (resource: MothershipResource) => void
@@ -71,17 +81,27 @@ export const PlusMenuDropdown = React.memo(
setOpen(false)
}, [])
+ // The `+` browse menu hides mention-only resource types; `@`-mention mode
+ // exposes the full catalog so integrations remain searchable inline.
+ const visibleResources = useMemo(
+ () =>
+ isMention
+ ? availableResources
+ : availableResources.filter(({ type }) => !MENTION_ONLY_RESOURCE_TYPES.has(type)),
+ [isMention, availableResources]
+ )
+
const workflowTree = useMemo(() => {
- const workflowGroup = availableResources.find((g) => g.type === 'workflow')
- const folderGroup = availableResources.find((g) => g.type === 'folder')
+ const workflowGroup = visibleResources.find((g) => g.type === 'workflow')
+ const folderGroup = visibleResources.find((g) => g.type === 'folder')
return buildWorkflowFolderTree(workflowGroup?.items ?? [], folderGroup?.items ?? [])
- }, [availableResources])
+ }, [visibleResources])
const fileFolderTree = useMemo(() => {
- const fileGroup = availableResources.find((g) => g.type === 'file')
- const fileFolderGroup = availableResources.find((g) => g.type === 'filefolder')
+ const fileGroup = visibleResources.find((g) => g.type === 'file')
+ const fileFolderGroup = visibleResources.find((g) => g.type === 'filefolder')
return buildFileFolderTree(fileGroup?.items ?? [], fileFolderGroup?.items ?? [])
- }, [availableResources])
+ }, [visibleResources])
const filteredItems = useMemo(() => {
const rawQuery = isMention ? (mentionQuery ?? '') : search
@@ -89,15 +109,12 @@ export const PlusMenuDropdown = React.memo(
// In mention mode always render a flat filtered list — empty query = show everything.
if (!isMention && !q) return null
if (isMention && !q) {
- return availableResources.flatMap(({ type, items }) =>
- items.map((item) => ({ type, item }))
- )
+ return visibleResources.flatMap(({ type, items }) => items.map((item) => ({ type, item })))
}
- return availableResources.flatMap(({ type, items }) =>
+ return visibleResources.flatMap(({ type, items }) =>
items.filter((item) => item.name.toLowerCase().includes(q)).map((item) => ({ type, item }))
)
- }, [isMention, mentionQuery, search, availableResources])
- const isRootMenu = !isMention && filteredItems === null
+ }, [isMention, mentionQuery, search, visibleResources])
const filteredItemsRef = useRef(filteredItems)
filteredItemsRef.current = filteredItems
@@ -257,7 +274,6 @@ export const PlusMenuDropdown = React.memo(
collisionPadding={8}
className={cn(
'flex flex-col overflow-hidden',
- isRootMenu && 'max-h-none',
// Plus-click shows short fixed labels (Workflows, Tables, …) — let it size
// to its content via the emcn DropdownMenuContent default max-w.
// Mention mode renders resource names directly, so widen for breathing room.
@@ -279,7 +295,7 @@ export const PlusMenuDropdown = React.memo(
onKeyDown={handleSearchKeyDown}
/>
)}
-
+
{/* Always-mounted; swapping this subtree with filtered results makes Radix's
menu FocusScope steal focus from the search input back to the content root. */}
@@ -287,7 +303,7 @@ export const PlusMenuDropdown = React.memo(
)}
- {availableResources
+ {visibleResources
.filter(
({ type }) =>
type !== 'workflow' &&
@@ -330,7 +346,7 @@ export const PlusMenuDropdown = React.memo(
return (
-
+
{config.label}
@@ -387,10 +403,10 @@ export const PlusMenuDropdown = React.memo(
ref={buttonRef}
type='button'
onClick={() => doOpen()}
- className='flex size-[28px] cursor-pointer items-center justify-center rounded-full border border-[var(--border-1)] transition-colors hover:bg-[var(--surface-hover)]'
+ className='flex h-[28px] w-[28px] cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-[var(--surface-hover)]'
title='Add attachments or resources'
>
-
+
>
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/index.ts
new file mode 100644
index 00000000000..2d5cd1b8f84
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/index.ts
@@ -0,0 +1 @@
+export { SendButton } from './send-button'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx
similarity index 82%
rename from apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button.tsx
rename to apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx
index b48d33dc71d..3eedfd22d3f 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/send-button/send-button.tsx
@@ -1,8 +1,7 @@
'use client'
import React from 'react'
-import { ArrowUp } from 'lucide-react'
-import { Button } from '@/components/emcn'
+import { ArrowUp, Button } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
SEND_BUTTON_ACTIVE,
@@ -32,7 +31,7 @@ export const SendButton = React.memo(function SendButton({
title='Stop generation'
>
@@ -48,7 +47,7 @@ export const SendButton = React.memo(function SendButton({
disabled={!canSubmit}
className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)}
>
-
+
)
})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx
new file mode 100644
index 00000000000..c5e586b850a
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown.tsx
@@ -0,0 +1,214 @@
+'use client'
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/components/emcn'
+import { AgentSkillsIcon } from '@/components/icons'
+import { cn } from '@/lib/core/utils/cn'
+import type { SkillDefinition } from '@/hooks/queries/skills'
+
+/**
+ * Imperative handle for driving the skills menu from the host textarea's
+ * keyboard handler. Mirrors the shape of `PlusMenuHandle` but exposes only
+ * the operations the slash-trigger flow needs.
+ */
+export interface SkillsMenuHandle {
+ /** Opens the menu, optionally anchored at a caret position. */
+ open: (anchor?: { left: number; top: number }) => void
+ /** Closes the menu. */
+ close: () => void
+ /** Moves the active highlight by `delta` rows (wrapping). */
+ moveActive: (delta: number) => void
+ /** Selects the active row. Returns true when a skill was selected. */
+ selectActive: () => boolean
+}
+
+interface SkillsMenuDropdownProps {
+ /** Skills available in the current workspace. */
+ skills: SkillDefinition[]
+ /** Called when a skill row is chosen (click / keyboard). */
+ onSkillSelect: (skill: SkillDefinition) => void
+ /** Called when the menu closes so the host can reset slash state. */
+ onClose: () => void
+ /** Host textarea — focus is restored to it on close. */
+ textareaRef: React.RefObject
+ /** Shared caret position restored after the dormant focus trap closes. */
+ pendingCursorRef: React.MutableRefObject
+ /** Active `/`-query used to filter the list (case-insensitive substring). */
+ slashQuery?: string
+}
+
+/**
+ * Floating autocomplete list of workspace skills, anchored at the caret. It
+ * mirrors the anchored-trigger + dormant-focus-trap pattern of
+ * `PlusMenuDropdown` so the textarea keeps focus and typing continues
+ * uninterrupted while the user navigates skills with the keyboard.
+ */
+export const SkillsMenuDropdown = React.memo(
+ React.forwardRef(function SkillsMenuDropdown(
+ { skills, onSkillSelect, onClose, textareaRef, pendingCursorRef, slashQuery },
+ ref
+ ) {
+ const [open, setOpen] = useState(false)
+ const [anchorPos, setAnchorPos] = useState<{ left: number; top: number } | null>(null)
+ const [activeIndex, setActiveIndex] = useState(0)
+ const contentRef = useRef(null)
+
+ const filteredSkills = useMemo(() => {
+ const q = (slashQuery ?? '').toLowerCase().trim()
+ if (!q) return skills
+ return skills.filter((skill) => skill.name.toLowerCase().includes(q))
+ }, [skills, slashQuery])
+
+ const filteredSkillsRef = useRef(filteredSkills)
+ filteredSkillsRef.current = filteredSkills
+ const activeIndexRef = useRef(activeIndex)
+ activeIndexRef.current = activeIndex
+
+ const doOpen = useCallback((anchor?: { left: number; top: number }) => {
+ if (anchor) setAnchorPos(anchor)
+ setOpen(true)
+ setActiveIndex(0)
+ }, [])
+
+ const doClose = useCallback(() => {
+ setOpen(false)
+ }, [])
+
+ const handleSelect = useCallback(
+ (skill: SkillDefinition) => {
+ onSkillSelect(skill)
+ setOpen(false)
+ setActiveIndex(0)
+ },
+ [onSkillSelect]
+ )
+
+ const handleSelectRef = useRef(handleSelect)
+ handleSelectRef.current = handleSelect
+
+ React.useImperativeHandle(
+ ref,
+ () => ({
+ open: doOpen,
+ close: doClose,
+ moveActive: (delta: number) => {
+ const items = filteredSkillsRef.current
+ if (items.length === 0) return
+ setActiveIndex((i) => {
+ const next = i + delta
+ if (next < 0) return items.length - 1
+ if (next >= items.length) return 0
+ return next
+ })
+ },
+ selectActive: () => {
+ const items = filteredSkillsRef.current
+ if (items.length === 0) return false
+ const target = items[activeIndexRef.current] ?? items[0]
+ if (!target) return false
+ handleSelectRef.current(target)
+ return true
+ },
+ }),
+ [doOpen, doClose]
+ )
+
+ // Reset highlight to the top whenever the query changes so the best match
+ // is always selected as the user types.
+ useEffect(() => {
+ setActiveIndex(0)
+ }, [slashQuery])
+
+ // Sync DOM scroll to the keyboard-highlighted row.
+ useEffect(() => {
+ if (filteredSkills.length === 0) return
+ const row = contentRef.current?.querySelector(
+ `[data-filtered-idx="${activeIndex}"]`
+ )
+ row?.scrollIntoView({ block: 'nearest' })
+ }, [activeIndex, filteredSkills])
+
+ const handleOpenChange = (isOpen: boolean) => {
+ setOpen(isOpen)
+ if (!isOpen) {
+ setAnchorPos(null)
+ setActiveIndex(0)
+ onClose()
+ }
+ }
+
+ const handleCloseAutoFocus = (e: Event) => {
+ e.preventDefault()
+ const textarea = textareaRef.current
+ if (!textarea) return
+ if (pendingCursorRef.current !== null) {
+ textarea.setSelectionRange(pendingCursorRef.current, pendingCursorRef.current)
+ pendingCursorRef.current = null
+ }
+ textarea.focus()
+ }
+
+ // Preventing the mount auto-focus keeps the textarea focused and leaves the
+ // Radix focus trap dormant, so typing continues uninterrupted.
+ const handleOpenAutoFocus = (e: Event) => {
+ e.preventDefault()
+ }
+
+ return (
+
+
+
+
+
+
+ {filteredSkills.length > 0 ? (
+ filteredSkills.map((skill, index) => {
+ const isActive = index === activeIndex
+ return (
+
setActiveIndex(index)}
+ onClick={() => handleSelect(skill)}
+ className={cn(
+ 'relative flex w-full min-w-0 cursor-pointer select-none items-center gap-2 rounded-[5px] px-2 py-1.5 text-left font-medium text-[var(--text-body)] text-caption outline-none transition-colors [&>span]:min-w-0 [&>span]:truncate [&_svg]:pointer-events-none [&_svg]:size-[14px] [&_svg]:shrink-0 [&_svg]:text-[var(--text-icon)]',
+ isActive && 'bg-[var(--surface-active)]'
+ )}
+ >
+
+ {skill.name}
+
+ )
+ })
+ ) : (
+
+ No skills
+
+ )}
+
+
+
+ )
+ })
+)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts
new file mode 100644
index 00000000000..8a962686df2
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention.ts
@@ -0,0 +1,188 @@
+import { useCallback, useMemo, useRef } from 'react'
+import {
+ escapeRegex,
+ SKILL_CHIP_TRIGGER,
+} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
+import type { SkillDefinition } from '@/hooks/queries/skills'
+import type { ChatContext } from '@/stores/panel'
+
+/**
+ * Characters that signal the user has completed a word. Used by the
+ * keystroke fast-path to detect that a typed skill name just ended. Mirrors
+ * the integration auto-mention boundary set so the two detectors behave
+ * symmetrically.
+ */
+const WORD_BOUNDARY_REGEX = /^[\s.,;:!?(){}[\]"'`/\\<>\n]$/
+
+type SkillContext = Extract
+
+/**
+ * A skill trigger — the typed `/` or the stored EM SPACE sentinel — only counts
+ * when it itself starts a token (position 0 or after whitespace). This prevents
+ * path-like patterns (`foo/bar`) from chipping as `/bar`, and lets a pasted or
+ * restored `name` token re-chip just like a freshly typed `/name`.
+ */
+function isTriggerPrefixAt(text: string, index: number): boolean {
+ const ch = text[index]
+ if (ch !== '/' && ch !== SKILL_CHIP_TRIGGER) return false
+ if (index === 0) return true
+ return /\s/.test(text[index - 1])
+}
+
+interface UseSkillAutoMentionProps {
+ /** Skills available in the current workspace. */
+ skills: SkillDefinition[]
+ /** Setter for the host's selected contexts. */
+ setSelectedContexts: React.Dispatch>
+}
+
+interface ProcessChangeArgs {
+ textarea: HTMLTextAreaElement
+ previousValue: string
+ nextValue: string
+}
+
+/**
+ * Auto-registers skill contexts when a typed `/skill-name` is completed.
+ *
+ * The user types `/skill-name`, but the displayed token stores an EM SPACE
+ * sentinel (`SKILL_CHIP_TRIGGER`) in place of the narrow `/` so the centered
+ * chip icon fits its overlay slot like `@` does. Both entry points accept
+ * either trigger — a freshly typed `/` or a pasted/restored sentinel — swap a
+ * typed `/` for the sentinel, and share one dedup-by-skillId helper:
+ * - `processChange`: keystroke fast-path. When a word-boundary char completes
+ * a `/name` token whose name matches a known skill, the leading `/` is
+ * swapped for the sentinel (caret preserved) and the skill context is
+ * registered.
+ * - `applyToText`: bulk path for paste, template insertion, draft restore, and
+ * speech-to-text. Swaps each matched typed `/` for the sentinel in the
+ * returned string and registers any matched skill contexts.
+ */
+export function useSkillAutoMention({ skills, setSelectedContexts }: UseSkillAutoMentionProps) {
+ /**
+ * Matcher built from skill names, longest-first so `/my-skill-extended`
+ * wins over `/my-skill`. The trailing guard rejects partial matches that
+ * continue into more name characters.
+ */
+ const matcher = useMemo(() => {
+ const byName = new Map()
+ for (const skill of skills) {
+ byName.set(skill.name.toLowerCase(), {
+ kind: 'skill',
+ skillId: skill.id,
+ label: skill.name,
+ })
+ }
+ const names = [...skills].map((s) => s.name).sort((a, b) => b.length - a.length)
+ if (names.length === 0) return { regex: null as RegExp | null, byName }
+ // Match either trigger: the typed '/' or the stored sentinel, so both fresh
+ // input and pasted/restored chips resolve. The trigger group is the match's
+ // first char (`text[match.index]`); group 1 is the skill name.
+ const trigger = `(?:/|${escapeRegex(SKILL_CHIP_TRIGGER)})`
+ const pattern = `${trigger}(${names.map(escapeRegex).join('|')})(?![A-Za-z0-9_-])`
+ return { regex: new RegExp(pattern, 'gi'), byName }
+ }, [skills])
+
+ const matcherRef = useRef(matcher)
+ matcherRef.current = matcher
+
+ const mergeContexts = useCallback(
+ (additions: SkillContext[]) => {
+ if (additions.length === 0) return
+ setSelectedContexts((prev) => {
+ const existing = new Set(
+ prev.filter((c): c is SkillContext => c.kind === 'skill').map((c) => c.skillId)
+ )
+ const fresh = additions.filter((c) => !existing.has(c.skillId))
+ return fresh.length > 0 ? [...prev, ...fresh] : prev
+ })
+ },
+ [setSelectedContexts]
+ )
+
+ const processChange = useCallback(
+ ({ textarea, previousValue, nextValue }: ProcessChangeArgs): string => {
+ if (nextValue.length !== previousValue.length + 1) return nextValue
+ const { regex, byName } = matcherRef.current
+ if (!regex) return nextValue
+
+ let diffIndex = 0
+ while (
+ diffIndex < previousValue.length &&
+ previousValue[diffIndex] === nextValue[diffIndex]
+ ) {
+ diffIndex++
+ }
+
+ const inserted = nextValue[diffIndex]
+ if (!inserted || !WORD_BOUNDARY_REGEX.test(inserted)) return nextValue
+
+ const before = nextValue.slice(0, diffIndex)
+ regex.lastIndex = 0
+ let completed: { start: number; name: string } | null = null
+ let match: RegExpExecArray | null
+ while ((match = regex.exec(before)) !== null) {
+ if (match.index + match[0].length === before.length) {
+ completed = { start: match.index, name: match[1] }
+ }
+ }
+ if (!completed) return nextValue
+ if (!isTriggerPrefixAt(nextValue, completed.start)) return nextValue
+
+ const context = byName.get(completed.name.toLowerCase())
+ if (!context) return nextValue
+
+ mergeContexts([context])
+
+ // A typed '/' becomes the wide sentinel so the centered chip icon fits its
+ // overlay slot; a sentinel that's already there (e.g. just-pasted) is left
+ // as-is. `setRangeText` with 'preserve' keeps the caret and folds into the
+ // keystroke's native undo step; the returned value mirrors the rewrite so
+ // the controlled state updates too.
+ if (nextValue[completed.start] !== '/') return nextValue
+ textarea.setRangeText(SKILL_CHIP_TRIGGER, completed.start, completed.start + 1, 'preserve')
+ return textarea.value
+ },
+ [matcherRef, mergeContexts]
+ )
+
+ const applyToText = useCallback(
+ (text: string): string => {
+ const { regex, byName } = matcherRef.current
+ if (!regex || !text) return text
+
+ regex.lastIndex = 0
+ const additions: SkillContext[] = []
+ const seen = new Set()
+ const slashIndices: number[] = []
+ let match: RegExpExecArray | null
+ while ((match = regex.exec(text)) !== null) {
+ const index = match.index
+ if (!isTriggerPrefixAt(text, index)) continue
+ const context = byName.get(match[1].toLowerCase())
+ if (!context) continue
+ // Rewrite every confirmed typed '/' (even a repeated skill) so duplicate
+ // tokens chip consistently; tokens already on the sentinel need no edit.
+ if (text[index] === '/') slashIndices.push(index)
+ if (seen.has(context.skillId)) continue
+ seen.add(context.skillId)
+ additions.push(context)
+ }
+ mergeContexts(additions)
+
+ if (slashIndices.length === 0) return text
+ // Replace each matched '/' with the wide sentinel so paste, draft, and STT
+ // paths chip correctly. Splice by UTF-16 index (the regex's index space)
+ // descending so earlier replacements don't shift later indices; the
+ // sentinel is one code unit wide like '/'.
+ let result = text
+ for (const idx of [...slashIndices].sort((a, b) => b - a)) {
+ result = result.slice(0, idx) + SKILL_CHIP_TRIGGER + result.slice(idx + 1)
+ }
+ return result
+ },
+ [matcherRef, mergeContexts]
+ )
+
+ return { processChange, applyToText }
+}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
index 305bd53c0c7..cda0b8b7a14 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx
@@ -12,31 +12,37 @@ import {
useState,
} from 'react'
import { createLogger } from '@sim/logger'
-import { Paperclip } from 'lucide-react'
import { useParams } from 'next/navigation'
-import { Button, Tooltip } from '@/components/emcn'
-import { useSession } from '@/lib/auth/auth-client'
+import { Button, Paperclip, Slash, Tooltip } from '@/components/emcn'
import { getMothershipAttachmentPreviewUrl } from '@/lib/copilot/chat/attachment-preview'
import { SIM_RESOURCE_DRAG_TYPE, SIM_RESOURCES_DRAG_TYPE } from '@/lib/copilot/resource-types'
import { cn } from '@/lib/core/utils/cn'
-import { handleKeyboardActivation } from '@/lib/core/utils/keyboard'
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon'
import { useAvailableResources } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown'
-import type { PlusMenuHandle } from '@/app/workspace/[workspaceId]/home/components/user-input/components'
+import type {
+ PlusMenuHandle,
+ SkillsMenuHandle,
+} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
import {
AnimatedPlaceholderEffect,
AttachedFilesList,
autoResizeTextarea,
+ chipDisplayToken,
+ chipLinkToContext,
DropOverlay,
MAX_CHAT_TEXTAREA_HEIGHT,
MicButton,
mapResourceToContext,
OVERLAY_CLASSES,
PlusMenuDropdown,
+ parseChipLinks,
SendButton,
+ SkillsMenuDropdown,
+ serializeSelectionForClipboard,
TEXTAREA_BASE_CLASSES,
} from '@/app/workspace/[workspaceId]/home/components/user-input/components'
+import { useSkillAutoMention } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-skill-auto-mention'
import type {
FileAttachmentForApi,
MothershipResource,
@@ -45,6 +51,7 @@ import type {
import {
useContextManagement,
useFileAttachments,
+ useIntegrationAutoMention,
useMentionMenu,
useMentionTokens,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
@@ -52,7 +59,11 @@ import type { AttachedFile } from '@/app/workspace/[workspaceId]/w/[workflowId]/
import {
computeMentionHighlightRanges,
extractContextTokens,
+ restoreSkillTriggerText,
+ SKILL_CHIP_TRIGGER,
+ stripMentionTrigger,
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
+import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
import { useWorkflowMap } from '@/hooks/queries/workflows'
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
import { useSpeechToText } from '@/hooks/use-speech-to-text'
@@ -149,26 +160,23 @@ export const UserInput = forwardRef(function Us
const { workspaceId } = useParams<{ workspaceId: string }>()
const { navigateToSettings } = useSettingsNavigation()
const { data: workflowsById = {} } = useWorkflowMap(workspaceId)
- const { data: session } = useSession()
+ const { data: skills = [] } = useSkills(workspaceId)
const [value, setValue] = useState(() => {
if (defaultValue) return defaultValue
if (!draftScopeKey) return ''
const text = useMothershipDraftsStore.getState().drafts[draftScopeKey]?.text
return typeof text === 'string' ? text : ''
})
+ const valueRef = useRef(value)
+ valueRef.current = value
const overlayRef = useRef(null)
const plusMenuRef = useRef(null)
+ const skillsMenuRef = useRef(null)
- const [prevDefaultValue, setPrevDefaultValue] = useState(defaultValue)
- if (defaultValue && defaultValue !== prevDefaultValue) {
- setPrevDefaultValue(defaultValue)
- setValue(defaultValue)
- } else if (!defaultValue && prevDefaultValue) {
- setPrevDefaultValue(defaultValue)
- }
+ const prevDefaultValueRef = useRef(defaultValue)
const files = useFileAttachments({
- userId: userId || session?.user?.id,
+ userId,
workspaceId,
disabled: false,
isLoading: isSending,
@@ -320,24 +328,72 @@ export const UserInput = forwardRef(function Us
selectedContexts: contextManagement.selectedContexts,
mentionMenu,
setMessage: setValue,
+ })
+
+ const integrationAutoMention = useIntegrationAutoMention({
+ setSelectedContexts: contextManagement.setSelectedContexts,
+ })
+
+ const skillAutoMention = useSkillAutoMention({
+ skills,
setSelectedContexts: contextManagement.setSelectedContexts,
})
+ /**
+ * Bulk-chipifies a block of text on the non-keystroke paths (mount, template,
+ * draft restore, STT, queued message, multi-char paste): explicit integration
+ * `@`-mentions first (casing canonicalized; bare names are never touched),
+ * then skill `/` triggers (swapped to the sentinel). Returns the fully
+ * converted text and registers both context kinds.
+ */
+ const applyAutoMentions = useCallback(
+ (text: string) => skillAutoMention.applyToText(integrationAutoMention.applyToText(text)),
+ [skillAutoMention.applyToText, integrationAutoMention.applyToText]
+ )
+
const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles
- const valueRef = useRef(value)
- valueRef.current = value
+ /**
+ * Canonicalize integration `@`-mentions on mount for any initial value
+ * seeded by `defaultValue` or a restored mothership draft. Mid-typing
+ * conversion is intentionally NOT handled here — the keystroke fast-path
+ * in `handleInputChange` covers that case via `processChange`, and running
+ * it on every value change would rewrite tokens while the user is still
+ * typing the name and prematurely open the mention menu.
+ */
+ useEffect(() => {
+ if (!valueRef.current) return
+ const original = valueRef.current
+ const converted = applyAutoMentions(original)
+ if (converted !== original) setValue(converted)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ /**
+ * Sync `value` when the `defaultValue` prop changes post-mount — e.g.
+ * the user clicks a different template while UserInput is already
+ * mounted. Mirrors the previously inline render-phase derivation but
+ * now runs the prompt through `applyToText` so integration `@`-mentions
+ * get chipified consistently with paste / draft restore flows.
+ */
+ useEffect(() => {
+ if (defaultValue === prevDefaultValueRef.current) return
+ prevDefaultValueRef.current = defaultValue
+ if (defaultValue) setValue(applyAutoMentions(defaultValue))
+ }, [defaultValue, applyAutoMentions])
+
const sttPrefixRef = useRef('')
function handleTranscript(text: string) {
const prefix = sttPrefixRef.current
const newVal = prefix ? `${prefix} ${text}` : text
- setValue(newVal)
- valueRef.current = newVal
+ const converted = applyAutoMentions(newVal)
+ setValue(converted)
+ valueRef.current = converted
}
function handleUsageLimitExceeded() {
- navigateToSettings({ section: 'subscription' })
+ navigateToSettings({ section: 'billing' })
}
const {
@@ -374,12 +430,15 @@ export const UserInput = forwardRef